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 56b961285 feat(python): expose remaining Topic/TopicDetails fields and 
partitions (#3623)
56b961285 is described below

commit 56b961285bf3bcf08a27347b321113b39cb03860
Author: Matthew Patton <[email protected]>
AuthorDate: Wed Aug 5 05:51:30 2026 -0400

    feat(python): expose remaining Topic/TopicDetails fields and partitions 
(#3623)
---
 bdd/python/uv.lock                     |   4 +-
 core/common/src/types/partition/mod.rs |   2 +-
 examples/python/uv.lock                |   2 +-
 foreign/python/Cargo.toml              |   2 +-
 foreign/python/apache_iggy.pyi         | 221 +++++++++++++++++++++++++-
 foreign/python/pyproject.toml          |   2 +-
 foreign/python/src/client.rs           | 109 ++++++++-----
 foreign/python/src/consumer.rs         |  32 ++--
 foreign/python/src/lib.rs              |   5 +-
 foreign/python/src/topic.rs            | 276 ++++++++++++++++++++++++++++++++-
 foreign/python/tests/test_topic.py     | 235 ++++++++++++++++++++++++----
 foreign/python/uv.lock                 |   4 +-
 12 files changed, 794 insertions(+), 100 deletions(-)

diff --git a/bdd/python/uv.lock b/bdd/python/uv.lock
index ed686c9bd..846067ffc 100644
--- a/bdd/python/uv.lock
+++ b/bdd/python/uv.lock
@@ -8,7 +8,7 @@ exclude-newer-span = "P7D"
 
 [[package]]
 name = "apache-iggy"
-version = "0.8.1.dev3"
+version = "0.8.1.dev4"
 source = { directory = "../../foreign/python" }
 
 [package.metadata]
@@ -200,7 +200,7 @@ name = "exceptiongroup"
 version = "1.3.1"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
-    { name = "typing-extensions" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz";,
 hash = 
"sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size 
= 30371, upload-time = "2025-11-21T23:01:54.787Z" }
 wheels = [
diff --git a/core/common/src/types/partition/mod.rs 
b/core/common/src/types/partition/mod.rs
index 7b75151a9..fdd7161ab 100644
--- a/core/common/src/types/partition/mod.rs
+++ b/core/common/src/types/partition/mod.rs
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
 /// - `current_offset`: the current offset of the partition.
 /// - `size_bytes`: the size of the partition in bytes.
 /// - `messages_count`: the number of messages in the partition.
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Partition {
     /// Unique identifier of the partition.
     pub id: u32,
diff --git a/examples/python/uv.lock b/examples/python/uv.lock
index 639a0f563..a33c0238f 100644
--- a/examples/python/uv.lock
+++ b/examples/python/uv.lock
@@ -8,7 +8,7 @@ exclude-newer-span = "P7D"
 
 [[package]]
 name = "apache-iggy"
-version = "0.8.1.dev3"
+version = "0.8.1.dev4"
 source = { directory = "../../foreign/python" }
 
 [package.metadata]
diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml
index d408c0303..cd9c4eda9 100644
--- a/foreign/python/Cargo.toml
+++ b/foreign/python/Cargo.toml
@@ -17,7 +17,7 @@
 
 [package]
 name = "apache-iggy"
-version = "0.8.1-dev3"
+version = "0.8.1-dev4"
 edition = "2024"
 authors = ["Iggy Committers <[email protected]>"]
 license = "Apache-2.0"
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 0ce240163..e10a5ec89 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -37,6 +37,9 @@ __all__ = [
     "GlobalPermissions",
     "IggyClient",
     "IggyConsumer",
+    "IggyExpiry",
+    "MaxTopicSize",
+    "Partition",
     "Permissions",
     "PollingStrategy",
     "ReceiveMessage",
@@ -956,12 +959,27 @@ class IggyClient:
         partitions_count: builtins.int,
         compression_algorithm: builtins.str | None = None,
         replication_factor: builtins.int | None = None,
-        message_expiry: datetime.timedelta | None = None,
-        max_topic_size: builtins.int | None = None,
+        message_expiry: IggyExpiry | None = None,
+        max_topic_size: MaxTopicSize | None = None,
     ) -> collections.abc.Awaitable[None]:
         r"""
         Creates a new topic with the given parameters.
-        Returns Ok(()) on successful topic creation or a PyRuntimeError on 
failure.
+
+        Args:
+            stream: Stream identifier as `str | int`.
+            name: Topic name as `str`.
+            partitions_count: Number of partitions as `int`.
+            compression_algorithm: Compression algorithm as `str | None`.
+            replication_factor: Replication factor as `int | None`.
+            message_expiry: Message expiry as `IggyExpiry | None`.
+            max_topic_size: Maximum topic size as `MaxTopicSize | None`.
+
+        Returns:
+            An awaitable that resolves to `None` when the topic is created.
+
+        Raises:
+            ValueError: If `message_expiry` or `max_topic_size` is out of 
range.
+            PyRuntimeError: If another argument is invalid or the request 
fails.
         """
     def get_topic(
         self,
@@ -994,8 +1012,8 @@ class IggyClient:
         name: builtins.str,
         compression_algorithm: builtins.str | None = None,
         replication_factor: builtins.int | None = None,
-        message_expiry: datetime.timedelta | None = None,
-        max_topic_size: builtins.int | None = None,
+        message_expiry: IggyExpiry | None = None,
+        max_topic_size: MaxTopicSize | None = None,
     ) -> collections.abc.Awaitable[None]:
         r"""
         Update an existing topic.
@@ -1009,14 +1027,15 @@ class IggyClient:
             name: New topic name as `str`.
             compression_algorithm: Compression algorithm as `str | None`.
             replication_factor: Replication factor as `int | None`.
-            message_expiry: Message expiry as `datetime.timedelta | None`.
-            max_topic_size: Maximum topic size in bytes as `int | None`.
+            message_expiry: Message expiry as `IggyExpiry | None`.
+            max_topic_size: Maximum topic size as `MaxTopicSize | None`.
 
         Returns:
             An awaitable that resolves to `None` when the topic is updated.
 
         Raises:
-            PyRuntimeError: If an argument is invalid or the request fails.
+            ValueError: If `message_expiry` or `max_topic_size` is out of 
range.
+            PyRuntimeError: If another argument is invalid or the request 
fails.
         """
     def delete_topic(
         self,
@@ -1325,6 +1344,134 @@ class IggyConsumer:
         Returns an awaitable that completes when shutdown is signaled or a 
PyRuntimeError on failure.
         """
 
+class IggyExpiry:
+    r"""
+    The expiry of the messages in a topic.
+    """
+    @typing.final
+    class ServerDefault(IggyExpiry):
+        r"""
+        Use the message expiry configured on the server for this topic,
+        rather than an explicit value set by the client.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> IggyExpiry.ServerDefault: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    @typing.final
+    class ExpireDuration(IggyExpiry):
+        r"""
+        Expire messages this long after they are appended to the topic.
+
+        `duration` must be greater than zero and less than the maximum
+        microsecond count a `u64` can hold (about 584,542 years): those two
+        values are reserved on the wire for `ServerDefault` and `NeverExpire`
+        respectively, so a `duration` at either boundary raises `ValueError`
+        when passed to `create_topic`/`update_topic`. A negative `timedelta`
+        also raises `ValueError`.
+        """
+
+        __match_args__ = ("duration",)
+        @property
+        def duration(self) -> datetime.timedelta: ...
+        def __new__(cls, duration: datetime.timedelta) -> 
IggyExpiry.ExpireDuration: ...
+
+    @typing.final
+    class NeverExpire(IggyExpiry):
+        r"""
+        Retain messages indefinitely; they never expire.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> IggyExpiry.NeverExpire: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    ...
+
+class MaxTopicSize:
+    r"""
+    The maximum size of a topic.
+    """
+    @typing.final
+    class ServerDefault(MaxTopicSize):
+        r"""
+        Use the maximum topic size configured on the server, rather than an
+        explicit value set by the client.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> MaxTopicSize.ServerDefault: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    @typing.final
+    class Custom(MaxTopicSize):
+        r"""
+        Cap the topic at this many bytes; as the topic approaches this size,
+        the server deletes the oldest sealed segments to make room for new
+        messages.
+
+        `bytes` must be greater than zero and less than the maximum value of
+        an unsigned 64-bit integer: those two values are reserved on the wire
+        for `ServerDefault` and `Unlimited` respectively, so a `Custom` size
+        at either boundary raises `ValueError` when passed to
+        `create_topic`/`update_topic`.
+        """
+
+        __match_args__ = ("bytes",)
+        @property
+        def bytes(self) -> builtins.int: ...
+        def __new__(cls, bytes: builtins.int) -> MaxTopicSize.Custom: ...
+
+    @typing.final
+    class Unlimited(MaxTopicSize):
+        r"""
+        Do not cap the topic size; it may grow without bound.
+        """
+
+        __match_args__ = ()
+        def __new__(cls) -> MaxTopicSize.Unlimited: ...
+        def __len__(self) -> builtins.int: ...
+        def __getitem__(self, key: builtins.int, /) -> typing.Any: ...
+
+    ...
+
[email protected]
+class Partition:
+    @property
+    def id(self) -> builtins.int:
+        r"""
+        The unique identifier (numeric) of the partition.
+        """
+    @property
+    def created_at(self) -> builtins.int:
+        r"""
+        The timestamp of the partition creation, in microseconds.
+        """
+    @property
+    def segments_count(self) -> builtins.int:
+        r"""
+        The number of segments in the partition.
+        """
+    @property
+    def current_offset(self) -> builtins.int:
+        r"""
+        The current offset of the partition.
+        """
+    @property
+    def size(self) -> builtins.int:
+        r"""
+        The size of the partition in bytes.
+        """
+    @property
+    def messages_count(self) -> builtins.int:
+        r"""
+        The number of messages in the partition.
+        """
+
 @typing.final
 class Permissions:
     r"""
@@ -1574,6 +1721,36 @@ class Topic:
         r"""
         The total number of partitions in the topic.
         """
+    @property
+    def created_at(self) -> builtins.int:
+        r"""
+        The timestamp when the topic was created, in microseconds.
+        """
+    @property
+    def size(self) -> builtins.int:
+        r"""
+        The total size of the topic in bytes.
+        """
+    @property
+    def message_expiry(self) -> IggyExpiry:
+        r"""
+        The expiry of the messages in the topic.
+        """
+    @property
+    def compression_algorithm(self) -> builtins.str:
+        r"""
+        Compression algorithm for the topic.
+        """
+    @property
+    def max_topic_size(self) -> MaxTopicSize:
+        r"""
+        The maximum size of the topic.
+        """
+    @property
+    def replication_factor(self) -> builtins.int:
+        r"""
+        Replication factor for the topic.
+        """
 
 @typing.final
 class TopicDetails:
@@ -1598,15 +1775,43 @@ class TopicDetails:
         The total number of partitions in the topic.
         """
     @property
+    def created_at(self) -> builtins.int:
+        r"""
+        The timestamp when the topic was created, in microseconds.
+        """
+    @property
+    def size(self) -> builtins.int:
+        r"""
+        The total size of the topic in bytes.
+        """
+    @property
+    def message_expiry(self) -> IggyExpiry:
+        r"""
+        The expiry of the messages in the topic.
+        """
+    @property
     def compression_algorithm(self) -> builtins.str:
         r"""
         Compression algorithm for the topic.
         """
     @property
+    def max_topic_size(self) -> MaxTopicSize:
+        r"""
+        The maximum size of the topic.
+        """
+    @property
     def replication_factor(self) -> builtins.int:
         r"""
         Replication factor for the topic.
         """
+    @property
+    def partitions(self) -> builtins.list[Partition]:
+        r"""
+        The collection of partitions in the topic.
+
+        Rebuilds the list from scratch on every access; cache the result
+        rather than reading this repeatedly in a loop.
+        """
 
 @typing.final
 class TopicPermissions:
diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml
index d7f878885..0dd8ad144 100644
--- a/foreign/python/pyproject.toml
+++ b/foreign/python/pyproject.toml
@@ -22,7 +22,7 @@ build-backend = "maturin"
 [project]
 name = "apache-iggy"
 requires-python = ">=3.10"
-version = "0.8.1.dev3"
+version = "0.8.1.dev4"
 description = "Apache Iggy is the persistent message streaming platform 
written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of 
processing millions of messages per second."
 readme = "README.md"
 license = { file = "LICENSE" }
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index a7f0cfd51..8d3aa1594 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -17,7 +17,8 @@
 
 use bytes::Bytes;
 use iggy::prelude::{
-    Consumer as RustConsumer, IggyClient as RustIggyClient, IggyMessage as 
RustMessage,
+    AutoCommit as RustAutoCommit, Consumer as RustConsumer, IggyClient as 
RustIggyClient,
+    IggyExpiry as RustIggyExpiry, IggyMessage as RustMessage, MaxTopicSize as 
RustMaxTopicSize,
     PollingStrategy as RustPollingStrategy, *,
 };
 use pyo3::PyRef;
@@ -38,7 +39,7 @@ use crate::permissions::Permissions as PyPermissions;
 use crate::receive_message::{PollingStrategy, ReceiveMessage};
 use crate::send_message::SendMessage;
 use crate::stream::StreamDetails;
-use crate::topic::{Topic, TopicDetails};
+use crate::topic::{IggyExpiry, MaxTopicSize, Topic, TopicDetails};
 use crate::user::{
     UserInfo as PyUserInfo, UserInfoDetails as PyUserInfoDetails, UserStatus 
as PyUserStatus,
 };
@@ -53,6 +54,32 @@ pub struct IggyClient {
     inner: Arc<RustIggyClient>,
 }
 
+/// Resolves the shared `create_topic`/`update_topic` parameters, applying
+/// server defaults where the caller left them unset.
+fn resolve_topic_params(
+    compression_algorithm: Option<String>,
+    message_expiry: Option<&IggyExpiry>,
+    max_topic_size: Option<&MaxTopicSize>,
+) -> PyResult<(CompressionAlgorithm, RustIggyExpiry, RustMaxTopicSize)> {
+    let compression_algorithm = match compression_algorithm {
+        Some(algo) => CompressionAlgorithm::from_str(&algo)
+            .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?,
+        None => CompressionAlgorithm::default(),
+    };
+
+    let expiry = message_expiry
+        .map(RustIggyExpiry::try_from)
+        .transpose()?
+        .unwrap_or(RustIggyExpiry::ServerDefault);
+
+    let max_size = max_topic_size
+        .map(RustMaxTopicSize::try_from)
+        .transpose()?
+        .unwrap_or(RustMaxTopicSize::ServerDefault);
+
+    Ok((compression_algorithm, expiry, max_size))
+}
+
 #[gen_stub_pymethods]
 #[pymethods]
 impl IggyClient {
@@ -409,7 +436,22 @@ impl IggyClient {
     }
 
     /// Creates a new topic with the given parameters.
-    /// Returns Ok(()) on successful topic creation or a PyRuntimeError on 
failure.
+    ///
+    /// Args:
+    ///     stream: Stream identifier as `str | int`.
+    ///     name: Topic name as `str`.
+    ///     partitions_count: Number of partitions as `int`.
+    ///     compression_algorithm: Compression algorithm as `str | None`.
+    ///     replication_factor: Replication factor as `int | None`.
+    ///     message_expiry: Message expiry as `IggyExpiry | None`.
+    ///     max_topic_size: Maximum topic size as `MaxTopicSize | None`.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `None` when the topic is created.
+    ///
+    /// Raises:
+    ///     ValueError: If `message_expiry` or `max_topic_size` is out of 
range.
+    ///     PyRuntimeError: If another argument is invalid or the request 
fails.
     #[pyo3(
         signature = (stream, name, partitions_count, compression_algorithm = 
None, replication_factor = None, message_expiry = None, max_topic_size = None)
     )]
@@ -427,22 +469,15 @@ impl IggyClient {
         #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
replication_factor: Option<
             u8,
         >,
-        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", 
imports=("datetime")))]
-        message_expiry: Option<Py<PyDelta>>,
-        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
max_topic_size: Option<u64>,
+        #[gen_stub(override_type(type_repr = "IggyExpiry | None"))] 
message_expiry: Option<
+            &IggyExpiry,
+        >,
+        #[gen_stub(override_type(type_repr = "MaxTopicSize | None"))] 
max_topic_size: Option<
+            &MaxTopicSize,
+        >,
     ) -> PyResult<Bound<'a, PyAny>> {
-        let compression_algorithm = match compression_algorithm {
-            Some(algo) => CompressionAlgorithm::from_str(&algo)
-                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?,
-            None => CompressionAlgorithm::default(),
-        };
-
-        let expiry = match message_expiry {
-            Some(delta) => 
IggyExpiry::ExpireDuration(py_delta_to_iggy_duration(&delta)),
-            None => IggyExpiry::ServerDefault,
-        };
-
-        let max_size = max_topic_size.map_or(MaxTopicSize::ServerDefault, 
MaxTopicSize::from);
+        let (compression_algorithm, expiry, max_size) =
+            resolve_topic_params(compression_algorithm, message_expiry, 
max_topic_size)?;
 
         let stream = Identifier::try_from(stream)?;
         let inner = self.inner.clone();
@@ -525,14 +560,15 @@ impl IggyClient {
     ///     name: New topic name as `str`.
     ///     compression_algorithm: Compression algorithm as `str | None`.
     ///     replication_factor: Replication factor as `int | None`.
-    ///     message_expiry: Message expiry as `datetime.timedelta | None`.
-    ///     max_topic_size: Maximum topic size in bytes as `int | None`.
+    ///     message_expiry: Message expiry as `IggyExpiry | None`.
+    ///     max_topic_size: Maximum topic size as `MaxTopicSize | None`.
     ///
     /// Returns:
     ///     An awaitable that resolves to `None` when the topic is updated.
     ///
     /// Raises:
-    ///     PyRuntimeError: If an argument is invalid or the request fails.
+    ///     ValueError: If `message_expiry` or `max_topic_size` is out of 
range.
+    ///     PyRuntimeError: If another argument is invalid or the request 
fails.
     #[pyo3(
         signature = (stream_id, topic_id, name, compression_algorithm = None, 
replication_factor = None, message_expiry = None, max_topic_size = None)
     )]
