From c8157a2b54693fe78e29150caeb8c069a949ac9d Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:14:49 +0800 Subject: [PATCH 01/20] feat(python): expose TCP client configuration to Python IggyClient accepted only a server address, so auto-login and reconnection tuning were unreachable from Python. Without credentials to replay, the SDK's own session recovery never fires and a dropped session surfaces as Unauthenticated on the next call, leaving the application to hand-roll a connect/login/probe loop. TcpConfig mirrors TcpClientConfig field for field and is accepted by the IggyClient constructor alongside the existing address string. AutoLogin carries the credentials without exposing them back to Python, and TcpReconnectionConfig carries the retry policy. Credentials is re-exported from the SDK prelude because AutoLogin::Enabled cannot be constructed without naming it. Closes #3742 --- core/sdk/src/prelude.rs | 6 +- foreign/python/Cargo.toml | 1 + foreign/python/apache_iggy.pyi | 145 ++++++++++++- foreign/python/src/client.rs | 36 +++- foreign/python/src/config.rs | 368 +++++++++++++++++++++++++++++++++ foreign/python/src/consumer.rs | 17 +- foreign/python/src/duration.rs | 43 ++++ foreign/python/src/lib.rs | 6 + 8 files changed, 599 insertions(+), 23 deletions(-) create mode 100644 foreign/python/src/config.rs create mode 100644 foreign/python/src/duration.rs diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs index 34f8d14844..add8cad1fd 100644 --- a/core/sdk/src/prelude.rs +++ b/core/sdk/src/prelude.rs @@ -51,9 +51,9 @@ pub use iggy_common::{ Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, CacheMetrics, CacheMetricsKey, ClientError, ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, - ConsumerKind, EncryptorKind, GlobalPermissions, HeaderKey, HeaderKind, HeaderValue, - HttpClientConfig, HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo, - IggyByteSize, IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, + ConsumerKind, Credentials, EncryptorKind, GlobalPermissions, HeaderKey, HeaderKind, + HeaderValue, HttpClientConfig, HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, + IdentityInfo, IggyByteSize, IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader, IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, Partition, Partitioner, Partitioning, Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages, PollingKind, PollingStrategy, diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml index 612cec38a2..aa2ee442e9 100644 --- a/foreign/python/Cargo.toml +++ b/foreign/python/Cargo.toml @@ -44,4 +44,5 @@ pyo3-async-runtimes = { version = "0.29.0", features = [ "tokio-runtime", ] } pyo3-stub-gen = "0.23.0" +secrecy = "0.10" tokio = "1.53.1" diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5b980c2e0d..3befd0deb7 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -29,6 +29,7 @@ __all__ = [ "AutoCommit", "AutoCommitAfter", "AutoCommitWhen", + "AutoLogin", "ConsumerGroup", "ConsumerGroupDetails", "ConsumerGroupMember", @@ -38,6 +39,8 @@ __all__ = [ "ReceiveMessage", "SendMessage", "StreamDetails", + "TcpConfig", + "TcpReconnectionConfig", "Topic", "TopicDetails", "UserInfo", @@ -238,6 +241,41 @@ class AutoCommitWhen: ... +@typing.final +class AutoLogin: + r""" + The credentials replayed by the client every time it (re)connects. + + `IggyClient` only recovers a lost session when it has credentials to replay, + so a long-running consumer should pass one of the enabled variants. + """ + @property + def enabled(self) -> builtins.bool: + r""" + Whether automatic login is enabled. + """ + @property + def username(self) -> builtins.str | None: + r""" + The username to log in with, or `None` for the disabled and token variants. + """ + @staticmethod + def disabled() -> AutoLogin: + r""" + No automatic login. `login_user()` must be called by hand after every connect. + """ + @staticmethod + def username_password(username: builtins.str, password: builtins.str) -> AutoLogin: + r""" + Log in with the given username and password on every connect. + """ + @staticmethod + def personal_access_token(token: builtins.str) -> AutoLogin: + r""" + Log in with the given personal access token on every connect. + """ + def __repr__(self) -> builtins.str: ... + @typing.final class ConsumerGroup: @property @@ -314,11 +352,20 @@ class IggyClient: It wraps the RustIggyClient and provides asynchronous functionality through the contained runtime. """ - def __new__(cls, conn: builtins.str | None = None) -> IggyClient: + def __new__(cls, conn: TcpConfig | builtins.str | None = None) -> IggyClient: r""" - Constructs a new IggyClient from a TCP server address. + Constructs a new IggyClient from a TCP server address or a `TcpConfig`. This initializes a new runtime for asynchronous operations. Future versions might utilize asyncio for more Pythonic async. + + Args: + conn: Either a `host:port` address, or a `TcpConfig` carrying the full + transport configuration. Defaults to `127.0.0.1:8090` with auto-login + disabled. + + Raises: + PyRuntimeError: If the address is not a valid `host:port` pair, or if the + client cannot be built. """ @classmethod def from_connection_string(cls, connection_string: builtins.str) -> IggyClient: @@ -916,6 +963,100 @@ class StreamDetails: @property def topics_count(self) -> builtins.int: ... +@typing.final +class TcpConfig: + r""" + Configuration for the TCP transport, accepted by `IggyClient(...)`. + + Mirrors `TcpClientConfig` in the Rust SDK. Every field is keyword-only and + falls back to the same default the Rust SDK uses. + """ + @property + def server_address(self) -> builtins.str: ... + @property + def auto_login(self) -> AutoLogin: ... + @property + def reconnection(self) -> TcpReconnectionConfig: ... + @property + def heartbeat_interval(self) -> datetime.timedelta: ... + @property + def tls_enabled(self) -> builtins.bool: ... + @property + def tls_domain(self) -> builtins.str: ... + @property + def tls_ca_file(self) -> builtins.str | None: ... + @property + def tls_validate_certificate(self) -> builtins.bool: ... + @property + def nodelay(self) -> builtins.bool: ... + def __new__( + cls, + *, + server_address: builtins.str | None = None, + auto_login: AutoLogin | None = None, + reconnection: TcpReconnectionConfig | None = None, + heartbeat_interval: datetime.timedelta | None = None, + tls_enabled: builtins.bool = False, + tls_domain: builtins.str | None = None, + tls_ca_file: builtins.str | None = None, + tls_validate_certificate: builtins.bool = True, + nodelay: builtins.bool = False, + ) -> TcpConfig: + r""" + Constructs a TCP configuration, defaulting every unset field to the value + the Rust SDK uses. + + Args: + server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8090`. + auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. + reconnection: Reconnection policy. Defaults to `TcpReconnectionConfig()`. + heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. + tls_enabled: Whether to connect over TLS. + tls_domain: Domain to validate the certificate against. Empty means it is + taken from `server_address`. + tls_ca_file: Path to the CA file for TLS. + tls_validate_certificate: Whether to validate the server certificate. + nodelay: Disable the Nagle algorithm for the TCP socket. + + Raises: + PyValueError: If `server_address` is not a valid `host:port` pair. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class TcpReconnectionConfig: + r""" + How the TCP client reconnects after the connection to the server is lost. + """ + @property + def enabled(self) -> builtins.bool: ... + @property + def max_retries(self) -> builtins.int | None: ... + @property + def interval(self) -> datetime.timedelta: ... + @property + def reestablish_after(self) -> datetime.timedelta: ... + def __new__( + cls, + *, + enabled: builtins.bool = True, + max_retries: builtins.int | None = None, + interval: datetime.timedelta | None = None, + reestablish_after: datetime.timedelta | None = None, + ) -> TcpReconnectionConfig: + r""" + Constructs a reconnection policy, defaulting every unset field to the + value the Rust SDK uses. + + Args: + enabled: Whether to reconnect at all. + max_retries: Attempts before giving up, or `None` for unlimited. + interval: Delay between attempts. Defaults to 1 second. + reestablish_after: Cooldown before reconnecting after a previously + successful connection. Defaults to 5 seconds. + """ + def __repr__(self) -> builtins.str: ... + @typing.final class Topic: @property diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index b860f19b5d..9aeb8c4ec6 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -29,10 +29,12 @@ use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use std::str::FromStr; use std::sync::Arc; +use crate::config::PyClientConfig; use crate::consumer::{ AutoCommit, ConsumerGroup as PyConsumerGroup, ConsumerGroupDetails as PyConsumerGroupDetails, - IggyConsumer, py_delta_to_iggy_duration, + IggyConsumer, }; +use crate::duration::py_delta_to_iggy_duration; use crate::identifier::PyIdentifier; use crate::receive_message::{PollingStrategy, ReceiveMessage}; use crate::send_message::SendMessage; @@ -55,17 +57,41 @@ pub struct IggyClient { #[gen_stub_pymethods] #[pymethods] impl IggyClient { - /// Constructs a new IggyClient from a TCP server address. + /// Constructs a new IggyClient from a TCP server address or a `TcpConfig`. /// This initializes a new runtime for asynchronous operations. /// Future versions might utilize asyncio for more Pythonic async. + /// + /// Args: + /// conn: Either a `host:port` address, or a `TcpConfig` carrying the full + /// transport configuration. Defaults to `127.0.0.1:8090` with auto-login + /// disabled. + /// + /// Raises: + /// PyRuntimeError: If the address is not a valid `host:port` pair, or if the + /// client cannot be built. #[new] #[pyo3(signature = (conn=None))] fn new( - #[gen_stub(override_type(type_repr = "builtins.str | None"))] conn: Option, + #[gen_stub(override_type(type_repr = "TcpConfig | builtins.str | None"))] conn: Option< + PyClientConfig, + >, ) -> PyResult { + let config = match conn { + Some(PyClientConfig::Config(config)) => config.client_config(), + Some(PyClientConfig::ServerAddress(server_address)) => Arc::new( + TcpClientConfigBuilder::new() + .with_server_address(server_address) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?, + ), + None => Arc::new(TcpClientConfig::default()), + }; + let tcp_client = TcpClient::create(config) + .map_err(|e| PyErr::new::(e.to_string()))?; let client = IggyClientBuilder::new() - .with_tcp() - .with_server_address(conn.unwrap_or("127.0.0.1:8090".to_string())) + .with_client(ClientWrapper::Tcp(tcp_client)) .build() .map_err(|e| PyErr::new::(e.to_string()))?; Ok(IggyClient { diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs new file mode 100644 index 0000000000..7bb1d651a5 --- /dev/null +++ b/foreign/python/src/config.rs @@ -0,0 +1,368 @@ +// 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 iggy::prelude::{ + AutoLogin as RustAutoLogin, Credentials as RustCredentials, + TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder, + TcpClientReconnectionConfig as RustTcpClientReconnectionConfig, +}; +use pyo3::prelude::*; +use pyo3::types::PyDelta; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +use pyo3_stub_gen::impl_stub_type; +use secrecy::SecretString; +use std::sync::Arc; + +use crate::duration::{iggy_duration_to_py_delta, py_delta_to_iggy_duration}; + +/// The credentials replayed by the client every time it (re)connects. +/// +/// `IggyClient` only recovers a lost session when it has credentials to replay, +/// so a long-running consumer should pass one of the enabled variants. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct AutoLogin { + pub(crate) inner: RustAutoLogin, +} + +#[gen_stub_pymethods] +#[pymethods] +impl AutoLogin { + /// No automatic login. `login_user()` must be called by hand after every connect. + #[staticmethod] + fn disabled() -> Self { + Self { + inner: RustAutoLogin::Disabled, + } + } + + /// Log in with the given username and password on every connect. + #[staticmethod] + fn username_password(username: String, password: String) -> Self { + Self { + inner: RustAutoLogin::Enabled(RustCredentials::UsernamePassword( + username, + SecretString::from(password), + )), + } + } + + /// Log in with the given personal access token on every connect. + #[staticmethod] + fn personal_access_token(token: String) -> Self { + Self { + inner: RustAutoLogin::Enabled(RustCredentials::PersonalAccessToken( + SecretString::from(token), + )), + } + } + + /// Whether automatic login is enabled. + #[getter] + fn enabled(&self) -> bool { + matches!(self.inner, RustAutoLogin::Enabled(_)) + } + + /// The username to log in with, or `None` for the disabled and token variants. + #[gen_stub(override_return_type(type_repr = "builtins.str | None"))] + #[getter] + fn username(&self) -> Option { + match &self.inner { + RustAutoLogin::Enabled(RustCredentials::UsernamePassword(username, _)) => { + Some(username.clone()) + } + _ => None, + } + } + + fn __repr__(&self) -> String { + match &self.inner { + RustAutoLogin::Disabled => "AutoLogin.disabled()".to_owned(), + RustAutoLogin::Enabled(RustCredentials::UsernamePassword(username, _)) => { + format!("AutoLogin.username_password({username:?}, ...)") + } + RustAutoLogin::Enabled(RustCredentials::PersonalAccessToken(_)) => { + "AutoLogin.personal_access_token(...)".to_owned() + } + } + } +} + +impl Default for AutoLogin { + fn default() -> Self { + Self::disabled() + } +} + +/// How the TCP client reconnects after the connection to the server is lost. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone, Default)] +pub struct TcpReconnectionConfig { + pub(crate) inner: RustTcpClientReconnectionConfig, +} + +#[gen_stub_pymethods] +#[pymethods] +impl TcpReconnectionConfig { + /// Constructs a reconnection policy, defaulting every unset field to the + /// value the Rust SDK uses. + /// + /// Args: + /// enabled: Whether to reconnect at all. + /// max_retries: Attempts before giving up, or `None` for unlimited. + /// interval: Delay between attempts. Defaults to 1 second. + /// reestablish_after: Cooldown before reconnecting after a previously + /// successful connection. Defaults to 5 seconds. + #[new] + #[pyo3(signature = (*, enabled=true, max_retries=None, interval=None, reestablish_after=None))] + fn new( + enabled: bool, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + interval: Option>, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + reestablish_after: Option>, + ) -> Self { + let defaults = RustTcpClientReconnectionConfig::default(); + Self { + inner: RustTcpClientReconnectionConfig { + enabled, + max_retries, + interval: interval + .as_ref() + .map_or(defaults.interval, py_delta_to_iggy_duration), + reestablish_after: reestablish_after + .as_ref() + .map_or(defaults.reestablish_after, py_delta_to_iggy_duration), + }, + } + } + + #[getter] + fn enabled(&self) -> bool { + self.inner.enabled + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn max_retries(&self) -> Option { + self.inner.max_retries + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.interval) + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.reestablish_after) + } + + fn __repr__(&self) -> String { + let max_retries = match self.inner.max_retries { + Some(max_retries) => max_retries.to_string(), + None => "None".to_owned(), + }; + format!( + "TcpReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})", + if self.inner.enabled { "True" } else { "False" }, + self.inner.interval.as_human_time_string(), + self.inner.reestablish_after.as_human_time_string(), + ) + } +} + +/// Configuration for the TCP transport, accepted by `IggyClient(...)`. +/// +/// Mirrors `TcpClientConfig` in the Rust SDK. Every field is keyword-only and +/// falls back to the same default the Rust SDK uses. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct TcpConfig { + auto_login: AutoLogin, + reconnection: TcpReconnectionConfig, + inner: Arc, +} + +impl TcpConfig { + /// The configuration in the shape `TcpClient::create` expects. + pub(crate) fn client_config(&self) -> Arc { + self.inner.clone() + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl TcpConfig { + /// Constructs a TCP configuration, defaulting every unset field to the value + /// the Rust SDK uses. + /// + /// Args: + /// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8090`. + /// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. + /// reconnection: Reconnection policy. Defaults to `TcpReconnectionConfig()`. + /// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. + /// tls_enabled: Whether to connect over TLS. + /// tls_domain: Domain to validate the certificate against. Empty means it is + /// taken from `server_address`. + /// tls_ca_file: Path to the CA file for TLS. + /// tls_validate_certificate: Whether to validate the server certificate. + /// nodelay: Disable the Nagle algorithm for the TCP socket. + /// + /// Raises: + /// PyValueError: If `server_address` is not a valid `host:port` pair. + #[new] + #[pyo3(signature = ( + *, + server_address=None, + auto_login=None, + reconnection=None, + heartbeat_interval=None, + tls_enabled=false, + tls_domain=None, + tls_ca_file=None, + tls_validate_certificate=true, + nodelay=false, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + #[gen_stub(override_type(type_repr = "builtins.str | None"))] server_address: Option< + String, + >, + #[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option, + #[gen_stub(override_type(type_repr = "TcpReconnectionConfig | None"))] reconnection: Option< + TcpReconnectionConfig, + >, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + heartbeat_interval: Option>, + tls_enabled: bool, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_domain: Option, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_ca_file: Option, + tls_validate_certificate: bool, + nodelay: bool, + ) -> PyResult { + let defaults = RustTcpClientConfig::default(); + let auto_login = auto_login.unwrap_or_default(); + let reconnection = reconnection.unwrap_or_default(); + + let mut builder = TcpClientConfigBuilder::new() + .with_server_address(server_address.unwrap_or(defaults.server_address)) + .with_auto_sign_in(auto_login.inner.clone()) + .with_tls_enabled(tls_enabled) + .with_tls_domain(tls_domain.unwrap_or(defaults.tls_domain)) + .with_tls_validate_certificate(tls_validate_certificate); + if let Some(tls_ca_file) = tls_ca_file { + builder = builder.with_tls_ca_file(tls_ca_file); + } + if nodelay { + builder = builder.with_no_delay(); + } + + let mut inner = builder + .build() + .map_err(|e| PyErr::new::(e.to_string()))?; + // TcpClientConfigBuilder exposes no setter for either of these. + inner.heartbeat_interval = heartbeat_interval + .as_ref() + .map_or(defaults.heartbeat_interval, py_delta_to_iggy_duration); + inner.reconnection = reconnection.inner.clone(); + + Ok(Self { + auto_login, + reconnection, + inner: Arc::new(inner), + }) + } + + #[getter] + fn server_address(&self) -> String { + self.inner.server_address.clone() + } + + #[getter] + fn auto_login(&self) -> AutoLogin { + self.auto_login.clone() + } + + #[getter] + fn reconnection(&self) -> TcpReconnectionConfig { + self.reconnection.clone() + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.heartbeat_interval) + } + + #[getter] + fn tls_enabled(&self) -> bool { + self.inner.tls_enabled + } + + #[getter] + fn tls_domain(&self) -> String { + self.inner.tls_domain.clone() + } + + #[gen_stub(override_return_type(type_repr = "builtins.str | None"))] + #[getter] + fn tls_ca_file(&self) -> Option { + self.inner.tls_ca_file.clone() + } + + #[getter] + fn tls_validate_certificate(&self) -> bool { + self.inner.tls_validate_certificate + } + + #[getter] + fn nodelay(&self) -> bool { + self.inner.nodelay + } + + fn __repr__(&self) -> String { + format!( + "TcpConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, tls_enabled={})", + self.inner.server_address, + self.auto_login.__repr__(), + self.reconnection.__repr__(), + self.inner.heartbeat_interval.as_human_time_string(), + if self.inner.tls_enabled { + "True" + } else { + "False" + }, + ) + } +} + +/// What `IggyClient(...)` accepts: a bare `host:port` or a full `TcpConfig`. +#[derive(FromPyObject)] +pub enum PyClientConfig { + #[pyo3(transparent)] + Config(TcpConfig), + #[pyo3(transparent, annotation = "str")] + ServerAddress(String), +} +impl_stub_type!(PyClientConfig = TcpConfig | String); diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs index 0b6066b686..41d849c79f 100644 --- a/foreign/python/src/consumer.rs +++ b/foreign/python/src/consumer.rs @@ -16,7 +16,6 @@ // under the License. use std::sync::Arc; -use std::time::Duration; use futures::StreamExt; use iggy::consumer_ext::{IggyConsumerMessageExt, MessageConsumer}; @@ -24,11 +23,11 @@ use iggy::prelude::{ AutoCommit as RustAutoCommit, AutoCommitAfter as RustAutoCommitAfter, AutoCommitWhen as RustAutoCommitWhen, ConsumerGroup as RustConsumerGroup, ConsumerGroupDetails as RustConsumerGroupDetails, - ConsumerGroupMember as RustConsumerGroupMember, IggyConsumer as RustIggyConsumer, IggyDuration, - IggyError, ReceivedMessage, + ConsumerGroupMember as RustConsumerGroupMember, IggyConsumer as RustIggyConsumer, IggyError, + ReceivedMessage, }; use pyo3::exceptions::PyStopAsyncIteration; -use pyo3::types::{PyDelta, PyDeltaAccess}; +use pyo3::types::PyDelta; use pyo3::prelude::*; use pyo3_async_runtimes::TaskLocals; @@ -39,6 +38,7 @@ use tokio::sync::Mutex; use tokio::sync::oneshot::Sender; use tokio::task::JoinHandle; +use crate::duration::py_delta_to_iggy_duration; use crate::identifier::PyIdentifier; use crate::receive_message::ReceiveMessage; @@ -516,12 +516,3 @@ impl PyStubType for AutoCommitAfter { TypeInfo::unqualified("AutoCommitAfter") } } - -pub fn py_delta_to_iggy_duration(delta1: &Py) -> IggyDuration { - Python::attach(|py| { - let delta = delta1.bind(py); - let seconds = (delta.get_days() * 60 * 60 * 24 + delta.get_seconds()) as u64; - let nanos = (delta.get_microseconds() * 1_000) as u32; - IggyDuration::new(Duration::new(seconds, nanos)) - }) -} diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs new file mode 100644 index 0000000000..d0a807cfb5 --- /dev/null +++ b/foreign/python/src/duration.rs @@ -0,0 +1,43 @@ +// 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 iggy::prelude::IggyDuration; +use pyo3::prelude::*; +use pyo3::types::{PyDelta, PyDeltaAccess}; +use std::time::Duration; + +pub fn py_delta_to_iggy_duration(delta: &Py) -> IggyDuration { + Python::attach(|py| { + let delta = delta.bind(py); + let seconds = (delta.get_days() * 60 * 60 * 24 + delta.get_seconds()) as u64; + let nanos = (delta.get_microseconds() * 1_000) as u32; + IggyDuration::new(Duration::new(seconds, nanos)) + }) +} + +pub fn iggy_duration_to_py_delta( + py: Python<'_>, + duration: IggyDuration, +) -> PyResult> { + let micros = duration.as_micros(); + let seconds = i32::try_from(micros / 1_000_000).map_err(|_| { + PyErr::new::( + "duration does not fit into a datetime.timedelta", + ) + })?; + PyDelta::new(py, 0, seconds, (micros % 1_000_000) as i32, true) +} diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index f831fd703a..66ea8cec75 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -16,7 +16,9 @@ // under the License. pub mod client; +mod config; mod consumer; +mod duration; mod identifier; mod receive_message; mod send_message; @@ -25,6 +27,7 @@ mod topic; mod user; use client::IggyClient; +use config::{AutoLogin, TcpConfig, TcpReconnectionConfig}; use consumer::{ AutoCommit, AutoCommitAfter, AutoCommitWhen, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator, @@ -42,6 +45,9 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; From 4d5f97c0aed2a654d4237b1489f5f3e83c8e86f6 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:17:26 +0800 Subject: [PATCH 02/20] test(python): cover the TCP client configuration surface Round-trip every field through the getters so a default that drifts from the Rust SDK is caught, and assert that neither the password nor a personal access token comes back out of repr. The auto-login tests are the point of the configuration: a privileged call succeeds without a manual login_user() when credentials are configured, and fails without them. --- foreign/python/tests/test_client_config.py | 234 +++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 foreign/python/tests/test_client_config.py diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py new file mode 100644 index 0000000000..18c86f37c3 --- /dev/null +++ b/foreign/python/tests/test_client_config.py @@ -0,0 +1,234 @@ +# 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. + +""" +Tests for the TCP client configuration surface. + +`TcpConfig`, `TcpReconnectionConfig` and `AutoLogin` mirror the Rust SDK +types, so most of these assert that a value set from Python survives to the +getters and that unset fields fall back to the Rust defaults. The last class +proves the point of the configuration: with `auto_login` set, credentials are +replayed on connect and no manual `login_user()` is needed. +""" + +from datetime import timedelta + +import pytest + +from apache_iggy import AutoLogin, IggyClient, TcpConfig, TcpReconnectionConfig + +from .utils import get_server_config, wait_for_ping, wait_for_server + + +class TestAutoLogin: + """Test the credentials carried into the client.""" + + def test_disabled_has_no_username(self): + """Test that the disabled variant carries no credentials.""" + auto_login = AutoLogin.disabled() + + assert auto_login.enabled is False + assert auto_login.username is None + + def test_username_password_exposes_username_only(self): + """Test that the username is readable back but the password is not.""" + auto_login = AutoLogin.username_password("iggy", "secret") + + assert auto_login.enabled is True + assert auto_login.username == "iggy" + assert "secret" not in repr(auto_login) + + def test_personal_access_token_hides_the_token(self): + """Test that a token login exposes neither a username nor the token.""" + auto_login = AutoLogin.personal_access_token("secret-token") + + assert auto_login.enabled is True + assert auto_login.username is None + assert "secret-token" not in repr(auto_login) + + +class TestTcpReconnectionConfig: + """Test the reconnection policy.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured policy reconnects forever, one second apart.""" + reconnection = TcpReconnectionConfig() + + assert reconnection.enabled is True + assert reconnection.max_retries is None + assert reconnection.interval == timedelta(seconds=1) + assert reconnection.reestablish_after == timedelta(seconds=5) + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + reconnection = TcpReconnectionConfig( + enabled=False, + max_retries=10, + interval=timedelta(milliseconds=250), + reestablish_after=timedelta(seconds=30), + ) + + assert reconnection.enabled is False + assert reconnection.max_retries == 10 + assert reconnection.interval == timedelta(milliseconds=250) + assert reconnection.reestablish_after == timedelta(seconds=30) + + def test_arguments_are_keyword_only(self): + """Test that the adjacent flags cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + TcpReconnectionConfig(True) + + +class TestTcpConfig: + """Test the transport configuration.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured transport matches the Rust SDK defaults.""" + config = TcpConfig() + + assert config.server_address == "127.0.0.1:8090" + assert config.auto_login.enabled is False + assert config.reconnection.enabled is True + assert config.heartbeat_interval == timedelta(seconds=5) + assert config.tls_enabled is False + assert config.tls_domain == "" + assert config.tls_ca_file is None + assert config.tls_validate_certificate is True + assert config.nodelay is False + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + config = TcpConfig( + server_address="localhost:8090", + auto_login=AutoLogin.username_password("iggy", "iggy"), + reconnection=TcpReconnectionConfig(max_retries=3), + heartbeat_interval=timedelta(seconds=15), + tls_enabled=True, + tls_domain="localhost", + tls_ca_file="ca.pem", + tls_validate_certificate=False, + nodelay=True, + ) + + assert config.server_address == "localhost:8090" + assert config.auto_login.username == "iggy" + assert config.reconnection.max_retries == 3 + assert config.heartbeat_interval == timedelta(seconds=15) + assert config.tls_enabled is True + assert config.tls_domain == "localhost" + assert config.tls_ca_file == "ca.pem" + assert config.tls_validate_certificate is False + assert config.nodelay is True + + def test_arguments_are_keyword_only(self): + """Test that the address cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + TcpConfig("127.0.0.1:8090") + + def test_repr_hides_the_password(self): + """Test that the password does not leak through repr.""" + config = TcpConfig(auto_login=AutoLogin.username_password("iggy", "secret")) + + assert "secret" not in repr(config) + + @pytest.mark.parametrize( + "invalid_address", + ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "::1:8090"], + ) + def test_invalid_server_address_is_rejected(self, invalid_address: str): + """Test that a malformed address fails at construction, not at connect.""" + with pytest.raises(ValueError): + TcpConfig(server_address=invalid_address) + + +class TestClientConstruction: + """Test what the client constructor accepts.""" + + def test_accepts_a_config(self): + """Test that a client can be built from a config object.""" + assert IggyClient(TcpConfig(server_address="127.0.0.1:8090")) is not None + + def test_accepts_an_address(self): + """Test that the address form still works.""" + assert IggyClient("127.0.0.1:8090") is not None + + def test_accepts_nothing(self): + """Test that the default address is used when no argument is given.""" + assert IggyClient() is not None + + def test_rejects_an_invalid_address(self): + """Test that a malformed address is rejected.""" + with pytest.raises(RuntimeError): + IggyClient("nonsense") + + +@pytest.mark.integration +class TestAutoLoginAgainstServer: + """Test that configured credentials are actually replayed on connect.""" + + @pytest.mark.asyncio + async def test_auto_login_authenticates_without_login_user(self, unique_name): + """Test that a privileged call succeeds without a manual login_user().""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient( + TcpConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "iggy"), + ) + ) + await client.connect() + await wait_for_ping(client) + + stream_name = unique_name() + await client.create_stream(stream_name) + assert await client.get_stream(stream_name) is not None + + @pytest.mark.asyncio + async def test_without_auto_login_a_privileged_call_is_unauthenticated( + self, unique_name + ): + """Test that the same call fails when no credentials are configured.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(TcpConfig(server_address=f"{host}:{port}")) + await client.connect() + await wait_for_ping(client) + + with pytest.raises(RuntimeError): + await client.create_stream(unique_name()) + + @pytest.mark.asyncio + async def test_wrong_auto_login_credentials_fail(self): + """Test that bad configured credentials surface as a connect failure.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient( + TcpConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "invalid-password"), + reconnection=TcpReconnectionConfig(enabled=False), + ) + ) + + with pytest.raises(RuntimeError): + await client.connect() From 44e02e2cb2b3f376aabc9ca0b84926ff21758394 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:21:07 +0800 Subject: [PATCH 03/20] docs(python): add a client configuration example The existing examples all reach for a connection string, which leaves the new config types undiscoverable. This one configures auto-login and reconnection directly and never calls login_user, so the recovery the credentials unlock is visible: restart the server while it runs and the client picks up where it left off. --- examples/python/README.md | 14 ++ examples/python/client-configuration/main.py | 142 +++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 examples/python/client-configuration/main.py diff --git a/examples/python/README.md b/examples/python/README.md index 9bf943b75c..afe255b293 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -71,6 +71,20 @@ python basic/consumer.py Demonstrates fundamental client connection, authentication, batch message sending, and polling with support for TCP/QUIC/HTTP protocols. +### Client Configuration + +Auto-login and reconnection, configured explicitly rather than through a connection string: + +```bash +# Using uv +uv run client-configuration/main.py + +# Without using uv +python client-configuration/main.py +``` + +Demonstrates `TcpConfig`, `TcpReconnectionConfig` and `AutoLogin`. Because the credentials are replayed on every connect, the client recovers its session after the server restarts instead of failing with `Unauthenticated`. + ## TLS Examples To test with a TLS-enabled server, start the server with TLS configured (see main README), then run: diff --git a/examples/python/client-configuration/main.py b/examples/python/client-configuration/main.py new file mode 100644 index 0000000000..22f07762a4 --- /dev/null +++ b/examples/python/client-configuration/main.py @@ -0,0 +1,142 @@ +# 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. + +""" +Configures the TCP client explicitly instead of passing a bare address. + +The point of `auto_login` is that the credentials are replayed every time the +client connects, including after a reconnect. That is what lets the SDK +recover a session the server dropped: without it, a restart of the server +surfaces as `Unauthenticated` on the next call and the application has to +reconnect and log in by hand. + +Run this, then restart the server while it is polling: the client reconnects, +replays the login and keeps going. +""" + +import argparse +import asyncio +import typing +from datetime import timedelta + +from apache_iggy import ( + AutoLogin, + IggyClient, + PollingStrategy, + StreamDetails, + TcpConfig, + TcpReconnectionConfig, + TopicDetails, +) +from apache_iggy import SendMessage as Message +from loguru import logger + +STREAM_NAME = "configured-stream" +TOPIC_NAME = "configured-topic" +PARTITION_ID = 0 +BATCHES_LIMIT = 5 + + +class ArgNamespace(typing.NamedTuple): + tcp_server_address: str + username: str + password: str + + +def parse_args() -> ArgNamespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--tcp-server-address", + default="127.0.0.1:8090", + help="Iggy TCP server address (host:port)", + ) + parser.add_argument("--username", default="iggy", help="Username to log in with") + parser.add_argument("--password", default="iggy", help="Password to log in with") + return ArgNamespace(**vars(parser.parse_args())) + + +def build_config(args: ArgNamespace) -> TcpConfig: + return TcpConfig( + server_address=args.tcp_server_address, + auto_login=AutoLogin.username_password(args.username, args.password), + reconnection=TcpReconnectionConfig( + enabled=True, + max_retries=None, # retry forever + interval=timedelta(seconds=1), + reestablish_after=timedelta(seconds=5), + ), + heartbeat_interval=timedelta(seconds=5), + nodelay=True, + ) + + +async def main(): + args = parse_args() + config = build_config(args) + logger.info(f"Connecting with {config}") + + client = IggyClient(config) + # No login_user() call: auto_login replays the credentials on every connect. + await client.connect() + logger.info("Connected and authenticated.") + + await init_system(client) + await produce_and_consume(client) + + +async def init_system(client: IggyClient): + stream: StreamDetails | None = await client.get_stream(STREAM_NAME) + if stream is None: + await client.create_stream(name=STREAM_NAME) + logger.info(f"Created stream {STREAM_NAME}.") + + topic: TopicDetails | None = await client.get_topic(STREAM_NAME, TOPIC_NAME) + if topic is None: + await client.create_topic( + stream=STREAM_NAME, + name=TOPIC_NAME, + partitions_count=1, + replication_factor=1, + ) + logger.info(f"Created topic {TOPIC_NAME}.") + + +async def produce_and_consume(client: IggyClient): + for batch in range(BATCHES_LIMIT): + messages = [Message(f"message-{batch}-{i}") for i in range(10)] + await client.send_messages( + stream=STREAM_NAME, + topic=TOPIC_NAME, + partitioning=PARTITION_ID, + messages=messages, + ) + logger.info(f"Sent batch {batch}.") + + polled = await client.poll_messages( + stream=STREAM_NAME, + topic=TOPIC_NAME, + partition_id=PARTITION_ID, + polling_strategy=PollingStrategy.Next(), + count=len(messages), + auto_commit=True, + ) + logger.info(f"Polled {len(polled)} messages.") + await asyncio.sleep(0.5) + + +if __name__ == "__main__": + asyncio.run(main()) From 76ab557799dcab497055c66094dce9ff138c2975 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:25:54 +0800 Subject: [PATCH 04/20] docs(python): document client configuration in the SDK README The README pointed only at the examples directory, so the configuration surface stayed invisible to anyone reading the package page on PyPI. --- foreign/python/README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/foreign/python/README.md b/foreign/python/README.md index f01754f6dc..898ef9d5dd 100644 --- a/foreign/python/README.md +++ b/foreign/python/README.md @@ -58,6 +58,38 @@ maturin develop pytest tests/ -v # Run tests (requires iggy-server running) ``` +## Client Configuration + +`IggyClient` takes either a server address or a `TcpConfig`. Configuring `auto_login` +lets the SDK replay the credentials whenever it reconnects, so a session dropped by a +server restart is recovered instead of surfacing as `Unauthenticated`: + +```python +from datetime import timedelta + +from apache_iggy import AutoLogin, IggyClient, TcpConfig, TcpReconnectionConfig + +client = IggyClient( + TcpConfig( + server_address="127.0.0.1:8090", + auto_login=AutoLogin.username_password("iggy", "iggy"), + reconnection=TcpReconnectionConfig( + enabled=True, + max_retries=10, + interval=timedelta(seconds=2), + reestablish_after=timedelta(seconds=30), + ), + heartbeat_interval=timedelta(seconds=5), + ) +) +await client.connect() +``` + +`TcpConfig` also carries `tls_enabled`, `tls_domain`, `tls_ca_file`, +`tls_validate_certificate` and `nodelay`. Every field is keyword-only and defaults to the +same value the Rust SDK uses. `IggyClient.from_connection_string(...)` remains available +for the same settings in string form. + ## Examples Refer to the [examples/python/](https://github.com/apache/iggy/tree/master/examples/python) directory for usage examples. From 921cd8f3965e9a421e99ce61b1af19c480068b76 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:57:05 +0800 Subject: [PATCH 05/20] fix(python): validate durations and derive config defaults from Rust A negative timedelta normalizes to negative days plus positive seconds, so the old conversion summed to a negative i32 and cast it to u64, turning interval=timedelta(seconds=-1) into u64::MAX seconds: the config constructed fine and the client then slept forever on reconnect. Days arithmetic also overflowed i32 beyond ~68 years, and the reverse conversion stuffed everything into the seconds argument so such values could not read back. Conversion is now fallible, rejects negative input with ValueError at construction, computes in i64, and splits days on the way out. The AutoCommit conversion becomes TryFrom to carry the error. The boolean constructor defaults were literals in the pyo3 signature, so a change to a Rust default would silently not propagate. They are now Option arguments that fall back to TcpClientConfig::default(), the same way the durations already did. --- foreign/python/apache_iggy.pyi | 22 ++++++---- foreign/python/src/client.rs | 16 +++---- foreign/python/src/config.rs | 79 +++++++++++++++++++--------------- foreign/python/src/consumer.rs | 19 ++++---- foreign/python/src/duration.rs | 19 +++++--- 5 files changed, 90 insertions(+), 65 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 3befd0deb7..c503a35f78 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -996,11 +996,11 @@ class TcpConfig: auto_login: AutoLogin | None = None, reconnection: TcpReconnectionConfig | None = None, heartbeat_interval: datetime.timedelta | None = None, - tls_enabled: builtins.bool = False, + tls_enabled: builtins.bool | None = None, tls_domain: builtins.str | None = None, tls_ca_file: builtins.str | None = None, - tls_validate_certificate: builtins.bool = True, - nodelay: builtins.bool = False, + tls_validate_certificate: builtins.bool | None = None, + nodelay: builtins.bool | None = None, ) -> TcpConfig: r""" Constructs a TCP configuration, defaulting every unset field to the value @@ -1011,15 +1011,18 @@ class TcpConfig: auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. reconnection: Reconnection policy. Defaults to `TcpReconnectionConfig()`. heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. - tls_enabled: Whether to connect over TLS. + tls_enabled: Whether to connect over TLS. Defaults to disabled. tls_domain: Domain to validate the certificate against. Empty means it is taken from `server_address`. tls_ca_file: Path to the CA file for TLS. tls_validate_certificate: Whether to validate the server certificate. - nodelay: Disable the Nagle algorithm for the TCP socket. + Defaults to validating. + nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to + leaving it on. Raises: - PyValueError: If `server_address` is not a valid `host:port` pair. + PyValueError: If `server_address` is not a valid `host:port` pair, or + if a duration is negative. """ def __repr__(self) -> builtins.str: ... @@ -1039,7 +1042,7 @@ class TcpReconnectionConfig: def __new__( cls, *, - enabled: builtins.bool = True, + enabled: builtins.bool | None = None, max_retries: builtins.int | None = None, interval: datetime.timedelta | None = None, reestablish_after: datetime.timedelta | None = None, @@ -1049,11 +1052,14 @@ class TcpReconnectionConfig: value the Rust SDK uses. Args: - enabled: Whether to reconnect at all. + enabled: Whether to reconnect at all. Defaults to enabled. max_retries: Attempts before giving up, or `None` for unlimited. interval: Delay between attempts. Defaults to 1 second. reestablish_after: Cooldown before reconnecting after a previously successful connection. Defaults to 5 seconds. + + Raises: + PyValueError: If a duration is negative. """ def __repr__(self) -> builtins.str: ... diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 9aeb8c4ec6..c7dc46974a 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -17,8 +17,8 @@ use bytes::Bytes; use iggy::prelude::{ - Consumer as RustConsumer, IggyClient as RustIggyClient, IggyMessage as RustMessage, - PollingStrategy as RustPollingStrategy, *, + AutoCommit as RustAutoCommit, Consumer as RustConsumer, IggyClient as RustIggyClient, + IggyMessage as RustMessage, PollingStrategy as RustPollingStrategy, *, }; use pyo3::PyRef; use pyo3::prelude::*; @@ -369,7 +369,7 @@ impl IggyClient { }; let expiry = match message_expiry { - Some(delta) => IggyExpiry::ExpireDuration(py_delta_to_iggy_duration(&delta)), + Some(delta) => IggyExpiry::ExpireDuration(py_delta_to_iggy_duration(&delta)?), None => IggyExpiry::ServerDefault, }; @@ -492,7 +492,7 @@ impl IggyClient { }; let expiry = match message_expiry { - Some(delta) => IggyExpiry::ExpireDuration(py_delta_to_iggy_duration(&delta)), + Some(delta) => IggyExpiry::ExpireDuration(py_delta_to_iggy_duration(&delta)?), None => IggyExpiry::ServerDefault, }; @@ -951,16 +951,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::( @@ -976,7 +976,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/config.rs b/foreign/python/src/config.rs index 7bb1d651a5..209ed6bb58 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -124,34 +124,41 @@ impl TcpReconnectionConfig { /// value the Rust SDK uses. /// /// Args: - /// enabled: Whether to reconnect at all. + /// enabled: Whether to reconnect at all. Defaults to enabled. /// max_retries: Attempts before giving up, or `None` for unlimited. /// interval: Delay between attempts. Defaults to 1 second. /// reestablish_after: Cooldown before reconnecting after a previously /// successful connection. Defaults to 5 seconds. + /// + /// Raises: + /// PyValueError: If a duration is negative. #[new] - #[pyo3(signature = (*, enabled=true, max_retries=None, interval=None, reestablish_after=None))] + #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] fn new( - enabled: bool, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option, #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option, #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] interval: Option>, #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] reestablish_after: Option>, - ) -> Self { + ) -> PyResult { let defaults = RustTcpClientReconnectionConfig::default(); - Self { + Ok(Self { inner: RustTcpClientReconnectionConfig { - enabled, + enabled: enabled.unwrap_or(defaults.enabled), max_retries, interval: interval .as_ref() - .map_or(defaults.interval, py_delta_to_iggy_duration), + .map(py_delta_to_iggy_duration) + .transpose()? + .unwrap_or(defaults.interval), reestablish_after: reestablish_after .as_ref() - .map_or(defaults.reestablish_after, py_delta_to_iggy_duration), + .map(py_delta_to_iggy_duration) + .transpose()? + .unwrap_or(defaults.reestablish_after), }, - } + }) } #[getter] @@ -222,15 +229,18 @@ impl TcpConfig { /// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. /// reconnection: Reconnection policy. Defaults to `TcpReconnectionConfig()`. /// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. - /// tls_enabled: Whether to connect over TLS. + /// tls_enabled: Whether to connect over TLS. Defaults to disabled. /// tls_domain: Domain to validate the certificate against. Empty means it is /// taken from `server_address`. /// tls_ca_file: Path to the CA file for TLS. /// tls_validate_certificate: Whether to validate the server certificate. - /// nodelay: Disable the Nagle algorithm for the TCP socket. + /// Defaults to validating. + /// nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to + /// leaving it on. /// /// Raises: - /// PyValueError: If `server_address` is not a valid `host:port` pair. + /// PyValueError: If `server_address` is not a valid `host:port` pair, or + /// if a duration is negative. #[new] #[pyo3(signature = ( *, @@ -238,11 +248,11 @@ impl TcpConfig { auto_login=None, reconnection=None, heartbeat_interval=None, - tls_enabled=false, + tls_enabled=None, tls_domain=None, tls_ca_file=None, - tls_validate_certificate=true, - nodelay=false, + tls_validate_certificate=None, + nodelay=None, ))] #[allow(clippy::too_many_arguments)] fn new( @@ -255,37 +265,38 @@ impl TcpConfig { >, #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] heartbeat_interval: Option>, - tls_enabled: bool, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] tls_enabled: Option, #[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_domain: Option, #[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_ca_file: Option, - tls_validate_certificate: bool, - nodelay: bool, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] + tls_validate_certificate: Option, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] nodelay: Option, ) -> PyResult { let defaults = RustTcpClientConfig::default(); let auto_login = auto_login.unwrap_or_default(); let reconnection = reconnection.unwrap_or_default(); - let mut builder = TcpClientConfigBuilder::new() + // The builder is only used to validate and trim the server address; the + // remaining fields are assigned directly so every unset argument falls + // back to the Rust `TcpClientConfig::default()` value instead of a + // literal duplicated here. + let mut inner = TcpClientConfigBuilder::new() .with_server_address(server_address.unwrap_or(defaults.server_address)) - .with_auto_sign_in(auto_login.inner.clone()) - .with_tls_enabled(tls_enabled) - .with_tls_domain(tls_domain.unwrap_or(defaults.tls_domain)) - .with_tls_validate_certificate(tls_validate_certificate); - if let Some(tls_ca_file) = tls_ca_file { - builder = builder.with_tls_ca_file(tls_ca_file); - } - if nodelay { - builder = builder.with_no_delay(); - } - - let mut inner = builder .build() .map_err(|e| PyErr::new::(e.to_string()))?; - // TcpClientConfigBuilder exposes no setter for either of these. + inner.auto_login = auto_login.inner.clone(); + inner.reconnection = reconnection.inner.clone(); inner.heartbeat_interval = heartbeat_interval .as_ref() - .map_or(defaults.heartbeat_interval, py_delta_to_iggy_duration); - inner.reconnection = reconnection.inner.clone(); + .map(py_delta_to_iggy_duration) + .transpose()? + .unwrap_or(defaults.heartbeat_interval); + inner.tls_enabled = tls_enabled.unwrap_or(defaults.tls_enabled); + inner.tls_domain = tls_domain.unwrap_or(defaults.tls_domain); + inner.tls_ca_file = tls_ca_file.or(defaults.tls_ca_file); + inner.tls_validate_certificate = + tls_validate_certificate.unwrap_or(defaults.tls_validate_certificate); + inner.nodelay = nodelay.unwrap_or(defaults.nodelay); Ok(Self { auto_login, diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs index 41d849c79f..fb95b1d0bf 100644 --- a/foreign/python/src/consumer.rs +++ b/foreign/python/src/consumer.rs @@ -429,25 +429,24 @@ 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 { + Ok(match val { AutoCommit::Disabled() => RustAutoCommit::Disabled, AutoCommit::Interval(delta) => { - let duration = py_delta_to_iggy_duration(delta); - RustAutoCommit::Interval(duration) + RustAutoCommit::Interval(py_delta_to_iggy_duration(delta)?) } AutoCommit::IntervalOrWhen(delta, when) => { - let duration = py_delta_to_iggy_duration(delta); - RustAutoCommit::IntervalOrWhen(duration, when.into()) + RustAutoCommit::IntervalOrWhen(py_delta_to_iggy_duration(delta)?, when.into()) } AutoCommit::IntervalOrAfter(delta, after) => { - let duration = py_delta_to_iggy_duration(delta); - RustAutoCommit::IntervalOrAfter(duration, after.into()) + RustAutoCommit::IntervalOrAfter(py_delta_to_iggy_duration(delta)?, after.into()) } AutoCommit::When(when) => RustAutoCommit::When(when.into()), AutoCommit::After(after) => RustAutoCommit::After(after.into()), - } + }) } } diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs index d0a807cfb5..03126d0676 100644 --- a/foreign/python/src/duration.rs +++ b/foreign/python/src/duration.rs @@ -20,12 +20,19 @@ use pyo3::prelude::*; use pyo3::types::{PyDelta, PyDeltaAccess}; use std::time::Duration; -pub fn py_delta_to_iggy_duration(delta: &Py) -> IggyDuration { +pub fn py_delta_to_iggy_duration(delta: &Py) -> PyResult { Python::attach(|py| { let delta = delta.bind(py); - let seconds = (delta.get_days() * 60 * 60 * 24 + delta.get_seconds()) as u64; + // Python normalizes a negative timedelta to negative days plus + // non-negative seconds/microseconds, so the sign lives in the sum. + let seconds = i64::from(delta.get_days()) * 60 * 60 * 24 + i64::from(delta.get_seconds()); + if seconds < 0 { + return Err(PyErr::new::( + "duration must not be negative", + )); + } let nanos = (delta.get_microseconds() * 1_000) as u32; - IggyDuration::new(Duration::new(seconds, nanos)) + Ok(IggyDuration::new(Duration::new(seconds as u64, nanos))) }) } @@ -34,10 +41,12 @@ pub fn iggy_duration_to_py_delta( duration: IggyDuration, ) -> PyResult> { let micros = duration.as_micros(); - let seconds = i32::try_from(micros / 1_000_000).map_err(|_| { + let total_seconds = micros / 1_000_000; + let days = i32::try_from(total_seconds / 86_400).map_err(|_| { PyErr::new::( "duration does not fit into a datetime.timedelta", ) })?; - PyDelta::new(py, 0, seconds, (micros % 1_000_000) as i32, true) + let seconds = (total_seconds % 86_400) as i32; + PyDelta::new(py, days, seconds, (micros % 1_000_000) as i32, true) } From a82351c10b17e2cc3e98f1b666c5c5e02cabbf61 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:57:15 +0800 Subject: [PATCH 06/20] test(python): make the unit marker selectable and pin duration edges conftest auto-marked every module as integration, so tests explicitly marked unit could not be selected with -m "not integration" even though they need no server. The auto-mark now skips them. New cases pin the duration boundaries (negative rejected, zero legal, beyond the i32 seconds range round-trips) and the README claim that a connection string and TcpConfig reach the same behavior. --- foreign/python/tests/conftest.py | 5 ++ foreign/python/tests/test_client_config.py | 69 ++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/foreign/python/tests/conftest.py b/foreign/python/tests/conftest.py index aa54ff50d8..3ab97065f5 100644 --- a/foreign/python/tests/conftest.py +++ b/foreign/python/tests/conftest.py @@ -131,5 +131,10 @@ def pytest_collection_modifyitems(items): path.name for path in Path(__file__).parent.glob("test_*.py") } for item in items: + # Tests explicitly marked as unit need no server; auto-marking them + # integration too would make `-m "not integration"` unable to select + # them. + if item.get_closest_marker("unit"): + continue if any(module in item.nodeid for module in integration_modules): item.add_marker(pytest.mark.integration) diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 18c86f37c3..50a0f7a882 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -25,6 +25,7 @@ replayed on connect and no manual `login_user()` is needed. """ +from collections.abc import Callable from datetime import timedelta import pytest @@ -34,6 +35,7 @@ from .utils import get_server_config, wait_for_ping, wait_for_server +@pytest.mark.unit class TestAutoLogin: """Test the credentials carried into the client.""" @@ -61,6 +63,7 @@ def test_personal_access_token_hides_the_token(self): assert "secret-token" not in repr(auto_login) +@pytest.mark.unit class TestTcpReconnectionConfig: """Test the reconnection policy.""" @@ -93,7 +96,41 @@ def test_arguments_are_keyword_only(self): # pyrefly: ignore # bad-argument-count TcpReconnectionConfig(True) + @pytest.mark.parametrize( + "construct", + [ + lambda duration: TcpReconnectionConfig(interval=duration), + lambda duration: TcpReconnectionConfig(reestablish_after=duration), + ], + ids=["interval", "reestablish_after"], + ) + @pytest.mark.parametrize( + "negative", + [timedelta(microseconds=-1), timedelta(seconds=-1), timedelta(days=-1)], + ) + def test_negative_duration_is_rejected( + self, + construct: Callable[[timedelta], TcpReconnectionConfig], + negative: timedelta, + ): + """Test that a negative duration fails at construction, not at connect.""" + with pytest.raises(ValueError, match="negative"): + construct(negative) + + def test_zero_interval_is_allowed(self): + """Test that a zero interval is legal and readable back.""" + reconnection = TcpReconnectionConfig(interval=timedelta(0)) + + assert reconnection.interval == timedelta(0) + def test_very_long_interval_round_trips(self): + """Test that an interval beyond 68 years survives the i32 boundary.""" + reconnection = TcpReconnectionConfig(interval=timedelta(days=30_000)) + + assert reconnection.interval == timedelta(days=30_000) + + +@pytest.mark.unit class TestTcpConfig: """Test the transport configuration.""" @@ -156,7 +193,13 @@ def test_invalid_server_address_is_rejected(self, invalid_address: str): with pytest.raises(ValueError): TcpConfig(server_address=invalid_address) + def test_negative_heartbeat_interval_is_rejected(self): + """Test that a negative heartbeat interval fails at construction.""" + with pytest.raises(ValueError, match="negative"): + TcpConfig(heartbeat_interval=timedelta(seconds=-3)) + +@pytest.mark.unit class TestClientConstruction: """Test what the client constructor accepts.""" @@ -216,6 +259,32 @@ async def test_without_auto_login_a_privileged_call_is_unauthenticated( with pytest.raises(RuntimeError): await client.create_stream(unique_name()) + @pytest.mark.asyncio + async def test_config_and_connection_string_are_equivalent(self, unique_name): + """Test that TcpConfig and a connection string reach the same behavior.""" + host, port = get_server_config() + wait_for_server(host, port) + + from_config = IggyClient( + TcpConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "iggy"), + reconnection=TcpReconnectionConfig( + max_retries=3, interval=timedelta(seconds=1) + ), + ) + ) + from_string = IggyClient.from_connection_string( + f"iggy+tcp://iggy:iggy@{host}:{port}" + "?reconnection_retries=3&reconnection_interval=1s" + ) + + stream_name = unique_name() + for client in (from_config, from_string): + await client.connect() + await wait_for_ping(client) + assert await client.get_stream(stream_name) is None + @pytest.mark.asyncio async def test_wrong_auto_login_credentials_fail(self): """Test that bad configured credentials surface as a connect failure.""" From b3190f4a1a7e499707201edeb31d8b6882115499 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Tue, 28 Jul 2026 21:57:15 +0800 Subject: [PATCH 07/20] docs(python): make the README configuration snippet runnable The snippet ended with a top-level await; every other sample in the repo wraps in asyncio.run, so paste-and-run failed on the only snippet a PyPI reader sees first. --- foreign/python/README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/foreign/python/README.md b/foreign/python/README.md index 898ef9d5dd..74149f9fa3 100644 --- a/foreign/python/README.md +++ b/foreign/python/README.md @@ -65,24 +65,30 @@ lets the SDK replay the credentials whenever it reconnects, so a session dropped server restart is recovered instead of surfacing as `Unauthenticated`: ```python +import asyncio from datetime import timedelta from apache_iggy import AutoLogin, IggyClient, TcpConfig, TcpReconnectionConfig -client = IggyClient( - TcpConfig( - server_address="127.0.0.1:8090", - auto_login=AutoLogin.username_password("iggy", "iggy"), - reconnection=TcpReconnectionConfig( - enabled=True, - max_retries=10, - interval=timedelta(seconds=2), - reestablish_after=timedelta(seconds=30), - ), - heartbeat_interval=timedelta(seconds=5), + +async def main(): + client = IggyClient( + TcpConfig( + server_address="127.0.0.1:8090", + auto_login=AutoLogin.username_password("iggy", "iggy"), + reconnection=TcpReconnectionConfig( + enabled=True, + max_retries=10, + interval=timedelta(seconds=2), + reestablish_after=timedelta(seconds=30), + ), + heartbeat_interval=timedelta(seconds=5), + ) ) -) -await client.connect() + await client.connect() + + +asyncio.run(main()) ``` `TcpConfig` also carries `tls_enabled`, `tls_domain`, `tls_ca_file`, From 4fb0ff33a764dc5465005db88f6f305ddba43779 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 30 Jul 2026 23:47:09 +0800 Subject: [PATCH 08/20] docs(python): fold the configuration example into getting-started A separate example for the new config is not needed. The getting-started producer and consumer now build a TcpConfig with auto-login and reconnection instead of a connection string. --- examples/python/README.md | 14 -- examples/python/client-configuration/main.py | 142 ------------------- examples/python/getting-started/consumer.py | 48 ++++--- examples/python/getting-started/producer.py | 46 +++--- 4 files changed, 51 insertions(+), 199 deletions(-) delete mode 100644 examples/python/client-configuration/main.py diff --git a/examples/python/README.md b/examples/python/README.md index afe255b293..9bf943b75c 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -71,20 +71,6 @@ python basic/consumer.py Demonstrates fundamental client connection, authentication, batch message sending, and polling with support for TCP/QUIC/HTTP protocols. -### Client Configuration - -Auto-login and reconnection, configured explicitly rather than through a connection string: - -```bash -# Using uv -uv run client-configuration/main.py - -# Without using uv -python client-configuration/main.py -``` - -Demonstrates `TcpConfig`, `TcpReconnectionConfig` and `AutoLogin`. Because the credentials are replayed on every connect, the client recovers its session after the server restarts instead of failing with `Unauthenticated`. - ## TLS Examples To test with a TLS-enabled server, start the server with TLS configured (see main README), then run: diff --git a/examples/python/client-configuration/main.py b/examples/python/client-configuration/main.py deleted file mode 100644 index 22f07762a4..0000000000 --- a/examples/python/client-configuration/main.py +++ /dev/null @@ -1,142 +0,0 @@ -# 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. - -""" -Configures the TCP client explicitly instead of passing a bare address. - -The point of `auto_login` is that the credentials are replayed every time the -client connects, including after a reconnect. That is what lets the SDK -recover a session the server dropped: without it, a restart of the server -surfaces as `Unauthenticated` on the next call and the application has to -reconnect and log in by hand. - -Run this, then restart the server while it is polling: the client reconnects, -replays the login and keeps going. -""" - -import argparse -import asyncio -import typing -from datetime import timedelta - -from apache_iggy import ( - AutoLogin, - IggyClient, - PollingStrategy, - StreamDetails, - TcpConfig, - TcpReconnectionConfig, - TopicDetails, -) -from apache_iggy import SendMessage as Message -from loguru import logger - -STREAM_NAME = "configured-stream" -TOPIC_NAME = "configured-topic" -PARTITION_ID = 0 -BATCHES_LIMIT = 5 - - -class ArgNamespace(typing.NamedTuple): - tcp_server_address: str - username: str - password: str - - -def parse_args() -> ArgNamespace: - parser = argparse.ArgumentParser() - parser.add_argument( - "--tcp-server-address", - default="127.0.0.1:8090", - help="Iggy TCP server address (host:port)", - ) - parser.add_argument("--username", default="iggy", help="Username to log in with") - parser.add_argument("--password", default="iggy", help="Password to log in with") - return ArgNamespace(**vars(parser.parse_args())) - - -def build_config(args: ArgNamespace) -> TcpConfig: - return TcpConfig( - server_address=args.tcp_server_address, - auto_login=AutoLogin.username_password(args.username, args.password), - reconnection=TcpReconnectionConfig( - enabled=True, - max_retries=None, # retry forever - interval=timedelta(seconds=1), - reestablish_after=timedelta(seconds=5), - ), - heartbeat_interval=timedelta(seconds=5), - nodelay=True, - ) - - -async def main(): - args = parse_args() - config = build_config(args) - logger.info(f"Connecting with {config}") - - client = IggyClient(config) - # No login_user() call: auto_login replays the credentials on every connect. - await client.connect() - logger.info("Connected and authenticated.") - - await init_system(client) - await produce_and_consume(client) - - -async def init_system(client: IggyClient): - stream: StreamDetails | None = await client.get_stream(STREAM_NAME) - if stream is None: - await client.create_stream(name=STREAM_NAME) - logger.info(f"Created stream {STREAM_NAME}.") - - topic: TopicDetails | None = await client.get_topic(STREAM_NAME, TOPIC_NAME) - if topic is None: - await client.create_topic( - stream=STREAM_NAME, - name=TOPIC_NAME, - partitions_count=1, - replication_factor=1, - ) - logger.info(f"Created topic {TOPIC_NAME}.") - - -async def produce_and_consume(client: IggyClient): - for batch in range(BATCHES_LIMIT): - messages = [Message(f"message-{batch}-{i}") for i in range(10)] - await client.send_messages( - stream=STREAM_NAME, - topic=TOPIC_NAME, - partitioning=PARTITION_ID, - messages=messages, - ) - logger.info(f"Sent batch {batch}.") - - polled = await client.poll_messages( - stream=STREAM_NAME, - topic=TOPIC_NAME, - partition_id=PARTITION_ID, - polling_strategy=PollingStrategy.Next(), - count=len(messages), - auto_commit=True, - ) - logger.info(f"Polled {len(polled)} messages.") - await asyncio.sleep(0.5) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/python/getting-started/consumer.py b/examples/python/getting-started/consumer.py index bb10e91382..12c2c7671b 100755 --- a/examples/python/getting-started/consumer.py +++ b/examples/python/getting-started/consumer.py @@ -19,8 +19,16 @@ import asyncio import typing import urllib.parse - -from apache_iggy import IggyClient, PollingStrategy, ReceiveMessage +from datetime import timedelta + +from apache_iggy import ( + AutoLogin, + IggyClient, + PollingStrategy, + ReceiveMessage, + TcpConfig, + TcpReconnectionConfig, +) from loguru import logger STREAM_NAME = "sample-stream" @@ -91,34 +99,30 @@ def parse_args() -> ArgNamespace: return ArgNamespace(**vars(args)) -def build_connection_string(args) -> str: - """Build a connection string with TLS support.""" - - conn_str = f"iggy://{args.username}:{args.password}@{args.tcp_server_address}" - - if args.tls: - # Extract domain from server address (host:port -> host) - host = args.tcp_server_address.split(":")[0] - query_params = ["tls=true", f"tls_domain={host}"] +def build_config(args: ArgNamespace) -> TcpConfig: + """Build a TCP client configuration with auto-login and reconnection.""" - # Add CA file if provided - if args.tls_ca_file: - query_params.append(f"tls_ca_file={args.tls_ca_file}") - conn_str += "?" + "&".join(query_params) - - return conn_str + return TcpConfig( + server_address=args.tcp_server_address, + auto_login=AutoLogin.username_password(args.username, args.password), + reconnection=TcpReconnectionConfig( + enabled=True, + interval=timedelta(seconds=1), + ), + tls_enabled=args.tls, + tls_ca_file=args.tls_ca_file or None, + ) async def main(): args: ArgNamespace = parse_args() + config = build_config(args) + logger.info(f"Connecting to {args.tcp_server_address} (TLS: {args.tls})") - # Build connection string with TLS support - connection_string = build_connection_string(args) - logger.info(f"Connection string: {connection_string}") - - client = IggyClient.from_connection_string(connection_string) + client = IggyClient(config) try: logger.info("Connecting to IggyClient...") + # No login_user() call: auto_login replays the credentials on every connect. await client.connect() logger.info("Connected.") await consume_messages(client) diff --git a/examples/python/getting-started/producer.py b/examples/python/getting-started/producer.py index c05c0317cd..b3d8441b8f 100755 --- a/examples/python/getting-started/producer.py +++ b/examples/python/getting-started/producer.py @@ -19,8 +19,16 @@ import asyncio import typing import urllib.parse - -from apache_iggy import IggyClient, StreamDetails, TopicDetails +from datetime import timedelta + +from apache_iggy import ( + AutoLogin, + IggyClient, + StreamDetails, + TcpConfig, + TcpReconnectionConfig, + TopicDetails, +) from apache_iggy import SendMessage as Message from loguru import logger @@ -92,33 +100,29 @@ def parse_args() -> ArgNamespace: return ArgNamespace(**vars(args)) -def build_connection_string(args) -> str: - """Build a connection string with TLS support.""" - - conn_str = f"iggy://{args.username}:{args.password}@{args.tcp_server_address}" - - if args.tls: - # Extract domain from server address (host:port -> host) - host = args.tcp_server_address.split(":")[0] - query_params = ["tls=true", f"tls_domain={host}"] +def build_config(args: ArgNamespace) -> TcpConfig: + """Build a TCP client configuration with auto-login and reconnection.""" - # Add CA file if provided - if args.tls_ca_file: - query_params.append(f"tls_ca_file={args.tls_ca_file}") - conn_str += "?" + "&".join(query_params) - - return conn_str + return TcpConfig( + server_address=args.tcp_server_address, + auto_login=AutoLogin.username_password(args.username, args.password), + reconnection=TcpReconnectionConfig( + enabled=True, + interval=timedelta(seconds=1), + ), + tls_enabled=args.tls, + tls_ca_file=args.tls_ca_file or None, + ) async def main(): args: ArgNamespace = parse_args() - # Build connection string with TLS support - connection_string = build_connection_string(args) - logger.info(f"Connection string: {connection_string}") + config = build_config(args) logger.info(f"Connecting to {args.tcp_server_address} (TLS: {args.tls})") - client = IggyClient.from_connection_string(connection_string) + client = IggyClient(config) logger.info("Connecting to IggyClient") + # No login_user() call: auto_login replays the credentials on every connect. await client.connect() logger.info("Connected.") await init_system(client) From 12da3bc0d3773ed21c2b219768b1538393ec24a8 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 30 Jul 2026 23:47:56 +0800 Subject: [PATCH 09/20] docs(python): show the optional TcpConfig fields inline in the README The TLS and nodelay options appear as commented-out fields in the snippet instead of prose, and the auto_login and from_connection_string notes are dropped. --- foreign/python/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/foreign/python/README.md b/foreign/python/README.md index 74149f9fa3..e2cc9a53ab 100644 --- a/foreign/python/README.md +++ b/foreign/python/README.md @@ -60,9 +60,7 @@ pytest tests/ -v # Run tests (requires iggy-server running) ## Client Configuration -`IggyClient` takes either a server address or a `TcpConfig`. Configuring `auto_login` -lets the SDK replay the credentials whenever it reconnects, so a session dropped by a -server restart is recovered instead of surfacing as `Unauthenticated`: +`IggyClient` takes either a server address or a `TcpConfig`: ```python import asyncio @@ -83,6 +81,11 @@ async def main(): reestablish_after=timedelta(seconds=30), ), heartbeat_interval=timedelta(seconds=5), + # tls_enabled=True, + # tls_domain="localhost", + # tls_ca_file="core/certs/iggy_ca_cert.pem", + # tls_validate_certificate=True, + # nodelay=True, ) ) await client.connect() @@ -91,11 +94,6 @@ async def main(): asyncio.run(main()) ``` -`TcpConfig` also carries `tls_enabled`, `tls_domain`, `tls_ca_file`, -`tls_validate_certificate` and `nodelay`. Every field is keyword-only and defaults to the -same value the Rust SDK uses. `IggyClient.from_connection_string(...)` remains available -for the same settings in string form. - ## Examples Refer to the [examples/python/](https://github.com/apache/iggy/tree/master/examples/python) directory for usage examples. From 1e5561dbeb823c9f6cd482a2530968b6726de2c8 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 30 Jul 2026 23:48:02 +0800 Subject: [PATCH 10/20] docs(python): name Python exceptions and drop Rust types from docs Python users see ValueError and RuntimeError rather than the PyO3 exception names, and the wrapped Rust types are an implementation detail. --- foreign/python/apache_iggy.pyi | 103 ++++++++++++-------------- foreign/python/src/client.rs | 75 +++++++++---------- foreign/python/src/config.rs | 13 ++-- foreign/python/src/consumer.rs | 11 ++- foreign/python/src/receive_message.rs | 2 +- foreign/python/src/send_message.rs | 2 - 6 files changed, 96 insertions(+), 110 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index c503a35f78..9c422fdd64 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -349,8 +349,7 @@ class ConsumerGroupMember: class IggyClient: r""" A Python class representing the Iggy client. - It wraps the RustIggyClient and provides asynchronous functionality - through the contained runtime. + It provides asynchronous functionality through the contained runtime. """ def __new__(cls, conn: TcpConfig | builtins.str | None = None) -> IggyClient: r""" @@ -364,7 +363,7 @@ class IggyClient: disabled. Raises: - PyRuntimeError: If the address is not a valid `host:port` pair, or if the + RuntimeError: If the address is not a valid `host:port` pair, or if the client cannot be built. """ @classmethod @@ -376,7 +375,7 @@ class IggyClient: def ping(self) -> collections.abc.Awaitable[None]: r""" Sends a ping request to the server to check connectivity. - Returns `Ok(())` if the server responds successfully, or a `PyRuntimeError` + Returns `Ok(())` if the server responds successfully, or a `RuntimeError` if the connection fails. """ def login_user( @@ -384,7 +383,7 @@ class IggyClient: ) -> collections.abc.Awaitable[None]: r""" Logs in the user with the given credentials. - Returns `Ok(())` on success, or a PyRuntimeError on failure. + Returns `Ok(())` on success, or a RuntimeError on failure. """ def get_user( self, user_id: builtins.str | builtins.int @@ -400,8 +399,8 @@ class IggyClient: or `None` otherwise. Raises: - PyValueError: If a string identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If a string identifier is invalid. + RuntimeError: If the request fails. """ def get_users(self) -> collections.abc.Awaitable[list[UserInfo]]: r""" @@ -411,7 +410,7 @@ class IggyClient: An awaitable that resolves to `list[UserInfo]`. Raises: - PyRuntimeError: If the request fails. + RuntimeError: If the request fails. """ def create_user( self, @@ -433,7 +432,7 @@ class IggyClient: An awaitable that resolves to the created `UserInfoDetails`. Raises: - PyRuntimeError: If an argument is invalid or the request fails. + RuntimeError: If an argument is invalid or the request fails. """ def update_user( self, @@ -453,8 +452,8 @@ class IggyClient: An awaitable that resolves to `None` when the user is updated. Raises: - PyValueError: If a string identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If a string identifier is invalid. + RuntimeError: If the request fails. """ def delete_user( self, user_id: builtins.str | builtins.int @@ -469,25 +468,25 @@ class IggyClient: An awaitable that resolves to `None` when the user is deleted. Raises: - PyValueError: If a string identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If a string identifier is invalid. + RuntimeError: If the request fails. """ def connect(self) -> collections.abc.Awaitable[None]: r""" Connects the IggyClient to its service. - Returns Ok(()) on successful connection or a PyRuntimeError on failure. + Returns Ok(()) on successful connection or a RuntimeError on failure. """ def create_stream(self, name: builtins.str) -> collections.abc.Awaitable[None]: r""" Creates a new stream with the provided ID and name. - Returns Ok(()) on successful stream creation or a PyRuntimeError on failure. + Returns Ok(()) on successful stream creation or a RuntimeError on failure. """ def get_stream( self, stream_id: builtins.str | builtins.int ) -> collections.abc.Awaitable[StreamDetails | None]: r""" Gets stream by id. - Returns Option of stream details or a PyRuntimeError on failure. + Returns Option of stream details or a RuntimeError on failure. """ def create_topic( self, @@ -501,7 +500,7 @@ class IggyClient: ) -> collections.abc.Awaitable[None]: r""" Creates a new topic with the given parameters. - Returns Ok(()) on successful topic creation or a PyRuntimeError on failure. + Returns Ok(()) on successful topic creation or a RuntimeError on failure. """ def get_topic( self, @@ -510,7 +509,7 @@ class IggyClient: ) -> collections.abc.Awaitable[TopicDetails | None]: r""" Gets topic by stream and id. - Returns Option of topic details or a PyRuntimeError on failure. + Returns Option of topic details or a RuntimeError on failure. """ def get_topics( self, stream_id: builtins.str | builtins.int @@ -525,7 +524,7 @@ class IggyClient: An awaitable that resolves to `list[Topic]`. Raises: - PyRuntimeError: If the identifier is invalid or the request fails. + RuntimeError: If the identifier is invalid or the request fails. """ def update_topic( self, @@ -556,7 +555,7 @@ class IggyClient: An awaitable that resolves to `None` when the topic is updated. Raises: - PyRuntimeError: If an argument is invalid or the request fails. + RuntimeError: If an argument is invalid or the request fails. """ def delete_topic( self, @@ -574,7 +573,7 @@ class IggyClient: An awaitable that resolves to `None` when the topic is deleted. Raises: - PyRuntimeError: If an identifier is invalid or the request fails. + RuntimeError: If an identifier is invalid or the request fails. """ def purge_topic( self, @@ -592,7 +591,7 @@ class IggyClient: An awaitable that resolves to `None` when the topic is purged. Raises: - PyRuntimeError: If an identifier is invalid or the request fails. + RuntimeError: If an identifier is invalid or the request fails. """ def create_consumer_group( self, @@ -612,8 +611,8 @@ class IggyClient: An awaitable that resolves to `None` when the consumer group is created. Raises: - PyValueError: If an identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If an identifier is invalid. + RuntimeError: If the request fails. """ def get_consumer_group( self, @@ -634,8 +633,8 @@ class IggyClient: or `None` otherwise. Raises: - PyValueError: If an identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If an identifier is invalid. + RuntimeError: If the request fails. """ def get_consumer_groups( self, @@ -653,8 +652,8 @@ class IggyClient: An awaitable that resolves to `list[ConsumerGroup]`. Raises: - PyValueError: If an identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If an identifier is invalid. + RuntimeError: If the request fails. """ def delete_consumer_group( self, @@ -674,8 +673,8 @@ class IggyClient: An awaitable that resolves to `None` when the consumer group is deleted. Raises: - PyValueError: If a string identifier is invalid. - PyRuntimeError: If the request fails. + ValueError: If a string identifier is invalid. + RuntimeError: If the request fails. """ def join_consumer_group( self, @@ -698,8 +697,8 @@ class IggyClient: An awaitable that resolves to `None` when the client joins the consumer group. Raises: - PyValueError: If a string identifier is invalid. - PyRuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. + ValueError: If a string identifier is invalid. + RuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. """ def leave_consumer_group( self, @@ -724,8 +723,8 @@ class IggyClient: rejoin on their next poll. Raises: - PyValueError: If a string identifier is invalid. - PyRuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. + ValueError: If a string identifier is invalid. + RuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. """ def send_messages( self, @@ -736,7 +735,7 @@ class IggyClient: ) -> collections.abc.Awaitable[None]: r""" Sends a list of messages to the specified topic. - Returns Ok(()) on successful sending or a PyRuntimeError on failure. + Returns Ok(()) on successful sending or a RuntimeError on failure. """ def poll_messages( self, @@ -749,7 +748,7 @@ class IggyClient: ) -> collections.abc.Awaitable[list[ReceiveMessage]]: r""" Polls for messages from the specified topic and partition. - Returns a list of received messages or a PyRuntimeError on failure. + Returns a list of received messages or a RuntimeError on failure. """ def consumer_group( self, @@ -770,7 +769,7 @@ class IggyClient: ) -> collections.abc.Awaitable[IggyConsumer]: r""" Creates a new consumer group consumer. - Returns the consumer or a PyRuntimeError on failure. + Returns the consumer or a RuntimeError on failure. """ def send_binary_request( self, code: builtins.int, payload: builtins.bytes @@ -789,15 +788,14 @@ class IggyClient: An awaitable that resolves to the raw response `bytes`. Raises: - PyRuntimeError: If the command cannot be sent or the server returns an error. + RuntimeError: If the command cannot be sent or the server returns an error. """ @typing.final class IggyConsumer: r""" A Python class representing the Iggy consumer. - It wraps the RustIggyConsumer and provides asynchronous functionality - through the contained runtime. + It provides asynchronous functionality through the contained runtime. """ def get_last_consumed_offset( self, partition_id: builtins.int @@ -831,7 +829,7 @@ class IggyConsumer: r""" Stores the provided offset for the provided partition id or if none is specified uses the current partition id for the consumer group. - Returns `Ok(())` if the server responds successfully, or a `PyRuntimeError` + Returns `Ok(())` if the server responds successfully, or a `RuntimeError` if the operation fails. """ def delete_offset( @@ -840,14 +838,14 @@ class IggyConsumer: r""" Deletes the offset for the provided partition id or if none is specified uses the current partition id for the consumer group. - Returns `Ok(())` if the server responds successfully, or a `PyRuntimeError` + Returns `Ok(())` if the server responds successfully, or a `RuntimeError` if the operation fails. """ def iter_messages(self) -> collections.abc.AsyncIterator[ReceiveMessage]: r""" Asynchronously iterate over `ReceiveMessage`s. Returns an async iterator that raises `StopAsyncIteration` when no more messages are available - or a `PyRuntimeError` on failure. + or a `RuntimeError` on failure. Note: This method does not currently support `AutoCommit.After`. For `AutoCommit.IntervalOrAfter(datetime.timedelta, AutoCommitAfter)`, only the interval part is applied; the `after` mode is ignored. @@ -862,7 +860,7 @@ class IggyConsumer: ) -> collections.abc.Awaitable[None]: r""" Consumes messages continuously using a callback function and an optional `asyncio.Event` for signaling shutdown. - Returns an awaitable that completes when shutdown is signaled or a PyRuntimeError on failure. + Returns an awaitable that completes when shutdown is signaled or a RuntimeError on failure. """ class PollingStrategy: @@ -901,7 +899,7 @@ class PollingStrategy: class ReceiveMessage: r""" A Python class representing a received message. - This class wraps a Rust message, allowing for access to its payload and offset from Python. + It provides access to the message payload and offset. """ def payload(self) -> bytes: r""" @@ -942,8 +940,6 @@ class ReceiveMessage: class SendMessage: r""" A Python class representing a message to be sent. - This class wraps a Rust message meant for sending, facilitating - the creation of such messages from Python and their subsequent use in Rust. """ def __new__(cls, data: builtins.str | bytes) -> SendMessage: r""" @@ -968,8 +964,7 @@ class TcpConfig: r""" Configuration for the TCP transport, accepted by `IggyClient(...)`. - Mirrors `TcpClientConfig` in the Rust SDK. Every field is keyword-only and - falls back to the same default the Rust SDK uses. + Every field is keyword-only and optional. """ @property def server_address(self) -> builtins.str: ... @@ -1003,8 +998,7 @@ class TcpConfig: nodelay: builtins.bool | None = None, ) -> TcpConfig: r""" - Constructs a TCP configuration, defaulting every unset field to the value - the Rust SDK uses. + Constructs a TCP configuration. Args: server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8090`. @@ -1021,7 +1015,7 @@ class TcpConfig: leaving it on. Raises: - PyValueError: If `server_address` is not a valid `host:port` pair, or + ValueError: If `server_address` is not a valid `host:port` pair, or if a duration is negative. """ def __repr__(self) -> builtins.str: ... @@ -1048,8 +1042,7 @@ class TcpReconnectionConfig: reestablish_after: datetime.timedelta | None = None, ) -> TcpReconnectionConfig: r""" - Constructs a reconnection policy, defaulting every unset field to the - value the Rust SDK uses. + Constructs a reconnection policy. Args: enabled: Whether to reconnect at all. Defaults to enabled. @@ -1059,7 +1052,7 @@ class TcpReconnectionConfig: successful connection. Defaults to 5 seconds. Raises: - PyValueError: If a duration is negative. + ValueError: If a duration is negative. """ def __repr__(self) -> builtins.str: ... diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index c7dc46974a..245cc42f85 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -46,8 +46,7 @@ use crate::user::{ use tokio::sync::Mutex; /// A Python class representing the Iggy client. -/// It wraps the RustIggyClient and provides asynchronous functionality -/// through the contained runtime. +/// It provides asynchronous functionality through the contained runtime. #[gen_stub_pyclass] #[pyclass] pub struct IggyClient { @@ -67,7 +66,7 @@ impl IggyClient { /// disabled. /// /// Raises: - /// PyRuntimeError: If the address is not a valid `host:port` pair, or if the + /// RuntimeError: If the address is not a valid `host:port` pair, or if the /// client cannot be built. #[new] #[pyo3(signature = (conn=None))] @@ -117,7 +116,7 @@ impl IggyClient { } /// Sends a ping request to the server to check connectivity. - /// Returns `Ok(())` if the server responds successfully, or a `PyRuntimeError` + /// Returns `Ok(())` if the server responds successfully, or a `RuntimeError` /// if the connection fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn ping<'a>(&self, py: Python<'a>) -> PyResult> { @@ -131,7 +130,7 @@ impl IggyClient { } /// Logs in the user with the given credentials. - /// Returns `Ok(())` on success, or a PyRuntimeError on failure. + /// Returns `Ok(())` on success, or a RuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn login_user<'a>( &self, @@ -159,8 +158,8 @@ impl IggyClient { /// or `None` otherwise. /// /// Raises: - /// PyValueError: If a string identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If a string identifier is invalid. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails | None]", imports=("collections.abc")))] fn get_user<'a>(&self, py: Python<'a>, user_id: PyIdentifier) -> PyResult> { let user_id = Identifier::try_from(user_id)?; @@ -181,7 +180,7 @@ impl IggyClient { /// An awaitable that resolves to `list[UserInfo]`. /// /// Raises: - /// PyRuntimeError: If the request fails. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[UserInfo]]", imports=("collections.abc")))] fn get_users<'a>(&self, py: Python<'a>) -> PyResult> { let inner = self.inner.clone(); @@ -208,7 +207,7 @@ impl IggyClient { /// An awaitable that resolves to the created `UserInfoDetails`. /// /// Raises: - /// PyRuntimeError: If an argument is invalid or the request fails. + /// RuntimeError: If an argument is invalid or the request fails. #[pyo3(signature = (username, password, status=None))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails]", imports=("collections.abc")))] fn create_user<'a>( @@ -241,8 +240,8 @@ impl IggyClient { /// An awaitable that resolves to `None` when the user is updated. /// /// Raises: - /// PyValueError: If a string identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If a string identifier is invalid. + /// RuntimeError: If the request fails. #[pyo3(signature = (user_id, username=None, status=None))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn update_user<'a>( @@ -274,8 +273,8 @@ impl IggyClient { /// An awaitable that resolves to `None` when the user is deleted. /// /// Raises: - /// PyValueError: If a string identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If a string identifier is invalid. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn delete_user<'a>(&self, py: Python<'a>, user_id: PyIdentifier) -> PyResult> { let user_id = Identifier::try_from(user_id)?; @@ -291,7 +290,7 @@ impl IggyClient { } /// Connects the IggyClient to its service. - /// Returns Ok(()) on successful connection or a PyRuntimeError on failure. + /// Returns Ok(()) on successful connection or a RuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn connect<'a>(&self, py: Python<'a>) -> PyResult> { let inner = self.inner.clone(); @@ -305,7 +304,7 @@ impl IggyClient { } /// Creates a new stream with the provided ID and name. - /// Returns Ok(()) on successful stream creation or a PyRuntimeError on failure. + /// Returns Ok(()) on successful stream creation or a RuntimeError on failure. #[pyo3(signature = (name))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn create_stream<'a>(&self, py: Python<'a>, name: String) -> PyResult> { @@ -320,7 +319,7 @@ impl IggyClient { } /// Gets stream by id. - /// Returns Option of stream details or a PyRuntimeError on failure. + /// Returns Option of stream details or a RuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[StreamDetails | None]", imports=("collections.abc")))] fn get_stream<'a>( &self, @@ -340,7 +339,7 @@ impl IggyClient { } /// Creates a new topic with the given parameters. - /// Returns Ok(()) on successful topic creation or a PyRuntimeError on failure. + /// Returns Ok(()) on successful topic creation or a RuntimeError on failure. #[pyo3( signature = (stream, name, partitions_count, compression_algorithm = None, replication_factor = None, message_expiry = None, max_topic_size = None) )] @@ -396,7 +395,7 @@ impl IggyClient { } /// Gets topic by stream and id. - /// Returns Option of topic details or a PyRuntimeError on failure. + /// Returns Option of topic details or a RuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[TopicDetails | None]", imports=("collections.abc")))] fn get_topic<'a>( &self, @@ -426,7 +425,7 @@ impl IggyClient { /// An awaitable that resolves to `list[Topic]`. /// /// Raises: - /// PyRuntimeError: If the identifier is invalid or the request fails. + /// RuntimeError: If the identifier is invalid or the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[Topic]]", imports=("collections.abc")))] fn get_topics<'a>( &self, @@ -463,7 +462,7 @@ impl IggyClient { /// An awaitable that resolves to `None` when the topic is updated. /// /// Raises: - /// PyRuntimeError: If an argument is invalid or the request fails. + /// RuntimeError: If an 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) )] @@ -529,7 +528,7 @@ impl IggyClient { /// An awaitable that resolves to `None` when the topic is deleted. /// /// Raises: - /// PyRuntimeError: If an identifier is invalid or the request fails. + /// RuntimeError: If an identifier is invalid or the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn delete_topic<'a>( &self, @@ -560,7 +559,7 @@ impl IggyClient { /// An awaitable that resolves to `None` when the topic is purged. /// /// Raises: - /// PyRuntimeError: If an identifier is invalid or the request fails. + /// RuntimeError: If an identifier is invalid or the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn purge_topic<'a>( &self, @@ -592,8 +591,8 @@ impl IggyClient { /// An awaitable that resolves to `None` when the consumer group is created. /// /// Raises: - /// PyValueError: If an identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If an identifier is invalid. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn create_consumer_group<'a>( &self, @@ -627,8 +626,8 @@ impl IggyClient { /// or `None` otherwise. /// /// Raises: - /// PyValueError: If an identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If an identifier is invalid. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[ConsumerGroupDetails | None]", imports=("collections.abc")))] fn get_consumer_group<'a>( &self, @@ -661,8 +660,8 @@ impl IggyClient { /// An awaitable that resolves to `list[ConsumerGroup]`. /// /// Raises: - /// PyValueError: If an identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If an identifier is invalid. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[ConsumerGroup]]", imports=("collections.abc")))] fn get_consumer_groups<'a>( &self, @@ -697,8 +696,8 @@ impl IggyClient { /// An awaitable that resolves to `None` when the consumer group is deleted. /// /// Raises: - /// PyValueError: If a string identifier is invalid. - /// PyRuntimeError: If the request fails. + /// ValueError: If a string identifier is invalid. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn delete_consumer_group<'a>( &self, @@ -735,8 +734,8 @@ impl IggyClient { /// An awaitable that resolves to `None` when the client joins the consumer group. /// /// Raises: - /// PyValueError: If a string identifier is invalid. - /// PyRuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. + /// ValueError: If a string identifier is invalid. + /// RuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn join_consumer_group<'a>( &self, @@ -775,8 +774,8 @@ impl IggyClient { /// rejoin on their next poll. /// /// Raises: - /// PyValueError: If a string identifier is invalid. - /// PyRuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. + /// ValueError: If a string identifier is invalid. + /// RuntimeError: If the request fails, including `Feature is unavailable` on HTTP transport. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn leave_consumer_group<'a>( &self, @@ -800,7 +799,7 @@ impl IggyClient { } /// Sends a list of messages to the specified topic. - /// Returns Ok(()) on successful sending or a PyRuntimeError on failure. + /// Returns Ok(()) on successful sending or a RuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn send_messages<'a>( &self, @@ -837,7 +836,7 @@ impl IggyClient { } /// Polls for messages from the specified topic and partition. - /// Returns a list of received messages or a PyRuntimeError on failure. + /// Returns a list of received messages or a RuntimeError on failure. #[allow(clippy::too_many_arguments)] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[ReceiveMessage]]", imports=("collections.abc")))] fn poll_messages<'a>( @@ -883,7 +882,7 @@ impl IggyClient { } /// Creates a new consumer group consumer. - /// Returns the consumer or a PyRuntimeError on failure. + /// Returns the consumer or a RuntimeError on failure. #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( name, @@ -1008,7 +1007,7 @@ impl IggyClient { /// An awaitable that resolves to the raw response `bytes`. /// /// Raises: - /// PyRuntimeError: If the command cannot be sent or the server returns an error. + /// RuntimeError: If the command cannot be sent or the server returns an error. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[bytes]", imports=("collections.abc")))] fn send_binary_request<'a>( &self, diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index 209ed6bb58..ca67cdc05e 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -120,8 +120,7 @@ pub struct TcpReconnectionConfig { #[gen_stub_pymethods] #[pymethods] impl TcpReconnectionConfig { - /// Constructs a reconnection policy, defaulting every unset field to the - /// value the Rust SDK uses. + /// Constructs a reconnection policy. /// /// Args: /// enabled: Whether to reconnect at all. Defaults to enabled. @@ -131,7 +130,7 @@ impl TcpReconnectionConfig { /// successful connection. Defaults to 5 seconds. /// /// Raises: - /// PyValueError: If a duration is negative. + /// ValueError: If a duration is negative. #[new] #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] fn new( @@ -200,8 +199,7 @@ impl TcpReconnectionConfig { /// Configuration for the TCP transport, accepted by `IggyClient(...)`. /// -/// Mirrors `TcpClientConfig` in the Rust SDK. Every field is keyword-only and -/// falls back to the same default the Rust SDK uses. +/// Every field is keyword-only and optional. #[gen_stub_pyclass] #[pyclass(from_py_object)] #[derive(Clone)] @@ -221,8 +219,7 @@ impl TcpConfig { #[gen_stub_pymethods] #[pymethods] impl TcpConfig { - /// Constructs a TCP configuration, defaulting every unset field to the value - /// the Rust SDK uses. + /// Constructs a TCP configuration. /// /// Args: /// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8090`. @@ -239,7 +236,7 @@ impl TcpConfig { /// leaving it on. /// /// Raises: - /// PyValueError: If `server_address` is not a valid `host:port` pair, or + /// ValueError: If `server_address` is not a valid `host:port` pair, or /// if a duration is negative. #[new] #[pyo3(signature = ( diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs index fb95b1d0bf..27cc742fb2 100644 --- a/foreign/python/src/consumer.rs +++ b/foreign/python/src/consumer.rs @@ -43,8 +43,7 @@ use crate::identifier::PyIdentifier; use crate::receive_message::ReceiveMessage; /// A Python class representing the Iggy consumer. -/// It wraps the RustIggyConsumer and provides asynchronous functionality -/// through the contained runtime. +/// It provides asynchronous functionality through the contained runtime. #[gen_stub_pyclass] #[pyclass] pub struct IggyConsumer { @@ -94,7 +93,7 @@ impl IggyConsumer { /// Stores the provided offset for the provided partition id or if none is specified /// uses the current partition id for the consumer group. - /// Returns `Ok(())` if the server responds successfully, or a `PyRuntimeError` + /// Returns `Ok(())` if the server responds successfully, or a `RuntimeError` /// if the operation fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn store_offset<'a>( @@ -116,7 +115,7 @@ impl IggyConsumer { /// Deletes the offset for the provided partition id or if none is specified /// uses the current partition id for the consumer group. - /// Returns `Ok(())` if the server responds successfully, or a `PyRuntimeError` + /// Returns `Ok(())` if the server responds successfully, or a `RuntimeError` /// if the operation fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn delete_offset<'a>( @@ -137,7 +136,7 @@ impl IggyConsumer { /// Asynchronously iterate over `ReceiveMessage`s. /// Returns an async iterator that raises `StopAsyncIteration` when no more messages are available - /// or a `PyRuntimeError` on failure. + /// or a `RuntimeError` on failure. /// Note: This method does not currently support `AutoCommit.After`. /// For `AutoCommit.IntervalOrAfter(datetime.timedelta, AutoCommitAfter)`, /// only the interval part is applied; the `after` mode is ignored. @@ -149,7 +148,7 @@ impl IggyConsumer { } /// Consumes messages continuously using a callback function and an optional `asyncio.Event` for signaling shutdown. - /// Returns an awaitable that completes when shutdown is signaled or a PyRuntimeError on failure. + /// Returns an awaitable that completes when shutdown is signaled or a RuntimeError on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn consume_messages<'a>( &self, diff --git a/foreign/python/src/receive_message.rs b/foreign/python/src/receive_message.rs index aadf0772cf..c93c0942f6 100644 --- a/foreign/python/src/receive_message.rs +++ b/foreign/python/src/receive_message.rs @@ -21,7 +21,7 @@ use pyo3::types::PyBytes; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum, gen_stub_pymethods}; /// A Python class representing a received message. -/// This class wraps a Rust message, allowing for access to its payload and offset from Python. +/// It provides access to the message payload and offset. #[pyclass] #[gen_stub_pyclass] pub struct ReceiveMessage { diff --git a/foreign/python/src/send_message.rs b/foreign/python/src/send_message.rs index 021125d7e6..586a426f11 100644 --- a/foreign/python/src/send_message.rs +++ b/foreign/python/src/send_message.rs @@ -25,8 +25,6 @@ use pyo3_stub_gen::{ use std::str::FromStr; /// A Python class representing a message to be sent. -/// This class wraps a Rust message meant for sending, facilitating -/// the creation of such messages from Python and their subsequent use in Rust. #[pyclass(from_py_object)] #[gen_stub_pyclass] pub struct SendMessage { From 42ad9ab63d60fb31188b8e66958c0a60334767cf Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sat, 1 Aug 2026 13:24:02 +0800 Subject: [PATCH 11/20] docs(python): describe results in Python terms instead of Ok(()) Docstrings for methods returning Awaitable[None] said they return Ok(()), which does not exist for a Python caller. State the raised exception instead. --- foreign/python/apache_iggy.pyi | 19 ++++++++----------- foreign/python/src/client.rs | 13 ++++++------- foreign/python/src/consumer.rs | 6 ++---- foreign/python/src/lib.rs | 2 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 9c422fdd64..93832344a7 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -375,15 +375,14 @@ class IggyClient: def ping(self) -> collections.abc.Awaitable[None]: r""" Sends a ping request to the server to check connectivity. - Returns `Ok(())` if the server responds successfully, or a `RuntimeError` - if the connection fails. + Raises `RuntimeError` if the connection fails. """ def login_user( self, username: builtins.str, password: builtins.str ) -> collections.abc.Awaitable[None]: r""" Logs in the user with the given credentials. - Returns `Ok(())` on success, or a RuntimeError on failure. + Raises `RuntimeError` on failure. """ def get_user( self, user_id: builtins.str | builtins.int @@ -474,12 +473,12 @@ class IggyClient: def connect(self) -> collections.abc.Awaitable[None]: r""" Connects the IggyClient to its service. - Returns Ok(()) on successful connection or a RuntimeError on failure. + Raises `RuntimeError` if the connection fails. """ def create_stream(self, name: builtins.str) -> collections.abc.Awaitable[None]: r""" Creates a new stream with the provided ID and name. - Returns Ok(()) on successful stream creation or a RuntimeError on failure. + Raises `RuntimeError` if the stream cannot be created. """ def get_stream( self, stream_id: builtins.str | builtins.int @@ -500,7 +499,7 @@ class IggyClient: ) -> collections.abc.Awaitable[None]: r""" Creates a new topic with the given parameters. - Returns Ok(()) on successful topic creation or a RuntimeError on failure. + Raises `RuntimeError` if the topic cannot be created. """ def get_topic( self, @@ -735,7 +734,7 @@ class IggyClient: ) -> collections.abc.Awaitable[None]: r""" Sends a list of messages to the specified topic. - Returns Ok(()) on successful sending or a RuntimeError on failure. + Raises `RuntimeError` if sending fails. """ def poll_messages( self, @@ -829,8 +828,7 @@ class IggyConsumer: r""" Stores the provided offset for the provided partition id or if none is specified uses the current partition id for the consumer group. - Returns `Ok(())` if the server responds successfully, or a `RuntimeError` - if the operation fails. + Raises `RuntimeError` if the operation fails. """ def delete_offset( self, partition_id: builtins.int | None @@ -838,8 +836,7 @@ class IggyConsumer: r""" Deletes the offset for the provided partition id or if none is specified uses the current partition id for the consumer group. - Returns `Ok(())` if the server responds successfully, or a `RuntimeError` - if the operation fails. + Raises `RuntimeError` if the operation fails. """ def iter_messages(self) -> collections.abc.AsyncIterator[ReceiveMessage]: r""" diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 245cc42f85..20a44ad30e 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -116,8 +116,7 @@ impl IggyClient { } /// Sends a ping request to the server to check connectivity. - /// Returns `Ok(())` if the server responds successfully, or a `RuntimeError` - /// if the connection fails. + /// Raises `RuntimeError` if the connection fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn ping<'a>(&self, py: Python<'a>) -> PyResult> { let inner = self.inner.clone(); @@ -130,7 +129,7 @@ impl IggyClient { } /// Logs in the user with the given credentials. - /// Returns `Ok(())` on success, or a RuntimeError on failure. + /// Raises `RuntimeError` on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn login_user<'a>( &self, @@ -290,7 +289,7 @@ impl IggyClient { } /// Connects the IggyClient to its service. - /// Returns Ok(()) on successful connection or a RuntimeError on failure. + /// Raises `RuntimeError` if the connection fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn connect<'a>(&self, py: Python<'a>) -> PyResult> { let inner = self.inner.clone(); @@ -304,7 +303,7 @@ impl IggyClient { } /// Creates a new stream with the provided ID and name. - /// Returns Ok(()) on successful stream creation or a RuntimeError on failure. + /// Raises `RuntimeError` if the stream cannot be created. #[pyo3(signature = (name))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn create_stream<'a>(&self, py: Python<'a>, name: String) -> PyResult> { @@ -339,7 +338,7 @@ impl IggyClient { } /// Creates a new topic with the given parameters. - /// Returns Ok(()) on successful topic creation or a RuntimeError on failure. + /// Raises `RuntimeError` if the topic cannot be created. #[pyo3( signature = (stream, name, partitions_count, compression_algorithm = None, replication_factor = None, message_expiry = None, max_topic_size = None) )] @@ -799,7 +798,7 @@ impl IggyClient { } /// Sends a list of messages to the specified topic. - /// Returns Ok(()) on successful sending or a RuntimeError on failure. + /// Raises `RuntimeError` if sending fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn send_messages<'a>( &self, diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs index 27cc742fb2..918df9a1d4 100644 --- a/foreign/python/src/consumer.rs +++ b/foreign/python/src/consumer.rs @@ -93,8 +93,7 @@ impl IggyConsumer { /// Stores the provided offset for the provided partition id or if none is specified /// uses the current partition id for the consumer group. - /// Returns `Ok(())` if the server responds successfully, or a `RuntimeError` - /// if the operation fails. + /// Raises `RuntimeError` if the operation fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn store_offset<'a>( &self, @@ -115,8 +114,7 @@ impl IggyConsumer { /// Deletes the offset for the provided partition id or if none is specified /// uses the current partition id for the consumer group. - /// Returns `Ok(())` if the server responds successfully, or a `RuntimeError` - /// if the operation fails. + /// Raises `RuntimeError` if the operation fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn delete_offset<'a>( &self, diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index 66ea8cec75..a5293d2ca1 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -39,7 +39,7 @@ use stream::StreamDetails; use topic::{Topic, TopicDetails}; use user::{UserInfo, UserInfoDetails, UserStatus}; -/// A Python module implemented in Rust. +/// Python client for Apache Iggy, the persistent message streaming platform. #[pymodule] fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; From 92048cb8f891218ed550724052a82532c4448f7f Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sat, 1 Aug 2026 13:30:07 +0800 Subject: [PATCH 12/20] fix(python): keep full microsecond precision when reading durations IggyDuration::as_micros() truncates the count to u64, so a duration near timedelta.max wrapped to a wrong value instead of surviving the round trip, and the OverflowError guard below could never fire. Read the std Duration directly to keep the u128. --- foreign/python/src/duration.rs | 5 ++++- foreign/python/tests/test_client_config.py | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs index 03126d0676..ef4de125b0 100644 --- a/foreign/python/src/duration.rs +++ b/foreign/python/src/duration.rs @@ -40,7 +40,10 @@ pub fn iggy_duration_to_py_delta( py: Python<'_>, duration: IggyDuration, ) -> PyResult> { - let micros = duration.as_micros(); + // IggyDuration::as_micros() truncates to u64; read the std Duration to keep + // the full u128 so oversized values fail the i32 conversion below instead of + // wrapping. + let micros = duration.get_duration().as_micros(); let total_seconds = micros / 1_000_000; let days = i32::try_from(total_seconds / 86_400).map_err(|_| { PyErr::new::( diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 50a0f7a882..a7f9373041 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -129,6 +129,12 @@ def test_very_long_interval_round_trips(self): assert reconnection.interval == timedelta(days=30_000) + def test_maximum_interval_round_trips(self): + """Test that the largest timedelta survives the u64-microsecond boundary.""" + reconnection = TcpReconnectionConfig(interval=timedelta(days=999_999_999)) + + assert reconnection.interval == timedelta(days=999_999_999) + @pytest.mark.unit class TestTcpConfig: From dc180e5ac1dc74ffce9a41d202b2393c3d325864 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sat, 1 Aug 2026 13:31:25 +0800 Subject: [PATCH 13/20] test(python): cover negative durations on the pre-existing surface The negative-duration rejection also changed methods that shipped before this branch, such as create_topic's message_expiry, but only the new config classes had coverage. --- foreign/python/tests/test_client_config.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index a7f9373041..ba55275efc 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -226,6 +226,22 @@ def test_rejects_an_invalid_address(self): with pytest.raises(RuntimeError): IggyClient("nonsense") + def test_negative_message_expiry_is_rejected(self): + """Test that the negative-duration rule reaches the pre-existing surface. + + create_topic accepted a negative message_expiry before durations were + validated; it now fails at the call, before any I/O. + """ + client = IggyClient() + + with pytest.raises(ValueError, match="negative"): + client.create_topic( + stream="stream", + name="topic", + partitions_count=1, + message_expiry=timedelta(seconds=-1), + ) + @pytest.mark.integration class TestAutoLoginAgainstServer: From ab97c0b8d5a39a0ee3d5d8d7646f5951dca9921a Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Sat, 1 Aug 2026 13:33:39 +0800 Subject: [PATCH 14/20] docs(python): state the cost of disabling certificate validation The tls_validate_certificate docstring was neutral for a flag that accepts any certificate the server presents. --- foreign/python/apache_iggy.pyi | 4 +++- foreign/python/src/config.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 93832344a7..37cc6dc253 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -1007,7 +1007,9 @@ class TcpConfig: taken from `server_address`. tls_ca_file: Path to the CA file for TLS. tls_validate_certificate: Whether to validate the server certificate. - Defaults to validating. + Defaults to validating. Disabling this accepts any certificate the + server presents, including self-signed and mismatched ones; intended + for local development only. nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to leaving it on. diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index ca67cdc05e..462bff65a9 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -231,7 +231,9 @@ impl TcpConfig { /// taken from `server_address`. /// tls_ca_file: Path to the CA file for TLS. /// tls_validate_certificate: Whether to validate the server certificate. - /// Defaults to validating. + /// Defaults to validating. Disabling this accepts any certificate the + /// server presents, including self-signed and mismatched ones; intended + /// for local development only. /// nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to /// leaving it on. /// From acd01f68e56c244254a92abc82e4fd2722db9c94 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Wed, 5 Aug 2026 22:19:09 +0800 Subject: [PATCH 15/20] fix(python): reject zero durations that spin the client A zero duration reads as "disabled" nowhere in the client: heartbeat_interval pings for as long as the client lives, a reconnection interval with unlimited retries reconnects in a continuous loop, polling_retry_interval spins without a syscall in the loop body, an AutoCommit interval spins and then floods the server with offset stores, and init_retry_interval panics inside the runtime timer without naming the argument that caused it. Each is now rejected where the sign is already validated, except where zero is meaningful: the cooldown before reestablishing, a bounded fast-retry interval, and any interval on a reconnection policy that is switched off. Building the configuration no longer keeps a second copy of the credentials and the reconnection policy beside the one the transport reads, no longer rebuilds the defaults the builder already produced, and no longer routes through a client builder whose only failure mode cannot happen here. Converting a timedelta now goes through the conversion pyo3 ships, keeping the message that does not name Rust types. --- foreign/python/apache_iggy.pyi | 7 +- foreign/python/src/client.rs | 19 ++-- foreign/python/src/config.rs | 115 ++++++++++++--------- foreign/python/src/consumer.rs | 18 ++-- foreign/python/src/duration.rs | 45 ++++---- foreign/python/tests/test_client_config.py | 100 ++++++++++++++++-- 6 files changed, 205 insertions(+), 99 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5714abfb4a..ec69f9a8a1 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -1861,8 +1861,8 @@ class TcpConfig: leaving it on. Raises: - ValueError: If `server_address` is not a valid `host:port` pair, or - if a duration is negative. + ValueError: If `server_address` is not a valid `host:port` pair, if a + duration is negative, or if `heartbeat_interval` is zero. """ def __repr__(self) -> builtins.str: ... @@ -1898,7 +1898,8 @@ class TcpReconnectionConfig: successful connection. Defaults to 5 seconds. Raises: - ValueError: If a duration is negative. + ValueError: If a duration is negative, or if `interval` is zero while + reconnection is enabled and `max_retries` is unlimited. """ def __repr__(self) -> builtins.str: ... diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 94e501bf4e..48316b0060 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -35,7 +35,7 @@ use crate::consumer::{ AutoCommit, ConsumerGroup as PyConsumerGroup, ConsumerGroupDetails as PyConsumerGroupDetails, IggyConsumer, }; -use crate::duration::py_delta_to_iggy_duration; +use crate::duration::{py_delta_to_iggy_duration, reject_zero}; use crate::identifier::PyIdentifier; use crate::permissions::Permissions as PyPermissions; use crate::receive_message::{PollingStrategy, ReceiveMessage}; @@ -117,12 +117,8 @@ impl IggyClient { }; let tcp_client = TcpClient::create(config) .map_err(|e| PyErr::new::(e.to_string()))?; - let client = IggyClientBuilder::new() - .with_client(ClientWrapper::Tcp(tcp_client)) - .build() - .map_err(|e| PyErr::new::(e.to_string()))?; Ok(IggyClient { - inner: Arc::new(client), + inner: Arc::new(RustIggyClient::new(ClientWrapper::Tcp(tcp_client))), }) } @@ -1084,8 +1080,10 @@ impl IggyClient { 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 = builder.polling_retry_interval(reject_zero( + py_delta_to_iggy_duration(&polling_retry_interval)?, + "polling_retry_interval", + )?) } if init_retries.is_some() && init_retry_interval.is_none() { return Err(PyErr::new::( @@ -1101,7 +1099,10 @@ impl IggyClient { { builder = builder.init_retries( init_retries, - py_delta_to_iggy_duration(&init_retry_interval)?, + reject_zero( + py_delta_to_iggy_duration(&init_retry_interval)?, + "init_retry_interval", + )?, ); } if allow_replay { diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index 462bff65a9..a0483d93f2 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -20,6 +20,7 @@ use iggy::prelude::{ TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder, TcpClientReconnectionConfig as RustTcpClientReconnectionConfig, }; +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyDelta; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; @@ -27,7 +28,7 @@ use pyo3_stub_gen::impl_stub_type; use secrecy::SecretString; use std::sync::Arc; -use crate::duration::{iggy_duration_to_py_delta, py_delta_to_iggy_duration}; +use crate::duration::{iggy_duration_to_py_delta, py_delta_to_iggy_duration, reject_zero}; /// The credentials replayed by the client every time it (re)connects. /// @@ -103,16 +104,10 @@ impl AutoLogin { } } -impl Default for AutoLogin { - fn default() -> Self { - Self::disabled() - } -} - /// How the TCP client reconnects after the connection to the server is lost. #[gen_stub_pyclass] #[pyclass(from_py_object)] -#[derive(Clone, Default)] +#[derive(Clone)] pub struct TcpReconnectionConfig { pub(crate) inner: RustTcpClientReconnectionConfig, } @@ -130,7 +125,8 @@ impl TcpReconnectionConfig { /// successful connection. Defaults to 5 seconds. /// /// Raises: - /// ValueError: If a duration is negative. + /// ValueError: If a duration is negative, or if `interval` is zero while + /// reconnection is enabled and `max_retries` is unlimited. #[new] #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] fn new( @@ -142,15 +138,25 @@ impl TcpReconnectionConfig { reestablish_after: Option>, ) -> PyResult { let defaults = RustTcpClientReconnectionConfig::default(); + let enabled = enabled.unwrap_or(defaults.enabled); + let interval = interval + .as_ref() + .map(py_delta_to_iggy_duration) + .transpose()? + .unwrap_or(defaults.interval); + // Unlimited retries at a zero interval reconnect in a continuous loop; + // a zero interval with a retry cap is a legitimate fast-retry policy, and + // with reconnection off the interval is never read at all. + if enabled && interval.is_zero() && max_retries.is_none() { + return Err(PyValueError::new_err( + "'interval' must not be zero unless 'max_retries' is set", + )); + } Ok(Self { inner: RustTcpClientReconnectionConfig { - enabled: enabled.unwrap_or(defaults.enabled), + enabled, max_retries, - interval: interval - .as_ref() - .map(py_delta_to_iggy_duration) - .transpose()? - .unwrap_or(defaults.interval), + interval, reestablish_after: reestablish_after .as_ref() .map(py_delta_to_iggy_duration) @@ -204,8 +210,6 @@ impl TcpReconnectionConfig { #[pyclass(from_py_object)] #[derive(Clone)] pub struct TcpConfig { - auto_login: AutoLogin, - reconnection: TcpReconnectionConfig, inner: Arc, } @@ -238,8 +242,8 @@ impl TcpConfig { /// leaving it on. /// /// Raises: - /// ValueError: If `server_address` is not a valid `host:port` pair, or - /// if a duration is negative. + /// ValueError: If `server_address` is not a valid `host:port` pair, if a + /// duration is negative, or if `heartbeat_interval` is zero. #[new] #[pyo3(signature = ( *, @@ -271,35 +275,44 @@ impl TcpConfig { tls_validate_certificate: Option, #[gen_stub(override_type(type_repr = "builtins.bool | None"))] nodelay: Option, ) -> PyResult { - let defaults = RustTcpClientConfig::default(); - let auto_login = auto_login.unwrap_or_default(); - let reconnection = reconnection.unwrap_or_default(); - - // The builder is only used to validate and trim the server address; the - // remaining fields are assigned directly so every unset argument falls - // back to the Rust `TcpClientConfig::default()` value instead of a - // literal duplicated here. - let mut inner = TcpClientConfigBuilder::new() - .with_server_address(server_address.unwrap_or(defaults.server_address)) + // The builder starts from `TcpClientConfig::default()`, and its `build()` + // trims and validates the address whether or not one was set here. + let mut builder = TcpClientConfigBuilder::new(); + if let Some(server_address) = server_address { + builder = builder.with_server_address(server_address); + } + let mut inner = builder .build() - .map_err(|e| PyErr::new::(e.to_string()))?; - inner.auto_login = auto_login.inner.clone(); - inner.reconnection = reconnection.inner.clone(); - inner.heartbeat_interval = heartbeat_interval - .as_ref() - .map(py_delta_to_iggy_duration) - .transpose()? - .unwrap_or(defaults.heartbeat_interval); - inner.tls_enabled = tls_enabled.unwrap_or(defaults.tls_enabled); - inner.tls_domain = tls_domain.unwrap_or(defaults.tls_domain); - inner.tls_ca_file = tls_ca_file.or(defaults.tls_ca_file); - inner.tls_validate_certificate = - tls_validate_certificate.unwrap_or(defaults.tls_validate_certificate); - inner.nodelay = nodelay.unwrap_or(defaults.nodelay); + .map_err(|e| PyValueError::new_err(e.to_string()))?; + if let Some(auto_login) = auto_login { + inner.auto_login = auto_login.inner; + } + if let Some(reconnection) = reconnection { + inner.reconnection = reconnection.inner; + } + if let Some(heartbeat_interval) = heartbeat_interval { + inner.heartbeat_interval = reject_zero( + py_delta_to_iggy_duration(&heartbeat_interval)?, + "heartbeat_interval", + )?; + } + if let Some(tls_enabled) = tls_enabled { + inner.tls_enabled = tls_enabled; + } + if let Some(tls_domain) = tls_domain { + inner.tls_domain = tls_domain; + } + if tls_ca_file.is_some() { + inner.tls_ca_file = tls_ca_file; + } + if let Some(tls_validate_certificate) = tls_validate_certificate { + inner.tls_validate_certificate = tls_validate_certificate; + } + if let Some(nodelay) = nodelay { + inner.nodelay = nodelay; + } Ok(Self { - auto_login, - reconnection, inner: Arc::new(inner), }) } @@ -311,12 +324,16 @@ impl TcpConfig { #[getter] fn auto_login(&self) -> AutoLogin { - self.auto_login.clone() + AutoLogin { + inner: self.inner.auto_login.clone(), + } } #[getter] fn reconnection(&self) -> TcpReconnectionConfig { - self.reconnection.clone() + TcpReconnectionConfig { + inner: self.inner.reconnection.clone(), + } } #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] @@ -355,8 +372,8 @@ impl TcpConfig { format!( "TcpConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, tls_enabled={})", self.inner.server_address, - self.auto_login.__repr__(), - self.reconnection.__repr__(), + self.auto_login().__repr__(), + self.reconnection().__repr__(), self.inner.heartbeat_interval.as_human_time_string(), if self.inner.tls_enabled { "True" diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs index 918df9a1d4..6a6e69a877 100644 --- a/foreign/python/src/consumer.rs +++ b/foreign/python/src/consumer.rs @@ -23,8 +23,8 @@ use iggy::prelude::{ AutoCommit as RustAutoCommit, AutoCommitAfter as RustAutoCommitAfter, AutoCommitWhen as RustAutoCommitWhen, ConsumerGroup as RustConsumerGroup, ConsumerGroupDetails as RustConsumerGroupDetails, - ConsumerGroupMember as RustConsumerGroupMember, IggyConsumer as RustIggyConsumer, IggyError, - ReceivedMessage, + ConsumerGroupMember as RustConsumerGroupMember, IggyConsumer as RustIggyConsumer, IggyDuration, + IggyError, ReceivedMessage, }; use pyo3::exceptions::PyStopAsyncIteration; use pyo3::types::PyDelta; @@ -38,7 +38,7 @@ use tokio::sync::Mutex; use tokio::sync::oneshot::Sender; use tokio::task::JoinHandle; -use crate::duration::py_delta_to_iggy_duration; +use crate::duration::{py_delta_to_iggy_duration, reject_zero}; use crate::identifier::PyIdentifier; use crate::receive_message::ReceiveMessage; @@ -432,14 +432,12 @@ impl TryFrom<&AutoCommit> for RustAutoCommit { fn try_from(val: &AutoCommit) -> PyResult { Ok(match val { AutoCommit::Disabled() => RustAutoCommit::Disabled, - AutoCommit::Interval(delta) => { - RustAutoCommit::Interval(py_delta_to_iggy_duration(delta)?) - } + AutoCommit::Interval(delta) => RustAutoCommit::Interval(auto_commit_interval(delta)?), AutoCommit::IntervalOrWhen(delta, when) => { - RustAutoCommit::IntervalOrWhen(py_delta_to_iggy_duration(delta)?, when.into()) + RustAutoCommit::IntervalOrWhen(auto_commit_interval(delta)?, when.into()) } AutoCommit::IntervalOrAfter(delta, after) => { - RustAutoCommit::IntervalOrAfter(py_delta_to_iggy_duration(delta)?, after.into()) + RustAutoCommit::IntervalOrAfter(auto_commit_interval(delta)?, after.into()) } AutoCommit::When(when) => RustAutoCommit::When(when.into()), AutoCommit::After(after) => RustAutoCommit::After(after.into()), @@ -447,6 +445,10 @@ impl TryFrom<&AutoCommit> for RustAutoCommit { } } +fn auto_commit_interval(delta: &Py) -> PyResult { + reject_zero(py_delta_to_iggy_duration(delta)?, "AutoCommit interval") +} + /// The auto-commit mode for storing the offset on the server. #[derive(Debug, PartialEq, Copy, Clone)] #[gen_stub_pyclass_complex_enum(skip_stub_type)] diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs index ef4de125b0..a31a3c8d04 100644 --- a/foreign/python/src/duration.rs +++ b/foreign/python/src/duration.rs @@ -16,23 +16,20 @@ // under the License. use iggy::prelude::IggyDuration; +use pyo3::exceptions::PyValueError; use pyo3::prelude::*; -use pyo3::types::{PyDelta, PyDeltaAccess}; +use pyo3::types::PyDelta; use std::time::Duration; pub fn py_delta_to_iggy_duration(delta: &Py) -> PyResult { Python::attach(|py| { - let delta = delta.bind(py); - // Python normalizes a negative timedelta to negative days plus - // non-negative seconds/microseconds, so the sign lives in the sum. - let seconds = i64::from(delta.get_days()) * 60 * 60 * 24 + i64::from(delta.get_seconds()); - if seconds < 0 { - return Err(PyErr::new::( - "duration must not be negative", - )); - } - let nanos = (delta.get_microseconds() * 1_000) as u32; - Ok(IggyDuration::new(Duration::new(seconds as u64, nanos))) + // The value is already a timedelta, so a negative one is the only failure + // left to map, and the Python surface must not name Rust types. + delta + .bind(py) + .extract::() + .map(IggyDuration::from) + .map_err(|_| PyValueError::new_err("duration must not be negative")) }) } @@ -40,16 +37,16 @@ pub fn iggy_duration_to_py_delta( py: Python<'_>, duration: IggyDuration, ) -> PyResult> { - // IggyDuration::as_micros() truncates to u64; read the std Duration to keep - // the full u128 so oversized values fail the i32 conversion below instead of - // wrapping. - let micros = duration.get_duration().as_micros(); - let total_seconds = micros / 1_000_000; - let days = i32::try_from(total_seconds / 86_400).map_err(|_| { - PyErr::new::( - "duration does not fit into a datetime.timedelta", - ) - })?; - let seconds = (total_seconds % 86_400) as i32; - PyDelta::new(py, days, seconds, (micros % 1_000_000) as i32, true) + duration.get_duration().into_pyobject(py) +} + +/// Rejects a zero duration for parameters where zero means an unthrottled loop +/// rather than "disabled". +pub fn reject_zero(duration: IggyDuration, parameter: &str) -> PyResult { + if duration.is_zero() { + return Err(PyValueError::new_err(format!( + "'{parameter}' must not be zero" + ))); + } + Ok(duration) } diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 303c208b4b..005c41a913 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -31,6 +31,9 @@ import pytest from apache_iggy import ( + AutoCommit, + AutoCommitAfter, + AutoCommitWhen, AutoLogin, IggyClient, IggyExpiry, @@ -123,12 +126,29 @@ def test_negative_duration_is_rejected( with pytest.raises(ValueError, match="negative"): construct(negative) - def test_zero_interval_is_allowed(self): - """Test that a zero interval is legal and readable back.""" - reconnection = TcpReconnectionConfig(interval=timedelta(0)) + def test_zero_reestablish_after_is_allowed(self): + """Test that a zero cooldown is legal and readable back.""" + reconnection = TcpReconnectionConfig(reestablish_after=timedelta(0)) + + assert reconnection.reestablish_after == timedelta(0) + + def test_zero_interval_is_allowed_with_bounded_retries(self): + """Test that a zero interval is legal as a bounded fast-retry policy.""" + reconnection = TcpReconnectionConfig(interval=timedelta(0), max_retries=5) + + assert reconnection.interval == timedelta(0) + + def test_zero_interval_is_allowed_when_reconnection_is_disabled(self): + """Test that a zero interval is legal when nothing ever reads it.""" + reconnection = TcpReconnectionConfig(enabled=False, interval=timedelta(0)) assert reconnection.interval == timedelta(0) + def test_zero_interval_with_unlimited_retries_is_rejected(self): + """Test that the combination that reconnects in a continuous loop fails.""" + with pytest.raises(ValueError, match="zero"): + TcpReconnectionConfig(interval=timedelta(0)) + def test_very_long_interval_round_trips(self): """Test that an interval beyond 68 years survives the i32 boundary.""" reconnection = TcpReconnectionConfig(interval=timedelta(days=30_000)) @@ -210,6 +230,15 @@ def test_negative_heartbeat_interval_is_rejected(self): with pytest.raises(ValueError, match="negative"): TcpConfig(heartbeat_interval=timedelta(seconds=-3)) + def test_zero_heartbeat_interval_is_rejected(self): + """Test that a zero heartbeat interval fails at construction. + + Nothing downstream reads zero as "disabled"; it heartbeats in a + continuous loop for as long as the client lives. + """ + with pytest.raises(ValueError, match="zero"): + TcpConfig(heartbeat_interval=timedelta(0)) + @pytest.mark.unit class TestClientConstruction: @@ -233,10 +262,9 @@ def test_rejects_an_invalid_address(self): IggyClient("nonsense") def test_negative_message_expiry_is_rejected(self): - """Test that the negative-duration rule reaches the pre-existing surface. + """Test that the negative-duration rule reaches create_topic. - create_topic accepted a negative message_expiry before durations were - validated; it now fails at the call, before any I/O. + The check runs at the call, before any I/O. """ client = IggyClient() @@ -248,6 +276,66 @@ def test_negative_message_expiry_is_rejected(self): message_expiry=IggyExpiry.ExpireDuration(timedelta(seconds=-1)), ) + @pytest.mark.parametrize( + "interval_kwargs", + [ + {"polling_retry_interval": timedelta(0)}, + {"init_retries": 3, "init_retry_interval": timedelta(0)}, + {"auto_commit": AutoCommit.Interval(timedelta(0))}, + { + "auto_commit": AutoCommit.IntervalOrWhen( + timedelta(0), AutoCommitWhen.PollingMessages() + ) + }, + { + "auto_commit": AutoCommit.IntervalOrAfter( + timedelta(0), AutoCommitAfter.ConsumingEachMessage() + ) + }, + ], + ids=[ + "polling_retry_interval", + "init_retry_interval", + "auto_commit_interval", + "auto_commit_interval_or_when", + "auto_commit_interval_or_after", + ], + ) + def test_zero_consumer_interval_is_rejected(self, interval_kwargs: dict): + """Test that a zero consumer interval fails at the call. + + Zero spins the retry loop, floods the server with offset stores, or + panics inside the runtime timer, and none of those name the argument + that caused it. + """ + client = IggyClient() + + with pytest.raises(ValueError, match="zero"): + client.consumer_group( + name="group", + stream="stream", + topic="topic", + **interval_kwargs, + ) + + def test_zero_poll_interval_is_allowed(self): + """Test that a zero poll interval passes validation. + + Zero there means "do not wait before polling" and is short-circuited + before the sleep, unlike the retry intervals. Reaching the awaitable is + what proves it: building one without a running loop is the next failure, + and a rejected value would have raised ValueError first. + """ + client = IggyClient() + + with pytest.raises(RuntimeError): + client.consumer_group( + name="group", + stream="stream", + topic="topic", + poll_interval=timedelta(0), + ) + @pytest.mark.integration class TestAutoLoginAgainstServer: From 54e80df417bab0b3f83c9c2bad3761046ea507f0 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 6 Aug 2026 02:02:14 +0800 Subject: [PATCH 16/20] docs(python): name the failure modes the configuration can reach The docstrings still described the surface as it behaved before durations were validated: a negative message_expiry or consumer interval now raises ValueError at the call rather than becoming a near-maximum duration, and a zero consumer interval raises it too. A malformed address reaches the user as ValueError through TcpConfig and as RuntimeError through the string form, which the constructor documented as one error. tls_ca_file is silently ignored unless certificate validation is on, and the default reconnection policy retries forever, so an awaited call never returns while the server is down. get_stream and get_topic still described their result as an Option, the one Rust type name left behind when the docstrings were translated for Python readers. --- foreign/python/apache_iggy.pyi | 34 +++++++++++++++++++++++++--------- foreign/python/src/client.rs | 20 ++++++++++++++------ foreign/python/src/config.rs | 14 +++++++++++--- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index ec69f9a8a1..a602121596 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -821,11 +821,14 @@ class IggyClient: Args: conn: Either a `host:port` address, or a `TcpConfig` carrying the full transport configuration. Defaults to `127.0.0.1:8090` with auto-login - disabled. + disabled. A malformed address is reported differently by the two + forms: the string form raises `RuntimeError` here, while `TcpConfig` + raises `ValueError` when it is constructed, before it ever reaches + this call. Neither exception is a subclass of the other. Raises: - RuntimeError: If the address is not a valid `host:port` pair, or if the - client cannot be built. + RuntimeError: If the address passed as a string is not a valid + `host:port` pair. """ @classmethod def from_connection_string(cls, connection_string: builtins.str) -> IggyClient: @@ -997,7 +1000,8 @@ class IggyClient: ) -> collections.abc.Awaitable[StreamDetails | None]: r""" Gets stream by id. - Returns Option of stream details or a RuntimeError on failure. + Returns the stream details, or `None` if the stream does not exist. + Raises `RuntimeError` on failure. """ def create_topic( self, @@ -1035,7 +1039,8 @@ class IggyClient: ) -> collections.abc.Awaitable[TopicDetails | None]: r""" Gets topic by stream and id. - Returns Option of topic details or a RuntimeError on failure. + Returns the topic details, or `None` if the topic does not exist. + Raises `RuntimeError` on failure. """ def get_topics( self, stream_id: builtins.str | builtins.int @@ -1299,7 +1304,10 @@ class IggyClient: ) -> collections.abc.Awaitable[IggyConsumer]: r""" Creates a new consumer group consumer. - Returns the consumer or a RuntimeError on failure. + Returns the consumer or a RuntimeError on failure. Raises `ValueError` if + `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an + `AutoCommit` interval is negative, or if any of those except `poll_interval` + is zero. """ def send_binary_request( self, code: builtins.int, payload: builtins.bytes @@ -1852,11 +1860,15 @@ class TcpConfig: tls_enabled: Whether to connect over TLS. Defaults to disabled. tls_domain: Domain to validate the certificate against. Empty means it is taken from `server_address`. - tls_ca_file: Path to the CA file for TLS. + tls_ca_file: Path to the CA file for TLS. Read only when `tls_enabled` + and `tls_validate_certificate` are both on; with either one off it + is kept but never consulted, so pairing it with + `tls_validate_certificate=False` pins nothing. tls_validate_certificate: Whether to validate the server certificate. Defaults to validating. Disabling this accepts any certificate the - server presents, including self-signed and mismatched ones; intended - for local development only. + server presents, including self-signed and mismatched ones, and + takes precedence over `tls_ca_file`; intended for local development + only. nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to leaving it on. @@ -1893,6 +1905,10 @@ class TcpReconnectionConfig: Args: enabled: Whether to reconnect at all. Defaults to enabled. max_retries: Attempts before giving up, or `None` for unlimited. + Defaults to unlimited, which means a call awaited while the server + is down never returns: `connect()`, `send_messages()` and + `poll_messages()` all wait inside the retry loop. Set a finite + number for request/reply style usage, so a call fails instead. interval: Delay between attempts. Defaults to 1 second. reestablish_after: Cooldown before reconnecting after a previously successful connection. Defaults to 5 seconds. diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index 48316b0060..cde56040ff 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -91,11 +91,14 @@ impl IggyClient { /// Args: /// conn: Either a `host:port` address, or a `TcpConfig` carrying the full /// transport configuration. Defaults to `127.0.0.1:8090` with auto-login - /// disabled. + /// disabled. A malformed address is reported differently by the two + /// forms: the string form raises `RuntimeError` here, while `TcpConfig` + /// raises `ValueError` when it is constructed, before it ever reaches + /// this call. Neither exception is a subclass of the other. /// /// Raises: - /// RuntimeError: If the address is not a valid `host:port` pair, or if the - /// client cannot be built. + /// RuntimeError: If the address passed as a string is not a valid + /// `host:port` pair. #[new] #[pyo3(signature = (conn=None))] fn new( @@ -436,7 +439,8 @@ impl IggyClient { } /// Gets stream by id. - /// Returns Option of stream details or a RuntimeError on failure. + /// Returns the stream details, or `None` if the stream does not exist. + /// Raises `RuntimeError` on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[StreamDetails | None]", imports=("collections.abc")))] fn get_stream<'a>( &self, @@ -520,7 +524,8 @@ impl IggyClient { } /// Gets topic by stream and id. - /// Returns Option of topic details or a RuntimeError on failure. + /// Returns the topic details, or `None` if the topic does not exist. + /// Raises `RuntimeError` on failure. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[TopicDetails | None]", imports=("collections.abc")))] fn get_topic<'a>( &self, @@ -1004,7 +1009,10 @@ impl IggyClient { } /// Creates a new consumer group consumer. - /// Returns the consumer or a RuntimeError on failure. + /// Returns the consumer or a RuntimeError on failure. Raises `ValueError` if + /// `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an + /// `AutoCommit` interval is negative, or if any of those except `poll_interval` + /// is zero. #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( name, diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index a0483d93f2..5891104e63 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -120,6 +120,10 @@ impl TcpReconnectionConfig { /// Args: /// enabled: Whether to reconnect at all. Defaults to enabled. /// max_retries: Attempts before giving up, or `None` for unlimited. + /// Defaults to unlimited, which means a call awaited while the server + /// is down never returns: `connect()`, `send_messages()` and + /// `poll_messages()` all wait inside the retry loop. Set a finite + /// number for request/reply style usage, so a call fails instead. /// interval: Delay between attempts. Defaults to 1 second. /// reestablish_after: Cooldown before reconnecting after a previously /// successful connection. Defaults to 5 seconds. @@ -233,11 +237,15 @@ impl TcpConfig { /// tls_enabled: Whether to connect over TLS. Defaults to disabled. /// tls_domain: Domain to validate the certificate against. Empty means it is /// taken from `server_address`. - /// tls_ca_file: Path to the CA file for TLS. + /// tls_ca_file: Path to the CA file for TLS. Read only when `tls_enabled` + /// and `tls_validate_certificate` are both on; with either one off it + /// is kept but never consulted, so pairing it with + /// `tls_validate_certificate=False` pins nothing. /// tls_validate_certificate: Whether to validate the server certificate. /// Defaults to validating. Disabling this accepts any certificate the - /// server presents, including self-signed and mismatched ones; intended - /// for local development only. + /// server presents, including self-signed and mismatched ones, and + /// takes precedence over `tls_ca_file`; intended for local development + /// only. /// nodelay: Disable the Nagle algorithm for the TCP socket. Defaults to /// leaving it on. /// From 72025b394e8a90f7a69d0fd776613a214e943136 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 6 Aug 2026 02:03:59 +0800 Subject: [PATCH 17/20] fix(python): print the whole configuration and fail the examples cleanly The repr of a configuration printed five of nine fields, dropping every one a TLS handshake is debugged with, so a config that accepts any certificate read exactly like a validating one. Durations printed in a form no constructor accepts, which cost the repr its one job of being pasteable. Both examples built their configuration outside the error handling, where an address without a port reached the user as a traceback rather than as the message the validation produced. --- examples/python/getting-started/consumer.py | 6 +++- examples/python/getting-started/producer.py | 6 +++- foreign/python/src/config.rs | 31 +++++++++++++-------- foreign/python/src/duration.rs | 13 +++++++++ foreign/python/tests/test_client_config.py | 25 +++++++++++++++++ 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/examples/python/getting-started/consumer.py b/examples/python/getting-started/consumer.py index 12c2c7671b..db0a6edb1f 100755 --- a/examples/python/getting-started/consumer.py +++ b/examples/python/getting-started/consumer.py @@ -116,7 +116,11 @@ def build_config(args: ArgNamespace) -> TcpConfig: async def main(): args: ArgNamespace = parse_args() - config = build_config(args) + try: + config = build_config(args) + except ValueError as error: + logger.error(f"Invalid client configuration: {error}") + return logger.info(f"Connecting to {args.tcp_server_address} (TLS: {args.tls})") client = IggyClient(config) diff --git a/examples/python/getting-started/producer.py b/examples/python/getting-started/producer.py index f6029b2013..23f964fa81 100755 --- a/examples/python/getting-started/producer.py +++ b/examples/python/getting-started/producer.py @@ -117,7 +117,11 @@ def build_config(args: ArgNamespace) -> TcpConfig: async def main(): args: ArgNamespace = parse_args() - config = build_config(args) + try: + config = build_config(args) + except ValueError as error: + logger.error(f"Invalid client configuration: {error}") + return logger.info(f"Connecting to {args.tcp_server_address} (TLS: {args.tls})") client = IggyClient(config) diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index 5891104e63..699f8fc400 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -28,7 +28,9 @@ use pyo3_stub_gen::impl_stub_type; use secrecy::SecretString; use std::sync::Arc; -use crate::duration::{iggy_duration_to_py_delta, py_delta_to_iggy_duration, reject_zero}; +use crate::duration::{ + duration_repr, iggy_duration_to_py_delta, py_delta_to_iggy_duration, reject_zero, +}; /// The credentials replayed by the client every time it (re)connects. /// @@ -200,9 +202,9 @@ impl TcpReconnectionConfig { }; format!( "TcpReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})", - if self.inner.enabled { "True" } else { "False" }, - self.inner.interval.as_human_time_string(), - self.inner.reestablish_after.as_human_time_string(), + python_bool(self.inner.enabled), + duration_repr(self.inner.interval), + duration_repr(self.inner.reestablish_after), ) } } @@ -377,21 +379,28 @@ impl TcpConfig { } fn __repr__(&self) -> String { + let tls_ca_file = match &self.inner.tls_ca_file { + Some(tls_ca_file) => format!("{tls_ca_file:?}"), + None => "None".to_owned(), + }; format!( - "TcpConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, tls_enabled={})", + "TcpConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, tls_enabled={}, tls_domain={:?}, tls_ca_file={tls_ca_file}, tls_validate_certificate={}, nodelay={})", self.inner.server_address, self.auto_login().__repr__(), self.reconnection().__repr__(), - self.inner.heartbeat_interval.as_human_time_string(), - if self.inner.tls_enabled { - "True" - } else { - "False" - }, + duration_repr(self.inner.heartbeat_interval), + python_bool(self.inner.tls_enabled), + self.inner.tls_domain, + python_bool(self.inner.tls_validate_certificate), + python_bool(self.inner.nodelay), ) } } +fn python_bool(value: bool) -> &'static str { + if value { "True" } else { "False" } +} + /// What `IggyClient(...)` accepts: a bare `host:port` or a full `TcpConfig`. #[derive(FromPyObject)] pub enum PyClientConfig { diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs index a31a3c8d04..9b2b419fe5 100644 --- a/foreign/python/src/duration.rs +++ b/foreign/python/src/duration.rs @@ -40,6 +40,19 @@ pub fn iggy_duration_to_py_delta( duration.get_duration().into_pyobject(py) } +/// Renders a duration the way it would be written in Python, so that a `__repr__` +/// built from it can be pasted back into a constructor. +pub fn duration_repr(duration: IggyDuration) -> String { + // Read the std duration, whose micros are u128: `IggyDuration::as_micros()` + // truncates to u64, which a timedelta near the Python maximum overflows. + let micros = duration.get_duration().as_micros(); + if micros.is_multiple_of(1_000_000) { + format!("datetime.timedelta(seconds={})", micros / 1_000_000) + } else { + format!("datetime.timedelta(microseconds={micros})") + } +} + /// Rejects a zero duration for parameters where zero means an unthrottled loop /// rather than "disabled". pub fn reject_zero(duration: IggyDuration, parameter: &str) -> PyResult { diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 005c41a913..8add26d145 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -25,6 +25,7 @@ replayed on connect and no manual `login_user()` is needed. """ +import ast from collections.abc import Callable from datetime import timedelta @@ -216,6 +217,30 @@ def test_repr_hides_the_password(self): assert "secret" not in repr(config) + def test_repr_shows_every_field_as_python(self): + """Test that repr covers the TLS fields and parses as Python. + + The TLS fields are the ones a handshake is debugged with, and a repr is + only worth printing if it can be pasted back into a constructor. + """ + config = TcpConfig( + heartbeat_interval=timedelta(seconds=15), + tls_enabled=True, + tls_domain="localhost", + tls_ca_file="ca.pem", + tls_validate_certificate=False, + nodelay=True, + ) + + printed = repr(config) + + assert 'tls_domain="localhost"' in printed + assert 'tls_ca_file="ca.pem"' in printed + assert "tls_validate_certificate=False" in printed + assert "nodelay=True" in printed + assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed + ast.parse(printed) + @pytest.mark.parametrize( "invalid_address", ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "::1:8090"], From 7ae1f0058f9d65e2de42c1635011ec90e610f029 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Wed, 5 Aug 2026 22:53:44 +0800 Subject: [PATCH 18/20] test(python): say what the duration and equivalence tests prove The maximum-interval test named a u64-microsecond boundary that the interval never crosses; what it covers is the day conversion in the getter. The equivalence test claimed both forms of configuration were equivalent while asserting only that both clients authenticate, which is all the client exposes. --- foreign/python/tests/test_client_config.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 8add26d145..a52a04321f 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -157,7 +157,7 @@ def test_very_long_interval_round_trips(self): assert reconnection.interval == timedelta(days=30_000) def test_maximum_interval_round_trips(self): - """Test that the largest timedelta survives the u64-microsecond boundary.""" + """Test that the largest timedelta survives the day conversion.""" reconnection = TcpReconnectionConfig(interval=timedelta(days=999_999_999)) assert reconnection.interval == timedelta(days=999_999_999) @@ -401,8 +401,13 @@ async def test_without_auto_login_a_privileged_call_is_unauthenticated( await client.create_stream(unique_name()) @pytest.mark.asyncio - async def test_config_and_connection_string_are_equivalent(self, unique_name): - """Test that TcpConfig and a connection string reach the same behavior.""" + async def test_config_and_connection_string_both_authenticate(self, unique_name): + """Test that either form of configuring credentials logs the client in. + + The reconnection policy is set on both sides to mirror the connection + string, but the client exposes no getter for it, so this asserts only + what is observable: both clients reach an authenticated session. + """ host, port = get_server_config() wait_for_server(host, port) From 71e3936dab1292612d4a8009d845f259b9f0f1d4 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 6 Aug 2026 01:04:56 +0800 Subject: [PATCH 19/20] docs(python): point the readme CA path at the file it means The path was written from the repository root, but the snippet around it is run from foreign/python, where the certificate is two levels up. The examples readme already spells it that way. --- foreign/python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foreign/python/README.md b/foreign/python/README.md index e2cc9a53ab..d46d42f804 100644 --- a/foreign/python/README.md +++ b/foreign/python/README.md @@ -83,7 +83,7 @@ async def main(): heartbeat_interval=timedelta(seconds=5), # tls_enabled=True, # tls_domain="localhost", - # tls_ca_file="core/certs/iggy_ca_cert.pem", + # tls_ca_file="../../core/certs/iggy_ca_cert.pem", # tls_validate_certificate=True, # nodelay=True, ) From 91500538606e0719d66e3cf38b5b6505f650d3e1 Mon Sep 17 00:00:00 2001 From: Ethan Lin Date: Thu, 6 Aug 2026 03:27:19 +0800 Subject: [PATCH 20/20] fix(python): report an out-of-range retry count as a value error A max_retries outside the unsigned 32-bit range reached the caller as OverflowError, raised by the argument conversion before any code here ran, so it named neither the argument nor the range. OverflowError is not a ValueError, so a caller guarding construction the way the getting-started examples do never caught it. The count is now taken wide and narrowed here, where the message can say which argument it is and what it accepts. --- foreign/python/apache_iggy.pyi | 3 ++- foreign/python/src/config.rs | 15 +++++++++++++-- foreign/python/tests/test_client_config.py | 10 ++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index a602121596..ab63e66c5a 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -1914,7 +1914,8 @@ class TcpReconnectionConfig: successful connection. Defaults to 5 seconds. Raises: - ValueError: If a duration is negative, or if `interval` is zero while + ValueError: If a duration is negative, if `max_retries` is outside the + range of an unsigned 32-bit integer, or if `interval` is zero while reconnection is enabled and `max_retries` is unlimited. """ def __repr__(self) -> builtins.str: ... diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index 699f8fc400..4519c939fb 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -131,13 +131,14 @@ impl TcpReconnectionConfig { /// successful connection. Defaults to 5 seconds. /// /// Raises: - /// ValueError: If a duration is negative, or if `interval` is zero while + /// ValueError: If a duration is negative, if `max_retries` is outside the + /// range of an unsigned 32-bit integer, or if `interval` is zero while /// reconnection is enabled and `max_retries` is unlimited. #[new] #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] fn new( #[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option, - #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option, #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] interval: Option>, #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] @@ -145,6 +146,16 @@ impl TcpReconnectionConfig { ) -> PyResult { let defaults = RustTcpClientReconnectionConfig::default(); let enabled = enabled.unwrap_or(defaults.enabled); + let max_retries = max_retries + .map(|max_retries| { + u32::try_from(max_retries).map_err(|_| { + PyValueError::new_err(format!( + "'max_retries' must be between 0 and {}", + u32::MAX + )) + }) + }) + .transpose()?; let interval = interval .as_ref() .map(py_delta_to_iggy_duration) diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index a52a04321f..78df23459c 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -127,6 +127,16 @@ def test_negative_duration_is_rejected( with pytest.raises(ValueError, match="negative"): construct(negative) + @pytest.mark.parametrize("out_of_range", [-1, 2**32]) + def test_out_of_range_max_retries_is_rejected(self, out_of_range: int): + """Test that a retry count outside the wire range names the argument. + + The conversion pyo3 does on its own raises OverflowError, which is not a + ValueError and so escapes the handler a caller wraps construction in. + """ + with pytest.raises(ValueError, match="max_retries"): + TcpReconnectionConfig(max_retries=out_of_range) + def test_zero_reestablish_after_is_allowed(self): """Test that a zero cooldown is legal and readable back.""" reconnection = TcpReconnectionConfig(reestablish_after=timedelta(0))