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/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
+ )