Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import sys
from dataclasses import dataclass

Expand All @@ -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.
Expand All @@ -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)
Comment on lines +43 to +63
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find public methods/attributes in DriverClient that could be shadowed

ast-grep --pattern $'class DriverClient($_):
  $$$
  def $_($$$):
    $$$
  $$$
'

# Also search for property definitions
rg -n '@property' python/packages/jumpstarter/jumpstarter/client/

Repository: jumpstarter-dev/jumpstarter

Length of output: 8422


🏁 Script executed:

# Read the ShellClient implementation around lines 43-63 with full context
cat -n python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py | head -80

Repository: jumpstarter-dev/jumpstarter

Length of output: 3452


🏁 Script executed:

# Find and read the get_methods() implementation
rg -A 10 'def get_methods' python/packages/jumpstarter-driver-shell/

Repository: jumpstarter-dev/jumpstarter

Length of output: 1440


🏁 Script executed:

# Check if ShellClient has any existing safeguards or method filtering
rg -B 5 -A 15 'class ShellClient' python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 711


🏁 Script executed:

# Find AsyncDriverClient to see if there are additional public methods/properties
fd -t f 'async' python/packages/jumpstarter/jumpstarter/client/ | head -5

Repository: jumpstarter-dev/jumpstarter

Length of output: 53


🏁 Script executed:

# Check the full parent class hierarchy of DriverClient
rg -B 5 'class AsyncDriverClient' python/packages/jumpstarter/jumpstarter/client/

Repository: jumpstarter-dev/jumpstarter

Length of output: 525


🏁 Script executed:

# Look for all public attributes and methods in DriverClient and related classes
rg -n '^\s+(def |[a-z_]+:)' python/packages/jumpstarter/jumpstarter/client/base.py | head -40

Repository: jumpstarter-dev/jumpstarter

Length of output: 870


🏁 Script executed:

# Check how shell methods are configured - look for self.methods in driver
rg -B 5 -A 5 'self.methods' python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py | head -40

Repository: jumpstarter-dev/jumpstarter

Length of output: 1767


🏁 Script executed:

# Look for how ShellDriver is instantiated to understand if method names are user-configurable
rg -n 'class ShellDriver' python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 53


🏁 Script executed:

# Check the complete ShellDriver init to understand method configuration
cat -n python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py | head -50

Repository: jumpstarter-dev/jumpstarter

Length of output: 2274


🏁 Script executed:

# Verify what "report", "uuid", "labels" might be - check for mixins or other parent classes
rg -n 'def report|def uuid|def labels|resources' python/packages/jumpstarter/jumpstarter/client/ | head -20

Repository: jumpstarter-dev/jumpstarter

Length of output: 306


🏁 Script executed:

# Verify the full method resolution order for ShellClient
python3 << 'EOF'
import sys
sys.path.insert(0, 'python/packages/jumpstarter')
sys.path.insert(0, 'python/packages/jumpstarter-driver-shell')

from jumpstarter_driver_shell.client import ShellClient
from jumpstarter.client import DriverClient

# Get all public attributes and methods from parent classes
public_members = set()
for cls in ShellClient.__mro__:
    if cls is object:
        continue
    for name in dir(cls):
        if not name.startswith('_'):
            public_members.add(name)

print("Public members in ShellClient hierarchy:")
for name in sorted(public_members):
    print(f"  {name}")
EOF

Repository: jumpstarter-dev/jumpstarter

Length of output: 785


🏁 Script executed:

# Check if there's any existing validation or safeguard in get_methods or driver configuration
rg -B 10 -A 10 'def get_methods' python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 902


🏁 Script executed:

# Check AsyncDriverClient for additional methods/properties
rg -A 50 'class AsyncDriverClient' python/packages/jumpstarter/jumpstarter/client/core.py | head -80

Repository: jumpstarter-dev/jumpstarter

Length of output: 1780


🏁 Script executed:

# Search for "report", "uuid", "labels" definitions in client hierarchy
rg -n 'def report|uuid|labels|resources' python/packages/jumpstarter/jumpstarter/client/core.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 435


🏁 Script executed:

# Look for mixins or parent classes that might add additional methods
rg -n 'class.*Mixin|class Driver' python/packages/jumpstarter/jumpstarter/driver.py | head -10

Repository: jumpstarter-dev/jumpstarter

Length of output: 160


🏁 Script executed:

# Find Metadata class definition to understand what properties it adds
rg -B 5 -A 20 'class Metadata' python/packages/jumpstarter/jumpstarter/client/core.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 53


🏁 Script executed:

# List all public methods in DriverClient that would be shadowed
cat -n python/packages/jumpstarter/jumpstarter/client/base.py | sed -n '20,150p'

Repository: jumpstarter-dev/jumpstarter

Length of output: 5306


🏁 Script executed:

# Test the actual shadowing scenario - create a test case
cat > /tmp/test_shadowing.py << 'EOF'
# Simulate what happens when ShellClient.__getattribute__ intercepts "call"

class Parent:
    def call(self, *args):
        return "parent_call_result"
    def close(self):
        return "parent_close_result"

