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

spetz 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 4a6f173d4 feat(python): add user management methods to IggyClient 
(#3695)
4a6f173d4 is described below

commit 4a6f173d4d65b07a50f8c1ca9a3ac97b317ee201
Author: Ethan Lin <[email protected]>
AuthorDate: Tue Jul 21 02:40:12 2026 +0800

    feat(python): add user management methods to IggyClient (#3695)
    
    Adds the missing user management operations to the Python SDK, which
    previously had no binding beyond `login_user` and forced callers to the
    CLI or another SDK to provision users. Closes #3682
---
 core/sdk/src/prelude.rs           |   4 +-
 foreign/python/apache_iggy.pyi    | 151 ++++++++++
 foreign/python/src/client.rs      | 144 +++++++++
 foreign/python/src/lib.rs         |   5 +
 foreign/python/src/user.rs        | 131 ++++++++
 foreign/python/tests/test_user.py | 616 ++++++++++++++++++++++++++++++++++++++
 6 files changed, 1049 insertions(+), 2 deletions(-)

diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs
index ec01c1920..34f8d1484 100644
--- a/core/sdk/src/prelude.rs
+++ b/core/sdk/src/prelude.rs
@@ -60,8 +60,8 @@ pub use iggy_common::{
     QuicClientConfig, QuicClientConfigBuilder, QuicClientReconnectionConfig, 
SendMessages,
     Sizeable, SnapshotCompression, Stats, Stream, StreamDetails, 
StreamPermissions,
     SystemSnapshotType, TcpClientConfig, TcpClientConfigBuilder, 
TcpClientReconnectionConfig,
-    Topic, TopicDetails, TopicPermissions, TransportEndpoints, 
TransportProtocol, UserId,
-    UserStatus, Validatable, WebSocketClientConfig, 
WebSocketClientConfigBuilder,
+    Topic, TopicDetails, TopicPermissions, TransportEndpoints, 
TransportProtocol, UserId, UserInfo,
+    UserInfoDetails, UserStatus, Validatable, WebSocketClientConfig, 
WebSocketClientConfigBuilder,
     WebSocketClientReconnectionConfig, defaults, locking,
 };
 pub use iggy_common::{
diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi
index 5b5366938..1bcd56193 100644
--- a/foreign/python/apache_iggy.pyi
+++ b/foreign/python/apache_iggy.pyi
@@ -22,6 +22,7 @@ import asyncio
 import builtins
 import collections.abc
 import datetime
+import enum
 import typing
 
 __all__ = [
@@ -39,6 +40,9 @@ __all__ = [
     "StreamDetails",
     "Topic",
     "TopicDetails",
+    "UserInfo",
+    "UserInfoDetails",
+    "UserStatus",
 ]
 
 class AutoCommit:
@@ -335,6 +339,92 @@ class IggyClient:
         Logs in the user with the given credentials.
         Returns `Ok(())` on success, or a PyRuntimeError on failure.
         """
+    def get_user(
+        self, user_id: builtins.str | builtins.int
+    ) -> collections.abc.Awaitable[UserInfoDetails | None]:
+        r"""
+        Get the info about a specific user by unique ID or username.
+
+        Args:
+            user_id: User identifier as `str | int`.
+
+        Returns:
+            An awaitable that resolves to `UserInfoDetails` if the user exists,
+            or `None` otherwise.
+
+        Raises:
+            PyValueError: If a string identifier is invalid.
+            PyRuntimeError: If the request fails.
+        """
+    def get_users(self) -> collections.abc.Awaitable[list[UserInfo]]:
+        r"""
+        Get the info about all the users.
+
+        Returns:
+            An awaitable that resolves to `list[UserInfo]`.
+
+        Raises:
+            PyRuntimeError: If the request fails.
+        """
+    def create_user(
+        self,
+        username: builtins.str,
+        password: builtins.str,
+        status: UserStatus | None = None,
+    ) -> collections.abc.Awaitable[UserInfoDetails]:
+        r"""
+        Create a new user.
+
+        The user is created without permissions.
+
+        Args:
+            username: Username as `str`.
+            password: Password as `str`.
+            status: User status as `UserStatus | None`; defaults to 
`UserStatus.Active`.
+
+        Returns:
+            An awaitable that resolves to the created `UserInfoDetails`.
+
+        Raises:
+            PyRuntimeError: If an argument is invalid or the request fails.
+        """
+    def update_user(
+        self,
+        user_id: builtins.str | builtins.int,
+        username: builtins.str | None = None,
+        status: UserStatus | None = None,
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Update a user by unique ID or username.
+
+        Args:
+            user_id: User identifier as `str | int`.
+            username: New username as `str | None`; unchanged when `None`.
+            status: New status as `UserStatus | None`; unchanged when `None`.
+
+        Returns:
+            An awaitable that resolves to `None` when the user is updated.
+
+        Raises:
+            PyValueError: If a string identifier is invalid.
+            PyRuntimeError: If the request fails.
+        """
+    def delete_user(
+        self, user_id: builtins.str | builtins.int
+    ) -> collections.abc.Awaitable[None]:
+        r"""
+        Delete a user by unique ID or username.
+
+        Args:
+            user_id: User identifier as `str | int`.
+
+        Returns:
+            An awaitable that resolves to `None` when the user is deleted.
+
+        Raises:
+            PyValueError: If a string identifier is invalid.
+            PyRuntimeError: If the request fails.
+        """
     def connect(self) -> collections.abc.Awaitable[None]:
         r"""
         Connects the IggyClient to its service.
@@ -862,3 +952,64 @@ class TopicDetails:
         r"""
         Replication factor for the topic.
         """
+
[email protected]
+class UserInfo:
+    @property
+    def id(self) -> builtins.int:
+        r"""
+        The unique identifier (numeric) of the user.
+        """
+    @property
+    def created_at(self) -> builtins.int:
+        r"""
+        The timestamp when the user was created, in microseconds since the 
Unix epoch.
+        """
+    @property
+    def status(self) -> UserStatus:
+        r"""
+        The status of the user.
+        """
+    @property
+    def username(self) -> builtins.str:
+        r"""
+        The username of the user.
+        """
+
[email protected]
+class UserInfoDetails:
+    @property
+    def id(self) -> builtins.int:
+        r"""
+        The unique identifier (numeric) of the user.
+        """
+    @property
+    def created_at(self) -> builtins.int:
+        r"""
+        The timestamp when the user was created, in microseconds since the 
Unix epoch.
+        """
+    @property
+    def status(self) -> UserStatus:
+        r"""
+        The status of the user.
+        """
+    @property
+    def username(self) -> builtins.str:
+        r"""
+        The username of the user.
+        """
+
[email protected]
+class UserStatus(enum.Enum):
+    r"""
+    The status of a user account.
+    """
+
+    Active = ...
+    r"""
+    The user account is active and can be used.
+    """
+    Inactive = ...
+    r"""
+    The user account is inactive and cannot be used.
+    """
diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs
index 2a36ef333..6b2254921 100644
--- a/foreign/python/src/client.rs
+++ b/foreign/python/src/client.rs
@@ -37,6 +37,9 @@ use crate::receive_message::{PollingStrategy, ReceiveMessage};
 use crate::send_message::SendMessage;
 use crate::stream::StreamDetails;
 use crate::topic::{Topic, TopicDetails};
+use crate::user::{
+    UserInfo as PyUserInfo, UserInfoDetails as PyUserInfoDetails, UserStatus 
as PyUserStatus,
+};
 use tokio::sync::Mutex;
 
 /// A Python class representing the Iggy client.
@@ -119,6 +122,147 @@ impl IggyClient {
         })
     }
 
+    /// Get the info about a specific user by unique ID or username.
+    ///
+    /// Args:
+    ///     user_id: User identifier as `str | int`.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `UserInfoDetails` if the user exists,
+    ///     or `None` otherwise.
+    ///
+    /// Raises:
+    ///     PyValueError: If a string identifier is invalid.
+    ///     PyRuntimeError: 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)?;
+        let inner = self.inner.clone();
+
+        future_into_py(py, async move {
+            let user = inner
+                .get_user(&user_id)
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(user.map(PyUserInfoDetails::from))
+        })
+    }
+
+    /// Get the info about all the users.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `list[UserInfo]`.
+    ///
+    /// Raises:
+    ///     PyRuntimeError: 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();
+
+        future_into_py(py, async move {
+            let users = inner
+                .get_users()
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(users.into_iter().map(PyUserInfo::from).collect::<Vec<_>>())
+        })
+    }
+
+    /// Create a new user.
+    ///
+    /// The user is created without permissions.
+    ///
+    /// Args:
+    ///     username: Username as `str`.
+    ///     password: Password as `str`.
+    ///     status: User status as `UserStatus | None`; defaults to 
`UserStatus.Active`.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to the created `UserInfoDetails`.
+    ///
+    /// Raises:
+    ///     PyRuntimeError: If an argument is invalid or the request fails.
+    #[pyo3(signature = (username, password, status=None))]
+    
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[UserInfoDetails]",
 imports=("collections.abc")))]
+    fn create_user<'a>(
+        &self,
+        py: Python<'a>,
+        username: String,
+        password: String,
+        #[gen_stub(override_type(type_repr = "UserStatus | None"))] status: 
Option<PyUserStatus>,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let status = status.map_or(UserStatus::Active, UserStatus::from);
+        let inner = self.inner.clone();
+
+        future_into_py(py, async move {
+            let user = inner
+                .create_user(&username, &password, status, None)
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(PyUserInfoDetails::from(user))
+        })
+    }
+
+    /// Update a user by unique ID or username.
+    ///
+    /// Args:
+    ///     user_id: User identifier as `str | int`.
+    ///     username: New username as `str | None`; unchanged when `None`.
+    ///     status: New status as `UserStatus | None`; unchanged when `None`.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `None` when the user is updated.
+    ///
+    /// Raises:
+    ///     PyValueError: If a string identifier is invalid.
+    ///     PyRuntimeError: 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>(
+        &self,
+        py: Python<'a>,
+        user_id: PyIdentifier,
+        #[gen_stub(override_type(type_repr = "builtins.str | None"))] 
username: Option<String>,
+        #[gen_stub(override_type(type_repr = "UserStatus | None"))] status: 
Option<PyUserStatus>,
+    ) -> PyResult<Bound<'a, PyAny>> {
+        let user_id = Identifier::try_from(user_id)?;
+        let status = status.map(UserStatus::from);
+        let inner = self.inner.clone();
+
+        future_into_py(py, async move {
+            inner
+                .update_user(&user_id, username.as_deref(), status)
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(())
+        })
+    }
+
+    /// Delete a user by unique ID or username.
+    ///
+    /// Args:
+    ///     user_id: User identifier as `str | int`.
+    ///
+    /// Returns:
+    ///     An awaitable that resolves to `None` when the user is deleted.
+    ///
+    /// Raises:
+    ///     PyValueError: If a string identifier is invalid.
+    ///     PyRuntimeError: 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)?;
+        let inner = self.inner.clone();
+
+        future_into_py(py, async move {
+            inner
+                .delete_user(&user_id)
+                .await
+                .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, 
_>(e.to_string()))?;
+            Ok(())
+        })
+    }
+
     /// Connects the IggyClient to its service.
     /// Returns Ok(()) on successful connection or a PyRuntimeError on failure.
     
#[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", 
imports=("collections.abc")))]
diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs
index 8c7845606..f831fd703 100644
--- a/foreign/python/src/lib.rs
+++ b/foreign/python/src/lib.rs
@@ -22,6 +22,7 @@ mod receive_message;
 mod send_message;
 mod stream;
 mod topic;
+mod user;
 
 use client::IggyClient;
 use consumer::{
@@ -33,6 +34,7 @@ use receive_message::{PollingStrategy, ReceiveMessage};
 use send_message::SendMessage;
 use stream::StreamDetails;
 use topic::{Topic, TopicDetails};
+use user::{UserInfo, UserInfoDetails, UserStatus};
 
 /// A Python module implemented in Rust.
 #[pymodule]
@@ -52,5 +54,8 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> 
PyResult<()> {
     m.add_class::<AutoCommitAfter>()?;
     m.add_class::<AutoCommitWhen>()?;
     m.add_class::<ReceiveMessageIterator>()?;
+    m.add_class::<UserStatus>()?;
+    m.add_class::<UserInfo>()?;
+    m.add_class::<UserInfoDetails>()?;
     Ok(())
 }
diff --git a/foreign/python/src/user.rs b/foreign/python/src/user.rs
new file mode 100644
index 000000000..1f9e6ddb0
--- /dev/null
+++ b/foreign/python/src/user.rs
@@ -0,0 +1,131 @@
+// 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::{
+    UserInfo as RustUserInfo, UserInfoDetails as RustUserInfoDetails, 
UserStatus as RustUserStatus,
+};
+use pyo3::prelude::*;
+use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, 
gen_stub_pymethods};
+
+/// The status of a user account.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[gen_stub_pyclass_enum]
+#[pyclass(eq, from_py_object)]
+pub enum UserStatus {
+    /// The user account is active and can be used.
+    Active,
+    /// The user account is inactive and cannot be used.
+    Inactive,
+}
+
+impl From<UserStatus> for RustUserStatus {
+    fn from(status: UserStatus) -> Self {
+        match status {
+            UserStatus::Active => RustUserStatus::Active,
+            UserStatus::Inactive => RustUserStatus::Inactive,
+        }
+    }
+}
+
+impl From<RustUserStatus> for UserStatus {
+    fn from(status: RustUserStatus) -> Self {
+        match status {
+            RustUserStatus::Active => UserStatus::Active,
+            RustUserStatus::Inactive => UserStatus::Inactive,
+        }
+    }
+}
+
+#[gen_stub_pyclass]
+#[pyclass]
+pub struct UserInfo {
+    pub(crate) inner: RustUserInfo,
+}
+
+impl From<RustUserInfo> for UserInfo {
+    fn from(user: RustUserInfo) -> Self {
+        Self { inner: user }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl UserInfo {
+    /// The unique identifier (numeric) of the user.
+    #[getter]
+    pub fn id(&self) -> u32 {
+        self.inner.id
+    }
+
+    /// The timestamp when the user was created, in microseconds since the 
Unix epoch.
+    #[getter]
+    pub fn created_at(&self) -> u64 {
+        self.inner.created_at.as_micros()
+    }
+
+    /// The status of the user.
+    #[getter]
+    pub fn status(&self) -> UserStatus {
+        self.inner.status.into()
+    }
+
+    /// The username of the user.
+    #[getter]
+    pub fn username(&self) -> String {
+        self.inner.username.to_string()
+    }
+}
+
+#[gen_stub_pyclass]
+#[pyclass]
+pub struct UserInfoDetails {
+    pub(crate) inner: RustUserInfoDetails,
+}
+
+impl From<RustUserInfoDetails> for UserInfoDetails {
+    fn from(user: RustUserInfoDetails) -> Self {
+        Self { inner: user }
+    }
+}
+
+#[gen_stub_pymethods]
+#[pymethods]
+impl UserInfoDetails {
+    /// The unique identifier (numeric) of the user.
+    #[getter]
+    pub fn id(&self) -> u32 {
+        self.inner.id
+    }
+
+    /// The timestamp when the user was created, in microseconds since the 
Unix epoch.
+    #[getter]
+    pub fn created_at(&self) -> u64 {
+        self.inner.created_at.as_micros()
+    }
+
+    /// The status of the user.
+    #[getter]
+    pub fn status(&self) -> UserStatus {
+        self.inner.status.into()
+    }
+
+    /// The username of the user.
+    #[getter]
+    pub fn username(&self) -> String {
+        self.inner.username.to_string()
+    }
+}
diff --git a/foreign/python/tests/test_user.py 
b/foreign/python/tests/test_user.py
new file mode 100644
index 000000000..9e94a5935
--- /dev/null
+++ b/foreign/python/tests/test_user.py
@@ -0,0 +1,616 @@
+# 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.
+
+import pytest
+
+from apache_iggy import IggyClient, UserInfoDetails, UserStatus
+
+from .utils import get_server_config, wait_for_ping, wait_for_server
+
+# Server-side limits: usernames are 3-50 bytes, passwords 3-100 bytes.
+MIN_USERNAME_BYTES = 3
+MAX_USERNAME_BYTES = 50
+MIN_PASSWORD_BYTES = 3
+MAX_PASSWORD_BYTES = 100
+
+
+def _unique_credentials(unique_name) -> tuple[str, str]:
+    username = unique_name(max_bytes=MAX_USERNAME_BYTES)
+    password = unique_name(max_bytes=MAX_PASSWORD_BYTES)
+    return username, password
+
+
+class TestCreateUser:
+    """Test user creation via create_user."""
+
+    @pytest.mark.asyncio
+    async def test_create_and_get_user(self, iggy_client: IggyClient, 
unique_name):
+        """Test user creation returns details and the user is retrievable."""
+        username, password = _unique_credentials(unique_name)
+
+        created = await iggy_client.create_user(username, password, 
UserStatus.Active)
+        assert isinstance(created, UserInfoDetails)
+        assert isinstance(created.id, int)
+        assert created.username == username
+        assert created.status == UserStatus.Active
+
+        user_by_name = await iggy_client.get_user(username)
+        assert user_by_name is not None
+        assert user_by_name.id == created.id
+        assert user_by_name.username == username
+        assert user_by_name.status == UserStatus.Active
+
+        user_by_id = await iggy_client.get_user(created.id)
+        assert user_by_id is not None
+        assert user_by_id.id == created.id
+        assert user_by_id.username == username
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_create_user_defaults_to_active_status(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test create_user without an explicit status creates an active 
user."""
+        username, password = _unique_credentials(unique_name)
+
+        created = await iggy_client.create_user(username, password)
+        assert created.status == UserStatus.Active
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_create_inactive_user(self, iggy_client: IggyClient, 
unique_name):
+        """Test create_user with UserStatus.Inactive creates an inactive 
user."""
+        username, password = _unique_credentials(unique_name)
+
+        created = await iggy_client.create_user(username, password, 
UserStatus.Inactive)
+        assert created.status == UserStatus.Inactive
+
+        user = await iggy_client.get_user(username)
+        assert user is not None
+        assert user.status == UserStatus.Inactive
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_duplicate_username_fails(self, iggy_client: IggyClient, 
unique_name):
+        """Test create_user rejects an already taken username."""
+        username, password = _unique_credentials(unique_name)
+
+        created = await iggy_client.create_user(username, password)
+
+        with pytest.raises(RuntimeError):
+            await iggy_client.create_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_created_user_can_login(self, iggy_client: IggyClient, 
unique_name):
+        """Test a freshly created user can authenticate with its 
credentials."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        host, port = get_server_config()
+        client = IggyClient(f"{host}:{port}")
+        await client.connect()
+        await wait_for_ping(client)
+        await client.login_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_inactive_user_cannot_login(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test an inactive user is denied login even with correct 
credentials."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password, 
UserStatus.Inactive)
+
+        host, port = get_server_config()
+        client = IggyClient(f"{host}:{port}")
+        await client.connect()
+        with pytest.raises(RuntimeError):
+            await client.login_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_deleted_user_cannot_login(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a deleted user cannot start a fresh authenticated session."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+        await iggy_client.delete_user(created.id)
+
+        host, port = get_server_config()
+        client = IggyClient(f"{host}:{port}")
+        await client.connect()
+        with pytest.raises(RuntimeError):
+            await client.login_user(username, password)
+
+    @pytest.mark.parametrize(
+        "username",
+        ["a" * (MIN_USERNAME_BYTES - 1), "a" * (MAX_USERNAME_BYTES + 1)],
+        ids=["too-short", "too-long"],
+    )
+    @pytest.mark.asyncio
+    async def test_create_user_rejects_out_of_bounds_username(
+        self, iggy_client: IggyClient, unique_name, username
+    ):
+        """Test create_user rejects usernames outside the 3-50 byte range."""
+        with pytest.raises(RuntimeError):
+            await iggy_client.create_user(
+                username, unique_name(max_bytes=MAX_PASSWORD_BYTES)
+            )
+
+    @pytest.mark.parametrize(
+        "password",
+        ["a" * (MIN_PASSWORD_BYTES - 1), "a" * (MAX_PASSWORD_BYTES + 1)],
+        ids=["too-short", "too-long"],
+    )
+    @pytest.mark.asyncio
+    async def test_create_user_rejects_out_of_bounds_password(
+        self, iggy_client: IggyClient, unique_name, password
+    ):
+        """Test create_user rejects passwords outside the 3-100 byte range."""
+        with pytest.raises(RuntimeError):
+            await iggy_client.create_user(
+                unique_name(max_bytes=MAX_USERNAME_BYTES), password
+            )
+
+    @pytest.mark.parametrize(
+        "prefix",
+        ["ユーザー", "사용자", "ผู้ใช้", "🦀🚀"],
+        ids=["japanese", "korean", "thai", "emoji"],
+    )
+    @pytest.mark.asyncio
+    async def test_create_user_accepts_multibyte_credentials(
+        self, iggy_client: IggyClient, unique_name, prefix
+    ):
+        """Test non-ascii credentials within the byte limits are accepted."""
+        username = f"{prefix}{unique_name(min_bytes=4, max_bytes=8)}"
+        password = f"{prefix}{unique_name(min_bytes=4, max_bytes=8)}"
+        assert len(username.encode()) <= MAX_USERNAME_BYTES
+
+        created = await iggy_client.create_user(username, password)
+        assert created.username == username
+
+        fetched = await iggy_client.get_user(username)
+        assert fetched is not None
+        assert fetched.id == created.id
+
+        host, port = get_server_config()
+        client = IggyClient(f"{host}:{port}")
+        await client.connect()
+        await client.login_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+
+class TestGetUser:
+    """Test user retrieval via get_user."""
+
+    @pytest.mark.asyncio
+    async def test_get_root_user(self, iggy_client: IggyClient):
+        """Test the default root user is retrievable by username."""
+        user = await iggy_client.get_user("iggy")
+        assert user is not None
+        assert user.username == "iggy"
+        assert user.status == UserStatus.Active
+
+    @pytest.mark.asyncio
+    async def test_get_nonexistent_user_returns_none(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test getting a non-existent user by name or numeric id returns 
None."""
+        user_by_name = await iggy_client.get_user(
+            unique_name(max_bytes=MAX_USERNAME_BYTES)
+        )
+        assert user_by_name is None
+
+        # Deleting a freshly created user guarantees its id is vacant.
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+        await iggy_client.delete_user(created.id)
+
+        user_by_id = await iggy_client.get_user(created.id)
+        assert user_by_id is None
+
+    @pytest.mark.asyncio
+    async def test_get_user_rejects_empty_identifier_locally(
+        self, iggy_client: IggyClient
+    ):
+        """Test the empty string is rejected client-side before any server 
call."""
+        with pytest.raises(ValueError):
+            iggy_client.get_user("")
+
+    @pytest.mark.parametrize("user_id", [-1, 2**32], ids=["negative", 
"above-u32"])
+    @pytest.mark.asyncio
+    async def test_get_user_rejects_out_of_range_numeric_id(
+        self, iggy_client: IggyClient, user_id
+    ):
+        """Test numeric ids outside the u32 range are rejected client-side."""
+        with pytest.raises(TypeError):
+            iggy_client.get_user(user_id)
+
+    @pytest.mark.asyncio
+    async def test_get_user_returns_same_result_when_called_repeatedly(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test repeated get_user calls return the same user view."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        first = await iggy_client.get_user(username)
+        second = await iggy_client.get_user(username)
+        assert first is not None
+        assert second is not None
+        assert first.id == second.id
+        assert first.username == second.username
+        assert first.status == second.status
+
+        await iggy_client.delete_user(created.id)
+
+
+class TestGetUsers:
+    """Test listing users via get_users."""
+
+    @pytest.mark.asyncio
+    async def test_get_users_lists_root_and_created_users(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test get_users returns the root user and newly created users."""
+        first_username, first_password = _unique_credentials(unique_name)
+        second_username, second_password = _unique_credentials(unique_name)
+
+        first = await iggy_client.create_user(first_username, first_password)
+        second = await iggy_client.create_user(second_username, 
second_password)
+
+        users = await iggy_client.get_users()
+        usernames = [user.username for user in users]
+        assert "iggy" in usernames
+        assert first_username in usernames
+        assert second_username in usernames
+        assert all(isinstance(user.id, int) for user in users)
+        assert all(isinstance(user.status, UserStatus) for user in users)
+
+        user_ids = [user.id for user in users]
+        assert user_ids == sorted(set(user_ids)), (
+            "get_users must return users in strictly ascending id order"
+        )
+
+        await iggy_client.delete_user(first.id)
+        await iggy_client.delete_user(second.id)
+
+
+class TestUpdateUser:
+    """Test user updates via update_user."""
+
+    @pytest.mark.asyncio
+    async def test_update_username(self, iggy_client: IggyClient, unique_name):
+        """Test update_user renames a user."""
+        username, password = _unique_credentials(unique_name)
+        new_username = unique_name(max_bytes=MAX_USERNAME_BYTES)
+
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.update_user(username, username=new_username)
+
+        assert await iggy_client.get_user(username) is None
+        renamed = await iggy_client.get_user(new_username)
+        assert renamed is not None
+        assert renamed.id == created.id
+        assert renamed.username == new_username
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_update_status(self, iggy_client: IggyClient, unique_name):
+        """Test update_user changes the user status."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+        assert created.status == UserStatus.Active
+
+        await iggy_client.update_user(created.id, status=UserStatus.Inactive)
+
+        user = await iggy_client.get_user(created.id)
+        assert user is not None
+        assert user.status == UserStatus.Inactive
+        assert user.username == username
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_update_username_and_status_together(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test update_user applies a new username and status in one call."""
+        username, password = _unique_credentials(unique_name)
+        new_username = unique_name(max_bytes=MAX_USERNAME_BYTES)
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.update_user(
+            created.id, username=new_username, status=UserStatus.Inactive
+        )
+
+        user = await iggy_client.get_user(created.id)
+        assert user is not None
+        assert user.username == new_username
+        assert user.status == UserStatus.Inactive
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_update_user_with_no_fields_is_a_noop(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test the server permits an update with neither username nor 
status."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.update_user(created.id)
+
+        user = await iggy_client.get_user(created.id)
+        assert user is not None
+        assert user.username == username
+        assert user.status == UserStatus.Active
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_update_user_applied_repeatedly_is_idempotent(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test applying the same update twice succeeds and keeps the state."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.update_user(created.id, status=UserStatus.Inactive)
+        await iggy_client.update_user(created.id, status=UserStatus.Inactive)
+
+        user = await iggy_client.get_user(created.id)
+        assert user is not None
+        assert user.status == UserStatus.Inactive
+        assert user.username == username
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_update_username_to_existing_username_fails(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test update_user rejects renaming to a username another user 
holds."""
+        first_username, first_password = _unique_credentials(unique_name)
+        second_username, second_password = _unique_credentials(unique_name)
+        first = await iggy_client.create_user(first_username, first_password)
+        second = await iggy_client.create_user(second_username, 
second_password)
+
+        with pytest.raises(RuntimeError):
+            await iggy_client.update_user(first.id, username=second_username)
+
+        unchanged = await iggy_client.get_user(first.id)
+        assert unchanged is not None
+        assert unchanged.username == first_username
+        assert unchanged.status == UserStatus.Active
+
+        await iggy_client.delete_user(first.id)
+        await iggy_client.delete_user(second.id)
+
+    @pytest.mark.asyncio
+    async def test_update_nonexistent_user_fails(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test update_user raises for a non-existent user."""
+        with pytest.raises(RuntimeError):
+            await iggy_client.update_user(
+                unique_name(max_bytes=MAX_USERNAME_BYTES),
+                status=UserStatus.Inactive,
+            )
+
+    @pytest.mark.parametrize(
+        "new_username",
+        ["a" * (MIN_USERNAME_BYTES - 1), "a" * (MAX_USERNAME_BYTES + 1), "あ" * 
17],
+        ids=["too-short", "too-long", "multibyte-51-bytes"],
+    )
+    @pytest.mark.asyncio
+    async def test_update_user_rejects_out_of_bounds_username(
+        self, iggy_client: IggyClient, unique_name, new_username
+    ):
+        """Test update_user rejects usernames outside the 3-50 byte range."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        with pytest.raises(RuntimeError):
+            await iggy_client.update_user(created.id, username=new_username)
+
+        unchanged = await iggy_client.get_user(created.id)
+        assert unchanged is not None
+        assert unchanged.username == username
+        assert unchanged.status == UserStatus.Active
+
+        await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_update_user_accepts_multibyte_username(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test update_user accepts a non-ascii username within the byte 
limit."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        new_username = f"사용자{unique_name(min_bytes=4, max_bytes=8)}"
+        await iggy_client.update_user(created.id, username=new_username)
+
+        renamed = await iggy_client.get_user(created.id)
+        assert renamed is not None
+        assert renamed.username == new_username
+
+        await iggy_client.delete_user(created.id)
+
+
+class TestDeleteUser:
+    """Test user deletion via delete_user."""
+
+    @pytest.mark.asyncio
+    async def test_delete_user_by_name(self, iggy_client: IggyClient, 
unique_name):
+        """Test delete_user removes a user addressed by username."""
+        username, password = _unique_credentials(unique_name)
+        await iggy_client.create_user(username, password)
+
+        await iggy_client.delete_user(username)
+
+        assert await iggy_client.get_user(username) is None
+
+    @pytest.mark.asyncio
+    async def test_delete_user_by_numeric_id(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test delete_user removes a user addressed by numeric id."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+        assert await iggy_client.get_user(created.id) is None
+
+    @pytest.mark.asyncio
+    async def test_delete_nonexistent_user_fails(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test delete_user raises for a non-existent user."""
+        with pytest.raises(RuntimeError):
+            await 
iggy_client.delete_user(unique_name(max_bytes=MAX_USERNAME_BYTES))
+
+    @pytest.mark.parametrize("root_identifier", ["iggy", 0], ids=["by-name", 
"by-id"])
+    @pytest.mark.asyncio
+    async def test_delete_root_user_fails(
+        self, iggy_client: IggyClient, root_identifier
+    ):
+        """Test the root user cannot be deleted by username or numeric id."""
+        with pytest.raises(RuntimeError):
+            await iggy_client.delete_user(root_identifier)
+
+        root = await iggy_client.get_user("iggy")
+        assert root is not None
+        assert root.status == UserStatus.Active
+
+    @pytest.mark.asyncio
+    async def test_delete_user_twice_fails(self, iggy_client: IggyClient, 
unique_name):
+        """Test deleting the same user twice fails on the second call."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+        with pytest.raises(RuntimeError):
+            await iggy_client.delete_user(created.id)
+
+    @pytest.mark.asyncio
+    async def test_delete_inactive_user(self, iggy_client: IggyClient, 
unique_name):
+        """Test an inactive user can be deleted."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password, 
UserStatus.Inactive)
+
+        await iggy_client.delete_user(created.id)
+
+        assert await iggy_client.get_user(created.id) is None
+
+    @pytest.mark.asyncio
+    async def test_deleted_user_disappears_from_listings(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a deleted user is absent from both get_user and get_users."""
+        username, password = _unique_credentials(unique_name)
+        created = await iggy_client.create_user(username, password)
+
+        await iggy_client.delete_user(created.id)
+
+        assert await iggy_client.get_user(username) is None
+        assert await iggy_client.get_user(created.id) is None
+        users = await iggy_client.get_users()
+        assert created.id not in [user.id for user in users]
+        assert username not in [user.username for user in users]
+
+    @pytest.mark.asyncio
+    async def test_deleted_user_live_session_loses_identity(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a session owned by a deleted user can no longer act as that 
user."""
+        # TODO: Re-enable once permission management lands in the Python SDK.
+        # With only unprivileged users available, this test cannot prove its 
claim.
+        pass
+
+    @pytest.mark.asyncio
+    async def test_deleted_username_is_reusable_with_fresh_credentials(
+        self, iggy_client: IggyClient, unique_name
+    ):
+        """Test a deleted username can be recreated without old password 
state."""
+        username = unique_name(max_bytes=MAX_USERNAME_BYTES)
+        password = unique_name(max_bytes=MAX_USERNAME_BYTES)
+        new_password = f"{password}x"
+
+        first = await iggy_client.create_user(username, password)
+        await iggy_client.delete_user(first.id)
+
+        recreated = await iggy_client.create_user(username, new_password)
+
+        host, port = get_server_config()
+        client = IggyClient(f"{host}:{port}")
+        await client.connect()
+        with pytest.raises(RuntimeError):
+            await client.login_user(username, password)
+        await client.login_user(username, new_password)
+
+        await iggy_client.delete_user(recreated.id)
+
+
[email protected](
+    "method_name",
+    [
+        "get_user",
+        "get_users",
+        "create_user",
+        "update_user",
+        "delete_user",
+    ],
+)
[email protected]
+async def test_user_management_requires_connection_and_auth(method_name, 
unique_name):
+    """Test user management methods fail before connecting and before login."""
+    host, port = get_server_config()
+    wait_for_server(host, port)
+
+    client = IggyClient(f"{host}:{port}")
+    username = unique_name(max_bytes=MAX_USERNAME_BYTES)
+    args_by_method = {
+        "get_user": (username,),
+        "get_users": (),
+        "create_user": (username, "secret"),
+        "update_user": (username,),
+        "delete_user": (username,),
+    }
+    method = getattr(client, method_name)
+    args = args_by_method[method_name]
+
+    with pytest.raises(RuntimeError):
+        await method(*args)
+
+    await client.connect()
+    with pytest.raises(RuntimeError):
+        await method(*args)


Reply via email to