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/port.py ADDED
@@ -0,0 +1,705 @@
1
+ """From a Hop3 recipe (a catalog app's hop3.toml and its files) to a SlapOS
2
+ software release on the hop3 stack of slapos_abilian_edge (stack/hop3/).
3
+
4
+ port() maps the recipe to a Release, a section of hop3.toml at a time;
5
+ software_cfg(), versions_cfg() and write() turn the Release into files."""
6
+
7
+ import hashlib
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import tomllib
13
+ from collections.abc import Callable, Collection
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from packaging.requirements import Requirement
20
+ from packaging.utils import canonicalize_name
21
+
22
+ from .lock import (
23
+ BUILDOUT_TOOLS,
24
+ Pin,
25
+ environment,
26
+ node_tags,
27
+ render_versions,
28
+ wheel_kind,
29
+ )
30
+
31
+
32
+ class PortError(Exception):
33
+ pass
34
+
35
+
36
+ # The catalog's presentation layer and Hop3's own checks: not part of the recipe.
37
+ OVERLAY = re.compile(
38
+ r"catalog\.toml|readme(-\w+)?\.md|icon\.\w+|check\.py|screenshots/.*"
39
+ )
40
+
41
+ # What the hop3 stack does without a word from the recipe, or what doesn't apply
42
+ # on SlapOS; any other section without a mapper (SECTIONS) is reported.
43
+ IGNORED = {
44
+ "metadata",
45
+ "test",
46
+ "port",
47
+ "domains",
48
+ "deploy",
49
+ "backup",
50
+ "limits",
51
+ "contexts",
52
+ }
53
+
54
+ PIP_INSTALL = "pip install -r requirements.txt"
55
+
56
+ # The build commands that need Node (SBE's version, already in the node's shared parts).
57
+ NODE_COMMAND = re.compile(r"(\w+=\S*\s+)*(npm|npx|node)\b")
58
+ COMPOSER_COMMAND = re.compile(r"(\w+=\S*\s+)*composer\b")
59
+ NODE_VERSION = "24.11.0"
60
+ # SlapOS's Go components, oldest first: an app gets the first that its go.mod allows.
61
+ GO_VERSIONS = ("1.23", "1.24", "1.26")
62
+ GO_MOD_VERSION = re.compile(r"^go\s+(\d+\.\d+)", re.MULTILINE)
63
+ # Packages that buildout can't build by name alone, and the SlapOS component that
64
+ # has what their build needs.
65
+ COMPONENTS = {"misaka": "python-misaka"}
66
+ # In a venv, pip builds them by name: the SlapOS component with the libraries an sdist needs.
67
+ BUILD_LIBRARIES = {"cffi": "libffi"}
68
+ # The kinds of [env] secret that the stack encodes from 32 random bytes, as Hop3 does
69
+ # by default (`secrets` in the stack: NAME:kind). A password is the stack's own kind.
70
+ ENCODED_SECRETS = {"hex", "base64", "urlsafe"}
71
+ ADMIN_SCRIPT = "hop3-admin-create.sh"
72
+
73
+ # Fetches a package's release files from PyPI: name, version -> file names.
74
+ PyPI = Callable[[str, str], list[str]]
75
+
76
+ # Pins a project's dependencies for the node: its pyproject.toml, Python -> requirements.
77
+ Resolve = Callable[[Path, str], str]
78
+
79
+ # The pins as a requirements file with their hashes: pins, Python -> its text.
80
+ Hashes = Callable[[list[Pin], str], str]
81
+
82
+
83
+ @dataclass
84
+ class Release:
85
+ """What the release's software.cfg and versions.cfg say, before they are written."""
86
+
87
+ name: str
88
+ title: str
89
+ version: str
90
+ python: str
91
+ recipe: Path # the recipe's directory
92
+ files: dict[str, str] # the recipe's files, relative to it: their md5
93
+ generated: dict[str, str] = field(
94
+ default_factory=dict
95
+ ) # what the port adds to them: their text
96
+ commands: list[str] = field(default_factory=list) # run in the build
97
+ toolchain: str = ""
98
+ node: bool = False # the commands need Node
99
+ go: str = "" # the app is built with this Go component
100
+ prebuilt: bool = (
101
+ False # the recipe fetches a binary: provisional, SlapOS builds from source
102
+ )
103
+ egg: str | None = None # the app's distribution, when installed from its source
104
+ pins: list[Pin] = field(default_factory=list)
105
+ eggs: list[str] = field(default_factory=list)
106
+ venv: bool = False # installed by pip in a venv of its own, not as eggs by buildout
107
+ requirements: str = "" # for the venv: the pins with their hashes
108
+ uploaded_prior_to: str = "" # for the venv: what its packages' builds may fetch
109
+ # What Hop3 gives every app, the address the node's health promise uses
110
+ # (an app that checks the Host header has to trust it), then what the
111
+ # recipe asks for. $${...}: substituted in the instance, not at build time.
112
+ environment: list[str] = field(
113
+ default_factory=lambda: [
114
+ "HOST_NAME=$DOMAIN",
115
+ "HOP3_PUBLIC_URL=$URL",
116
+ "HOP3_BACKEND_URL=$${app-parameters:url}",
117
+ ]
118
+ )
119
+ secrets: list[str] = field(default_factory=list)
120
+ volumes: list[str] = field(default_factory=list) # "name target"
121
+ processes: dict[str, list[str]] = field(default_factory=dict) # name: its commands
122
+ health: str | None = None
123
+ database: bool = False
124
+ mariadb: bool = False
125
+ composer: bool = False # the build runs composer: stack/hop3/composer.cfg
126
+ notes: list[str] = field(
127
+ default_factory=list
128
+ ) # what wasn't mapped, or needs a look
129
+
130
+
131
+ def pypi_files(name: str, version: str) -> list[str]:
132
+ import httpx2
133
+
134
+ response = httpx2.get(f"https://pypi.org/pypi/{name}/{version}/json", timeout=30)
135
+ if response.status_code == 404:
136
+ raise PortError(f"{name} {version} is not on PyPI")
137
+ response.raise_for_status()
138
+ return [f["filename"] for f in response.json()["urls"]]
139
+
140
+
141
+ def uv_compile(pyproject: Path, python: str) -> str:
142
+ done = subprocess.run(
143
+ [
144
+ "uv",
145
+ "pip",
146
+ "compile",
147
+ str(pyproject),
148
+ "--python-version",
149
+ python,
150
+ "--python-platform",
151
+ "x86_64-unknown-linux-gnu",
152
+ "--no-header",
153
+ "--no-annotate",
154
+ "-q",
155
+ ],
156
+ capture_output=True,
157
+ text=True,
158
+ check=False,
159
+ )
160
+ if done.returncode:
161
+ raise PortError(f"uv pip compile {pyproject}: {done.stderr.strip()}")
162
+ return done.stdout
163
+
164
+
165
+ def uv_hashes(pins: list[Pin], python: str) -> str:
166
+ done = subprocess.run(
167
+ [
168
+ "uv",
169
+ "pip",
170
+ "compile",
171
+ "-",
172
+ "--no-deps",
173
+ "--generate-hashes",
174
+ "--python-version",
175
+ python,
176
+ "--python-platform",
177
+ "x86_64-unknown-linux-gnu",
178
+ "--no-header",
179
+ "--no-annotate",
180
+ "-q",
181
+ ],
182
+ input="".join(f"{p.name}=={p.version}\n" for p in pins),
183
+ capture_output=True,
184
+ text=True,
185
+ check=False,
186
+ )
187
+ if done.returncode:
188
+ raise PortError(f"uv pip compile --generate-hashes: {done.stderr.strip()}")
189
+ return done.stdout
190
+
191
+
192
+ def port(
193
+ recipe: Path,
194
+ python: str,
195
+ pypi: PyPI = pypi_files,
196
+ source: Path | None = None,
197
+ resolve: Resolve = uv_compile,
198
+ venv: bool = False,
199
+ hashes: Hashes = uv_hashes,
200
+ ) -> Release:
201
+ """The release for the recipe in `recipe` (the directory of its hop3.toml).
202
+
203
+ An app without a requirements.txt is installed from its source, which the recipe
204
+ fetches at build time: `source` is an unpacked copy, for its name and dependencies,
205
+ or to tell a Go app (Hop3 finds its go.mod, the recipe doesn't name the toolchain)."""
206
+ spec = tomllib.loads((recipe / "hop3.toml").read_text())
207
+ build = spec.setdefault("build", {}) # its mapper refuses what it can't build
208
+ if "toolchain" not in build and source is not None and (source / "go.mod").exists():
209
+ build["toolchain"] = "go"
210
+ meta = spec["metadata"]
211
+ files = sorted(
212
+ f
213
+ for f in (str(p.relative_to(recipe)) for p in recipe.rglob("*") if p.is_file())
214
+ if not OVERLAY.fullmatch(f)
215
+ )
216
+ release = Release(
217
+ meta["id"],
218
+ meta.get("title", meta["id"]),
219
+ meta.get("version", ""),
220
+ python,
221
+ recipe,
222
+ {f: hashlib.md5((recipe / f).read_bytes()).hexdigest() for f in files},
223
+ )
224
+ for name, mapper in SECTIONS.items():
225
+ if name in spec:
226
+ mapper(release, spec[name])
227
+ release.notes += [
228
+ f"[{s}]: not mapped" for s in spec if s not in SECTIONS and s not in IGNORED
229
+ ]
230
+ if release.go and source is not None:
231
+ release.go = _go_component((source / "go.mod").read_text())
232
+ if release.toolchain != "python":
233
+ if venv:
234
+ raise PortError(f"{release.name}: --venv is for a Python app")
235
+ return release
236
+ if (recipe / "requirements.txt").exists():
237
+ requirements = (recipe / "requirements.txt").read_text()
238
+ else:
239
+ requirements = _from_source(release, source, resolve)
240
+ release.pins, release.eggs = _requirements(
241
+ requirements, python, pypi, frozenset() if venv else BUILDOUT_TOOLS
242
+ )
243
+ if venv:
244
+ release.venv = True
245
+ release.requirements = hashes(release.pins, python)
246
+ release.uploaded_prior_to = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
247
+ return release
248
+
249
+
250
+ def _from_source(release: Release, source: Path | None, resolve: Resolve) -> str:
251
+ if source is None:
252
+ raise PortError(
253
+ "no requirements.txt: for an app installed from its source, "
254
+ "give its unpacked release with --source"
255
+ )
256
+ pyproject = source / "pyproject.toml"
257
+ if not pyproject.exists():
258
+ raise PortError(f"{source}: no pyproject.toml")
259
+ release.egg = tomllib.loads(pyproject.read_text())["project"]["name"]
260
+ release.notes.append(
261
+ f"{release.egg} from its source: its dependencies are pinned as resolved today"
262
+ )
263
+ return resolve(pyproject, release.python)
264
+
265
+
266
+ def _build(release: Release, build: dict) -> None:
267
+ if build.get("builder", "local") != "local":
268
+ raise PortError(
269
+ f"builder {build['builder']!r}: only the local builder is supported"
270
+ )
271
+ toolchain = build.get("toolchain")
272
+ if toolchain not in ("python", "go", "generic", "node", "php"):
273
+ raise PortError(
274
+ f"toolchain {toolchain!r}: only python, go, generic, node and php are supported so far "
275
+ "(a Go recipe that doesn't name it: give its source with --source)"
276
+ )
277
+ release.toolchain = toolchain
278
+ install = _commands(build.get("build"))
279
+ release.go = GO_VERSIONS[0] if toolchain == "go" else ""
280
+ release.prebuilt = toolchain == "generic"
281
+ if toolchain == "python" and install not in ([], [PIP_INSTALL]):
282
+ raise PortError(
283
+ f"[build] build = {install}: only {PIP_INSTALL!r} is supported so far"
284
+ )
285
+ if release.prebuilt:
286
+ release.notes.append(
287
+ "toolchain 'generic': provisional, runs the binary the recipe fetches, "
288
+ "until a variant of the recipe builds it from source"
289
+ )
290
+ release.commands = (
291
+ _commands(build.get("before-build"))
292
+ + ([] if toolchain == "python" else install)
293
+ + _commands(build.get("after-build"))
294
+ )
295
+ release.node = toolchain == "node" or any(
296
+ NODE_COMMAND.match(c) for c in release.commands
297
+ )
298
+ release.composer = toolchain == "php" and any(
299
+ COMPOSER_COMMAND.match(c) for c in release.commands
300
+ )
301
+ if toolchain == "php":
302
+ release.processes |= {"fpm": ["hop3-php-fpm"], "web": ["hop3-php-web"]}
303
+ release.environment.append("PHPRC=${hop3-php-ini:output}")
304
+ _packages(release, "build", build)
305
+
306
+
307
+ def _env(release: Release, env: dict) -> None:
308
+ for key, value in env.items():
309
+ if key == "computed":
310
+ release.environment += [
311
+ f"{k}={_shell(_variables(v))}" for k, v in value.items()
312
+ ]
313
+ elif key.startswith("_"):
314
+ continue
315
+ elif isinstance(value, str):
316
+ release.environment.append(f"{key}={_shell(value)}")
317
+ elif "generate" in value:
318
+ _secret(release, key, value)
319
+ elif value.get("key") in ("domain", "hostname") and "from" not in value:
320
+ release.environment.append(f"{key}=$DOMAIN")
321
+ elif value.get("key") == "name" and "from" not in value:
322
+ release.environment.append(f"{key}={release.name}")
323
+ else:
324
+ release.notes.append(f"[env] {key} = {value}: not mapped")
325
+
326
+
327
+ def _secret(release: Release, key: str, spec: dict) -> None:
328
+ kind = spec["generate"]
329
+ if (
330
+ "prefix" in spec
331
+ ): # the stack's secret under its own name, the app's variable built from it
332
+ secret = f"HOP3_SECRET_{key}"
333
+ release.environment.append(f"{key}={_shell(spec['prefix'] + '$' + secret)}")
334
+ key, spec = secret, {k: v for k, v in spec.items() if k != "prefix"}
335
+ release.secrets.append(f"{key}:{kind}" if kind in ENCODED_SECRETS else key)
336
+ if kind not in ENCODED_SECRETS | {"password"}:
337
+ release.notes.append(
338
+ f"[env] {key}: generated as a 32-character password, not {kind!r}"
339
+ )
340
+ elif kind != "password" and spec.get("length", 32) != 32:
341
+ release.notes.append(
342
+ f"[env] {key}: generated from 32 bytes, not {spec['length']}"
343
+ )
344
+
345
+
346
+ def _run(release: Release, run: dict) -> None:
347
+ release.processes["release"] = _commands(run.get("before-run"))
348
+ if "start" in run:
349
+ release.processes["web"] = _commands(run["start"])
350
+ release.processes |= {k: _commands(v) for k, v in run.get("workers", {}).items()}
351
+ _packages(release, "run", run)
352
+
353
+
354
+ def _admin(release: Release, admin: dict) -> None:
355
+ release.secrets.append("HOP3_ADMIN_PASSWORD")
356
+ if "username" in admin:
357
+ release.environment.append(f"HOP3_ADMIN_USER={_shell(admin['username'])}")
358
+ if (
359
+ admin.get("email") == "operator"
360
+ ): # Hop3's operator: the create scripts require an address
361
+ release.environment.append(f"HOP3_ADMIN_EMAIL=admin@{release.name}.invalid")
362
+ release.notes.append(
363
+ f"[admin] email: admin@{release.name}.invalid, unless HOP3_ADMIN_EMAIL is an instance parameter"
364
+ )
365
+ elif "email" in admin:
366
+ release.environment.append(f"HOP3_ADMIN_EMAIL={_shell(admin['email'])}")
367
+ create = admin.get("create")
368
+ if not create: # the app makes its admin from the HOP3_ADMIN_* variables
369
+ return
370
+ if (
371
+ "\n" in create.strip() or "${" in create
372
+ ): # in a script: buildout would split or substitute it
373
+ release.generated[ADMIN_SCRIPT] = create
374
+ release.files[ADMIN_SCRIPT] = hashlib.md5(create.encode()).hexdigest()
375
+ create = f"bash {ADMIN_SCRIPT}"
376
+ release.processes["after-start"] = _commands(create)
377
+
378
+
379
+ def _probe(release: Release, probe: dict) -> None:
380
+ release.notes.append("[probe]: not created, nothing on SlapOS signs in with it")
381
+
382
+
383
+ def _volumes(release: Release, volumes: list[dict]) -> None:
384
+ # A scratch volume is reset by every deploy, as the copy of the build is.
385
+ release.volumes = [
386
+ f"{v['name']} {v['target']}"
387
+ for v in volumes
388
+ if v.get("type", "persist") == "persist"
389
+ ]
390
+
391
+
392
+ def _addons(release: Release, addons: list[dict]) -> None:
393
+ for addon in addons:
394
+ if addon["type"] == "postgres":
395
+ release.database = True
396
+ elif addon["type"] == "mysql":
397
+ release.mariadb = True
398
+ else:
399
+ release.notes.append(f"[[addons]] {addon['type']}: not supported")
400
+
401
+
402
+ def _proxy(release: Release, proxy: dict) -> None:
403
+ if release.toolchain == "php" and proxy.keys() == {
404
+ "deny"
405
+ }: # for nginx in stack/hop3/php.cfg
406
+ release.environment.append(f"HOP3_PROXY_DENY={_shell(' '.join(proxy['deny']))}")
407
+ else:
408
+ release.notes.append(f"[proxy] {proxy}: not mapped")
409
+
410
+
411
+ def _healthcheck(release: Release, healthcheck: dict) -> None:
412
+ release.health = healthcheck.get("path")
413
+
414
+
415
+ # The sections mapped, in this order: [env.computed] can read what [admin] sets.
416
+ SECTIONS: dict[str, Callable[[Release, Any], None]] = {
417
+ "build": _build,
418
+ "run": _run,
419
+ "admin": _admin,
420
+ "env": _env,
421
+ "probe": _probe,
422
+ "volumes": _volumes,
423
+ "addons": _addons,
424
+ "proxy": _proxy,
425
+ "healthcheck": _healthcheck,
426
+ }
427
+
428
+
429
+ def software_cfg(release: Release) -> str:
430
+ processes = {k: " && ".join(v) for k, v in release.processes.items() if v}
431
+ lines = [
432
+ f"# {release.title} {release.version}, from its Hop3 recipe in hop3/,",
433
+ "# by `hox port`: change the recipe and port it again rather than edit this file.",
434
+ *(
435
+ [
436
+ "# Provisional: runs the binary the recipe fetches, where a SlapOS release builds",
437
+ "# from source. To replace with a port of a variant of the recipe that does.",
438
+ ]
439
+ if release.prebuilt
440
+ else []
441
+ ),
442
+ "[buildout]",
443
+ "extends =",
444
+ *([" ../../component/golang/buildout.cfg"] if release.go else []),
445
+ *([" ../../component/nodejs/buildout.cfg"] if release.node else []),
446
+ *(
447
+ [
448
+ " ../../component/apache-php/buildout.cfg",
449
+ " ../../component/nginx/buildout.cfg",
450
+ ]
451
+ if release.toolchain == "php"
452
+ else []
453
+ ),
454
+ *(
455
+ [" ../../component/postgresql/buildout.cfg"]
456
+ if release.database or release.toolchain == "php"
457
+ else []
458
+ ),
459
+ *([" ../../component/mariadb/buildout.cfg"] if release.mariadb else []),
460
+ *(f" ../../component/{c}/buildout.cfg" for c in _components(release)),
461
+ " ../../stack/hop3/buildout.cfg",
462
+ *([" ../../stack/hop3/php.cfg"] if release.toolchain == "php" else []),
463
+ *([" ../../stack/hop3/composer.cfg"] if release.composer else []),
464
+ *(
465
+ [" ../../stack/python-app/venv.cfg", " ../../stack/hop3/venv.cfg"]
466
+ if release.venv
467
+ else [" ./versions.cfg"]
468
+ if release.pins
469
+ else []
470
+ ),
471
+ "",
472
+ "[python3]",
473
+ f"<= python{release.python}",
474
+ "",
475
+ *(["[nodejs]", f"<= nodejs-{NODE_VERSION}", ""] if release.node else []),
476
+ *(
477
+ [
478
+ "[nodejs-headers]",
479
+ f"<= nodejs-headers-{NODE_VERSION}",
480
+ "",
481
+ "# Native modules from their source, where npm would fetch a prebuilt binary.",
482
+ "[python-app-git]",
483
+ "environment +=",
484
+ " npm_config_build_from_source=true",
485
+ " npm_config_tarball=${nodejs-headers:target}",
486
+ " NODE_GYP_FORCE_PYTHON=${python3:location}/bin/python3",
487
+ "",
488
+ ]
489
+ if release.toolchain == "node"
490
+ else []
491
+ ),
492
+ "[hop3]",
493
+ "base = ${:_profile_base_location_}/hop3",
494
+ "files =",
495
+ *(f" {f} {md5}" for f, md5 in release.files.items()),
496
+ "commands =",
497
+ *(f" {c}" for c in release.commands),
498
+ *(
499
+ [f"tools = {_tools(release)}"]
500
+ if release.go or release.node or release.toolchain == "php"
501
+ else []
502
+ ),
503
+ "",
504
+ *(
505
+ [
506
+ "[python-app-requirements]",
507
+ "url = ${:_profile_base_location_}/requirements.txt",
508
+ f"md5sum = {hashlib.md5(release.requirements.encode()).hexdigest()}",
509
+ "",
510
+ ]
511
+ if release.venv
512
+ else []
513
+ ),
514
+ "[python-app]",
515
+ f"name = {release.name}",
516
+ *([f"egg = {release.egg}"] if release.egg else []),
517
+ *(_venv_options(release) if release.venv else _eggs(release)),
518
+ *([f"health = {release.health}"] if release.health else []),
519
+ *(["postgresql-bin = ${postgresql:location}/bin"] if release.database else []),
520
+ *(["mariadb-bin = ${mariadb:location}/bin"] if release.mariadb else []),
521
+ *([f"secrets = {' '.join(release.secrets)}"] if release.secrets else []),
522
+ "environment =",
523
+ *(f" {e}" for e in release.environment),
524
+ *(
525
+ ["volumes =", *(f" {v}" for v in release.volumes)]
526
+ if release.volumes
527
+ else []
528
+ ),
529
+ "",
530
+ "[python-app-processes]",
531
+ *(f"{k} = {v}" for k, v in processes.items()),
532
+ "",
533
+ ]
534
+ return "\n".join(lines)
535
+
536
+
537
+ def versions_cfg(release: Release) -> str:
538
+ source = "the app's pyproject.toml" if release.egg else "hop3/requirements.txt"
539
+ return render_versions(release.pins, f"{source} by `hox port`", release.python)
540
+
541
+
542
+ def write(release: Release, out: Path) -> None:
543
+ """Write the release in `out` (software/<name>/), the recipe's files in out/hop3/."""
544
+ cfg = out / "software.cfg"
545
+ if cfg.exists() and "by `hox port`" not in cfg.read_text():
546
+ raise PortError(f"{cfg} doesn't come from hox port: give the recipe another id")
547
+ if (out / "hop3").exists():
548
+ shutil.rmtree(out / "hop3")
549
+ for f in release.files:
550
+ (out / "hop3" / f).parent.mkdir(parents=True, exist_ok=True)
551
+ if f in release.generated:
552
+ (out / "hop3" / f).write_text(release.generated[f])
553
+ else:
554
+ shutil.copyfile(release.recipe / f, out / "hop3" / f)
555
+ cfg.write_text(software_cfg(release))
556
+ if release.venv:
557
+ (out / "requirements.txt").write_text(release.requirements)
558
+ elif release.pins:
559
+ (out / "versions.cfg").write_text(versions_cfg(release))
560
+
561
+
562
+ def _compiled(release: Release) -> list[str]:
563
+ """What a venv builds from its sdist, where a wheel would bring compiled code."""
564
+ return [canonicalize_name(p.name) for p in release.pins if p.wheel == "binary"]
565
+
566
+
567
+ def _components(release: Release) -> list[str]:
568
+ if release.venv:
569
+ return [BUILD_LIBRARIES[n] for n in _compiled(release) if n in BUILD_LIBRARIES]
570
+ return [COMPONENTS[e] for e in release.eggs if e in COMPONENTS]
571
+
572
+
573
+ def _eggs(release: Release) -> list[str]:
574
+ return [
575
+ "eggs =",
576
+ *([" ${python-app-develop:egg}"] if release.egg else []),
577
+ *(
578
+ f" ${{{COMPONENTS[e]}:egg}}" if e in COMPONENTS else f" {e}"
579
+ for e in release.eggs
580
+ ),
581
+ ]
582
+
583
+
584
+ def _venv_options(release: Release) -> list[str]:
585
+ compiled = _compiled(release)
586
+ return [
587
+ *([f"no-binary = {' '.join(compiled)}"] if compiled else []),
588
+ *(
589
+ [
590
+ f"build-libraries = {' '.join(f'${{{c}:location}}' for c in _components(release))}"
591
+ ]
592
+ if _components(release)
593
+ else []
594
+ ),
595
+ f"uploaded-prior-to = {release.uploaded_prior_to}",
596
+ ]
597
+
598
+
599
+ def _tools(release: Release) -> str:
600
+ """What the build finds on PATH, before what the stack gives every release."""
601
+ tools = [f"${{golang{release.go}:location}}/bin"] if release.go else []
602
+ tools += ["${nodejs:location}/bin"] if release.node else []
603
+ tools += (
604
+ ["${gcc:prefix}/bin", "${binutils:location}/bin"]
605
+ if release.toolchain == "node"
606
+ else []
607
+ )
608
+ tools += ["${hop3-composer:location}"] if release.composer else []
609
+ tools += ["${hop3-php:tools}"] if release.toolchain == "php" else []
610
+ return ":".join([*tools, "${:base-tools}"])
611
+
612
+
613
+ def _go_component(go_mod: str) -> str:
614
+ """The first Go component at least as new as what a go.mod asks for."""
615
+ asked = GO_MOD_VERSION.search(go_mod)
616
+ if asked is None:
617
+ return GO_VERSIONS[0]
618
+ for version in GO_VERSIONS:
619
+ if _minor(version) >= _minor(asked[1]):
620
+ return version
621
+ raise PortError(
622
+ f"go.mod asks for Go {asked[1]}: SlapOS's newest component here is {GO_VERSIONS[-1]}"
623
+ )
624
+
625
+
626
+ def _minor(version: str) -> int:
627
+ return int(version.split(".")[1])
628
+
629
+
630
+ def _commands(value: str | list[str] | None) -> list[str]:
631
+ """A command or a list of them. Each one line, without ${…}: buildout would
632
+ split the one and substitute the other (write $NAME)."""
633
+ commands = [
634
+ c.strip()
635
+ for c in ([] if value is None else [value] if isinstance(value, str) else value)
636
+ ]
637
+ for command in commands:
638
+ if "\n" in command or "${" in command:
639
+ raise PortError(f"{command!r}: commands must be one line, without ${{…}}")
640
+ return [c for c in commands if c]
641
+
642
+
643
+ def _packages(release: Release, section: str, value: dict) -> None:
644
+ if value.get("packages"):
645
+ release.notes.append(
646
+ f"[{section}] packages = {value['packages']}: needs SlapOS components"
647
+ )
648
+
649
+
650
+ def _requirements(
651
+ requirements: str, python: str, pypi: PyPI, skip: Collection[str]
652
+ ) -> tuple[list[Pin], list[str]]:
653
+ """The pins of compiled requirements, and the eggs to install (all of them, as
654
+ `pip install -r` does, but those in `skip`: buildout's own, whose versions are SlapOS's)."""
655
+ env = environment(python)
656
+ supported = node_tags(python)
657
+ pins, eggs = [], []
658
+ for line in requirements.splitlines():
659
+ line = line.split("#", 1)[0].strip()
660
+ if not line:
661
+ continue
662
+ if line.startswith("-"):
663
+ raise PortError(
664
+ f"requirements: {line!r}: only pinned requirements are supported"
665
+ )
666
+ req = Requirement(line)
667
+ if (req.marker and not req.marker.evaluate(env)) or canonicalize_name(
668
+ req.name
669
+ ) in skip:
670
+ continue
671
+ version = next((s.version for s in req.specifier if s.operator == "=="), None)
672
+ if version is None:
673
+ raise PortError(f"requirements: {line!r} isn't pinned")
674
+ files = pypi(req.name, version)
675
+ pins.append(
676
+ Pin(
677
+ req.name,
678
+ version,
679
+ any(f.endswith((".tar.gz", ".zip")) for f in files),
680
+ wheel_kind([f for f in files if f.endswith(".whl")], supported),
681
+ )
682
+ )
683
+ eggs.append(
684
+ f"{req.name}[{','.join(sorted(req.extras))}]" if req.extras else req.name
685
+ )
686
+ return sorted(pins, key=lambda p: p.name.lower()), eggs
687
+
688
+
689
+ def _variables(value: str) -> str:
690
+ """${NAME} as $NAME, which buildout leaves alone, where the shell reads the same."""
691
+ converted = re.sub(r"\$\{(\w+)\}(?!\w)", r"$\1", value)
692
+ if "${" in converted:
693
+ raise PortError(
694
+ f"{value!r}: a ${{…}} followed by a name character can't be written for buildout"
695
+ )
696
+ return converted
697
+
698
+
699
+ def _shell(value: str) -> str:
700
+ """A value for a shell assignment, $NAME expanded."""
701
+ if "$" not in value:
702
+ return shlex.quote(value)
703
+ return (
704
+ '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`") + '"'
705
+ )