Skip to content

fix shell method being shadowed#382

Open
bennyz wants to merge 1 commit intojumpstarter-dev:mainfrom
bennyz:fix-shadowing
Open

fix shell method being shadowed#382
bennyz wants to merge 1 commit intojumpstarter-dev:mainfrom
bennyz:fix-shadowing

Conversation

@bennyz
Copy link
Member

@bennyz bennyz commented Mar 24, 2026

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.

A configuration with:

vm:
    type: "jumpstarter_driver_shell.driver.Shell"
    description: VM management
    config:
      methods:
        status: |
          ...

would confuse j vm status and try to call AsyncDriverClient#status

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 <bzlotnik@redhat.com>
Assisted-by: claude-sonnet-4.6
@netlify
Copy link

netlify bot commented Mar 24, 2026

Deploy Preview for jumpstarter-docs ready!

Name Link
🔨 Latest commit 04eaa2d
🔍 Latest deploy log https://app.netlify.com/projects/jumpstarter-docs/deploys/69c2deb36ea0c80008047968
😎 Deploy Preview https://deploy-preview-382--jumpstarter-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

Refactored dynamic method invocation in ShellClient: changed _methods storage from list to set, introduced centralized _call_method for consistent streaming and return-code handling, reworked __getattribute__ for lazy method loading, and updated the CLI execution path. Added logging and a new test validating status method behavior.

Changes

Cohort / File(s) Summary
Method Dispatch Refactoring
jumpstarter_driver_shell/client.py
Restructured dynamic method invocation: changed _methods from list[str] to set[str], introduced _call_method for centralized execution with streaming and return-code handling, implemented __getattribute__ for lazy method loading with recursion guard, updated __getattr__ delegation, added module-level logging, and modified CLI path to use new dispatch mechanism.
Test Coverage
jumpstarter_driver_shell/driver_test.py
Added test_method_named_status to validate status method invocation, CLI generation, and Click integration for shell methods named status and stop.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A refactoring dance, methods now in sets we keep,
Dispatch flows through _call_method, consistent and deep,
Lazy loading guards against the recursion beast,
Streams and exit codes, handled with the least.
Tests now verify our status stands proud and true!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main change: fixing shell methods being shadowed by parent class methods through getattribute interception.
Description check ✅ Passed The pull request description clearly explains the problem (shell methods being shadowed by parent class methods) and the solution (adding getattribute to intercept shell method names), with a concrete example of how 'status' would be mishandled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py`:
- Around line 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.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f83a8c2a-7f8f-4329-8f0e-3e03256a6ada

📥 Commits

Reviewing files that changed from the base of the PR and between feef832 and 04eaa2d.

📒 Files selected for processing (2)
  • python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/client.py
  • python/packages/jumpstarter-driver-shell/jumpstarter_driver_shell/driver_test.py

Comment on lines +43 to +63
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)
Copy link
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
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
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
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
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
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`

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants