kanibako-cli 1.5.0.dev14__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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
kanibako/rig_source.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Source detection + name derivation for the future ``rig add`` command.
|
|
2
|
+
|
|
3
|
+
Given a *source* string -- a local file path, a registry reference, or a URL --
|
|
4
|
+
these pure helpers decide whether the source describes an OCI **image** (a
|
|
5
|
+
prefab to pull / a saved image tar to load) or a buildable **template**
|
|
6
|
+
(a Containerfile), and derive a sensible default rig name.
|
|
7
|
+
|
|
8
|
+
The core helpers do **no network I/O**. A raw ``http(s)://`` URL is undecidable
|
|
9
|
+
on its own; the caller is expected to fetch it first via :func:`fetch_to_temp`
|
|
10
|
+
(a thin, separately monkeypatchable wrapper) and then classify the downloaded
|
|
11
|
+
file with :func:`detect_source_kind`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# How many leading lines to scan when sniffing a text file for template signals.
|
|
23
|
+
_TEMPLATE_SCAN_LINES = 20
|
|
24
|
+
|
|
25
|
+
# Header conventions mirrored from templates_image.py. We only need to *detect*
|
|
26
|
+
# their presence here, so a loose prefix match is enough.
|
|
27
|
+
_TEMPLATE_HEADER_RE = re.compile(
|
|
28
|
+
r"^#\s*kanibako-template(?:-check)?:\s*", re.IGNORECASE
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# A leading ``FROM <image>`` directive marks a Containerfile/Dockerfile.
|
|
32
|
+
_FROM_DIRECTIVE_RE = re.compile(r"^\s*FROM\s+\S", re.IGNORECASE)
|
|
33
|
+
|
|
34
|
+
# Loose OCI registry-reference grammar: ``[registry/]repo[:tag][@digest]``.
|
|
35
|
+
# Lowercased before matching. Allows path segments separated by ``/`` and the
|
|
36
|
+
# usual ``.``/``-``/``_`` separators within a segment, an optional ``:tag`` and
|
|
37
|
+
# an optional ``@sha256:<hex>`` digest.
|
|
38
|
+
_REF_RE = re.compile(
|
|
39
|
+
r"^[a-z0-9]+([._-][a-z0-9]+)*" # first segment (registry host or repo)
|
|
40
|
+
r"(:[0-9]+)?" # optional :port on the host segment
|
|
41
|
+
r"(/[a-z0-9]+([._-][a-z0-9]+)*)*" # further /path segments
|
|
42
|
+
r"(:[\w][\w.-]*)?" # optional :tag
|
|
43
|
+
r"(@sha256:[a-f0-9]+)?$", # optional @digest
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Members whose presence in a tar marks it as an OCI / docker-save image archive.
|
|
47
|
+
_IMAGE_TAR_MARKERS = ("manifest.json", "oci-layout")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_to_temp(url: str) -> Path:
|
|
51
|
+
"""Download *url* to a temporary file and return its :class:`Path`.
|
|
52
|
+
|
|
53
|
+
Thin wrapper over :func:`urllib.request.urlretrieve` so that callers can
|
|
54
|
+
fetch a remote source before classifying it, and tests can monkeypatch the
|
|
55
|
+
network hop. The temp file is **not** auto-deleted; the caller owns cleanup.
|
|
56
|
+
"""
|
|
57
|
+
fd, tmp_name = tempfile.mkstemp(prefix="kanibako-rig-src-")
|
|
58
|
+
# Close our handle; urlretrieve manages the file by name.
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
os.close(fd)
|
|
62
|
+
urllib.request.urlretrieve(url, tmp_name)
|
|
63
|
+
return Path(tmp_name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_image_tar(path: str) -> bool:
|
|
67
|
+
"""Return True if *path* is a tar whose members mark it as an image archive."""
|
|
68
|
+
if not tarfile.is_tarfile(path):
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
with tarfile.open(path) as tar:
|
|
72
|
+
names = tar.getnames()
|
|
73
|
+
except (tarfile.TarError, OSError):
|
|
74
|
+
return False
|
|
75
|
+
for name in names:
|
|
76
|
+
norm = name.lstrip("./")
|
|
77
|
+
if norm in _IMAGE_TAR_MARKERS:
|
|
78
|
+
return True
|
|
79
|
+
# A top-level ``blobs/`` directory entry (OCI layout layout).
|
|
80
|
+
if norm == "blobs" or norm.startswith("blobs/"):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _has_template_signal(path: Path) -> bool:
|
|
86
|
+
"""Return True if *path* reads like a Containerfile/template."""
|
|
87
|
+
try:
|
|
88
|
+
with path.open(encoding="utf-8", errors="replace") as fh:
|
|
89
|
+
scanned = 0
|
|
90
|
+
for raw in fh:
|
|
91
|
+
if scanned >= _TEMPLATE_SCAN_LINES:
|
|
92
|
+
break
|
|
93
|
+
scanned += 1
|
|
94
|
+
line = raw.rstrip("\n")
|
|
95
|
+
if _TEMPLATE_HEADER_RE.match(line):
|
|
96
|
+
return True
|
|
97
|
+
stripped = line.strip()
|
|
98
|
+
if not stripped or stripped.startswith("#"):
|
|
99
|
+
continue
|
|
100
|
+
# First non-empty, non-comment line decides the FROM question.
|
|
101
|
+
return bool(_FROM_DIRECTIVE_RE.match(line))
|
|
102
|
+
except OSError:
|
|
103
|
+
return False
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def detect_source_kind(source: str, *, force: str | None = None) -> str:
|
|
108
|
+
"""Classify *source* as ``"image"`` or ``"template"``.
|
|
109
|
+
|
|
110
|
+
Resolution order:
|
|
111
|
+
|
|
112
|
+
1. *force* in ``{"image", "template"}`` -> returned directly.
|
|
113
|
+
2. **Local file** (``Path(source).is_file()``) -> sniffed: an image-tar
|
|
114
|
+
(manifest.json / oci-layout / blobs/) is ``"image"``; a file with a
|
|
115
|
+
leading ``FROM`` directive or a ``# kanibako-template[-check]:`` header
|
|
116
|
+
is ``"template"``. Neither signal -> :class:`ValueError`.
|
|
117
|
+
3. **Registry reference** (a non-existent path matching the loose ref
|
|
118
|
+
grammar, e.g. ``ghcr.io/corp/base:1.0`` or ``busybox``) -> ``"image"``.
|
|
119
|
+
|
|
120
|
+
A raw ``http(s)://`` URL is **undecidable** here -- fetch it first with
|
|
121
|
+
:func:`fetch_to_temp`, then classify the downloaded file. Anything that
|
|
122
|
+
matches none of the above raises :class:`ValueError` with guidance.
|
|
123
|
+
"""
|
|
124
|
+
if force is not None:
|
|
125
|
+
if force in ("image", "template"):
|
|
126
|
+
return force
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"invalid force value '{force}'; expected 'image' or 'template'"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
path = Path(source)
|
|
132
|
+
if path.is_file():
|
|
133
|
+
if _is_image_tar(source):
|
|
134
|
+
return "image"
|
|
135
|
+
if _has_template_signal(path):
|
|
136
|
+
return "template"
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"cannot classify source '{source}'; pass --as image|template"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
lowered = source.lower()
|
|
142
|
+
if lowered.startswith(("http://", "https://")):
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"cannot classify URL '{source}' directly; fetch it first "
|
|
145
|
+
"(fetch_to_temp) then classify the downloaded file."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if _REF_RE.match(lowered):
|
|
149
|
+
return "image"
|
|
150
|
+
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"cannot classify source '{source}'; expected a local file, an image "
|
|
153
|
+
"reference, or a fetched URL (pass --as image|template to force)."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _name_from_containerfile_basename(basename: str) -> str | None:
|
|
158
|
+
"""Derive a rig name from a ``Containerfile.<rest>`` basename, else None."""
|
|
159
|
+
for prefix in ("Containerfile.", "Dockerfile."):
|
|
160
|
+
if basename.startswith(prefix):
|
|
161
|
+
rest = basename[len(prefix):]
|
|
162
|
+
if not rest:
|
|
163
|
+
return None
|
|
164
|
+
if rest.startswith("template-"):
|
|
165
|
+
rest = rest[len("template-"):]
|
|
166
|
+
return rest or None
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _name_from_ref(ref: str) -> str | None:
|
|
171
|
+
"""Return *ref* minus a leading registry-host segment, if any."""
|
|
172
|
+
if not ref:
|
|
173
|
+
return None
|
|
174
|
+
segments = ref.split("/")
|
|
175
|
+
if len(segments) > 1:
|
|
176
|
+
first = segments[0]
|
|
177
|
+
# A leading segment is a registry host if it carries a ``.`` or a port.
|
|
178
|
+
if "." in first or ":" in first:
|
|
179
|
+
return "/".join(segments[1:]) or None
|
|
180
|
+
return ref
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _name_from_image_tar(path: str) -> str | None:
|
|
184
|
+
"""Derive a rig name from an image tar's first RepoTag, else its stem."""
|
|
185
|
+
try:
|
|
186
|
+
with tarfile.open(path) as tar:
|
|
187
|
+
try:
|
|
188
|
+
member = tar.getmember("manifest.json")
|
|
189
|
+
except KeyError:
|
|
190
|
+
member = None
|
|
191
|
+
if member is not None:
|
|
192
|
+
fh = tar.extractfile(member)
|
|
193
|
+
if fh is not None:
|
|
194
|
+
import json
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
manifest = json.loads(fh.read().decode("utf-8"))
|
|
198
|
+
except (ValueError, UnicodeDecodeError):
|
|
199
|
+
manifest = None
|
|
200
|
+
if isinstance(manifest, list) and manifest:
|
|
201
|
+
tags = manifest[0].get("RepoTags")
|
|
202
|
+
if isinstance(tags, list) and tags:
|
|
203
|
+
first = tags[0]
|
|
204
|
+
if isinstance(first, str) and first:
|
|
205
|
+
return first
|
|
206
|
+
except (tarfile.TarError, OSError):
|
|
207
|
+
pass
|
|
208
|
+
return Path(path).stem or None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def derive_name(source: str, kind: str) -> str | None:
|
|
212
|
+
"""Best-effort default rig name for *source* (of the given *kind*), or None.
|
|
213
|
+
|
|
214
|
+
- A Containerfile/Dockerfile path or URL whose basename is
|
|
215
|
+
``Containerfile.<rest>`` -> ``<rest>`` with an optional leading
|
|
216
|
+
``template-`` stripped (a bare ``Containerfile``/``Dockerfile`` -> None).
|
|
217
|
+
- An image **reference** -> the ref minus a leading registry-host segment
|
|
218
|
+
(``ghcr.io/corp/base:1.0`` -> ``corp/base:1.0``; ``busybox`` -> ``busybox``).
|
|
219
|
+
- An image **tar** (an existing file) -> its first RepoTag, else the stem.
|
|
220
|
+
- Anything underivable -> None.
|
|
221
|
+
|
|
222
|
+
Note: derived image names may legitimately contain ``/`` and ``:`` and are
|
|
223
|
+
*not* run through :func:`kanibako.templates_image.validate_template_name`.
|
|
224
|
+
"""
|
|
225
|
+
# A basename-based Containerfile name wins regardless of kind: ``rig add``
|
|
226
|
+
# of a template URL/path should name itself after the file.
|
|
227
|
+
basename = source.rsplit("/", 1)[-1]
|
|
228
|
+
cf_name = _name_from_containerfile_basename(basename)
|
|
229
|
+
if cf_name is not None:
|
|
230
|
+
return cf_name
|
|
231
|
+
|
|
232
|
+
if kind == "template":
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# kind == "image"
|
|
236
|
+
path = Path(source)
|
|
237
|
+
if path.is_file():
|
|
238
|
+
return _name_from_image_tar(source)
|
|
239
|
+
|
|
240
|
+
lowered = source.lower()
|
|
241
|
+
if lowered.startswith(("http://", "https://")):
|
|
242
|
+
# A URL with no Containerfile-style basename is underivable here.
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
return _name_from_ref(source)
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# helper-init.sh — Default helper entrypoint wrapper (kanibako)
|
|
3
|
+
#
|
|
4
|
+
# This script is copied into every helper's playbook/scripts/ directory
|
|
5
|
+
# by the parent agent. It runs as the container entrypoint.
|
|
6
|
+
#
|
|
7
|
+
# The parent creates the directory structure (vault, workspace, playbook,
|
|
8
|
+
# peers, broadcast channels) before launching the helper. This script
|
|
9
|
+
# handles registration with the hub and additional bootstrap, then execs
|
|
10
|
+
# the agent command.
|
|
11
|
+
#
|
|
12
|
+
# Parents can replace this with a custom version in their own
|
|
13
|
+
# playbook/scripts/helper-init.sh — kanibako will use the parent's
|
|
14
|
+
# version if it exists, falling back to this bundled default.
|
|
15
|
+
#
|
|
16
|
+
# Usage: helper-init.sh HELPER_NUM [COMMAND...]
|
|
17
|
+
# HELPER_NUM — this helper's global agent number
|
|
18
|
+
# COMMAND — the agent command to exec (default: claude)
|
|
19
|
+
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
HELPER_NUM="${1:-unknown}"
|
|
23
|
+
shift || true
|
|
24
|
+
|
|
25
|
+
SOCKET_PATH="$HOME/.kanibako/helper.sock"
|
|
26
|
+
|
|
27
|
+
# Register with the hub via kanibako CLI (one-shot)
|
|
28
|
+
if [ -S "$SOCKET_PATH" ] && command -v kanibako >/dev/null 2>&1; then
|
|
29
|
+
kanibako helper register "$HELPER_NUM" 2>/dev/null || true
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Source parent startup script from broadcast channel if present
|
|
33
|
+
if [ -f "$HOME/all/ro/startup.sh" ]; then
|
|
34
|
+
# shellcheck disable=SC1091
|
|
35
|
+
source "$HOME/all/ro/startup.sh"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
echo "Helper $HELPER_NUM initialized." >&2
|
|
39
|
+
|
|
40
|
+
# Exec the agent command (or claude if none given)
|
|
41
|
+
if [ $# -gt 0 ]; then
|
|
42
|
+
exec "$@"
|
|
43
|
+
else
|
|
44
|
+
exec claude
|
|
45
|
+
fi
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Container-side entry point for kanibako CLI.
|
|
3
|
+
|
|
4
|
+
Bind-mounted into containers at /home/agent/.local/bin/kanibako.
|
|
5
|
+
The kanibako package directory is bind-mounted at /opt/kanibako/kanibako/.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, "/opt/kanibako")
|
|
10
|
+
from kanibako.cli import main # noqa: E402
|
|
11
|
+
|
|
12
|
+
sys.exit(main() or 0)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Settings-framework expression resolution engine (pure, additive).
|
|
2
|
+
|
|
3
|
+
This module is the single home for the settings-framework expression grammar:
|
|
4
|
+
precedence resolution, ``$VAR``/``~``/``@``-ref expansion, and ``host_src:
|
|
5
|
+
guest_dest`` bind splitting. It operates ONLY on already-parsed nested data
|
|
6
|
+
(passed in as :class:`LevelView` objects and a lookup callback). It performs
|
|
7
|
+
**no file I/O, no mounting, no global mutable state**, and imports neither
|
|
8
|
+
``config.py`` nor ``paths.py``. It is intentionally format-agnostic — the
|
|
9
|
+
caller is responsible for parsing TOML/YAML into the simple mappings this
|
|
10
|
+
module consumes.
|
|
11
|
+
|
|
12
|
+
Precedence model (the design's "unset ≠ '' " distinction)
|
|
13
|
+
---------------------------------------------------------
|
|
14
|
+
:func:`resolve_value` walks the levels ordered MOST-SPECIFIC-FIRST
|
|
15
|
+
(``[box, workset, crab, system]``):
|
|
16
|
+
|
|
17
|
+
* **Explicit set values beat all declared defaults**, regardless of level.
|
|
18
|
+
* Among set values, the **most-specific** level wins.
|
|
19
|
+
* An explicit ``""`` is a **terminal suppression**: it wins at its level and
|
|
20
|
+
does NOT fall through to a less-specific default (``terminal=True``).
|
|
21
|
+
* Among defaults (reached only when nothing is set), the most-specific
|
|
22
|
+
(highest-authority) declared default wins.
|
|
23
|
+
* Nothing set and no default ⇒ :data:`UNSET`.
|
|
24
|
+
|
|
25
|
+
Expansion is a separate, explicit step (:func:`expand_expr`): the caller takes
|
|
26
|
+
the winning raw literal from :func:`resolve_value` and expands it in the
|
|
27
|
+
appropriate *space* (``"host"`` or ``"guest"``).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Callable, Mapping
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Literal
|
|
36
|
+
|
|
37
|
+
from kanibako.errors import KanibakoError
|
|
38
|
+
|
|
39
|
+
GUEST_HOME = "/home/agent"
|
|
40
|
+
MAX_REF_DEPTH = 64
|
|
41
|
+
|
|
42
|
+
_VAR_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
43
|
+
_REF_NAME_RE = re.compile(r"[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SettingsError(KanibakoError):
|
|
47
|
+
"""Raised on unknown variable, unresolvable/cyclic ``@``-ref, or depth-cap."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Unset:
|
|
51
|
+
"""Sentinel type for "no value resolved" (distinct from an explicit ``""``)."""
|
|
52
|
+
|
|
53
|
+
__slots__ = ()
|
|
54
|
+
|
|
55
|
+
def __repr__(self) -> str:
|
|
56
|
+
return "UNSET"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
UNSET = _Unset()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ResolveCtx:
|
|
64
|
+
"""Context for variable expansion.
|
|
65
|
+
|
|
66
|
+
*xdg* maps XDG variable names (e.g. ``"XDG_DATA_HOME"``) to host paths.
|
|
67
|
+
The dataclass is frozen; do not mutate *xdg* in place.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
crab_name: str | None
|
|
71
|
+
workset_name: str | None
|
|
72
|
+
host_home: str
|
|
73
|
+
xdg: dict[str, str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class LevelView:
|
|
78
|
+
"""A single precedence level's explicitly-set values and declared defaults.
|
|
79
|
+
|
|
80
|
+
*name* is the level name (e.g. ``"box"``). *values* holds values the user
|
|
81
|
+
explicitly set at this level; *defaults* holds defaults declared at this
|
|
82
|
+
level.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
values: Mapping[str, str]
|
|
87
|
+
defaults: Mapping[str, str] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ResolvedValue:
|
|
92
|
+
"""A resolved (but not yet expanded) value plus its provenance.
|
|
93
|
+
|
|
94
|
+
*value* is the raw winning literal (``@``-refs/``$vars``/``~`` intact).
|
|
95
|
+
*level* names the level that supplied it. *is_default* is True when the
|
|
96
|
+
value came from a declared default rather than an explicit set. *terminal*
|
|
97
|
+
is True when the winning value was an explicit ``""`` (a terminal
|
|
98
|
+
suppression that does not fall through to defaults).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
value: str
|
|
102
|
+
level: str
|
|
103
|
+
is_default: bool = False
|
|
104
|
+
terminal: bool = False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _unescape(s: str) -> str:
|
|
108
|
+
"""Resolve backslash escapes consistently.
|
|
109
|
+
|
|
110
|
+
A backslash before any character yields that character literally
|
|
111
|
+
(``\\:`` → ``:``, ``\\\\`` → ``\\``, ``\\x`` → ``x``). A trailing lone
|
|
112
|
+
backslash is kept literal.
|
|
113
|
+
"""
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
i = 0
|
|
116
|
+
n = len(s)
|
|
117
|
+
while i < n:
|
|
118
|
+
c = s[i]
|
|
119
|
+
if c == "\\":
|
|
120
|
+
if i + 1 < n:
|
|
121
|
+
out.append(s[i + 1])
|
|
122
|
+
i += 2
|
|
123
|
+
continue
|
|
124
|
+
# Trailing lone backslash: keep literal.
|
|
125
|
+
out.append("\\")
|
|
126
|
+
i += 1
|
|
127
|
+
continue
|
|
128
|
+
out.append(c)
|
|
129
|
+
i += 1
|
|
130
|
+
return "".join(out)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def split_bind(value: str) -> tuple[str, str | None]:
|
|
134
|
+
"""Split ``host_src:guest_dest`` into its two halves.
|
|
135
|
+
|
|
136
|
+
Scans left-to-right; a backslash escapes the next character (so ``\\:`` is
|
|
137
|
+
a literal colon, ``\\\\`` a literal backslash). Splits at the FIRST
|
|
138
|
+
UNESCAPED ``:``. With no unescaped colon, returns ``(unescaped(value),
|
|
139
|
+
None)`` for a plain scalar. Each returned half has its escapes resolved.
|
|
140
|
+
|
|
141
|
+
Linux container paths only — no Windows drive-letter / URI special-casing.
|
|
142
|
+
Use ``\\:`` to embed a literal colon in either half.
|
|
143
|
+
"""
|
|
144
|
+
i = 0
|
|
145
|
+
n = len(value)
|
|
146
|
+
while i < n:
|
|
147
|
+
c = value[i]
|
|
148
|
+
if c == "\\":
|
|
149
|
+
# Skip the escaped character.
|
|
150
|
+
i += 2
|
|
151
|
+
continue
|
|
152
|
+
if c == ":":
|
|
153
|
+
host = _unescape(value[:i])
|
|
154
|
+
guest = _unescape(value[i + 1 :])
|
|
155
|
+
return host, guest
|
|
156
|
+
i += 1
|
|
157
|
+
return _unescape(value), None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def expand_expr(
|
|
161
|
+
expr: str,
|
|
162
|
+
*,
|
|
163
|
+
space: Literal["host", "guest"],
|
|
164
|
+
ctx: ResolveCtx,
|
|
165
|
+
lookup: Callable[[str, tuple[str, ...]], str],
|
|
166
|
+
chain: tuple[str, ...] = (),
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Expand a single path/scalar expression (one bind half).
|
|
169
|
+
|
|
170
|
+
Single left-to-right scan emitting literal vs expanded segments. A
|
|
171
|
+
substituted value is a LEAF — it is not re-scanned (prevents
|
|
172
|
+
double-expansion / loops).
|
|
173
|
+
|
|
174
|
+
Grammar:
|
|
175
|
+
|
|
176
|
+
* **Escapes:** ``\\@``→``@``, ``\\$``→``$``, ``\\\\``→``\\``; a backslash
|
|
177
|
+
before any other char yields that char literally.
|
|
178
|
+
* **``~``:** ONLY when it is the FIRST character of *expr*. Expands to
|
|
179
|
+
``ctx.host_home`` (``space=="host"``) or :data:`GUEST_HOME`
|
|
180
|
+
(``space=="guest"``). A ``~`` elsewhere is literal.
|
|
181
|
+
* **``$VAR`` / ``${VAR}``:** name = ``[A-Za-z_][A-Za-z0-9_]*``. ``CRAB`` →
|
|
182
|
+
``ctx.crab_name``, ``WORKSET`` → ``ctx.workset_name``, ``XDG_*`` →
|
|
183
|
+
``ctx.xdg[name]``. Unknown names, or known names whose context value is
|
|
184
|
+
``None``/missing, raise :class:`SettingsError`.
|
|
185
|
+
* **``@``-ref:** ``@`` then a dotted name ``[A-Za-z0-9_]+(\\.[...])*``. The
|
|
186
|
+
ref name ends at the first char outside that set. Cycle-guarded against
|
|
187
|
+
*chain* and capped at :data:`MAX_REF_DEPTH`. Substitutes
|
|
188
|
+
``lookup(ref_name, chain + (ref_name,))``; the result is a leaf.
|
|
189
|
+
"""
|
|
190
|
+
out: list[str] = []
|
|
191
|
+
i = 0
|
|
192
|
+
n = len(expr)
|
|
193
|
+
|
|
194
|
+
# Leading ~ → home (only at position 0).
|
|
195
|
+
if n > 0 and expr[0] == "~":
|
|
196
|
+
out.append(ctx.host_home if space == "host" else GUEST_HOME)
|
|
197
|
+
i = 1
|
|
198
|
+
|
|
199
|
+
while i < n:
|
|
200
|
+
c = expr[i]
|
|
201
|
+
if c == "\\":
|
|
202
|
+
if i + 1 < n:
|
|
203
|
+
out.append(expr[i + 1])
|
|
204
|
+
i += 2
|
|
205
|
+
continue
|
|
206
|
+
out.append("\\")
|
|
207
|
+
i += 1
|
|
208
|
+
continue
|
|
209
|
+
if c == "$":
|
|
210
|
+
seg, i = _expand_var(expr, i, ctx)
|
|
211
|
+
out.append(seg)
|
|
212
|
+
continue
|
|
213
|
+
if c == "@":
|
|
214
|
+
seg, i = _expand_ref(expr, i, lookup, chain)
|
|
215
|
+
out.append(seg)
|
|
216
|
+
continue
|
|
217
|
+
out.append(c)
|
|
218
|
+
i += 1
|
|
219
|
+
|
|
220
|
+
return "".join(out)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _expand_var(expr: str, i: int, ctx: ResolveCtx) -> tuple[str, int]:
|
|
224
|
+
"""Expand a ``$VAR`` or ``${VAR}`` starting at index *i* (the ``$``)."""
|
|
225
|
+
n = len(expr)
|
|
226
|
+
braced = i + 1 < n and expr[i + 1] == "{"
|
|
227
|
+
name_start = i + 2 if braced else i + 1
|
|
228
|
+
m = _VAR_NAME_RE.match(expr, name_start)
|
|
229
|
+
if m is None:
|
|
230
|
+
raise SettingsError(f"Malformed variable reference at: {expr[i:]!r}")
|
|
231
|
+
name = m.group(0)
|
|
232
|
+
end = m.end()
|
|
233
|
+
if braced:
|
|
234
|
+
if end >= n or expr[end] != "}":
|
|
235
|
+
raise SettingsError(f"Unterminated ${{...}} reference: {expr[i:]!r}")
|
|
236
|
+
end += 1
|
|
237
|
+
return _resolve_var(name, ctx), end
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _resolve_var(name: str, ctx: ResolveCtx) -> str:
|
|
241
|
+
"""Resolve a variable name against the context namespace."""
|
|
242
|
+
if name == "CRAB":
|
|
243
|
+
if ctx.crab_name is None:
|
|
244
|
+
raise SettingsError("Variable $CRAB is not set in this context.")
|
|
245
|
+
return ctx.crab_name
|
|
246
|
+
if name == "WORKSET":
|
|
247
|
+
if ctx.workset_name is None:
|
|
248
|
+
raise SettingsError("Variable $WORKSET is not set in this context.")
|
|
249
|
+
return ctx.workset_name
|
|
250
|
+
if name.startswith("XDG_"):
|
|
251
|
+
if name not in ctx.xdg:
|
|
252
|
+
raise SettingsError(f"Variable ${name} is not set in this context.")
|
|
253
|
+
return ctx.xdg[name]
|
|
254
|
+
raise SettingsError(f"Unknown variable: ${name}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _expand_ref(
|
|
258
|
+
expr: str,
|
|
259
|
+
i: int,
|
|
260
|
+
lookup: Callable[[str, tuple[str, ...]], str],
|
|
261
|
+
chain: tuple[str, ...],
|
|
262
|
+
) -> tuple[str, int]:
|
|
263
|
+
"""Expand an ``@ref`` starting at index *i* (the ``@``)."""
|
|
264
|
+
m = _REF_NAME_RE.match(expr, i + 1)
|
|
265
|
+
if m is None:
|
|
266
|
+
raise SettingsError(f"Malformed @-reference at: {expr[i:]!r}")
|
|
267
|
+
ref_name = m.group(0)
|
|
268
|
+
end = m.end()
|
|
269
|
+
if ref_name in chain:
|
|
270
|
+
cycle = " -> ".join((*chain, ref_name))
|
|
271
|
+
raise SettingsError(f"Cyclic @-reference: {cycle}")
|
|
272
|
+
if len(chain) >= MAX_REF_DEPTH:
|
|
273
|
+
raise SettingsError(
|
|
274
|
+
f"@-reference depth cap ({MAX_REF_DEPTH}) exceeded resolving "
|
|
275
|
+
f"'{ref_name}'."
|
|
276
|
+
)
|
|
277
|
+
return lookup(ref_name, (*chain, ref_name)), end
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def resolve_value(
|
|
281
|
+
key: str,
|
|
282
|
+
*,
|
|
283
|
+
levels: list[LevelView],
|
|
284
|
+
ctx: ResolveCtx,
|
|
285
|
+
lookup: Callable[[str, tuple[str, ...]], str],
|
|
286
|
+
) -> ResolvedValue | _Unset:
|
|
287
|
+
"""Resolve *key* by precedence over *levels* (most-specific first).
|
|
288
|
+
|
|
289
|
+
Returns the raw winning literal (unexpanded) with provenance, or
|
|
290
|
+
:data:`UNSET`. Does NOT expand — the caller expands the result via
|
|
291
|
+
:func:`expand_expr` with the appropriate *space*. *ctx*/*lookup* are
|
|
292
|
+
accepted for signature stability; the pure precedence logic does not use
|
|
293
|
+
them.
|
|
294
|
+
"""
|
|
295
|
+
del ctx, lookup # accepted for signature stability; unused here.
|
|
296
|
+
|
|
297
|
+
# Pass 1: explicit set values, most-specific first.
|
|
298
|
+
for level in levels:
|
|
299
|
+
if key in level.values:
|
|
300
|
+
val = level.values[key]
|
|
301
|
+
if val == "":
|
|
302
|
+
return ResolvedValue(value="", level=level.name, terminal=True)
|
|
303
|
+
return ResolvedValue(value=val, level=level.name)
|
|
304
|
+
|
|
305
|
+
# Pass 2: declared defaults, most-specific first.
|
|
306
|
+
for level in levels:
|
|
307
|
+
if key in level.defaults:
|
|
308
|
+
return ResolvedValue(
|
|
309
|
+
value=level.defaults[key], level=level.name, is_default=True
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return UNSET
|