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/plugin_cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.plugin_cli
|
|
3
|
+
-------------------
|
|
4
|
+
Lets a plugin contribute its own `arc <plugin-name> ...` subcommands —
|
|
5
|
+
`arc psqldb connect`, `arc psqldb status`, and so on — the same way
|
|
6
|
+
`register(kernel)` lets it contribute a capability. Kernel stays
|
|
7
|
+
domain-blind (§3.1): this file never knows "psqldb" or "redix" by name, it
|
|
8
|
+
just discovers and mounts whatever Typer app a plugin chooses to expose.
|
|
9
|
+
|
|
10
|
+
A plugin opts in via a SECOND, separate entry-point group in its own
|
|
11
|
+
pyproject.toml (distinct from `arc.plugins`, which is register() only):
|
|
12
|
+
|
|
13
|
+
[project.entry-points."arc.plugins.cli"]
|
|
14
|
+
psqldb = "psqldb.cli:app"
|
|
15
|
+
|
|
16
|
+
...where the target is a `typer.Typer()` instance (or a zero-arg callable
|
|
17
|
+
returning one). It is mounted under the plugin's own name, so whatever
|
|
18
|
+
subcommands that Typer app defines become `arc psqldb <subcommand>`.
|
|
19
|
+
|
|
20
|
+
Scope rule: a plugin's CLI is mounted if it appears in the CURRENT
|
|
21
|
+
project's plugins.lock at all — regardless of enabled/disabled. Disabling
|
|
22
|
+
a plugin only affects arc.boot() (§3.6, runtime attachment); it must not
|
|
23
|
+
hide operational/debug tooling like `connect`/`status`, which people
|
|
24
|
+
reasonably want to use *while* a plugin is disabled (e.g. mid-migration).
|
|
25
|
+
|
|
26
|
+
Failure handling: a broken or misconfigured plugin CLI must never break
|
|
27
|
+
the base `arc` tool (`arc --help` has to keep working). Every failure mode
|
|
28
|
+
here is a skip-with-a-printed-warning, never a raised exception — mirrors
|
|
29
|
+
the advisory philosophy already used in arc.resolver / arc.kernel.advise.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from importlib.metadata import entry_points as _installed_entry_points
|
|
35
|
+
import sys
|
|
36
|
+
from typing import Any
|
|
37
|
+
import typer
|
|
38
|
+
from rich.console import Console
|
|
39
|
+
|
|
40
|
+
from . import registry
|
|
41
|
+
from .runtime import find_project_root
|
|
42
|
+
|
|
43
|
+
PLUGIN_CLI_ENTRY_POINT_GROUP = "arc.plugins.cli"
|
|
44
|
+
|
|
45
|
+
# Top-level commands the kernel itself owns — a plugin can never claim these.
|
|
46
|
+
RESERVED_CLI_NAMES = frozenset({"init", "install", "build", "settings", "plugin", "doctor", "health"})
|
|
47
|
+
|
|
48
|
+
console = Console()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mount_plugin_clis(app: typer.Typer) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Best-effort: mount every project plugin's CLI Typer app onto `app`, if it
|
|
54
|
+
has one. A silent no-op outside a project (e.g. the global bootstrap
|
|
55
|
+
`arc` used only for `init`/`install` before any project exists) — there
|
|
56
|
+
is no plugins.lock to consult yet, so there is nothing to mount.
|
|
57
|
+
"""
|
|
58
|
+
root = find_project_root() # None outside a project; never raises/exits here
|
|
59
|
+
if root is None:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
lock_path = root / ".arc" / "plugins.lock"
|
|
63
|
+
if not lock_path.exists():
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
lock_doc = registry.load_lock(lock_path)
|
|
67
|
+
known_plugin_names = {name for name, _entry in registry.list_plugins(lock_doc)}
|
|
68
|
+
mounted: set[str] = set()
|
|
69
|
+
|
|
70
|
+
for ep in _installed_entry_points(group=PLUGIN_CLI_ENTRY_POINT_GROUP):
|
|
71
|
+
if ep.name not in known_plugin_names:
|
|
72
|
+
continue # not part of this project — same "stray, ignore" spirit as the resolver
|
|
73
|
+
|
|
74
|
+
if ep.name in RESERVED_CLI_NAMES:
|
|
75
|
+
console.print(
|
|
76
|
+
f"[yellow]Skipping CLI for plugin '{ep.name}': that name is "
|
|
77
|
+
f"reserved for a built-in `arc` command.[/yellow]"
|
|
78
|
+
)
|
|
79
|
+
continue
|
|
80
|
+
if ep.name in mounted:
|
|
81
|
+
console.print(
|
|
82
|
+
f"[yellow]Skipping duplicate CLI entry point for plugin "
|
|
83
|
+
f"'{ep.name}' — already mounted.[/yellow]"
|
|
84
|
+
)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
plugin_app = _load_plugin_cli_app(ep)
|
|
88
|
+
if plugin_app is None:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
app.add_typer(plugin_app, name=ep.name, help=f"Commands for the '{ep.name}' plugin.")
|
|
92
|
+
mounted.add(ep.name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_plugin_cli_app(ep: Any) -> typer.Typer | None:
|
|
96
|
+
try:
|
|
97
|
+
target = ep.load()
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
console.print(
|
|
100
|
+
f"[yellow]Could not import CLI for plugin '{ep.name}': "
|
|
101
|
+
f"{exc.__class__.__name__}: {exc}\n"
|
|
102
|
+
f" This `arc` is running under: {sys.executable}\n"
|
|
103
|
+
f" If that's not this project's .venv/bin/python, you're likely running "
|
|
104
|
+
f"the global bootstrap `arc` instead of the project-local one (§3.8) — "
|
|
105
|
+
f"try the full path to this project's own arc, e.g. `./.venv/bin/arc`, "
|
|
106
|
+
f"or `hash -r` if you activated .venv after `arc` was already resolved "
|
|
107
|
+
f"in this shell.[/yellow]"
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
plugin_app = target() if callable(target) and not isinstance(target, typer.Typer) else target
|
|
112
|
+
|
|
113
|
+
if not isinstance(plugin_app, typer.Typer):
|
|
114
|
+
console.print(
|
|
115
|
+
f"[yellow]Plugin '{ep.name}'s {PLUGIN_CLI_ENTRY_POINT_GROUP} entry "
|
|
116
|
+
f"point must resolve to a typer.Typer app (or a zero-arg callable "
|
|
117
|
+
f"returning one) — got {type(plugin_app).__name__}. Skipped.[/yellow]"
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
120
|
+
return plugin_app
|
arc/registry.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.registry
|
|
3
|
+
-------------------
|
|
4
|
+
Plugin manifest discovery and the `.arc/plugins.lock` file.
|
|
5
|
+
|
|
6
|
+
Every plugin directory under `plugins/<name>/` carries a `plugin.toml`
|
|
7
|
+
manifest:
|
|
8
|
+
|
|
9
|
+
[plugin]
|
|
10
|
+
name = "psqldb"
|
|
11
|
+
version = "0.1.0"
|
|
12
|
+
capability = "psqldb" # namespace it exports as arc.<capability>
|
|
13
|
+
requires = [] # other capability names this plugin needs
|
|
14
|
+
optional_requires = [] # capability names used IF present, never required
|
|
15
|
+
|
|
16
|
+
[dependencies]
|
|
17
|
+
asyncpg = ">=0.29"
|
|
18
|
+
|
|
19
|
+
`plugins.lock` is the kernel's source of truth for "what did `arc build`
|
|
20
|
+
resolve, and which plugins are currently enabled". It is distinct from
|
|
21
|
+
"physically present in plugins/" — a plugin can be on disk and disabled,
|
|
22
|
+
which means arc.boot() will not call its register() function and its
|
|
23
|
+
capability namespace is never attached to `arc`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import tomlkit
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RegistryError(RuntimeError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class PluginManifest:
|
|
40
|
+
name: str
|
|
41
|
+
version: str
|
|
42
|
+
capability: str
|
|
43
|
+
requires: list[str] = field(default_factory=list)
|
|
44
|
+
optional_requires: list[str] = field(default_factory=list)
|
|
45
|
+
source_dir: Path | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_manifest(plugin_toml: Path) -> PluginManifest:
|
|
49
|
+
doc = tomlkit.parse(plugin_toml.read_text())
|
|
50
|
+
plugin_section = doc.get("plugin")
|
|
51
|
+
if not plugin_section:
|
|
52
|
+
raise RegistryError(f"{plugin_toml} is missing a [plugin] section.")
|
|
53
|
+
|
|
54
|
+
name = plugin_section.get("name")
|
|
55
|
+
if not name:
|
|
56
|
+
raise RegistryError(f"{plugin_toml} [plugin] section is missing 'name'.")
|
|
57
|
+
|
|
58
|
+
return PluginManifest(
|
|
59
|
+
name=name,
|
|
60
|
+
version=plugin_section.get("version", "0.0.0"),
|
|
61
|
+
capability=plugin_section.get("capability", name),
|
|
62
|
+
requires=list(plugin_section.get("requires", [])),
|
|
63
|
+
optional_requires=list(plugin_section.get("optional_requires", [])),
|
|
64
|
+
source_dir=plugin_toml.parent,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_manifest(plugin_toml: Path) -> PluginManifest:
|
|
69
|
+
"""Public entry point for reading a single plugin.toml (e.g. right after a clone)."""
|
|
70
|
+
return _read_manifest(plugin_toml)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def discover_plugins(plugins_dir: Path, only: str | None = None) -> list[PluginManifest]:
|
|
74
|
+
"""
|
|
75
|
+
Scan plugins_dir/*/plugin.toml. If `only` is given, restrict to that
|
|
76
|
+
single plugin directory (used by `arc build -p <name>`).
|
|
77
|
+
"""
|
|
78
|
+
if not plugins_dir.exists():
|
|
79
|
+
raise RegistryError(f"Plugins directory not found: {plugins_dir}")
|
|
80
|
+
|
|
81
|
+
manifests: list[PluginManifest] = []
|
|
82
|
+
for entry in sorted(plugins_dir.iterdir()):
|
|
83
|
+
if not entry.is_dir():
|
|
84
|
+
continue
|
|
85
|
+
if only is not None and entry.name != only:
|
|
86
|
+
continue
|
|
87
|
+
manifest_path = entry / "plugin.toml"
|
|
88
|
+
if not manifest_path.exists():
|
|
89
|
+
continue
|
|
90
|
+
manifests.append(_read_manifest(manifest_path))
|
|
91
|
+
|
|
92
|
+
if only is not None and not manifests:
|
|
93
|
+
raise RegistryError(
|
|
94
|
+
f"No plugin named '{only}' found under {plugins_dir} "
|
|
95
|
+
f"(expected {plugins_dir / only / 'plugin.toml'})."
|
|
96
|
+
)
|
|
97
|
+
return manifests
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_requires(manifests: list[PluginManifest]) -> list[str]:
|
|
101
|
+
"""
|
|
102
|
+
Returns a list of human-readable warnings for any hard `requires` that
|
|
103
|
+
isn't satisfied by the given set of manifests. Does not raise — this is
|
|
104
|
+
advisory at build time; arc.boot() is what enforces it at runtime.
|
|
105
|
+
"""
|
|
106
|
+
available = {m.capability for m in manifests}
|
|
107
|
+
warnings = []
|
|
108
|
+
for m in manifests:
|
|
109
|
+
for req in m.requires:
|
|
110
|
+
if req not in available:
|
|
111
|
+
warnings.append(
|
|
112
|
+
f"Plugin '{m.name}' requires capability '{req}', "
|
|
113
|
+
f"which is not among the plugins being built."
|
|
114
|
+
)
|
|
115
|
+
return warnings
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------- #
|
|
119
|
+
# plugins.lock
|
|
120
|
+
# ---------------------------------------------------------------------- #
|
|
121
|
+
|
|
122
|
+
def load_lock(lock_path: Path) -> tomlkit.TOMLDocument:
|
|
123
|
+
if not lock_path.exists():
|
|
124
|
+
doc = tomlkit.document()
|
|
125
|
+
doc["plugins"] = tomlkit.table()
|
|
126
|
+
return doc
|
|
127
|
+
return tomlkit.parse(lock_path.read_text())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def save_lock(lock_path: Path, doc: tomlkit.TOMLDocument) -> None:
|
|
131
|
+
lock_path.write_text(tomlkit.dumps(doc))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def merge_manifests_into_lock(
|
|
135
|
+
lock_doc: tomlkit.TOMLDocument, manifests: list[PluginManifest]
|
|
136
|
+
) -> tomlkit.TOMLDocument:
|
|
137
|
+
"""
|
|
138
|
+
Update lock entries for the given manifests. Preserves the existing
|
|
139
|
+
`enabled` flag for plugins already in the lock; defaults new plugins
|
|
140
|
+
to enabled=true. Plugins not in `manifests` are left untouched (this
|
|
141
|
+
lets `-p` scoped builds update one entry without disturbing the rest).
|
|
142
|
+
"""
|
|
143
|
+
plugins_table = lock_doc.setdefault("plugins", tomlkit.table())
|
|
144
|
+
|
|
145
|
+
for m in manifests:
|
|
146
|
+
existing = plugins_table.get(m.name)
|
|
147
|
+
enabled = existing.get("enabled", True) if existing else True
|
|
148
|
+
|
|
149
|
+
entry = tomlkit.table()
|
|
150
|
+
entry["version"] = m.version
|
|
151
|
+
entry["capability"] = m.capability
|
|
152
|
+
entry["requires"] = m.requires
|
|
153
|
+
entry["optional_requires"] = m.optional_requires
|
|
154
|
+
entry["enabled"] = enabled
|
|
155
|
+
plugins_table[m.name] = entry
|
|
156
|
+
|
|
157
|
+
return lock_doc
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def set_enabled(lock_doc: tomlkit.TOMLDocument, name: str, enabled: bool) -> None:
|
|
161
|
+
plugins_table = lock_doc.get("plugins")
|
|
162
|
+
if not plugins_table or name not in plugins_table:
|
|
163
|
+
available = list(plugins_table.keys()) if plugins_table else []
|
|
164
|
+
raise RegistryError(
|
|
165
|
+
f"Plugin '{name}' is not in plugins.lock. "
|
|
166
|
+
f"Run `arc build` first. Known plugins: {available or 'none'}"
|
|
167
|
+
)
|
|
168
|
+
plugins_table[name]["enabled"] = enabled
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def list_plugins(lock_doc: tomlkit.TOMLDocument) -> list[tuple[str, dict]]:
|
|
172
|
+
plugins_table = lock_doc.get("plugins", {})
|
|
173
|
+
return [(name, dict(entry)) for name, entry in plugins_table.items()]
|
arc/resolver.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.resolver
|
|
3
|
+
-------------------
|
|
4
|
+
Pure, side-effect-free boot planning: turn `.arc/plugins.lock` plus the
|
|
5
|
+
installed `arc.plugins` entry points into an ordered, validated BootPlan.
|
|
6
|
+
|
|
7
|
+
Shared by two consumers with deliberately different natures:
|
|
8
|
+
|
|
9
|
+
* arc.runtime.boot() — resolves a plan, then EXECUTES it,
|
|
10
|
+
* `arc doctor` (CLI) — resolves a plan, then only PRINTS it.
|
|
11
|
+
|
|
12
|
+
To keep the doctor path a true dry run, this module never imports plugin
|
|
13
|
+
code: entry points are matched by name only, never `.load()`ed here.
|
|
14
|
+
|
|
15
|
+
Resolution rules (Architecture §3.1 / §3.3 / §3.6):
|
|
16
|
+
* plugins.lock is the source of truth for what is enabled — an entry point
|
|
17
|
+
installed in the venv but absent from the lock is skipped with a warning
|
|
18
|
+
(run `arc build`), never silently loaded;
|
|
19
|
+
* an ENABLED lock entry with no matching entry point is a hard failure;
|
|
20
|
+
* duplicate capability names among enabled plugins → hard failure;
|
|
21
|
+
* missing hard `requires` → hard failure, with a targeted hint when the
|
|
22
|
+
provider exists but is disabled;
|
|
23
|
+
* `optional_requires` that are absent are simply fine; when present they
|
|
24
|
+
influence load order (dependency first) unless that would create a
|
|
25
|
+
cycle, in which case the optional edge is dropped with a warning;
|
|
26
|
+
* load order is deterministic: topological, alphabetical tie-break.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import heapq
|
|
32
|
+
from collections import deque
|
|
33
|
+
from dataclasses import dataclass, field, replace
|
|
34
|
+
from importlib.metadata import EntryPoint
|
|
35
|
+
from importlib.metadata import entry_points as _installed_entry_points
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Iterable
|
|
38
|
+
|
|
39
|
+
import tomlkit
|
|
40
|
+
|
|
41
|
+
from .kernel import KernelError, capability_name_problem
|
|
42
|
+
from .registry import load_lock
|
|
43
|
+
|
|
44
|
+
ENTRY_POINT_GROUP = "arc.plugins"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ResolutionError(KernelError):
|
|
48
|
+
"""Boot planning failed — arc.boot() would not be able to start."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PluginSpec:
|
|
53
|
+
"""One plugin as declared by plugins.lock, optionally matched to an entry point."""
|
|
54
|
+
name: str
|
|
55
|
+
capability: str
|
|
56
|
+
version: str
|
|
57
|
+
requires: tuple[str, ...]
|
|
58
|
+
optional_requires: tuple[str, ...]
|
|
59
|
+
enabled: bool
|
|
60
|
+
# importlib.metadata.EntryPoint in production; tests inject duck-typed
|
|
61
|
+
# fakes exposing .name / .value / .load().
|
|
62
|
+
entry_point: EntryPoint | Any | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class SkippedPlugin:
|
|
67
|
+
name: str
|
|
68
|
+
reason: str
|
|
69
|
+
capability: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class BootPlan:
|
|
74
|
+
"""Everything arc.boot() would do, computed without doing any of it."""
|
|
75
|
+
project_root: Path | None
|
|
76
|
+
load_order: list[PluginSpec]
|
|
77
|
+
skipped: list[SkippedPlugin] = field(default_factory=list)
|
|
78
|
+
warnings: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"ok": True,
|
|
83
|
+
"project_root": str(self.project_root) if self.project_root else None,
|
|
84
|
+
"load_order": [
|
|
85
|
+
{
|
|
86
|
+
"position": i,
|
|
87
|
+
"name": s.name,
|
|
88
|
+
"capability": s.capability,
|
|
89
|
+
"version": s.version,
|
|
90
|
+
"requires": list(s.requires),
|
|
91
|
+
"optional_requires": list(s.optional_requires),
|
|
92
|
+
"entry_point": getattr(s.entry_point, "value", None),
|
|
93
|
+
}
|
|
94
|
+
for i, s in enumerate(self.load_order, start=1)
|
|
95
|
+
],
|
|
96
|
+
"skipped": [
|
|
97
|
+
{"plugin": sk.name, "capability": sk.capability, "reason": sk.reason}
|
|
98
|
+
for sk in self.skipped
|
|
99
|
+
],
|
|
100
|
+
"warnings": list(self.warnings),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --------------------------------------------------------------------------- #
|
|
105
|
+
# Inputs
|
|
106
|
+
# --------------------------------------------------------------------------- #
|
|
107
|
+
def discover_entry_points() -> tuple[EntryPoint, ...]:
|
|
108
|
+
"""All installed entry points in the `arc.plugins` group. Nothing is imported."""
|
|
109
|
+
return tuple(_installed_entry_points(group=ENTRY_POINT_GROUP))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def specs_from_lock(lock_doc: tomlkit.TOMLDocument) -> list[PluginSpec]:
|
|
113
|
+
plugins_table = lock_doc.get("plugins", {}) or {}
|
|
114
|
+
specs: list[PluginSpec] = []
|
|
115
|
+
for name, entry in plugins_table.items():
|
|
116
|
+
specs.append(
|
|
117
|
+
PluginSpec(
|
|
118
|
+
name=str(name),
|
|
119
|
+
capability=str(entry.get("capability", name)),
|
|
120
|
+
version=str(entry.get("version", "0.0.0")),
|
|
121
|
+
requires=tuple(str(r) for r in (entry.get("requires", []) or [])),
|
|
122
|
+
optional_requires=tuple(
|
|
123
|
+
str(r) for r in (entry.get("optional_requires", []) or [])
|
|
124
|
+
),
|
|
125
|
+
enabled=bool(entry.get("enabled", True)),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return sorted(specs, key=lambda s: s.name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------- #
|
|
132
|
+
# Resolution
|
|
133
|
+
# --------------------------------------------------------------------------- #
|
|
134
|
+
def resolve(
|
|
135
|
+
project_root: Path | None = None,
|
|
136
|
+
*,
|
|
137
|
+
lock_doc: tomlkit.TOMLDocument | None = None,
|
|
138
|
+
entry_points: Iterable[Any] | None = None,
|
|
139
|
+
) -> BootPlan:
|
|
140
|
+
"""
|
|
141
|
+
Compute a BootPlan. Raises ResolutionError for anything that would be a
|
|
142
|
+
hard boot failure; everything advisory lands in BootPlan.warnings.
|
|
143
|
+
"""
|
|
144
|
+
if lock_doc is None:
|
|
145
|
+
if project_root is None:
|
|
146
|
+
lock_doc = tomlkit.document()
|
|
147
|
+
else:
|
|
148
|
+
lock_doc = load_lock(Path(project_root) / ".arc" / "plugins.lock")
|
|
149
|
+
eps = tuple(entry_points) if entry_points is not None else discover_entry_points()
|
|
150
|
+
|
|
151
|
+
warnings_: list[str] = []
|
|
152
|
+
skipped: list[SkippedPlugin] = []
|
|
153
|
+
specs = specs_from_lock(lock_doc)
|
|
154
|
+
|
|
155
|
+
# -- entry points: unique names, indexed --------------------------------
|
|
156
|
+
eps_by_name: dict[str, Any] = {}
|
|
157
|
+
for ep in eps:
|
|
158
|
+
if ep.name in eps_by_name:
|
|
159
|
+
raise ResolutionError(
|
|
160
|
+
f"two installed packages both provide an '{ENTRY_POINT_GROUP}' "
|
|
161
|
+
f"entry point named '{ep.name}' "
|
|
162
|
+
f"('{getattr(eps_by_name[ep.name], 'value', '?')}' and "
|
|
163
|
+
f"'{getattr(ep, 'value', '?')}') — plugin names must be unique; "
|
|
164
|
+
f"uninstall or rename one."
|
|
165
|
+
)
|
|
166
|
+
eps_by_name[ep.name] = ep
|
|
167
|
+
|
|
168
|
+
# -- entry points installed but unknown to the lock: never loaded -------
|
|
169
|
+
known_names = {s.name for s in specs}
|
|
170
|
+
for stray in sorted(set(eps_by_name) - known_names):
|
|
171
|
+
reason = "installed in the environment but not present in plugins.lock"
|
|
172
|
+
skipped.append(SkippedPlugin(name=stray, reason=reason))
|
|
173
|
+
warnings_.append(
|
|
174
|
+
f"entry point '{stray}' ({ENTRY_POINT_GROUP}) is {reason} — it will "
|
|
175
|
+
f"NOT be loaded. If it is a real plugin of this project, run "
|
|
176
|
+
f"`arc build` to register it; otherwise remove it from the venv."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# -- lock entries: split enabled/disabled, demand entry points ----------
|
|
180
|
+
enabled: list[PluginSpec] = []
|
|
181
|
+
for spec in specs:
|
|
182
|
+
if not spec.enabled:
|
|
183
|
+
skipped.append(
|
|
184
|
+
SkippedPlugin(
|
|
185
|
+
name=spec.name,
|
|
186
|
+
capability=spec.capability,
|
|
187
|
+
reason="disabled in plugins.lock "
|
|
188
|
+
f"(`arc plugin enable {spec.name}` to load it)",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
ep = eps_by_name.get(spec.name)
|
|
193
|
+
if ep is None:
|
|
194
|
+
raise ResolutionError(
|
|
195
|
+
f"plugin '{spec.name}' is enabled in plugins.lock, but no "
|
|
196
|
+
f"'{ENTRY_POINT_GROUP}' entry point named '{spec.name}' is "
|
|
197
|
+
f"installed in this environment. The plugin's pyproject.toml "
|
|
198
|
+
f"must declare\n"
|
|
199
|
+
f' [project.entry-points."{ENTRY_POINT_GROUP}"]\n'
|
|
200
|
+
f' {spec.name} = "<package>:register"\n'
|
|
201
|
+
f"then run `arc build` (or `uv sync --all-packages`) to install it."
|
|
202
|
+
)
|
|
203
|
+
enabled.append(replace(spec, entry_point=ep))
|
|
204
|
+
|
|
205
|
+
# -- capability names: valid, non-reserved, unique among enabled --------
|
|
206
|
+
by_capability: dict[str, PluginSpec] = {}
|
|
207
|
+
for spec in enabled:
|
|
208
|
+
problem = capability_name_problem(spec.capability)
|
|
209
|
+
if problem:
|
|
210
|
+
raise ResolutionError(
|
|
211
|
+
f"plugin '{spec.name}' declares an invalid capability: {problem}."
|
|
212
|
+
)
|
|
213
|
+
clash = by_capability.get(spec.capability)
|
|
214
|
+
if clash is not None:
|
|
215
|
+
raise ResolutionError(
|
|
216
|
+
f"plugins '{clash.name}' and '{spec.name}' both export capability "
|
|
217
|
+
f"'{spec.capability}' — capability names must be unique (§3.1); "
|
|
218
|
+
f"rename one, or `arc plugin disable` one of them."
|
|
219
|
+
)
|
|
220
|
+
by_capability[spec.capability] = spec
|
|
221
|
+
|
|
222
|
+
# -- hard requires must be satisfiable by the ENABLED set ---------------
|
|
223
|
+
disabled_by_capability = {s.capability: s for s in specs if not s.enabled}
|
|
224
|
+
for spec in enabled:
|
|
225
|
+
for req in spec.requires:
|
|
226
|
+
if req in by_capability:
|
|
227
|
+
continue
|
|
228
|
+
provider = disabled_by_capability.get(req)
|
|
229
|
+
if provider is not None:
|
|
230
|
+
raise ResolutionError(
|
|
231
|
+
f"plugin '{spec.name}' requires capability '{req}', which is "
|
|
232
|
+
f"provided by plugin '{provider.name}' — currently DISABLED. "
|
|
233
|
+
f"Run `arc plugin enable {provider.name}`, or disable "
|
|
234
|
+
f"'{spec.name}' as well."
|
|
235
|
+
)
|
|
236
|
+
raise ResolutionError(
|
|
237
|
+
f"plugin '{spec.name}' requires capability '{req}', but no "
|
|
238
|
+
f"enabled plugin provides it. Install one "
|
|
239
|
+
f"(`arc install <git-url>`) or disable '{spec.name}'."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
load_order = _topological_order(enabled, warnings_)
|
|
243
|
+
return BootPlan(
|
|
244
|
+
project_root=project_root,
|
|
245
|
+
load_order=load_order,
|
|
246
|
+
skipped=skipped,
|
|
247
|
+
warnings=warnings_,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# --------------------------------------------------------------------------- #
|
|
252
|
+
# Ordering
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
def _kahn_unresolved(adjacency: dict[str, set[str]]) -> set[str]:
|
|
255
|
+
"""Nodes left over after a Kahn pass — non-empty iff the graph has a cycle."""
|
|
256
|
+
indegree = {node: 0 for node in adjacency}
|
|
257
|
+
for outs in adjacency.values():
|
|
258
|
+
for out in outs:
|
|
259
|
+
indegree[out] += 1
|
|
260
|
+
queue = deque(node for node, deg in indegree.items() if deg == 0)
|
|
261
|
+
while queue:
|
|
262
|
+
node = queue.popleft()
|
|
263
|
+
for out in adjacency[node]:
|
|
264
|
+
indegree[out] -= 1
|
|
265
|
+
if indegree[out] == 0:
|
|
266
|
+
queue.append(out)
|
|
267
|
+
return {node for node, deg in indegree.items() if deg > 0}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _topological_order(
|
|
271
|
+
specs: list[PluginSpec], warnings_: list[str]
|
|
272
|
+
) -> list[PluginSpec]:
|
|
273
|
+
"""
|
|
274
|
+
Order specs so every hard dependency registers before its dependents.
|
|
275
|
+
Edges run dependency -> dependent. Hard edges are mandatory (a cycle is a
|
|
276
|
+
hard failure); optional edges are honored when possible and dropped with a
|
|
277
|
+
warning when they would close a cycle. Ties break alphabetically by
|
|
278
|
+
capability so load order is fully deterministic.
|
|
279
|
+
"""
|
|
280
|
+
by_capability = {s.capability: s for s in specs}
|
|
281
|
+
adjacency: dict[str, set[str]] = {cap: set() for cap in by_capability}
|
|
282
|
+
|
|
283
|
+
for spec in specs:
|
|
284
|
+
for req in spec.requires: # presence already validated by resolve()
|
|
285
|
+
adjacency[req].add(spec.capability)
|
|
286
|
+
|
|
287
|
+
unresolved = _kahn_unresolved(adjacency)
|
|
288
|
+
if unresolved:
|
|
289
|
+
detail = "; ".join(
|
|
290
|
+
f"'{by_capability[cap].name}' requires {list(by_capability[cap].requires)}"
|
|
291
|
+
for cap in sorted(unresolved)
|
|
292
|
+
)
|
|
293
|
+
raise ResolutionError(
|
|
294
|
+
f"hard dependency cycle among plugins: {detail} — cycles in "
|
|
295
|
+
f"`requires` cannot be booted; make one side an optional_requires "
|
|
296
|
+
f"or break the dependency."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def reaches(src: str, dst: str) -> bool:
|
|
300
|
+
stack, seen = [src], {src}
|
|
301
|
+
while stack:
|
|
302
|
+
node = stack.pop()
|
|
303
|
+
if node == dst:
|
|
304
|
+
return True
|
|
305
|
+
for out in adjacency[node]:
|
|
306
|
+
if out not in seen:
|
|
307
|
+
seen.add(out)
|
|
308
|
+
stack.append(out)
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
for spec in sorted(specs, key=lambda s: s.capability):
|
|
312
|
+
for opt in sorted(set(spec.optional_requires)):
|
|
313
|
+
if opt not in by_capability:
|
|
314
|
+
continue # absent optional — allowed, plugin handles it (§3.3)
|
|
315
|
+
if spec.capability in adjacency[opt]:
|
|
316
|
+
continue # already an edge (also a hard require)
|
|
317
|
+
if reaches(spec.capability, opt):
|
|
318
|
+
warnings_.append(
|
|
319
|
+
f"optional ordering skipped: '{spec.name}' optionally "
|
|
320
|
+
f"requires '{opt}', but loading '{opt}' first would create a "
|
|
321
|
+
f"dependency cycle. '{by_capability[opt].name}' will register "
|
|
322
|
+
f"AFTER '{spec.name}', so '{spec.name}' must look it up "
|
|
323
|
+
f"lazily (post-boot) rather than at register() time."
|
|
324
|
+
)
|
|
325
|
+
continue
|
|
326
|
+
adjacency[opt].add(spec.capability)
|
|
327
|
+
|
|
328
|
+
indegree = {cap: 0 for cap in by_capability}
|
|
329
|
+
for outs in adjacency.values():
|
|
330
|
+
for out in outs:
|
|
331
|
+
indegree[out] += 1
|
|
332
|
+
heap = [cap for cap, deg in indegree.items() if deg == 0]
|
|
333
|
+
heapq.heapify(heap)
|
|
334
|
+
order: list[PluginSpec] = []
|
|
335
|
+
while heap:
|
|
336
|
+
cap = heapq.heappop(heap)
|
|
337
|
+
order.append(by_capability[cap])
|
|
338
|
+
for out in sorted(adjacency[cap]):
|
|
339
|
+
indegree[out] -= 1
|
|
340
|
+
if indegree[out] == 0:
|
|
341
|
+
heapq.heappush(heap, out)
|
|
342
|
+
|
|
343
|
+
if len(order) != len(specs): # unreachable: optional edges never close cycles
|
|
344
|
+
raise ResolutionError(
|
|
345
|
+
"internal resolver error: ordering did not cover every plugin."
|
|
346
|
+
)
|
|
347
|
+
return order
|