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

Commit 3ab99f5

Browse files
committed
gracefully handle missing drivers with stub client
1 parent 7963e82 commit 3ab99f5

File tree

4 files changed

+101
-32
lines changed

4 files changed

+101
-32
lines changed

packages/jumpstarter/jumpstarter/client/base.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from contextlib import ExitStack, contextmanager
88
from dataclasses import field
99

10+
import click
1011
from anyio.from_thread import BlockingPortal
1112
from pydantic import ConfigDict
1213
from pydantic.dataclasses import dataclass
1314

1415
from .core import AsyncDriverClient
16+
from jumpstarter.common.importlib import _format_missing_driver_message
1517
from jumpstarter.streams.blocking import BlockingStream
1618

1719

@@ -103,3 +105,52 @@ def close(self):
103105

104106
def __del__(self):
105107
self.close()
108+
109+
110+
@dataclass(kw_only=True, config=ConfigDict(arbitrary_types_allowed=True))
111+
class MissingDriverClient(DriverClient):
112+
"""Stub client for drivers that are not installed.
113+
114+
This client is created when a driver client class cannot be imported.
115+
It provides a placeholder that raises a clear error when the driver
116+
is actually used.
117+
"""
118+
119+
def _get_missing_class_path(self) -> str:
120+
"""Get the missing class path from labels."""
121+
return self.labels["jumpstarter.dev/client"]
122+
123+
def _raise_missing_error(self):
124+
"""Raise ImportError with installation instructions."""
125+
class_path = self._get_missing_class_path()
126+
message = _format_missing_driver_message(class_path)
127+
raise ImportError(message)
128+
129+
def call(self, method, *args):
130+
"""Invoke driver call - raises ImportError since driver is not installed."""
131+
self._raise_missing_error()
132+
133+
def streamingcall(self, method, *args):
134+
"""Invoke streaming driver call - raises ImportError since driver is not installed."""
135+
self._raise_missing_error()
136+
# Unreachable yield to make this a generator function for type checking
137+
while False: # noqa: SIM114
138+
yield
139+
140+
@contextmanager
141+
def stream(self, method="connect"):
142+
"""Open a stream - raises ImportError since driver is not installed."""
143+
self._raise_missing_error()
144+
yield
145+
146+
def cli(self):
147+
"""Return a Click command that shows an error when executed."""
148+
@click.command()
149+
@click.pass_context
150+
def unavailable(ctx):
151+
class_path = self._get_missing_class_path()
152+
message = _format_missing_driver_message(class_path)
153+
click.echo(f"Error: {message}", err=True)
154+
ctx.exit(1)
155+
156+
return unavailable

packages/jumpstarter/jumpstarter/client/client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from collections import OrderedDict, defaultdict
23
from contextlib import ExitStack, asynccontextmanager
34
from graphlib import TopologicalSorter
@@ -9,6 +10,8 @@
910

1011
from .grpc import MultipathExporterStub
1112
from jumpstarter.client import DriverClient
13+
from jumpstarter.client.base import MissingDriverClient
14+
from jumpstarter.common.exceptions import MissingDriverError
1215
from jumpstarter.common.importlib import import_class
1316

1417

@@ -50,7 +53,13 @@ async def client_from_channel(
5053
for index in TopologicalSorter(topo).static_order():
5154
report = reports[index]
5255

53-
client_class = import_class(report.labels["jumpstarter.dev/client"], allow, unsafe)
56+
try:
57+
client_class = import_class(report.labels["jumpstarter.dev/client"], allow, unsafe)
58+
except MissingDriverError as e:
59+
# Create stub client instead of failing
60+
logger = logging.getLogger(__name__)
61+
logger.warning("Driver client '%s' is not available.", e.class_path)
62+
client_class = MissingDriverClient
5463

5564
client = client_class(
5665
uuid=UUID(report.uuid),

packages/jumpstarter/jumpstarter/common/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,15 @@ class EnvironmentVariableNotSetError(JumpstarterException):
7979
"""Raised when a environment variable is not set."""
8080

8181
pass
82+
83+
84+
class MissingDriverError(JumpstarterException):
85+
"""Raised when a driver module is not found but should be handled gracefully.
86+
87+
This exception is raised when a driver client class cannot be imported,
88+
but the connection should continue with a stub client instead of failing.
89+
"""
90+
91+
def __init__(self, message: str, class_path: str):
92+
super().__init__(message)
93+
self.class_path = class_path

packages/jumpstarter/jumpstarter/common/importlib.py

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,33 @@
55
from fnmatch import fnmatchcase
66
from importlib import import_module
77

8+
from jumpstarter.common.exceptions import MissingDriverError
9+
810
logger = logging.getLogger(__name__)
911

1012

13+
def _format_missing_driver_message(class_path: str) -> str:
14+
"""Format error message depending on whether the class path is a Jumpstarter driver."""
15+
# Extract package name from class path (first component)
16+
package_name = class_path.split(".")[0]
17+
18+
if class_path.startswith("jumpstarter_driver_"):
19+
return (
20+
f"Driver '{class_path}' is not installed.\n\n"
21+
"This usually indicates a version mismatch between your client and the exporter.\n"
22+
"Please try to update your client to the latest version and ensure the exporter "
23+
"has the correct version installed.\n"
24+
)
25+
else:
26+
return (
27+
f"Driver '{class_path}' is not installed.\n\n"
28+
"Please install the missing module:\n"
29+
f" pip install {package_name}\n\n"
30+
"or if using uv:\n"
31+
f" uv pip install {package_name}"
32+
)
33+
34+
1135
def cached_import(module_path, class_name):
1236
# Check whether module is loaded and fully initialized.
1337
if not (
@@ -40,36 +64,9 @@ def import_class(class_path: str, allow: list[str], unsafe: bool):
4064
try:
4165
return cached_import(module_path, class_name)
4266
except ModuleNotFoundError as e:
43-
module_name = str(e).split("'")[1] if "'" in str(e) else str(e).split()[-1]
44-
45-
is_jumpstarter_driver = unsafe or any(fnmatchcase(class_path, pattern) for pattern in allow)
46-
47-
if is_jumpstarter_driver:
48-
logger.error(
49-
"Missing Jumpstarter driver module '%s' for class '%s'. "
50-
"This usually indicates a version mismatch between your client and the exporter.",
51-
module_name,
52-
class_path,
53-
)
54-
raise ConnectionError(
55-
f"Missing Jumpstarter driver module '{module_name}'.\n\n"
56-
"This usually indicates a version mismatch between your client and the exporter.\n"
57-
"Please try to update your client to the latest version and ensure the exporter "
58-
"has the correct version installed.\n"
59-
) from e
60-
else:
61-
logger.error(
62-
"Missing Python module '%s' while importing '%s'. "
63-
"This module needs to be installed in your environment.",
64-
module_name,
65-
class_path,
66-
)
67-
raise ConnectionError(
68-
f"Missing Python module '{module_name}'.\n\n"
69-
"Please install the missing module:\n"
70-
f" pip install {module_name}\n\n"
71-
"or if using uv:\n"
72-
f" uv pip install {module_name}"
73-
) from e
67+
raise MissingDriverError(
68+
message=_format_missing_driver_message(class_path),
69+
class_path=class_path,
70+
) from e
7471
except AttributeError as e:
7572
raise ImportError(f"{module_path} doesn't have specified class {class_name}") from e

0 commit comments

Comments
 (0)