Jarvis-Forge 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.
- jarvis_forge/__init__.py +4 -0
- jarvis_forge/__main__.py +4 -0
- jarvis_forge/apt_cli.py +234 -0
- jarvis_forge/catalog.py +171 -0
- jarvis_forge/cli.py +354 -0
- jarvis_forge/comparison.py +90 -0
- jarvis_forge/downloads.py +87 -0
- jarvis_forge/env_cli.py +65 -0
- jarvis_forge/environment.py +158 -0
- jarvis_forge/environment_registry.py +252 -0
- jarvis_forge/examples/smoke.yaml +24 -0
- jarvis_forge/official.py +366 -0
- jarvis_forge/packages.py +498 -0
- jarvis_forge/recipes/eigen.yaml +22 -0
- jarvis_forge/recipes/redis.yaml +20 -0
- jarvis_forge/runner.py +203 -0
- jarvis_forge/schemas/recipe.schema.json +46 -0
- jarvis_forge/schemas/suite.schema.json +143 -0
- jarvis_forge/storage.py +151 -0
- jarvis_forge/suite.py +144 -0
- jarvis_forge-1.0.0.dist-info/METADATA +213 -0
- jarvis_forge-1.0.0.dist-info/RECORD +26 -0
- jarvis_forge-1.0.0.dist-info/WHEEL +5 -0
- jarvis_forge-1.0.0.dist-info/entry_points.txt +2 -0
- jarvis_forge-1.0.0.dist-info/licenses/LICENSE +22 -0
- jarvis_forge-1.0.0.dist-info/top_level.txt +1 -0
jarvis_forge/__init__.py
ADDED
jarvis_forge/__main__.py
ADDED
jarvis_forge/apt_cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""APT-shaped commands backed by Forge recipes, never the system apt binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shlex
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from packaging.version import Version
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from .catalog import Catalog, requirement, source_config
|
|
14
|
+
from .packages import (edit_sources, environment_path, install, installed, plan_install,
|
|
15
|
+
plan_remove, remove, update)
|
|
16
|
+
from .storage import ForgeError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(invoke_without_command=True, no_args_is_help=False,
|
|
20
|
+
help="Manage source-built packages in your Forge home without sudo.\n\n"
|
|
21
|
+
"Command help: jforge apt COMMAND -h")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _emit(ctx, command, **values):
|
|
25
|
+
from .cli import emit
|
|
26
|
+
return emit(ctx, "apt " + command, **values)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def apt(ctx: typer.Context):
|
|
31
|
+
if ctx.invoked_subcommand is None:
|
|
32
|
+
if ctx.obj.as_json:
|
|
33
|
+
_emit(ctx, "help", commands=sorted(ctx.command.commands))
|
|
34
|
+
else:
|
|
35
|
+
typer.echo(ctx.get_help())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command("list")
|
|
39
|
+
def list_packages(ctx: typer.Context,
|
|
40
|
+
pattern: str = typer.Argument("*", help="Package name pattern.", metavar="<pattern>"),
|
|
41
|
+
installed_only: bool = typer.Option(False, "--installed", help="Show recorded installations only."),
|
|
42
|
+
upgradable: bool = typer.Option(False, "--upgradable", help="Show active packages with newer recipes.")):
|
|
43
|
+
"""List available or installed user-local packages."""
|
|
44
|
+
records = installed(ctx.obj.home)
|
|
45
|
+
catalog = Catalog(ctx.obj.home)
|
|
46
|
+
if installed_only:
|
|
47
|
+
packages = records
|
|
48
|
+
else:
|
|
49
|
+
packages = []
|
|
50
|
+
for recipe in catalog.list():
|
|
51
|
+
matches = [r for r in records if r["name"] == recipe["name"] and r["version"] == recipe["version"]]
|
|
52
|
+
active = next((r for r in matches if r["active"]), None)
|
|
53
|
+
packages.append({"name": recipe["name"], "version": recipe["version"],
|
|
54
|
+
"status": "installed" if matches else "available", "active": bool(active),
|
|
55
|
+
"prefix": active["prefix"] if active else "", "description": recipe["description"]})
|
|
56
|
+
if upgradable:
|
|
57
|
+
packages = [r for r in records if r["active"] and any(
|
|
58
|
+
p["name"] == r["name"] and (Version(p["version"]) > Version(r["version"]) or
|
|
59
|
+
(Version(p["version"]) == Version(r["version"]) and p["source"] != r.get("source")))
|
|
60
|
+
for p in catalog.list())]
|
|
61
|
+
_emit(ctx, "list", packages=[p for p in packages if fnmatch.fnmatchcase(p["name"], pattern)])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.command()
|
|
65
|
+
def search(ctx: typer.Context,
|
|
66
|
+
query: str = typer.Argument(..., help="Match package names and descriptions.", metavar="<query>")):
|
|
67
|
+
"""Search the recipe catalog without downloading or installing."""
|
|
68
|
+
_emit(ctx, "search", packages=[{"name": p["name"], "version": p["version"], "description": p["description"]}
|
|
69
|
+
for p in Catalog(ctx.obj.home).list(query)])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def show(ctx: typer.Context,
|
|
74
|
+
package: str = typer.Argument(..., help="Package or package=version.", metavar="<package>")):
|
|
75
|
+
"""Show recipe details, dependencies and installed locations."""
|
|
76
|
+
req = requirement(package)
|
|
77
|
+
choices = [p for p in Catalog(ctx.obj.home).list() if p["name"] == req.name and Version(p["version"]) in req.specifier]
|
|
78
|
+
if not choices:
|
|
79
|
+
raise ForgeError(f"No recipe matches {package}.", hint="jforge apt search <query>")
|
|
80
|
+
recipe = max(choices, key=lambda p: Version(p["version"]))
|
|
81
|
+
_emit(ctx, "show", package=recipe, installed_instances=[r for r in installed(ctx.obj.home) if r["name"] == req.name])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _install(ctx, packages, jobs, timeout, dry_run, mode):
|
|
85
|
+
from .cli import _console
|
|
86
|
+
if dry_run:
|
|
87
|
+
result = plan_install(ctx.obj.home, packages or [], jobs=jobs, mode=mode)
|
|
88
|
+
_emit(ctx, mode, dry_run=True, plan=result)
|
|
89
|
+
else:
|
|
90
|
+
result = install(ctx.obj.home, packages or [], jobs=jobs, timeout=timeout, mode=mode,
|
|
91
|
+
progress=lambda message: _console(stderr=True).print(message, markup=False))
|
|
92
|
+
_emit(ctx, mode, **result)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("install")
|
|
96
|
+
def install_packages(ctx: typer.Context,
|
|
97
|
+
packages: list[str] = typer.Argument(..., help="Package names or name=version specs.", metavar="<packages>"),
|
|
98
|
+
jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
|
|
99
|
+
timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
|
|
100
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the plan without writing or downloading.")):
|
|
101
|
+
"""Download, verify, build and install into private prefixes."""
|
|
102
|
+
_install(ctx, packages, jobs, timeout, dry_run, "install")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def reinstall(ctx: typer.Context,
|
|
107
|
+
packages: list[str] = typer.Argument(..., help="Packages to rebuild into new instances.", metavar="<packages>"),
|
|
108
|
+
jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
|
|
109
|
+
timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
|
|
110
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the reinstall plan only.")):
|
|
111
|
+
"""Rebuild requested packages while keeping working instances."""
|
|
112
|
+
_install(ctx, packages, jobs, timeout, dry_run, "reinstall")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def upgrade(ctx: typer.Context,
|
|
117
|
+
packages: Optional[list[str]] = typer.Argument(None, help="Upgrade these packages, or all manual roots.", metavar="<packages>"),
|
|
118
|
+
jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
|
|
119
|
+
timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
|
|
120
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the upgrade plan only.")):
|
|
121
|
+
"""Select newer compatible recipes and retain previous instances."""
|
|
122
|
+
_install(ctx, packages, jobs, timeout, dry_run, "upgrade")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("full-upgrade")
|
|
126
|
+
def full_upgrade(ctx: typer.Context,
|
|
127
|
+
jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
|
|
128
|
+
timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
|
|
129
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show the full upgrade plan only.")):
|
|
130
|
+
"""Upgrade all roots and dependencies; old instances are retained."""
|
|
131
|
+
_install(ctx, [], jobs, timeout, dry_run, "full-upgrade")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def satisfy(ctx: typer.Context,
|
|
136
|
+
constraints: list[str] = typer.Argument(..., help="Version constraints, such as 'eigen>=3.4'.", metavar="<constraints>"),
|
|
137
|
+
jobs: int = typer.Option(1, "--jobs", "-j", min=1, help="Parallel build jobs."),
|
|
138
|
+
timeout: float = typer.Option(3600, "--timeout", help="Timeout per build/check command, in seconds."),
|
|
139
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Resolve constraints without installing.")):
|
|
140
|
+
"""Install recipes satisfying explicit version constraints."""
|
|
141
|
+
if dry_run:
|
|
142
|
+
_emit(ctx, "satisfy", dry_run=True, plan=plan_install(ctx.obj.home, constraints, jobs=jobs))
|
|
143
|
+
else:
|
|
144
|
+
from .cli import _console
|
|
145
|
+
_emit(ctx, "satisfy", **install(ctx.obj.home, constraints, jobs=jobs, timeout=timeout,
|
|
146
|
+
progress=lambda m: _console(stderr=True).print(m, markup=False)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _remove(ctx, packages, automatic, yes, dry_run):
|
|
150
|
+
command = "autoremove" if automatic else "remove"
|
|
151
|
+
plan = plan_remove(ctx.obj.home, packages, automatic=automatic)
|
|
152
|
+
if dry_run:
|
|
153
|
+
_emit(ctx, command, dry_run=True, **plan)
|
|
154
|
+
return
|
|
155
|
+
if plan["packages"] and not yes:
|
|
156
|
+
import sys
|
|
157
|
+
if ctx.obj.as_json or not sys.stdin.isatty():
|
|
158
|
+
raise ForgeError("Removal requires --yes in non-interactive/JSON mode.",
|
|
159
|
+
hint=f"jforge apt {command} {' '.join(packages)} --yes")
|
|
160
|
+
names = ", ".join(f"{p['name']}={p['version']} ({p['install_id']})" for p in plan["packages"])
|
|
161
|
+
if not typer.confirm(f"Remove {names}?", default=False):
|
|
162
|
+
raise ForgeError("Removal cancelled.")
|
|
163
|
+
_emit(ctx, command, **remove(ctx.obj.home, packages, automatic=automatic, expected_keys=plan["keys"]))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("remove")
|
|
167
|
+
def remove_packages(ctx: typer.Context,
|
|
168
|
+
packages: list[str] = typer.Argument(..., help="Package names or name=version specs.", metavar="<packages>"),
|
|
169
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm removal of the selected installations."),
|
|
170
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show what would be removed.")):
|
|
171
|
+
"""Remove Forge-owned installations after dependency checks."""
|
|
172
|
+
_remove(ctx, packages, False, yes, dry_run)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@app.command()
|
|
176
|
+
def autoremove(ctx: typer.Context,
|
|
177
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm removal of unused automatic dependencies."),
|
|
178
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-s", help="Show unused automatic dependencies.")):
|
|
179
|
+
"""Remove automatic packages unreachable from retained manual installs."""
|
|
180
|
+
_remove(ctx, [], True, yes, dry_run)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("update")
|
|
184
|
+
def update_sources(ctx: typer.Context):
|
|
185
|
+
"""Refresh recipe indexes; installed software is not changed."""
|
|
186
|
+
_emit(ctx, "update", **update(ctx.obj.home))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command("import")
|
|
190
|
+
def import_official(ctx: typer.Context,
|
|
191
|
+
package: str = typer.Argument(..., help="Official binary or source package name.", metavar="<package>"),
|
|
192
|
+
suite: str = typer.Option(..., "--suite", help="Distribution codename, such as bookworm or jammy.", metavar="<str>"),
|
|
193
|
+
origin: str = typer.Option("debian", "--origin", help="Official distribution: debian or ubuntu.", metavar="<debian|ubuntu>"),
|
|
194
|
+
component: str = typer.Option("main", "--component", help="Repository component.", metavar="<str>"),
|
|
195
|
+
name: Optional[str] = typer.Option(None, "--name", help="Forge package name.", metavar="<str>"),
|
|
196
|
+
template: Optional[str] = typer.Option(None, "--template", help="Reuse a reviewed Forge recipe's build adapter.", metavar="<str>"),
|
|
197
|
+
source_version: Optional[str] = typer.Option(None, "--source-version", help="Pin an exact Debian source version.", metavar="<str>"),
|
|
198
|
+
keyring: Optional[Path] = typer.Option(None, "--keyring", help="Require gpgv verification using this trusted archive keyring.", metavar="<path>")):
|
|
199
|
+
"""Import official source metadata and track recipes with apt update."""
|
|
200
|
+
from .official import import_package
|
|
201
|
+
result = import_package(ctx.obj.home, package, origin=origin, suite=suite, component=component,
|
|
202
|
+
name=name, template_name=template, source_version=source_version, keyring=keyring)
|
|
203
|
+
_emit(ctx, "import", **result)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command("edit-sources")
|
|
207
|
+
def configure_sources(ctx: typer.Context,
|
|
208
|
+
add: Optional[str] = typer.Option(None, "--add", help="Add a local directory/file or HTTPS YAML index.", metavar="<source>"),
|
|
209
|
+
remove_source: Optional[str] = typer.Option(None, "--remove", help="Remove a configured source.", metavar="<source>"),
|
|
210
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm removal of a configured source.")):
|
|
211
|
+
"""Show or edit Forge recipe sources without opening an editor."""
|
|
212
|
+
if add or remove_source:
|
|
213
|
+
if remove_source and not yes:
|
|
214
|
+
import sys
|
|
215
|
+
if ctx.obj.as_json or not sys.stdin.isatty():
|
|
216
|
+
raise ForgeError("Removing a source requires --yes in non-interactive/JSON mode.")
|
|
217
|
+
if not typer.confirm(f"Remove recipe source {remove_source}?", default=False):
|
|
218
|
+
raise ForgeError("Source removal cancelled.")
|
|
219
|
+
_emit(ctx, "edit-sources", **edit_sources(ctx.obj.home, add=add, remove_source=remove_source))
|
|
220
|
+
else:
|
|
221
|
+
_emit(ctx, "edit-sources", path=str(ctx.obj.home.root / "apt/sources.json"), **source_config(ctx.obj.home))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@app.command("env")
|
|
225
|
+
def env(ctx: typer.Context,
|
|
226
|
+
path: bool = typer.Option(False, "--path", help="Print only the current activation script path.")):
|
|
227
|
+
"""Locate the versioned activation script for active packages."""
|
|
228
|
+
if path and ctx.obj.as_json:
|
|
229
|
+
raise ForgeError("--path and --json are mutually exclusive.")
|
|
230
|
+
activation = environment_path(ctx.obj.home)
|
|
231
|
+
if path:
|
|
232
|
+
print(activation)
|
|
233
|
+
else:
|
|
234
|
+
_emit(ctx, "env", activation=activation, source_command="source " + shlex.quote(activation))
|
jarvis_forge/catalog.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Recipe catalogs and explicit version constraints for user-local packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from jsonschema import Draft202012Validator
|
|
11
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .downloads import fetch
|
|
16
|
+
from .storage import ForgeError, ForgeHome, digest, now, read_json, write_json
|
|
17
|
+
from .suite import UniqueLoader
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def requirement(text: str) -> Requirement:
|
|
21
|
+
value = re.sub(r"\s*\(([^()]+)\)\s*$", r"\1", text.strip())
|
|
22
|
+
value = re.sub(r"(?<=[A-Za-z0-9_])=(?!=)", "==", value)
|
|
23
|
+
try:
|
|
24
|
+
req = Requirement(value)
|
|
25
|
+
except InvalidRequirement as exc:
|
|
26
|
+
raise ForgeError(f"Invalid package constraint: {text!r}") from exc
|
|
27
|
+
if req.extras or req.marker or req.url or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", req.name):
|
|
28
|
+
raise ForgeError(f"Unsupported package constraint: {text!r}")
|
|
29
|
+
return req
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_recipe(recipe: dict, origin: str) -> dict:
|
|
33
|
+
schema = json.loads(files("jarvis_forge").joinpath("schemas/recipe.schema.json").read_text())
|
|
34
|
+
error = next(iter(Draft202012Validator(schema).iter_errors(recipe)), None)
|
|
35
|
+
if error:
|
|
36
|
+
raise ForgeError(f"Invalid recipe in {origin}: {error.message}")
|
|
37
|
+
if recipe.get("provenance", {}).get("status") == "draft":
|
|
38
|
+
raise ForgeError(f"Recipe is a draft and needs a build adapter: {origin}")
|
|
39
|
+
if recipe["source"].get("patch_archive") and "patch" not in recipe.get("requires_tools", []):
|
|
40
|
+
raise ForgeError(f"Recipes with patch archives must require the patch tool: {origin}")
|
|
41
|
+
try:
|
|
42
|
+
Version(recipe["version"])
|
|
43
|
+
except InvalidVersion as exc:
|
|
44
|
+
raise ForgeError(f"Invalid package version in {origin}: {recipe['version']}") from exc
|
|
45
|
+
subdir = Path(recipe["source"]["subdir"])
|
|
46
|
+
if subdir.is_absolute() or ".." in subdir.parts:
|
|
47
|
+
raise ForgeError(f"Invalid source subdirectory in {origin}.")
|
|
48
|
+
for dep in recipe.get("depends", []):
|
|
49
|
+
requirement(dep)
|
|
50
|
+
return recipe
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_recipes(text: str, origin: str) -> list[dict]:
|
|
54
|
+
try:
|
|
55
|
+
data = yaml.load(text, Loader=UniqueLoader)
|
|
56
|
+
except (yaml.YAMLError, UnicodeError) as exc:
|
|
57
|
+
raise ForgeError(f"Cannot read recipe source {origin}: {exc}") from exc
|
|
58
|
+
if isinstance(data, dict) and "packages" in data:
|
|
59
|
+
if set(data) != {"schema_version", "packages"} or data["schema_version"] != 1 or not isinstance(data["packages"], list):
|
|
60
|
+
raise ForgeError(f"Invalid recipe index: {origin}")
|
|
61
|
+
recipes = data["packages"]
|
|
62
|
+
else:
|
|
63
|
+
recipes = [data]
|
|
64
|
+
return [validate_recipe(item, origin) for item in recipes]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Catalog:
|
|
68
|
+
def __init__(self, home: ForgeHome):
|
|
69
|
+
self.home = home
|
|
70
|
+
self.items = {}
|
|
71
|
+
for path in sorted(files("jarvis_forge").joinpath("recipes").iterdir(), key=lambda p: p.name):
|
|
72
|
+
if path.name.endswith(".yaml"):
|
|
73
|
+
self._add(read_recipes(path.read_text(), f"bundled:{path.name}"), f"bundled:{path.name}")
|
|
74
|
+
cached = home.root / "cache/apt/catalog.json"
|
|
75
|
+
if cached.exists():
|
|
76
|
+
record = read_json(cached)
|
|
77
|
+
if record.get("schema_version") != 1 or not isinstance(record.get("packages"), list):
|
|
78
|
+
raise ForgeError(f"Invalid cached catalog: {cached}")
|
|
79
|
+
for item in record["packages"]:
|
|
80
|
+
if not isinstance(item, dict) or not isinstance(item.get("origin"), str):
|
|
81
|
+
raise ForgeError(f"Invalid cached recipe entry: {cached}")
|
|
82
|
+
self._add([validate_recipe(item.get("recipe"), item["origin"])], item["origin"])
|
|
83
|
+
official = home.root / "cache/apt/official.json"
|
|
84
|
+
if official.exists():
|
|
85
|
+
record = read_json(official)
|
|
86
|
+
if record.get("schema_version") != 1 or not isinstance(record.get("packages"), list):
|
|
87
|
+
raise ForgeError(f"Invalid official-source catalog: {official}")
|
|
88
|
+
for item in record["packages"]:
|
|
89
|
+
self._add([validate_recipe(item["recipe"], item["origin"])], item["origin"])
|
|
90
|
+
for path in sorted((home.root / "recipes").glob("*.yaml")):
|
|
91
|
+
self._add(read_recipes(path.read_text(encoding="utf-8"), str(path)), str(path))
|
|
92
|
+
|
|
93
|
+
def _add(self, recipes, origin):
|
|
94
|
+
for recipe in recipes:
|
|
95
|
+
self.items[(recipe["name"], recipe["version"])] = {"recipe": recipe, "origin": origin}
|
|
96
|
+
|
|
97
|
+
def list(self, query="") -> list[dict]:
|
|
98
|
+
return [dict(item["recipe"], origin=item["origin"]) for _, item in sorted(self.items.items())
|
|
99
|
+
if query.lower() in (item["recipe"]["name"] + " " + item["recipe"]["description"]).lower()]
|
|
100
|
+
|
|
101
|
+
def resolve(self, specs: list[str]) -> list[dict]:
|
|
102
|
+
def solve(pending, selected, constraints):
|
|
103
|
+
if not pending:
|
|
104
|
+
return selected
|
|
105
|
+
req, *rest = pending
|
|
106
|
+
limits = {key: list(value) for key, value in constraints.items()}
|
|
107
|
+
limits.setdefault(req.name, []).append(req.specifier)
|
|
108
|
+
matches = lambda v: all(v in spec for spec in limits[req.name])
|
|
109
|
+
if req.name in selected:
|
|
110
|
+
return solve(rest, selected, limits) if matches(Version(selected[req.name]["version"])) else None
|
|
111
|
+
choices = [item["recipe"] for (name, _), item in self.items.items()
|
|
112
|
+
if name == req.name and matches(Version(item["recipe"]["version"]))]
|
|
113
|
+
for choice in sorted(choices, key=lambda r: Version(r["version"]), reverse=True):
|
|
114
|
+
found = solve([requirement(dep) for dep in choice.get("depends", [])] + rest,
|
|
115
|
+
{**selected, req.name: choice}, limits)
|
|
116
|
+
if found is not None:
|
|
117
|
+
return found
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
selected = solve([requirement(s) for s in specs], {}, {})
|
|
121
|
+
if selected is None:
|
|
122
|
+
raise ForgeError(f"No compatible recipes satisfy: {', '.join(specs)}", hint="jforge apt search <name>")
|
|
123
|
+
ordered, visiting, done = [], set(), set()
|
|
124
|
+
|
|
125
|
+
def visit(name):
|
|
126
|
+
if name in visiting:
|
|
127
|
+
raise ForgeError(f"Recipe dependency cycle involving {name}.")
|
|
128
|
+
if name in done:
|
|
129
|
+
return
|
|
130
|
+
visiting.add(name)
|
|
131
|
+
for spec in selected[name].get("depends", []):
|
|
132
|
+
visit(requirement(spec).name)
|
|
133
|
+
visiting.remove(name)
|
|
134
|
+
done.add(name)
|
|
135
|
+
ordered.append(selected[name])
|
|
136
|
+
|
|
137
|
+
for name in selected:
|
|
138
|
+
visit(name)
|
|
139
|
+
return ordered
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def source_config(home: ForgeHome) -> dict:
|
|
143
|
+
path = home.root / "apt/sources.json"
|
|
144
|
+
config = read_json(path) if path.exists() else {"schema_version": 1, "sources": []}
|
|
145
|
+
if (config.get("schema_version") != 1 or not isinstance(config.get("sources"), list)
|
|
146
|
+
or not all(isinstance(s, str) for s in config["sources"])):
|
|
147
|
+
raise ForgeError(f"Invalid source configuration: {path}")
|
|
148
|
+
return config
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def update_catalog(home: ForgeHome, operation: Path) -> dict:
|
|
152
|
+
entries, sources = [], []
|
|
153
|
+
for index, source in enumerate(source_config(home)["sources"]):
|
|
154
|
+
if source.startswith("https://"):
|
|
155
|
+
path = operation / f"source-{index}.yaml"
|
|
156
|
+
fetch(source, path, operation / f"source-{index}")
|
|
157
|
+
paths = [path]
|
|
158
|
+
else:
|
|
159
|
+
location = Path(source).expanduser()
|
|
160
|
+
if not location.is_absolute():
|
|
161
|
+
raise ForgeError(f"Local recipe sources must be absolute paths: {source}")
|
|
162
|
+
paths = sorted(location.glob("*.yaml")) if location.is_dir() else [location]
|
|
163
|
+
if not location.exists():
|
|
164
|
+
raise ForgeError(f"Recipe source does not exist: {source}")
|
|
165
|
+
for path in paths:
|
|
166
|
+
for recipe in read_recipes(path.read_text(encoding="utf-8"), source):
|
|
167
|
+
entries.append({"recipe": recipe, "origin": source})
|
|
168
|
+
sources.append({"source": source, "path": str(path), "sha256": digest(path)})
|
|
169
|
+
result = {"schema_version": 1, "updated_at": now(), "packages": entries, "sources": sources}
|
|
170
|
+
write_json(home.root / "cache/apt/catalog.json", result)
|
|
171
|
+
return {"sources": sources, "available": len(Catalog(home).list()), "updated_at": result["updated_at"]}
|