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;
|
||||
- a fail-closed manual desktop-acceptance report and validator;
|
||||
- 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
|
||||
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
|
||||
`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
|
||||
[upstream contract and license audit](../research/upstream-contract-and-license-audit.md)
|
||||
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
|
||||
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;
|
||||
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;
|
||||
5. re-enable it and verify re-registration;
|
||||
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.
|
||||
|
||||
The two result files had SHA-256
|
||||
`610f8f191526afe4685f8659c1ef2b34fac3c68a8f8088d77ca5e1fdd6080bfc`.
|
||||
The checked result is `tests/fixtures/rayforge-addon-spike.json`.
|
||||
The current checked result is `tests/fixtures/rayforge-addon-spike.json` with
|
||||
SHA-256
|
||||
`c7a7ba7d561af58829c9c0fc271234e7600d50cca5f8125fc574fa3f0a1aa25f`.
|
||||
|
||||
## Registration seam finding
|
||||
|
||||
|
|
@ -37,10 +37,13 @@ a driver class. The source-level driver registry instead exposes
|
|||
`rayforge.machine.driver.register_driver(driver)`.
|
||||
|
||||
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,
|
||||
enable and reload, but it relies on mutable module-level registry internals for
|
||||
cleanup. Before a production addon, Rayforge should ideally expose a dedicated
|
||||
addon-aware driver registry hook with supported unregister behavior.
|
||||
entry point and removes its class in `on_unload`. All access to the mutable
|
||||
module-level registry is now isolated in `rayforge_compat.py`. The adapter
|
||||
accepts only addon API 23, checks the expected registry shape, refuses class
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
MSIX Rayforge build discovers an externally installed addon.
|
||||
- The current registration seam is functional but not a stable, addon-aware
|
||||
API. Upstream feasibility should be assessed before relying on it.
|
||||
- The compatibility adapter contains the unstable seam, but does not make 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,
|
||||
transport, controller status or cancellation is implemented here.
|
||||
- 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
|
||||
registry contract with symmetric registration and cleanup. Until upstream
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -333,8 +333,9 @@ ignored build area, records their hashes and refuses to overwrite an existing
|
|||
test run.
|
||||
The pinned upstream contracts and license notices are now machine-verifiable.
|
||||
Rayforge's documented addon lifecycle is viable, but production driver
|
||||
registration still needs a supported symmetric registry API; project license
|
||||
selection remains an explicit owner decision in ADR 0003.
|
||||
registration still needs a supported symmetric registry API. The current
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ evidence for:
|
|||
run directory;
|
||||
- pinned Rayforge and MeerK40t license/source contracts with drift tests, plus
|
||||
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:
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ def run(source: Path, addon_path: Path) -> dict[str, object]:
|
|||
),
|
||||
"lifecycle": ["load", "disable", "enable", "reload", "disable"],
|
||||
"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 rayforge.core.hooks import hookimpl
|
||||
from rayforge.machine import driver as driver_registry
|
||||
from rayforge.machine.driver import NoDeviceDriver
|
||||
from rayforge.pipeline.encoder.base import (
|
||||
EncodedOutput,
|
||||
|
|
@ -24,6 +23,7 @@ from .offline_session import (
|
|||
OfflineJCZSession,
|
||||
OfflineSessionState,
|
||||
)
|
||||
from .rayforge_compat import register_driver, unregister_driver
|
||||
|
||||
|
||||
class OfflineJCZEncoder(OpsEncoder):
|
||||
|
|
@ -341,19 +341,11 @@ class PirateRayJCZNoDeviceDriver(NoDeviceDriver):
|
|||
|
||||
|
||||
def _register() -> None:
|
||||
name = PirateRayJCZNoDeviceDriver.__name__
|
||||
if name not in driver_registry.driver_by_classname:
|
||||
driver_registry.register_driver(PirateRayJCZNoDeviceDriver)
|
||||
register_driver(PirateRayJCZNoDeviceDriver)
|
||||
|
||||
|
||||
def _unregister() -> None:
|
||||
name = PirateRayJCZNoDeviceDriver.__name__
|
||||
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
|
||||
]
|
||||
unregister_driver(PirateRayJCZNoDeviceDriver)
|
||||
|
||||
|
||||
# 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/offline_session.py",
|
||||
"pirate_ray_jcz/profile_import.py",
|
||||
"pirate_ray_jcz/rayforge_compat.py",
|
||||
"pirate_ray_jcz/recipe_metadata.py",
|
||||
"pirate_ray_jcz/worker.py",
|
||||
"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"],
|
||||
)
|
||||
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):
|
||||
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