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

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


The following commit(s) were added to refs/heads/master by this push:
     new 35d2c4244 feat(python): let poll_messages take a consumer (#3877)
35d2c4244 is described below

commit 35d2c4244d09d06bab86ecadc30f6e3d391a0b5d
Author: Ethan Lin <[email protected]>
AuthorDate: Thu Aug 20 21:28:51 2026 +0800

    feat(python): let poll_messages take a consumer (#3877)
---
 bdd/python/tests/test_basic_messaging.py           |   3 +-
 examples/python/basic/consumer.py                  |   4 +-
 examples/python/getting-started/consumer.py        |   3 +
 examples/python/message-headers/common.py          |   7 +-
 .../message-headers/plain-headers/consumer.py      |   2 +-
 .../message-headers/typed-headers/consumer.py      |   2 +-
 foreign/python/apache_iggy.pyi                     | 302 ++++++++++-------
 foreign/python/src/client.rs                       |  17 +-
 foreign/python/src/consumer.rs                     |  31 +-
 foreign/python/src/identifier.rs                   |  20 +-
 foreign/python/src/lib.rs                          |   3 +-
 foreign/python/tests/test_message_operations.py    | 377 +++++++++++++++++++++
 foreign/python/tests/test_tls.py                   |   3 +-
 13 files changed, 620 insertions(+), 154 deletions(-)

diff --git a/bdd/python/tests/test_basic_messaging.py 
b/bdd/python/tests/test_basic_messaging.py
index d489ef4e9..cbaf3869a 100644
--- a/bdd/python/tests/test_basic_messaging.py
+++ b/bdd/python/tests/test_basic_messaging.py
@@ -22,7 +22,7 @@ Basic messaging BDD test implementation for Python SDK
 import asyncio
 import socket
 
-from apache_iggy import IggyClient, PollingStrategy, SendMessage
+from apache_iggy import Consumer, IggyClient, PollingStrategy, SendMessage
 from pytest_bdd import given, parsers, scenarios, then, when
 
 # Load scenarios from the shared feature file
@@ -221,6 +221,7 @@ def poll_messages(context, stream_id, topic_id, 
partition_id, start_offset):
         context.last_polled_messages = await context.client.poll_messages(
             stream=stream_id,
             topic=topic_id,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Offset(value=start_offset),
             count=100,  # Poll up to 100 messages
diff --git a/examples/python/basic/consumer.py 
b/examples/python/basic/consumer.py
index e20cf3175..e90df3c19 100644
--- a/examples/python/basic/consumer.py
+++ b/examples/python/basic/consumer.py
@@ -19,7 +19,7 @@ import argparse
 import asyncio
 from typing import NamedTuple
 
-from apache_iggy import IggyClient, PollingStrategy, ReceiveMessage
+from apache_iggy import Consumer, IggyClient, PollingStrategy, ReceiveMessage
 from loguru import logger
 
 STREAM_NAME = "sample-stream"
@@ -27,6 +27,7 @@ TOPIC_NAME = "sample-topic"
 STREAM_ID = 0
 TOPIC_ID = 0
 PARTITION_ID = 0
+CONSUMER_NAME = "sample-consumer"
 BATCHES_LIMIT = 5
 
 
@@ -73,6 +74,7 @@ async def consume_messages(client: IggyClient):
             polled_messages = await client.poll_messages(
                 stream=STREAM_NAME,
                 topic=TOPIC_NAME,
+                consumer=Consumer.Single(CONSUMER_NAME),
                 partition_id=PARTITION_ID,
                 polling_strategy=PollingStrategy.Next(),
                 count=messages_per_batch,
diff --git a/examples/python/getting-started/consumer.py 
b/examples/python/getting-started/consumer.py
index db0a6edb1..651084104 100755
--- a/examples/python/getting-started/consumer.py
+++ b/examples/python/getting-started/consumer.py
@@ -23,6 +23,7 @@ from datetime import timedelta
 
 from apache_iggy import (
     AutoLogin,
+    Consumer,
     IggyClient,
     PollingStrategy,
     ReceiveMessage,
@@ -36,6 +37,7 @@ TOPIC_NAME = "sample-topic"
 STREAM_ID = 0
 TOPIC_ID = 0
 PARTITION_ID = 0
+CONSUMER_NAME = "sample-consumer"
 BATCHES_LIMIT = 5
 
 
@@ -150,6 +152,7 @@ async def consume_messages(client: IggyClient):
             polled_messages = await client.poll_messages(
                 stream=STREAM_NAME,
                 topic=TOPIC_NAME,
+                consumer=Consumer.Single(CONSUMER_NAME),
                 partition_id=PARTITION_ID,
                 polling_strategy=PollingStrategy.Next(),
                 count=messages_per_batch,
diff --git a/examples/python/message-headers/common.py 
b/examples/python/message-headers/common.py
index 967afe930..6f8acef71 100644
--- a/examples/python/message-headers/common.py
+++ b/examples/python/message-headers/common.py
@@ -25,6 +25,7 @@ from dataclasses import dataclass
 from enum import Enum
 
 from apache_iggy import (
+    Consumer,
     HeaderKey,
     HeaderValue,
     IggyClient,
@@ -176,11 +177,14 @@ async def produce_messages(client: IggyClient, 
build_headers: HeadersBuilder) ->
     logger.info(f"Sent {sent_batches} batches of messages, exiting.")
 
 
-async def consume_messages(client: IggyClient, handle_message: MessageHandler) 
-> None:
+async def consume_messages(
+    client: IggyClient, handle_message: MessageHandler, consumer_name: str
+) -> None:
     interval = 0.5
     logger.info(
         f"Messages will be consumed from stream: {STREAM_NAME}, "
         f"topic: {TOPIC_NAME}, partition: {PARTITION_ID} "
+        f"as consumer: {consumer_name} "
         f"with interval {interval * 1000} ms."
     )
     consumed_batches = 0
@@ -191,6 +195,7 @@ async def consume_messages(client: IggyClient, 
handle_message: MessageHandler) -
             polled_messages = await client.poll_messages(
                 stream=STREAM_NAME,
                 topic=TOPIC_NAME,
+                consumer=Consumer.Single(consumer_name),
                 partition_id=PARTITION_ID,
                 polling_strategy=PollingStrategy.Next(),
                 count=MESSAGES_PER_BATCH,
diff --git a/examples/python/message-headers/plain-headers/consumer.py 
b/examples/python/message-headers/plain-headers/consumer.py
index 92874b022..db18d7599 100644
--- a/examples/python/message-headers/plain-headers/consumer.py
+++ b/examples/python/message-headers/plain-headers/consumer.py
@@ -70,7 +70,7 @@ def get_message_type(
 async def main() -> None:
     args = parse_args()
     client = await connect(args.connection_string)
-    await consume_messages(client, handle_message)
+    await consume_messages(client, handle_message, "plain-headers-consumer")
 
 
 if __name__ == "__main__":
diff --git a/examples/python/message-headers/typed-headers/consumer.py 
b/examples/python/message-headers/typed-headers/consumer.py
index e24d531b2..60cbffc80 100644
--- a/examples/python/message-headers/typed-headers/consumer.py
+++ b/examples/python/message-headers/typed-headers/consumer.py
@@ -70,7 +70,7 @@ def get_message_type(headers: UserHeaders | None) -> 
OrderType | None:
 async def main() -> None:
     args = parse_args()
     client = await connect(args.connection_string)
-    await consume_messages(client, handle_message)
+    await consume_messages(client, handle_message, "typed-headers-consumer")
 
 
 if __name__ == "__main__":
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 50b65786d..1bdd5cafc 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -30,12 +30,13 @@ __all__ = [
     "AutoCommitAfter",
     "AutoCommitWhen",
     "AutoLogin",
+    "Consumer",
     "ConsumerGroup",
     "ConsumerGroupDetails",
     "ConsumerGroupMember",
+    "GlobalPermissions",
     "HeaderKey",
     "HeaderValue",
-    "GlobalPermissions",
     "IggyClient",
     "IggyConsumer",
     "IggyExpiry",
@@ -55,10 +56,10 @@ __all__ = [
     "Topic",
     "TopicDetails",
     "TopicPermissions",
+    "UserHeaders",
     "UserInfo",
     "UserInfoDetails",
     "UserStatus",
-    "UserHeaders",
 ]
 
 class AutoCommit:
@@ -289,6 +290,35 @@ class AutoLogin:
         """
     def __repr__(self) -> builtins.str: ...
 
+class Consumer:
+    r"""
+    The consumer polling the messages. It selects both the consumer kind and 
the
+    identifier the server keys the stored offset on.
+    """
+    @typing.final
+    class Single(Consumer):
+        r"""
+        A regular consumer, owning its offset on the polled partition.
+        """
+
+        __match_args__ = ("id",)
+        @property
+        def id(self) -> builtins.str | builtins.int: ...
+        def __new__(cls, id: builtins.str | builtins.int) -> Consumer.Single: 
...
+
+    @typing.final
+    class Group(Consumer):
+        r"""
+        A member of the consumer group, sharing the group's offset.
+        """
+
+        __match_args__ = ("id",)
+        @property
+        def id(self) -> builtins.str | builtins.int: ...
+        def __new__(cls, id: builtins.str | builtins.int) -> Consumer.Group: 
...
+
+    ...
+
 @typing.final
 class ConsumerGroup:
     @property
@@ -358,6 +388,105 @@ class ConsumerGroupMember:
         Gets the collection of partitions the consumer group member is 
consuming.
         """
 
[email protected]
+class GlobalPermissions:
+    r"""
+    Global permissions, applied to all streams without specifying them one by 
one.
+    """
+    @property
+    def manage_servers(self) -> builtins.bool:
+        r"""
+        Whether managing servers is allowed; includes `read_servers`.
+        """
+    @property
+    def read_servers(self) -> builtins.bool:
+        r"""
+        Whether reading server info (stats, clients) is allowed.
+        """
+    @property
+    def manage_users(self) -> builtins.bool:
+        r"""
+        Whether managing users is allowed; includes `read_users`.
+        """
+    @property
+    def read_users(self) -> builtins.bool:
+        r"""
+        Whether reading user info is allowed.
+        """
+    @property
+    def manage_streams(self) -> builtins.bool:
+        r"""
+        Whether managing all streams is allowed; includes `read_streams` and
+        `manage_topics`.
+        """
+    @property
+    def read_streams(self) -> builtins.bool:
+        r"""
+        Whether reading all streams is allowed; includes `read_topics`.
+        """
+    @property
+    def manage_topics(self) -> builtins.bool:
+        r"""
+        Whether managing all topics is allowed; includes `read_topics` and
+        `send_messages`.
+        """
+    @property
+    def read_topics(self) -> builtins.bool:
+        r"""
+        Whether reading all topics and managing consumer groups is allowed;
+        includes `poll_messages`.
+        """
+    @property
+    def poll_messages(self) -> builtins.bool:
+        r"""
+        Whether polling messages from all streams and managing consumer
+        offsets is allowed.
+        """
+    @property
+    def send_messages(self) -> builtins.bool:
+        r"""
+        Whether sending messages to all streams is allowed.
+        """
+    def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
+    def __new__(
+        cls,
+        *,
+        manage_servers: builtins.bool = False,
+        read_servers: builtins.bool = False,
+        manage_users: builtins.bool = False,
+        read_users: builtins.bool = False,
+        manage_streams: builtins.bool = False,
+        read_streams: builtins.bool = False,
+        manage_topics: builtins.bool = False,
+        read_topics: builtins.bool = False,
+        poll_messages: builtins.bool = False,
+        send_messages: builtins.bool = False,
+    ) -> GlobalPermissions:
+        r"""
+        Create global permissions. Every flag defaults to `False`.
+
+        The `includes` notes below are transitive: a flag also grants 
everything
+        its included flags grant. For example `manage_streams` includes
+        `manage_topics`, and through it `read_topics`, `poll_messages`, and
+        `send_messages`.
+
+        Args:
+            manage_servers: Allow managing servers; includes `read_servers`.
+            read_servers: Allow reading server info (stats, clients).
+            manage_users: Allow managing users; includes `read_users`.
+            read_users: Allow reading user info.
+            manage_streams: Allow managing all streams; includes `read_streams`
+                and `manage_topics`.
+            read_streams: Allow reading all streams; includes `read_topics`.
+            manage_topics: Allow managing all topics; includes `read_topics`
+                and `send_messages`.
+            read_topics: Allow reading all topics and managing consumer groups
+                (including create and delete); includes `poll_messages`.
+            poll_messages: Allow polling messages from all streams and managing
+                consumer offsets.
+            send_messages: Allow sending messages to all streams.
+        """
+
 class HeaderKey:
     r"""
     Typed key for an Iggy user header.
@@ -708,105 +837,6 @@ class HeaderValue:
         def value(self) -> builtins.float: ...
         def __new__(cls, value: builtins.float) -> HeaderValue.Float64: ...
 
[email protected]
-class GlobalPermissions:
-    r"""
-    Global permissions, applied to all streams without specifying them one by 
one.
-    """
-    @property
-    def manage_servers(self) -> builtins.bool:
-        r"""
-        Whether managing servers is allowed; includes `read_servers`.
-        """
-    @property
-    def read_servers(self) -> builtins.bool:
-        r"""
-        Whether reading server info (stats, clients) is allowed.
-        """
-    @property
-    def manage_users(self) -> builtins.bool:
-        r"""
-        Whether managing users is allowed; includes `read_users`.
-        """
-    @property
-    def read_users(self) -> builtins.bool:
-        r"""
-        Whether reading user info is allowed.
-        """
-    @property
-    def manage_streams(self) -> builtins.bool:
-        r"""
-        Whether managing all streams is allowed; includes `read_streams` and
-        `manage_topics`.
-        """
-    @property
-    def read_streams(self) -> builtins.bool:
-        r"""
-        Whether reading all streams is allowed; includes `read_topics`.
-        """
-    @property
-    def manage_topics(self) -> builtins.bool:
-        r"""
-        Whether managing all topics is allowed; includes `read_topics` and
-        `send_messages`.
-        """
-    @property
-    def read_topics(self) -> builtins.bool:
-        r"""
-        Whether reading all topics and managing consumer groups is allowed;
-        includes `poll_messages`.
-        """
-    @property
-    def poll_messages(self) -> builtins.bool:
-        r"""
-        Whether polling messages from all streams and managing consumer
-        offsets is allowed.
-        """
-    @property
-    def send_messages(self) -> builtins.bool:
-        r"""
-        Whether sending messages to all streams is allowed.
-        """
-    def __eq__(self, other: builtins.object, /) -> builtins.bool: ...
-    def __new__(
-        cls,
-        *,
-        manage_servers: builtins.bool = False,
-        read_servers: builtins.bool = False,
-        manage_users: builtins.bool = False,
-        read_users: builtins.bool = False,
-        manage_streams: builtins.bool = False,
-        read_streams: builtins.bool = False,
-        manage_topics: builtins.bool = False,
-        read_topics: builtins.bool = False,
-        poll_messages: builtins.bool = False,
-        send_messages: builtins.bool = False,
-    ) -> GlobalPermissions:
-        r"""
-        Create global permissions. Every flag defaults to `False`.
-
-        The `includes` notes below are transitive: a flag also grants 
everything
-        its included flags grant. For example `manage_streams` includes
-        `manage_topics`, and through it `read_topics`, `poll_messages`, and
-        `send_messages`.
-
-        Args:
-            manage_servers: Allow managing servers; includes `read_servers`.
-            read_servers: Allow reading server info (stats, clients).
-            manage_users: Allow managing users; includes `read_users`.
-            read_users: Allow reading user info.
-            manage_streams: Allow managing all streams; includes `read_streams`
-                and `manage_topics`.
-            read_streams: Allow reading all streams; includes `read_topics`.
-            manage_topics: Allow managing all topics; includes `read_topics`
-                and `send_messages`.
-            read_topics: Allow reading all topics and managing consumer groups
-                (including create and delete); includes `poll_messages`.
-            poll_messages: Allow polling messages from all streams and managing
-                consumer offsets.
-            send_messages: Allow sending messages to all streams.
-        """
-
 @typing.final
 class IggyClient:
     r"""
@@ -1315,13 +1345,17 @@ class IggyClient:
         self,
         stream: builtins.str | builtins.int,
         topic: builtins.str | builtins.int,
-        partition_id: builtins.int,
+        *,
+        consumer: Consumer,
         polling_strategy: PollingStrategy,
         count: builtins.int,
         auto_commit: builtins.bool,
+        partition_id: builtins.int | None = None,
     ) -> collections.abc.Awaitable[list[ReceiveMessage]]:
         r"""
-        Polls for messages from the specified topic and partition.
+        Polls for messages from the specified topic on behalf of the given 
consumer.
+        Omitting `partition_id` reads partition 0 for a regular consumer, and
+        polls the member's assigned partitions for a consumer group.
         Returns a list of received messages or a RuntimeError on failure.
         """
     def consumer_group(
@@ -1714,6 +1748,10 @@ class ReceiveMessage:
     def user_headers(self) -> UserHeaders | None:
         r"""
         Retrieves user headers attached to the received message.
+
+        Returns `None` when no headers are present or when the headers
+        on the wire are structurally malformed (those errors are logged
+        internally). Only known semantic decode errors raise `ValueError`.
         """
 
 @typing.final
@@ -2188,6 +2226,36 @@ class TopicPermissions:
             send_messages: Allow sending messages to the topic.
         """
 
[email protected]
+class UserHeaders(dict):
+    r"""
+    User headers dictionary returned by `ReceiveMessage.user_headers`.
+
+    This is a regular `dict[HeaderKey, HeaderValue]` (so all mapping
+    operations work) that additionally exposes `to_scalar_dict` for the 
convenient
+    scalar form.
+    """
+    def __new__(cls, mapping: dict | None = None) -> UserHeaders:
+        r"""
+        Wraps a mapping so its entries gain the `to_scalar_dict` helper.
+
+        Accepts a dict whose keys and values can each independently be
+        `HeaderKey`/`HeaderValue` or a plain scalar (`str | bytes | bool |
+        int | float`). The inherited `dict` initializer copies the provided
+        mapping.
+        """
+    def __setitem__(self, key: typing.Any, value: typing.Any) -> None: ...
+    def to_scalar_dict(
+        self,
+    ) -> dict[str | bytes | bool | int | float, str | bytes | bool | int | 
float]:
+        r"""
+        Converts these headers into the convenient plain dictionary form.
+
+        Returns an error if two distinct typed keys map to the same plain
+        Python scalar (e.g., `UnsignedInt8(1)` and `UnsignedInt16(1)` both
+        become `int(1)`), or if a stored field cannot be decoded.
+        """
+
 @typing.final
 class UserInfo:
     @property
@@ -2253,31 +2321,3 @@ class UserStatus(enum.Enum):
     r"""
     The user account is inactive and cannot be used.
     """
-
-class UserHeaders(dict):
-    r"""
-    User headers dictionary returned by `ReceiveMessage.user_headers`.
-
-    This is a regular `dict[HeaderKey, HeaderValue]` (so all mapping
-    operations work) that additionally exposes `to_scalar_dict` for the 
convenient
-    scalar form.
-    """
-    def __new__(cls, mapping: dict | None = None) -> UserHeaders:
-        r"""
-        Wraps a mapping so its entries gain the `to_scalar_dict` helper.
-
-        Accepts a dict whose keys and values can each independently be
-        `HeaderKey`/`HeaderValue` or a plain scalar (`str | bytes | bool |
-        int | float`). The inherited `dict` initializer copies the provided
-        mapping.
-        """
-    def to_scalar_dict(
-        self,
-    ) -> dict[str | bytes | bool | int | float, str | bytes | bool | int | 
float]:
-        r"""
-        Converts these headers into the convenient plain dictionary form.
-
-        Returns an error if two distinct typed keys map to the same plain
-        Python scalar (e.g., `UnsignedInt8(1)` and `UnsignedInt16(1)` both
-        become `int(1)`), or if a stored field cannot be decoded.
-        """
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 816ba4e61..00f4122f3 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -33,8 +33,8 @@ use std::sync::Arc;
 
 use crate::config::PyClientConfig;
 use crate::consumer::{
-    AutoCommit, ConsumerGroup as PyConsumerGroup, ConsumerGroupDetails as 
PyConsumerGroupDetails,
-    IggyConsumer,
+    AutoCommit, Consumer as PyConsumer, ConsumerGroup as PyConsumerGroup,
+    ConsumerGroupDetails as PyConsumerGroupDetails, IggyConsumer,
 };
 use crate::duration::{py_delta_to_iggy_duration, reject_zero};
 use crate::identifier::PyIdentifier;
@@ -1035,21 +1035,25 @@ impl IggyClient {
         })
     }
 
-    /// Polls for messages from the specified topic and partition.
+    /// Polls for messages from the specified topic on behalf of the given 
consumer.
+    /// Omitting `partition_id` reads partition 0 for a regular consumer, and
+    /// polls the member's assigned partitions for a consumer group.
     /// Returns a list of received messages or a RuntimeError on failure.
     #[allow(clippy::too_many_arguments)]
+    #[pyo3(signature = (stream, topic, *, consumer, polling_strategy, count, 
auto_commit, partition_id = None))]
     
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[ReceiveMessage]]",
 imports=("collections.abc")))]
     fn poll_messages<'a>(
         &self,
         py: Python<'a>,
         stream: PyIdentifier,
         topic: PyIdentifier,
-        partition_id: u32,
+        consumer: &PyConsumer,
         polling_strategy: &PollingStrategy,
         count: u32,
         auto_commit: bool,
+        partition_id: Option<u32>,
     ) -> PyResult<Bound<'a, PyAny>> {
-        let consumer = RustConsumer::default();
+        let consumer = RustConsumer::try_from(consumer)?;
         let stream = Identifier::try_from(stream)?;
         let topic = Identifier::try_from(topic)?;
         let strategy: RustPollingStrategy = polling_strategy.into();
@@ -1061,7 +1065,7 @@ impl IggyClient {
                 .poll_messages(
                     &stream,
                     &topic,
-                    Some(partition_id),
+                    partition_id,
                     &consumer,
                     &strategy,
                     count,
@@ -1069,6 +1073,7 @@ impl IggyClient {
                 )
                 .await
                 .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            let partition_id = polled_messages.partition_id;
             let messages = polled_messages
                 .messages
                 .into_iter()
diff --git a/foreign/python/src/consumer.rs b/foreign/python/src/consumer.rs
index 6a6e69a87..7f8fe8303 100644
--- a/foreign/python/src/consumer.rs
+++ b/foreign/python/src/consumer.rs
@@ -21,10 +21,10 @@ use futures::StreamExt;
 use iggy::consumer_ext::{IggyConsumerMessageExt, MessageConsumer};
 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,
+    AutoCommitWhen as RustAutoCommitWhen, Consumer as RustConsumer,
+    ConsumerGroup as RustConsumerGroup, ConsumerGroupDetails as 
RustConsumerGroupDetails,
+    ConsumerGroupMember as RustConsumerGroupMember, Identifier, IggyConsumer 
as RustIggyConsumer,
+    IggyDuration, IggyError, ReceivedMessage,
 };
 use pyo3::exceptions::PyStopAsyncIteration;
 use pyo3::types::PyDelta;
@@ -215,6 +215,29 @@ impl IggyConsumer {
     }
 }
 
+/// The consumer polling the messages. It selects both the consumer kind and 
the
+/// identifier the server keys the stored offset on.
+#[derive(Clone)]
+#[gen_stub_pyclass_complex_enum]
+#[pyclass(from_py_object)]
+pub enum Consumer {
+    /// A regular consumer, owning its offset on the polled partition.
+    Single { id: PyIdentifier },
+    /// A member of the consumer group, sharing the group's offset.
+    Group { id: PyIdentifier },
+}
+
+impl TryFrom<&Consumer> for RustConsumer {
+    type Error = PyErr;
+
+    fn try_from(value: &Consumer) -> PyResult<RustConsumer> {
+        Ok(match value {
+            Consumer::Single { id } => 
RustConsumer::new(Identifier::try_from(id)?),
+            Consumer::Group { id } => 
RustConsumer::group(Identifier::try_from(id)?),
+        })
+    }
+}
+
 #[gen_stub_pyclass]
 #[pyclass]
 pub struct ConsumerGroup {
diff --git a/foreign/python/src/identifier.rs b/foreign/python/src/identifier.rs
index a113e5e54..f800e80bd 100644
--- a/foreign/python/src/identifier.rs
+++ b/foreign/python/src/identifier.rs
@@ -24,8 +24,8 @@ use pyo3::{
 };
 use pyo3_stub_gen::impl_stub_type;
 
-#[derive(FromPyObject, IntoPyObject)]
-pub(crate) enum PyIdentifier {
+#[derive(Clone, FromPyObject, IntoPyObject)]
+pub enum PyIdentifier {
     #[pyo3(transparent, annotation = "str")]
     String(String),
     #[pyo3(transparent, annotation = "int")]
@@ -33,21 +33,29 @@ pub(crate) enum PyIdentifier {
 }
 impl_stub_type!(PyIdentifier = String | isize);
 
-impl TryFrom<PyIdentifier> for Identifier {
+impl TryFrom<&PyIdentifier> for Identifier {
     type Error = PyErr;
 
-    fn try_from(py_identifier: PyIdentifier) -> Result<Self, Self::Error> {
+    fn try_from(py_identifier: &PyIdentifier) -> Result<Self, Self::Error> {
         match py_identifier {
             PyIdentifier::String(s) => {
-                Identifier::from_str(&s).map_err(|e| 
PyErr::new::<PyValueError, _>(e.to_string()))
+                Identifier::from_str(s).map_err(|e| PyErr::new::<PyValueError, 
_>(e.to_string()))
             }
             PyIdentifier::Int(i) => {
-                Identifier::numeric(i).map_err(|e| PyErr::new::<PyValueError, 
_>(e.to_string()))
+                Identifier::numeric(*i).map_err(|e| PyErr::new::<PyValueError, 
_>(e.to_string()))
             }
         }
     }
 }
 
+impl TryFrom<PyIdentifier> for Identifier {
+    type Error = PyErr;
+
+    fn try_from(py_identifier: PyIdentifier) -> Result<Self, Self::Error> {
+        Identifier::try_from(&py_identifier)
+    }
+}
+
 impl TryFrom<&Identifier> for PyIdentifier {
     type Error = PyErr;
 
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 7910af77b..5f2e12826 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -32,7 +32,7 @@ mod user_headers;
 use client::IggyClient;
 use config::{AutoLogin, TcpConfig, TcpReconnectionConfig};
 use consumer::{
-    AutoCommit, AutoCommitAfter, AutoCommitWhen, ConsumerGroup, 
ConsumerGroupDetails,
+    AutoCommit, AutoCommitAfter, AutoCommitWhen, Consumer, ConsumerGroup, 
ConsumerGroupDetails,
     ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator,
 };
 use options::OptionSpec;
@@ -63,6 +63,7 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<MaxTopicSize>()?;
     m.add_class::<OptionSpec>()?;
     m.add_class::<Partition>()?;
+    m.add_class::<Consumer>()?;
     m.add_class::<ConsumerGroup>()?;
     m.add_class::<ConsumerGroupDetails>()?;
     m.add_class::<ConsumerGroupMember>()?;
diff --git a/foreign/python/tests/test_message_operations.py 
b/foreign/python/tests/test_message_operations.py
index d9128a0e1..bbf5b1d0e 100644
--- a/foreign/python/tests/test_message_operations.py
+++ b/foreign/python/tests/test_message_operations.py
@@ -21,6 +21,7 @@ import uuid
 import pytest
 
 from apache_iggy import (
+    Consumer,
     HeaderKey,
     HeaderValue,
     IggyClient,
@@ -58,6 +59,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -93,6 +95,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -136,6 +139,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -170,6 +174,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -226,6 +231,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -276,6 +282,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -318,6 +325,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -619,6 +627,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -660,6 +669,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -699,6 +709,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -735,6 +746,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=1,
@@ -769,6 +781,7 @@ class TestMessageOperations:
         polled_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -806,6 +819,7 @@ class TestMessageOperations:
             await iggy_client.poll_messages(
                 stream=stream_name,
                 topic=topic_name,
+                consumer=Consumer.Single(1),
                 partition_id=partition_id,
                 polling_strategy=PollingStrategy.First(),
                 count=0,
@@ -843,6 +857,7 @@ class TestMessageOperations:
             await iggy_client.poll_messages(
                 stream=stream_name,
                 topic=topic_name,
+                consumer=Consumer.Single(1),
                 partition_id=1,
                 polling_strategy=PollingStrategy.First(),
                 count=1,
@@ -875,6 +890,7 @@ class TestMessageOperations:
         last_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=1,
@@ -909,6 +925,7 @@ class TestMessageOperations:
         last_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Last(),
             count=2,
@@ -945,6 +962,7 @@ class TestMessageOperations:
         first_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -955,6 +973,7 @@ class TestMessageOperations:
         offset_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Offset(value=start_offset),
             count=10,
@@ -991,6 +1010,7 @@ class TestMessageOperations:
         first_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -1001,6 +1021,7 @@ class TestMessageOperations:
         offset_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             
polling_strategy=PollingStrategy.Offset(value=offset_beyond_newest),
             count=10,
@@ -1036,6 +1057,7 @@ class TestMessageOperations:
         first_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -1046,6 +1068,7 @@ class TestMessageOperations:
         timestamp_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Timestamp(value=start_timestamp),
             count=10,
@@ -1084,6 +1107,7 @@ class TestMessageOperations:
         first_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -1094,6 +1118,7 @@ class TestMessageOperations:
         timestamp_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             
polling_strategy=PollingStrategy.Timestamp(value=timestamp_after_newest),
             count=10,
@@ -1130,6 +1155,7 @@ class TestMessageOperations:
         first_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -1150,6 +1176,7 @@ class TestMessageOperations:
         next_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Next(),
             count=10,
@@ -1188,6 +1215,7 @@ class TestMessageOperations:
         first_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,
@@ -1208,6 +1236,7 @@ class TestMessageOperations:
         next_messages = await iggy_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.Next(),
             count=10,
@@ -1217,3 +1246,351 @@ class TestMessageOperations:
         assert [
             message.payload().decode("utf-8") for message in next_messages
         ] == existing_messages + new_messages
+
+    @pytest.mark.asyncio
+    async def 
test_poll_messages_with_distinct_consumers_keeps_offsets_independent(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test each consumer owns its offset, so both see the whole 
partition."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        test_messages = [f"Consumer isolation {i} - {unique_name()}" for i in 
range(3)]
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message(message) for message in test_messages],
+        )
+
+        for consumer_name in ("isolation-consumer-a", "isolation-consumer-b"):
+            polled_messages = await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Single(consumer_name),
+                partition_id=partition_id,
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+            assert [
+                message.payload().decode("utf-8") for message in 
polled_messages
+            ] == test_messages
+
+    @pytest.mark.asyncio
+    async def test_poll_messages_with_shared_consumer_shares_the_partition(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test two polls under the same consumer share one stored offset."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partition_id = 0
+        test_messages = [f"Shared consumer {i} - {unique_name()}" for i in 
range(3)]
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=partition_id,
+            messages=[Message(message) for message in test_messages],
+        )
+
+        first_poll = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single("shared-consumer"),
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.Next(),
+            count=10,
+            auto_commit=True,
+        )
+        assert [
+            message.payload().decode("utf-8") for message in first_poll
+        ] == test_messages
+
+        second_poll = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single("shared-consumer"),
+            partition_id=partition_id,
+            polling_strategy=PollingStrategy.Next(),
+            count=10,
+            auto_commit=True,
+        )
+        assert second_poll == []
+
+    @pytest.mark.asyncio
+    async def test_poll_messages_without_a_consumer_raises_type_error(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test the consumer argument is required."""
+        # Argument binding fails before the call reaches the server, so the
+        # stream and topic never have to exist.
+        with pytest.raises(TypeError, match="consumer"):
+            # pyrefly: ignore  # missing-argument
+            await iggy_client.poll_messages(
+                stream=unique_name(),
+                topic=unique_name(),
+                partition_id=0,
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "consumer", ["single", 1, None], ids=["str", "int", "none"]
+    )
+    async def test_poll_messages_with_a_non_consumer_raises_type_error(
+        self, iggy_client: IggyClient, unique_name, consumer
+    ):
+        """Test the consumer argument only accepts a Consumer."""
+        # Argument binding fails before the call reaches the server, so the
+        # stream and topic never have to exist.
+        with pytest.raises(TypeError, match="not an instance of 'Consumer'"):
+            # pyrefly: ignore  # bad-argument-type
+            await iggy_client.poll_messages(
+                stream=unique_name(),
+                topic=unique_name(),
+                consumer=consumer,
+                partition_id=0,
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+
+    @pytest.mark.parametrize("identifier", [-1, 2**32], ids=["negative", 
"above-u32"])
+    def test_consumer_rejects_numeric_ids_outside_u32(self, identifier):
+        """Test an out-of-range numeric id is refused when the consumer is 
built."""
+        with pytest.raises(TypeError):
+            Consumer.Single(identifier)
+        with pytest.raises(TypeError):
+            Consumer.Group(identifier)
+
+    def test_consumer_accepts_valid_numeric_ids(self):
+        """Test both consumer kinds keep the numeric id they were given."""
+        assert Consumer.Single(1).id == 1
+        assert Consumer.Group(4294967295).id == 4294967295
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("identifier", ["", "a" * 256], ids=["empty", 
"too-long"])
+    @pytest.mark.parametrize(
+        "consumer_kind", [Consumer.Single, Consumer.Group], ids=["single", 
"group"]
+    )
+    async def 
test_poll_messages_with_an_invalid_string_identifier_raises_value_error(
+        self, iggy_client: IggyClient, unique_name, identifier, consumer_kind
+    ):
+        """Test a string id is validated when it is converted, not when it is 
built."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+
+        with pytest.raises(ValueError, match="Invalid identifier"):
+            await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=consumer_kind(identifier),
+                partition_id=0,
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+
+    @pytest.mark.asyncio
+    async def test_poll_messages_without_partition_id_reads_partition_zero(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a regular consumer falls back to partition 0, not the whole 
topic."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        partitions_count = 3
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, 
partitions_count=partitions_count
+        )
+        for partition_id in range(partitions_count):
+            await iggy_client.send_messages(
+                stream=stream_name,
+                topic=topic_name,
+                partitioning=partition_id,
+                messages=[Message(f"Partition {partition_id}")],
+            )
+
+        polled_messages = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Single("partition-zero-consumer"),
+            polling_strategy=PollingStrategy.Next(),
+            count=10,
+            auto_commit=True,
+        )
+
+        assert [
+            (message.partition_id(), message.payload().decode("utf-8"))
+            for message in polled_messages
+        ] == [(0, "Partition 0")]
+
+    @pytest.mark.asyncio
+    async def 
test_poll_messages_with_consumer_group_rotates_assigned_partitions(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a sole group member reaches every partition over repeated 
polls."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        group_name = unique_name()
+        partitions_count = 3
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, 
partitions_count=partitions_count
+        )
+        await iggy_client.create_consumer_group(stream_name, topic_name, 
group_name)
+        await iggy_client.join_consumer_group(stream_name, topic_name, 
group_name)
+        for partition_id in range(partitions_count):
+            await iggy_client.send_messages(
+                stream=stream_name,
+                topic=topic_name,
+                partitioning=partition_id,
+                messages=[Message(f"Partition {partition_id}")],
+            )
+
+        polled = []
+        for _ in range(partitions_count):
+            polled_messages = await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Group(group_name),
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+            polled.extend(
+                (message.partition_id(), message.payload().decode("utf-8"))
+                for message in polled_messages
+            )
+
+        assert polled == [
+            (partition_id, f"Partition {partition_id}")
+            for partition_id in range(partitions_count)
+        ]
+
+    @pytest.mark.asyncio
+    async def test_poll_messages_with_an_unjoined_consumer_group_fails(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a group poll needs membership, since it has no assignment 
without it."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        group_name = unique_name()
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+        await iggy_client.create_consumer_group(stream_name, topic_name, 
group_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=0,
+            messages=[Message("Unjoined group")],
+        )
+
+        # The group exists; only the join is missing.
+        with pytest.raises(RuntimeError, match="was not found"):
+            await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Group(group_name),
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+
+    @pytest.mark.asyncio
+    async def 
test_poll_messages_with_consumer_group_honours_an_explicit_partition(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test an explicit partition overrides the rotation for a group 
member."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        group_name = unique_name()
+        partitions_count = 3
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, 
partitions_count=partitions_count
+        )
+        await iggy_client.create_consumer_group(stream_name, topic_name, 
group_name)
+        await iggy_client.join_consumer_group(stream_name, topic_name, 
group_name)
+        for partition_id in range(partitions_count):
+            await iggy_client.send_messages(
+                stream=stream_name,
+                topic=topic_name,
+                partitioning=partition_id,
+                messages=[Message(f"Partition {partition_id}")],
+            )
+
+        # Partition 0 is the fallback, so asking for the others is what proves
+        # the explicit id is honoured.
+        for requested_partition in (1, 2):
+            polled_messages = await iggy_client.poll_messages(
+                stream=stream_name,
+                topic=topic_name,
+                consumer=Consumer.Group(group_name),
+                partition_id=requested_partition,
+                polling_strategy=PollingStrategy.Next(),
+                count=10,
+                auto_commit=True,
+            )
+            assert [
+                (message.partition_id(), message.payload().decode("utf-8"))
+                for message in polled_messages
+            ] == [(requested_partition, f"Partition {requested_partition}")]
+
+    @pytest.mark.asyncio
+    async def test_poll_messages_with_consumer_group_reads_assigned_partitions(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a group member polls its assignment when no partition is 
given."""
+        stream_name = unique_name()
+        topic_name = unique_name()
+        group_name = unique_name()
+        test_messages = [f"Group polling {i} - {unique_name()}" for i in 
range(3)]
+
+        await iggy_client.create_stream(stream_name)
+        await iggy_client.create_topic(
+            stream=stream_name, name=topic_name, partitions_count=1
+        )
+        await iggy_client.create_consumer_group(stream_name, topic_name, 
group_name)
+        await iggy_client.join_consumer_group(stream_name, topic_name, 
group_name)
+        await iggy_client.send_messages(
+            stream=stream_name,
+            topic=topic_name,
+            partitioning=0,
+            messages=[Message(message) for message in test_messages],
+        )
+
+        polled_messages = await iggy_client.poll_messages(
+            stream=stream_name,
+            topic=topic_name,
+            consumer=Consumer.Group(group_name),
+            polling_strategy=PollingStrategy.Next(),
+            count=10,
+            auto_commit=True,
+        )
+        assert [
+            message.payload().decode("utf-8") for message in polled_messages
+        ] == test_messages
diff --git a/foreign/python/tests/test_tls.py b/foreign/python/tests/test_tls.py
index f914e4146..f9be94379 100644
--- a/foreign/python/tests/test_tls.py
+++ b/foreign/python/tests/test_tls.py
@@ -40,7 +40,7 @@ import pytest
 from testcontainers.core.container import DockerContainer
 from testcontainers.core.waiting_utils import wait_for_logs
 
-from apache_iggy import IggyClient, PollingStrategy
+from apache_iggy import Consumer, IggyClient, PollingStrategy
 from apache_iggy import SendMessage as Message
 
 from .utils import wait_for_ping, wait_for_server
@@ -143,6 +143,7 @@ class TestTlsConnectivity:
         polled = await tls_client.poll_messages(
             stream=stream_name,
             topic=topic_name,
+            consumer=Consumer.Single(1),
             partition_id=partition_id,
             polling_strategy=PollingStrategy.First(),
             count=10,

Reply via email to