kanon-cli 1.0.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.
- kanon_cli/__init__.py +3 -0
- kanon_cli/__main__.py +5 -0
- kanon_cli/catalog/kanon/.kanon +35 -0
- kanon_cli/catalog/kanon/kanon-readme.md +155 -0
- kanon_cli/cli.py +67 -0
- kanon_cli/commands/__init__.py +0 -0
- kanon_cli/commands/bootstrap.py +70 -0
- kanon_cli/commands/clean.py +40 -0
- kanon_cli/commands/install.py +186 -0
- kanon_cli/commands/validate.py +133 -0
- kanon_cli/constants.py +29 -0
- kanon_cli/core/__init__.py +0 -0
- kanon_cli/core/bootstrap.py +107 -0
- kanon_cli/core/catalog.py +133 -0
- kanon_cli/core/clean.py +111 -0
- kanon_cli/core/install.py +349 -0
- kanon_cli/core/kanonenv.py +322 -0
- kanon_cli/core/marketplace.py +433 -0
- kanon_cli/core/marketplace_validator.py +237 -0
- kanon_cli/core/xml_validator.py +94 -0
- kanon_cli/version.py +189 -0
- kanon_cli-1.0.0.dist-info/METADATA +844 -0
- kanon_cli-1.0.0.dist-info/RECORD +26 -0
- kanon_cli-1.0.0.dist-info/WHEEL +4 -0
- kanon_cli-1.0.0.dist-info/entry_points.txt +2 -0
- kanon_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Multi-source Kanon install business logic.
|
|
2
|
+
|
|
3
|
+
Parses .kanon, validates sources, creates isolated source workspaces
|
|
4
|
+
under ``.kanon-data/sources/<name>/``, runs ``repo init``/``envsubst``/``sync``
|
|
5
|
+
per source, aggregates symlinks into ``.packages/``, detects collisions,
|
|
6
|
+
updates ``.gitignore``, and optionally installs marketplace plugins.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import pathlib
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from kanon_cli.core.marketplace import install_marketplace_plugins
|
|
16
|
+
from kanon_cli.core.kanonenv import parse_kanonenv
|
|
17
|
+
from kanon_cli.version import resolve_version
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_source_dirs(
|
|
21
|
+
source_names: list[str],
|
|
22
|
+
base_dir: pathlib.Path,
|
|
23
|
+
) -> dict[str, pathlib.Path]:
|
|
24
|
+
"""Create .kanon-data/sources/<name>/ directories for each source.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
source_names: Ordered list of source names (auto-discovered, alphabetical).
|
|
28
|
+
base_dir: Project root directory.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Dict mapping source name to its directory path.
|
|
32
|
+
"""
|
|
33
|
+
result: dict[str, pathlib.Path] = {}
|
|
34
|
+
for name in source_names:
|
|
35
|
+
source_dir = base_dir / ".kanon-data" / "sources" / name
|
|
36
|
+
source_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
result[name] = source_dir
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_repo_init(
|
|
42
|
+
source_dir: pathlib.Path,
|
|
43
|
+
url: str,
|
|
44
|
+
revision: str,
|
|
45
|
+
manifest_path: str,
|
|
46
|
+
repo_rev: str = "",
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Run ``repo init -u <URL> -b <REVISION> -m <PATH>`` in source directory.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
source_dir: Path to ``.kanon-data/sources/<name>/``.
|
|
52
|
+
url: Repository URL for repo init.
|
|
53
|
+
revision: Branch/tag/revision for repo init.
|
|
54
|
+
manifest_path: Manifest file path for repo init.
|
|
55
|
+
repo_rev: Repo tool version tag for ``--repo-rev``.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
SystemExit: If repo init exits non-zero.
|
|
59
|
+
"""
|
|
60
|
+
cmd = [
|
|
61
|
+
"repo",
|
|
62
|
+
"--color=never",
|
|
63
|
+
"init",
|
|
64
|
+
"--no-repo-verify",
|
|
65
|
+
"-u",
|
|
66
|
+
url,
|
|
67
|
+
"-b",
|
|
68
|
+
revision,
|
|
69
|
+
"-m",
|
|
70
|
+
manifest_path,
|
|
71
|
+
]
|
|
72
|
+
if repo_rev:
|
|
73
|
+
cmd.extend(["--repo-rev", repo_rev])
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
cmd,
|
|
76
|
+
cwd=source_dir,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
text=True,
|
|
79
|
+
check=False,
|
|
80
|
+
input="n\n",
|
|
81
|
+
)
|
|
82
|
+
if result.returncode != 0:
|
|
83
|
+
print(
|
|
84
|
+
f"Error: repo init failed in {source_dir}: {result.stderr}",
|
|
85
|
+
file=sys.stderr,
|
|
86
|
+
)
|
|
87
|
+
sys.exit(1)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_repo_envsubst(
|
|
91
|
+
source_dir: pathlib.Path,
|
|
92
|
+
env_vars: dict[str, str],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Run ``repo envsubst`` in source directory with exported env vars.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
source_dir: Path to ``.kanon-data/sources/<name>/``.
|
|
98
|
+
env_vars: Environment variables to export (GITBASE, CLAUDE_MARKETPLACES_DIR).
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
SystemExit: If repo envsubst exits non-zero.
|
|
102
|
+
"""
|
|
103
|
+
run_env = {**os.environ, **env_vars}
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
["repo", "envsubst"],
|
|
106
|
+
cwd=source_dir,
|
|
107
|
+
env=run_env,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
check=False,
|
|
111
|
+
)
|
|
112
|
+
if result.returncode != 0:
|
|
113
|
+
print(
|
|
114
|
+
f"Error: repo envsubst failed in {source_dir}: {result.stderr}",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
sys.exit(1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run_repo_sync(source_dir: pathlib.Path) -> None:
|
|
121
|
+
"""Run ``repo sync`` in source directory.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
source_dir: Path to ``.kanon-data/sources/<name>/``.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
SystemExit: If repo sync exits non-zero.
|
|
128
|
+
"""
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["repo", "sync"],
|
|
131
|
+
cwd=source_dir,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
check=False,
|
|
135
|
+
)
|
|
136
|
+
if result.returncode != 0:
|
|
137
|
+
print(
|
|
138
|
+
f"Error: repo sync failed in {source_dir}: {result.stderr}",
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def aggregate_symlinks(
|
|
145
|
+
source_names: list[str],
|
|
146
|
+
base_dir: pathlib.Path,
|
|
147
|
+
) -> dict[str, str]:
|
|
148
|
+
"""Aggregate packages from all sources into ``.packages/``.
|
|
149
|
+
|
|
150
|
+
For each ``.kanon-data/sources/<name>/.packages/*``, creates a symlink in
|
|
151
|
+
the top-level ``.packages/`` directory. Detects collisions when two
|
|
152
|
+
sources produce the same package name.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
source_names: Ordered list of source names.
|
|
156
|
+
base_dir: Project root directory.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Dict mapping package name to source name.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
SystemExit: If two sources produce the same package name.
|
|
163
|
+
"""
|
|
164
|
+
packages_dir = base_dir / ".packages"
|
|
165
|
+
packages_dir.mkdir(exist_ok=True)
|
|
166
|
+
|
|
167
|
+
package_owners: dict[str, str] = {}
|
|
168
|
+
|
|
169
|
+
for name in source_names:
|
|
170
|
+
source_packages = base_dir / ".kanon-data" / "sources" / name / ".packages"
|
|
171
|
+
if not source_packages.exists():
|
|
172
|
+
continue
|
|
173
|
+
for pkg in source_packages.iterdir():
|
|
174
|
+
pkg_name = pkg.name
|
|
175
|
+
if pkg_name in package_owners:
|
|
176
|
+
print(
|
|
177
|
+
f"Error: Package collision for '{pkg_name}': "
|
|
178
|
+
f"provided by both '{package_owners[pkg_name]}' "
|
|
179
|
+
f"and '{name}'",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
package_owners[pkg_name] = name
|
|
184
|
+
link_path = packages_dir / pkg_name
|
|
185
|
+
if link_path.exists() or link_path.is_symlink():
|
|
186
|
+
link_path.unlink()
|
|
187
|
+
link_path.symlink_to(pkg.resolve())
|
|
188
|
+
|
|
189
|
+
return package_owners
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def update_gitignore(
|
|
193
|
+
base_dir: pathlib.Path,
|
|
194
|
+
entries: list[str] | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Ensure ``.gitignore`` contains the required entries.
|
|
197
|
+
|
|
198
|
+
Creates ``.gitignore`` if it does not exist. Appends missing entries
|
|
199
|
+
without duplicating existing ones.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
base_dir: Project root directory.
|
|
203
|
+
entries: List of gitignore entries to ensure. Defaults to
|
|
204
|
+
``.packages/`` and ``.kanon-data/``.
|
|
205
|
+
"""
|
|
206
|
+
gitignore = base_dir / ".gitignore"
|
|
207
|
+
required_entries = entries if entries is not None else [".packages/", ".kanon-data/"]
|
|
208
|
+
|
|
209
|
+
existing_content = ""
|
|
210
|
+
if gitignore.exists():
|
|
211
|
+
existing_content = gitignore.read_text()
|
|
212
|
+
|
|
213
|
+
existing_lines = existing_content.splitlines()
|
|
214
|
+
missing = [entry for entry in required_entries if entry not in existing_lines]
|
|
215
|
+
|
|
216
|
+
if missing:
|
|
217
|
+
with gitignore.open("a") as f:
|
|
218
|
+
if existing_content and not existing_content.endswith("\n"):
|
|
219
|
+
f.write("\n")
|
|
220
|
+
for entry in missing:
|
|
221
|
+
f.write(f"{entry}\n")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def prepare_marketplace_dir(marketplace_dir: pathlib.Path) -> None:
|
|
225
|
+
"""Create and clean the marketplace directory for pre-sync setup.
|
|
226
|
+
|
|
227
|
+
Creates the directory if it does not exist, then removes all
|
|
228
|
+
contents for a clean slate before sync.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
marketplace_dir: Path to CLAUDE_MARKETPLACES_DIR.
|
|
232
|
+
"""
|
|
233
|
+
marketplace_dir.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
for item in marketplace_dir.iterdir():
|
|
235
|
+
if item.is_symlink() or not item.is_dir():
|
|
236
|
+
item.unlink()
|
|
237
|
+
else:
|
|
238
|
+
shutil.rmtree(item)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _print_package_summary(
|
|
242
|
+
package_owners: dict[str, str],
|
|
243
|
+
source_names: list[str],
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Print a structured summary of synced packages grouped by source.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
package_owners: Dict mapping package name to source name.
|
|
249
|
+
source_names: Ordered list of source names.
|
|
250
|
+
"""
|
|
251
|
+
if not package_owners:
|
|
252
|
+
print("\nkanon install: no packages synced.")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Group packages by source, preserving source order
|
|
256
|
+
by_source: dict[str, list[str]] = {name: [] for name in source_names}
|
|
257
|
+
for pkg_name, source_name in sorted(package_owners.items()):
|
|
258
|
+
by_source[source_name].append(pkg_name)
|
|
259
|
+
|
|
260
|
+
total = len(package_owners)
|
|
261
|
+
print(f"\nkanon install: {total} packages synced to .packages/")
|
|
262
|
+
for source_name in source_names:
|
|
263
|
+
pkgs = by_source[source_name]
|
|
264
|
+
if not pkgs:
|
|
265
|
+
continue
|
|
266
|
+
print(f"\n [{source_name}] ({len(pkgs)} packages)")
|
|
267
|
+
for pkg in pkgs:
|
|
268
|
+
print(f" - {pkg}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def install(kanonenv_path: pathlib.Path) -> None:
|
|
272
|
+
"""Execute the full Kanon install lifecycle.
|
|
273
|
+
|
|
274
|
+
Steps:
|
|
275
|
+
1. Parse .kanon and validate sources
|
|
276
|
+
2. If KANON_MARKETPLACE_INSTALL=true: create and clean marketplace dir
|
|
277
|
+
3. For each source: mkdir, repo init, envsubst, sync
|
|
278
|
+
4. Aggregate symlinks into .packages/
|
|
279
|
+
5. Update .gitignore
|
|
280
|
+
6. If KANON_MARKETPLACE_INSTALL=true: run install script
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
kanonenv_path: Path to the .kanon configuration file.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
SystemExit: On any failure during the install process.
|
|
287
|
+
"""
|
|
288
|
+
print(f"kanon install: parsing {kanonenv_path}...")
|
|
289
|
+
config = parse_kanonenv(kanonenv_path)
|
|
290
|
+
base_dir = kanonenv_path.parent
|
|
291
|
+
source_names = config["KANON_SOURCES"]
|
|
292
|
+
sources = config["sources"]
|
|
293
|
+
marketplace_install = config["KANON_MARKETPLACE_INSTALL"]
|
|
294
|
+
globals_dict = config["globals"]
|
|
295
|
+
|
|
296
|
+
marketplace_dir_str = globals_dict.get("CLAUDE_MARKETPLACES_DIR", "")
|
|
297
|
+
|
|
298
|
+
if marketplace_install and not marketplace_dir_str:
|
|
299
|
+
print(
|
|
300
|
+
"Error: KANON_MARKETPLACE_INSTALL=true but CLAUDE_MARKETPLACES_DIR is not defined in .kanon",
|
|
301
|
+
file=sys.stderr,
|
|
302
|
+
)
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
|
|
305
|
+
if marketplace_install:
|
|
306
|
+
marketplace_dir = pathlib.Path(marketplace_dir_str)
|
|
307
|
+
print("kanon install: preparing marketplace directory...")
|
|
308
|
+
prepare_marketplace_dir(marketplace_dir)
|
|
309
|
+
|
|
310
|
+
repo_rev = globals_dict.get("REPO_REV", "")
|
|
311
|
+
|
|
312
|
+
env_vars: dict[str, str] = {}
|
|
313
|
+
if "GITBASE" in globals_dict:
|
|
314
|
+
env_vars["GITBASE"] = globals_dict["GITBASE"]
|
|
315
|
+
if marketplace_dir_str:
|
|
316
|
+
env_vars["CLAUDE_MARKETPLACES_DIR"] = marketplace_dir_str
|
|
317
|
+
|
|
318
|
+
source_dirs = create_source_dirs(source_names, base_dir)
|
|
319
|
+
|
|
320
|
+
for name in source_names:
|
|
321
|
+
source_dir = source_dirs[name]
|
|
322
|
+
source_data = sources[name]
|
|
323
|
+
print(f"kanon install: syncing source '{name}'...")
|
|
324
|
+
resolved_revision = resolve_version(source_data["url"], source_data["revision"])
|
|
325
|
+
print(f" repo init ({source_data['path']})...")
|
|
326
|
+
run_repo_init(
|
|
327
|
+
source_dir,
|
|
328
|
+
source_data["url"],
|
|
329
|
+
resolved_revision,
|
|
330
|
+
source_data["path"],
|
|
331
|
+
repo_rev,
|
|
332
|
+
)
|
|
333
|
+
print(" repo envsubst...")
|
|
334
|
+
run_repo_envsubst(source_dir, env_vars)
|
|
335
|
+
print(" repo sync...")
|
|
336
|
+
run_repo_sync(source_dir)
|
|
337
|
+
|
|
338
|
+
print("kanon install: aggregating packages into .packages/...")
|
|
339
|
+
package_owners = aggregate_symlinks(source_names, base_dir)
|
|
340
|
+
update_gitignore(base_dir)
|
|
341
|
+
|
|
342
|
+
_print_package_summary(package_owners, source_names)
|
|
343
|
+
|
|
344
|
+
if marketplace_install:
|
|
345
|
+
print("\nkanon install: installing marketplace plugins...")
|
|
346
|
+
marketplace_dir = pathlib.Path(marketplace_dir_str)
|
|
347
|
+
install_marketplace_plugins(marketplace_dir)
|
|
348
|
+
|
|
349
|
+
print("\nkanon install: done.")
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Multi-source .kanon file parser.
|
|
2
|
+
|
|
3
|
+
Parses KEY=VALUE configuration files used by Kanon bootstrap. The .kanon
|
|
4
|
+
format supports:
|
|
5
|
+
- Comments (lines starting with #) and blank lines
|
|
6
|
+
- Shell variable expansion (``${VAR}``) resolved from environment
|
|
7
|
+
- Environment variable overrides (env vars take precedence over file values)
|
|
8
|
+
- Auto-discovered named source groups from ``KANON_SOURCE_<name>_URL`` keys
|
|
9
|
+
- Boolean parsing for KANON_MARKETPLACE_INSTALL
|
|
10
|
+
|
|
11
|
+
Source names are auto-discovered by scanning for keys matching the
|
|
12
|
+
``KANON_SOURCE_<name>_URL`` pattern. Names are sorted alphabetically for
|
|
13
|
+
deterministic ordering. Each discovered source must also define
|
|
14
|
+
``KANON_SOURCE_<name>_REVISION`` and ``KANON_SOURCE_<name>_PATH``.
|
|
15
|
+
|
|
16
|
+
The parser reads the file, applies environment overrides, expands shell
|
|
17
|
+
variables, validates required fields, and returns a structured dict.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
from kanon_cli.constants import (
|
|
25
|
+
SHELL_VAR_PATTERN,
|
|
26
|
+
SOURCE_PREFIX,
|
|
27
|
+
SOURCE_SUFFIXES,
|
|
28
|
+
SUFFIX_TO_KEY,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_kanonenv(path: pathlib.Path) -> dict:
|
|
33
|
+
"""Parse a .kanon file into a structured configuration dict.
|
|
34
|
+
|
|
35
|
+
Reads KEY=VALUE pairs from the file, applies environment variable
|
|
36
|
+
overrides, expands shell variables (``${VAR}``), auto-discovers
|
|
37
|
+
source names from ``KANON_SOURCE_<name>_URL`` keys, and groups
|
|
38
|
+
source-specific variables.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
path: Path to the .kanon file.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A dict with the following keys:
|
|
45
|
+
|
|
46
|
+
- ``KANON_SOURCES``: list of source names (auto-discovered,
|
|
47
|
+
sorted alphabetically)
|
|
48
|
+
- ``KANON_MARKETPLACE_INSTALL``: bool (defaults to False)
|
|
49
|
+
- ``sources``: dict mapping each source name to a dict with
|
|
50
|
+
``url``, ``revision``, and ``path`` keys
|
|
51
|
+
- ``globals``: dict of all other KEY=VALUE pairs
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
FileNotFoundError: If the file does not exist.
|
|
55
|
+
ValueError: If KANON_SOURCES is explicitly set (no longer supported),
|
|
56
|
+
if no sources are discovered, if a named source is missing
|
|
57
|
+
required variables (URL, REVISION, PATH), or if a shell
|
|
58
|
+
variable reference cannot be resolved.
|
|
59
|
+
"""
|
|
60
|
+
if not path.exists():
|
|
61
|
+
msg = f".kanon file not found: {path}"
|
|
62
|
+
raise FileNotFoundError(msg)
|
|
63
|
+
|
|
64
|
+
raw_vars = _read_key_value_pairs(path)
|
|
65
|
+
merged = _apply_env_overrides(raw_vars)
|
|
66
|
+
expanded = _expand_shell_variables(merged)
|
|
67
|
+
|
|
68
|
+
return _build_result(expanded)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_key_value_pairs(path: pathlib.Path) -> dict[str, str]:
|
|
72
|
+
"""Read KEY=VALUE pairs from a file, ignoring comments and blanks.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: Path to the .kanon file.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict of raw string key-value pairs.
|
|
79
|
+
"""
|
|
80
|
+
result: dict[str, str] = {}
|
|
81
|
+
for line in path.read_text().splitlines():
|
|
82
|
+
stripped = line.strip()
|
|
83
|
+
if not stripped or stripped.startswith("#"):
|
|
84
|
+
continue
|
|
85
|
+
if "=" not in stripped:
|
|
86
|
+
continue
|
|
87
|
+
key, _, value = stripped.partition("=")
|
|
88
|
+
result[key.strip()] = value.strip()
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_env_overrides(raw_vars: dict[str, str]) -> dict[str, str]:
|
|
93
|
+
"""Override file values with environment variables of the same name.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
raw_vars: Dict of KEY=VALUE pairs from the file.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dict with environment overrides applied.
|
|
100
|
+
"""
|
|
101
|
+
merged = dict(raw_vars)
|
|
102
|
+
for key in merged:
|
|
103
|
+
env_value = os.environ.get(key)
|
|
104
|
+
if env_value is not None:
|
|
105
|
+
merged[key] = env_value
|
|
106
|
+
# Also check for env vars that define source groups not in the file
|
|
107
|
+
for key, value in os.environ.items():
|
|
108
|
+
if key.startswith(SOURCE_PREFIX) and key not in merged:
|
|
109
|
+
merged[key] = value
|
|
110
|
+
return merged
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _expand_shell_variables(merged: dict[str, str]) -> dict[str, str]:
|
|
114
|
+
"""Expand ``${VAR}`` references in values using environment variables.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
merged: Dict of KEY=VALUE pairs with overrides applied.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dict with shell variables expanded.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If a referenced variable is not defined in
|
|
124
|
+
the environment.
|
|
125
|
+
"""
|
|
126
|
+
expanded: dict[str, str] = {}
|
|
127
|
+
for key, value in merged.items():
|
|
128
|
+
expanded[key] = _expand_value(value)
|
|
129
|
+
return expanded
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _expand_value(value: str) -> str:
|
|
133
|
+
"""Expand all ``${VAR}`` references in a single value.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
value: The raw value string potentially containing ${VAR}.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The value with all variables expanded.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValueError: If a referenced variable is not in the environment.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def _replace(match: re.Match) -> str:
|
|
146
|
+
var_name = match.group(1)
|
|
147
|
+
env_val = os.environ.get(var_name)
|
|
148
|
+
if env_val is None:
|
|
149
|
+
msg = f"Undefined shell variable '${{{var_name}}}' referenced in .kanon value"
|
|
150
|
+
raise ValueError(msg)
|
|
151
|
+
return env_val
|
|
152
|
+
|
|
153
|
+
return SHELL_VAR_PATTERN.sub(_replace, value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _discover_source_names(expanded: dict[str, str]) -> list[str]:
|
|
157
|
+
"""Auto-discover source names from ``KANON_SOURCE_<name>_URL`` keys.
|
|
158
|
+
|
|
159
|
+
Scans all keys for the ``KANON_SOURCE_<name>_URL`` pattern, extracts
|
|
160
|
+
the ``<name>`` portion, and returns a sorted list for deterministic
|
|
161
|
+
ordering.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
expanded: Dict of expanded KEY=VALUE pairs.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Sorted list of discovered source names.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If no ``KANON_SOURCE_<name>_URL`` keys are found.
|
|
171
|
+
"""
|
|
172
|
+
url_suffix = "_URL"
|
|
173
|
+
names: list[str] = []
|
|
174
|
+
for key in expanded:
|
|
175
|
+
if key.startswith(SOURCE_PREFIX) and key.endswith(url_suffix):
|
|
176
|
+
name = key[len(SOURCE_PREFIX) : -len(url_suffix)]
|
|
177
|
+
if name:
|
|
178
|
+
names.append(name)
|
|
179
|
+
|
|
180
|
+
if not names:
|
|
181
|
+
msg = (
|
|
182
|
+
"No sources found. Define at least one source using "
|
|
183
|
+
"KANON_SOURCE_<name>_URL, KANON_SOURCE_<name>_REVISION, "
|
|
184
|
+
"and KANON_SOURCE_<name>_PATH variables in .kanon"
|
|
185
|
+
)
|
|
186
|
+
raise ValueError(msg)
|
|
187
|
+
|
|
188
|
+
return sorted(names)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build_result(expanded: dict[str, str]) -> dict:
|
|
192
|
+
"""Build the structured result dict from expanded variables.
|
|
193
|
+
|
|
194
|
+
Auto-discovers source names from ``KANON_SOURCE_<name>_URL`` keys
|
|
195
|
+
and sorts them alphabetically. Raises an error if ``KANON_SOURCES``
|
|
196
|
+
is explicitly defined (no longer supported).
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
expanded: Dict of expanded KEY=VALUE pairs.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Structured dict with KANON_SOURCES (auto-discovered), sources,
|
|
203
|
+
globals, and KANON_MARKETPLACE_INSTALL.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ValueError: If KANON_SOURCES is explicitly set, if no sources
|
|
207
|
+
are discovered, or if a named source is missing required
|
|
208
|
+
variables.
|
|
209
|
+
"""
|
|
210
|
+
if "KANON_SOURCES" in expanded:
|
|
211
|
+
msg = (
|
|
212
|
+
"KANON_SOURCES is no longer supported. Source names are "
|
|
213
|
+
"auto-discovered from KANON_SOURCE_<name>_URL variables. "
|
|
214
|
+
"Remove the KANON_SOURCES line from your .kanon file."
|
|
215
|
+
)
|
|
216
|
+
raise ValueError(msg)
|
|
217
|
+
|
|
218
|
+
source_names = _discover_source_names(expanded)
|
|
219
|
+
sources = _extract_sources(expanded, source_names)
|
|
220
|
+
globals_dict = _extract_globals(expanded, source_names)
|
|
221
|
+
marketplace_install = _parse_bool(expanded.get("KANON_MARKETPLACE_INSTALL", "false"))
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"KANON_SOURCES": source_names,
|
|
225
|
+
"KANON_MARKETPLACE_INSTALL": marketplace_install,
|
|
226
|
+
"sources": sources,
|
|
227
|
+
"globals": globals_dict,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def validate_sources(
|
|
232
|
+
expanded: dict[str, str],
|
|
233
|
+
source_names: list[str],
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Validate that all named sources have required variables.
|
|
236
|
+
|
|
237
|
+
Each source name in ``source_names`` must have three corresponding
|
|
238
|
+
variables defined in ``expanded``:
|
|
239
|
+
- ``KANON_SOURCE_<name>_URL``
|
|
240
|
+
- ``KANON_SOURCE_<name>_REVISION``
|
|
241
|
+
- ``KANON_SOURCE_<name>_PATH``
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
expanded: Dict of expanded KEY=VALUE pairs from the .kanon file.
|
|
245
|
+
source_names: List of source names (auto-discovered, alphabetical).
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
ValueError: If any named source is missing a required variable.
|
|
249
|
+
The error message includes both the source name and the
|
|
250
|
+
missing variable name for actionable diagnostics.
|
|
251
|
+
"""
|
|
252
|
+
for name in source_names:
|
|
253
|
+
for suffix in SOURCE_SUFFIXES:
|
|
254
|
+
var_name = f"{SOURCE_PREFIX}{name}{suffix}"
|
|
255
|
+
if var_name not in expanded:
|
|
256
|
+
msg = f"Missing required variable '{var_name}' for source '{name}'"
|
|
257
|
+
raise ValueError(msg)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _extract_sources(
|
|
261
|
+
expanded: dict[str, str],
|
|
262
|
+
source_names: list[str],
|
|
263
|
+
) -> dict[str, dict[str, str]]:
|
|
264
|
+
"""Extract named source groups after validation.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
expanded: Dict of expanded KEY=VALUE pairs.
|
|
268
|
+
source_names: List of source names (auto-discovered, alphabetical).
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Dict mapping source name to {url, revision, path}.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
ValueError: If a source is missing a required variable.
|
|
275
|
+
"""
|
|
276
|
+
validate_sources(expanded, source_names)
|
|
277
|
+
sources: dict[str, dict[str, str]] = {}
|
|
278
|
+
for name in source_names:
|
|
279
|
+
source_data: dict[str, str] = {}
|
|
280
|
+
for suffix in SOURCE_SUFFIXES:
|
|
281
|
+
var_name = f"{SOURCE_PREFIX}{name}{suffix}"
|
|
282
|
+
result_key = SUFFIX_TO_KEY[suffix]
|
|
283
|
+
source_data[result_key] = expanded[var_name]
|
|
284
|
+
sources[name] = source_data
|
|
285
|
+
return sources
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_globals(
|
|
289
|
+
expanded: dict[str, str],
|
|
290
|
+
source_names: list[str],
|
|
291
|
+
) -> dict[str, str]:
|
|
292
|
+
"""Extract non-source, non-special variables as globals.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
expanded: Dict of expanded KEY=VALUE pairs.
|
|
296
|
+
source_names: List of source names (auto-discovered, alphabetical).
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Dict of global variables (excludes KANON_MARKETPLACE_INSTALL
|
|
300
|
+
and source-specific variables).
|
|
301
|
+
"""
|
|
302
|
+
source_keys: set[str] = set()
|
|
303
|
+
for name in source_names:
|
|
304
|
+
for suffix in SOURCE_SUFFIXES:
|
|
305
|
+
source_keys.add(f"{SOURCE_PREFIX}{name}{suffix}")
|
|
306
|
+
|
|
307
|
+
special_keys = {"KANON_MARKETPLACE_INSTALL"}
|
|
308
|
+
exclude = source_keys | special_keys
|
|
309
|
+
|
|
310
|
+
return {k: v for k, v in expanded.items() if k not in exclude}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _parse_bool(value: str) -> bool:
|
|
314
|
+
"""Parse a string boolean value (case-insensitive).
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
value: String value to parse ('true' or 'false').
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
True if value is 'true' (case-insensitive), False otherwise.
|
|
321
|
+
"""
|
|
322
|
+
return value.strip().lower() == "true"
|