Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit 240cb81

Browse files
Add description and methods_description infrastructure
this allows override of the default strings to show up in the jmp shell CLI. the Driver descriptions can be customized with: exporter: driver_x: type: x description: "this driver does x" methods_description: method1: "this method does y" method2: "this method does z" Client-side changes: - Add DriverClickGroup class in client/decorators.py for CLI generation - Add description and methods_description field to DriverClient, updated from GetReport from server Server-side changes: - Include methods_description in DriverInstanceReport - Extend @export and @exportstream with optional help= parameter - Add desscription and methods_description field to Driver base class The infrastructure is using a single DriverInstanceReport message to save on RPC calls.
1 parent 7084e68 commit 240cb81

File tree

6 files changed

+474
-18
lines changed

6 files changed

+474
-18
lines changed

packages/jumpstarter/jumpstarter/client/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ class DriverClient(AsyncDriverClient):
3333
portal: BlockingPortal
3434
stack: ExitStack
3535

36+
description: str | None = None
37+
"""Driver description from GetReport(), used for CLI help text"""
38+
39+
methods_description: dict[str, str] = field(default_factory=dict)
40+
"""Map of method names to their help descriptions from GetReport()"""
41+
3642
def call(self, method, *args):
3743
"""
3844
Invoke driver call

packages/jumpstarter/jumpstarter/client/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,16 @@ async def client_from_channel(
5151
report = reports[index]
5252

5353
client_class = import_class(report.labels["jumpstarter.dev/client"], allow, unsafe)
54+
5455
client = client_class(
5556
uuid=UUID(report.uuid),
5657
labels=report.labels,
5758
stub=stub,
5859
portal=portal,
5960
stack=stack.enter_context(ExitStack()),
6061
children={reports[k].labels["jumpstarter.dev/name"]: clients[k] for k in topo[index]},
62+
description=getattr(report, 'description', None) or None,
63+
methods_description=getattr(report, 'methods_description', {}) or {},
6164
)
6265

6366
clients[index] = client
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
Client-side Click group helpers for building driver CLIs.
3+
"""
4+
5+
from typing import Any, Callable
6+
7+
import click
8+
9+
10+
class DriverClickGroup(click.Group):
11+
"""
12+
Custom Click Group to use methods_description for command help.
13+
Usage:
14+
def cli(self):
15+
# client.description from server or help= fallback
16+
base = DriverClickGroup(self, help="Default driver description")
17+
18+
# Add options elegantly
19+
base.add_option("--log-level", type=click.Choice([...]), help="...")
20+
21+
# methods_description['on'] from server or help= fallback
22+
@base.command(help="Power on")
23+
def on():
24+
self.on()
25+
26+
return base
27+
"""
28+
29+
def __init__(self, client: Any, *args: Any, **kwargs: Any) -> None:
30+
"""
31+
Initialize a DriverClickGroup bound to a client.
32+
33+
:param client: DriverClient instance (provides methods_description and description)
34+
:param args: Arguments passed to click.Group
35+
:param kwargs: Keyword arguments passed to click.Group (help= is used as fallback)
36+
"""
37+
# Use client.description if available, otherwise use provided help
38+
if 'help' in kwargs:
39+
kwargs['help'] = getattr(client, 'description', None) or kwargs['help']
40+
41+
super().__init__(*args, **kwargs)
42+
self.client = client
43+
44+
def add_option(self, *param_decls: str, **kwargs: Any) -> 'DriverClickGroup':
45+
"""
46+
Add a Click option to this group (chainable).
47+
48+
Usage:
49+
base = DriverClickGroup(self, help="...")
50+
base.add_option("--log-level", type=click.Choice([...]), help="...")
51+
52+
:param param_decls: Option names (e.g., "--log-level", "-l")
53+
:param kwargs: Click Option parameters (type, help, callback, etc.)
54+
:return: self for chaining
55+
"""
56+
self.params.append(click.Option(list(param_decls), **kwargs))
57+
return self
58+
59+
def command(self, *args: Any, **kwargs: Any) -> Callable:
60+
"""
61+
Decorator to register a client-side command with automatic help text.
62+
63+
Priority order for help text:
64+
1. methods_description from server (highest priority)
65+
2. help= parameter explicitly provided in @base.command(help="...")
66+
3. Empty string (fallback)
67+
"""
68+
def decorator(f: Callable) -> click.Command:
69+
# Determine command name (from kwargs or function name)
70+
name = kwargs.get('name') or f.__name__
71+
72+
# Priority order for help text:
73+
# 1. methods_description from server
74+
# 2. help= parameter explicitly provided by client
75+
# 3. Empty string
76+
if name in self.client.methods_description:
77+
kwargs['help'] = self.client.methods_description[name]
78+
elif 'help' not in kwargs:
79+
kwargs['help'] = ''
80+
81+
return super(DriverClickGroup, self).command(*args, **kwargs)(f)
82+
83+
return decorator

