hubcio commented on code in PR #4065: URL: https://github.com/apache/iggy/pull/4065#discussion_r3940333563
########## foreign/python/tests/test_client_info.py: ########## @@ -0,0 +1,235 @@ +# 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 connection introspection via get_me, get_client and get_clients.""" + +import pytest + +from apache_iggy import ( + ClientInfo, + ClientInfoDetails, + GlobalPermissions, + IggyClient, + Permissions, +) + +from .utils import login_fresh_client, unique_credentials + + +def unused_client_id(clients: list[ClientInfo]) -> int: + """Return a client id that no currently connected client holds.""" + return max((client.client_id for client in clients), default=0) + 1_000_000 + + +async def group_with_distinct_ids(client: IggyClient, unique_name): + """Create a stream, topic and consumer group with pairwise distinct ids. + + Topic and consumer group ids restart per parent, so a fresh stream hands + its first topic and first group the same id and a swapped field mapping + would still satisfy the assertions. Creating three candidates of each and + picking one that collides with nothing leaves the three ids distinct. + """ + stream_name = unique_name() + await client.create_stream(stream_name) + stream = await client.get_stream(stream_name) + assert stream is not None + + topics = [] + for _ in range(3): + name = unique_name() + await client.create_topic( + stream=stream_name, + name=name, + partitions_count=1, + ) + details = await client.get_topic(stream_name, name) + assert details is not None + topics.append((name, details)) + topic_name, topic = next((n, t) for n, t in topics if t.id != stream.id) + + groups = [] + for _ in range(3): + name = unique_name() + await client.create_consumer_group(stream_name, topic_name, name) + details = await client.get_consumer_group(stream_name, topic_name, name) + assert details is not None + groups.append((name, details)) + group_name, group = next( + (n, g) for n, g in groups if g.id not in (stream.id, topic.id) + ) + + return (stream_name, topic_name, group_name), (stream, topic, group) + + +class TestGetMe: + """Test the currently connected client via get_me.""" + + @pytest.mark.asyncio + async def test_get_me_returns_connected_client(self, iggy_client: IggyClient): + """Test get_me describes this connection over the fixture's transport.""" + me = await iggy_client.get_me() + + assert isinstance(me, ClientInfoDetails) + assert me.client_id > 0 + assert me.address + assert me.transport == "TCP" + + @pytest.mark.asyncio + async def test_get_me_user_id_matches_logged_in_user(self, iggy_client: IggyClient): + """Test the reported user id is the one the fixture authenticated as.""" + me = await iggy_client.get_me() + root = await iggy_client.get_user("iggy") + + assert root is not None + assert me.user_id == root.id + + @pytest.mark.asyncio + async def test_get_me_reports_joined_consumer_group( + self, iggy_client: IggyClient, unique_name + ): + """Test a joined group appears with the stream, topic and group ids.""" + names, (stream, topic, group) = await group_with_distinct_ids( + iggy_client, unique_name + ) + stream_name, topic_name, group_name = names + # Guard the assertions below: equal ids would survive a swapped mapping. + assert len({stream.id, topic.id, group.id}) == 3 + + # A fresh connection keeps the session-scoped fixture out of the group. + member = await login_fresh_client("iggy", "iggy") + await member.join_consumer_group(stream_name, topic_name, group_name) + + me = await member.get_me() + assert me.consumer_groups_count == 1 + assert len(me.consumer_groups) == 1 + + joined = me.consumer_groups[0] + assert joined.stream_id == stream.id + assert joined.topic_id == topic.id + assert joined.group_id == group.id + + await member.leave_consumer_group(stream_name, topic_name, group_name) + await iggy_client.delete_consumer_group(stream_name, topic_name, group_name) Review Comment: warning: cleanup runs only on the success path, so a failed assert leaks the consumer group or the user. wrap in `try/finally`. also at lines 215, 235. ########## foreign/python/tests/test_client_info.py: ########## @@ -0,0 +1,235 @@ +# 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 connection introspection via get_me, get_client and get_clients.""" + +import pytest + +from apache_iggy import ( + ClientInfo, + ClientInfoDetails, + GlobalPermissions, + IggyClient, + Permissions, +) + +from .utils import login_fresh_client, unique_credentials + + +def unused_client_id(clients: list[ClientInfo]) -> int: + """Return a client id that no currently connected client holds.""" + return max((client.client_id for client in clients), default=0) + 1_000_000 Review Comment: nit: `1_000_000` is an unnamed literal. name it, same for the `3` in `range(3)` at lines 52 and 65. ########## foreign/python/tests/test_client_info.py: ########## @@ -0,0 +1,235 @@ +# 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 connection introspection via get_me, get_client and get_clients.""" + +import pytest + +from apache_iggy import ( + ClientInfo, + ClientInfoDetails, + GlobalPermissions, + IggyClient, + Permissions, +) + +from .utils import login_fresh_client, unique_credentials + + +def unused_client_id(clients: list[ClientInfo]) -> int: + """Return a client id that no currently connected client holds.""" + return max((client.client_id for client in clients), default=0) + 1_000_000 + + +async def group_with_distinct_ids(client: IggyClient, unique_name): + """Create a stream, topic and consumer group with pairwise distinct ids. + + Topic and consumer group ids restart per parent, so a fresh stream hands + its first topic and first group the same id and a swapped field mapping + would still satisfy the assertions. Creating three candidates of each and + picking one that collides with nothing leaves the three ids distinct. + """ + stream_name = unique_name() + await client.create_stream(stream_name) + stream = await client.get_stream(stream_name) + assert stream is not None + + topics = [] + for _ in range(3): + name = unique_name() + await client.create_topic( + stream=stream_name, + name=name, + partitions_count=1, + ) + details = await client.get_topic(stream_name, name) + assert details is not None + topics.append((name, details)) + topic_name, topic = next((n, t) for n, t in topics if t.id != stream.id) Review Comment: nit: bare `next()` cannot fire today, but if it ever does the `StopIteration` escaping an `async def` becomes `RuntimeError: coroutine raised StopIteration`, the same type the permission tests expect. give it a `None` default and assert, like line 146. also at line 71. ########## foreign/python/tests/test_client_info.py: ########## @@ -0,0 +1,235 @@ +# 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 connection introspection via get_me, get_client and get_clients.""" + +import pytest + +from apache_iggy import ( + ClientInfo, + ClientInfoDetails, + GlobalPermissions, + IggyClient, + Permissions, +) + +from .utils import login_fresh_client, unique_credentials + + +def unused_client_id(clients: list[ClientInfo]) -> int: + """Return a client id that no currently connected client holds.""" + return max((client.client_id for client in clients), default=0) + 1_000_000 + + +async def group_with_distinct_ids(client: IggyClient, unique_name): + """Create a stream, topic and consumer group with pairwise distinct ids. + + Topic and consumer group ids restart per parent, so a fresh stream hands + its first topic and first group the same id and a swapped field mapping + would still satisfy the assertions. Creating three candidates of each and + picking one that collides with nothing leaves the three ids distinct. + """ + stream_name = unique_name() + await client.create_stream(stream_name) + stream = await client.get_stream(stream_name) + assert stream is not None + + topics = [] + for _ in range(3): + name = unique_name() + await client.create_topic( + stream=stream_name, + name=name, + partitions_count=1, + ) + details = await client.get_topic(stream_name, name) + assert details is not None + topics.append((name, details)) + topic_name, topic = next((n, t) for n, t in topics if t.id != stream.id) + + groups = [] + for _ in range(3): + name = unique_name() + await client.create_consumer_group(stream_name, topic_name, name) + details = await client.get_consumer_group(stream_name, topic_name, name) + assert details is not None + groups.append((name, details)) + group_name, group = next( + (n, g) for n, g in groups if g.id not in (stream.id, topic.id) + ) + + return (stream_name, topic_name, group_name), (stream, topic, group) + + +class TestGetMe: + """Test the currently connected client via get_me.""" + + @pytest.mark.asyncio + async def test_get_me_returns_connected_client(self, iggy_client: IggyClient): + """Test get_me describes this connection over the fixture's transport.""" + me = await iggy_client.get_me() + + assert isinstance(me, ClientInfoDetails) + assert me.client_id > 0 + assert me.address + assert me.transport == "TCP" + + @pytest.mark.asyncio + async def test_get_me_user_id_matches_logged_in_user(self, iggy_client: IggyClient): + """Test the reported user id is the one the fixture authenticated as.""" + me = await iggy_client.get_me() + root = await iggy_client.get_user("iggy") + + assert root is not None + assert me.user_id == root.id + + @pytest.mark.asyncio + async def test_get_me_reports_joined_consumer_group( + self, iggy_client: IggyClient, unique_name + ): + """Test a joined group appears with the stream, topic and group ids.""" + names, (stream, topic, group) = await group_with_distinct_ids( + iggy_client, unique_name + ) + stream_name, topic_name, group_name = names + # Guard the assertions below: equal ids would survive a swapped mapping. + assert len({stream.id, topic.id, group.id}) == 3 + + # A fresh connection keeps the session-scoped fixture out of the group. + member = await login_fresh_client("iggy", "iggy") + await member.join_consumer_group(stream_name, topic_name, group_name) + + me = await member.get_me() + assert me.consumer_groups_count == 1 + assert len(me.consumer_groups) == 1 + + joined = me.consumer_groups[0] + assert joined.stream_id == stream.id + assert joined.topic_id == topic.id + assert joined.group_id == group.id + + await member.leave_consumer_group(stream_name, topic_name, group_name) + await iggy_client.delete_consumer_group(stream_name, topic_name, group_name) + + +class TestGetClients: + """Test the connected client listing via get_clients.""" + + @pytest.mark.asyncio + async def test_get_clients_contains_this_client(self, iggy_client: IggyClient): + """Test the id reported by get_me appears in the listing.""" + me = await iggy_client.get_me() + clients = await iggy_client.get_clients() + + assert all(isinstance(client, ClientInfo) for client in clients) + + # get_clients() is a best-effort scatter-gather across shards: one + # that misses the server's LIST_CLIENTS_GATHER_TIMEOUT (3s) budget is + # dropped from the result rather than failing the whole read. This + # test server is unloaded and single-node, so every shard replies + # well within budget and the caller's own entry is always present; + # that would not hold under load or across a busier cluster. + mine = next((c for c in clients if c.client_id == me.client_id), None) + assert mine is not None + assert mine.address == me.address + assert mine.transport == me.transport Review Comment: nit: equality with `me.transport` cannot catch a wire-code shift, both sides come from the same `transport_kind_to_wire`. assert `== "TCP"` here too, like line 89. also at line 165. ########## foreign/python/src/client.rs: ########## @@ -157,6 +158,93 @@ impl IggyClient { }) } + /// Get the info about the currently connected client. + /// + /// Not to be confused with the user: a client is a single connection, + /// while a user is the identity it authenticated as. + /// + /// Requires authentication only, unlike `get_client` and `get_clients`. + /// + /// Unimplemented over the HTTP transport: it always raises, unlike + /// `get_client` and `get_clients`, which do work over HTTP. + /// + /// Returns: + /// An awaitable that resolves to the `ClientInfoDetails` of this + /// connection. + /// + /// Raises: + /// RuntimeError: `Feature is unavailable` unconditionally over HTTP, + /// or if the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[ClientInfoDetails]", imports=("collections.abc")))] + fn get_me<'a>(&self, py: Python<'a>) -> PyResult<Bound<'a, PyAny>> { + let inner = self.inner.clone(); + + future_into_py(py, async move { + let client = inner + .get_me() + .await + .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?; + Ok(PyClientInfoDetails::from(client)) + }) + } + + /// Get the info about a specific client by unique ID. + /// + /// Requires the `read_servers` or `manage_servers` global permission. + /// + /// Args: + /// client_id: Client identifier as `int`. Converted to the wire's u32 + /// before the awaitable exists, so a value outside that range + /// raises `OverflowError` synchronously rather than surfacing as + /// a `RuntimeError` from the request itself. + /// + /// Returns: + /// An awaitable that resolves to `ClientInfoDetails` if the client is + /// connected, or `None` otherwise. + /// + /// Raises: + /// OverflowError: If `client_id` is outside the u32 range. + /// RuntimeError: `Unauthorized` when the user holds neither + /// `read_servers` nor `manage_servers`, or if the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[ClientInfoDetails | None]", imports=("collections.abc")))] + fn get_client<'a>(&self, py: Python<'a>, client_id: u32) -> PyResult<Bound<'a, PyAny>> { + let inner = self.inner.clone(); + + future_into_py(py, async move { + let client = inner + .get_client(client_id) + .await + .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?; + Ok(client.map(PyClientInfoDetails::from)) + }) + } + + /// Get the info about all the currently connected clients. + /// + /// Requires the `read_servers` or `manage_servers` global permission. + /// + /// Returns: + /// An awaitable that resolves to `list[ClientInfo]`. Review Comment: nit: the best-effort caveat lives only in a test comment. python callers read this docstring, so say here that a shard missing the gather timeout is dropped from the list instead of failing the call. ########## foreign/python/tests/test_client_info.py: ########## @@ -0,0 +1,235 @@ +# 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 connection introspection via get_me, get_client and get_clients.""" + +import pytest + +from apache_iggy import ( + ClientInfo, + ClientInfoDetails, + GlobalPermissions, + IggyClient, + Permissions, +) + +from .utils import login_fresh_client, unique_credentials + + +def unused_client_id(clients: list[ClientInfo]) -> int: + """Return a client id that no currently connected client holds.""" + return max((client.client_id for client in clients), default=0) + 1_000_000 + + +async def group_with_distinct_ids(client: IggyClient, unique_name): + """Create a stream, topic and consumer group with pairwise distinct ids. + + Topic and consumer group ids restart per parent, so a fresh stream hands + its first topic and first group the same id and a swapped field mapping + would still satisfy the assertions. Creating three candidates of each and + picking one that collides with nothing leaves the three ids distinct. + """ + stream_name = unique_name() + await client.create_stream(stream_name) + stream = await client.get_stream(stream_name) + assert stream is not None + + topics = [] + for _ in range(3): + name = unique_name() + await client.create_topic( + stream=stream_name, + name=name, + partitions_count=1, + ) + details = await client.get_topic(stream_name, name) + assert details is not None + topics.append((name, details)) + topic_name, topic = next((n, t) for n, t in topics if t.id != stream.id) + + groups = [] + for _ in range(3): + name = unique_name() + await client.create_consumer_group(stream_name, topic_name, name) + details = await client.get_consumer_group(stream_name, topic_name, name) + assert details is not None + groups.append((name, details)) + group_name, group = next( + (n, g) for n, g in groups if g.id not in (stream.id, topic.id) + ) + + return (stream_name, topic_name, group_name), (stream, topic, group) + + +class TestGetMe: + """Test the currently connected client via get_me.""" + + @pytest.mark.asyncio + async def test_get_me_returns_connected_client(self, iggy_client: IggyClient): + """Test get_me describes this connection over the fixture's transport.""" + me = await iggy_client.get_me() + + assert isinstance(me, ClientInfoDetails) + assert me.client_id > 0 + assert me.address + assert me.transport == "TCP" + + @pytest.mark.asyncio + async def test_get_me_user_id_matches_logged_in_user(self, iggy_client: IggyClient): + """Test the reported user id is the one the fixture authenticated as.""" + me = await iggy_client.get_me() + root = await iggy_client.get_user("iggy") + + assert root is not None + assert me.user_id == root.id + + @pytest.mark.asyncio + async def test_get_me_reports_joined_consumer_group( + self, iggy_client: IggyClient, unique_name + ): + """Test a joined group appears with the stream, topic and group ids.""" + names, (stream, topic, group) = await group_with_distinct_ids( + iggy_client, unique_name + ) + stream_name, topic_name, group_name = names + # Guard the assertions below: equal ids would survive a swapped mapping. + assert len({stream.id, topic.id, group.id}) == 3 + + # A fresh connection keeps the session-scoped fixture out of the group. + member = await login_fresh_client("iggy", "iggy") + await member.join_consumer_group(stream_name, topic_name, group_name) + + me = await member.get_me() + assert me.consumer_groups_count == 1 + assert len(me.consumer_groups) == 1 + + joined = me.consumer_groups[0] + assert joined.stream_id == stream.id + assert joined.topic_id == topic.id + assert joined.group_id == group.id + + await member.leave_consumer_group(stream_name, topic_name, group_name) + await iggy_client.delete_consumer_group(stream_name, topic_name, group_name) + + +class TestGetClients: + """Test the connected client listing via get_clients.""" + + @pytest.mark.asyncio + async def test_get_clients_contains_this_client(self, iggy_client: IggyClient): + """Test the id reported by get_me appears in the listing.""" + me = await iggy_client.get_me() + clients = await iggy_client.get_clients() + + assert all(isinstance(client, ClientInfo) for client in clients) + + # get_clients() is a best-effort scatter-gather across shards: one + # that misses the server's LIST_CLIENTS_GATHER_TIMEOUT (3s) budget is + # dropped from the result rather than failing the whole read. This + # test server is unloaded and single-node, so every shard replies + # well within budget and the caller's own entry is always present; + # that would not hold under load or across a busier cluster. + mine = next((c for c in clients if c.client_id == me.client_id), None) + assert mine is not None + assert mine.address == me.address + assert mine.transport == me.transport + assert mine.user_id == me.user_id + + +class TestGetClient: + """Test single client lookup via get_client.""" + + @pytest.mark.asyncio + async def test_get_client_by_id_matches_get_me(self, iggy_client: IggyClient): + """Test get_client on this connection's id returns the same details.""" + me = await iggy_client.get_me() + + client = await iggy_client.get_client(me.client_id) + assert client is not None + assert client.client_id == me.client_id + assert client.user_id == me.user_id + assert client.transport == me.transport + + @pytest.mark.asyncio + async def test_get_client_unknown_id_returns_none(self, iggy_client: IggyClient): + """Test an unknown client id resolves to None rather than raising.""" + clients = await iggy_client.get_clients() + + assert await iggy_client.get_client(unused_client_id(clients)) is None + + @pytest.mark.parametrize("out_of_range", [-1, 2**32], ids=["negative", "above-u32"]) + @pytest.mark.asyncio + async def test_get_client_out_of_range_id_raises_overflow_error( + self, iggy_client: IggyClient, out_of_range: int + ): + """Test a client_id outside the u32 wire range raises OverflowError. + + pyo3 converts the argument to u32 before the awaitable exists, so this + fails synchronously with OverflowError rather than surfacing as a + RuntimeError from the request itself. + """ + with pytest.raises(OverflowError): + iggy_client.get_client(out_of_range) + + +class TestServerInfoPermission: + """Test the read_servers gate on get_client and get_clients.""" + + @pytest.mark.asyncio + async def test_user_with_read_servers_can_list_clients( + self, iggy_client: IggyClient, unique_name + ): + """Test read_servers grants both get_client and get_clients.""" + username, password = unique_credentials(unique_name) + permissions = Permissions( + global_permissions=GlobalPermissions(read_servers=True) + ) + created = await iggy_client.create_user( + username, password, permissions=permissions + ) + + client = await login_fresh_client(username, password) + me = await client.get_me() + + clients = await client.get_clients() + # Best-effort scatter-gather across shards, see the completeness note + # on test_get_clients_contains_this_client; holds here for the same + # reason. + assert any(other.client_id == me.client_id for other in clients) + assert (await client.get_client(me.client_id)) is not None + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_user_without_read_servers_cannot_list_clients( + self, iggy_client: IggyClient, unique_name + ): + """Test get_client and get_clients are denied without read_servers.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + client = await login_fresh_client(username, password) + # get_me needs authentication only, so it stays available. + me = await client.get_me() + assert me.user_id == created.id + + with pytest.raises(RuntimeError): Review Comment: warning: a bare `pytest.raises(RuntimeError)` passes on any failure, since every error in this binding becomes `RuntimeError`. use `match="Unauthorized"` - the docstring promises that string and it survives the wire round trip. also at line 232. ########## foreign/python/src/client_info.rs: ########## @@ -0,0 +1,178 @@ +// 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::{ + ClientInfo as RustClientInfo, ClientInfoDetails as RustClientInfoDetails, + ConsumerGroupInfo as RustConsumerGroupInfo, +}; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; + +#[gen_stub_pyclass] +#[pyclass] +pub struct ClientInfo { + pub(crate) inner: RustClientInfo, +} + +impl From<RustClientInfo> for ClientInfo { + fn from(client: RustClientInfo) -> Self { + Self { inner: client } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl ClientInfo { + /// The unique identifier of the client. + #[getter] + pub fn client_id(&self) -> u32 { + self.inner.client_id + } + + /// The unique identifier of the user, or `None` while the client is + /// connected but not yet authenticated. Review Comment: nit: nothing tests the `None` branch, though a client that connects without `login_user` shows up in `get_clients` with `user_id` `None`. deliberate, or worth a test? ########## foreign/python/src/client_info.rs: ########## @@ -0,0 +1,178 @@ +// 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::{ + ClientInfo as RustClientInfo, ClientInfoDetails as RustClientInfoDetails, + ConsumerGroupInfo as RustConsumerGroupInfo, +}; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; + +#[gen_stub_pyclass] +#[pyclass] +pub struct ClientInfo { + pub(crate) inner: RustClientInfo, +} + +impl From<RustClientInfo> for ClientInfo { + fn from(client: RustClientInfo) -> Self { + Self { inner: client } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl ClientInfo { + /// The unique identifier of the client. + #[getter] + pub fn client_id(&self) -> u32 { + self.inner.client_id + } + + /// The unique identifier of the user, or `None` while the client is + /// connected but not yet authenticated. + #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + pub fn user_id(&self) -> Option<u32> { + self.inner.user_id + } + + /// The remote address of the client. + #[getter] + pub fn address(&self) -> &str { + &self.inner.address + } + + /// The transport protocol used by the client, one of `"TCP"`, `"QUIC"`, + /// `"HTTP"`, `"WebSocket"`, or `"Unknown"` for a transport this server + /// does not recognise. + #[getter] + pub fn transport(&self) -> &str { + &self.inner.transport + } + + /// The number of consumer groups the client is part of. + #[getter] + pub fn consumer_groups_count(&self) -> u32 { + self.inner.consumer_groups_count + } +} + +#[gen_stub_pyclass] +#[pyclass] +pub struct ClientInfoDetails { + pub(crate) inner: RustClientInfoDetails, +} + +impl From<RustClientInfoDetails> for ClientInfoDetails { + fn from(client: RustClientInfoDetails) -> Self { + Self { inner: client } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl ClientInfoDetails { + /// The unique identifier of the client. + #[getter] + pub fn client_id(&self) -> u32 { + self.inner.client_id + } + + /// The unique identifier of the user, or `None` while the client is + /// connected but not yet authenticated. + #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + pub fn user_id(&self) -> Option<u32> { + self.inner.user_id + } + + /// The remote address of the client. + #[getter] + pub fn address(&self) -> &str { + &self.inner.address + } + + /// The transport protocol used by the client, one of `"TCP"`, `"QUIC"`, + /// `"HTTP"`, `"WebSocket"`, or `"Unknown"` for a transport this server + /// does not recognise. + #[getter] + pub fn transport(&self) -> &str { + &self.inner.transport + } + + /// The number of consumer groups the client is part of. + #[getter] + pub fn consumer_groups_count(&self) -> u32 { + self.inner.consumer_groups_count + } + + /// The collection of consumer groups the client is part of. + /// + /// Each read rebuilds the list and every `ConsumerGroupInfo` in it, so + /// bind it once (`groups = details.consumer_groups`) rather than + /// subscripting the attribute repeatedly: two reads never share object + /// identity, and mutating the returned list does not affect the client. Review Comment: nit: the part a caller actually hits is that `ConsumerGroupInfo` has no `__eq__`, so `==` between groups from two reads is `False`. say that instead of the list-mutation sentence. ########## foreign/python/src/client_info.rs: ########## @@ -0,0 +1,178 @@ +// 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::{ + ClientInfo as RustClientInfo, ClientInfoDetails as RustClientInfoDetails, + ConsumerGroupInfo as RustConsumerGroupInfo, +}; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; + +#[gen_stub_pyclass] +#[pyclass] +pub struct ClientInfo { + pub(crate) inner: RustClientInfo, +} + +impl From<RustClientInfo> for ClientInfo { + fn from(client: RustClientInfo) -> Self { + Self { inner: client } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl ClientInfo { + /// The unique identifier of the client. + #[getter] + pub fn client_id(&self) -> u32 { + self.inner.client_id + } + + /// The unique identifier of the user, or `None` while the client is + /// connected but not yet authenticated. + #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + pub fn user_id(&self) -> Option<u32> { + self.inner.user_id + } + + /// The remote address of the client. + #[getter] + pub fn address(&self) -> &str { + &self.inner.address + } + + /// The transport protocol used by the client, one of `"TCP"`, `"QUIC"`, + /// `"HTTP"`, `"WebSocket"`, or `"Unknown"` for a transport this server + /// does not recognise. + #[getter] + pub fn transport(&self) -> &str { + &self.inner.transport + } + + /// The number of consumer groups the client is part of. + #[getter] + pub fn consumer_groups_count(&self) -> u32 { + self.inner.consumer_groups_count + } +} + +#[gen_stub_pyclass] +#[pyclass] +pub struct ClientInfoDetails { + pub(crate) inner: RustClientInfoDetails, +} + +impl From<RustClientInfoDetails> for ClientInfoDetails { + fn from(client: RustClientInfoDetails) -> Self { + Self { inner: client } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl ClientInfoDetails { + /// The unique identifier of the client. + #[getter] + pub fn client_id(&self) -> u32 { + self.inner.client_id + } + + /// The unique identifier of the user, or `None` while the client is + /// connected but not yet authenticated. + #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + pub fn user_id(&self) -> Option<u32> { + self.inner.user_id + } + + /// The remote address of the client. + #[getter] + pub fn address(&self) -> &str { + &self.inner.address + } + + /// The transport protocol used by the client, one of `"TCP"`, `"QUIC"`, + /// `"HTTP"`, `"WebSocket"`, or `"Unknown"` for a transport this server + /// does not recognise. + #[getter] + pub fn transport(&self) -> &str { + &self.inner.transport + } + + /// The number of consumer groups the client is part of. + #[getter] + pub fn consumer_groups_count(&self) -> u32 { + self.inner.consumer_groups_count + } + + /// The collection of consumer groups the client is part of. + /// + /// Each read rebuilds the list and every `ConsumerGroupInfo` in it, so + /// bind it once (`groups = details.consumer_groups`) rather than + /// subscripting the attribute repeatedly: two reads never share object + /// identity, and mutating the returned list does not affect the client. + #[getter] + pub fn consumer_groups(&self) -> Vec<ConsumerGroupInfo> { + self.inner + .consumer_groups + .iter() + .map(ConsumerGroupInfo::from) + .collect() + } +} + +#[gen_stub_pyclass] +#[pyclass] +pub struct ConsumerGroupInfo { + pub(crate) inner: RustConsumerGroupInfo, +} + +impl From<&RustConsumerGroupInfo> for ConsumerGroupInfo { Review Comment: simplification: this hand-rolled copy exists only because `ConsumerGroupInfo` in `iggy_common` has no `Clone`. derive it there and this collapses to `inner: group.clone()`. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
