From 3cc740f0abaeb905c7765cbd25de02227e9535c6 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Mon, 15 Jun 2026 00:24:56 +0800 Subject: [PATCH 1/3] update listDaemons to return a structure Signed-off-by: kerthcet --- examples/exec_commands.py | 3 ++- examples/install_htop.py | 5 +++-- examples/interactive_session.py | 6 +++--- python/sandd/__init__.py | 3 ++- python/sandd/models.py | 31 ++++++++++++++++++++++++++++- python/sandd/server.py | 34 ++++++++++++++++++++++---------- python/tests/test_e2e.py | 32 ++++++++++++++++++------------ python/tests/test_integration.py | 31 ++++++++++++++++++----------- server/src/lib.rs | 33 +++++++++++++++++++++++++++++-- 9 files changed, 133 insertions(+), 45 deletions(-) diff --git a/examples/exec_commands.py b/examples/exec_commands.py index 9555067..fa72c94 100755 --- a/examples/exec_commands.py +++ b/examples/exec_commands.py @@ -20,7 +20,8 @@ print(f"\rConnected: {stats.total_daemons} | Platforms: {stats.by_platform}", end="", flush=True) if daemons and len(daemons) > 0: - for daemon_id in daemons: + for daemon in daemons: + daemon_id = daemon.id try: # Test 1: Python script result = server.exec( diff --git a/examples/install_htop.py b/examples/install_htop.py index f4b5df0..81f8102 100755 --- a/examples/install_htop.py +++ b/examples/install_htop.py @@ -90,8 +90,9 @@ def main(): time.sleep(1) daemons = server.list_daemons() - daemon_id = daemons[0] - print(f"✓ Found daemon: {daemon_id}\n") + daemon = daemons[0] + daemon_id = daemon.id + print(f"✓ Found daemon: {daemon_id} (version={daemon.version})\n") # Check if htop is available print("Checking if htop is available...") diff --git a/examples/interactive_session.py b/examples/interactive_session.py index c40cee9..ce04a07 100755 --- a/examples/interactive_session.py +++ b/examples/interactive_session.py @@ -29,14 +29,14 @@ def main(): time.sleep(1) daemons = server.list_daemons() - daemon_id = daemons[0] - print(f"✓ Found daemon: {daemon_id}\n") + daemon = daemons[0] + print(f"✓ Found daemon: {daemon.id} (version={daemon.version})\n") print("Starting interactive terminal...") print() # Start session in interactive mode - this blocks until user exits - server.new_session(daemon_id, interactive=True) + server.new_session(daemon.id, interactive=True) if __name__ == "__main__": diff --git a/python/sandd/__init__.py b/python/sandd/__init__.py index 557030c..a766202 100644 --- a/python/sandd/__init__.py +++ b/python/sandd/__init__.py @@ -39,7 +39,7 @@ ... ) """ -from .models import CommandResult, ServerStats +from .models import CommandResult, ServerStats, DaemonInfo from .server import Server from .async_server import AsyncServer @@ -57,4 +57,5 @@ "Session", "CommandResult", "ServerStats", + "DaemonInfo", ] diff --git a/python/sandd/models.py b/python/sandd/models.py index 0f3642a..0b06c8f 100644 --- a/python/sandd/models.py +++ b/python/sandd/models.py @@ -1,6 +1,6 @@ """Data models for SandD""" -from typing import Dict +from typing import Dict, Optional try: from ._core import PyCommandResult, PyStats @@ -58,6 +58,35 @@ def __repr__(self) -> str: ) +class DaemonInfo: + """Information about a connected daemon + + Attributes: + id: Daemon identifier + version: Daemon version string + labels: Key-value labels for filtering + is_busy: Whether daemon has pending commands + """ + + def __init__( + self, + id: str, + version: str, + labels: Dict[str, str], + is_busy: bool, + ): + self.id = id + self.version = version + self.labels = labels + self.is_busy = is_busy + + def __repr__(self) -> str: + return ( + f"DaemonInfo(id={self.id!r}, version={self.version!r}, " + f"labels={self.labels}, is_busy={self.is_busy})" + ) + + class ServerStats: """Server statistics diff --git a/python/sandd/server.py b/python/sandd/server.py index 4688347..fed4e3f 100644 --- a/python/sandd/server.py +++ b/python/sandd/server.py @@ -5,7 +5,7 @@ import sys import select -from .models import CommandResult, ServerStats +from .models import CommandResult, ServerStats, DaemonInfo try: from ._core import Server as _RustServer, Session @@ -185,30 +185,41 @@ def download_file( def list_daemons( self, labels: Optional[Dict[str, str]] = None, - ) -> List[str]: - """List all connected daemon IDs, optionally filtered by labels + ) -> List[DaemonInfo]: + """List all connected daemons with their information, optionally filtered by labels Args: labels: Dictionary of label key-value pairs to filter by (AND logic) All specified labels must match for a daemon to be included Returns: - List of daemon IDs + List of DaemonInfo objects Example: >>> # List all daemons >>> daemons = server.list_daemons() >>> print(f"Connected: {len(daemons)} daemons") + >>> for daemon in daemons: + ... print(f" {daemon.id}: {daemon.version}, busy={daemon.is_busy}") >>> >>> # List daemons with single label >>> prod_daemons = server.list_daemons(labels={"env": "prod"}) >>> >>> # List daemons with multiple labels (AND logic) >>> west_prod = server.list_daemons(labels={"env": "prod", "region": "us-west"}) - >>> for daemon_id in west_prod: - ... print(f" - {daemon_id}") + >>> for daemon in west_prod: + ... print(f" - {daemon.id} ({daemon.labels})") """ - return self._server.list_daemons(labels) + py_infos = self._server.list_daemons(labels) + return [ + DaemonInfo( + id=info.id, + version=info.version, + labels=info.labels, + is_busy=info.is_busy, + ) + for info in py_infos + ] def daemon_count(self) -> int: """Get number of connected daemons @@ -339,10 +350,12 @@ def broadcast( import concurrent.futures # Get matching daemons - daemon_ids = self.list_daemons(labels=labels) - if not daemon_ids: + daemons = self.list_daemons(labels=labels) + if not daemons: return {} + daemon_ids = [d.id for d in daemons] + # Execute command on all daemons concurrently def run_command(daemon_id): try: @@ -390,7 +403,8 @@ def wait_for_daemon( """ start = time.time() while time.time() - start < timeout: - if daemon_id in self.list_daemons(): + daemons = self.list_daemons() + if any(d.id == daemon_id for d in daemons): return True time.sleep(poll_interval) return False diff --git a/python/tests/test_e2e.py b/python/tests/test_e2e.py index e3311af..3f73834 100644 --- a/python/tests/test_e2e.py +++ b/python/tests/test_e2e.py @@ -60,13 +60,14 @@ class TestE2EBasicOperations: def test_all_daemons_connected(self, server): """Verify all 6 daemons connected (2 debian + 2 alpine + 2 rocky)""" daemons = server.list_daemons() + daemon_ids = [d.id for d in daemons] expected = [ "daemon-debian-1", "daemon-debian-2", "daemon-alpine-1", "daemon-alpine-2", "daemon-rocky-1", "daemon-rocky-2" ] for daemon_id in expected: - assert daemon_id in daemons + assert daemon_id in daemon_ids assert server.daemon_count() >= 6 def test_execute_on_each_daemon(self, server): @@ -224,29 +225,34 @@ class TestE2ELabels: def test_filter_by_env_label(self, server): """Filter daemons by env label""" test_daemons = server.list_daemons(labels={"env": "test"}) - assert "daemon-debian-1" in test_daemons - assert "daemon-debian-2" in test_daemons - assert "daemon-alpine-1" in test_daemons - assert "daemon-rocky-2" in test_daemons + test_ids = [d.id for d in test_daemons] + assert "daemon-debian-1" in test_ids + assert "daemon-debian-2" in test_ids + assert "daemon-alpine-1" in test_ids + assert "daemon-rocky-2" in test_ids prod_daemons = server.list_daemons(labels={"env": "prod"}) - assert "daemon-alpine-2" in prod_daemons - assert "daemon-rocky-1" in prod_daemons + prod_ids = [d.id for d in prod_daemons] + assert "daemon-alpine-2" in prod_ids + assert "daemon-rocky-1" in prod_ids def test_filter_by_distro_label(self, server): """Filter daemons by distribution""" debian_daemons = server.list_daemons(labels={"distro": "debian"}) - assert "daemon-debian-1" in debian_daemons - assert "daemon-debian-2" in debian_daemons + debian_ids = [d.id for d in debian_daemons] + assert "daemon-debian-1" in debian_ids + assert "daemon-debian-2" in debian_ids assert len(debian_daemons) >= 2 alpine_daemons = server.list_daemons(labels={"distro": "alpine"}) - assert "daemon-alpine-1" in alpine_daemons - assert "daemon-alpine-2" in alpine_daemons + alpine_ids = [d.id for d in alpine_daemons] + assert "daemon-alpine-1" in alpine_ids + assert "daemon-alpine-2" in alpine_ids rocky_daemons = server.list_daemons(labels={"distro": "rocky"}) - assert "daemon-rocky-1" in rocky_daemons - assert "daemon-rocky-2" in rocky_daemons + rocky_ids = [d.id for d in rocky_daemons] + assert "daemon-rocky-1" in rocky_ids + assert "daemon-rocky-2" in rocky_ids class TestE2EResilience: diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py index 3a661fc..96786a5 100644 --- a/python/tests/test_integration.py +++ b/python/tests/test_integration.py @@ -79,7 +79,8 @@ def test_daemon_connects(self, server, daemon_process): # Verify daemon is in the list daemons = server.list_daemons() - assert daemon_id in daemons + daemon_ids = [d.id for d in daemons] + assert daemon_id in daemon_ids # Verify daemon count assert server.daemon_count() == 1 @@ -109,9 +110,10 @@ def test_multiple_daemons_connect(self, server, sandd_binary): # Verify all connected daemons = server.list_daemons() + daemon_id_list = [d.id for d in daemons] assert server.daemon_count() == 3 for daemon_id in daemon_ids: - assert daemon_id in daemons + assert daemon_id in daemon_id_list finally: # Cleanup all daemons @@ -160,29 +162,34 @@ def test_daemon_with_labels(self, server, sandd_binary): # Test: list all daemons (no filter) all_daemons = server.list_daemons() - assert daemon_id_prod in all_daemons - assert daemon_id_dev in all_daemons + all_ids = [d.id for d in all_daemons] + assert daemon_id_prod in all_ids + assert daemon_id_dev in all_ids assert len(all_daemons) >= 2 # Test: filter by env=prod prod_daemons = server.list_daemons(labels={"env": "prod"}) - assert daemon_id_prod in prod_daemons - assert daemon_id_dev not in prod_daemons + prod_ids = [d.id for d in prod_daemons] + assert daemon_id_prod in prod_ids + assert daemon_id_dev not in prod_ids # Test: filter by env=dev dev_daemons = server.list_daemons(labels={"env": "dev"}) - assert daemon_id_dev in dev_daemons - assert daemon_id_prod not in dev_daemons + dev_ids = [d.id for d in dev_daemons] + assert daemon_id_dev in dev_ids + assert daemon_id_prod not in dev_ids # Test: filter by region=us-west region_daemons = server.list_daemons(labels={"region": "us-west"}) - assert daemon_id_prod in region_daemons - assert daemon_id_dev not in region_daemons + region_ids = [d.id for d in region_daemons] + assert daemon_id_prod in region_ids + assert daemon_id_dev not in region_ids # Test: filter by multiple labels (AND logic) west_prod = server.list_daemons(labels={"env": "prod", "region": "us-west"}) - assert daemon_id_prod in west_prod - assert daemon_id_dev not in west_prod + west_prod_ids = [d.id for d in west_prod] + assert daemon_id_prod in west_prod_ids + assert daemon_id_dev not in west_prod_ids # Test: filter by non-existent label none_daemons = server.list_daemons(labels={"env": "staging"}) diff --git a/server/src/lib.rs b/server/src/lib.rs index 0a573f4..9d67180 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -225,8 +225,22 @@ impl Server { /// List all connected daemons, optionally filtered by labels #[pyo3(signature = (labels=None))] - fn list_daemons(&self, labels: Option>) -> PyResult> { - Ok(self.registry.list_all(labels.as_ref())) + fn list_daemons(&self, labels: Option>) -> PyResult> { + let daemon_ids = self.registry.list_all(labels.as_ref()); + let mut result = Vec::new(); + + for daemon_id in daemon_ids { + if let Some(conn) = self.registry.get(&daemon_id) { + result.push(PyDaemonInfo { + id: conn.id.clone(), + version: conn.metadata.version.clone(), + labels: conn.metadata.labels.clone(), + is_busy: conn.is_busy(), + }); + } + } + + Ok(result) } /// Get daemon count @@ -325,6 +339,20 @@ impl Session { } } +/// Daemon information +#[pyclass] +#[derive(Clone)] +pub struct PyDaemonInfo { + #[pyo3(get)] + pub id: String, + #[pyo3(get)] + pub version: String, + #[pyo3(get)] + pub labels: HashMap, + #[pyo3(get)] + pub is_busy: bool, +} + /// Command execution result #[pyclass] #[derive(Clone)] @@ -370,6 +398,7 @@ fn _core(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; Ok(()) } From 6691478e70411ed6b0a9b6eebcf4bd8215d02864 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Mon, 15 Jun 2026 00:31:10 +0800 Subject: [PATCH 2/3] fix lint Signed-off-by: kerthcet --- python/sandd/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/sandd/models.py b/python/sandd/models.py index 0b06c8f..5bcd8af 100644 --- a/python/sandd/models.py +++ b/python/sandd/models.py @@ -1,6 +1,6 @@ """Data models for SandD""" -from typing import Dict, Optional +from typing import Dict try: from ._core import PyCommandResult, PyStats From a80ce34d95ebe56514020028a7e75b1765bb7df9 Mon Sep 17 00:00:00 2001 From: kerthcet Date: Mon, 15 Jun 2026 00:38:02 +0800 Subject: [PATCH 3/3] optimize the vec initialization Signed-off-by: kerthcet --- server/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/lib.rs b/server/src/lib.rs index 9d67180..f7e9eee 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -227,7 +227,7 @@ impl Server { #[pyo3(signature = (labels=None))] fn list_daemons(&self, labels: Option>) -> PyResult> { let daemon_ids = self.registry.list_all(labels.as_ref()); - let mut result = Vec::new(); + let mut result = Vec::with_capacity(daemon_ids.len()); for daemon_id in daemon_ids { if let Some(conn) = self.registry.get(&daemon_id) {