@@ -550,22 +586,15 @@ impl IggyClient {
         #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
replication_factor: Option<
             u8,
         >,
-        #[gen_stub(override_type(type_repr = "datetime.timedelta | None", 
imports=("datetime")))]
-        message_expiry: Option<Py<PyDelta>>,
-        #[gen_stub(override_type(type_repr = "builtins.int | None"))] 
max_topic_size: Option<u64>,
+        #[gen_stub(override_type(type_repr = "IggyExpiry | None"))] 
message_expiry: Option<
+            &IggyExpiry,
+        >,
+        #[gen_stub(override_type(type_repr = "MaxTopicSize | None"))] 
max_topic_size: Option<
+            &MaxTopicSize,
+        >,
     ) -> PyResult<Bound<'a, PyAny>> {
-        let compression_algorithm = match compression_algorithm {
-            Some(algo) => CompressionAlgorithm::from_str(&algo)
-                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?,
-            None => CompressionAlgorithm::default(),
-        };
-
-        let expiry = match message_expiry {
-            Some(delta) => 
IggyExpiry::ExpireDuration(py_delta_to_iggy_duration(&delta)),
-            None => IggyExpiry::ServerDefault,
-        };
-
-        let max_size = max_topic_size.map_or(MaxTopicSize::ServerDefault, 
MaxTopicSize::from);
+        let (compression_algorithm, expiry, max_size) =
+            resolve_topic_params(compression_algorithm, message_expiry, 
max_topic_size)?;
 
         let stream_id = Identifier::try_from(stream_id)?;
         let topic_id = Identifier::try_from(topic_id)?;
