From 04eaa2d128ca858932ca12b7c15cdadbf05a675e Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Tue, 24 Mar 2026 20:48:45 +0200 Subject: [PATCH] fix shell method being shadowed A shell command named status would be shadowed by a parent class like AsyncDriverClient with the method status. Add __getattribute__ to ShellClient to intercept configured shell method names before descriptor resolution. Signed-off-by: Benny Zlotnik Assisted-by: claude-sonnet-4.6 --- .../jumpstarter_driver_shell/client.py | 56 ++++++++++++++----- .../jumpstarter_driver_shell/driver_test.py | 25 +++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py index 3f02905b5..eb1bc087f 100644 --- a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py +++ b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py @@ -1,3 +1,4 @@ +import logging import sys from dataclasses import dataclass @@ -6,10 +7,12 @@ from jumpstarter.client import DriverClient from jumpstarter.client.decorators import driver_click_group +logger = logging.getLogger(__name__) + @dataclass(kw_only=True) class ShellClient(DriverClient): - _methods: list[str] | None = None + _methods: set[str] | None = None """ Client interface for Shell driver. @@ -21,23 +24,48 @@ class ShellClient(DriverClient): def _check_method_exists(self, method): if self._methods is None: - self._methods = self.call("get_methods") + self._methods = set(self.call("get_methods")) if method not in self._methods: raise AttributeError(f"method {method} not found in {self._methods}") - ## capture any method calls dynamically + def _call_method(self, method_name, *args, **kwargs): + self._check_method_exists(method_name) + returncode = 0 + for stdout, stderr, code in self.streamingcall("call_method", method_name, kwargs, *args): + if stdout: + print(stdout, end='', flush=True) + if stderr: + print(stderr, end='', file=sys.stderr, flush=True) + if code is not None: + returncode = code + return returncode + + def __getattribute__(self, name): + if not name.startswith("_"): + d = object.__getattribute__(self, "__dict__") + methods = d.get("_methods") + + # Lazy-load on first access; guard prevents recursion + # since self.call() re-enters __getattribute__ + if methods is None and not d.get("_loading_methods"): + d["_loading_methods"] = True + try: + methods = set(object.__getattribute__(self, "call")("get_methods")) + d["_methods"] = methods + except Exception: + logger.debug("Failed to lazy-load shell methods", exc_info=True) + finally: + d.pop("_loading_methods", None) + + if methods and name in methods: + return lambda *args, **kwargs: object.__getattribute__(self, "_call_method")(name, *args, **kwargs) + + return object.__getattribute__(self, name) + def __getattr__(self, name): self._check_method_exists(name) def execute(*args, **kwargs): - returncode = 0 - for stdout, stderr, code in self.streamingcall("call_method", name, kwargs, *args): - if stdout: - print(stdout, end='', flush=True) - if stderr: - print(stderr, end='', file=sys.stderr, flush=True) - if code is not None: - returncode = code - return returncode + return self._call_method(name, *args, **kwargs) return execute def cli(self): @@ -49,7 +77,7 @@ def base(): # Get available methods from the driver if self._methods is None: - self._methods = self.call("get_methods") + self._methods = set(self.call("get_methods")) # Create a command for each configured method for method_name in self._methods: @@ -69,7 +97,7 @@ def method_command(args, env): else: raise click.BadParameter(f"Invalid --env value '{env_var}'. Use KEY=VALUE.") - returncode = getattr(self, method_name)(*args, **env_dict) + returncode = self._call_method(method_name, *args, **env_dict) # Exit with the same return code as the shell command if returncode != 0: diff --git a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py index 20faad63d..33919963e 100644 --- a/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py +++ b/python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py @@ -255,3 +255,28 @@ def test_mixed_format_methods(): assert cli.commands['simple'].help == "Execute the simple shell method" assert cli.commands['detailed'].help == "A detailed command with description" assert cli.commands['default_cmd'].help == "Method using default command" + + +def test_method_named_status(): + """ + AsyncDriverClient.status is a @property (data descriptor) that would + normally shadow __getattr__. ShellClient.__getattribute__ detects + configured shell methods and returns them before the property resolves. + """ + shell = Shell( + methods={ + "status": "echo ok", + "stop": "echo stopped", + } + ) + + with serve(shell) as client: + returncode = client.status() + assert returncode == 0 + + cli = client.cli() + assert "status" in cli.commands + cli(["status"], standalone_mode=False) + + returncode = client.stop() + assert returncode == 0