refactor: isolate Rayforge driver registry seam
This commit is contained in:
parent
415b00e157
commit
fe2625e423
12 changed files with 292 additions and 28 deletions
|
|
@ -21,6 +21,7 @@ The repository now provides:
|
||||||
synthetic inputs;
|
synthetic inputs;
|
||||||
- a fail-closed manual desktop-acceptance report and validator;
|
- a fail-closed manual desktop-acceptance report and validator;
|
||||||
- pinned upstream contract/license manifests and a drift checker;
|
- pinned upstream contract/license manifests and a drift checker;
|
||||||
|
- a fail-closed API-23 adapter isolating Rayforge's internal driver registry;
|
||||||
- a proposed MIT project-license decision in ADR 0003; Kaz has not accepted
|
- a proposed MIT project-license decision in ADR 0003; Kaz has not accepted
|
||||||
it, so the repository remains unlicensed.
|
it, so the repository remains unlicensed.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ Kaz must explicitly choose the project license. Until then this ADR remains
|
||||||
Proposed, the README's no-license statement remains authoritative, and no root
|
Proposed, the README's no-license statement remains authoritative, and no root
|
||||||
`LICENSE` file is created.
|
`LICENSE` file is created.
|
||||||
|
|
||||||
|
The compatibility-adapter part of item 5 is implemented for the offline addon
|
||||||
|
and fails closed outside Rayforge addon API 23. This implementation evidence
|
||||||
|
does not accept this ADR or select the project license.
|
||||||
|
|
||||||
See the
|
See the
|
||||||
[upstream contract and license audit](../research/upstream-contract-and-license-audit.md)
|
[upstream contract and license audit](../research/upstream-contract-and-license-audit.md)
|
||||||
for the evidence and API-stability assessment.
|
for the evidence and API-stability assessment.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ An external addon loaded through Rayforge's real `AddonManager` and registered
|
||||||
discovery specification or transport, and returns a recognizable binary test
|
discovery specification or transport, and returns a recognizable binary test
|
||||||
payload from an `OpsEncoder`.
|
payload from an `OpsEncoder`.
|
||||||
|
|
||||||
The following lifecycle completed twice with identical JSON results:
|
The following lifecycle completes against the pinned Rayforge checkout:
|
||||||
|
|
||||||
1. load the external addon;
|
1. load the external addon;
|
||||||
2. resolve the driver by class name;
|
2. resolve the driver by class name;
|
||||||
|
|
@ -21,12 +21,12 @@ The following lifecycle completed twice with identical JSON results:
|
||||||
4. disable the addon and verify driver removal;
|
4. disable the addon and verify driver removal;
|
||||||
5. re-enable it and verify re-registration;
|
5. re-enable it and verify re-registration;
|
||||||
6. reload it and verify there is exactly one registry entry;
|
6. reload it and verify there is exactly one registry entry;
|
||||||
7. package its three source/manifest files into a ZIP; and
|
7. package its source and manifest files into a ZIP; and
|
||||||
8. disable it cleanly.
|
8. disable it cleanly.
|
||||||
|
|
||||||
The two result files had SHA-256
|
The current checked result is `tests/fixtures/rayforge-addon-spike.json` with
|
||||||
`610f8f191526afe4685f8659c1ef2b34fac3c68a8f8088d77ca5e1fdd6080bfc`.
|
SHA-256
|
||||||
The checked result is `tests/fixtures/rayforge-addon-spike.json`.
|
`c7a7ba7d561af58829c9c0fc271234e7600d50cca5f8125fc574fa3f0a1aa25f`.
|
||||||
|
|
||||||
## Registration seam finding
|
## Registration seam finding
|
||||||
|
|
||||||
|
|
@ -37,10 +37,13 @@ a driver class. The source-level driver registry instead exposes
|
||||||
`rayforge.machine.driver.register_driver(driver)`.
|
`rayforge.machine.driver.register_driver(driver)`.
|
||||||
|
|
||||||
The working spike therefore registers during import of the addon's worker
|
The working spike therefore registers during import of the addon's worker
|
||||||
entry point and removes its class in `on_unload`. This survives load, disable,
|
entry point and removes its class in `on_unload`. All access to the mutable
|
||||||
enable and reload, but it relies on mutable module-level registry internals for
|
module-level registry is now isolated in `rayforge_compat.py`. The adapter
|
||||||
cleanup. Before a production addon, Rayforge should ideally expose a dedicated
|
accepts only addon API 23, checks the expected registry shape, refuses class
|
||||||
addon-aware driver registry hook with supported unregister behavior.
|
name collisions, is idempotent, and verifies symmetric cleanup. Contract drift
|
||||||
|
therefore fails closed instead of silently replacing another driver. Before a
|
||||||
|
production addon, Rayforge should still expose a dedicated addon-aware driver
|
||||||
|
registry hook with supported unregister behavior.
|
||||||
|
|
||||||
## Reproduce
|
## Reproduce
|
||||||
|
|
||||||
|
|
@ -67,8 +70,9 @@ The script refuses any Rayforge revision other than the pinned commit.
|
||||||
|
|
||||||
- The ZIP check is addon packaging, not a proof that a complete PyInstaller or
|
- The ZIP check is addon packaging, not a proof that a complete PyInstaller or
|
||||||
MSIX Rayforge build discovers an externally installed addon.
|
MSIX Rayforge build discovers an externally installed addon.
|
||||||
- The current registration seam is functional but not a stable, addon-aware
|
- The compatibility adapter contains the unstable seam, but does not make it
|
||||||
API. Upstream feasibility should be assessed before relying on it.
|
a supported addon API. Upstream feasibility should be assessed before
|
||||||
|
relying on it in production.
|
||||||
- No JCZ command encoding, machine profile, UI, project reload, discovery,
|
- No JCZ command encoding, machine profile, UI, project reload, discovery,
|
||||||
transport, controller status or cancellation is implemented here.
|
transport, controller status or cancellation is implemented here.
|
||||||
- Nothing in this spike authorizes USB access, galvo movement or emission.
|
- Nothing in this spike authorizes USB access, galvo movement or emission.
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,10 @@ its manifest and sidecar; installer-only publication is not accepted.
|
||||||
The immediate upstream contribution should be a narrow, addon-aware driver
|
The immediate upstream contribution should be a narrow, addon-aware driver
|
||||||
registry contract with symmetric registration and cleanup. Until upstream
|
registry contract with symmetric registration and cleanup. Until upstream
|
||||||
accepts such a contract, all internal registry access must remain isolated in
|
accepts such a contract, all internal registry access must remain isolated in
|
||||||
one compatibility adapter and guarded by the hashes/symbol tests.
|
one compatibility adapter and guarded by the hashes/symbol tests. The current
|
||||||
|
addon now does this: its API-23 adapter rejects version drift, malformed
|
||||||
|
registries, duplicate entries and foreign class-name collisions, and its
|
||||||
|
load/disable/enable/reload lifecycle passes against the pinned checkout.
|
||||||
|
|
||||||
## Code-origin boundary
|
## Code-origin boundary
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,8 +333,9 @@ ignored build area, records their hashes and refuses to overwrite an existing
|
||||||
test run.
|
test run.
|
||||||
The pinned upstream contracts and license notices are now machine-verifiable.
|
The pinned upstream contracts and license notices are now machine-verifiable.
|
||||||
Rayforge's documented addon lifecycle is viable, but production driver
|
Rayforge's documented addon lifecycle is viable, but production driver
|
||||||
registration still needs a supported symmetric registry API; project license
|
registration still needs a supported symmetric registry API. The current
|
||||||
selection remains an explicit owner decision in ADR 0003.
|
internal access is contained by a fail-closed API-23 compatibility adapter;
|
||||||
|
project license selection remains an explicit owner decision in ADR 0003.
|
||||||
|
|
||||||
## Decision rules
|
## Decision rules
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,9 @@ evidence for:
|
||||||
run directory;
|
run directory;
|
||||||
- pinned Rayforge and MeerK40t license/source contracts with drift tests, plus
|
- pinned Rayforge and MeerK40t license/source contracts with drift tests, plus
|
||||||
checkpoint schema 2 requiring the Rayforge MIT notice in the portable ZIP
|
checkpoint schema 2 requiring the Rayforge MIT notice in the portable ZIP
|
||||||
and beside the installer manifest.
|
and beside the installer manifest;
|
||||||
|
- a single fail-closed Rayforge API-23 compatibility adapter around the
|
||||||
|
internal driver registry, with collision, drift and symmetric-cleanup tests.
|
||||||
|
|
||||||
The remaining M1 gates are deliberately explicit:
|
The remaining M1 gates are deliberately explicit:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,9 @@ def run(source: Path, addon_path: Path) -> dict[str, object]:
|
||||||
),
|
),
|
||||||
"lifecycle": ["load", "disable", "enable", "reload", "disable"],
|
"lifecycle": ["load", "disable", "enable", "reload", "disable"],
|
||||||
"packaged_files": packaged_files,
|
"packaged_files": packaged_files,
|
||||||
"registration_seam": "worker import side effect + on_unload cleanup",
|
"registration_seam": (
|
||||||
|
"fail-closed API 23 compatibility adapter + on_unload cleanup"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""Fail-closed compatibility boundary for Rayforge's driver registry.
|
||||||
|
|
||||||
|
Rayforge API 23 does not expose addon-aware driver registration and cleanup.
|
||||||
|
Keep the unavoidable internal registry access here so it can be removed when
|
||||||
|
the host provides a supported symmetric API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_PLUGIN_API_VERSION = 23
|
||||||
|
|
||||||
|
|
||||||
|
class RayforgeCompatibilityError(RuntimeError):
|
||||||
|
"""The pinned Rayforge registry contract is unavailable or has drifted."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_host() -> tuple[Any, int]:
|
||||||
|
from rayforge.core.hooks import PLUGIN_API_VERSION
|
||||||
|
from rayforge.machine import driver as registry
|
||||||
|
|
||||||
|
return registry, PLUGIN_API_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_host(registry: Any, plugin_api_version: int) -> None:
|
||||||
|
if plugin_api_version != SUPPORTED_PLUGIN_API_VERSION:
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
"unsupported Rayforge addon API: "
|
||||||
|
f"expected {SUPPORTED_PLUGIN_API_VERSION}, got {plugin_api_version}"
|
||||||
|
)
|
||||||
|
if not isinstance(getattr(registry, "driver_by_classname", None), dict):
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
"Rayforge driver_by_classname registry is unavailable"
|
||||||
|
)
|
||||||
|
if not isinstance(getattr(registry, "drivers", None), list):
|
||||||
|
raise RayforgeCompatibilityError("Rayforge drivers registry is unavailable")
|
||||||
|
if not callable(getattr(registry, "register_driver", None)):
|
||||||
|
raise RayforgeCompatibilityError("Rayforge register_driver is unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
def _host(
|
||||||
|
registry: Any | None, plugin_api_version: int | None
|
||||||
|
) -> tuple[Any, int]:
|
||||||
|
if registry is None or plugin_api_version is None:
|
||||||
|
resolved_registry, resolved_version = _resolve_host()
|
||||||
|
registry = resolved_registry if registry is None else registry
|
||||||
|
plugin_api_version = (
|
||||||
|
resolved_version
|
||||||
|
if plugin_api_version is None
|
||||||
|
else plugin_api_version
|
||||||
|
)
|
||||||
|
_validate_host(registry, plugin_api_version)
|
||||||
|
return registry, plugin_api_version
|
||||||
|
|
||||||
|
|
||||||
|
def register_driver(
|
||||||
|
driver_cls: type,
|
||||||
|
*,
|
||||||
|
registry: Any | None = None,
|
||||||
|
plugin_api_version: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Register exactly one driver class, or fail before replacing anything."""
|
||||||
|
|
||||||
|
registry, _version = _host(registry, plugin_api_version)
|
||||||
|
name = driver_cls.__name__
|
||||||
|
registered = registry.driver_by_classname.get(name)
|
||||||
|
named_entries = [item for item in registry.drivers if item.__name__ == name]
|
||||||
|
|
||||||
|
if registered is driver_cls:
|
||||||
|
if named_entries == [driver_cls]:
|
||||||
|
return False
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
f"inconsistent Rayforge driver registry for {name}"
|
||||||
|
)
|
||||||
|
if registered is not None or named_entries:
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
f"Rayforge driver name collision for {name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.register_driver(driver_cls)
|
||||||
|
if registry.driver_by_classname.get(name) is not driver_cls:
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
f"Rayforge did not index registered driver {name}"
|
||||||
|
)
|
||||||
|
if sum(item is driver_cls for item in registry.drivers) != 1:
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
f"Rayforge did not register exactly one {name} driver"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_driver(
|
||||||
|
driver_cls: type,
|
||||||
|
*,
|
||||||
|
registry: Any | None = None,
|
||||||
|
plugin_api_version: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove only the exact class previously registered by this addon."""
|
||||||
|
|
||||||
|
registry, _version = _host(registry, plugin_api_version)
|
||||||
|
name = driver_cls.__name__
|
||||||
|
registered = registry.driver_by_classname.get(name)
|
||||||
|
identity_entries = [item for item in registry.drivers if item is driver_cls]
|
||||||
|
named_foreign = [
|
||||||
|
item
|
||||||
|
for item in registry.drivers
|
||||||
|
if item.__name__ == name and item is not driver_cls
|
||||||
|
]
|
||||||
|
|
||||||
|
if registered is None and not identity_entries and not named_foreign:
|
||||||
|
return False
|
||||||
|
if registered is not driver_cls or named_foreign:
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
f"refusing to unregister foreign Rayforge driver {name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
del registry.driver_by_classname[name]
|
||||||
|
registry.drivers[:] = [
|
||||||
|
item for item in registry.drivers if item is not driver_cls
|
||||||
|
]
|
||||||
|
if name in registry.driver_by_classname or any(
|
||||||
|
item is driver_cls for item in registry.drivers
|
||||||
|
):
|
||||||
|
raise RayforgeCompatibilityError(
|
||||||
|
f"Rayforge did not fully unregister driver {name}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
@ -12,7 +12,6 @@ import struct
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from rayforge.core.hooks import hookimpl
|
from rayforge.core.hooks import hookimpl
|
||||||
from rayforge.machine import driver as driver_registry
|
|
||||||
from rayforge.machine.driver import NoDeviceDriver
|
from rayforge.machine.driver import NoDeviceDriver
|
||||||
from rayforge.pipeline.encoder.base import (
|
from rayforge.pipeline.encoder.base import (
|
||||||
EncodedOutput,
|
EncodedOutput,
|
||||||
|
|
@ -24,6 +23,7 @@ from .offline_session import (
|
||||||
OfflineJCZSession,
|
OfflineJCZSession,
|
||||||
OfflineSessionState,
|
OfflineSessionState,
|
||||||
)
|
)
|
||||||
|
from .rayforge_compat import register_driver, unregister_driver
|
||||||
|
|
||||||
|
|
||||||
class OfflineJCZEncoder(OpsEncoder):
|
class OfflineJCZEncoder(OpsEncoder):
|
||||||
|
|
@ -341,19 +341,11 @@ class PirateRayJCZNoDeviceDriver(NoDeviceDriver):
|
||||||
|
|
||||||
|
|
||||||
def _register() -> None:
|
def _register() -> None:
|
||||||
name = PirateRayJCZNoDeviceDriver.__name__
|
register_driver(PirateRayJCZNoDeviceDriver)
|
||||||
if name not in driver_registry.driver_by_classname:
|
|
||||||
driver_registry.register_driver(PirateRayJCZNoDeviceDriver)
|
|
||||||
|
|
||||||
|
|
||||||
def _unregister() -> None:
|
def _unregister() -> None:
|
||||||
name = PirateRayJCZNoDeviceDriver.__name__
|
unregister_driver(PirateRayJCZNoDeviceDriver)
|
||||||
registered = driver_registry.driver_by_classname.get(name)
|
|
||||||
if registered is PirateRayJCZNoDeviceDriver:
|
|
||||||
del driver_registry.driver_by_classname[name]
|
|
||||||
driver_registry.drivers[:] = [
|
|
||||||
item for item in driver_registry.drivers if item is not registered
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Rayforge currently has no driver registry in call_registration_hooks().
|
# Rayforge currently has no driver registry in call_registration_hooks().
|
||||||
|
|
|
||||||
3
tests/fixtures/rayforge-addon-spike.json
vendored
3
tests/fixtures/rayforge-addon-spike.json
vendored
|
|
@ -19,9 +19,10 @@
|
||||||
"pirate_ray_jcz/__init__.py",
|
"pirate_ray_jcz/__init__.py",
|
||||||
"pirate_ray_jcz/offline_session.py",
|
"pirate_ray_jcz/offline_session.py",
|
||||||
"pirate_ray_jcz/profile_import.py",
|
"pirate_ray_jcz/profile_import.py",
|
||||||
|
"pirate_ray_jcz/rayforge_compat.py",
|
||||||
"pirate_ray_jcz/recipe_metadata.py",
|
"pirate_ray_jcz/recipe_metadata.py",
|
||||||
"pirate_ray_jcz/worker.py",
|
"pirate_ray_jcz/worker.py",
|
||||||
"rayforge-addon.yaml"
|
"rayforge-addon.yaml"
|
||||||
],
|
],
|
||||||
"registration_seam": "worker import side effect + on_unload cleanup"
|
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,10 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
||||||
result["lifecycle"],
|
result["lifecycle"],
|
||||||
)
|
)
|
||||||
self.assertEqual(23, result["rayforge_api_version"])
|
self.assertEqual(23, result["rayforge_api_version"])
|
||||||
|
self.assertEqual(
|
||||||
|
"fail-closed API 23 compatibility adapter + on_unload cleanup",
|
||||||
|
result["registration_seam"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_addon_contains_no_hardware_transport_import(self):
|
def test_addon_contains_no_hardware_transport_import(self):
|
||||||
source = "\n".join(
|
source = "\n".join(
|
||||||
|
|
|
||||||
123
tests/test_rayforge_compat.py
Normal file
123
tests/test_rayforge_compat.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
COMPAT_PATH = (
|
||||||
|
ROOT
|
||||||
|
/ "spikes"
|
||||||
|
/ "rayforge-jcz-no-device-addon"
|
||||||
|
/ "pirate_ray_jcz"
|
||||||
|
/ "rayforge_compat.py"
|
||||||
|
)
|
||||||
|
SPEC = importlib.util.spec_from_file_location("rayforge_compat_test", COMPAT_PATH)
|
||||||
|
COMPAT = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(COMPAT)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self.drivers = []
|
||||||
|
self.driver_by_classname = {}
|
||||||
|
|
||||||
|
def register_driver(self, driver_cls):
|
||||||
|
self.drivers.append(driver_cls)
|
||||||
|
self.driver_by_classname[driver_cls.__name__] = driver_cls
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleDriver:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RayforgeCompatibilityTests(unittest.TestCase):
|
||||||
|
def test_registration_and_cleanup_are_symmetric_and_idempotent(self):
|
||||||
|
registry = FakeRegistry()
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
COMPAT.register_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=23
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
COMPAT.register_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=23
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual([ExampleDriver], registry.drivers)
|
||||||
|
self.assertIs(
|
||||||
|
ExampleDriver, registry.driver_by_classname["ExampleDriver"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
COMPAT.unregister_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=23
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
COMPAT.unregister_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=23
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual([], registry.drivers)
|
||||||
|
self.assertEqual({}, registry.driver_by_classname)
|
||||||
|
|
||||||
|
def test_api_version_drift_fails_before_mutation(self):
|
||||||
|
registry = FakeRegistry()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
COMPAT.RayforgeCompatibilityError,
|
||||||
|
"expected 23, got 24",
|
||||||
|
):
|
||||||
|
COMPAT.register_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=24
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], registry.drivers)
|
||||||
|
self.assertEqual({}, registry.driver_by_classname)
|
||||||
|
|
||||||
|
def test_name_collision_is_never_replaced_or_removed(self):
|
||||||
|
foreign_driver = type("ExampleDriver", (), {})
|
||||||
|
registry = FakeRegistry()
|
||||||
|
registry.register_driver(foreign_driver)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
COMPAT.RayforgeCompatibilityError, "name collision"
|
||||||
|
):
|
||||||
|
COMPAT.register_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=23
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
COMPAT.RayforgeCompatibilityError, "foreign"
|
||||||
|
):
|
||||||
|
COMPAT.unregister_driver(
|
||||||
|
ExampleDriver, registry=registry, plugin_api_version=23
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([foreign_driver], registry.drivers)
|
||||||
|
self.assertIs(
|
||||||
|
foreign_driver, registry.driver_by_classname["ExampleDriver"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_incomplete_registry_contract_fails_closed(self):
|
||||||
|
class MissingRegister:
|
||||||
|
drivers = []
|
||||||
|
driver_by_classname = {}
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
COMPAT.RayforgeCompatibilityError, "register_driver"
|
||||||
|
):
|
||||||
|
COMPAT.register_driver(
|
||||||
|
ExampleDriver,
|
||||||
|
registry=MissingRegister(),
|
||||||
|
plugin_api_version=23,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_adapter_has_no_transport_imports(self):
|
||||||
|
source = COMPAT_PATH.read_text(encoding="utf-8").lower()
|
||||||
|
for forbidden in ("import usb", "import serial", "import socket"):
|
||||||
|
self.assertNotIn(forbidden, source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Reference in a new issue