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/runtime.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.runtime
|
|
3
|
+
------------------
|
|
4
|
+
`arc.boot()` — the runtime counterpart to the CLI tooling. The CLI mutates the
|
|
5
|
+
project (install/build/enable); boot() only ever reads it.
|
|
6
|
+
|
|
7
|
+
Boot contract (Architecture §3.6):
|
|
8
|
+
* runs at EVERY process start,
|
|
9
|
+
* read-only — never mutates plugins.lock, settings, or anything on disk,
|
|
10
|
+
* fully offline — no git, no PyPI,
|
|
11
|
+
* safe to run on every replica simultaneously,
|
|
12
|
+
* idempotent within a process — a second boot() is a no-op returning the
|
|
13
|
+
same Kernel; pass force=True to rebuild (tests, embedding).
|
|
14
|
+
|
|
15
|
+
Sequence:
|
|
16
|
+
1. locate the project root: explicit argument > $ARC_PROJECT_ROOT > walk up
|
|
17
|
+
from cwd looking for .arc/arc.toml;
|
|
18
|
+
2. read .arc/plugins.lock (source of truth for what is enabled) and the
|
|
19
|
+
installed `arc.plugins` entry points;
|
|
20
|
+
3. resolve a BootPlan (arc.resolver — pure; the same function powers
|
|
21
|
+
`arc doctor` as a dry run);
|
|
22
|
+
4. bind this project's SettingsManager onto the Kernel, making the
|
|
23
|
+
module-level arc.settings API live (§3.5);
|
|
24
|
+
5. call each plugin's register(kernel) in topological order — the active
|
|
25
|
+
kernel is published *before* execution, so a plugin may use
|
|
26
|
+
arc.<earlier_capability> and arc.settings inside register();
|
|
27
|
+
6. every exported capability becomes reachable as arc.<name> via the
|
|
28
|
+
package's PEP 562 __getattr__.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import threading
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Iterable
|
|
37
|
+
|
|
38
|
+
from . import _state
|
|
39
|
+
from .kernel import Kernel, KernelError
|
|
40
|
+
from .registry import load_lock
|
|
41
|
+
from .resolver import PluginSpec, discover_entry_points, resolve
|
|
42
|
+
from .settings import SettingsManager
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BootError(KernelError):
|
|
46
|
+
"""arc.boot() could not start the process."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_boot_lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_project_root(start: Path | None = None) -> Path | None:
|
|
53
|
+
"""
|
|
54
|
+
Locate the ARC project root. $ARC_PROJECT_ROOT wins when set (and is
|
|
55
|
+
validated loudly — explicit configuration that is wrong should never be
|
|
56
|
+
silently ignored); otherwise walk up from `start`/cwd looking for
|
|
57
|
+
.arc/arc.toml. Returns None when no project is found.
|
|
58
|
+
"""
|
|
59
|
+
env_root = os.environ.get("ARC_PROJECT_ROOT")
|
|
60
|
+
if env_root:
|
|
61
|
+
root = Path(env_root).expanduser().resolve()
|
|
62
|
+
if not (root / ".arc" / "arc.toml").exists():
|
|
63
|
+
raise BootError(
|
|
64
|
+
f"$ARC_PROJECT_ROOT is set to '{root}', but no .arc/arc.toml "
|
|
65
|
+
f"exists there — fix or unset the variable."
|
|
66
|
+
)
|
|
67
|
+
return root
|
|
68
|
+
|
|
69
|
+
current = (start or Path.cwd()).resolve()
|
|
70
|
+
for candidate in (current, *current.parents):
|
|
71
|
+
if (candidate / ".arc" / "arc.toml").exists():
|
|
72
|
+
return candidate
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def boot(
|
|
77
|
+
project_root: str | Path | None = None,
|
|
78
|
+
*,
|
|
79
|
+
force: bool = False,
|
|
80
|
+
entry_points: Iterable[Any] | None = None,
|
|
81
|
+
) -> Kernel:
|
|
82
|
+
"""
|
|
83
|
+
Boot the ARC runtime for this process and return the Kernel.
|
|
84
|
+
|
|
85
|
+
`entry_points` overrides installed-entry-point discovery — for tests and
|
|
86
|
+
embedding; production callers never pass it.
|
|
87
|
+
"""
|
|
88
|
+
with _boot_lock:
|
|
89
|
+
existing = _state.get_kernel()
|
|
90
|
+
if existing is not None:
|
|
91
|
+
if not force:
|
|
92
|
+
return existing
|
|
93
|
+
_state.set_kernel(None)
|
|
94
|
+
|
|
95
|
+
if project_root is not None:
|
|
96
|
+
root = Path(project_root).expanduser().resolve()
|
|
97
|
+
if not (root / ".arc" / "arc.toml").exists():
|
|
98
|
+
raise BootError(
|
|
99
|
+
f"project_root was given as '{root}', but no .arc/arc.toml "
|
|
100
|
+
f"exists there — is this really an ARC project? "
|
|
101
|
+
f"Run `arc init` first."
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
root = find_project_root()
|
|
105
|
+
if root is None:
|
|
106
|
+
raise BootError(
|
|
107
|
+
"Not inside an ARC project — no .arc/arc.toml found in the "
|
|
108
|
+
"current directory or any parent. Run from within a project, "
|
|
109
|
+
"pass boot(project_root=...), or set $ARC_PROJECT_ROOT."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
lock_doc = load_lock(root / ".arc" / "plugins.lock")
|
|
113
|
+
eps = tuple(entry_points) if entry_points is not None else discover_entry_points()
|
|
114
|
+
plan = resolve(root, lock_doc=lock_doc, entry_points=eps)
|
|
115
|
+
|
|
116
|
+
settings_manager = SettingsManager(root / ".arc")
|
|
117
|
+
kernel = Kernel(project_root=root, settings=settings_manager, plan=plan)
|
|
118
|
+
|
|
119
|
+
for warning in plan.warnings:
|
|
120
|
+
kernel.advise(warning)
|
|
121
|
+
if settings_manager.secrets_provider() == "local_file":
|
|
122
|
+
# §3.5: fully supported tier for dev/self-hosted — advisory, never an error.
|
|
123
|
+
kernel.advise(
|
|
124
|
+
"local-file secrets provider in use (.arc/arc.secrets encrypted "
|
|
125
|
+
"with .arc/arc.mkey) — a supported tier for dev and self-hosted "
|
|
126
|
+
"deployments. For managed environments, consider a keyring/KMS "
|
|
127
|
+
"provider via [secrets].provider in .arc/arc.toml. Advisory only; "
|
|
128
|
+
"startup is never blocked."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Publish BEFORE executing registers: topological order means a plugin
|
|
132
|
+
# may legitimately use arc.<earlier_capability> and arc.settings inside
|
|
133
|
+
# its own register(). Any failure below tears this back down.
|
|
134
|
+
_state.set_kernel(kernel)
|
|
135
|
+
try:
|
|
136
|
+
for spec in plan.load_order:
|
|
137
|
+
register = _load_register(spec)
|
|
138
|
+
kernel._begin_plugin(spec)
|
|
139
|
+
try:
|
|
140
|
+
register(kernel)
|
|
141
|
+
except KernelError:
|
|
142
|
+
raise
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
raise BootError(
|
|
145
|
+
f"plugin '{spec.name}' raised during register(): "
|
|
146
|
+
f"{exc.__class__.__name__}: {exc}"
|
|
147
|
+
) from exc
|
|
148
|
+
kernel._finish_plugin(spec)
|
|
149
|
+
except BaseException:
|
|
150
|
+
_state.set_kernel(None)
|
|
151
|
+
raise
|
|
152
|
+
return kernel
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def shutdown() -> None:
|
|
156
|
+
"""
|
|
157
|
+
Clear the process-wide kernel so the next boot() starts fresh. Primarily
|
|
158
|
+
for tests and embedding. No lifecycle/teardown hooks are invoked — that
|
|
159
|
+
surface belongs to the future arc.health/lifecycle design, not here.
|
|
160
|
+
"""
|
|
161
|
+
_state.set_kernel(None)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_register(spec: PluginSpec) -> Any:
|
|
165
|
+
"""Import the plugin's entry point and return its register(kernel) callable."""
|
|
166
|
+
ep = spec.entry_point
|
|
167
|
+
if ep is None: # defensive: resolve() always attaches one for enabled specs
|
|
168
|
+
raise BootError(
|
|
169
|
+
f"plugin '{spec.name}' has no entry point attached — internal "
|
|
170
|
+
f"resolution error."
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
target = ep.load()
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
raise BootError(
|
|
176
|
+
f"could not import plugin '{spec.name}' "
|
|
177
|
+
f"(entry point '{getattr(ep, 'value', '?')}'): "
|
|
178
|
+
f"{exc.__class__.__name__}: {exc}"
|
|
179
|
+
) from exc
|
|
180
|
+
if callable(target):
|
|
181
|
+
return target
|
|
182
|
+
register = getattr(target, "register", None)
|
|
183
|
+
if callable(register):
|
|
184
|
+
return register
|
|
185
|
+
raise BootError(
|
|
186
|
+
f"the entry point for plugin '{spec.name}' must reference a callable "
|
|
187
|
+
f"register(kernel) function (e.g. \"{spec.name} = 'yourpkg:register'\") "
|
|
188
|
+
f"or a module defining one — got {type(target).__name__}."
|
|
189
|
+
)
|
arc/secrets.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.secrets
|
|
3
|
+
------------------
|
|
4
|
+
Local-file secrets provider.
|
|
5
|
+
|
|
6
|
+
This is the dev / self-hosted default described in the ARC settings design:
|
|
7
|
+
values are encrypted at rest using a Fernet key derived from `.arc/arc.mkey`,
|
|
8
|
+
and are never written to arc.toml or any non-secret config file.
|
|
9
|
+
|
|
10
|
+
Cloud providers (Vault, AWS Secrets Manager, Azure Key Vault) implement the
|
|
11
|
+
same three functions (`load`, `save_value`, `delete_value`) against their own
|
|
12
|
+
backend — this module is one interchangeable implementation of that shape,
|
|
13
|
+
not a hardcoded dependency of the settings layer above it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecretsError(RuntimeError):
|
|
26
|
+
"""Raised for any secrets-store failure (missing key, corrupt store, etc.)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fernet_from_mkey(mkey_path: Path) -> Fernet:
|
|
30
|
+
if not mkey_path.exists():
|
|
31
|
+
raise SecretsError(
|
|
32
|
+
f"Master key not found at {mkey_path}. Run `arc init` first."
|
|
33
|
+
)
|
|
34
|
+
mkey_hex = mkey_path.read_text().strip()
|
|
35
|
+
try:
|
|
36
|
+
raw = bytes.fromhex(mkey_hex)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise SecretsError(f"Master key at {mkey_path} is not valid hex.") from exc
|
|
39
|
+
if len(raw) != 32:
|
|
40
|
+
raise SecretsError(
|
|
41
|
+
f"Master key at {mkey_path} must decode to 32 bytes, got {len(raw)}."
|
|
42
|
+
)
|
|
43
|
+
fernet_key = base64.urlsafe_b64encode(raw)
|
|
44
|
+
return Fernet(fernet_key)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load(secrets_path: Path, mkey_path: Path) -> dict[str, str]:
|
|
48
|
+
"""Decrypt and return the full secrets dict. Empty dict if store is empty/new."""
|
|
49
|
+
if not secrets_path.exists() or secrets_path.stat().st_size == 0:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
fernet = _fernet_from_mkey(mkey_path)
|
|
53
|
+
token = secrets_path.read_bytes()
|
|
54
|
+
try:
|
|
55
|
+
plaintext = fernet.decrypt(token)
|
|
56
|
+
except InvalidToken as exc:
|
|
57
|
+
raise SecretsError(
|
|
58
|
+
f"Could not decrypt {secrets_path} — wrong master key, or the store is corrupt."
|
|
59
|
+
) from exc
|
|
60
|
+
return json.loads(plaintext.decode("utf-8"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write(secrets_path: Path, mkey_path: Path, data: dict[str, str]) -> None:
|
|
64
|
+
fernet = _fernet_from_mkey(mkey_path)
|
|
65
|
+
plaintext = json.dumps(data, sort_keys=True).encode("utf-8")
|
|
66
|
+
token = fernet.encrypt(plaintext)
|
|
67
|
+
secrets_path.write_bytes(token)
|
|
68
|
+
secrets_path.chmod(0o600)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_value(secrets_path: Path, mkey_path: Path, key: str, value: str) -> None:
|
|
72
|
+
"""Set a single secret key, preserving all others already stored."""
|
|
73
|
+
data = load(secrets_path, mkey_path)
|
|
74
|
+
data[key] = value
|
|
75
|
+
_write(secrets_path, mkey_path, data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def delete_value(secrets_path: Path, mkey_path: Path, key: str) -> bool:
|
|
79
|
+
"""Remove a single secret key. Returns True if it existed."""
|
|
80
|
+
data = load(secrets_path, mkey_path)
|
|
81
|
+
if key not in data:
|
|
82
|
+
return False
|
|
83
|
+
del data[key]
|
|
84
|
+
_write(secrets_path, mkey_path, data)
|
|
85
|
+
return True
|
arc/settings.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc.settings
|
|
3
|
+
-------------------
|
|
4
|
+
Implements the single `get()` / `set()` / `delete()` surface described in the
|
|
5
|
+
ARC settings design: one call site for every key, secret or not. The manager
|
|
6
|
+
decides internally which store a key belongs in.
|
|
7
|
+
|
|
8
|
+
Layout on disk:
|
|
9
|
+
.arc/arc.toml -> [settings] table for plain config
|
|
10
|
+
-> [secrets] declared = ["key1", "key2"] (NAMES only, never values)
|
|
11
|
+
.arc/arc.secrets -> encrypted store holding the actual secret VALUES
|
|
12
|
+
.arc/arc.mkey -> master key used to encrypt/decrypt arc.secrets
|
|
13
|
+
|
|
14
|
+
A key is treated as secret if (a) the caller passes secret=True on `set`, or
|
|
15
|
+
(b) the key already appears in [secrets].declared — so callers never have to
|
|
16
|
+
remember whether a key is secret on every subsequent get().
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import tomlkit
|
|
24
|
+
from tomlkit import TOMLDocument
|
|
25
|
+
|
|
26
|
+
from . import secrets as secret_store
|
|
27
|
+
|
|
28
|
+
REDACTED = "********"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SettingsError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SettingsManager:
|
|
36
|
+
def __init__(self, arc_dir: Path):
|
|
37
|
+
self.arc_dir = arc_dir
|
|
38
|
+
self.toml_path = arc_dir / "arc.toml"
|
|
39
|
+
self.secrets_path = arc_dir / "arc.secrets"
|
|
40
|
+
self.mkey_path = arc_dir / "arc.mkey"
|
|
41
|
+
|
|
42
|
+
if not self.toml_path.exists():
|
|
43
|
+
raise SettingsError(
|
|
44
|
+
f"{self.toml_path} not found. Run `arc init` first."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------ #
|
|
48
|
+
# Internal helpers
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
def _read_toml(self) -> TOMLDocument:
|
|
51
|
+
return tomlkit.parse(self.toml_path.read_text())
|
|
52
|
+
|
|
53
|
+
def _write_toml(self, doc: TOMLDocument) -> None:
|
|
54
|
+
self.toml_path.write_text(tomlkit.dumps(doc))
|
|
55
|
+
|
|
56
|
+
def _declared_secret_keys(self, doc: TOMLDocument) -> list[str]:
|
|
57
|
+
return list(doc.get("secrets", {}).get("declared", []))
|
|
58
|
+
|
|
59
|
+
def _declare_secret_key(self, doc: TOMLDocument, key: str) -> None:
|
|
60
|
+
secrets_table = doc.setdefault("secrets", tomlkit.table())
|
|
61
|
+
declared = secrets_table.setdefault("declared", tomlkit.array())
|
|
62
|
+
if key not in declared:
|
|
63
|
+
declared.append(key)
|
|
64
|
+
|
|
65
|
+
def _undeclare_secret_key(self, doc: TOMLDocument, key: str) -> None:
|
|
66
|
+
secrets_table = doc.get("secrets")
|
|
67
|
+
if not secrets_table:
|
|
68
|
+
return
|
|
69
|
+
declared = secrets_table.get("declared")
|
|
70
|
+
if declared and key in declared:
|
|
71
|
+
declared.remove(key)
|
|
72
|
+
|
|
73
|
+
def is_secret(self, key: str) -> bool:
|
|
74
|
+
doc = self._read_toml()
|
|
75
|
+
return key in self._declared_secret_keys(doc)
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
# Public API — mirrors arc.settings.get/set/delete at runtime
|
|
79
|
+
# ------------------------------------------------------------------ #
|
|
80
|
+
def get(self, key: str, reveal: bool = False) -> str | None:
|
|
81
|
+
doc = self._read_toml()
|
|
82
|
+
|
|
83
|
+
if key in self._declared_secret_keys(doc):
|
|
84
|
+
values = secret_store.load(self.secrets_path, self.mkey_path)
|
|
85
|
+
value = values.get(key)
|
|
86
|
+
if value is None:
|
|
87
|
+
return None
|
|
88
|
+
return value if reveal else REDACTED
|
|
89
|
+
|
|
90
|
+
settings_table = doc.get("settings", {})
|
|
91
|
+
value = settings_table.get(key)
|
|
92
|
+
return str(value) if value is not None else None
|
|
93
|
+
|
|
94
|
+
def set(self, key: str, value: str, secret: bool = False) -> None:
|
|
95
|
+
doc = self._read_toml()
|
|
96
|
+
|
|
97
|
+
if secret:
|
|
98
|
+
secret_store.save_value(self.secrets_path, self.mkey_path, key, value)
|
|
99
|
+
self._declare_secret_key(doc, key)
|
|
100
|
+
# If the same key previously existed as a plain setting, remove it
|
|
101
|
+
# so there is exactly one source of truth per key.
|
|
102
|
+
settings_table = doc.get("settings")
|
|
103
|
+
if settings_table and key in settings_table:
|
|
104
|
+
del settings_table[key]
|
|
105
|
+
self._write_toml(doc)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
if key in self._declared_secret_keys(doc):
|
|
109
|
+
raise SettingsError(
|
|
110
|
+
f"'{key}' is declared as a secret. Use --secret to update it, "
|
|
111
|
+
f"or delete it first if you want it to become a plain setting."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
settings_table = doc.setdefault("settings", tomlkit.table())
|
|
115
|
+
settings_table[key] = value
|
|
116
|
+
self._write_toml(doc)
|
|
117
|
+
|
|
118
|
+
def delete(self, key: str) -> bool:
|
|
119
|
+
doc = self._read_toml()
|
|
120
|
+
|
|
121
|
+
if key in self._declared_secret_keys(doc):
|
|
122
|
+
existed = secret_store.delete_value(self.secrets_path, self.mkey_path, key)
|
|
123
|
+
self._undeclare_secret_key(doc, key)
|
|
124
|
+
self._write_toml(doc)
|
|
125
|
+
return existed
|
|
126
|
+
|
|
127
|
+
settings_table = doc.get("settings")
|
|
128
|
+
if settings_table and key in settings_table:
|
|
129
|
+
del settings_table[key]
|
|
130
|
+
self._write_toml(doc)
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
# NEW — needed by arc.boot() / plugin register(): declare a key's
|
|
136
|
+
# secret-ness up front, with no value yet (§3.5, "typically by the
|
|
137
|
+
# owning plugin"), and let boot inspect which secrets provider is
|
|
138
|
+
# configured so it can emit the local-file advisory.
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
def declare(self, key: str, secret: bool = False) -> None:
|
|
141
|
+
"""
|
|
142
|
+
Declare a key's secret-ness without setting a value yet. Idempotent.
|
|
143
|
+
Mirrors the existing get/set collision rules: a key already declared
|
|
144
|
+
the other way must be resolved explicitly rather than silently
|
|
145
|
+
flipped.
|
|
146
|
+
"""
|
|
147
|
+
doc = self._read_toml()
|
|
148
|
+
declared = key in self._declared_secret_keys(doc)
|
|
149
|
+
|
|
150
|
+
if secret:
|
|
151
|
+
if declared:
|
|
152
|
+
return
|
|
153
|
+
settings_table = doc.get("settings")
|
|
154
|
+
if settings_table and key in settings_table:
|
|
155
|
+
raise SettingsError(
|
|
156
|
+
f"'{key}' already exists as a plain setting. Use "
|
|
157
|
+
f"set('{key}', <value>, secret=True) to migrate its value "
|
|
158
|
+
f"into the secret store, or delete it first."
|
|
159
|
+
)
|
|
160
|
+
self._declare_secret_key(doc, key)
|
|
161
|
+
self._write_toml(doc)
|
|
162
|
+
elif declared:
|
|
163
|
+
raise SettingsError(
|
|
164
|
+
f"'{key}' is declared as a secret; delete it first if it "
|
|
165
|
+
f"should become a plain setting."
|
|
166
|
+
)
|
|
167
|
+
# declaring a plain key that isn't already a secret is a no-op —
|
|
168
|
+
# plain keys need no declaration to be set.
|
|
169
|
+
|
|
170
|
+
def secrets_provider(self) -> str:
|
|
171
|
+
"""The configured [secrets].provider — 'local_file' when unset (§3.5)."""
|
|
172
|
+
doc = self._read_toml()
|
|
173
|
+
secrets_table = doc.get("secrets", {})
|
|
174
|
+
return str(secrets_table.get("provider", "local_file"))
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
# NEW — enumeration. Every other method here answers "what's the value
|
|
178
|
+
# of THIS key", assuming the caller already knows the key exists (a
|
|
179
|
+
# plugin declares its own keys at boot). Nothing previously answered
|
|
180
|
+
# "what keys exist at all" — a real gap once something (admin's own
|
|
181
|
+
# Settings page) needs to show the whole picture rather than one
|
|
182
|
+
# already-known key. Read-only, additive, and structurally incapable
|
|
183
|
+
# of leaking a secret value: it reads [settings] directly (already
|
|
184
|
+
# plaintext on disk) but only ever lists secret NAMES from
|
|
185
|
+
# [secrets].declared, never touching secret_store.load() at all.
|
|
186
|
+
# ------------------------------------------------------------------ #
|
|
187
|
+
def list_all(self) -> dict:
|
|
188
|
+
doc = self._read_toml()
|
|
189
|
+
# tomlkit hands back its own String/Array wrapper types (it
|
|
190
|
+
# preserves formatting/whitespace for round-tripping), not plain
|
|
191
|
+
# Python str — msgspec's encoder doesn't recognize them and raises
|
|
192
|
+
# "Encoding objects of type String is unsupported". get() above
|
|
193
|
+
# already casts a single value with str(value) for this exact
|
|
194
|
+
# reason; this does the same for every value/key here.
|
|
195
|
+
settings_table = doc.get("settings", {})
|
|
196
|
+
return {
|
|
197
|
+
"settings": {str(k): str(v) for k, v in settings_table.items()},
|
|
198
|
+
"secrets": [str(k) for k in self._declared_secret_keys(doc)],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --------------------------------------------------------------------------- #
|
|
203
|
+
# NEW — module-level runtime API for arc.boot().
|
|
204
|
+
#
|
|
205
|
+
# §3.5 writes this as `arc.settings.get(...)` / `arc.settings.declare(...)`:
|
|
206
|
+
# the `settings` module itself is the runtime surface application code and
|
|
207
|
+
# plugins use after boot, proxying to the booted kernel's project-bound
|
|
208
|
+
# SettingsManager (the same class the CLI uses directly, above). Defined at
|
|
209
|
+
# the very end of the file so the builtin `set` is untouched by everything
|
|
210
|
+
# above it.
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
def _bound_manager() -> SettingsManager:
|
|
213
|
+
from . import _state
|
|
214
|
+
|
|
215
|
+
kernel = _state.get_kernel()
|
|
216
|
+
if kernel is None or kernel.settings is None:
|
|
217
|
+
raise SettingsError(
|
|
218
|
+
"arc.settings is not bound to a project — call arc.boot() (from "
|
|
219
|
+
"inside an ARC project) before using arc.settings.get/set/delete/"
|
|
220
|
+
"declare. Outside the runtime, use the CLI: `arc settings ...`."
|
|
221
|
+
)
|
|
222
|
+
return kernel.settings
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get(key: str, reveal: bool = False) -> str | None:
|
|
226
|
+
return _bound_manager().get(key, reveal=reveal)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def set(key: str, value: str, secret: bool = False) -> None: # noqa: A001 - deliberate API name
|
|
230
|
+
return _bound_manager().set(key, value, secret=secret)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def delete(key: str) -> bool:
|
|
234
|
+
return _bound_manager().delete(key)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def declare(key: str, secret: bool = False) -> None:
|
|
238
|
+
return _bound_manager().declare(key, secret=secret)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def is_secret(key: str) -> bool:
|
|
242
|
+
return _bound_manager().is_secret(key)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def list_all() -> dict:
|
|
246
|
+
return _bound_manager().list_all()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arc-kernel
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ARC kernel — capability-based Python platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/vignesh-ramsh/arc
|
|
6
|
+
Project-URL: Repository, https://github.com/vignesh-ramsh/arc
|
|
7
|
+
Author: Vignesh Ramsh
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: arc,capability,cli,framework,kernel
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Requires-Dist: cryptography>=42.0
|
|
21
|
+
Requires-Dist: msgspec>=0.18
|
|
22
|
+
Requires-Dist: rich>=13.0
|
|
23
|
+
Requires-Dist: tomlkit>=0.12
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# kernel
|
|
28
|
+
|
|
29
|
+
The ARC kernel — repo/project name is `kernel`; the Python package it
|
|
30
|
+
provides is `arc` (flat layout, no `src/`). This is what every project's
|
|
31
|
+
`arc.<capability>` namespace is ultimately built on.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
arc/ # this repo
|
|
35
|
+
├── pyproject.toml # name = "arc-kernel" (distribution — "arc" was taken
|
|
36
|
+
│ on PyPI), packages = ["arc"]
|
|
37
|
+
├── README.md
|
|
38
|
+
├── LICENSE
|
|
39
|
+
├── .gitignore
|
|
40
|
+
└── arc/ # the actual package — `import arc`
|
|
41
|
+
├── __init__.py
|
|
42
|
+
├── cli.py # the `arc` command: init, install, build, settings, plugin
|
|
43
|
+
├── registry.py
|
|
44
|
+
├── settings.py
|
|
45
|
+
└── secrets.py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Two-tier install model (mirrors frappe-bench / bench)
|
|
49
|
+
|
|
50
|
+
1. **Bootstrap globally, once** — either `pip install arc-kernel` (or
|
|
51
|
+
`pipx install arc-kernel` / `uv tool install arc-kernel`, both preferred
|
|
52
|
+
over a bare `pip install` for a CLI tool) from PyPI, or
|
|
53
|
+
`uv tool install --editable .` from a local clone of this repo if you're
|
|
54
|
+
working on the kernel itself. Either way you get the `arc` command
|
|
55
|
+
anywhere on your machine.
|
|
56
|
+
2. **Per project** — `arc init` clones THIS repo into `<project>/arc/` and wires
|
|
57
|
+
up a uv workspace. From then on, use `<project>/.venv/bin/arc` for project
|
|
58
|
+
commands — that copy is pinned to the exact commit this project was built
|
|
59
|
+
against, independent of the global bootstrap CLI's version.
|
|
60
|
+
|
|
61
|
+
Note: inside a scaffolded project you'll see `project/arc/arc/` — the outer
|
|
62
|
+
`arc/` is this project's kernel *slot* (a workspace member, named the way
|
|
63
|
+
`apps/frappe` names its slot in a bench); the inner `arc/` is the actual
|
|
64
|
+
Python package. Two different things, same name for a real reason — not
|
|
65
|
+
duplication.
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
arc init [project_name] --kernel-repo <git-url-or-path> [--kernel-branch main] [--env dev]
|
|
71
|
+
arc install <git-url> [--branch BRANCH] [--name NAME]
|
|
72
|
+
arc build [-p/--plugin NAME] [--no-lock]
|
|
73
|
+
arc settings get <key> [--reveal]
|
|
74
|
+
arc settings set <key> <value> [--secret]
|
|
75
|
+
arc settings delete <key>
|
|
76
|
+
arc plugin list
|
|
77
|
+
arc plugin enable <name>
|
|
78
|
+
arc plugin disable <name>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`ARC_KERNEL_REPO` env var can be set once so `--kernel-repo` doesn't need to
|
|
82
|
+
be typed on every `arc init`.
|
|
83
|
+
|
|
84
|
+
## Plugin manifest format
|
|
85
|
+
|
|
86
|
+
Every plugin repo has, at its root:
|
|
87
|
+
|
|
88
|
+
```toml
|
|
89
|
+
# plugin.toml — ARC-specific metadata only
|
|
90
|
+
[plugin]
|
|
91
|
+
name = "psqldb"
|
|
92
|
+
version = "0.1.0"
|
|
93
|
+
capability = "psqldb" # exported as arc.<capability> at boot
|
|
94
|
+
requires = []
|
|
95
|
+
optional_requires = ["redix"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```toml
|
|
99
|
+
# pyproject.toml — standard Python packaging; real dependencies live HERE
|
|
100
|
+
[project]
|
|
101
|
+
name = "psqldb"
|
|
102
|
+
dependencies = ["asyncpg>=0.29"]
|
|
103
|
+
|
|
104
|
+
[build-system]
|
|
105
|
+
requires = ["hatchling"]
|
|
106
|
+
build-backend = "hatchling.build"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Following this same kernel's convention, plugin packages should also use a
|
|
110
|
+
flat layout (`psqldb/psqldb/__init__.py` inside the plugin's own repo, not
|
|
111
|
+
`psqldb/src/psqldb/`) — same reasoning, same fix, applied consistently.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
arc/__init__.py,sha256=bLJ90yTygTLkan9eK4wWqVmcre6y0RF02aVjVLEAgPQ,2009
|
|
2
|
+
arc/_state.py,sha256=Qfs5AHxcnzo3s95gUJdIpmgaMwwHDSDzVxqkCEGsc50,813
|
|
3
|
+
arc/cli.py,sha256=kqyma1MXk3hagOID6yuiOekbUj1d4WrTnN_joeK4kco,35184
|
|
4
|
+
arc/codec.py,sha256=Y_Ezx-5PKg3PcChQllT6ZKddk0qaIF6nCBiGBz4LNzo,2939
|
|
5
|
+
arc/doctor.py,sha256=9J3NcvqCOZvYu8JVqL6IYosov_lhKPkL2Z7G9479QDA,3358
|
|
6
|
+
arc/health.py,sha256=8MxUvICTLzQD5l7DuSBy3xx4GzDSZHNURFAd5UvBDJY,1921
|
|
7
|
+
arc/healthcmd.py,sha256=syowduYxXvoXi0XkuXws_UwKBWPraLQmJnRGaIZGiRA,2772
|
|
8
|
+
arc/kernel.py,sha256=ylD5ncnlFGgj6e4-1Alf1W3VYZVRdI9O3RixekHARwQ,12006
|
|
9
|
+
arc/plugin_cli.py,sha256=B__phnQF2_HtOQ-MF-c30uRk0rm0rvg-4p00fwJTlx4,4867
|
|
10
|
+
arc/registry.py,sha256=Q6rREkaPwnP8t2KwGKiFOjxH9oxlrv8iiCFbdR1FLN4,5949
|
|
11
|
+
arc/resolver.py,sha256=CglliHX3EUQj57zhxergUoomcvv3GPhAXn4wD7c8VDg,13884
|
|
12
|
+
arc/runtime.py,sha256=VLjKJGtxk0SoFB9wC7qYz2Jcz35U_A0vG4HMKwcn_Pc,7322
|
|
13
|
+
arc/secrets.py,sha256=5As5o8nXBxln6jdktBEDuE_2W4tfoYRk96N6wMJQ2ys,2915
|
|
14
|
+
arc/settings.py,sha256=LHbXAdzs_BxOfvzubUmcVluSl81V4S4NixVcIlUiC3U,9976
|
|
15
|
+
arc_kernel-0.1.0.dist-info/METADATA,sha256=C5l78s2gvUF-FqipcTdYAWfBm1no3j86qlQLIe7INn4,3945
|
|
16
|
+
arc_kernel-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
17
|
+
arc_kernel-0.1.0.dist-info/entry_points.txt,sha256=fZYe2b6BdwlzsXOPCjIZHVReCO5kmAeLWnz7hYX-xFo,36
|
|
18
|
+
arc_kernel-0.1.0.dist-info/licenses/LICENSE,sha256=fsl3ih-5C8nclkwJo9UKUjDWVIZzkupjrNPudz8Ghrk,1070
|
|
19
|
+
arc_kernel-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vignesh Ramsh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|