arc-kernel 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arc/__init__.py +65 -0
- arc/_state.py +31 -0
- arc/cli.py +841 -0
- arc/codec.py +72 -0
- arc/doctor.py +107 -0
- arc/health.py +55 -0
- arc/healthcmd.py +85 -0
- arc/kernel.py +290 -0
- arc/plugin_cli.py +120 -0
- arc/registry.py +173 -0
- arc/resolver.py +347 -0
- arc/runtime.py +189 -0
- arc/secrets.py +85 -0
- arc/settings.py +246 -0
- arc_kernel-0.1.0.dist-info/METADATA +111 -0
- arc_kernel-0.1.0.dist-info/RECORD +19 -0
- arc_kernel-0.1.0.dist-info/WHEEL +4 -0
- arc_kernel-0.1.0.dist-info/entry_points.txt +2 -0
- arc_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
arc/codec.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.codec
|
|
3
|
+
-----------------
|
|
4
|
+
The Kernel's sole codec (Architecture §4: msgspec — orjson deliberately
|
|
5
|
+
dropped as redundant). Stateless: no project binding needed, works even
|
|
6
|
+
before arc.boot(). One shared implementation so plugins stop reinventing
|
|
7
|
+
serialization each their own way — gateway called msgspec directly in three
|
|
8
|
+
places, psqldb used stdlib json for its jsonb columns; both now route
|
|
9
|
+
through here instead of duplicating it.
|
|
10
|
+
|
|
11
|
+
import arc
|
|
12
|
+
arc.codec.encode(value) -> bytes
|
|
13
|
+
arc.codec.decode(data) -> Any
|
|
14
|
+
arc.codec.decode(data, type=MyStruct) -> MyStruct, validated
|
|
15
|
+
arc.codec.validate(obj, type=MyStruct) -> MyStruct, validated (obj is
|
|
16
|
+
already-decoded, e.g. a dict)
|
|
17
|
+
arc.codec.schema(MyStruct) -> JSON Schema dict
|
|
18
|
+
|
|
19
|
+
decode()/validate() always raise CodecError, never a raw msgspec exception
|
|
20
|
+
— callers never need `import msgspec` themselves just to catch a failure.
|
|
21
|
+
`Struct` is re-exported so a plugin can declare a schema type without that
|
|
22
|
+
import either.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any, TypeVar
|
|
28
|
+
|
|
29
|
+
import msgspec
|
|
30
|
+
from msgspec import Struct # noqa: F401 - re-exported: arc.codec.Struct
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CodecError(ValueError):
|
|
36
|
+
"""A decode/validate call failed — bad JSON, or data that doesn't match
|
|
37
|
+
the given type. The one exception type every caller needs to know
|
|
38
|
+
about, regardless of which underlying codec library is doing the work."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def encode(value: Any) -> bytes:
|
|
42
|
+
return msgspec.json.encode(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode(data: bytes | str, *, type: type[T] | None = None) -> T:
|
|
46
|
+
"""Decode raw JSON bytes/str. With `type`, decodes AND validates in one
|
|
47
|
+
step (msgspec's core strength — no separate parse-then-validate pass)."""
|
|
48
|
+
try:
|
|
49
|
+
if type is None:
|
|
50
|
+
return msgspec.json.decode(data)
|
|
51
|
+
return msgspec.json.decode(data, type=type)
|
|
52
|
+
except msgspec.ValidationError as exc: # must come first: it subclasses DecodeError
|
|
53
|
+
raise CodecError(f"validation failed: {exc}") from exc
|
|
54
|
+
except msgspec.DecodeError as exc:
|
|
55
|
+
raise CodecError(f"malformed JSON: {exc}") from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate(obj: Any, *, type: type[T]) -> T:
|
|
59
|
+
"""Validate/coerce an already-decoded object (e.g. a plain dict — the
|
|
60
|
+
shape a schema/patch file or a psqldb row arrives as) against `type`.
|
|
61
|
+
msgspec has no separate "validate this in-memory object" entry point;
|
|
62
|
+
this is msgspec.convert(), named to match decode()/encode() here."""
|
|
63
|
+
try:
|
|
64
|
+
return msgspec.convert(obj, type=type)
|
|
65
|
+
except msgspec.ValidationError as exc:
|
|
66
|
+
raise CodecError(f"validation failed: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def schema(type: Any) -> dict:
|
|
70
|
+
"""JSON Schema for `type` — gateway.openapi's only remaining call site
|
|
71
|
+
for this is arc.codec.schema() now, not msgspec.json.schema() directly."""
|
|
72
|
+
return msgspec.json.schema(type)
|
arc/doctor.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.doctor
|
|
3
|
+
-----------------
|
|
4
|
+
`arc doctor` — a true dry run of arc.boot()'s resolution phase.
|
|
5
|
+
|
|
6
|
+
Shows exactly what boot WOULD do — load order, skipped plugins, warnings —
|
|
7
|
+
or exactly why it would fail, without importing a single line of plugin code
|
|
8
|
+
and without starting anything. Powered by the same pure function
|
|
9
|
+
(arc.resolver.resolve) that boot() executes, so doctor can never drift from
|
|
10
|
+
reality.
|
|
11
|
+
|
|
12
|
+
Output: rich tables for humans, `--json` for machines (answers the open
|
|
13
|
+
"text vs JSON" question with: both).
|
|
14
|
+
|
|
15
|
+
Wiring into the CLI is two lines in arc/cli.py:
|
|
16
|
+
|
|
17
|
+
from .doctor import doctor as _doctor_command
|
|
18
|
+
app.command(name="doctor")(_doctor_command)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
|
|
25
|
+
import typer
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
|
|
29
|
+
from . import resolver
|
|
30
|
+
from .registry import load_lock
|
|
31
|
+
from .runtime import BootError, find_project_root
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
err_console = Console(stderr=True, style="bold red")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def doctor(
|
|
38
|
+
as_json: bool = typer.Option(
|
|
39
|
+
False, "--json", help="Emit machine-readable JSON instead of tables."
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Dry-run boot resolution: show what arc.boot() would load, skip, and warn
|
|
44
|
+
about — or why it would fail — without importing any plugin code.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
root = find_project_root()
|
|
48
|
+
except BootError as exc: # e.g. $ARC_PROJECT_ROOT points somewhere invalid
|
|
49
|
+
_fail(str(exc), as_json)
|
|
50
|
+
return # unreachable; _fail raises typer.Exit
|
|
51
|
+
if root is None:
|
|
52
|
+
_fail(
|
|
53
|
+
"Not inside an ARC project — no .arc/arc.toml found in the current "
|
|
54
|
+
"directory or any parent.",
|
|
55
|
+
as_json,
|
|
56
|
+
)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
lock_doc = load_lock(root / ".arc" / "plugins.lock")
|
|
60
|
+
try:
|
|
61
|
+
plan = resolver.resolve(root, lock_doc=lock_doc)
|
|
62
|
+
except resolver.ResolutionError as exc:
|
|
63
|
+
_fail(str(exc), as_json)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
if as_json:
|
|
67
|
+
print(json.dumps(plan.to_dict(), indent=2))
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if not plan.load_order:
|
|
71
|
+
console.print(
|
|
72
|
+
"[dim]No enabled plugins — arc.boot() would start with an empty "
|
|
73
|
+
"capability registry.[/dim]"
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
table = Table(title="arc.boot() load order")
|
|
77
|
+
table.add_column("#", justify="right")
|
|
78
|
+
table.add_column("plugin")
|
|
79
|
+
table.add_column("capability")
|
|
80
|
+
table.add_column("version")
|
|
81
|
+
table.add_column("requires")
|
|
82
|
+
table.add_column("optional")
|
|
83
|
+
for position, spec in enumerate(plan.load_order, start=1):
|
|
84
|
+
table.add_row(
|
|
85
|
+
str(position),
|
|
86
|
+
spec.name,
|
|
87
|
+
spec.capability,
|
|
88
|
+
spec.version,
|
|
89
|
+
", ".join(spec.requires) or "-",
|
|
90
|
+
", ".join(spec.optional_requires) or "-",
|
|
91
|
+
)
|
|
92
|
+
console.print(table)
|
|
93
|
+
|
|
94
|
+
for skipped in plan.skipped:
|
|
95
|
+
console.print(f"[yellow]skipped:[/yellow] {skipped.name} — {skipped.reason}")
|
|
96
|
+
for warning in plan.warnings:
|
|
97
|
+
console.print(f"[yellow]warning:[/yellow] {warning}")
|
|
98
|
+
|
|
99
|
+
console.print("[bold green]Boot resolution OK.[/bold green]")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fail(message: str, as_json: bool) -> None:
|
|
103
|
+
if as_json:
|
|
104
|
+
print(json.dumps({"ok": False, "error": message}, indent=2))
|
|
105
|
+
else:
|
|
106
|
+
err_console.print(f"Boot would FAIL: {message}")
|
|
107
|
+
raise typer.Exit(code=1)
|
arc/health.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.health
|
|
3
|
+
-----------------
|
|
4
|
+
Aggregated health across every currently-registered capability — the
|
|
5
|
+
Kernel-level home for what used to be a gateway-only "working preview"
|
|
6
|
+
(its own docstring called it that). Any process that has called
|
|
7
|
+
arc.boot() can ask this now, not just one serving HTTP — a background
|
|
8
|
+
worker or a bare CLI command gets the same answer without booting Gateway
|
|
9
|
+
just to get it.
|
|
10
|
+
|
|
11
|
+
Mechanism (duck-typed, domain-blind per §3.1): a capability MAY expose an
|
|
12
|
+
`async def health() -> dict`. check() calls whichever ones do and collects
|
|
13
|
+
the results; anything without one is silently skipped — the Kernel never
|
|
14
|
+
needs to know what "psqldb" or "redix" even are, only that the attribute
|
|
15
|
+
might exist.
|
|
16
|
+
|
|
17
|
+
import arc
|
|
18
|
+
arc.boot()
|
|
19
|
+
results = await arc.health.check()
|
|
20
|
+
# {"psqldb": {"ok": True, "version": "..."}, "redix": {"ok": True}}
|
|
21
|
+
arc.health.all_ok(results) -> bool
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from . import _state
|
|
27
|
+
from .kernel import KernelError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HealthError(KernelError):
|
|
31
|
+
"""arc.health.check() was called before arc.boot() — no active kernel,
|
|
32
|
+
so no capabilities exist yet to check."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def check() -> dict[str, dict]:
|
|
36
|
+
kernel = _state.get_kernel()
|
|
37
|
+
if kernel is None:
|
|
38
|
+
raise HealthError(
|
|
39
|
+
"arc.health.check() requires arc.boot() first — there is no "
|
|
40
|
+
"active kernel, so no capabilities to check."
|
|
41
|
+
)
|
|
42
|
+
results: dict[str, dict] = {}
|
|
43
|
+
for name, cap in kernel.capabilities().items():
|
|
44
|
+
health_fn = getattr(cap.instance, "health", None)
|
|
45
|
+
if not callable(health_fn):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
results[name] = await health_fn()
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
results[name] = {"ok": False, "error": f"{exc.__class__.__name__}: {exc}"}
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def all_ok(results: dict[str, dict]) -> bool:
|
|
55
|
+
return all(r.get("ok", True) for r in results.values())
|
arc/healthcmd.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.healthcmd
|
|
3
|
+
-----------------
|
|
4
|
+
`arc health` — a CLI view onto arc.health.check(), the same way `arc doctor`
|
|
5
|
+
is a CLI view onto arc.resolver.resolve(). Named healthcmd.py (not health.py)
|
|
6
|
+
so it never collides with the real arc.health module this wraps.
|
|
7
|
+
|
|
8
|
+
Opens every capability that has an open() first (short-lived — same idea as
|
|
9
|
+
gateway's own ASGI lifespan open/close, just scoped to one CLI invocation
|
|
10
|
+
instead of a running server) so a check actually exercises real
|
|
11
|
+
connections, not just "constructed, never started".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
err_console = Console(stderr=True, style="bold red")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def health(
|
|
29
|
+
as_json: bool = typer.Option(False, "--json", help="Emit machine-readable JSON instead of a table."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Boot, open every capability, run arc.health.check() across all of
|
|
32
|
+
them, then close back down. Exits non-zero if anything reported unhealthy."""
|
|
33
|
+
import arc
|
|
34
|
+
|
|
35
|
+
with warnings.catch_warnings():
|
|
36
|
+
warnings.simplefilter("ignore", arc.ArcAdvisory)
|
|
37
|
+
try:
|
|
38
|
+
kernel = arc.boot()
|
|
39
|
+
except arc.BootError as exc:
|
|
40
|
+
_fail(str(exc), as_json)
|
|
41
|
+
return # unreachable; _fail raises typer.Exit
|
|
42
|
+
|
|
43
|
+
results = asyncio.run(_check(kernel))
|
|
44
|
+
|
|
45
|
+
if as_json:
|
|
46
|
+
print(json.dumps({"ok": arc.health.all_ok(results), "results": results}, indent=2, default=str))
|
|
47
|
+
else:
|
|
48
|
+
table = Table(title="arc.health.check()")
|
|
49
|
+
table.add_column("capability")
|
|
50
|
+
table.add_column("ok")
|
|
51
|
+
table.add_column("detail")
|
|
52
|
+
for name, result in sorted(results.items()):
|
|
53
|
+
ok = bool(result.get("ok", True))
|
|
54
|
+
detail = ", ".join(f"{k}={v}" for k, v in result.items() if k != "ok")
|
|
55
|
+
table.add_row(name, "[green]yes[/green]" if ok else "[bold red]no[/bold red]", detail)
|
|
56
|
+
console.print(table)
|
|
57
|
+
|
|
58
|
+
if not arc.health.all_ok(results):
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _check(kernel) -> dict:
|
|
63
|
+
import arc
|
|
64
|
+
|
|
65
|
+
opened = []
|
|
66
|
+
for name, cap in kernel.capabilities().items():
|
|
67
|
+
open_fn = getattr(cap.instance, "open", None)
|
|
68
|
+
if callable(open_fn):
|
|
69
|
+
await open_fn()
|
|
70
|
+
opened.append(cap.instance)
|
|
71
|
+
try:
|
|
72
|
+
return await arc.health.check()
|
|
73
|
+
finally:
|
|
74
|
+
for instance in opened:
|
|
75
|
+
close_fn = getattr(instance, "close", None)
|
|
76
|
+
if callable(close_fn):
|
|
77
|
+
await close_fn()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _fail(message: str, as_json: bool) -> None:
|
|
81
|
+
if as_json:
|
|
82
|
+
print(json.dumps({"ok": False, "error": message}, indent=2))
|
|
83
|
+
else:
|
|
84
|
+
err_console.print(f"Health check FAILED: {message}")
|
|
85
|
+
raise typer.Exit(code=1)
|
arc/kernel.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.kernel
|
|
3
|
+
-----------------
|
|
4
|
+
The runtime capability registry — the object every plugin's `register(kernel)`
|
|
5
|
+
function talks to, and what `arc.boot()` returns.
|
|
6
|
+
|
|
7
|
+
This is NOT arc/registry.py. That module is the CLI's bookkeeping for
|
|
8
|
+
`.arc/plugins.lock` (what is installed and enabled *on disk*). This module is
|
|
9
|
+
the in-process registry that exists only for the lifetime of a booted process.
|
|
10
|
+
|
|
11
|
+
Domain-blind by design (Architecture §3.1): the Kernel only ever sees a string
|
|
12
|
+
name, an instance, a `requires` list, and an `optional_requires` list. It
|
|
13
|
+
never defines what a capability *is* — no typed Protocols, no domain
|
|
14
|
+
knowledge. Adding a new capability type must cost zero kernel code changes,
|
|
15
|
+
permanently.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import typing
|
|
22
|
+
import warnings as _warnings
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import MappingProxyType
|
|
26
|
+
from typing import Any, Iterable, Mapping
|
|
27
|
+
|
|
28
|
+
if typing.TYPE_CHECKING:
|
|
29
|
+
from .resolver import BootPlan, PluginSpec
|
|
30
|
+
from .settings import SettingsManager
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ArcAdvisory(UserWarning):
|
|
34
|
+
"""
|
|
35
|
+
A non-fatal boot/runtime advisory (Architecture §3.5): something worth
|
|
36
|
+
knowing that must never block startup — e.g. "local-file secrets in use".
|
|
37
|
+
Emitted via warnings.warn() and also collected on Kernel.advisories.
|
|
38
|
+
Placeholder channel until arc.health exists as a real runtime API.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KernelError(RuntimeError):
|
|
43
|
+
"""Base class for all runtime kernel failures."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExportError(KernelError):
|
|
47
|
+
"""A plugin's export was invalid — always a hard boot failure."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Capability names that can never be exported. Anything here would be found
|
|
51
|
+
# by normal attribute lookup on the `arc` module *before* PEP 562
|
|
52
|
+
# __getattr__ ever fires, silently shadowing the capability. The static set
|
|
53
|
+
# covers the kernel's own submodules and public API; is_reserved_capability_name()
|
|
54
|
+
# additionally checks the live `arc` module namespace so nothing slips through.
|
|
55
|
+
RESERVED_CAPABILITY_NAMES = frozenset({
|
|
56
|
+
# kernel submodules
|
|
57
|
+
"cli", "kernel", "registry", "resolver", "runtime", "secrets", "settings",
|
|
58
|
+
"doctor", "plugin_cli", "codec", "health",
|
|
59
|
+
# public API re-exported on the arc package
|
|
60
|
+
"boot", "shutdown", "find_project_root",
|
|
61
|
+
"Kernel", "Capability", "KernelError", "ExportError",
|
|
62
|
+
"BootError", "ResolutionError", "ArcAdvisory",
|
|
63
|
+
"BootPlan", "PluginSpec",
|
|
64
|
+
"__version__",
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_reserved_capability_name(name: str) -> bool:
|
|
69
|
+
if name in RESERVED_CAPABILITY_NAMES:
|
|
70
|
+
return True
|
|
71
|
+
arc_module = sys.modules.get("arc")
|
|
72
|
+
# vars() reads the module dict directly — it does NOT trigger the
|
|
73
|
+
# package's __getattr__, so booted capabilities never look "reserved".
|
|
74
|
+
return arc_module is not None and name in vars(arc_module)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def capability_name_problem(name: object) -> str | None:
|
|
78
|
+
"""Return a human-readable problem with a proposed capability name, or None if valid."""
|
|
79
|
+
if not isinstance(name, str) or not name:
|
|
80
|
+
return "capability name must be a non-empty string"
|
|
81
|
+
if not name.isidentifier():
|
|
82
|
+
return (
|
|
83
|
+
f"'{name}' is not a valid Python identifier — a capability becomes "
|
|
84
|
+
f"the attribute `arc.{name}`, so it must be importable as one"
|
|
85
|
+
)
|
|
86
|
+
if name.startswith("_"):
|
|
87
|
+
return f"'{name}' starts with an underscore, which is reserved for arc internals"
|
|
88
|
+
if is_reserved_capability_name(name):
|
|
89
|
+
return (
|
|
90
|
+
f"'{name}' collides with the arc module's own namespace (a kernel "
|
|
91
|
+
f"submodule or public API attribute) and would be shadowed — "
|
|
92
|
+
f"pick a different capability name"
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Capability:
|
|
99
|
+
"""One exported capability: exactly what §3.1 says the kernel may know."""
|
|
100
|
+
name: str
|
|
101
|
+
instance: Any
|
|
102
|
+
requires: tuple[str, ...]
|
|
103
|
+
optional_requires: tuple[str, ...]
|
|
104
|
+
plugin: str # which plugin exported it ("<direct export>" outside boot)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Kernel:
|
|
108
|
+
"""
|
|
109
|
+
The runtime capability registry.
|
|
110
|
+
|
|
111
|
+
Created by arc.boot(); plugins receive it as the sole argument to their
|
|
112
|
+
register(kernel) function and call kernel.export(...) exactly once.
|
|
113
|
+
Can also be constructed directly (tests, embedding) — spec cross-checks
|
|
114
|
+
are then skipped, but name validation and duplicate/require enforcement
|
|
115
|
+
still apply.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
project_root: Path | None = None,
|
|
121
|
+
*,
|
|
122
|
+
settings: "SettingsManager | None" = None,
|
|
123
|
+
plan: "BootPlan | None" = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
self.project_root = project_root
|
|
126
|
+
#: The project-bound SettingsManager (or None when constructed
|
|
127
|
+
#: directly outside a project). `arc.settings.get/set/...` at module
|
|
128
|
+
#: level proxy to this — one call site, secret or not (§3.5).
|
|
129
|
+
self.settings = settings
|
|
130
|
+
self.advisories: list[str] = []
|
|
131
|
+
self._plan = plan
|
|
132
|
+
self._caps: dict[str, Capability] = {}
|
|
133
|
+
self._current_spec: "PluginSpec | None" = None
|
|
134
|
+
self._current_export_count = 0
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
# The registration surface plugins see (§3.1)
|
|
138
|
+
# ------------------------------------------------------------------ #
|
|
139
|
+
def export(
|
|
140
|
+
self,
|
|
141
|
+
name: str,
|
|
142
|
+
instance: Any,
|
|
143
|
+
requires: Iterable[str] = (),
|
|
144
|
+
optional_requires: Iterable[str] = (),
|
|
145
|
+
) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Export `instance` as the capability `name` — reachable as arc.<name>
|
|
148
|
+
after boot. Hard `requires` must already be registered (boot's
|
|
149
|
+
topological order guarantees this when manifests are accurate).
|
|
150
|
+
"""
|
|
151
|
+
requires = tuple(requires or ())
|
|
152
|
+
optional_requires = tuple(optional_requires or ())
|
|
153
|
+
|
|
154
|
+
problem = capability_name_problem(name)
|
|
155
|
+
if problem:
|
|
156
|
+
who = f"plugin '{self._current_spec.name}'" if self._current_spec else "a direct export"
|
|
157
|
+
raise ExportError(f"invalid capability export from {who}: {problem}.")
|
|
158
|
+
|
|
159
|
+
if name in self._caps:
|
|
160
|
+
raise ExportError(
|
|
161
|
+
f"capability '{name}' is already exported by plugin "
|
|
162
|
+
f"'{self._caps[name].plugin}' — two plugins exporting the same "
|
|
163
|
+
f"name is a hard boot failure (§3.1); rename one."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
spec = self._current_spec
|
|
167
|
+
if spec is not None:
|
|
168
|
+
if self._current_export_count >= 1:
|
|
169
|
+
raise ExportError(
|
|
170
|
+
f"plugin '{spec.name}' attempted to export a second "
|
|
171
|
+
f"capability ('{name}') — each plugin exports exactly one "
|
|
172
|
+
f"namespace under arc.<capability> (§2)."
|
|
173
|
+
)
|
|
174
|
+
if name != spec.capability:
|
|
175
|
+
raise ExportError(
|
|
176
|
+
f"plugin '{spec.name}' exported capability '{name}', but its "
|
|
177
|
+
f"manifest (plugin.toml, via plugins.lock) declares "
|
|
178
|
+
f"capability = \"{spec.capability}\". Align register() and "
|
|
179
|
+
f"plugin.toml, then run `arc build` to refresh the lock."
|
|
180
|
+
)
|
|
181
|
+
if set(requires) != set(spec.requires) or set(optional_requires) != set(
|
|
182
|
+
spec.optional_requires
|
|
183
|
+
):
|
|
184
|
+
self.advise(
|
|
185
|
+
f"manifest drift in plugin '{spec.name}': register() declared "
|
|
186
|
+
f"requires={sorted(requires)} / optional_requires="
|
|
187
|
+
f"{sorted(optional_requires)}, but plugins.lock has "
|
|
188
|
+
f"requires={sorted(spec.requires)} / optional_requires="
|
|
189
|
+
f"{sorted(spec.optional_requires)}. Load order was computed "
|
|
190
|
+
f"from the lock — update plugin.toml and run `arc build`."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
for req in requires:
|
|
194
|
+
if req not in self._caps:
|
|
195
|
+
raise ExportError(self._missing_requirement_message(name, spec, req))
|
|
196
|
+
|
|
197
|
+
self._caps[name] = Capability(
|
|
198
|
+
name=name,
|
|
199
|
+
instance=instance,
|
|
200
|
+
requires=requires,
|
|
201
|
+
optional_requires=optional_requires,
|
|
202
|
+
plugin=spec.name if spec else "<direct export>",
|
|
203
|
+
)
|
|
204
|
+
self._current_export_count += 1
|
|
205
|
+
|
|
206
|
+
# ------------------------------------------------------------------ #
|
|
207
|
+
# Lookup — what arc/__init__'s PEP 562 __getattr__ resolves against
|
|
208
|
+
# ------------------------------------------------------------------ #
|
|
209
|
+
def get(self, name: str) -> Any:
|
|
210
|
+
"""Return the instance exported as `name`."""
|
|
211
|
+
try:
|
|
212
|
+
return self._caps[name].instance
|
|
213
|
+
except KeyError:
|
|
214
|
+
raise KernelError(
|
|
215
|
+
f"no capability named '{name}' is registered "
|
|
216
|
+
f"(registered: {sorted(self._caps) or 'none'})."
|
|
217
|
+
) from None
|
|
218
|
+
|
|
219
|
+
def has(self, name: str) -> bool:
|
|
220
|
+
return name in self._caps
|
|
221
|
+
|
|
222
|
+
def capabilities(self) -> Mapping[str, Capability]:
|
|
223
|
+
"""Read-only view of every exported capability, keyed by name."""
|
|
224
|
+
return MappingProxyType(self._caps)
|
|
225
|
+
|
|
226
|
+
def current_plugin(self) -> str | None:
|
|
227
|
+
"""The name of the plugin whose register(kernel) is currently
|
|
228
|
+
executing, or None outside of one. Lets a provider (e.g. psqldb)
|
|
229
|
+
attribute something a caller declares — a set of schema files, say
|
|
230
|
+
— to whichever plugin declared it, without that caller having to
|
|
231
|
+
pass its own name by hand. Still just a string (§3.1): the kernel
|
|
232
|
+
doesn't know or care what the provider does with it."""
|
|
233
|
+
return self._current_spec.name if self._current_spec else None
|
|
234
|
+
|
|
235
|
+
def advise(self, message: str) -> None:
|
|
236
|
+
"""Record a non-fatal advisory (§3.5) — collected AND warned, never raised."""
|
|
237
|
+
self.advisories.append(message)
|
|
238
|
+
_warnings.warn(message, ArcAdvisory, stacklevel=3)
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------ #
|
|
241
|
+
# Internal — called by arc.runtime around each register() invocation
|
|
242
|
+
# ------------------------------------------------------------------ #
|
|
243
|
+
def _begin_plugin(self, spec: "PluginSpec") -> None:
|
|
244
|
+
self._current_spec = spec
|
|
245
|
+
self._current_export_count = 0
|
|
246
|
+
|
|
247
|
+
def _finish_plugin(self, spec: "PluginSpec") -> None:
|
|
248
|
+
try:
|
|
249
|
+
if self._current_export_count == 0:
|
|
250
|
+
raise ExportError(
|
|
251
|
+
f"plugin '{spec.name}' register() completed without exporting "
|
|
252
|
+
f"its capability '{spec.capability}' — register(kernel) must "
|
|
253
|
+
f"call kernel.export(...) exactly once."
|
|
254
|
+
)
|
|
255
|
+
finally:
|
|
256
|
+
self._current_spec = None
|
|
257
|
+
self._current_export_count = 0
|
|
258
|
+
|
|
259
|
+
def _missing_requirement_message(
|
|
260
|
+
self, name: str, spec: "PluginSpec | None", req: str
|
|
261
|
+
) -> str:
|
|
262
|
+
base = (
|
|
263
|
+
f"capability '{name}' declares a hard require on '{req}', "
|
|
264
|
+
f"which is not registered"
|
|
265
|
+
)
|
|
266
|
+
plan = self._plan
|
|
267
|
+
if plan is not None:
|
|
268
|
+
later = next((s for s in plan.load_order if s.capability == req), None)
|
|
269
|
+
if later is not None:
|
|
270
|
+
return (
|
|
271
|
+
base
|
|
272
|
+
+ f" yet — plugin '{later.name}' provides it but loads later. "
|
|
273
|
+
f"The requirement is missing from the manifest that load "
|
|
274
|
+
f"order was computed from: add '{req}' to `requires` in "
|
|
275
|
+
f"plugin.toml and run `arc build`."
|
|
276
|
+
)
|
|
277
|
+
skipped = next(
|
|
278
|
+
(sk for sk in plan.skipped if sk.capability == req), None
|
|
279
|
+
)
|
|
280
|
+
if skipped is not None:
|
|
281
|
+
return (
|
|
282
|
+
base
|
|
283
|
+
+ f" — plugin '{skipped.name}' provides it but was skipped "
|
|
284
|
+
f"({skipped.reason})."
|
|
285
|
+
)
|
|
286
|
+
return (
|
|
287
|
+
base
|
|
288
|
+
+ ". No plugin in this boot provides it — install one "
|
|
289
|
+
"(`arc install <git-url>`) or drop the requirement."
|
|
290
|
+
)
|