class Child(Parent):
    def __init__(self):
        self._methods = {"call", "status"}  # User configured "call" as a shell method
    
    def __getattribute__(self, name):
        if not name.startswith("_"):
            methods = object.__getattribute__(self, "_methods")
            if methods and name in methods:
                return lambda *args, **kwargs: f"shell_{name}_called"
        return object.__getattribute__(self, name)

# Test
c = Child()
print(f"c.call() = {c.call()}")  # Should call shell_call, not Parent.call
print(f"c.close() = {c.close()}")  # Should call Parent.close
EOF
python3 /tmp/test_shadowing.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 131


🏁 Script executed:

# Find Metadata class to see what properties it adds
rg -B 2 -A 30 'class Metadata' python/packages/jumpstarter/jumpstarter/client/core.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 53


🏁 Script executed:

# Get a comprehensive list of all public methods and attributes in the client hierarchy
cat > /tmp/find_public.py << 'EOF'
import ast
import sys

# Read DriverClient definition
with open('python/packages/jumpstarter/jumpstarter/client/base.py', 'r') as f:
    tree = ast.parse(f.read())

public_members = set()
for node in ast.walk(tree):
    if isinstance(node, ast.ClassDef) and node.name in ['DriverClient', 'AsyncDriverClient']:
        for item in node.body:
            if isinstance(item, ast.FunctionDef) and not item.name.startswith('_'):
                public_members.add(item.name)

# Read core.py for AsyncDriverClient/Metadata
with open('python/packages/jumpstarter/jumpstarter/client/core.py', 'r') as f:
    content = f.read()
    tree = ast.parse(content)

for node in ast.walk(tree):
    if isinstance(node, ast.ClassDef) and node.name in ['Metadata', 'AsyncDriverClient']:
        for item in node.body:
            if isinstance(item, ast.FunctionDef) and not item.name.startswith('_'):
                public_members.add(item.name)
            elif isinstance(item, (ast.Assign, ast.AnnAssign)):
                if isinstance(item, ast.Assign):
                    for target in item.targets:
                        if isinstance(target, ast.Name) and not target.id.startswith('_'):
                            public_members.add(target.id)
                else:
                    if isinstance(item.target, ast.Name) and not item.target.id.startswith('_'):
                        public_members.add(item.target.id)

print("All public members that could be shadowed:")
for name in sorted(public_members):
    print(f"  {name}")
EOF
python3 /tmp/find_public.py

Repository: jumpstarter-dev/jumpstarter

Length of output: 217


🏁 Script executed:

# Check Metadata class specifically
rg -B 3 -A 40 'class Metadata' python/packages/jumpstarter/jumpstarter/common/metadata.py 2>/dev/null || rg -r 'class Metadata' python/packages/jumpstarter/

Repository: jumpstarter-dev/jumpstarter

Length of output: 329


Missing safeguard for reserved method names could break client functionality.

The __getattribute__ override intercepts all non-underscore attributes and returns a shell method callable if the name is in _methods. However, if a user configures a shell method with the same name as a critical parent class method (e.g., "call", "streamingcall", "close", "stream"), the lambda will shadow that method and break the client. This is a real shadowing risk that has been verified.

Per the driver code, get_methods() returns all configured method names without filtering. The proposed approach of adding a reserved names check is sound, but the suggested list needs to be more complete to cover all public methods and properties:

  • Core methods: call, streamingcall, stream, log_stream, open_stream, close
  • Client methods: cli
  • Attributes/Fields: portal, stack, children, description, methods_description
  • Metadata properties: uuid, labels, name
  • AsyncClient attributes: stub, log_level, logger, status
🛡️ Suggested reserved names filter
 `@dataclass`(kw_only=True)
 class ShellClient(DriverClient):
     _methods: set[str] | None = None
+    _RESERVED_NAMES: frozenset = frozenset({
+        "call", "streamingcall", "cli", "close", "stream",
+        "log_stream", "open_stream", "portal", "stack", "children",
+        "description", "methods_description", "uuid", "labels", "name",
+        "stub", "log_level", "logger", "status",
+    })

     ...

     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"))
+                    reserved = object.__getattribute__(self, "_RESERVED_NAMES")
+                    if methods & reserved:
+                        logger.warning(
+                            f"Shell methods {methods & reserved} shadow reserved names and will be ignored"
+                        )
+                        methods -= reserved
                     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:
