pyhusk 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.
- pyhusk/__init__.py +3 -0
- pyhusk/_probe.py +186 -0
- pyhusk/cli.py +432 -0
- pyhusk/config.py +114 -0
- pyhusk/deps.py +132 -0
- pyhusk/discover.py +106 -0
- pyhusk/docker.py +54 -0
- pyhusk/dockerize.py +96 -0
- pyhusk/errors.py +6 -0
- pyhusk/graph.py +172 -0
- pyhusk/pipeline.py +125 -0
- pyhusk/report.py +34 -0
- pyhusk/staleness.py +185 -0
- pyhusk/verify.py +147 -0
- pyhusk-0.1.0.dist-info/METADATA +10 -0
- pyhusk-0.1.0.dist-info/RECORD +19 -0
- pyhusk-0.1.0.dist-info/WHEEL +4 -0
- pyhusk-0.1.0.dist-info/entry_points.txt +2 -0
- pyhusk-0.1.0.dist-info/licenses/LICENSE +204 -0
pyhusk/__init__.py
ADDED
pyhusk/_probe.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Route and dependency probe. Runs inside the TARGET repository's interpreter.
|
|
2
|
+
|
|
3
|
+
Import nothing from pyhusk here, and nothing outside the standard library. This
|
|
4
|
+
file is handed to a foreign interpreter by absolute path, because pyhusk is not
|
|
5
|
+
installed in the target's virtualenv.
|
|
6
|
+
|
|
7
|
+
Output on stdout is a single JSON object. Anything diagnostic goes to stderr, so
|
|
8
|
+
stdout stays parseable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import importlib
|
|
15
|
+
import inspect
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _source_file(obj: object) -> str | None:
|
|
22
|
+
"""Best-effort source file for a callable, seeing through decorators."""
|
|
23
|
+
try:
|
|
24
|
+
unwrapped = inspect.unwrap(obj)
|
|
25
|
+
except Exception:
|
|
26
|
+
unwrapped = obj
|
|
27
|
+
try:
|
|
28
|
+
found = inspect.getsourcefile(unwrapped)
|
|
29
|
+
except (TypeError, OSError):
|
|
30
|
+
found = None
|
|
31
|
+
if found is None:
|
|
32
|
+
# A callable instance: fall back to the class that defines __call__.
|
|
33
|
+
try:
|
|
34
|
+
found = inspect.getsourcefile(unwrapped.__class__)
|
|
35
|
+
except (TypeError, OSError, AttributeError):
|
|
36
|
+
return None
|
|
37
|
+
return found
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _collect_dependant(dependant: object, files: set[str], seen: set[int]) -> None:
|
|
41
|
+
"""Walk a FastAPI dependant tree, recording the source of every callable.
|
|
42
|
+
|
|
43
|
+
FastAPI has already resolved Depends() into this tree at route-registration
|
|
44
|
+
time, including router-level and app-level dependencies, so walking it is
|
|
45
|
+
exact rather than heuristic.
|
|
46
|
+
"""
|
|
47
|
+
if dependant is None or id(dependant) in seen:
|
|
48
|
+
return
|
|
49
|
+
seen.add(id(dependant))
|
|
50
|
+
|
|
51
|
+
call = getattr(dependant, "call", None)
|
|
52
|
+
if call is not None:
|
|
53
|
+
found = _source_file(call)
|
|
54
|
+
if found:
|
|
55
|
+
files.add(found)
|
|
56
|
+
|
|
57
|
+
for sub in getattr(dependant, "dependencies", None) or []:
|
|
58
|
+
_collect_dependant(sub, files, seen)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _collect_routes(
|
|
62
|
+
routes: object,
|
|
63
|
+
prefix: str,
|
|
64
|
+
files: set[str],
|
|
65
|
+
described: list[dict[str, object]],
|
|
66
|
+
seen: set[int],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Walk app.routes, which is NOT homogeneous.
|
|
69
|
+
|
|
70
|
+
Three shapes must survive here:
|
|
71
|
+
* fastapi.routing.APIRoute / APIWebSocketRoute - .endpoint and .dependant
|
|
72
|
+
* starlette.routing.Route / WebSocketRoute - .endpoint, NO .dependant,
|
|
73
|
+
because FastAPI runs no dependency injection for them
|
|
74
|
+
* starlette.routing.Mount - no .endpoint, but .routes to recurse into
|
|
75
|
+
|
|
76
|
+
Every attribute is read with getattr(..., None). Assuming APIRoute is how a
|
|
77
|
+
probe crashes on the first real repository it meets.
|
|
78
|
+
"""
|
|
79
|
+
for route in routes or []:
|
|
80
|
+
if id(route) in seen:
|
|
81
|
+
continue
|
|
82
|
+
seen.add(id(route))
|
|
83
|
+
|
|
84
|
+
path = prefix + (getattr(route, "path", "") or "")
|
|
85
|
+
|
|
86
|
+
endpoint = getattr(route, "endpoint", None)
|
|
87
|
+
if endpoint is not None:
|
|
88
|
+
found = _source_file(endpoint)
|
|
89
|
+
if found:
|
|
90
|
+
files.add(found)
|
|
91
|
+
|
|
92
|
+
dependant = getattr(route, "dependant", None)
|
|
93
|
+
_collect_dependant(dependant, files, set())
|
|
94
|
+
|
|
95
|
+
nested = getattr(route, "routes", None)
|
|
96
|
+
if nested:
|
|
97
|
+
_collect_routes(nested, path, files, described, seen)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if endpoint is None:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
methods = sorted(getattr(route, "methods", None) or [])
|
|
104
|
+
# Only APIRoute carries .dependant, and only a documented one appears in
|
|
105
|
+
# the OpenAPI schema. A plain Starlette route added with add_route never
|
|
106
|
+
# does, so verification must not demand it there.
|
|
107
|
+
documented = bool(
|
|
108
|
+
dependant is not None
|
|
109
|
+
and getattr(route, "include_in_schema", False)
|
|
110
|
+
and methods
|
|
111
|
+
)
|
|
112
|
+
described.append({"path": path, "methods": methods, "documented": documented})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _import_entry(entry: Path) -> object:
|
|
116
|
+
"""Import the entry file as a module, so its relative imports resolve."""
|
|
117
|
+
repo_root = Path.cwd()
|
|
118
|
+
if str(repo_root) not in sys.path:
|
|
119
|
+
sys.path.insert(0, str(repo_root))
|
|
120
|
+
return importlib.import_module(".".join(entry.with_suffix("").parts))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> int:
|
|
124
|
+
parser = argparse.ArgumentParser(description="pyhusk route probe")
|
|
125
|
+
parser.add_argument("--entry", required=True, help="entry file, relative to repo root")
|
|
126
|
+
parser.add_argument("--app", required=True, help="attribute holding the app")
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--factory",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="the attribute is a callable returning the app, not the app itself",
|
|
131
|
+
)
|
|
132
|
+
args = parser.parse_args()
|
|
133
|
+
|
|
134
|
+
module = _import_entry(Path(args.entry))
|
|
135
|
+
|
|
136
|
+
if not hasattr(module, args.app):
|
|
137
|
+
print(
|
|
138
|
+
f"module {module.__name__!r} has no attribute {args.app!r}; "
|
|
139
|
+
f"check the 'app' key for this service",
|
|
140
|
+
file=sys.stderr,
|
|
141
|
+
)
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
application = getattr(module, args.app)
|
|
145
|
+
if args.factory:
|
|
146
|
+
if not callable(application):
|
|
147
|
+
print(
|
|
148
|
+
f"{module.__name__}.{args.app} is not callable, but factory: true "
|
|
149
|
+
f"was configured for this service",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
return 1
|
|
153
|
+
application = application()
|
|
154
|
+
|
|
155
|
+
if not hasattr(application, "routes"):
|
|
156
|
+
hint = "" if args.factory else ". If it is a factory function, set factory: true"
|
|
157
|
+
print(
|
|
158
|
+
f"{module.__name__}.{args.app} has no .routes attribute; it does not look "
|
|
159
|
+
f"like a FastAPI or Starlette application{hint}",
|
|
160
|
+
file=sys.stderr,
|
|
161
|
+
)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
files: set[str] = set()
|
|
165
|
+
described: list[dict[str, object]] = []
|
|
166
|
+
_collect_routes(application.routes, "", files, described, set())
|
|
167
|
+
|
|
168
|
+
# Middleware, lifespan handlers and exception handlers are registered in the
|
|
169
|
+
# entry module and reachable from no route, so it is always part of the slice.
|
|
170
|
+
entry_source = _source_file(module)
|
|
171
|
+
if entry_source:
|
|
172
|
+
files.add(entry_source)
|
|
173
|
+
|
|
174
|
+
json.dump(
|
|
175
|
+
{
|
|
176
|
+
"files": sorted(str(Path(f).resolve()) for f in files),
|
|
177
|
+
"routes": described,
|
|
178
|
+
"openapi_url": getattr(application, "openapi_url", None),
|
|
179
|
+
},
|
|
180
|
+
sys.stdout,
|
|
181
|
+
)
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
raise SystemExit(main())
|
pyhusk/cli.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from pyhusk.config import (
|
|
12
|
+
CONFIG_NAME,
|
|
13
|
+
PyhuskConfig,
|
|
14
|
+
ServiceConfig,
|
|
15
|
+
autodetect_services,
|
|
16
|
+
load_config,
|
|
17
|
+
)
|
|
18
|
+
from pyhusk.deps import RequirementsResult, all_requirements
|
|
19
|
+
from pyhusk.docker import image_exists, image_size, require_docker, run_docker
|
|
20
|
+
from pyhusk.dockerize import assemble_context, render_base_dockerfile, render_dockerfile
|
|
21
|
+
from pyhusk.errors import PyhuskError
|
|
22
|
+
from pyhusk.pipeline import Resolution, is_stale, resolve
|
|
23
|
+
from pyhusk.report import count_python_files, human_size
|
|
24
|
+
from pyhusk.staleness import SLICE_LABEL
|
|
25
|
+
from pyhusk.verify import verify
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
add_completion=False,
|
|
29
|
+
help="Minimal Docker images for FastAPI monorepos.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
RepoOption = Annotated[
|
|
34
|
+
Path,
|
|
35
|
+
typer.Option("--repo", help="Repository root. Defaults to the current directory."),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fail(message: str) -> None:
|
|
40
|
+
typer.secho(f"error: {message}", fg=typer.colors.RED, err=True)
|
|
41
|
+
raise typer.Exit(code=1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_gitignored(repo: Path) -> None:
|
|
45
|
+
"""Add .build/ to .gitignore exactly once."""
|
|
46
|
+
gitignore = repo / ".gitignore"
|
|
47
|
+
if not gitignore.exists():
|
|
48
|
+
gitignore.write_text(".build/\n", encoding="utf-8")
|
|
49
|
+
typer.echo("created .gitignore with .build/")
|
|
50
|
+
return
|
|
51
|
+
existing = gitignore.read_text(encoding="utf-8")
|
|
52
|
+
if any(line.strip() in {".build", ".build/"} for line in existing.splitlines()):
|
|
53
|
+
return
|
|
54
|
+
separator = "" if existing.endswith("\n") or not existing else "\n"
|
|
55
|
+
gitignore.write_text(f"{existing}{separator}.build/\n", encoding="utf-8")
|
|
56
|
+
typer.echo("added .build/ to .gitignore")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def init(repo: RepoOption = Path(".")) -> None:
|
|
61
|
+
"""Write pyhusk.yaml from detected services and ignore the build directory."""
|
|
62
|
+
repo = repo.resolve()
|
|
63
|
+
config_path = repo / CONFIG_NAME
|
|
64
|
+
|
|
65
|
+
if config_path.exists():
|
|
66
|
+
typer.echo(f"{CONFIG_NAME} already exists, leaving it untouched.")
|
|
67
|
+
else:
|
|
68
|
+
try:
|
|
69
|
+
detected = autodetect_services(repo)
|
|
70
|
+
except PyhuskError as exc:
|
|
71
|
+
_fail(str(exc))
|
|
72
|
+
if not detected:
|
|
73
|
+
_fail(
|
|
74
|
+
"No main.py containing a FastAPI() call was found. Create your services "
|
|
75
|
+
f"first, or write {CONFIG_NAME} by hand."
|
|
76
|
+
)
|
|
77
|
+
document = {
|
|
78
|
+
"services": {
|
|
79
|
+
name: {
|
|
80
|
+
"entry": str(service.entry.relative_to(repo)),
|
|
81
|
+
"app": service.app,
|
|
82
|
+
}
|
|
83
|
+
for name, service in sorted(detected.items())
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
config_path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8")
|
|
87
|
+
typer.echo(
|
|
88
|
+
f"wrote {CONFIG_NAME} with {len(detected)} service(s): "
|
|
89
|
+
f"{', '.join(sorted(detected))}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
_ensure_gitignored(repo)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("list")
|
|
96
|
+
def list_services(repo: RepoOption = Path(".")) -> None:
|
|
97
|
+
"""List the services pyhusk can see."""
|
|
98
|
+
repo = repo.resolve()
|
|
99
|
+
try:
|
|
100
|
+
config = load_config(repo)
|
|
101
|
+
except PyhuskError as exc:
|
|
102
|
+
_fail(str(exc))
|
|
103
|
+
for name, service in sorted(config.services.items()):
|
|
104
|
+
relative = service.entry.relative_to(repo)
|
|
105
|
+
suffix = "()" if service.factory else ""
|
|
106
|
+
typer.echo(f"{name}\t{relative}\t{service.app}{suffix}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
IncludeOption = Annotated[
|
|
110
|
+
list[Path] | None,
|
|
111
|
+
typer.Option("--include", help="Force a path into the slice. Repeatable."),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _selected(config: PyhuskConfig, names: list[str] | None) -> dict[str, ServiceConfig]:
|
|
116
|
+
if not names:
|
|
117
|
+
return dict(config.services)
|
|
118
|
+
unknown = [name for name in names if name not in config.services]
|
|
119
|
+
if unknown:
|
|
120
|
+
_fail(
|
|
121
|
+
f"unknown service(s): {', '.join(unknown)}. "
|
|
122
|
+
f"Known: {', '.join(sorted(config.services))}"
|
|
123
|
+
)
|
|
124
|
+
return {name: config.services[name] for name in names}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_all(
|
|
128
|
+
repo: Path,
|
|
129
|
+
services: dict[str, ServiceConfig],
|
|
130
|
+
includes: list[Path],
|
|
131
|
+
shared_base: bool,
|
|
132
|
+
) -> dict[str, Resolution]:
|
|
133
|
+
resolutions: dict[str, Resolution] = {}
|
|
134
|
+
for name, service in sorted(services.items()):
|
|
135
|
+
try:
|
|
136
|
+
resolutions[name] = resolve(repo, name, service, includes, shared_base)
|
|
137
|
+
except PyhuskError as exc:
|
|
138
|
+
_fail(f"{name}: {exc}")
|
|
139
|
+
return resolutions
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _print_warnings(resolutions: dict[str, Resolution]) -> None:
|
|
143
|
+
for name, resolution in sorted(resolutions.items()):
|
|
144
|
+
for warning in resolution.warnings:
|
|
145
|
+
typer.secho(f"warning: {name}: {warning}", fg=typer.colors.YELLOW, err=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_bases(repo: Path, resolutions: dict[str, Resolution]) -> None:
|
|
149
|
+
"""Build every shared base image the given resolutions need, once each.
|
|
150
|
+
|
|
151
|
+
Separate from service builds because `pyhusk plan --json` exists to fan out a
|
|
152
|
+
CI matrix, and two runners building the same base tag concurrently race. A
|
|
153
|
+
file lock cannot help across machines that share no filesystem, so CI runs
|
|
154
|
+
this as one job that the matrix depends on.
|
|
155
|
+
"""
|
|
156
|
+
wanted: dict[str, RequirementsResult] = {}
|
|
157
|
+
sources: dict[str, str] = {}
|
|
158
|
+
for resolution in resolutions.values():
|
|
159
|
+
if resolution.base_tag is None:
|
|
160
|
+
continue
|
|
161
|
+
wanted[resolution.base_tag] = resolution.requirements
|
|
162
|
+
sources[resolution.base_tag] = resolution.base.pinned
|
|
163
|
+
|
|
164
|
+
for tag, requirements in sorted(wanted.items()):
|
|
165
|
+
if image_exists(tag):
|
|
166
|
+
typer.echo(f"base {tag} already present")
|
|
167
|
+
continue
|
|
168
|
+
context = repo / ".build" / "_bases" / tag.split(":", 1)[1]
|
|
169
|
+
if context.exists():
|
|
170
|
+
shutil.rmtree(context)
|
|
171
|
+
context.mkdir(parents=True)
|
|
172
|
+
(context / "requirements.txt").write_text(requirements.text, encoding="utf-8")
|
|
173
|
+
(context / "Dockerfile").write_text(
|
|
174
|
+
render_base_dockerfile(sources[tag]), encoding="utf-8"
|
|
175
|
+
)
|
|
176
|
+
typer.echo(f"building base {tag} ({len(requirements.pins)} packages)")
|
|
177
|
+
if run_docker(["build", "-t", tag, str(context)], capture_output=False).returncode != 0:
|
|
178
|
+
_fail(f"building base {tag} failed")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command("build-bases")
|
|
182
|
+
def build_bases(
|
|
183
|
+
service: Annotated[
|
|
184
|
+
list[str] | None, typer.Argument(help="Limit to these services.")
|
|
185
|
+
] = None,
|
|
186
|
+
repo: RepoOption = Path("."),
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Build the shared base images the current plan requires."""
|
|
189
|
+
repo = repo.resolve()
|
|
190
|
+
try:
|
|
191
|
+
require_docker()
|
|
192
|
+
config = load_config(repo)
|
|
193
|
+
except PyhuskError as exc:
|
|
194
|
+
_fail(str(exc))
|
|
195
|
+
_build_bases(repo, _resolve_all(repo, _selected(config, service), [], True))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _compare_against_naive(repo: Path, resolution: Resolution) -> None:
|
|
199
|
+
"""Build a whole-repo image the naive way and report the difference.
|
|
200
|
+
|
|
201
|
+
Doubles build time, so it is off by default. It exists so the size claim can
|
|
202
|
+
be checked rather than asserted.
|
|
203
|
+
"""
|
|
204
|
+
context = repo / ".build" / "_naive" / resolution.name
|
|
205
|
+
if context.exists():
|
|
206
|
+
shutil.rmtree(context)
|
|
207
|
+
context.mkdir(parents=True)
|
|
208
|
+
|
|
209
|
+
for source in repo.rglob("*"):
|
|
210
|
+
if not source.is_file():
|
|
211
|
+
continue
|
|
212
|
+
relative = source.relative_to(repo)
|
|
213
|
+
if relative.parts and relative.parts[0] in {".build", ".venv", ".git"}:
|
|
214
|
+
continue
|
|
215
|
+
destination = context / relative
|
|
216
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
shutil.copy2(source, destination)
|
|
218
|
+
|
|
219
|
+
(context / "requirements.txt").write_text(all_requirements(repo).text, encoding="utf-8")
|
|
220
|
+
(context / "Dockerfile").write_text(
|
|
221
|
+
render_dockerfile(repo, resolution.service, resolution.base.pinned, True),
|
|
222
|
+
encoding="utf-8",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
tag = f"{resolution.name}-naive:latest"
|
|
226
|
+
typer.echo(f"{resolution.name}: building naive baseline for comparison")
|
|
227
|
+
if run_docker(["build", "-t", tag, str(context)], capture_output=False).returncode != 0:
|
|
228
|
+
typer.secho(
|
|
229
|
+
f"warning: {resolution.name}: baseline build failed, no comparison",
|
|
230
|
+
fg=typer.colors.YELLOW,
|
|
231
|
+
err=True,
|
|
232
|
+
)
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
pruned, naive = image_size(resolution.tag), image_size(tag)
|
|
236
|
+
if not naive:
|
|
237
|
+
return
|
|
238
|
+
saved = naive - pruned
|
|
239
|
+
typer.echo(
|
|
240
|
+
f"{resolution.name}: pruned {human_size(pruned)} vs naive {human_size(naive)} "
|
|
241
|
+
f"compressed saved {human_size(saved)} ({saved / naive:.0%})"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command()
|
|
246
|
+
def build(
|
|
247
|
+
service: Annotated[
|
|
248
|
+
list[str] | None, typer.Argument(help="Services to build. Default: all stale.")
|
|
249
|
+
] = None,
|
|
250
|
+
repo: RepoOption = Path("."),
|
|
251
|
+
include: IncludeOption = None,
|
|
252
|
+
show_dockerfile: Annotated[
|
|
253
|
+
bool, typer.Option("--show-dockerfile", help="Write the context and stop.")
|
|
254
|
+
] = False,
|
|
255
|
+
force: Annotated[bool, typer.Option("--force", help="Rebuild even when unchanged.")] = False,
|
|
256
|
+
compare: Annotated[
|
|
257
|
+
bool, typer.Option("--compare", help="Also build a whole-repo image and show the delta.")
|
|
258
|
+
] = False,
|
|
259
|
+
no_verify: Annotated[
|
|
260
|
+
bool, typer.Option("--no-verify", help="Skip container verification.")
|
|
261
|
+
] = False,
|
|
262
|
+
no_shared_base: Annotated[
|
|
263
|
+
bool, typer.Option("--no-shared-base", help="Produce self-contained images.")
|
|
264
|
+
] = False,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Build minimal images for the named services, or every stale service."""
|
|
267
|
+
repo = repo.resolve()
|
|
268
|
+
includes = [(repo / path).resolve() for path in (include or [])]
|
|
269
|
+
for path in includes:
|
|
270
|
+
if not path.exists():
|
|
271
|
+
_fail(f"--include path does not exist: {path}")
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
config = load_config(repo)
|
|
275
|
+
except PyhuskError as exc:
|
|
276
|
+
_fail(str(exc))
|
|
277
|
+
|
|
278
|
+
resolutions = _resolve_all(repo, _selected(config, service), includes, not no_shared_base)
|
|
279
|
+
_print_warnings(resolutions)
|
|
280
|
+
|
|
281
|
+
for name, resolution in sorted(resolutions.items()):
|
|
282
|
+
assemble_context(
|
|
283
|
+
repo,
|
|
284
|
+
name,
|
|
285
|
+
resolution.service,
|
|
286
|
+
resolution.files,
|
|
287
|
+
resolution.requirements.text,
|
|
288
|
+
resolution.dockerfile_text,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if show_dockerfile:
|
|
292
|
+
for name in sorted(resolutions):
|
|
293
|
+
typer.echo(f"{name}: wrote {(repo / '.build' / name).relative_to(repo)}/")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
require_docker()
|
|
298
|
+
except PyhuskError as exc:
|
|
299
|
+
_fail(str(exc))
|
|
300
|
+
|
|
301
|
+
pending = {
|
|
302
|
+
name: resolution
|
|
303
|
+
for name, resolution in resolutions.items()
|
|
304
|
+
if force or is_stale(resolution)
|
|
305
|
+
}
|
|
306
|
+
for name in sorted(set(resolutions) - set(pending)):
|
|
307
|
+
typer.echo(f"{name}: unchanged")
|
|
308
|
+
if not pending:
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
_build_bases(repo, pending)
|
|
312
|
+
|
|
313
|
+
total_repo_files = count_python_files(repo)
|
|
314
|
+
failures: list[str] = []
|
|
315
|
+
for name, resolution in sorted(pending.items()):
|
|
316
|
+
typer.echo(f"{name}: building")
|
|
317
|
+
built = run_docker(
|
|
318
|
+
[
|
|
319
|
+
"build",
|
|
320
|
+
"-t",
|
|
321
|
+
resolution.tag,
|
|
322
|
+
"--label",
|
|
323
|
+
f"{SLICE_LABEL}={resolution.slice_hash}",
|
|
324
|
+
str(repo / ".build" / name),
|
|
325
|
+
],
|
|
326
|
+
capture_output=False,
|
|
327
|
+
)
|
|
328
|
+
if built.returncode != 0:
|
|
329
|
+
typer.secho(f"{name}: docker build failed", fg=typer.colors.RED, err=True)
|
|
330
|
+
failures.append(name)
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
if not no_verify:
|
|
334
|
+
outcome = verify(
|
|
335
|
+
resolution.tag, resolution.service, resolution.routes, resolution.openapi_url
|
|
336
|
+
)
|
|
337
|
+
if not outcome.ok:
|
|
338
|
+
typer.secho(
|
|
339
|
+
f"{name}: verification failed ({outcome.tier})\n{outcome.detail}",
|
|
340
|
+
fg=typer.colors.RED,
|
|
341
|
+
err=True,
|
|
342
|
+
)
|
|
343
|
+
# The image is already stamped with this content's hash. Left in
|
|
344
|
+
# place, the next build would compare hashes, see a match, and
|
|
345
|
+
# report "unchanged" - a broken image would never be retried.
|
|
346
|
+
run_docker(["rmi", "-f", resolution.tag])
|
|
347
|
+
typer.secho(
|
|
348
|
+
f"{name}: removed {resolution.tag}; the next build will retry",
|
|
349
|
+
fg=typer.colors.RED,
|
|
350
|
+
err=True,
|
|
351
|
+
)
|
|
352
|
+
failures.append(name)
|
|
353
|
+
continue
|
|
354
|
+
if outcome.tier != "schema":
|
|
355
|
+
typer.secho(
|
|
356
|
+
f"warning: {name}: degraded verification ({outcome.tier}) - {outcome.detail}",
|
|
357
|
+
fg=typer.colors.YELLOW,
|
|
358
|
+
err=True,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if resolution.remote_tag:
|
|
362
|
+
run_docker(["tag", resolution.tag, resolution.remote_tag])
|
|
363
|
+
|
|
364
|
+
typer.echo(
|
|
365
|
+
f"{name}: built {resolution.tag} "
|
|
366
|
+
f"{len(resolution.files)}/{total_repo_files} files "
|
|
367
|
+
f"{len(resolution.requirements.pins)} packages "
|
|
368
|
+
f"{human_size(image_size(resolution.tag))} compressed"
|
|
369
|
+
)
|
|
370
|
+
if compare:
|
|
371
|
+
_compare_against_naive(repo, resolution)
|
|
372
|
+
|
|
373
|
+
if failures:
|
|
374
|
+
_fail(f"failed: {', '.join(sorted(failures))}")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@app.command()
|
|
378
|
+
def plan(
|
|
379
|
+
service: Annotated[
|
|
380
|
+
list[str] | None, typer.Argument(help="Limit to these services.")
|
|
381
|
+
] = None,
|
|
382
|
+
repo: RepoOption = Path("."),
|
|
383
|
+
as_json: Annotated[
|
|
384
|
+
bool, typer.Option("--json", help="Machine-readable output for CI.")
|
|
385
|
+
] = False,
|
|
386
|
+
include: IncludeOption = None,
|
|
387
|
+
no_shared_base: Annotated[bool, typer.Option("--no-shared-base")] = False,
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Report which services are stale, without building anything."""
|
|
390
|
+
repo = repo.resolve()
|
|
391
|
+
includes = [(repo / path).resolve() for path in (include or [])]
|
|
392
|
+
try:
|
|
393
|
+
config = load_config(repo)
|
|
394
|
+
except PyhuskError as exc:
|
|
395
|
+
_fail(str(exc))
|
|
396
|
+
|
|
397
|
+
resolutions = _resolve_all(repo, _selected(config, service), includes, not no_shared_base)
|
|
398
|
+
# Warnings go to stderr so `--json` stdout stays a clean document; a CI job
|
|
399
|
+
# pipes this straight into a matrix definition.
|
|
400
|
+
_print_warnings(resolutions)
|
|
401
|
+
|
|
402
|
+
entries: list[dict[str, object]] = []
|
|
403
|
+
bases: dict[str, int] = {}
|
|
404
|
+
for name, resolution in sorted(resolutions.items()):
|
|
405
|
+
stale = is_stale(resolution)
|
|
406
|
+
entries.append(
|
|
407
|
+
{
|
|
408
|
+
"name": name,
|
|
409
|
+
"tag": resolution.tag,
|
|
410
|
+
"base": resolution.base_tag,
|
|
411
|
+
"hash": resolution.slice_hash,
|
|
412
|
+
"stale": stale,
|
|
413
|
+
}
|
|
414
|
+
)
|
|
415
|
+
if resolution.base_tag and stale:
|
|
416
|
+
bases[resolution.base_tag] = len(resolution.requirements.pins)
|
|
417
|
+
|
|
418
|
+
if as_json:
|
|
419
|
+
typer.echo(
|
|
420
|
+
json.dumps(
|
|
421
|
+
{
|
|
422
|
+
"bases": [
|
|
423
|
+
{"tag": tag, "packages": count} for tag, count in sorted(bases.items())
|
|
424
|
+
],
|
|
425
|
+
"services": entries,
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
for entry in entries:
|
|
432
|
+
typer.echo(f"{entry['name']}\t{'stale' if entry['stale'] else 'unchanged'}")
|