packages/jumpstarter/jumpstarter/driver/base.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from .decorators import (
2525
MARKER_DRIVERCALL,
26+
MARKER_HELP,
2627
MARKER_MAGIC,
2728
MARKER_STREAMCALL,
2829
MARKER_STREAMING_DRIVERCALL,
@@ -72,6 +73,12 @@ class Driver(
7273
resources: dict[UUID, Any] = field(default_factory=dict, init=False)
7374
"""Dict of client side resources"""
7475

76+
description: str | None = None
77+
"""Custom description for the driver (shown in CLI help)"""
78+
79+
methods_description: dict[str, str] = field(default_factory=dict, init=False)
80+
"""Map of method names to their help descriptions"""
81+
7582
log_level: str = "INFO"
7683
logger: logging.Logger = field(init=False)
7784

@@ -82,6 +89,23 @@ def __post_init__(self):
8289
self.logger = logging.getLogger(self.__class__.__name__)
8390
self.logger.setLevel(self.log_level)
8491

92+
# Collect help texts from decorated methods
93+
self._collect_methods_description()
94+
95+
def _collect_methods_description(self):
96+
"""Collect help texts from @export and @exportstream decorated methods"""
97+
for name in dir(self):
98+
if name.startswith('_'):
99+
continue
100+
101+
try:
102+
method = getattr(self, name)
103+
if callable(method) and hasattr(method, MARKER_HELP):
104+
help_text = getattr(method, MARKER_HELP)
105+
self.methods_description[name] = help_text
106+
except Exception:
107+
continue
108+
85109
def close(self):
86110
for child in self.children.values():
87111
child.close()
@@ -206,6 +230,8 @@ def report(self, *, root=None, parent=None, name=None):
206230
| self.extra_labels()
207231
| ({"jumpstarter.dev/client": self.client()})
208232
| ({"jumpstarter.dev/name": name} if name else {}),
233+
description=self.description or None,
234+
methods_description=self.methods_description or {},
209235
)
210236

211237
def enumerate(self, *, root=None, parent=None, name=None):
Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
1+
"""
2+
Server-side decorators for exporting driver methods.
3+
"""
4+
15
from inspect import isasyncgenfunction, iscoroutinefunction, isfunction, isgeneratorfunction
2-
from typing import Final
6+
from typing import Callable, Final
37

48
MARKER_MAGIC: Final[str] = "07c9b9cc"
59
MARKER_DRIVERCALL: Final[str] = "marker_drivercall"
610
MARKER_STREAMCALL: Final[str] = "marker_streamcall"
711
MARKER_STREAMING_DRIVERCALL: Final[str] = "marker_streamingdrivercall"
12+
MARKER_HELP: Final[str] = "marker_help"
813

914

10-
def export(func):
11-
"""
12-
Decorator for exporting method as driver call
13-
"""
14-
if isasyncgenfunction(func) or isgeneratorfunction(func):
15-
setattr(func, MARKER_STREAMING_DRIVERCALL, MARKER_MAGIC)
16-
elif iscoroutinefunction(func) or isfunction(func):
17-
setattr(func, MARKER_DRIVERCALL, MARKER_MAGIC)
18-
else:
19-
raise ValueError(f"unsupported exported function {func}")
20-
return func
15+
def export(func: Callable | None = None, *, help: str | None = None) -> Callable:
16+
"""Decorator for exporting method as driver call"""
17+
def decorator(f: Callable) -> Callable:
18+
if isasyncgenfunction(f) or isgeneratorfunction(f):
19+
setattr(f, MARKER_STREAMING_DRIVERCALL, MARKER_MAGIC)
20+
elif iscoroutinefunction(f) or isfunction(f):
21+
setattr(f, MARKER_DRIVERCALL, MARKER_MAGIC)
22+
else:
23+
raise ValueError(f"unsupported exported function {f}")
24+
if help is not None:
25+
setattr(f, MARKER_HELP, help)
26+
return f
27+
return decorator(func) if func else decorator
2128

2229

23-
def exportstream(func):
24-
"""
25-
Decorator for exporting method as stream
26-
"""
27-
setattr(func, MARKER_STREAMCALL, MARKER_MAGIC)
28-
return func
30+
def exportstream(func: Callable | None = None, *, help: str | None = None) -> Callable:
31+
"""Decorator for exporting method as stream"""
32+
def decorator(f: Callable) -> Callable:
33+
setattr(f, MARKER_STREAMCALL, MARKER_MAGIC)
34+
if help is not None:
35+
setattr(f, MARKER_HELP, help)
36+
return f
37+
return decorator(func) if func else decorator

0 commit comments

Comments
 (0)