hox-cli 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.
hox_cli/config.py ADDED
@@ -0,0 +1,205 @@
1
+ """hox.toml, parsed once into frozen dataclasses."""
2
+
3
+ import dataclasses
4
+ import re
5
+ import tomllib
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ FILENAME = "hox.toml"
10
+
11
+
12
+ class ConfigError(Exception):
13
+ pass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Master:
18
+ url: str
19
+ cert: Path
20
+ key: Path
21
+ # api.vifib.com serves slap.vifib.com's certificate: verify it under that name.
22
+ api_tls_name: str | None = None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Project:
27
+ reference: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Node:
32
+ title: str # on the master; hox looks its reference up there
33
+ ssh: tuple[str, ...]
34
+ # Where `hox re6st-token` requests the node's re6st token (panel: "Re6st Registries").
35
+ re6st_project: str
36
+ re6st_release: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Check:
41
+ name: str
42
+ url: str
43
+ headers: dict[str, str] = field(default_factory=dict)
44
+ expect_status: int | None = None
45
+ expect_header: dict[str, str] = field(default_factory=dict)
46
+
47
+
48
+ RAW = "https://raw.githubusercontent.com"
49
+
50
+
51
+ def short_ref(ref: str) -> str:
52
+ """A commit's first nine characters; a branch stays whole."""
53
+ return ref[:9] if re.fullmatch(r"[0-9a-f]{40}", ref) else ref
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Software:
58
+ name: str
59
+ repo: str
60
+ checkout: Path
61
+ branch: str
62
+ path: str
63
+ software_type: str
64
+ product: str
65
+ instance: str
66
+ checks: tuple[Check, ...] = ()
67
+ # The instance's parameters, sent with every request; None keeps those it has.
68
+ parameters: dict[str, str] | None = None
69
+ # Services of etc/service that exit once their work is done: their exit isn't a failure.
70
+ one_shot: tuple[str, ...] = ()
71
+
72
+ def raw_url(self, file: str, ref: str | None = None) -> str:
73
+ return f"{RAW}/{self.repo}/{ref or self.branch}/{file}"
74
+
75
+ def pinned_url(self, commit: str) -> str:
76
+ return self.raw_url(self.path, commit)
77
+
78
+ @property
79
+ def branch_url(self) -> str:
80
+ return self.raw_url(self.path)
81
+
82
+ def ref_of(self, url: str) -> str | None:
83
+ """The branch or commit of one of this software's release URLs; None for another URL."""
84
+ prefix, suffix = f"{RAW}/{self.repo}/", f"/{self.path}"
85
+ if url.startswith(prefix) and url.endswith(suffix):
86
+ return url[len(prefix) : -len(suffix)]
87
+ return None
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class Config:
92
+ root: Path
93
+ master: Master
94
+ project: Project
95
+ node: Node
96
+ software: dict[str, Software]
97
+
98
+ def software_named(self, name: str | None) -> Software:
99
+ if name is None:
100
+ if len(self.software) != 1:
101
+ raise ConfigError(
102
+ f"several software in {FILENAME}, name one of: {', '.join(self.software)}"
103
+ )
104
+ return next(iter(self.software.values()))
105
+ if name in self.software:
106
+ return self.software[name]
107
+ for sw in self.software.values():
108
+ if sw.instance == name:
109
+ return sw
110
+ raise ConfigError(
111
+ f"no software or instance {name!r} in {FILENAME} (software: {', '.join(self.software)})"
112
+ )
113
+
114
+ def url_name(self, url: str) -> str:
115
+ """A software release URL as hox.toml knows it ("sbe branch", "sbe @ 971d944c2"), else the URL."""
116
+ for sw in self.software.values():
117
+ if ref := sw.ref_of(url):
118
+ return (
119
+ f"{sw.name} branch"
120
+ if ref == sw.branch
121
+ else f"{sw.name} @ {short_ref(ref)}"
122
+ )
123
+ return url
124
+
125
+
126
+ def find(start: Path) -> Path:
127
+ for directory in (start, *start.parents):
128
+ if (directory / FILENAME).is_file():
129
+ return directory / FILENAME
130
+ raise ConfigError(f"no {FILENAME} in {start} or its parents")
131
+
132
+
133
+ def load(path: Path | None = None) -> Config:
134
+ path = (path or find(Path.cwd())).resolve()
135
+ try:
136
+ data = tomllib.loads(path.read_text())
137
+ except (OSError, tomllib.TOMLDecodeError) as e:
138
+ raise ConfigError(f"{path}: {e}") from e
139
+ return parse(data, path.parent)
140
+
141
+
142
+ def parse(data: dict, root: Path) -> Config:
143
+ data = dict(data)
144
+
145
+ def resolve(value: str) -> Path:
146
+ return (root / Path(value).expanduser()).resolve()
147
+
148
+ def ssh_args(args: tuple[str, ...]) -> tuple[str, ...]:
149
+ # The files of ssh's -F (config) and -i (key) resolve against hox.toml's directory.
150
+ return tuple(
151
+ str(resolve(a)) if prev in ("-F", "-i") else a
152
+ for prev, a in zip(("",) + args, args)
153
+ )
154
+
155
+ master = _build(Master, _table(data, "master"), "master")
156
+ master = dataclasses.replace(
157
+ master, cert=resolve(master.cert), key=resolve(master.key)
158
+ )
159
+ project = _build(Project, _table(data, "project"), "project")
160
+ node = _build(Node, _table(data, "node"), "node")
161
+ node = dataclasses.replace(node, ssh=ssh_args(node.ssh))
162
+
163
+ software = {}
164
+ for name, table in _table(data, "software").items():
165
+ ctx = f"software.{name}"
166
+ table = dict(table)
167
+ checks = tuple(
168
+ _build(Check, c, f"{ctx}.checks[{i}]")
169
+ for i, c in enumerate(table.pop("checks", []))
170
+ )
171
+ if not all(isinstance(v, str) for v in table.get("parameters", {}).values()):
172
+ raise ConfigError(f"[{ctx}.parameters] values must be strings")
173
+ sw = _build(Software, {"name": name, **table, "checks": checks}, ctx)
174
+ software[name] = dataclasses.replace(sw, checkout=resolve(sw.checkout))
175
+ if not software:
176
+ raise ConfigError("[software.<name>]: at least one is required")
177
+ if data:
178
+ raise ConfigError(f"unknown sections: {', '.join(data)}")
179
+ return Config(root, master, project, node, software)
180
+
181
+
182
+ def _table(data: dict, name: str) -> dict:
183
+ if name not in data:
184
+ raise ConfigError(f"[{name}] is required")
185
+ value = data.pop(name)
186
+ if not isinstance(value, dict):
187
+ raise ConfigError(f"[{name}] must be a table")
188
+ return value
189
+
190
+
191
+ def _build(cls, table: dict, ctx: str):
192
+ fields = {f.name: f for f in dataclasses.fields(cls)}
193
+ unknown = set(table) - set(fields)
194
+ if unknown:
195
+ raise ConfigError(f"[{ctx}] unknown keys: {', '.join(sorted(unknown))}")
196
+ required = {
197
+ n
198
+ for n, f in fields.items()
199
+ if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING
200
+ }
201
+ missing = required - set(table)
202
+ if missing:
203
+ raise ConfigError(f"[{ctx}] missing keys: {', '.join(sorted(missing))}")
204
+ values = {k: tuple(v) if isinstance(v, list) else v for k, v in table.items()}
205
+ return cls(**values)
hox_cli/deploy.py ADDED
@@ -0,0 +1,95 @@
1
+ """`hox deploy`: wait for a requested instance to be allocated, built, and to pass its checks.
2
+
3
+ The node's own cron does the work (it builds what is supplied and deploys what
4
+ is allocated): this only waits, says what it waits for, and forces one
5
+ instance run once the build is there.
6
+ """
7
+
8
+ import time
9
+
10
+ from . import dev, status
11
+ from .config import Software
12
+ from .master import Api
13
+ from .node import SLAPGRID_PROMISE_FAIL, Node
14
+
15
+ ALLOCATION_POLL_SECONDS, ALLOCATION_TIMEOUT_SECONDS = 30, 30 * 60
16
+ BUILD_POLL_SECONDS, BUILD_TIMEOUT_SECONDS = 60, 8 * 3600 # SBE without cache: hours
17
+ CHECKS_POLL_SECONDS, CHECKS_TIMEOUT_SECONDS = 60, 45 * 60 # frontends take a while
18
+
19
+
20
+ def wait_allocated(api: Api, sw: Software, out=print, sleep=time.sleep) -> str:
21
+ def find() -> list[str]:
22
+ inst = status.instance(api, sw.instance)
23
+ return [inst.partition] if inst and inst.partition else []
24
+
25
+ out(f"{sw.instance}: waiting for allocation")
26
+ partition = dev.wait_for(
27
+ f"{sw.instance}'s allocation",
28
+ find,
29
+ sleep,
30
+ ALLOCATION_POLL_SECONDS,
31
+ ALLOCATION_TIMEOUT_SECONDS,
32
+ )
33
+ out(f"{sw.instance}: allocated on {partition}")
34
+ return partition
35
+
36
+
37
+ def wait_built(node: Node, url: str, out=print, sleep=time.sleep) -> None:
38
+ last = None
39
+
40
+ def find() -> list[bool]:
41
+ nonlocal last
42
+ [state] = node.facts([url]).software
43
+ if (state.state, state.last_error) != last:
44
+ out(
45
+ f"build of {url}: {state.state}"
46
+ + (f" (last error: {state.last_error})" if state.last_error else "")
47
+ )
48
+ last = (state.state, state.last_error)
49
+ return [True] if state.state == "completed" else []
50
+
51
+ dev.wait_for(
52
+ f"build of {url}", find, sleep, BUILD_POLL_SECONDS, BUILD_TIMEOUT_SECONDS
53
+ )
54
+
55
+
56
+ def wait_checks(
57
+ api: Api, sw: Software, node: Node, out=print, sleep=time.sleep
58
+ ) -> list[dev.CheckResult]:
59
+ last = None
60
+
61
+ def find() -> list[list[dev.CheckResult]]:
62
+ nonlocal last
63
+ results = dev.run_checks(
64
+ sw.checks, status.require_instance(api, sw).connection, node
65
+ )
66
+ failing = [f"{r.name}: {r.detail}" for r in results if not r.ok]
67
+ if failing and failing != last:
68
+ out("waiting for: " + "; ".join(failing))
69
+ last = failing
70
+ return [] if failing else [results]
71
+
72
+ return dev.wait_for(
73
+ f"{sw.instance}'s checks",
74
+ find,
75
+ sleep,
76
+ CHECKS_POLL_SECONDS,
77
+ CHECKS_TIMEOUT_SECONDS,
78
+ )
79
+
80
+
81
+ def deploy(
82
+ api: Api, sw: Software, url: str, node: Node, out=print, sleep=time.sleep
83
+ ) -> list[dev.CheckResult]:
84
+ """After `hox setup` and `hox request`: allocated, built, deployed, checks passing."""
85
+ partition = wait_allocated(api, sw, out, sleep)
86
+ wait_built(node, url, out, sleep)
87
+ # Only our partition: another one's failure (a release still building) isn't ours.
88
+ if (
89
+ node.slapgrid(f"instance --force --only-cp {partition}", out)
90
+ == SLAPGRID_PROMISE_FAIL
91
+ ):
92
+ out(
93
+ "some promises fail (see above); they may pass once the frontends are there"
94
+ )
95
+ return wait_checks(api, sw, node, out, sleep)
hox_cli/dev.py ADDED
@@ -0,0 +1,162 @@
1
+ """The dev loop: pushed? served by GitHub? rebuild in place, then check."""
2
+
3
+ import shlex
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import PurePosixPath
7
+
8
+ import httpx2
9
+
10
+ from .config import Check, Software
11
+ from .node import Node, git
12
+
13
+ GITHUB_POLL_SECONDS = 15
14
+ GITHUB_TIMEOUT_SECONDS = (
15
+ 6 * 60
16
+ ) # raw.githubusercontent.com caches branch files up to 5 minutes
17
+ CURL_TIMEOUT_SECONDS = 20
18
+
19
+
20
+ class DevError(Exception):
21
+ pass
22
+
23
+
24
+ def wait_for(what: str, find, sleep=time.sleep, poll: int = 5, timeout: int = 180):
25
+ """The first of find()'s results, once there is one."""
26
+ waited = 0
27
+ while not (rows := find()):
28
+ if waited >= timeout:
29
+ raise DevError(
30
+ f"{what}: still not there after {waited // 60} min {waited % 60} s"
31
+ )
32
+ sleep(poll)
33
+ waited += poll
34
+ return rows[0]
35
+
36
+
37
+ def require_pushed(sw: Software) -> str:
38
+ """The local branch head, if origin has it too (asks the remote: no fetch, no write)."""
39
+ head = git(sw.checkout, "rev-parse", sw.branch).strip()
40
+ line = git(sw.checkout, "ls-remote", "origin", f"refs/heads/{sw.branch}").strip()
41
+ remote = line.split()[0] if line else None
42
+ if remote != head:
43
+ raise DevError(
44
+ f"{sw.branch} is at {head[:9]} locally and {remote[:9] if remote else 'absent'} on origin: push it first"
45
+ )
46
+ return head
47
+
48
+
49
+ def pushed_commit(sw: Software, commit: str) -> str:
50
+ """The full SHA of commit, if a branch of origin contains it (as last fetched).
51
+
52
+ Pinned URLs use it: GitHub also serves short SHAs, but each spelling is
53
+ another URL for the master and another software directory on the node.
54
+ """
55
+ sha = git(sw.checkout, "rev-parse", "--verify", f"{commit}^{{commit}}").strip()
56
+ if not git(sw.checkout, "branch", "-r", "--contains", sha).strip():
57
+ raise DevError(
58
+ f"{commit} is on no branch of origin (as last fetched): push it first"
59
+ )
60
+ return sha
61
+
62
+
63
+ def software_files(sw: Software, commit: str) -> dict[str, str]:
64
+ """The files of the software's directory at commit, as the node will fetch them."""
65
+ directory = str(PurePosixPath(sw.path).parent)
66
+ names = git(sw.checkout, "ls-tree", "-r", "--name-only", commit, directory).split()
67
+ return {name: git(sw.checkout, "show", f"{commit}:{name}") for name in names}
68
+
69
+
70
+ def stale_files(sw: Software, files: dict[str, str], http: httpx2.Client) -> list[str]:
71
+ stale = []
72
+ for name, content in files.items():
73
+ response = http.get(sw.raw_url(name))
74
+ if response.status_code != 200 or response.text != content:
75
+ stale.append(name)
76
+ return stale
77
+
78
+
79
+ def wait_for_github(
80
+ sw: Software, commit: str, http: httpx2.Client, out=print, sleep=time.sleep
81
+ ) -> None:
82
+ files = software_files(sw, commit)
83
+ waited = 0
84
+ while stale := stale_files(sw, files, http):
85
+ if waited >= GITHUB_TIMEOUT_SECONDS:
86
+ raise DevError(
87
+ f"raw.githubusercontent.com still serves old versions of: {', '.join(stale)}"
88
+ )
89
+ out(
90
+ f"raw.githubusercontent.com serves old {', '.join(stale)}, retrying in {GITHUB_POLL_SECONDS}s"
91
+ )
92
+ sleep(GITHUB_POLL_SECONDS)
93
+ waited += GITHUB_POLL_SECONDS
94
+ out(f"raw.githubusercontent.com serves {commit[:9]} ({len(files)} files)")
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class CheckResult:
99
+ name: str
100
+ url: str | None # None: a connection parameter it needs is not published
101
+ ok: bool
102
+ detail: str
103
+
104
+
105
+ def run_checks(
106
+ checks: tuple[Check, ...], connection: dict[str, str], node: Node
107
+ ) -> list[CheckResult]:
108
+ """Run each check with curl on the node: backends are only reachable over re6st."""
109
+ published = {k: v for k, v in connection.items() if v not in (None, "None", "")}
110
+ results = []
111
+ for check in checks:
112
+ try:
113
+ url = check.url.format_map(published)
114
+ except KeyError as e:
115
+ results.append(
116
+ CheckResult(
117
+ check.name, None, False, f"{e.args[0]} is not published yet"
118
+ )
119
+ )
120
+ continue
121
+ results.append(evaluate(check, url, node.run(curl_command(check, url))))
122
+ return results
123
+
124
+
125
+ def curl_command(check: Check, url: str) -> str:
126
+ args = [
127
+ "curl",
128
+ "-gsk",
129
+ "-o",
130
+ "/dev/null",
131
+ "-D",
132
+ "-",
133
+ "--max-time",
134
+ str(CURL_TIMEOUT_SECONDS),
135
+ ]
136
+ for key, value in check.headers.items():
137
+ args += ["-H", f"{key}: {value}"]
138
+ # Always exit 0 so the output is ours to read; curl's own code comes last.
139
+ return shlex.join([*args, url]) + '; echo "curl-exit=$?"'
140
+
141
+
142
+ def evaluate(check: Check, url: str, output: str) -> CheckResult:
143
+ lines = output.strip().splitlines()
144
+ curl_exit = int(lines[-1].removeprefix("curl-exit="))
145
+ if curl_exit != 0:
146
+ return CheckResult(check.name, url, False, f"curl exit {curl_exit}")
147
+ status_line = next(line for line in lines if line.startswith("HTTP/"))
148
+ status = int(status_line.split()[1])
149
+ headers = {}
150
+ for line in lines:
151
+ if ":" in line and not line.startswith("HTTP/"):
152
+ key, value = line.split(":", 1)
153
+ headers[key.strip().lower()] = value.strip()
154
+ problems = []
155
+ if check.expect_status is not None and status != check.expect_status:
156
+ problems.append(f"status {status}, expected {check.expect_status}")
157
+ for key, value in check.expect_header.items():
158
+ if headers.get(key.lower()) != value:
159
+ problems.append(f"{key}: {headers.get(key.lower())!r}, expected {value!r}")
160
+ return CheckResult(
161
+ check.name, url, not problems, "; ".join(problems) or f"status {status}"
162
+ )
hox_cli/lock.py ADDED
@@ -0,0 +1,198 @@
1
+ """From a project's uv.lock to buildout's [versions]: what the project needs at run
2
+ time on a SlapOS node, pinned as uv resolved it, and what buildout can't install
3
+ as is."""
4
+
5
+ import tomllib
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from packaging import tags
10
+ from packaging.markers import Marker
11
+ from packaging.utils import canonicalize_name, parse_wheel_filename
12
+
13
+
14
+ # buildout's own: their versions are SlapOS's (stack/slapos.cfg), whatever the app wants.
15
+ BUILDOUT_TOOLS = frozenset({"pip", "setuptools", "wheel", "zc-buildout"})
16
+
17
+ # Built by SlapOS's stack itself, from source, for slapos.cookbook (stack/slapos.cfg):
18
+ # a :whl pin breaks that build.
19
+ STACK_BUILT = frozenset({"cffi", "lxml", "pyyaml"})
20
+
21
+
22
+ class LockError(Exception):
23
+ pass
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Pin:
28
+ name: str
29
+ version: str
30
+ sdist: bool
31
+ wheel: (
32
+ str | None
33
+ ) # "pure", "binary" (manylinux, for the node), or none for the node
34
+
35
+ @property
36
+ def stack_built(self) -> bool:
37
+ return canonicalize_name(self.name) in STACK_BUILT
38
+
39
+ @property
40
+ def constraint(self) -> str:
41
+ # A wheel installs without a build: buildout builds sdists with the build's
42
+ # setuptools (74 on SlapOS 1.0), which many recent sdists are too new for.
43
+ return (
44
+ f"{self.version}:whl"
45
+ if self.wheel and not self.stack_built
46
+ else self.version
47
+ )
48
+
49
+
50
+ def node_tags(python: str) -> frozenset[tags.Tag]:
51
+ """The wheel tags a SlapOS node's CPython `python` installs: manylinux x86_64
52
+ (the node's glibc, Debian 12, is 2.36)."""
53
+ version = tuple(int(n) for n in python.split(".")[:2])
54
+ platforms = [f"manylinux_2_{minor}_x86_64" for minor in range(36, 4, -1)]
55
+ platforms += [
56
+ "manylinux2014_x86_64",
57
+ "manylinux2010_x86_64",
58
+ "manylinux1_x86_64",
59
+ "linux_x86_64",
60
+ ]
61
+ return frozenset([
62
+ *tags.cpython_tags(version, platforms=platforms),
63
+ *tags.compatible_tags(version, platforms=platforms),
64
+ ])
65
+
66
+
67
+ def wheel_kind(filenames: list[str], supported: frozenset[tags.Tag]) -> str | None:
68
+ found = {t for f in filenames for t in parse_wheel_filename(f)[3]} & supported
69
+ if any(t.platform == "any" for t in found):
70
+ return "pure"
71
+ return "binary" if found else None
72
+
73
+
74
+ def environment(python: str) -> dict[str, str]:
75
+ """Marker values on a SlapOS node: Linux x86_64, CPython `python` (3.12)."""
76
+ return {
77
+ "python_version": ".".join(python.split(".")[:2]),
78
+ "python_full_version": python,
79
+ "implementation_name": "cpython",
80
+ "platform_python_implementation": "CPython",
81
+ "sys_platform": "linux",
82
+ "platform_system": "Linux",
83
+ "platform_machine": "x86_64",
84
+ "os_name": "posix",
85
+ }
86
+
87
+
88
+ def runtime_pins(lock: dict, python: str) -> list[Pin]:
89
+ """The root project's run-time dependencies (no dependency groups), with the
90
+ extras they ask for, whose markers hold on the node."""
91
+ env = environment(python)
92
+ supported = node_tags(python)
93
+ packages: dict[str, list[dict]] = {}
94
+ for p in lock["package"]:
95
+ packages.setdefault(p["name"], []).append(p)
96
+ roots = [p for p in lock["package"] if {"editable", "virtual"} & p["source"].keys()]
97
+ if len(roots) != 1:
98
+ raise LockError(
99
+ f"expected one editable or virtual root package, found {len(roots)}"
100
+ )
101
+ root = roots[0]
102
+
103
+ def holds(dep: dict, extra: str = "") -> bool:
104
+ return "marker" not in dep or Marker(dep["marker"]).evaluate(
105
+ env | {"extra": extra}
106
+ )
107
+
108
+ def package(dep: dict) -> dict:
109
+ found = packages[dep["name"]]
110
+ if len(found) > 1: # uv forked the resolution: the dependency says which
111
+ found = [p for p in found if p["version"] == dep["version"]]
112
+ return found[0]
113
+
114
+ seen: set[tuple[str, str]] = set()
115
+ pins: dict[str, Pin] = {}
116
+ todo = [(root, "")]
117
+ while todo:
118
+ p, extra = todo.pop()
119
+ if (p["name"], extra) in seen:
120
+ continue
121
+ seen.add((p["name"], extra))
122
+ deps = (
123
+ p.get("optional-dependencies", {}).get(extra, [])
124
+ if extra
125
+ else p.get("dependencies", [])
126
+ )
127
+ for dep in deps:
128
+ if not holds(dep, extra):
129
+ continue
130
+ q = package(dep)
131
+ todo += [(q, "")] + [(q, e) for e in dep.get("extra", [])]
132
+ if p is not root and not extra and p["name"] not in BUILDOUT_TOOLS:
133
+ wheels = [w["url"].rsplit("/", 1)[1] for w in p.get("wheels", [])]
134
+ pins[p["name"]] = Pin(
135
+ p["name"], p["version"], "sdist" in p, wheel_kind(wheels, supported)
136
+ )
137
+ return sorted(pins.values(), key=lambda pin: pin.name)
138
+
139
+
140
+ def versions_cfg(path: Path, python: str) -> str:
141
+ """A [versions] section for buildout, with what needs attention as comments."""
142
+ return render_versions(
143
+ runtime_pins(tomllib.loads(path.read_text()), python),
144
+ f"{path.name} by `hox versions`",
145
+ python,
146
+ )
147
+
148
+
149
+ def render_versions(pins: list[Pin], source: str, python: str) -> str:
150
+ binary = [p.name for p in pins if p.wheel == "binary" and not p.stack_built]
151
+ stack = [p.name for p in pins if p.stack_built]
152
+ compiled = [p.name for p in pins if not p.wheel and p.sdist]
153
+ unbuildable = [p.name for p in pins if not p.wheel and not p.sdist]
154
+ lines = [
155
+ f"# From {source}, for CPython {python} on Linux x86_64.",
156
+ "# Packages with a wheel for the node are installed from it (:whl).",
157
+ ]
158
+ if binary:
159
+ lines += [
160
+ "# From binary (manylinux) wheels, with their C libraries bundled, not",
161
+ "# SlapOS's: a security fix in one comes with a new release of the package.",
162
+ *_wrapped(binary),
163
+ ]
164
+ if stack:
165
+ lines += [
166
+ "# Built by SlapOS's stack (slapos.cookbook), from source, with its recipe:",
167
+ *_wrapped(stack),
168
+ ]
169
+ if compiled:
170
+ lines += [
171
+ "# Built from their sdist (no wheel for the node): those with C or Rust",
172
+ "# code need their libraries and build tools in the build:",
173
+ *_wrapped(compiled),
174
+ ]
175
+ if unbuildable:
176
+ lines += [
177
+ "# No sdist and no wheel for the node, so buildout can't install them:",
178
+ "# replace them (another extra, or a SlapOS component):",
179
+ *_wrapped(unbuildable),
180
+ ]
181
+ width = max(len(p.name) for p in pins)
182
+ return "\n".join([
183
+ *lines,
184
+ "",
185
+ "[versions]",
186
+ *(f"{p.name:<{width}} = {p.constraint}" for p in pins),
187
+ "",
188
+ ])
189
+
190
+
191
+ def _wrapped(names: list[str]) -> list[str]:
192
+ lines, line = [], "# "
193
+ for name in names:
194
+ if len(line) + len(name) > 76:
195
+ lines.append(line)
196
+ line = "# "
197
+ line += f" {name}"
198
+ return [*lines, line]