This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 64b0bae71 feat(python): expose get_stats on IggyClient (#4018)
64b0bae71 is described below

commit 64b0bae7175d42df120bb9e93c66ceb8a2e2e07f
Author: BingEdward <[email protected]>
AuthorDate: Mon Sep 7 15:27:13 2026 +0800

    feat(python): expose get_stats on IggyClient (#4018)
---
 .../actions/python-maturin/pre-merge/action.yml    |   9 +
 core/bench/report/src/types/server_stats.rs        |   2 +-
 core/common/src/types/stats/mod.rs                 |   2 +-
 foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs |   2 +-
 foreign/python/apache_iggy.pyi                     | 245 +++++++++++
 foreign/python/src/client.rs                       |  25 ++
 foreign/python/src/lib.rs                          |   5 +
 foreign/python/src/stats.rs                        | 449 +++++++++++++++++++++
 foreign/python/tests/test_stats.py                 | 250 ++++++++++++
 9 files changed, 986 insertions(+), 3 deletions(-)

diff --git a/.github/actions/python-maturin/pre-merge/action.yml 
b/.github/actions/python-maturin/pre-merge/action.yml
index 6a28d0e5b..234c36efe 100644
--- a/.github/actions/python-maturin/pre-merge/action.yml
+++ b/.github/actions/python-maturin/pre-merge/action.yml
@@ -90,6 +90,15 @@ runs:
         echo "pyrefly version: $(uv run pyrefly --version)"
       shell: bash
 
+    # The crate is outside the root workspace, so the Rust test jobs never
+    # reach it; without this its unit tests would only ever be compiled.
+    - name: Rust unit tests for Python extension
+      if: inputs.task == 'lint'
+      run: |
+        cd foreign/python
+        cargo test --manifest-path Cargo.toml
+      shell: bash
+
     - name: Build Python wheel
       if: inputs.task == 'test'
       run: |
diff --git a/core/bench/report/src/types/server_stats.rs 
b/core/bench/report/src/types/server_stats.rs
index 0eca6a939..e2b24f37b 100644
--- a/core/bench/report/src/types/server_stats.rs
+++ b/core/bench/report/src/types/server_stats.rs
@@ -72,7 +72,7 @@ pub struct BenchmarkServerStats {
     pub kernel_version: String,
     /// The version of the Iggy server.
     pub iggy_server_version: String,
-    /// The semantic version of the Iggy server in the numeric format e.g. 
1.2.3 -> 100200300 (major * 1000000 + minor * 1000 + patch).
+    /// The semantic version of the Iggy server in the numeric format e.g. 
1.2.3 -> 1002003 (major * 1000000 + minor * 1000 + patch).
     pub iggy_server_semver: Option<u32>,
     /// Cache metrics per partition
     #[serde(with = "cache_metrics_serializer")]
diff --git a/core/common/src/types/stats/mod.rs 
b/core/common/src/types/stats/mod.rs
index 5de7f03b4..fcec5cf4c 100644
--- a/core/common/src/types/stats/mod.rs
+++ b/core/common/src/types/stats/mod.rs
@@ -70,7 +70,7 @@ pub struct Stats {
     pub kernel_version: String,
     /// The version of the Iggy server.
     pub iggy_server_version: String,
-    /// The semantic version of the Iggy server in the numeric format e.g. 
1.2.3 -> 100200300 (major * 1000000 + minor * 1000 + patch).
+    /// The semantic version of the Iggy server in the numeric format e.g. 
1.2.3 -> 1002003 (major * 1000000 + minor * 1000 + patch).
     pub iggy_server_semver: Option<u32>,
     /// Cache metrics per partition
     #[serde(with = "cache_metrics_serializer")]
diff --git a/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs 
b/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs
index 772fbaa57..4b6efa627 100644
--- a/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs
+++ b/foreign/csharp/Iggy_SDK/Contracts/StatsResponse.cs
@@ -148,7 +148,7 @@ public sealed class StatsResponse
     public required string IggyServerVersion { get; init; }
 
     /// <summary>
-    ///     Semantic version of the Iggy server in the numeric format e.g. 
1.2.3 -> 100200300 (major * 1000000 + minor * 1000 +
+    ///     Semantic version of the Iggy server in the numeric format e.g. 
1.2.3 -> 1002003 (major * 1000000 + minor * 1000 +
     ///     patch).
     /// </summary>
     public uint IggyServerSemver { get; init; }
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index c0f603e58..ed15ed2f5 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -30,6 +30,8 @@ __all__ = [
     "AutoCommitAfter",
     "AutoCommitWhen",
     "AutoLogin",
+    "CacheMetrics",
+    "CacheMetricsKey",
     "Consumer",
     "ConsumerGroup",
     "ConsumerGroupDetails",
@@ -49,6 +51,7 @@ __all__ = [
     "SendMessage",
     "SendMessagesConfirmation",
     "SendMessagesResponse",
+    "Stats",
     "StreamDetails",
     "StreamPermissions",
     "TcpConfig",
@@ -290,6 +293,57 @@ class AutoLogin:
         """
     def __repr__(self) -> builtins.str: ...
 
[email protected]
+class CacheMetrics:
+    r"""
+    Cache metrics for a specific partition.
+    """
+    @property
+    def hits(self) -> builtins.int:
+        r"""
+        Number of cache hits.
+        """
+    @property
+    def misses(self) -> builtins.int:
+        r"""
+        Number of cache misses.
+        """
+    @property
+    def hit_ratio(self) -> builtins.float:
+        r"""
+        Hit ratio (hits / (hits + misses)).
+        """
+    def __repr__(self) -> builtins.str: ...
+
[email protected]
+class CacheMetricsKey:
+    r"""
+    Key identifying the partition a `CacheMetrics` entry belongs to.
+
+    Hashable and comparable, so it can key the `Stats.cache_metrics` dict.
+    """
+    @property
+    def stream_id(self) -> builtins.int:
+        r"""
+        The unique identifier (numeric) of the stream.
+        """
+    @property
+    def topic_id(self) -> builtins.int:
+        r"""
+        The unique identifier (numeric) of the topic within the stream.
+        """
+    @property
+    def partition_id(self) -> builtins.int:
+        r"""
+        The unique identifier (numeric) of the partition within the topic.
+        """
+    def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
+    def __hash__(self) -> builtins.int: ...
+    def __new__(
+        cls, stream_id: builtins.int, topic_id: builtins.int, partition_id: 
builtins.int
+    ) -> CacheMetricsKey: ...
+    def __repr__(self) -> builtins.str: ...
+
 class Consumer:
     r"""
     The consumer polling the messages. It selects both the consumer kind and 
the
@@ -872,6 +926,21 @@ class IggyClient:
         Sends a ping request to the server to check connectivity.
         Raises `RuntimeError` if the connection fails.
         """
+    def get_stats(self) -> collections.abc.Awaitable[Stats]:
+        r"""
+        Get the statistics and details of the server and its running process.
+
+        Requires an authenticated session whose user holds the `read_servers`
+        or `manage_servers` global permission.
+
+        Returns:
+            An awaitable that resolves to `Stats`.
+
+        Raises:
+            RuntimeError: If the client is not connected, the session is not
+                authenticated, the user lacks the permission, or the request
+                fails.
+        """
     def describe_options(
         self, scope: builtins.str
     ) -> collections.abc.Awaitable[list[OptionSpec]]:
@@ -1836,6 +1905,182 @@ class SendMessagesResponse:
         with an offset a client has already recorded.
         """
 
[email protected]
+class Stats:
+    r"""
+    The statistics and details of the server and its running process.
+
+    The fields are gathered from several sources while the request is served
+    (metadata counters, a process probe, a disk probe), so they are not an
+    atomic snapshot of one instant.
+    """
+    @property
+    def process_id(self) -> builtins.int:
+        r"""
+        The unique identifier of the server process.
+        """
+    @property
+    def cpu_usage(self) -> builtins.float:
+        r"""
+        The CPU usage of the server process, in percent summed over the cores
+        it ran on, so it exceeds 100 whenever the process uses more than one
+        core.
+
+        Measured as a delta since the previous `get_stats` served by the same
+        server shard, so the first sample a shard serves is 0.
+        """
+    @property
+    def total_cpu_usage(self) -> builtins.float:
+        r"""
+        The total CPU usage of the system, in percent averaged over the cores
+        the server may run on when confined by an affinity/cpuset mask (over
+        every host core otherwise), so it stays within 0-100.
+
+        Same per-shard delta sampling as `cpu_usage`: the first sample a shard
+        serves is 0.
+        """
+    @property
+    def memory_usage(self) -> builtins.int:
+        r"""
+        The memory usage of the server process, in bytes.
+        """
+    @property
+    def total_memory(self) -> builtins.int:
+        r"""
+        The total memory of the system, in bytes, or the effective cgroup 
memory
+        limit when the server runs inside a memory-capped cgroup (container,
+        systemd slice).
+        """
+    @property
+    def available_memory(self) -> builtins.int:
+        r"""
+        The available memory of the system, in bytes, scoped to the cgroup
+        limit when one applies.
+        """
+    @property
+    def run_time(self) -> datetime.timedelta:
+        r"""
+        The run time of the server process, with whole-second precision.
+        """
+    @property
+    def start_time(self) -> builtins.int:
+        r"""
+        The start time of the server process, in microseconds since the Unix
+        epoch, with whole-second precision.
+        """
+    @property
+    def read_bytes(self) -> builtins.int:
+        r"""
+        The total number of bytes read.
+        """
+    @property
+    def written_bytes(self) -> builtins.int:
+        r"""
+        The total number of bytes written.
+        """
+    @property
+    def messages_size_bytes(self) -> builtins.int:
+        r"""
+        The total size of the messages, in bytes.
+        """
+    @property
+    def streams_count(self) -> builtins.int:
+        r"""
+        The total number of streams.
+        """
+    @property
+    def topics_count(self) -> builtins.int:
+        r"""
+        The total number of topics.
+        """
+    @property
+    def partitions_count(self) -> builtins.int:
+        r"""
+        The total number of partitions.
+        """
+    @property
+    def segments_count(self) -> builtins.int:
+        r"""
+        The total number of segments.
+        """
+    @property
+    def messages_count(self) -> builtins.int:
+        r"""
+        The total number of messages.
+        """
+    @property
+    def clients_count(self) -> builtins.int:
+        r"""
+        The total number of connected clients.
+        """
+    @property
+    def consumer_groups_count(self) -> builtins.int:
+        r"""
+        The total number of consumer groups.
+        """
+    @property
+    def hostname(self) -> builtins.str:
+        r"""
+        The name of the host the server runs on.
+        """
+    @property
+    def os_name(self) -> builtins.str:
+        r"""
+        The name of the operating system.
+        """
+    @property
+    def os_version(self) -> builtins.str:
+        r"""
+        The version of the operating system.
+        """
+    @property
+    def kernel_version(self) -> builtins.str:
+        r"""
+        The version of the kernel.
+        """
+    @property
+    def iggy_server_version(self) -> builtins.str:
+        r"""
+        The version of the Iggy server.
+        """
+    @property
+    def iggy_server_semver(self) -> builtins.int | None:
+        r"""
+        The numeric semantic version of the Iggy server, or `None` when 
unknown.
+        E.g. 1.2.3 -> 1002003 (major * 1000000 + minor * 1000 + patch).
+        """
+    @property
+    def cache_metrics(self) -> builtins.dict[CacheMetricsKey, CacheMetrics]:
+        r"""
+        Cache metrics per partition.
+
+        Current servers do not populate this and reply with an empty map. Each
+        access builds a fresh dict, so mutating the returned dict does not
+        change the stats.
+        """
+    @property
+    def threads_count(self) -> builtins.int:
+        r"""
+        The number of threads in the server process.
+        """
+    @property
+    def free_disk_space(self) -> builtins.int:
+        r"""
+        The available (free) disk space for the data directory, in bytes.
+
+        0 when the server does not know its data directory or the disk probe
+        fails.
+        """
+    @property
+    def total_disk_space(self) -> builtins.int:
+        r"""
+        The total disk space for the data directory, in bytes.
+
+        0 when the server does not know its data directory or the disk probe
+        fails.
+        """
+    def __repr__(self) -> builtins.str: ...
+
 @typing.final
 class StreamDetails:
     @property
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 10c5c776b..8252156a3 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -42,6 +42,7 @@ use crate::options::OptionSpec as PyOptionSpec;
 use crate::permissions::Permissions as PyPermissions;
 use crate::receive_message::{PollingStrategy, ReceiveMessage};
 use crate::send_message::{SendMessage, SendMessagesResponse as 
PySendMessagesResponse};
+use crate::stats::Stats as PyStats;
 use crate::stream::StreamDetails;
 use crate::topic::{IggyExpiry, MaxTopicSize, Topic, TopicDetails};
 use crate::user::{
@@ -157,6 +158,30 @@ impl IggyClient {
         })
     }
 
+    /// Get the statistics and details of the server and its running process.
+    ///
+    /// Requires an authenticated session whose user holds the `read_servers`
+    /// or `manage_servers` global permission.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `Stats`.
+    ///
+    /// Raises:
+    ///     RuntimeError: If the client is not connected, the session is not
+    ///         authenticated, the user lacks the permission, or the request
+    ///         fails.
+    
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[Stats]", 
imports=("collections.abc")))]
+    fn get_stats<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
+        let inner = self.inner.clone();
+        future_into_py(py, async move {
+            let stats = inner
+                .get_stats()
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(PyStats::from(stats))
+        })
+    }
+
     /// Describe the option catalog for a resource scope.
     ///
     /// This is the discovery surface for the `options` argument on
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 5f2e12826..d4397d5ab 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -24,6 +24,7 @@ mod options;
 mod permissions;
 mod receive_message;
 mod send_message;
+mod stats;
 mod stream;
 mod topic;
 mod user;
@@ -40,6 +41,7 @@ use permissions::{GlobalPermissions, Permissions, 
StreamPermissions, TopicPermis
 use pyo3::prelude::*;
 use receive_message::{PollingStrategy, ReceiveMessage};
 use send_message::{SendMessage, SendMessagesConfirmation, 
SendMessagesResponse};
+use stats::{CacheMetrics, CacheMetricsKey, Stats};
 use stream::StreamDetails;
 use topic::{IggyExpiry, MaxTopicSize, Partition, Topic, TopicDetails};
 use user::{UserInfo, UserInfoDetails, UserStatus};
@@ -57,6 +59,9 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<TcpConfig>()?;
     m.add_class::<TcpReconnectionConfig>()?;
     m.add_class::<StreamDetails>()?;
+    m.add_class::<Stats>()?;
+    m.add_class::<CacheMetrics>()?;
+    m.add_class::<CacheMetricsKey>()?;
     m.add_class::<Topic>()?;
     m.add_class::<TopicDetails>()?;
     m.add_class::<IggyExpiry>()?;
diff --git a/foreign/python/src/stats.rs b/foreign/python/src/stats.rs
new file mode 100644
index 000000000..af48425a0
--- /dev/null
+++ b/foreign/python/src/stats.rs
@@ -0,0 +1,449 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::duration::iggy_duration_to_py_delta;
+use iggy::prelude::{
+    CacheMetrics as RustCacheMetrics, CacheMetricsKey as RustCacheMetricsKey, 
Stats as RustStats,
+};
+use pyo3::prelude::*;
+use pyo3::types::{PyDelta, PyDict};
+use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
+
+/// Key identifying the partition a `CacheMetrics` entry belongs to.
+///
+/// Hashable and comparable, so it can key the `Stats.cache_metrics` dict.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+#[gen_stub_pyclass]
+#[pyclass(eq, frozen, hash, skip_from_py_object)]
+pub struct CacheMetricsKey {
+    /// The unique identifier (numeric) of the stream.
+    #[pyo3(get)]
+    pub stream_id: u32,
+    /// The unique identifier (numeric) of the topic within the stream.
+    #[pyo3(get)]
+    pub topic_id: u32,
+    /// The unique identifier (numeric) of the partition within the topic.
+    #[pyo3(get)]
+    pub partition_id: u32,
+}
+
+impl From<&RustCacheMetricsKey> for CacheMetricsKey {
+    fn from(key: &RustCacheMetricsKey) -> Self {
+        Self {
+            stream_id: key.stream_id,
+            topic_id: key.topic_id,
+            partition_id: key.partition_id,
+        }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl CacheMetricsKey {
+    #[new]
+    fn new(stream_id: u32, topic_id: u32, partition_id: u32) -> Self {
+        Self {
+            stream_id,
+            topic_id,
+            partition_id,
+        }
+    }
+
+    fn __repr__(&self) -> String {
+        format!(
+            "CacheMetricsKey(stream_id={}, topic_id={}, partition_id={})",
+            self.stream_id, self.topic_id, self.partition_id
+        )
+    }
+}
+
+/// Cache metrics for a specific partition.
+#[gen_stub_pyclass]
+#[pyclass]
+pub struct CacheMetrics {
+    /// Number of cache hits.
+    #[pyo3(get)]
+    pub hits: u64,
+    /// Number of cache misses.
+    #[pyo3(get)]
+    pub misses: u64,
+    /// Hit ratio (hits / (hits + misses)).
+    #[pyo3(get)]
+    pub hit_ratio: f32,
+}
+
+impl From<&RustCacheMetrics> for CacheMetrics {
+    fn from(metrics: &RustCacheMetrics) -> Self {
+        Self {
+            hits: metrics.hits,
+            misses: metrics.misses,
+            hit_ratio: metrics.hit_ratio,
+        }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl CacheMetrics {
+    fn __repr__(&self) -> String {
+        format!(
+            "CacheMetrics(hits={}, misses={}, hit_ratio={})",
+            self.hits, self.misses, self.hit_ratio
+        )
+    }
+}
+
+/// The statistics and details of the server and its running process.
+///
+/// The fields are gathered from several sources while the request is served
+/// (metadata counters, a process probe, a disk probe), so they are not an
+/// atomic snapshot of one instant.
+#[gen_stub_pyclass]
+#[pyclass]
+pub struct Stats {
+    pub(crate) inner: RustStats,
+}
+
+impl From<RustStats> for Stats {
+    fn from(stats: RustStats) -> Self {
+        Self { inner: stats }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl Stats {
+    /// The unique identifier of the server process.
+    #[getter]
+    pub fn process_id(&self) -> u32 {
+        self.inner.process_id
+    }
+
+    /// The CPU usage of the server process, in percent summed over the cores
+    /// it ran on, so it exceeds 100 whenever the process uses more than one
+    /// core.
+    ///
+    /// Measured as a delta since the previous `get_stats` served by the same
+    /// server shard, so the first sample a shard serves is 0.
+    #[getter]
+    pub fn cpu_usage(&self) -> f32 {
+        self.inner.cpu_usage
+    }
+
+    /// The total CPU usage of the system, in percent averaged over the cores
+    /// the server may run on when confined by an affinity/cpuset mask (over
+    /// every host core otherwise), so it stays within 0-100.
+    ///
+    /// Same per-shard delta sampling as `cpu_usage`: the first sample a shard
+    /// serves is 0.
+    #[getter]
+    pub fn total_cpu_usage(&self) -> f32 {
+        self.inner.total_cpu_usage
+    }
+
+    /// The memory usage of the server process, in bytes.
+    #[getter]
+    pub fn memory_usage(&self) -> u64 {
+        self.inner.memory_usage.as_bytes_u64()
+    }
+
+    /// The total memory of the system, in bytes, or the effective cgroup 
memory
+    /// limit when the server runs inside a memory-capped cgroup (container,
+    /// systemd slice).
+    #[getter]
+    pub fn total_memory(&self) -> u64 {
+        self.inner.total_memory.as_bytes_u64()
+    }
+
+    /// The available memory of the system, in bytes, scoped to the cgroup
+    /// limit when one applies.
+    #[getter]
+    pub fn available_memory(&self) -> u64 {
+        self.inner.available_memory.as_bytes_u64()
+    }
+
+    /// The run time of the server process, with whole-second precision.
+    #[getter]
+    #[gen_stub(override_return_type(type_repr = "datetime.timedelta", 
imports=("datetime")))]
+    pub fn run_time<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> 
{
+        iggy_duration_to_py_delta(py, self.inner.run_time)
+    }
+
+    /// The start time of the server process, in microseconds since the Unix
+    /// epoch, with whole-second precision.
+    #[getter]
+    pub fn start_time(&self) -> u64 {
+        self.inner.start_time.as_micros()
+    }
+
+    /// The total number of bytes read.
+    #[getter]
+    pub fn read_bytes(&self) -> u64 {
+        self.inner.read_bytes.as_bytes_u64()
+    }
+
+    /// The total number of bytes written.
+    #[getter]
+    pub fn written_bytes(&self) -> u64 {
+        self.inner.written_bytes.as_bytes_u64()
+    }
+
+    /// The total size of the messages, in bytes.
+    #[getter]
+    pub fn messages_size_bytes(&self) -> u64 {
+        self.inner.messages_size_bytes.as_bytes_u64()
+    }
+
+    /// The total number of streams.
+    #[getter]
+    pub fn streams_count(&self) -> u32 {
+        self.inner.streams_count
+    }
+
+    /// The total number of topics.
+    #[getter]
+    pub fn topics_count(&self) -> u32 {
+        self.inner.topics_count
+    }
+
+    /// The total number of partitions.
+    #[getter]
+    pub fn partitions_count(&self) -> u32 {
+        self.inner.partitions_count
+    }
+
+    /// The total number of segments.
+    #[getter]
+    pub fn segments_count(&self) -> u32 {
+        self.inner.segments_count
+    }
+
+    /// The total number of messages.
+    #[getter]
+    pub fn messages_count(&self) -> u64 {
+        self.inner.messages_count
+    }
+
+    /// The total number of connected clients.
+    #[getter]
+    pub fn clients_count(&self) -> u32 {
+        self.inner.clients_count
+    }
+
+    /// The total number of consumer groups.
+    #[getter]
+    pub fn consumer_groups_count(&self) -> u32 {
+        self.inner.consumer_groups_count
+    }
+
+    /// The name of the host the server runs on.
+    #[getter]
+    pub fn hostname(&self) -> String {
+        self.inner.hostname.clone()
+    }
+
+    /// The name of the operating system.
+    #[getter]
+    pub fn os_name(&self) -> String {
+        self.inner.os_name.clone()
+    }
+
+    /// The version of the operating system.
+    #[getter]
+    pub fn os_version(&self) -> String {
+        self.inner.os_version.clone()
+    }
+
+    /// The version of the kernel.
+    #[getter]
+    pub fn kernel_version(&self) -> String {
+        self.inner.kernel_version.clone()
+    }
+
+    /// The version of the Iggy server.
+    #[getter]
+    pub fn iggy_server_version(&self) -> String {
+        self.inner.iggy_server_version.clone()
+    }
+
+    /// The numeric semantic version of the Iggy server, or `None` when 
unknown.
+    /// E.g. 1.2.3 -> 1002003 (major * 1000000 + minor * 1000 + patch).
+    #[getter]
+    #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+    pub fn iggy_server_semver(&self) -> Option<u32> {
+        self.inner.iggy_server_semver
+    }
+
+    /// Cache metrics per partition.
+    ///
+    /// Current servers do not populate this and reply with an empty map. Each
+    /// access builds a fresh dict, so mutating the returned dict does not
+    /// change the stats.
+    #[getter]
+    #[gen_stub(override_return_type(type_repr = 
"builtins.dict[CacheMetricsKey, CacheMetrics]"))]
+    pub fn cache_metrics<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, 
PyDict>> {
+        let dict = PyDict::new(py);
+        for (key, metrics) in &self.inner.cache_metrics {
+            dict.set_item(CacheMetricsKey::from(key), 
CacheMetrics::from(metrics))?;
+        }
+        Ok(dict)
+    }
+
+    /// The number of threads in the server process.
+    #[getter]
+    pub fn threads_count(&self) -> u32 {
+        self.inner.threads_count
+    }
+
+    /// The available (free) disk space for the data directory, in bytes.
+    ///
+    /// 0 when the server does not know its data directory or the disk probe
+    /// fails.
+    #[getter]
+    pub fn free_disk_space(&self) -> u64 {
+        self.inner.free_disk_space.as_bytes_u64()
+    }
+
+    /// The total disk space for the data directory, in bytes.
+    ///
+    /// 0 when the server does not know its data directory or the disk probe
+    /// fails.
+    #[getter]
+    pub fn total_disk_space(&self) -> u64 {
+        self.inner.total_disk_space.as_bytes_u64()
+    }
+
+    fn __repr__(&self) -> String {
+        format!(
+            "Stats(hostname='{}', iggy_server_version='{}', streams_count={}, \
+             topics_count={}, partitions_count={}, messages_count={}, 
clients_count={})",
+            self.inner.hostname,
+            self.inner.iggy_server_version,
+            self.inner.streams_count,
+            self.inner.topics_count,
+            self.inner.partitions_count,
+            self.inner.messages_count,
+            self.inner.clients_count
+        )
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy::prelude::{IggyByteSize, IggyDuration, IggyTimestamp};
+    use std::collections::HashMap;
+
+    /// Two entries sharing a stream and topic, so a key collision in the
+    /// `HashMap` -> `PyDict` conversion drops one of them.
+    fn cache_metrics_entries() -> HashMap<RustCacheMetricsKey, 
RustCacheMetrics> {
+        HashMap::from([
+            (
+                RustCacheMetricsKey {
+                    stream_id: 1,
+                    topic_id: 2,
+                    partition_id: 3,
+                },
+                RustCacheMetrics {
+                    hits: 7,
+                    misses: 3,
+                    hit_ratio: 0.7,
+                },
+            ),
+            (
+                RustCacheMetricsKey {
+                    stream_id: 1,
+                    topic_id: 2,
+                    partition_id: 4,
+                },
+                RustCacheMetrics {
+                    hits: 0,
+                    misses: 5,
+                    hit_ratio: 0.0,
+                },
+            ),
+        ])
+    }
+
+    fn rust_stats(iggy_server_semver: Option<u32>) -> RustStats {
+        RustStats {
+            process_id: 1,
+            cpu_usage: 0.0,
+            total_cpu_usage: 0.0,
+            memory_usage: IggyByteSize::default(),
+            total_memory: IggyByteSize::default(),
+            available_memory: IggyByteSize::default(),
+            run_time: IggyDuration::default(),
+            start_time: IggyTimestamp::default(),
+            read_bytes: IggyByteSize::default(),
+            written_bytes: IggyByteSize::default(),
+            messages_size_bytes: IggyByteSize::default(),
+            streams_count: 0,
+            topics_count: 0,
+            partitions_count: 0,
+            segments_count: 0,
+            messages_count: 0,
+            clients_count: 0,
+            consumer_groups_count: 0,
+            hostname: String::new(),
+            os_name: String::new(),
+            os_version: String::new(),
+            kernel_version: String::new(),
+            iggy_server_version: String::new(),
+            iggy_server_semver,
+            cache_metrics: cache_metrics_entries(),
+            threads_count: 0,
+            free_disk_space: IggyByteSize::default(),
+            total_disk_space: IggyByteSize::default(),
+        }
+    }
+
+    #[test]
+    fn given_stats_when_converting_should_preserve_semver_option() {
+        Python::initialize();
+
+        assert_eq!(Stats::from(rust_stats(None)).iggy_server_semver(), None);
+        assert_eq!(
+            Stats::from(rust_stats(Some(1_002_003))).iggy_server_semver(),
+            Some(1_002_003)
+        );
+    }
+
+    #[test]
+    fn given_populated_cache_metrics_when_reading_should_key_every_entry() {
+        Python::initialize();
+
+        let stats = Stats::from(rust_stats(None));
+
+        Python::attach(|py| {
+            let dict = stats.cache_metrics(py).expect("build cache metrics 
dict");
+            assert_eq!(dict.len(), cache_metrics_entries().len());
+
+            let entry = dict
+                .get_item(CacheMetricsKey::new(1, 2, 4))
+                .expect("look up cache metrics entry")
+                .expect("entry for an independently built key");
+            assert_eq!(
+                entry
+                    .getattr("misses")
+                    .and_then(|misses| misses.extract::<u64>())
+                    .expect("read misses"),
+                5
+            );
+        });
+    }
+}
diff --git a/foreign/python/tests/test_stats.py 
b/foreign/python/tests/test_stats.py
new file mode 100644
index 000000000..2b4423cb8
--- /dev/null
+++ b/foreign/python/tests/test_stats.py
@@ -0,0 +1,250 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+
+import pytest
+
+from apache_iggy import CacheMetricsKey, GlobalPermissions, IggyClient, 
Permissions
+from apache_iggy import SendMessage as Message
+
+from .utils import (
+    get_server_config,
+    login_fresh_client,
+    unique_credentials,
+    wait_for_server,
+)
+
+# Fields that describe the server process itself and must not change between
+# two calls within one server run.
+PROCESS_IDENTITY_FIELDS = (
+    "process_id",
+    "start_time",
+    "hostname",
+    "os_name",
+    "os_version",
+    "kernel_version",
+    "iggy_server_version",
+    "iggy_server_semver",
+)
+
+
+class TestStats:
+    """Test server statistics retrieval."""
+
+    @pytest.mark.asyncio
+    async def test_get_stats(self, iggy_client: IggyClient, unique_name):
+        """Sending messages moves the server counts reported by get_stats."""
+        stats_before = await iggy_client.get_stats()
+
+        stream_name = unique_name()
+        topic_name = unique_name()
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=0,
+            messages=[Message(f"stats message {i}") for i in range(3)],
+        )
+
+        stats = await iggy_client.get_stats()
+
+        # `>=` rather than exact equality: the counters are server-global, so
+        # concurrently running tests (e.g. under pytest-xdist) may bump them 
too.
+        assert stats.streams_count >= stats_before.streams_count + 1
+        assert stats.topics_count >= stats_before.topics_count + 1
+        assert stats.partitions_count >= stats_before.partitions_count + 1
+        assert stats.messages_count >= stats_before.messages_count + 3
+        assert stats.messages_size_bytes > stats_before.messages_size_bytes
+        assert stats.clients_count >= 1
+
+        assert stats.iggy_server_version
+        assert stats.hostname
+        assert stats.os_name
+        assert stats.os_version
+        assert stats.kernel_version
+        assert stats.process_id > 0
+        # sysinfo cannot enumerate a process's threads on macOS, so a server
+        # running there reports 0.
+        if stats.os_name != "Darwin":
+            assert stats.threads_count > 0
+        assert stats.start_time > 0
+        assert stats.total_memory > 0
+        assert stats.available_memory <= stats.total_memory
+        assert stats.total_disk_space > 0
+        assert stats.free_disk_space <= stats.total_disk_space
+
+        assert isinstance(stats.run_time, datetime.timedelta)
+        assert stats.run_time >= stats_before.run_time
+        for field in PROCESS_IDENTITY_FIELDS:
+            assert getattr(stats, field) == getattr(stats_before, field)
+
+        assert f"streams_count={stats.streams_count}" in repr(stats)
+        assert stats.hostname in repr(stats)
+
+    @pytest.mark.asyncio
+    async def test_get_stats_reflects_topology(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Streams, topics, partitions, consumer groups and a second client 
show
+        up in the counters, and deleting the streams brings them back down."""
+        stats_before = await iggy_client.get_stats()
+
+        streams = [unique_name() for _ in range(2)]
+        topics_per_stream = 2
+        partitions_per_topic = 3
+        topics = []
+        for stream_name in streams:
+            await iggy_client.create_stream(stream_name)
+            for _ in range(topics_per_stream):
+                topic_name = unique_name()
+                await iggy_client.create_topic(
+                    stream=stream_name,
+                    name=topic_name,
+                    partitions_count=partitions_per_topic,
+                )
+                await iggy_client.create_consumer_group(
+                    stream_name, topic_name, unique_name()
+                )
+                topics.append((stream_name, topic_name))
+        # Bound only to keep a second connection open until the test ends.
+        _second_client = await login_fresh_client("iggy", "iggy")
+
+        topics_created = len(topics)
+        partitions_created = topics_created * partitions_per_topic
+
+        stats = await iggy_client.get_stats()
+
+        assert stats.streams_count >= stats_before.streams_count + len(streams)
+        assert stats.topics_count >= stats_before.topics_count + topics_created
+        assert (
+            stats.partitions_count >= stats_before.partitions_count + 
partitions_created
+        )
+        # Every new partition opens with one segment.
+        assert stats.segments_count >= stats_before.segments_count + 
partitions_created
+        assert (
+            stats.consumer_groups_count
+            >= stats_before.consumer_groups_count + topics_created
+        )
+        # Both `iggy_client` and `_second_client` are connected at this point, 
so
+        # the cross-shard total covers them regardless of what else the server
+        # reaped in between.
+        assert stats.clients_count >= 2
+
+        # The SDK has no delete_stream, so the streams stay behind and only the
+        # topic-level counters are expected to drop. The baseline is re-read
+        # right before the deletes to keep the window in which a concurrent
+        # test can bump the server-global counters as small as possible.
+        stats_before_delete = await iggy_client.get_stats()
+        for stream_name, topic_name in topics:
+            await iggy_client.delete_topic(stream_name, topic_name)
+
+        stats_after = await iggy_client.get_stats()
+
+        assert (
+            stats_after.topics_count
+            <= stats_before_delete.topics_count - topics_created
+        )
+        assert (
+            stats_after.partitions_count
+            <= stats_before_delete.partitions_count - partitions_created
+        )
+        assert (
+            stats_after.segments_count
+            <= stats_before_delete.segments_count - partitions_created
+        )
+        assert (
+            stats_after.consumer_groups_count
+            <= stats_before_delete.consumer_groups_count - topics_created
+        )
+
+    @pytest.mark.asyncio
+    async def test_get_stats_cache_metrics_dict(self, iggy_client: IggyClient):
+        """cache_metrics is a dict the server currently leaves empty."""
+        stats = await iggy_client.get_stats()
+
+        assert stats.cache_metrics == {}
+
+    @pytest.mark.unit
+    def test_cache_metrics_key_is_constructible_and_hashable(self):
+        """A key built in Python can address a cache_metrics dict entry."""
+        key = CacheMetricsKey(stream_id=1, topic_id=2, partition_id=3)
+
+        assert key.stream_id == 1
+        assert key.topic_id == 2
+        assert key.partition_id == 3
+        assert repr(key) == "CacheMetricsKey(stream_id=1, topic_id=2, 
partition_id=3)"
+
+        equal_key = CacheMetricsKey(stream_id=1, topic_id=2, partition_id=3)
+        other_key = CacheMetricsKey(stream_id=1, topic_id=2, partition_id=4)
+        assert key == equal_key
+        assert key != other_key
+        assert hash(key) == hash(equal_key)
+
+        # An equal key constructed independently hits the same dict slot.
+        metrics_by_key = {key: "metrics"}
+        assert metrics_by_key[equal_key] == "metrics"
+        assert other_key not in metrics_by_key
+
+    @pytest.mark.asyncio
+    async def test_get_stats_requires_connection_and_auth(self):
+        """get_stats fails before connecting, before login, and after 
logout."""
+        host, port = get_server_config()
+        wait_for_server(host, port)
+
+        client = IggyClient(f"{host}:{port}")
+        with pytest.raises(RuntimeError, match="Not connected"):
+            await client.get_stats()
+
+        await client.connect()
+        with pytest.raises(RuntimeError, match="Unauthenticated"):
+            await client.get_stats()
+
+        await client.login_user("iggy", "iggy")
+        await client.get_stats()
+
+        await client.logout_user()
+        with pytest.raises(RuntimeError, match="Unauthenticated"):
+            await client.get_stats()
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("flag", ["read_servers", "manage_servers"])
+    async def test_get_stats_requires_server_permission(
+        self, iggy_client: IggyClient, unique_name, flag
+    ):
+        """A user without read_servers or manage_servers is denied; either 
grants."""
+        username, password = unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        try:
+            denied_client = await login_fresh_client(username, password)
+            with pytest.raises(RuntimeError, match="Unauthorized"):
+                await denied_client.get_stats()
+
+            await iggy_client.update_permissions(
+                created.id,
+                Permissions(global_permissions=GlobalPermissions(**{flag: 
True})),
+            )
+
+            granted_client = await login_fresh_client(username, password)
+            stats = await granted_client.get_stats()
+            assert stats.process_id > 0
+        finally:
+            await iggy_client.delete_user(created.id)

Reply via email to