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 ADDED
@@ -0,0 +1,3 @@
1
+ """Minimal Docker images for FastAPI monorepos."""
2
+
3
+ __version__ = "0.1.0"
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'}")