@@ -1020,16 +1049,16 @@ impl IggyClient {
             builder = builder.batch_length(batch_length)
         };
         if let Some(auto_commit) = auto_commit {
-            builder = builder.auto_commit(auto_commit.into())
+            builder = 
builder.auto_commit(RustAutoCommit::try_from(auto_commit)?)
         };
         if let Some(poll_interval) = poll_interval {
-            builder = 
builder.poll_interval(py_delta_to_iggy_duration(&poll_interval))
+            builder = 
builder.poll_interval(py_delta_to_iggy_duration(&poll_interval)?)
         } else {
             builder = builder.without_poll_interval()
         };
         if let Some(polling_retry_interval) = polling_retry_interval {
             builder =
-                
builder.polling_retry_interval(py_delta_to_iggy_duration(&polling_retry_interval))
+                
builder.polling_retry_interval(py_delta_to_iggy_duration(&polling_retry_interval)?)
         }
         if init_retries.is_some() && init_retry_interval.is_none() {
             return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
@@ -1045,7 +1074,7 @@ impl IggyClient {
         {
             builder = builder.init_retries(
                 init_retries,
-                py_delta_to_iggy_duration(&init_retry_interval),
+                py_delta_to_iggy_duration(&init_retry_interval)?,
             );
         }
         if allow_replay {
diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs
index 0b6066b68..4d64fc626 100644
--- a/foreign/python/src/consumer.rs
+++ b/foreign/python/src/consumer.rs
@@ -27,7 +27,7 @@ use iggy::prelude::{
     ConsumerGroupMember as RustConsumerGroupMember, IggyConsumer as 
RustIggyConsumer, IggyDuration,
     IggyError, ReceivedMessage,
 };
-use pyo3::exceptions::PyStopAsyncIteration;
+use pyo3::exceptions::{PyStopAsyncIteration, PyValueError};
 use pyo3::types::{PyDelta, PyDeltaAccess};
 
 use pyo3::prelude::*;
@@ -429,25 +429,27 @@ pub enum AutoCommit {
     After(AutoCommitAfter),
 }
 
-impl From<&AutoCommit> for RustAutoCommit {
-    fn from(val: &AutoCommit) -> RustAutoCommit {
-        match val {
+impl TryFrom<&AutoCommit> for RustAutoCommit {
+    type Error = PyErr;
+
+    fn try_from(val: &AutoCommit) -> PyResult<RustAutoCommit> {
+        Ok(match val {
             AutoCommit::Disabled() => RustAutoCommit::Disabled,
             AutoCommit::Interval(delta) => {
-                let duration = py_delta_to_iggy_duration(delta);
+                let duration = py_delta_to_iggy_duration(delta)?;
                 RustAutoCommit::Interval(duration)
             }
             AutoCommit::IntervalOrWhen(delta, when) => {
-                let duration = py_delta_to_iggy_duration(delta);
+                let duration = py_delta_to_iggy_duration(delta)?;
                 RustAutoCommit::IntervalOrWhen(duration, when.into())
             }
             AutoCommit::IntervalOrAfter(delta, after) => {
-                let duration = py_delta_to_iggy_duration(delta);
+                let duration = py_delta_to_iggy_duration(delta)?;
                 RustAutoCommit::IntervalOrAfter(duration, after.into())
             }
             AutoCommit::When(when) => RustAutoCommit::When(when.into()),
             AutoCommit::After(after) => RustAutoCommit::After(after.into()),
-        }
+        })
     }
 }
 
@@ -517,11 +519,19 @@ impl PyStubType for AutoCommitAfter {
     }
 }
 
-pub fn py_delta_to_iggy_duration(delta1: &Py<PyDelta>) -> IggyDuration {
+pub fn py_delta_to_iggy_duration(delta1: &Py<PyDelta>) -> 
PyResult<IggyDuration> {
     Python::attach(|py| {
         let delta = delta1.bind(py);
-        let seconds = (delta.get_days() * 60 * 60 * 24 + delta.get_seconds()) 
as u64;
+        let total_seconds = i64::from(delta.get_days()) * 86_400 + 
i64::from(delta.get_seconds());
+        if total_seconds < 0 {
+            return Err(PyValueError::new_err(
+                "duration must not be negative".to_string(),
+            ));
+        }
         let nanos = (delta.get_microseconds() * 1_000) as u32;
-        IggyDuration::new(Duration::new(seconds, nanos))
+        Ok(IggyDuration::new(Duration::new(
+            total_seconds as u64,
+            nanos,
+        )))
     })
 }
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 9bb2308f4..4f552eadc 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -36,7 +36,7 @@ use pyo3::prelude::*;
 use receive_message::{PollingStrategy, ReceiveMessage};
 use send_message::SendMessage;
 use stream::StreamDetails;
-use topic::{Topic, TopicDetails};
+use topic::{IggyExpiry, MaxTopicSize, Partition, Topic, TopicDetails};
 use user::{UserInfo, UserInfoDetails, UserStatus};
 use user_headers::{HeaderKey, HeaderValue, UserHeaders};
 
@@ -49,6 +49,9 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<StreamDetails>()?;
     m.add_class::<Topic>()?;
     m.add_class::<TopicDetails>()?;
+    m.add_class::<IggyExpiry>()?;
+    m.add_class::<MaxTopicSize>()?;
+    m.add_class::<Partition>()?;
     m.add_class::<ConsumerGroup>()?;
     m.add_class::<ConsumerGroupDetails>()?;
     m.add_class::<ConsumerGroupMember>()?;
diff --git a/foreign/python/src/topic.rs b/foreign/python/src/topic.rs
index 0c9028ef4..178f90dd5 100644
--- a/foreign/python/src/topic.rs
+++ b/foreign/python/src/topic.rs
@@ -15,9 +15,155 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use iggy::prelude::{Topic as RustTopic, TopicDetails as RustTopicDetails};
+use std::time::Duration;
+
+use iggy::prelude::{
+    IggyByteSize, IggyExpiry as RustIggyExpiry, MaxTopicSize as 
RustMaxTopicSize,
+    Partition as RustPartition, Topic as RustTopic, TopicDetails as 
RustTopicDetails,
+};
+use pyo3::exceptions::PyValueError;
 use pyo3::prelude::*;
-use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods};
+use pyo3::types::PyDelta;
+use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum, 
gen_stub_pymethods};
+
+use crate::consumer::py_delta_to_iggy_duration;
+
+/// The expiry of the messages in a topic.
+#[gen_stub_pyclass_complex_enum]
+#[pyclass]
+pub enum IggyExpiry {
+    /// Use the message expiry configured on the server for this topic,
+    /// rather than an explicit value set by the client.
+    ServerDefault(),
+    /// Expire messages this long after they are appended to the topic.
+    ///
+    /// `duration` must be greater than zero and less than the maximum
+    /// microsecond count a `u64` can hold (about 584,542 years): those two
+    /// values are reserved on the wire for `ServerDefault` and `NeverExpire`
+    /// respectively, so a `duration` at either boundary raises `ValueError`
+    /// when passed to `create_topic`/`update_topic`. A negative `timedelta`
+    /// also raises `ValueError`.
+    ExpireDuration { duration: Py<PyDelta> },
+    /// Retain messages indefinitely; they never expire.
+    NeverExpire(),
+}
+
+impl TryFrom<RustIggyExpiry> for IggyExpiry {
+    type Error = PyErr;
+
+    fn try_from(expiry: RustIggyExpiry) -> PyResult<Self> {
+        Ok(match expiry {
+            RustIggyExpiry::ServerDefault => IggyExpiry::ServerDefault(),
+            RustIggyExpiry::ExpireDuration(duration) => 
IggyExpiry::ExpireDuration {
+                duration: iggy_duration_to_py_delta(duration.get_duration())?,
+            },
+            RustIggyExpiry::NeverExpire => IggyExpiry::NeverExpire(),
+        })
+    }
+}
+
+impl TryFrom<&IggyExpiry> for RustIggyExpiry {
+    type Error = PyErr;
+
+    fn try_from(expiry: &IggyExpiry) -> PyResult<Self> {
+        Ok(match expiry {
+            IggyExpiry::ServerDefault() => RustIggyExpiry::ServerDefault,
+            IggyExpiry::ExpireDuration { duration } => {
+                let iggy_duration = py_delta_to_iggy_duration(duration)?;
+                if iggy_duration.is_zero() {
+                    return Err(PyValueError::new_err(
+                        "duration must be greater than zero and less than the 
maximum \
+                         representable microsecond count; those values are 
reserved for \
+                         IggyExpiry.ServerDefault() and 
IggyExpiry.NeverExpire() respectively"
+                            .to_string(),
+                    ));
+                }
+                if iggy_duration.get_duration().as_micros() >= u64::MAX as 
u128 {
+                    return Err(PyValueError::new_err(
+                        "duration must be greater than zero and less than the 
maximum \
+                         representable microsecond count; those values are 
reserved for \
+                         IggyExpiry.ServerDefault() and 
IggyExpiry.NeverExpire() respectively"
+                            .to_string(),
+                    ));
+                }
+                RustIggyExpiry::ExpireDuration(iggy_duration)
+            }
+            IggyExpiry::NeverExpire() => RustIggyExpiry::NeverExpire,
+        })
+    }
+}
+
+fn iggy_duration_to_py_delta(duration: Duration) -> PyResult<Py<PyDelta>> {
+    let days = duration.as_secs() / 86_400;
+    let secs_of_day = duration.as_secs() % 86_400;
+    Python::attach(|py| {
+        PyDelta::new(
+            py,
+            days as i32,
+            secs_of_day as i32,
+            duration.subsec_micros() as i32,
+            true,
+        )
+        .map(|delta| delta.unbind())
+        .map_err(|err| {
+            PyValueError::new_err(format!(
+                "topic message expiry duration does not fit within timedelta 
bounds: {err}"
+            ))
+        })
+    })
+}
+
+/// The maximum size of a topic.
+#[gen_stub_pyclass_complex_enum]
+#[pyclass]
+pub enum MaxTopicSize {
+    /// Use the maximum topic size configured on the server, rather than an
+    /// explicit value set by the client.
+    ServerDefault(),
+    /// Cap the topic at this many bytes; as the topic approaches this size,
+    /// the server deletes the oldest sealed segments to make room for new
+    /// messages.
+    ///
+    /// `bytes` must be greater than zero and less than the maximum value of
+    /// an unsigned 64-bit integer: those two values are reserved on the wire
+    /// for `ServerDefault` and `Unlimited` respectively, so a `Custom` size
+    /// at either boundary raises `ValueError` when passed to
+    /// `create_topic`/`update_topic`.
+    Custom { bytes: u64 },
+    /// Do not cap the topic size; it may grow without bound.
+    Unlimited(),
+}
+
+impl From<RustMaxTopicSize> for MaxTopicSize {
+    fn from(max_size: RustMaxTopicSize) -> Self {
+        match max_size {
+            RustMaxTopicSize::ServerDefault => MaxTopicSize::ServerDefault(),
+            RustMaxTopicSize::Custom(size) => MaxTopicSize::Custom {
+                bytes: size.as_bytes_u64(),
+            },
+            RustMaxTopicSize::Unlimited => MaxTopicSize::Unlimited(),
+        }
+    }
+}
+
+impl TryFrom<&MaxTopicSize> for RustMaxTopicSize {
+    type Error = PyErr;
+
+    fn try_from(max_size: &MaxTopicSize) -> PyResult<Self> {
+        Ok(match max_size {
+            MaxTopicSize::ServerDefault() => RustMaxTopicSize::ServerDefault,
+            MaxTopicSize::Custom { bytes } => {
+                if *bytes == 0 || *bytes == u64::MAX {
+                    return Err(PyValueError::new_err(
+                        "bytes must be greater than zero and less than 
u64::MAX".to_string(),
+                    ));
+                }
+                RustMaxTopicSize::Custom(IggyByteSize::from(*bytes))
+            }
+            MaxTopicSize::Unlimited() => RustMaxTopicSize::Unlimited,
+        })
+    }
+}
 
 #[gen_stub_pyclass]
 #[pyclass]
@@ -57,6 +203,42 @@ impl Topic {
     pub fn partitions_count(&self) -> u32 {
         self.inner.partitions_count
     }
+
+    /// The timestamp when the topic was created, in microseconds.
+    #[getter]
+    pub fn created_at(&self) -> u64 {
+        self.inner.created_at.as_micros()
+    }
+
+    /// The total size of the topic in bytes.
+    #[getter]
+    pub fn size(&self) -> u64 {
+        self.inner.size.as_bytes_u64()
+    }
+
+    /// The expiry of the messages in the topic.
+    #[getter]
+    pub fn message_expiry(&self) -> PyResult<IggyExpiry> {
+        self.inner.message_expiry.try_into()
+    }
+
+    /// Compression algorithm for the topic.
+    #[getter]
+    pub fn compression_algorithm(&self) -> String {
+        self.inner.compression_algorithm.to_string()
+    }
+
+    /// The maximum size of the topic.
+    #[getter]
+    pub fn max_topic_size(&self) -> MaxTopicSize {
+        self.inner.max_topic_size.into()
+    }
+
+    /// Replication factor for the topic.
+    #[getter]
+    pub fn replication_factor(&self) -> u8 {
+        self.inner.replication_factor
+    }
 }
 
 #[gen_stub_pyclass]
@@ -100,15 +282,105 @@ impl TopicDetails {
         self.inner.partitions_count
     }
 
+    /// The timestamp when the topic was created, in microseconds.
+    #[getter]
+    pub fn created_at(&self) -> u64 {
+        self.inner.created_at.as_micros()
+    }
+
+    /// The total size of the topic in bytes.
+    #[getter]
+    pub fn size(&self) -> u64 {
+        self.inner.size.as_bytes_u64()
+    }
+
+    /// The expiry of the messages in the topic.
+    #[getter]
+    pub fn message_expiry(&self) -> PyResult<IggyExpiry> {
+        self.inner.message_expiry.try_into()
+    }
+
     /// Compression algorithm for the topic.
     #[getter]
     pub fn compression_algorithm(&self) -> String {
         self.inner.compression_algorithm.to_string()
     }
 
+    /// The maximum size of the topic.
+    #[getter]
+    pub fn max_topic_size(&self) -> MaxTopicSize {
+        self.inner.max_topic_size.into()
+    }
+
     /// Replication factor for the topic.
     #[getter]
     pub fn replication_factor(&self) -> u8 {
         self.inner.replication_factor
     }
+
+    /// The collection of partitions in the topic.
+    ///
+    /// Rebuilds the list from scratch on every access; cache the result
+    /// rather than reading this repeatedly in a loop.
+    #[getter]
+    pub fn partitions(&self) -> Vec<Partition> {
+        self.inner
+            .partitions
+            .iter()
+            .cloned()
+            .map(Partition::from)
+            .collect()
+    }
+}
+
+#[gen_stub_pyclass]
+#[pyclass]
+pub struct Partition {
+    pub(crate) inner: RustPartition,
+}
+
+impl From<RustPartition> for Partition {
+    fn from(partition: RustPartition) -> Self {
+        Self { inner: partition }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl Partition {
+    /// The unique identifier (numeric) of the partition.
+    #[getter]
+    pub fn id(&self) -> u32 {
+        self.inner.id
+    }
+
+    /// The timestamp of the partition creation, in microseconds.
+    #[getter]
+    pub fn created_at(&self) -> u64 {
+        self.inner.created_at.as_micros()
+    }
+
+    /// The number of segments in the partition.
+    #[getter]
+    pub fn segments_count(&self) -> u32 {
+        self.inner.segments_count
+    }
+
+    /// The current offset of the partition.
+    #[getter]
+    pub fn current_offset(&self) -> u64 {
+        self.inner.current_offset
+    }
+
+    /// The size of the partition in bytes.
+    #[getter]
+    pub fn size(&self) -> u64 {
+        self.inner.size.as_bytes_u64()
+    }
+
+    /// The number of messages in the partition.
+    #[getter]
+    pub fn messages_count(&self) -> u64 {
+        self.inner.messages_count
+    }
 }
diff --git a/foreign/python/tests/test_topic.py 
b/foreign/python/tests/test_topic.py
index 62bb37a5b..d76d16f35 100644
--- a/foreign/python/tests/test_topic.py
+++ b/foreign/python/tests/test_topic.py
@@ -19,7 +19,7 @@ from datetime import timedelta
 
 import pytest
 
-from apache_iggy import IggyClient, SendMessage
+from apache_iggy import IggyClient, IggyExpiry, MaxTopicSize, SendMessage
 
 from .utils import get_server_config, wait_for_ping, wait_for_server
 
@@ -70,6 +70,10 @@ class TestCreateTopic:
         assert topic is not None
         assert topic.name == topic_name
         assert topic.partitions_count == 2
+        assert topic.created_at > 0
+        assert topic.size == 0
+        assert len(topic.partitions) == 2
+        assert all(partition.messages_count == 0 for partition in 
topic.partitions)
 
         stream = await iggy_client.get_stream(stream_name)
         assert stream is not None
@@ -229,15 +233,20 @@ class TestCreateTopic:
     @pytest.mark.parametrize(
         "message_expiry",
         [
-            timedelta(0),  # value for server default message expiry
-            timedelta(microseconds=1),
-            timedelta(seconds=1),
-            timedelta(minutes=10),
-            timedelta(days=1, seconds=2, microseconds=3),
+            IggyExpiry.ExpireDuration(timedelta(microseconds=1)),
+            IggyExpiry.ExpireDuration(timedelta(seconds=1)),
+            IggyExpiry.ExpireDuration(timedelta(minutes=10)),
+            IggyExpiry.ExpireDuration(timedelta(days=1, seconds=2, 
microseconds=3)),
+            # days * 86_400 overflows i32 (max ~24,855 days); regression test
+            # for widening the days-to-seconds conversion to i64.
+            IggyExpiry.ExpireDuration(timedelta(days=30_000)),
         ],
     )
     async def test_create_topic_with_message_expiry(
-        self, iggy_client: IggyClient, unique_name, message_expiry: timedelta
+        self,
+        iggy_client: IggyClient,
+        unique_name,
+        message_expiry: IggyExpiry.ExpireDuration,
     ):
         """Test create_topic accepts an explicit message expiry."""
         stream_name = unique_name()
@@ -254,13 +263,47 @@ class TestCreateTopic:
         topic = await iggy_client.get_topic(stream_name, topic_name)
         assert topic is not None
         assert topic.name == topic_name
+        assert isinstance(topic.message_expiry, IggyExpiry.ExpireDuration)
+        assert topic.message_expiry.duration == message_expiry.duration
 
     @pytest.mark.asyncio
-    @pytest.mark.parametrize("invalid_message_expiry", [1, "1s", object()])
+    @pytest.mark.parametrize(
+        "invalid_duration",
+        [
+            timedelta(seconds=-1),
+            # 0 is the wire sentinel reserved for IggyExpiry.ServerDefault();
+            # matches MaxTopicSize.Custom(0) rejecting its own sentinel.
+            timedelta(0),
+            # u64::MAX microseconds is the wire sentinel reserved for
+            # IggyExpiry.NeverExpire(); matches MaxTopicSize.Custom(u64::MAX).
+            timedelta(microseconds=2**64 - 1),
+        ],
+    )
+    async def test_create_topic_rejects_invalid_message_expiry_duration(
+        self, iggy_client: IggyClient, unique_name, invalid_duration: timedelta
+    ):
+        """Test create_topic rejects an ExpireDuration at a reserved 
boundary."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+
+        await iggy_client.create_stream(stream_name)
+
+        with pytest.raises(ValueError):
+            await iggy_client.create_topic(
+                stream=stream_name,
+                name=topic_name,
+                partitions_count=1,
+                message_expiry=IggyExpiry.ExpireDuration(invalid_duration),
+            )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "invalid_message_expiry", [1, "1s", object(), timedelta(seconds=1)]
+    )
     async def test_create_topic_invalid_message_expiry(
         self, iggy_client: IggyClient, unique_name, invalid_message_expiry
     ):
-        """Test create_topic rejects message_expiry values that are not 
timedeltas."""
+        """Test create_topic rejects non-IggyExpiry message_expiry values."""
         stream_name = unique_name()
         topic_name = unique_name()
 
@@ -276,15 +319,19 @@ class TestCreateTopic:
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
-        "max_topic_size",
+        ("max_topic_size", "expected_kind"),
         [
-            0,  # value for server default max topic size
-            2**64 - 1,
-            2_000_000_000,
+            (MaxTopicSize.ServerDefault(), "unlimited"),  # resolved by 
create_topic
+            (MaxTopicSize.Unlimited(), "unlimited"),
+            (MaxTopicSize.Custom(2_000_000_000), "custom"),
         ],
     )
     async def test_create_topic_with_valid_max_topic_size(
-        self, iggy_client: IggyClient, unique_name, max_topic_size: int
+        self,
+        iggy_client: IggyClient,
+        unique_name,
+        max_topic_size: MaxTopicSize,
+        expected_kind: str,
     ):
         """Test create_topic accepts supported maximum topic size values."""
         stream_name = unique_name()
@@ -301,21 +348,29 @@ class TestCreateTopic:
         topic = await iggy_client.get_topic(stream_name, topic_name)
         assert topic is not None
         assert topic.name == topic_name
+        if expected_kind == "unlimited":
+            assert isinstance(topic.max_topic_size, MaxTopicSize.Unlimited)
+        else:
+            assert isinstance(topic.max_topic_size, MaxTopicSize.Custom)
+            assert isinstance(max_topic_size, MaxTopicSize.Custom)
+            assert topic.max_topic_size.bytes == max_topic_size.bytes
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
-        ("max_topic_size", "expected_exception"),
+        ("max_topic_size_bytes", "expected_exception"),
         [
             (4563, RuntimeError),
             (-1, OverflowError),
             (2e64, TypeError),
+            (0, ValueError),
+            (2**64 - 1, ValueError),  # u64::MAX is reserved for Unlimited
         ],
     )
     async def test_create_topic_invalid_max_topic_size(
         self,
         iggy_client: IggyClient,
         unique_name,
-        max_topic_size,
+        max_topic_size_bytes,
         expected_exception,
     ):
         """Test create_topic rejects invalid maximum topic size values."""
@@ -329,7 +384,7 @@ class TestCreateTopic:
                 stream=stream_name,
                 name=topic_name,
                 partitions_count=1,
-                max_topic_size=max_topic_size,
+                max_topic_size=MaxTopicSize.Custom(max_topic_size_bytes),
             )
 
     @pytest.mark.asyncio
@@ -540,6 +595,28 @@ class TestGetTopic:
         assert topic_by_id.id == topic_by_name.id
         assert topic_by_id.name == topic_by_name.name
 
+    @pytest.mark.asyncio
+    async def test_get_topic_partitions(self, iggy_client: IggyClient, 
unique_name):
+        """Test TopicDetails.partitions returns one Partition per partition."""
+        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=3
+        )
+
+        topic = await iggy_client.get_topic(stream_name, topic_name)
+        assert topic is not None
+        assert len(topic.partitions) == 3
+        assert [partition.id for partition in topic.partitions] == [0, 1, 2]
+        for partition in topic.partitions:
+            assert partition.created_at > 0
+            assert partition.segments_count == 1
+            assert partition.current_offset == 0
+            assert partition.size == 0
+            assert partition.messages_count == 0
+
     @pytest.mark.asyncio
     async def test_get_nonexistent_topic(self, iggy_client: IggyClient, 
unique_name):
         """Test getting a non-existent topic by name or numeric id."""
@@ -690,6 +767,44 @@ class TestGetTopics:
 class TestUpdateTopic:
     """Test updating topics via update_topic."""
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("prefix", "min_bytes", "max_bytes"),
+        [
+            ("", 0, 0),
+            ("a" * 248, 256, 256),
+            ("é" * 124, 256, 256),
+            (("é" * 123) + "ab", 256, 256),
+            (("한" * 82) + "ab", 256, 256),
+            (("漢" * 82) + "ab", 256, 256),
+            (("あ" * 82) + "ab", 256, 256),
+            ("😀" * 62, 256, 256),
+            (("😀" * 61) + "abcd", 256, 256),
+        ],
+    )
+    async def test_update_topic_invalid_names(
+        self,
+        iggy_client: IggyClient,
+        unique_name,
+        prefix: str,
+        min_bytes: int,
+        max_bytes: int,
+    ):
+        """Test update_topic enforces byte-length validation."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        invalid_name = unique_name(prefix, min_bytes=min_bytes, 
max_bytes=max_bytes)
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+
+        with pytest.raises(RuntimeError):
+            await iggy_client.update_topic(
+                stream_id=stream_name, topic_id=topic_name, name=invalid_name
+            )
+
     @pytest.mark.asyncio
     async def test_update_topic_renames_topic(
         self, iggy_client: IggyClient, unique_name
@@ -812,11 +927,13 @@ class TestUpdateTopic:
             )
 
     @pytest.mark.asyncio
-    @pytest.mark.parametrize("invalid_message_expiry", [1, "1s", object()])
+    @pytest.mark.parametrize(
+        "invalid_message_expiry", [1, "1s", object(), timedelta(seconds=1)]
+    )
     async def test_update_topic_invalid_message_expiry(
         self, iggy_client: IggyClient, unique_name, invalid_message_expiry
     ):
-        """Test update_topic rejects message_expiry values that are not 
timedeltas."""
+        """Test update_topic rejects non-IggyExpiry message_expiry values."""
         stream_name = unique_name()
         topic_name = unique_name()
 
@@ -895,17 +1012,19 @@ class TestUpdateTopic:
 
     @pytest.mark.asyncio
     @pytest.mark.parametrize(
-        ("max_topic_size", "expected_exception"),
+        ("max_topic_size_bytes", "expected_exception"),
         [
             (-1, OverflowError),
             (2e64, TypeError),
+            (0, ValueError),
+            (2**64 - 1, ValueError),  # u64::MAX is reserved for Unlimited
         ],
     )
     async def test_update_topic_invalid_max_topic_size(
         self,
         iggy_client: IggyClient,
         unique_name,
-        max_topic_size,
+        max_topic_size_bytes,
         expected_exception,
     ):
         """Test update_topic rejects invalid maximum topic size values."""
@@ -922,7 +1041,7 @@ class TestUpdateTopic:
                 stream_id=stream_name,
                 topic_id=topic_name,
                 name=topic_name,
-                max_topic_size=max_topic_size,
+                max_topic_size=MaxTopicSize.Custom(max_topic_size_bytes),
             )
 
     @pytest.mark.asyncio
@@ -942,19 +1061,63 @@ class TestUpdateTopic:
             stream_id=stream_name,
             topic_id=topic_name,
             name=topic_name,
-            message_expiry=timedelta(minutes=10),
+            message_expiry=IggyExpiry.ExpireDuration(timedelta(minutes=10)),
         )
 
         topic = await iggy_client.get_topic(stream_name, topic_name)
         assert topic is not None
         assert topic.name == topic_name
-        # TODO: assert topic.message_expiry once TopicDetails exposes that
-        # getter (tracked for a follow-up PR).
+        assert isinstance(topic.message_expiry, IggyExpiry.ExpireDuration)
+        assert topic.message_expiry.duration == timedelta(minutes=10)
 
     @pytest.mark.asyncio
-    @pytest.mark.parametrize("max_topic_size", [0, 2_000_000_000, 2**64 - 1])
+    @pytest.mark.parametrize(
+        "invalid_duration",
+        [
+            timedelta(seconds=-1),
+            # 0 is the wire sentinel reserved for IggyExpiry.ServerDefault();
+            # matches MaxTopicSize.Custom(0) rejecting its own sentinel.
+            timedelta(0),
+            # u64::MAX microseconds is the wire sentinel reserved for
+            # IggyExpiry.NeverExpire(); matches MaxTopicSize.Custom(u64::MAX).
+            timedelta(microseconds=2**64 - 1),
+        ],
+    )
+    async def test_update_topic_rejects_invalid_message_expiry_duration(
+        self, iggy_client: IggyClient, unique_name, invalid_duration: timedelta
+    ):
+        """Test update_topic rejects an ExpireDuration at a reserved 
boundary."""
+        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
+        )
+
+        with pytest.raises(ValueError):
+            await iggy_client.update_topic(
+                stream_id=stream_name,
+                topic_id=topic_name,
+                name=topic_name,
+                message_expiry=IggyExpiry.ExpireDuration(invalid_duration),
+            )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("max_topic_size", "expected_kind"),
+        [
+            (MaxTopicSize.ServerDefault(), "server_default"),
+            (MaxTopicSize.Custom(2_000_000_000), "custom"),
+            (MaxTopicSize.Unlimited(), "unlimited"),
+        ],
+    )
     async def test_update_topic_with_valid_max_topic_size(
-        self, iggy_client: IggyClient, unique_name, max_topic_size: int
+        self,
+        iggy_client: IggyClient,
+        unique_name,
+        max_topic_size: MaxTopicSize,
+        expected_kind: str,
     ):
         """Test update_topic accepts supported maximum topic size values."""
         stream_name = unique_name()
