This is an automated email from the ASF dual-hosted git repository.
mmodzelewski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 1ac819e99 feat(python): expose TCP client configuration (#3776)
1ac819e99 is described below
commit 1ac819e996ac1764fd966c8a6de4dc057a4bca96
Author: Ethan Lin <[email protected]>
AuthorDate: Mon Aug 10 01:53:15 2026 -0700
feat(python): expose TCP client configuration (#3776)
---
core/sdk/src/prelude.rs | 24 +-
examples/python/getting-started/consumer.py | 52 ++--
examples/python/getting-started/producer.py | 50 +--
foreign/python/Cargo.toml | 1 +
foreign/python/README.md | 36 +++
foreign/python/apache_iggy.pyi | 263 ++++++++++++----
foreign/python/src/client.rs | 135 ++++----
foreign/python/src/config.rs | 423 +++++++++++++++++++++++++
foreign/python/src/consumer.rs | 51 +---
foreign/python/src/duration.rs | 65 ++++
foreign/python/src/lib.rs | 8 +-
foreign/python/src/receive_message.rs | 2 +-
foreign/python/src/send_message.rs | 2 -
foreign/python/src/topic.rs | 33 +-
foreign/python/tests/conftest.py | 5 +
foreign/python/tests/test_client_config.py | 459 ++++++++++++++++++++++++++++
16 files changed, 1386 insertions(+), 223 deletions(-)
diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index ee0dc06d6..51b34e34a 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -51,18 +51,18 @@ pub use iggy_common::{
Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, CacheMetrics,
CacheMetricsKey, ClientError,
ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole,
ClusterNodeStatus,
CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails,
ConsumerGroupMember,
- ConsumerKind, EncryptorKind, GlobalPermissions, HeaderField, 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,
- QuicClientConfig, QuicClientConfigBuilder, QuicClientReconnectionConfig,
SendMessages,
- SendMessagesConfirmationResponse, SendMessagesResponse, Sizeable,
SnapshotCompression, Stats,
- Stream, StreamDetails, StreamPermissions, SystemSnapshotType,
TcpClientConfig,
- TcpClientConfigBuilder, TcpClientReconnectionConfig, Topic, TopicDetails,
TopicPermissions,
- TransportEndpoints, TransportProtocol, UserId, UserInfo, UserInfoDetails,
UserStatus,
- Validatable, WebSocketClientConfig, WebSocketClientConfigBuilder,
+ ConsumerKind, Credentials, EncryptorKind, GlobalPermissions, HeaderField,
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, QuicClientConfig, QuicClientConfigBuilder,
QuicClientReconnectionConfig,
+ SendMessages, SendMessagesConfirmationResponse, SendMessagesResponse,
Sizeable,
+ SnapshotCompression, Stats, Stream, StreamDetails, StreamPermissions,
SystemSnapshotType,
+ TcpClientConfig, TcpClientConfigBuilder, TcpClientReconnectionConfig,
Topic, TopicDetails,
+ TopicPermissions, TransportEndpoints, TransportProtocol, UserId, UserInfo,
UserInfoDetails,
+ UserStatus, Validatable, WebSocketClientConfig,
WebSocketClientConfigBuilder,
WebSocketClientReconnectionConfig, defaults, locking,
};
pub use iggy_common::{
diff --git a/examples/python/getting-started/consumer.py
b/examples/python/getting-started/consumer.py
index bb10e9138..db0a6edb1 100755
--- a/examples/python/getting-started/consumer.py
+++ b/examples/python/getting-started/consumer.py
@@ -19,8 +19,16 @@ import argparse
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,34 @@ 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}"]
-
- # 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)
+def build_config(args: ArgNamespace) -> TcpConfig:
+ """Build a TCP client configuration with auto-login and reconnection."""
- 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()
+ 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})")
- # 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 642399edb..23f964fa8 100755
--- a/examples/python/getting-started/producer.py
+++ b/examples/python/getting-started/producer.py
@@ -19,8 +19,16 @@ import argparse
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,33 @@ 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}")
+ 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.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)
diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml
index f709947b6..ff2cce834 100644
--- a/foreign/python/Cargo.toml
+++ b/foreign/python/Cargo.toml
@@ -47,4 +47,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/README.md b/foreign/python/README.md
index 99a9b0ed6..8a5aff425 100644
--- a/foreign/python/README.md
+++ b/foreign/python/README.md
@@ -139,6 +139,42 @@ running prek / committing / pushing. This list is not
exhaustive and other hook
./scripts/ci/markdownlint.sh --fix foreign/python/README.md # read the diff
after applying this, sometimes it gives unwanted results, e.g. messing up
enumerations
```
+## Client Configuration
+
+`IggyClient` takes either a server address or a `TcpConfig`:
+
+```python
+import asyncio
+from datetime import timedelta
+
+from apache_iggy import AutoLogin, IggyClient, TcpConfig, TcpReconnectionConfig
+
+
+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),
+ # tls_enabled=True,
+ # tls_domain="localhost",
+ # tls_ca_file="../../core/certs/iggy_ca_cert.pem",
+ # tls_validate_certificate=True,
+ # nodelay=True,
+ )
+ )
+ await client.connect()
+
+
+asyncio.run(main())
+```
+
## Examples
Refer to the
[examples/python/](https://github.com/apache/iggy/tree/master/examples/python)
directory for usage examples.
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 341b3885c..ab63e66c5 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",
@@ -48,6 +49,8 @@ __all__ = [
"SendMessagesResponse",
"StreamDetails",
"StreamPermissions",
+ "TcpConfig",
+ "TcpReconnectionConfig",
"Topic",
"TopicDetails",
"TopicPermissions",
@@ -250,6 +253,41 @@ class AutoCommitWhen:
...
[email protected]
+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
@@ -772,14 +810,25 @@ class GlobalPermissions:
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: 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. 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 passed as a string is not a valid
+ `host:port` pair.
"""
@classmethod
def from_connection_string(cls, connection_string: builtins.str) ->
IggyClient:
@@ -790,15 +839,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
`PyRuntimeError`
- 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 PyRuntimeError on failure.
+ Raises `RuntimeError` on failure.
"""
def get_user(
self, user_id: builtins.str | builtins.int
@@ -814,8 +862,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"""
@@ -825,7 +873,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,
@@ -847,7 +895,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,
@@ -867,8 +915,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
@@ -883,8 +931,8 @@ 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 update_permissions(
self, user_id: builtins.str | builtins.int, permissions: Permissions |
None
@@ -903,8 +951,8 @@ class IggyClient:
An awaitable that resolves to `None` when the permissions are
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 change_password(
self,
@@ -924,8 +972,8 @@ class IggyClient:
An awaitable that resolves to `None` when the password is changed.
Raises:
- PyValueError: If a string identifier is invalid.
- PyRuntimeError: If the current password is wrong or the request
fails.
+ ValueError: If a string identifier is invalid.
+ RuntimeError: If the current password is wrong or the request
fails.
"""
def logout_user(self) -> collections.abc.Awaitable[None]:
r"""
@@ -935,24 +983,25 @@ class IggyClient:
An awaitable that resolves to `None` when the user is logged out.
Raises:
- PyRuntimeError: If the request fails.
+ 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.
+ 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 PyRuntimeError on
failure.
+ Raises `RuntimeError` if the stream cannot be created.
"""
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 the stream details, or `None` if the stream does not exist.
+ Raises `RuntimeError` on failure.
"""
def create_topic(
self,
@@ -990,7 +1039,8 @@ 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 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
@@ -1005,7 +1055,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,
@@ -1055,7 +1105,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,
@@ -1073,7 +1123,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,
@@ -1093,8 +1143,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,
@@ -1115,8 +1165,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,
@@ -1134,8 +1184,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,
@@ -1155,8 +1205,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,
@@ -1179,8 +1229,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,
@@ -1205,8 +1255,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,
@@ -1233,7 +1283,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,
@@ -1254,7 +1304,10 @@ 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. 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
@@ -1273,15 +1326,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
@@ -1315,8 +1367,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`
- if the operation fails.
+ Raises `RuntimeError` if the operation fails.
"""
def delete_offset(
self, partition_id: builtins.int | None
@@ -1324,14 +1375,13 @@ 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`
- if the operation fails.
+ Raises `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.
@@ -1346,7 +1396,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 IggyExpiry:
@@ -1546,7 +1596,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"""
@@ -1596,8 +1646,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,
@@ -1763,6 +1811,115 @@ class StreamPermissions:
treated as `None`.
"""
[email protected]
+class TcpConfig:
+ r"""
+ Configuration for the TCP transport, accepted by `IggyClient(...)`.
+
+ Every field is keyword-only and optional.
+ """
+ @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 | None = None,
+ tls_domain: builtins.str | None = None,
+ tls_ca_file: builtins.str | None = None,
+ tls_validate_certificate: builtins.bool | None = None,
+ nodelay: builtins.bool | None = None,
+ ) -> TcpConfig:
+ r"""
+ Constructs a TCP configuration.
+
+ 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. 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. 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, 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.
+
+ Raises:
+ 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: ...
+
[email protected]
+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 | None = None,
+ max_retries: builtins.int | None = None,
+ interval: datetime.timedelta | None = None,
+ reestablish_after: datetime.timedelta | None = None,
+ ) -> TcpReconnectionConfig:
+ r"""
+ Constructs a reconnection policy.
+
+ 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.
+
+ Raises:
+ 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: ...
+
@typing.final
class Topic:
@property
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index c7cbe3952..cde56040f 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -30,10 +30,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, reject_zero};
use crate::identifier::PyIdentifier;
use crate::permissions::Permissions as PyPermissions;
use crate::receive_message::{PollingStrategy, ReceiveMessage};
@@ -46,8 +48,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 {
@@ -83,21 +84,44 @@ fn resolve_topic_params(
#[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. 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 passed as a string is not a valid
+ /// `host:port` pair.
#[new]
#[pyo3(signature = (conn=None))]
fn new(
- #[gen_stub(override_type(type_repr = "builtins.str | None"))] conn:
Option<String>,
+ #[gen_stub(override_type(type_repr = "TcpConfig | builtins.str |
None"))] conn: Option<
+ PyClientConfig,
+ >,
) -> PyResult<Self> {
- let client = IggyClientBuilder::new()
- .with_tcp()
- .with_server_address(conn.unwrap_or("127.0.0.1:8090".to_string()))
- .build()
+ 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::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string())
+ })?,
+ ),
+ None => Arc::new(TcpClientConfig::default()),
+ };
+ let tcp_client = TcpClient::create(config)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError,
_>(e.to_string()))?;
Ok(IggyClient {
- inner: Arc::new(client),
+ inner:
Arc::new(RustIggyClient::new(ClientWrapper::Tcp(tcp_client))),
})
}
@@ -119,8 +143,7 @@ impl IggyClient {
}
/// Sends a ping request to the server to check connectivity.
- /// Returns `Ok(())` if the server responds successfully, or a
`PyRuntimeError`
- /// 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<Bound<'a, PyAny>> {
let inner = self.inner.clone();
@@ -133,7 +156,7 @@ impl IggyClient {
}
/// Logs in the user with the given credentials.
- /// Returns `Ok(())` on success, or a PyRuntimeError 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,
@@ -161,8 +184,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<Bound<'a, PyAny>> {
let user_id = Identifier::try_from(user_id)?;
@@ -183,7 +206,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<Bound<'a, PyAny>> {
let inner = self.inner.clone();
@@ -209,7 +232,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, permissions=None))]
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails]",
imports=("collections.abc")))]
fn create_user<'a>(
@@ -246,8 +269,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>(
@@ -279,8 +302,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<Bound<'a, PyAny>> {
let user_id = Identifier::try_from(user_id)?;
@@ -308,8 +331,8 @@ impl IggyClient {
/// An awaitable that resolves to `None` when the permissions are
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, permissions))]
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
fn update_permissions<'a>(
@@ -344,8 +367,8 @@ impl IggyClient {
/// An awaitable that resolves to `None` when the password is changed.
///
/// Raises:
- /// PyValueError: If a string identifier is invalid.
- /// PyRuntimeError: If the current password is wrong or the request
fails.
+ /// ValueError: If a string identifier is invalid.
+ /// RuntimeError: If the current password is wrong or the request
fails.
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
fn change_password<'a>(
&self,
@@ -372,7 +395,7 @@ impl IggyClient {
/// An awaitable that resolves to `None` when the user is logged out.
///
/// Raises:
- /// PyRuntimeError: If the request fails.
+ /// RuntimeError: If the request fails.
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]",
imports=("collections.abc")))]
fn logout_user<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> {
let inner = self.inner.clone();
@@ -387,7 +410,7 @@ impl IggyClient {
}
/// Connects the IggyClient to its service.
- /// Returns Ok(()) on successful connection or a PyRuntimeError 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<Bound<'a, PyAny>> {
let inner = self.inner.clone();
@@ -401,7 +424,7 @@ impl IggyClient {
}
/// Creates a new stream with the provided ID and name.
- /// Returns Ok(()) on successful stream creation or a PyRuntimeError 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<Bound<'a, PyAny>> {
@@ -416,7 +439,8 @@ impl IggyClient {
}
/// Gets stream by id.
- /// Returns Option of stream details or a PyRuntimeError 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,
@@ -500,7 +524,8 @@ impl IggyClient {
}
/// Gets topic by stream and id.
- /// Returns Option of topic details or a PyRuntimeError 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,
@@ -530,7 +555,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,
@@ -627,7 +652,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,
@@ -658,7 +683,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,
@@ -690,8 +715,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,
@@ -725,8 +750,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,
@@ -759,8 +784,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,
@@ -795,8 +820,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,
@@ -833,8 +858,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,
@@ -873,8 +898,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,
@@ -938,7 +963,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>(
@@ -984,7 +1009,10 @@ impl IggyClient {
}
/// Creates a new consumer group consumer.
- /// Returns the consumer or a PyRuntimeError 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,
@@ -1060,8 +1088,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::<pyo3::exceptions::PyRuntimeError, _>(
@@ -1077,7 +1107,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 {
@@ -1109,7 +1142,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
new file mode 100644
index 000000000..4519c939f
--- /dev/null
+++ b/foreign/python/src/config.rs
@@ -0,0 +1,423 @@
+// 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::exceptions::PyValueError;
+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::{
+ 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.
+///
+/// `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<String> {
+ 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()
+ }
+ }
+ }
+}
+
+/// How the TCP client reconnects after the connection to the server is lost.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct TcpReconnectionConfig {
+ pub(crate) inner: RustTcpClientReconnectionConfig,
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl TcpReconnectionConfig {
+ /// Constructs a reconnection policy.
+ ///
+ /// 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.
+ ///
+ /// Raises:
+ /// 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<bool>,
+ #[gen_stub(override_type(type_repr = "builtins.int | None"))]
max_retries: Option<i64>,
+ #[gen_stub(override_type(type_repr = "datetime.timedelta | None",
imports=("datetime")))]
+ interval: Option<Py<PyDelta>>,
+ #[gen_stub(override_type(type_repr = "datetime.timedelta | None",
imports=("datetime")))]
+ reestablish_after: Option<Py<PyDelta>>,
+ ) -> PyResult<Self> {
+ 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)
+ .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,
+ max_retries,
+ interval,
+ reestablish_after: reestablish_after
+ .as_ref()
+ .map(py_delta_to_iggy_duration)
+ .transpose()?
+ .unwrap_or(defaults.reestablish_after),
+ },
+ })
+ }
+
+ #[getter]
+ fn enabled(&self) -> bool {
+ self.inner.enabled
+ }
+
+ #[gen_stub(override_return_type(type_repr = "builtins.int | None"))]
+ #[getter]
+ fn max_retries(&self) -> Option<u32> {
+ self.inner.max_retries
+ }
+
+ #[gen_stub(override_return_type(type_repr = "datetime.timedelta",
imports=("datetime")))]
+ #[getter]
+ fn interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyDelta>> {
+ 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<Bound<'a,
PyDelta>> {
+ 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={})",
+ python_bool(self.inner.enabled),
+ duration_repr(self.inner.interval),
+ duration_repr(self.inner.reestablish_after),
+ )
+ }
+}
+
+/// Configuration for the TCP transport, accepted by `IggyClient(...)`.
+///
+/// Every field is keyword-only and optional.
+#[gen_stub_pyclass]
+#[pyclass(from_py_object)]
+#[derive(Clone)]
+pub struct TcpConfig {
+ inner: Arc<RustTcpClientConfig>,
+}
+
+impl TcpConfig {
+ /// The configuration in the shape `TcpClient::create` expects.
+ pub(crate) fn client_config(&self) -> Arc<RustTcpClientConfig> {
+ self.inner.clone()
+ }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl TcpConfig {
+ /// Constructs a TCP configuration.
+ ///
+ /// 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. 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. 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, 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.
+ ///
+ /// Raises:
+ /// 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 = (
+ *,
+ server_address=None,
+ auto_login=None,
+ reconnection=None,
+ heartbeat_interval=None,
+ tls_enabled=None,
+ tls_domain=None,
+ tls_ca_file=None,
+ tls_validate_certificate=None,
+ nodelay=None,
+ ))]
+ #[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<AutoLogin>,
+ #[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<Py<PyDelta>>,
+ #[gen_stub(override_type(type_repr = "builtins.bool | None"))]
tls_enabled: Option<bool>,
+ #[gen_stub(override_type(type_repr = "builtins.str | None"))]
tls_domain: Option<String>,
+ #[gen_stub(override_type(type_repr = "builtins.str | None"))]
tls_ca_file: Option<String>,
+ #[gen_stub(override_type(type_repr = "builtins.bool | None"))]
+ tls_validate_certificate: Option<bool>,
+ #[gen_stub(override_type(type_repr = "builtins.bool | None"))]
nodelay: Option<bool>,
+ ) -> PyResult<Self> {
+ // 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| 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 {
+ inner: Arc::new(inner),
+ })
+ }
+
+ #[getter]
+ fn server_address(&self) -> String {
+ self.inner.server_address.clone()
+ }
+
+ #[getter]
+ fn auto_login(&self) -> AutoLogin {
+ AutoLogin {
+ inner: self.inner.auto_login.clone(),
+ }
+ }
+
+ #[getter]
+ fn reconnection(&self) -> TcpReconnectionConfig {
+ TcpReconnectionConfig {
+ inner: self.inner.reconnection.clone(),
+ }
+ }
+
+ #[gen_stub(override_return_type(type_repr = "datetime.timedelta",
imports=("datetime")))]
+ #[getter]
+ fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a,
PyDelta>> {
+ 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<String> {
+ 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 {
+ 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={}, tls_domain={:?},
tls_ca_file={tls_ca_file}, tls_validate_certificate={}, nodelay={})",
+ self.inner.server_address,
+ self.auto_login().__repr__(),
+ self.reconnection().__repr__(),
+ 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 {
+ #[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 4d64fc626..6a6e69a87 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};
@@ -27,8 +26,8 @@ use iggy::prelude::{
ConsumerGroupMember as RustConsumerGroupMember, IggyConsumer as
RustIggyConsumer, IggyDuration,
IggyError, ReceivedMessage,
};
-use pyo3::exceptions::{PyStopAsyncIteration, PyValueError};
-use pyo3::types::{PyDelta, PyDeltaAccess};
+use pyo3::exceptions::PyStopAsyncIteration;
+use pyo3::types::PyDelta;
use pyo3::prelude::*;
use pyo3_async_runtimes::TaskLocals;
@@ -39,12 +38,12 @@ use tokio::sync::Mutex;
use tokio::sync::oneshot::Sender;
use tokio::task::JoinHandle;
+use crate::duration::{py_delta_to_iggy_duration, reject_zero};
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,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
`PyRuntimeError`
- /// 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,
@@ -116,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
`PyRuntimeError`
- /// 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,
@@ -137,7 +134,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 +146,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,
@@ -435,17 +432,12 @@ impl TryFrom<&AutoCommit> for RustAutoCommit {
fn try_from(val: &AutoCommit) -> PyResult<RustAutoCommit> {
Ok(match val {
AutoCommit::Disabled() => RustAutoCommit::Disabled,
- AutoCommit::Interval(delta) => {
- let duration = py_delta_to_iggy_duration(delta)?;
- RustAutoCommit::Interval(duration)
- }
+ AutoCommit::Interval(delta) =>
RustAutoCommit::Interval(auto_commit_interval(delta)?),
AutoCommit::IntervalOrWhen(delta, when) => {
- let duration = py_delta_to_iggy_duration(delta)?;
- RustAutoCommit::IntervalOrWhen(duration, when.into())
+ RustAutoCommit::IntervalOrWhen(auto_commit_interval(delta)?,
when.into())
}
AutoCommit::IntervalOrAfter(delta, after) => {
- let duration = py_delta_to_iggy_duration(delta)?;
- RustAutoCommit::IntervalOrAfter(duration, after.into())
+ RustAutoCommit::IntervalOrAfter(auto_commit_interval(delta)?,
after.into())
}
AutoCommit::When(when) => RustAutoCommit::When(when.into()),
AutoCommit::After(after) => RustAutoCommit::After(after.into()),
@@ -453,6 +445,10 @@ impl TryFrom<&AutoCommit> for RustAutoCommit {
}
}
+fn auto_commit_interval(delta: &Py<PyDelta>) -> PyResult<IggyDuration> {
+ 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)]
@@ -518,20 +514,3 @@ impl PyStubType for AutoCommitAfter {
TypeInfo::unqualified("AutoCommitAfter")
}
}
-
-pub fn py_delta_to_iggy_duration(delta1: &Py<PyDelta>) ->
PyResult<IggyDuration> {
- Python::attach(|py| {
- let delta = delta1.bind(py);
- let total_seconds = i64::from(delta.get_days()) * 86_400 +
i64::from(delta.get_seconds());
- if total_seconds < 0 {
- return Err(PyValueError::new_err(
- "duration must not be negative".to_string(),
- ));
- }
- let nanos = (delta.get_microseconds() * 1_000) as u32;
- Ok(IggyDuration::new(Duration::new(
- total_seconds as u64,
- nanos,
- )))
- })
-}
diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs
new file mode 100644
index 000000000..9b2b419fe
--- /dev/null
+++ b/foreign/python/src/duration.rs
@@ -0,0 +1,65 @@
+// 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::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::PyDelta;
+use std::time::Duration;
+
+pub fn py_delta_to_iggy_duration(delta: &Py<PyDelta>) ->
PyResult<IggyDuration> {
+ Python::attach(|py| {
+ // 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::<Duration>()
+ .map(IggyDuration::from)
+ .map_err(|_| PyValueError::new_err("duration must not be
negative"))
+ })
+}
+
+pub fn iggy_duration_to_py_delta(
+ py: Python<'_>,
+ duration: IggyDuration,
+) -> PyResult<Bound<'_, PyDelta>> {
+ 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<IggyDuration> {
+ if duration.is_zero() {
+ return Err(PyValueError::new_err(format!(
+ "'{parameter}' must not be zero"
+ )));
+ }
+ Ok(duration)
+}
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 985476ff7..9c7f1efaa 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 permissions;
mod receive_message;
@@ -27,6 +29,7 @@ mod user;
mod user_headers;
use client::IggyClient;
+use config::{AutoLogin, TcpConfig, TcpReconnectionConfig};
use consumer::{
AutoCommit, AutoCommitAfter, AutoCommitWhen, ConsumerGroup,
ConsumerGroupDetails,
ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator,
@@ -40,7 +43,7 @@ use topic::{IggyExpiry, MaxTopicSize, Partition, Topic,
TopicDetails};
use user::{UserInfo, UserInfoDetails, UserStatus};
use user_headers::{HeaderKey, HeaderValue, UserHeaders};
-/// 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::<SendMessage>()?;
@@ -48,6 +51,9 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) ->
PyResult<()> {
m.add_class::<SendMessagesConfirmation>()?;
m.add_class::<ReceiveMessage>()?;
m.add_class::<IggyClient>()?;
+ m.add_class::<AutoLogin>()?;
+ m.add_class::<TcpConfig>()?;
+ m.add_class::<TcpReconnectionConfig>()?;
m.add_class::<StreamDetails>()?;
m.add_class::<Topic>()?;
m.add_class::<TopicDetails>()?;
diff --git a/foreign/python/src/receive_message.rs
b/foreign/python/src/receive_message.rs
index ecabb6cf2..ddb14e623 100644
--- a/foreign/python/src/receive_message.rs
+++ b/foreign/python/src/receive_message.rs
@@ -24,7 +24,7 @@ use pyo3_stub_gen::derive::{gen_stub_pyclass,
gen_stub_pyclass_complex_enum, gen
use crate::user_headers::{UserHeaders, rust_user_headers_to_py};
/// 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 e63b1a2f7..bfa3cdd67 100644
--- a/foreign/python/src/send_message.rs
+++ b/foreign/python/src/send_message.rs
@@ -30,8 +30,6 @@ use pyo3_stub_gen::{
use crate::user_headers::py_user_headers_to_rust;
/// 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 {
diff --git a/foreign/python/src/topic.rs b/foreign/python/src/topic.rs
index 178f90dd5..40a7db228 100644
--- a/foreign/python/src/topic.rs
+++ b/foreign/python/src/topic.rs
@@ -15,8 +15,6 @@
// specific language governing permissions and limitations
// under the License.
-use std::time::Duration;
-
use iggy::prelude::{
IggyByteSize, IggyExpiry as RustIggyExpiry, MaxTopicSize as
RustMaxTopicSize,
Partition as RustPartition, Topic as RustTopic, TopicDetails as
RustTopicDetails,
@@ -26,7 +24,7 @@ use pyo3::prelude::*;
use pyo3::types::PyDelta;
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_complex_enum,
gen_stub_pymethods};
-use crate::consumer::py_delta_to_iggy_duration;
+use crate::duration::{iggy_duration_to_py_delta, py_delta_to_iggy_duration};
/// The expiry of the messages in a topic.
#[gen_stub_pyclass_complex_enum]
@@ -55,7 +53,14 @@ impl TryFrom<RustIggyExpiry> for IggyExpiry {
Ok(match expiry {
RustIggyExpiry::ServerDefault => IggyExpiry::ServerDefault(),
RustIggyExpiry::ExpireDuration(duration) =>
IggyExpiry::ExpireDuration {
- duration: iggy_duration_to_py_delta(duration.get_duration())?,
+ duration: Python::attach(|py| {
+ iggy_duration_to_py_delta(py, duration).map(|delta|
delta.unbind())
+ })
+ .map_err(|err| {
+ PyValueError::new_err(format!(
+ "topic message expiry duration does not fit within
timedelta bounds: {err}"
+ ))
+ })?,
},
RustIggyExpiry::NeverExpire => IggyExpiry::NeverExpire(),
})
@@ -93,26 +98,6 @@ impl TryFrom<&IggyExpiry> for RustIggyExpiry {
}
}
-fn iggy_duration_to_py_delta(duration: Duration) -> PyResult<Py<PyDelta>> {
- let days = duration.as_secs() / 86_400;
- let secs_of_day = duration.as_secs() % 86_400;
- Python::attach(|py| {
- PyDelta::new(
- py,
- days as i32,
- secs_of_day as i32,
- duration.subsec_micros() as i32,
- true,
- )
- .map(|delta| delta.unbind())
- .map_err(|err| {
- PyValueError::new_err(format!(
- "topic message expiry duration does not fit within timedelta
bounds: {err}"
- ))
- })
- })
-}
-
/// The maximum size of a topic.
#[gen_stub_pyclass_complex_enum]
#[pyclass]
diff --git a/foreign/python/tests/conftest.py b/foreign/python/tests/conftest.py
index aa54ff50d..3ab97065f 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
new file mode 100644
index 000000000..78df23459
--- /dev/null
+++ b/foreign/python/tests/test_client_config.py
@@ -0,0 +1,459 @@
+# 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.
+"""
+
+import ast
+from collections.abc import Callable
+from datetime import timedelta
+
+import pytest
+
+from apache_iggy import (
+ AutoCommit,
+ AutoCommitAfter,
+ AutoCommitWhen,
+ AutoLogin,
+ IggyClient,
+ IggyExpiry,
+ TcpConfig,
+ TcpReconnectionConfig,
+)
+
+from .utils import get_server_config, wait_for_ping, wait_for_server
+
+
[email protected]
+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)
+
+
[email protected]
+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)
+
+ @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)
+
+ @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))
+
+ 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))
+
+ assert reconnection.interval == timedelta(days=30_000)
+
+ def test_maximum_interval_round_trips(self):
+ """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)
+
+
[email protected]
+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)
+
+ 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"],
+ )
+ 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)
+
+ 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))
+
+ 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))
+
+
[email protected]
+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")
+
+ def test_negative_message_expiry_is_rejected(self):
+ """Test that the negative-duration rule reaches create_topic.
+
+ The check runs 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=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),
+ )
+
+
[email protected]
+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_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_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."""
+ 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()