diff --git a/python/packages/jumpstarter-cli-driver/README.md b/python/packages/jumpstarter-cli-driver/README.md index 7fdd7b4bc..73746e781 100644 --- a/python/packages/jumpstarter-cli-driver/README.md +++ b/python/packages/jumpstarter-cli-driver/README.md @@ -1 +1,44 @@ # Jumpstarter Driver CLI + +## Driver configuration schemas + +`jmp driver schema` describes the configuration accepted by drivers installed +in the local Python environment. Editors can use it for exporter YAML +completion and validation without maintaining a hard-coded driver catalog. +It is distinct from inspecting driver clients in a running lease. + +```console +jmp driver schema +jmp driver schema -o json +jmp driver schema TcpNetwork -o yaml +jmp driver schema jumpstarter_driver_network.driver.TcpNetwork -o json +``` + +Names filter by entry-point name or full dotted driver type. An unknown name +fails before any driver is loaded, including when other names match. Output +supports tables (default), JSON, YAML, and `-o name`. + +JSON and YAML return a `drivers` list. Each entry contains: + +- `name`, `type`, `package`, and `version`: installed driver identity. +- `client`: the client class path used by client-config `drivers.allow` patterns, + when it can be discovered. +- `description`: the first line of the driver class docstring. +- `properties` and `required`: JSON Schema fragments for the exporter's + driver-specific `config:` block; common Driver fields and non-constructor + fields are excluded. +- `defs`: definitions referenced as `#/$defs/...` inside those fragments. + Consumers combining driver schemas must preserve and namespace these references. +- `error`: an import/schema-generation error, or `null` on success. Schema + generation failures retain best-effort dataclass field names and required + keys, without claiming complete type information. + +A broken driver does not hide the other results. Inspect each entry's `error`; +exit status zero does not mean every driver produced a complete schema. +An empty environment produces `{"drivers": []}` in JSON/YAML output. + +Discovery imports driver packages and invokes their metadata/schema hooks. +It does not instantiate drivers or open a lease itself, but imports/hooks run +arbitrary local Python code: only use it with trusted installed packages. +Python-level discovery output is redirected to stderr to keep structured stdout +parseable. This command is not a sandbox or live hardware validation. diff --git a/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__init__.py b/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__init__.py index e899403eb..c11b422d7 100644 --- a/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__init__.py +++ b/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/__init__.py @@ -3,7 +3,7 @@ from jumpstarter_cli_common.opt import opt_log_level from jumpstarter_cli_common.version import version -from .driver import list_drivers +from .driver import driver_schema, list_drivers @click.group(cls=AliasedGroup) @@ -13,6 +13,7 @@ def driver(): driver.add_command(list_drivers) +driver.add_command(driver_schema) driver.add_command(version) if __name__ == "__main__": diff --git a/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py b/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py index 6b5878077..a89d208b3 100644 --- a/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py +++ b/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/driver.py @@ -1,9 +1,14 @@ +import dataclasses +import inspect +import sys +from contextlib import redirect_stdout from importlib.metadata import entry_points +from typing import Any import click from jumpstarter_cli_common.opt import OutputType, opt_output_all from jumpstarter_cli_common.print import model_print -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter class DriverEntry(BaseModel): @@ -36,6 +41,174 @@ def rich_add_names(self, names): entry.rich_add_names(names) +class DriverSchema(BaseModel): + """A driver's user-settable config keys, as JSON Schema.""" + + name: str + type: str + # Client class this driver is consumed through — what a client config's + # drivers.allow patterns are matched against. + client: str | None = None + package: str | None = None + version: str | None = None + description: str | None = None + # JSON Schema fragment for the exporter config's `config:` block. + properties: dict[str, Any] = {} + required: list[str] = [] + # Definitions the properties reference (enums, nested models). Kept so a + # consumer can resolve the "#/$defs/..." refs inside properties. + defs: dict[str, Any] = {} + # Set instead of the schema when the driver class could not be loaded + # (an optional dependency is missing, most often). + error: str | None = None + + def rich_add_rows(self, table): + keys = ", ".join(sorted(self.properties)) if self.properties else "" + table.add_row(self.name, self.error or keys, ", ".join(self.required)) + + def rich_add_names(self, names): + names.append(self.name) + + +class DriverSchemaList(BaseModel): + drivers: list[DriverSchema] + + @classmethod + def rich_add_columns(cls, table): + table.add_column("NAME", no_wrap=True) + table.add_column("CONFIG KEYS") + table.add_column("REQUIRED") + + def rich_add_rows(self, table): + for entry in self.drivers: + entry.rich_add_rows(table) + + def rich_add_names(self, names): + for entry in self.drivers: + entry.rich_add_names(names) + + +def _base_field_names() -> set[str]: + """Fields every Driver has, which are not part of a driver's own config.""" + from jumpstarter.driver import Driver + + return {field.name for field in dataclasses.fields(Driver)} + + +def _first_docstring_line(cls: type) -> str | None: + doc = inspect.getdoc(cls) + if not doc: + return None + return doc.strip().splitlines()[0] + + +def _client_class_path(cls: type) -> str | None: + """The driver client class path, which drivers.allow patterns are matched against.""" + client = getattr(cls, "client", None) + if not callable(client): + return None + try: + value = client() + except Exception: + return None + return value if isinstance(value, str) else None + + +def _fields_without_schema(cls: type, base_fields: set[str]) -> tuple[dict[str, Any], list[str]]: + """Config keys recovered from the dataclass when JSON Schema generation fails. + + Some drivers hold fields whose types pydantic cannot model. Their names and + annotations are still worth reporting: without types a consumer can still + complete the keys and tell required from optional. + """ + properties: dict[str, Any] = {} + required: list[str] = [] + if not dataclasses.is_dataclass(cls): + return properties, required + for field in dataclasses.fields(cls): + if field.name in base_fields or not field.init: + continue + annotation = field.type if isinstance(field.type, str) else getattr(field.type, "__name__", None) + properties[field.name] = {"title": field.name} | ( + {"description": f"type: {annotation}"} if annotation else {} + ) + if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING: + required.append(field.name) + return properties, required + + +def _schema_for_entry_point(entry_point, base_fields: set[str]) -> DriverSchema: + dist = entry_point.dist + entry = DriverSchema( + name=entry_point.name, + type=entry_point.value.replace(":", "."), + package=dist.name if dist else None, + version=dist.version if dist else None, + ) + try: + cls = entry_point.load() + except Exception as e: + # One uninstallable driver must not sink the whole listing. + entry.error = f"{type(e).__name__}: {e}" + return entry + + entry.description = _first_docstring_line(cls) + entry.client = _client_class_path(cls) + try: + schema = TypeAdapter(cls).json_schema() + except Exception as e: + entry.error = f"{type(e).__name__}: {e}" + if dataclasses.is_dataclass(cls): + entry.properties, entry.required = _fields_without_schema(cls, base_fields) + return entry + + entry.defs = schema.get("$defs", {}) + # Recursive dataclasses can put the root object in $defs as well. + if schema.get("$ref", "").startswith("#/$defs/"): + schema = entry.defs[schema["$ref"].removeprefix("#/$defs/")] + excluded_fields = set(base_fields) + if dataclasses.is_dataclass(cls): + excluded_fields.update(field.name for field in dataclasses.fields(cls) if not field.init) + entry.properties = {k: v for k, v in schema.get("properties", {}).items() if k not in excluded_fields} + entry.required = [r for r in schema.get("required", []) if r not in excluded_fields] + return entry + + +@click.command("schema") +@click.argument("names", nargs=-1) +@opt_output_all +def driver_schema(names: tuple[str, ...], output: OutputType): + """Show the config keys accepted by installed drivers + + Reports each driver's user-settable config keys as JSON Schema, derived + from the driver class itself, for the `config:` block of an exporter + config. NAMES filters by driver name or by full type path. + """ + wanted = set(names) + installed = sorted(entry_points(group="jumpstarter.drivers"), key=lambda ep: (ep.name, ep.value)) + known = {name for ep in installed for name in (ep.name, ep.value.replace(":", "."))} + missing = wanted - known + if missing: + raise click.ClickException(f"No installed driver matches: {', '.join(sorted(missing))}") + + # Imports (and driver schema hooks) can print. Keep Python-level output on + # stderr so stdout remains a single machine-readable document. Loading a + # driver executes local package code; this is not a sandbox. + with redirect_stdout(sys.stderr): + base_fields = _base_field_names() + drivers = [ + _schema_for_entry_point(entry_point, base_fields) + for entry_point in installed + if not wanted or entry_point.name in wanted or entry_point.value.replace(":", ".") in wanted + ] + + if not drivers and output is None: + click.echo("No drivers found.") + return + + model_print(DriverSchemaList(drivers=drivers), output) + + @click.command("list") @opt_output_all def list_drivers(output: OutputType): diff --git a/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/schema_test.py b/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/schema_test.py new file mode 100644 index 000000000..9312c2633 --- /dev/null +++ b/python/packages/jumpstarter-cli-driver/jumpstarter_cli_driver/schema_test.py @@ -0,0 +1,192 @@ +"""Driver-schema discovery is independent of optional installed drivers.""" + +import json +from dataclasses import field +from enum import Enum +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest +import yaml +from click.testing import CliRunner +from pydantic import BaseModel +from pydantic.dataclasses import dataclass + +from . import driver +from jumpstarter.driver import Driver + + +class Mode(str, Enum): + TCP = "tcp" + UDP = "udp" + + +class Endpoint(BaseModel): + mode: Mode = Mode.TCP + + +@dataclass(kw_only=True) +class ConfigDriver(Driver): + """A driver with typed configuration, without hardware access.""" + + host: str + port: int + endpoint: Endpoint = field(default_factory=Endpoint) + runtime_state: int = field(default=0, init=False) + + @classmethod + def client(cls): + return "example.client.ConfigClient" + + def __post_init__(self): + raise AssertionError("Schema discovery must not instantiate a driver") + + +@dataclass(kw_only=True) +class RecursiveDriver(ConfigDriver): + peer: "RecursiveDriver | None" = None + + +def entry_point(name="Config", cls=ConfigDriver): + """An entry point whose load can be observed or made to fail.""" + return SimpleNamespace( + name=name, + value=f"{cls.__module__}:{cls.__qualname__}", + dist=SimpleNamespace(name="example-driver", version="1.2.3"), + load=Mock(return_value=cls), + ) + + +@pytest.fixture +def installed(): + entry = entry_point() + with patch("jumpstarter_cli_driver.driver.entry_points", return_value=[entry]) as discover: + yield entry, discover + + +def schema_json(*args): + """Parse stdout directly: discovery chatter must never prefix the JSON.""" + result = CliRunner().invoke(driver, ["schema", *args, "-o", "json"]) + assert result.exit_code == 0, result.output + return json.loads(result.stdout), result + + +def test_typed_schema_and_metadata(installed): + entry, discover = installed + data, _ = schema_json() + schema = data["drivers"][0] + assert schema["name"] == "Config" + assert schema["type"] == entry.value.replace(":", ".") + assert schema["client"] == "example.client.ConfigClient" + assert schema["package"] == "example-driver" + assert schema["version"] == "1.2.3" + assert schema["description"] == ConfigDriver.__doc__ + assert schema["properties"]["host"]["type"] == "string" + assert schema["properties"]["port"]["type"] == "integer" + assert set(schema["required"]) == {"host", "port"} + assert schema["error"] is None + assert set(schema["properties"]) == {"host", "port", "endpoint"} + discover.assert_called_once_with(group="jumpstarter.drivers") + entry.load.assert_called_once_with() + # Nested models and enums keep resolvable references. + assert schema["properties"]["endpoint"]["$ref"] == "#/$defs/Endpoint" + assert schema["defs"]["Endpoint"]["properties"]["mode"]["$ref"] == "#/$defs/Mode" + assert schema["defs"]["Mode"]["enum"] == ["tcp", "udp"] + + +def test_recursive_root_schema(installed): + entry, _ = installed + entry.load.return_value = RecursiveDriver + schema = schema_json()[0]["drivers"][0] + assert schema["properties"]["host"]["type"] == "string" + assert set(schema["required"]) == {"host", "port"} + assert schema["properties"]["peer"]["anyOf"][0]["$ref"] == "#/$defs/RecursiveDriver" + assert "RecursiveDriver" in schema["defs"] + + +def test_fallback_recovers_only_constructor_keys(installed): + with patch("jumpstarter_cli_driver.driver.TypeAdapter", side_effect=ValueError("unsupported field")): + schema = schema_json()[0]["drivers"][0] + assert schema["error"] == "ValueError: unsupported field" + assert set(schema["properties"]) == {"host", "port", "endpoint"} + assert set(schema["required"]) == {"host", "port"} + assert schema["defs"] == {} + + +def test_broken_import_does_not_hide_other_drivers(installed): + good, discover = installed + broken = entry_point("Broken") + broken.load.side_effect = ImportError("missing optional dependency") + broken.dist = None + discover.return_value = [good, broken] + schemas = schema_json()[0]["drivers"] + assert [s["name"] for s in schemas] == ["Broken", "Config"] + assert schemas[0]["error"] == "ImportError: missing optional dependency" + assert schemas[0]["properties"] == {} + assert schemas[0]["package"] is None + assert schemas[0]["version"] is None + assert schemas[1]["error"] is None + + +def test_discovery_output_goes_to_stderr(installed): + entry, _ = installed + + def noisy_import(): + print("driver import chatter {not JSON}") + return ConfigDriver + + entry.load.side_effect = noisy_import + _, result = schema_json() + assert "driver import chatter" in result.stderr + assert "driver import chatter" not in result.stdout + + +@pytest.mark.parametrize("selector", ["name", "type"]) +def test_filter_loads_only_selected_drivers(installed, selector): + entry, discover = installed + other = entry_point("Other") + other.value = "other.driver:Other" + discover.return_value = [other, entry] + name = entry.name if selector == "name" else entry.value.replace(":", ".") + assert [s["name"] for s in schema_json(name)[0]["drivers"]] == ["Config"] + other.load.assert_not_called() + + +@pytest.mark.parametrize("names", [["Missing"], ["Config", "Missing"]]) +def test_unknown_filters_fail_before_loading(installed, names): + entry, _ = installed + result = CliRunner().invoke(driver, ["schema", *names, "-o", "json"]) + assert result.exit_code != 0 + assert "No installed driver matches: Missing" in result.stderr + assert result.stdout == "" + entry.load.assert_not_called() + + +@pytest.mark.parametrize("output", ["json", "yaml", "name", None]) +def test_empty_discovery(installed, output): + _, discover = installed + discover.return_value = [] + result = CliRunner().invoke(driver, ["schema", *(["-o", output] if output else [])]) + assert result.exit_code == 0 + if output == "json": + assert json.loads(result.stdout) == {"drivers": []} + elif output == "yaml": + assert yaml.safe_load(result.stdout) == {"drivers": []} + elif output == "name": + assert result.stdout == "" + else: + assert "No drivers found." in result.stdout + + +@pytest.mark.parametrize("output", ["yaml", "name", None]) +def test_output_formats(installed, output): + result = CliRunner().invoke(driver, ["schema", *(["-o", output] if output else [])]) + assert result.exit_code == 0 + if output == "yaml": + assert yaml.safe_load(result.stdout)["drivers"][0]["properties"]["host"]["type"] == "string" + elif output == "name": + assert result.stdout.strip() == "Config" + else: + assert "CONFIG KEYS" in result.stdout + assert "Config" in result.stdout + assert "host" in result.stdout