@@ -975,8 +1138,14 @@ class TestUpdateTopic:
         topic = await iggy_client.get_topic(stream_name, topic_name)
         assert topic is not None
         assert topic.name == topic_name
-        # TODO: assert topic.message_expiry and topic.max_topic_size once
-        # TopicDetails exposes those getters (tracked for a follow-up PR).
+        if expected_kind == "server_default":
+            assert isinstance(topic.max_topic_size, MaxTopicSize.ServerDefault)
+        elif expected_kind == "unlimited":
+            assert isinstance(topic.max_topic_size, MaxTopicSize.Unlimited)
+        else:
+            assert isinstance(topic.max_topic_size, MaxTopicSize.Custom)
+            assert isinstance(max_topic_size, MaxTopicSize.Custom)
+            assert topic.max_topic_size.bytes == max_topic_size.bytes
 
     @pytest.mark.asyncio
     async def test_update_topic_applies_repeated_updates(
@@ -1213,12 +1382,18 @@ class TestPurgeTopic:
         after = await iggy_client.get_topic(stream_name, topic_name)
         assert after is not None
         assert after.messages_count == 0
-        # Purging clears messages only; every other field is left unchanged.
+        assert after.size == 0
+        # Purging clears messages and size only; topic config is unchanged.
         assert after.id == before.id
         assert after.name == before.name
+        assert after.created_at == before.created_at
         assert after.partitions_count == before.partitions_count
         assert after.compression_algorithm == before.compression_algorithm
         assert after.replication_factor == before.replication_factor
+        assert isinstance(before.message_expiry, IggyExpiry.NeverExpire)
+        assert isinstance(after.message_expiry, IggyExpiry.NeverExpire)
+        assert isinstance(before.max_topic_size, MaxTopicSize.Unlimited)
+        assert isinstance(after.max_topic_size, MaxTopicSize.Unlimited)
 
     @pytest.mark.asyncio
     async def test_purge_empty_topic_succeeds(
diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock
index 55960c278..0ae74df8d 100644
--- a/foreign/python/uv.lock
+++ b/foreign/python/uv.lock
@@ -12,7 +12,7 @@ exclude-newer-span = "P7D"
 
 [[package]]
 name = "apache-iggy"
-version = "0.8.1.dev3"
+version = "0.8.1.dev4"
 source = { editable = "." }
 
 [package.optional-dependencies]
@@ -335,7 +335,7 @@ name = "exceptiongroup"
 version = "1.3.1"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
-    { name = "typing-extensions" },
+    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
 ]
 sdist = { url = 
"https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz";,
 hash = 
"sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size 
= 30371, upload-time = "2025-11-21T23:01:54.787Z" }
 wheels = [

Reply via email to