+            if methods and name in methods and name not in object.__getattribute__(self, "_RESERVED_NAMES"):
                 return lambda *args, **kwargs: object.__getattribute__(self, "_call_method")(name, *args, **kwargs)

         return object.__getattribute__(self, name)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py`
around lines 43 - 63, The __getattribute__ override currently returns a lambda
for any name in _methods which can shadow critical client methods; add a
reserved-names safeguard: define a set of reserved names (including core methods
call, streamingcall, stream, log_stream, open_stream, close; client methods cli;
attributes portal, stack, children, description, methods_description; metadata
uuid, labels, name; AsyncClient/stub attrs stub, log_level, logger, status) and,
inside __getattribute__, after loading _methods and before returning the lambda
from _call_method, check if name is in that reserved set and if so do not treat
it as a dynamic shell method (fall back to object.__getattribute__); ensure this
uses the existing symbols _methods, _call_method and __getattribute__ so
existing logic remains but avoids shadowing reserved names.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mangelajo yeah, this is a difficult problem, I don't know how likely it is to use call in the exporter config, but close doesn't sound very unlikely

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, probably we should add a checker on the exporter config with a banned list of names that can't be used.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai how can we effectively maintain such a lease without forgetting to add new methods?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bennyz Great question! The most reliable way to avoid maintaining a manual list is to derive the reserved names programmatically from the MRO at class-definition time. This means the set auto-updates whenever parent classes gain new public methods — no manual bookkeeping required.

Here's the approach:

`@dataclass`(kw_only=True)
class ShellClient(DriverClient):
    _methods: set[str] | None = None

    `@classmethod`
    def _parent_public_attrs(cls) -> frozenset[str]:
        """Dynamically derive reserved names from the parent class hierarchy.
        Skips ShellClient itself (mro[0]) so only parent names are reserved.
        """
        return frozenset(
            attr
            for parent in cls.__mro__[1:]   # skip ShellClient
            for attr in vars(parent)
            if not attr.startswith("_")
        )

    def __getattribute__(self, name):
        if not name.startswith("_"):
            d = object.__getattribute__(self, "__dict__")
            methods = d.get("_methods")

            if methods is None and not d.get("_loading_methods"):
                d["_loading_methods"] = True
                try:
                    methods = set(object.__getattribute__(self, "call")("get_methods"))
                    # Strip any names that would shadow parent class attrs
                    reserved = object.__getattribute__(self, "_parent_public_attrs")()
                    shadowed = methods & reserved
                    if shadowed:
                        logger.warning(
                            "Shell method(s) %s shadow reserved client names and will be ignored",
                            shadowed,
                        )
                        methods -= reserved
                    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)

Why this works without ongoing maintenance:

  • cls.__mro__[1:] skips ShellClient but walks every ancestor (DriverClient, AsyncDriverClient, object, mixins, etc.).
  • vars(parent) inspects each class's own __dict__, so only directly-defined names are included (no duplicates from inheritance, no surprises).
  • If a new public method is added to any ancestor, it's automatically in reserved the next time the code runs — nothing to update.

Optional: cache it to avoid recomputing on every __getattribute__ call:

# After the class body:
ShellClient._RESERVED_NAMES: frozenset[str] = ShellClient._parent_public_attrs()

Then reference _RESERVED_NAMES directly instead of calling _parent_public_attrs() each time. The tradeoff is that the cache is frozen at import time, but since parent classes don't change at runtime, that's perfectly fine.


🧠 Learnings used
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/project-structure.mdc:0-0
Timestamp: 2026-02-04T18:13:10.193Z
Learning: Applies to python/packages/jumpstarter-driver-*/jumpstarter_driver_*/client.py : Each driver package must include a `client.py` file in `<package>/jumpstarter_driver_<name>/client.py`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai, but this would break status

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bennyz You're absolutely right — that's the key flaw in my suggestion. The PR's whole purpose is to allow user-configured shell methods like status to shadow parent class methods. Stripping out everything from __mro__[1:] would re-introduce the original bug.

The correct distinction is between:

  1. Critical infrastructure methods — those the client literally cannot function without (e.g., call, streamingcall, close, stream). If these are shadowed, the client breaks entirely.
  2. Overrideable parent methods — those that just happen to exist in a parent class (e.g., status, name, labels). These should legitimately yield to user-configured shell methods.

So the right approach is a small, manually-curated list of only the critical infrastructure names:

# Only reserve names that are essential for client-server communication.
# Do NOT include names like "status", "name", "labels" — those are
# intentionally overrideable by user-configured shell methods.
_RESERVED_NAMES: frozenset = frozenset({
    "call", "streamingcall", "stream",
    "log_stream", "open_stream", "close",
    "cli",
})

This list is actually quite stable — it maps directly to the RPC transport layer. New methods would only be added to it if they're core transport/lifecycle methods, not domain-level methods. The concern about "forgetting to update" is much lower here because:

  • These methods live in DriverClient/AsyncDriverClient and changing them is already a breaking API change requiring careful review.
  • The set is small enough (~7 entries) that a PR adding a new transport method would naturally prompt updating this list.

The tradeoff (vs. the MRO approach) is intentional: we're trading "auto-complete coverage" for "respects the PR's shadowing intent."


🧠 Learnings used
Learnt from: CR
Repo: jumpstarter-dev/jumpstarter PR: 0
File: .cursor/rules/project-structure.mdc:0-0
Timestamp: 2026-02-04T18:13:10.193Z
Learning: Applies to python/packages/jumpstarter-driver-*/jumpstarter_driver_*/client.py : Each driver package must include a `client.py` file in `<package>/jumpstarter_driver_<name>/client.py`


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):
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading