docker-devkit 0.1.19__tar.gz → 0.1.21__tar.gz

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.
Files changed (40) hide show
  1. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/.github/workflows/release.yml +1 -1
  2. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/AGENTS.md +12 -11
  3. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/PKG-INFO +3 -1
  4. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/README.md +3 -3
  5. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/pyproject.toml +6 -3
  6. docker_devkit-0.1.21/release-devkit.yaml +8 -0
  7. docker_devkit-0.1.21/src/docker_devkit/config.py +85 -0
  8. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/lifecycle.py +4 -19
  9. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/mirror.py +4 -24
  10. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/score_generate.py +6 -37
  11. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/up.py +1 -1
  12. docker_devkit-0.1.21/tests/test_config.py +151 -0
  13. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_lifecycle.py +30 -20
  14. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_mirror.py +23 -24
  15. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_score_generate.py +22 -12
  16. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/uv.lock +51 -0
  17. docker_devkit-0.1.19/release-devkit.json +0 -13
  18. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/.gitattributes +0 -0
  19. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/.github/workflows/ci.yml +0 -0
  20. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/.gitignore +0 -0
  21. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/.python-version +0 -0
  22. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/CLAUDE.md +0 -0
  23. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/LICENSE +0 -0
  24. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/NOTICE +0 -0
  25. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/plan.md +0 -0
  26. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/ruff.base.toml +0 -0
  27. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/ruff.toml +0 -0
  28. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/__init__.py +0 -0
  29. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/build_docker.py +0 -0
  30. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/context_sha.py +0 -0
  31. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/detect_gpu.py +0 -0
  32. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/documents.py +0 -0
  33. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/down.py +0 -0
  34. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/image_refs.py +0 -0
  35. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/modes.py +0 -0
  36. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/src/docker_devkit/py.typed +0 -0
  37. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_context_sha.py +0 -0
  38. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_default_targets.py +0 -0
  39. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_documents.py +0 -0
  40. {docker_devkit-0.1.19 → docker_devkit-0.1.21}/tests/test_image_refs.py +0 -0
@@ -28,4 +28,4 @@ jobs:
28
28
  - uses: astral-sh/setup-uv@v7
29
29
 
30
30
  - name: Publish
31
- run: uvx --from release-devkit==0.1.14 publish-stable --config release-devkit.json
31
+ run: uvx --from release-devkit==0.1.20 publish-stable --config release-devkit.yaml
@@ -2,15 +2,15 @@
2
2
 
3
3
  ## What this is
4
4
 
5
- `docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build`, `mirror` — plus the helpers they share (`detect_gpu`, `modes`, `context_sha`). It is a small, generic package that any repo shipping a compose graph can depend on to get the same bring-up/tear-down / cross-build flow, with the compose assembly declared per consumer in `[tool.docker-devkit.lifecycle]` so a repo that authors its own images and a repo that only OCI-includes an upstream artifact both work through the same commands.
5
+ `docker-devkit` owns the Docker-stack lifecycle commands — `up`, `down`, `build`, `mirror` — plus the helpers they share (`detect_gpu`, `modes`, `context_sha`). It is a small, generic package that any repo shipping a compose graph can depend on to get the same bring-up/tear-down / cross-build flow, with the compose assembly declared per consumer in the `lifecycle:` section of `docker-devkit.yaml` so a repo that authors its own images and a repo that only OCI-includes an upstream artifact both work through the same commands.
6
6
 
7
- The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all dependencies resolve from PyPI (`bashrun>=0.1.0`; git-source pins only in scratch branches testing unreleased changes). The repo and package renamed twice on the way here — `stack-lifecycle` → `stack-toolkit` (2026-09-20, before any publish) and `stack-toolkit` → `docker-devkit` (2026-09-21, member of the `-devkit` family). The PyPI identity is fresh: `docker-devkit` starts its own tag ledger at `0.1.0`; the terminal `stack-toolkit` distributions (≤0.1.1) are deprecation signposts pointing here, not this package's history.
7
+ The package is `docker_devkit` (src-layout under `src/docker_devkit/`); all dependencies resolve from PyPI — `bashrun` (shell-outs), `packaging` (`requires` specifier parsing), `pathspec`, `pydantic`/`pydantic-settings`, `pyyaml` (score-document parsing), `strictyaml` (config loading), `typer` (CLIs) — git-source pins only in scratch branches testing unreleased changes. The repo and package renamed twice on the way here — `stack-lifecycle` → `stack-toolkit` (2026-09-20, before any publish) and `stack-toolkit` → `docker-devkit` (2026-09-21, member of the `-devkit` family). The PyPI identity is fresh: `docker-devkit` starts its own tag ledger at `0.1.0`; the terminal `stack-toolkit` distributions (≤0.1.1) are deprecation signposts pointing here, not this package's history.
8
8
 
9
- Config surfaces org-wide follow one law (its home is release-devkit's `AGENTS.md`): config names its owning devkit and lives beside the unit it describes — project-dep devkits configure via `[tool.<devkit>.*]` pyproject tables, uvx-isolated devkits via one root `<devkit>.json` per repo. This repo's `[tool.docker-devkit.lifecycle]` tables in consumers are an instance; this repo itself is published by root `release-devkit.json`.
9
+ Config surfaces org-wide follow one law (its home is release-devkit's `AGENTS.md`): config lives beside the unit whose facts it carries; consumption model and config location are independent axes. Every devkit configures via one root `<devkit>.yaml` per repo — except python-devkit (pyproject `[tool.python-devkit.*]` tables, its subject being the Python workspace) and unity-devkit (JSON, read by C# inside Unity, which ships no YAML parser). A docker-devkit consumer declares a root `docker-devkit.yaml` whose schema (`config.DockerDevkitConfig`, strictyaml-loaded, `extra="forbid"` on every model) carries a required `requires` version stamp plus optional `lifecycle` / `mirror` / `generate-score` sections; `config.load_config` self-checks the installed docker-devkit version against `requires` and refuses loudly outside the range (the `0.0.0.dev0` dev sentinel bypasses the check, not the field). Strictyaml preserves all scalars as strings (no implicit typing) and rejects duplicate keys and flow-style collections, so empty collections are expressed by omitting the key, not by `[]`/`{}`. This repo itself is published by root `release-devkit.yaml`.
10
10
 
11
11
  ## Release flow
12
12
 
13
- Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's `publish-stable` (an inlined, version-pinned `uvx` step in the publish job, keeping OIDC identity local), never a project dependency (docker-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `docker-devkit-v*` tags are the version ledger (declared `major_minor` line in `release-devkit.json`, patch-auto within the line). While pre-1.0, breaking changes ride the normal patch flow without a bump; from 1.0 on, API-breaking changes ship with a manually bumped `major_minor` — patch-auto assumes additive changes.
13
+ Publishing rides `release.yml`, triggered by a successful CI run on a `main` push: the machinery — release-devkit's `publish-stable` (an inlined, version-pinned `uvx` step in the publish job, keeping OIDC identity local), never a project dependency (docker-devkit sits inside its own dependency graph; a project-level release-devkit edge is a resolver cycle) — computes the plan from the tag ledger and path-diff, patches the version ephemerally, and publishes to PyPI under OIDC trusted publishing (publisher bound to `release.yml`, no environment). The committed `pyproject.toml` version is permanently the `0.0.0.dev0` sentinel; the `docker-devkit-v*` tags are the version ledger (declared `major_minor` line in `release-devkit.yaml`, patch-auto within the line). While pre-1.0, breaking changes ride the normal patch flow without a bump; from 1.0 on, API-breaking changes ship with a manually bumped `major_minor` — patch-auto assumes additive changes.
14
14
 
15
15
  ## Shape
16
16
 
@@ -21,14 +21,15 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
21
21
  | `up.py` | `docker compose up`. Flags: `--attached`/`-a`, `--quiet-pull`/`-q`, `--build`, `--gpu auto\|cuda\|rocm\|none`, `--dev`, `--allow-upstream-fallback`. |
22
22
  | `down.py` | `docker compose down`. Flags: `--volumes`/`-v` (also removes named volumes), `--gpu`. |
23
23
  | `build_docker.py` | `run_build()` + the `build` command — cross-builds all images per the resolved image manifest, writing the paired lock (one `NAME=path:tag@sha256:…` entry per `x-base-images` declaration; `@digest`-pinned declarations pass through unresolved; mirror-prefixed declarations resolve their digest against the upstream reference). `--mode ci` hard-requires `x-registry-cache` in the manifest. |
24
- | `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `stack/score/` sources: `${ALL_CAPS}` placeholder expansion into tempdir-rendered workload copies, idempotent `score-compose`/`score-k8s` init (state preserved across runs), and a determinism suite (document sort, state-path normalisation, 0644 modes, LF newlines). Consumer policy — workloads, provisioners, patch templates, output paths, storage classes, publishes, tool versions — is declared in the consumer's root `pyproject.toml` under `[tool.docker-devkit.generate-score]` (`ScoreConfig`); `load_score_config()` reads it, `ensure_score_tools()` fetches the pinned score-compose/score-k8s release binaries (for CI staleness gates). Output and state paths resolve against the repo root: the compose half runs in `score_dir` (its `.score-compose` state stays there, gitignored), the k8s half runs in the configured `k8s_state` parent so the committed `.score-k8s` state lands in the generated tier. `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
25
- | `lifecycle.py` | `LifecycleConfig` — the `[tool.docker-devkit.lifecycle]` declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (walks `tool → docker-devkit → lifecycle`, `None` when absent), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`, with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved so repos can upgrade before moving files), `enforce_bake_declaration()` (a manifest under either name requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies). |
24
+ | `score_generate.py` | `generate-score` — deterministic Score-manifest generation from a consumer's `stack/score/` sources: `${ALL_CAPS}` placeholder expansion into tempdir-rendered workload copies, idempotent `score-compose`/`score-k8s` init (state preserved across runs), and a determinism suite (document sort, state-path normalisation, 0644 modes, LF newlines). Consumer policy — workloads, provisioners, patch templates, output paths, storage classes, publishes, tool versions — is declared in the consumer's root `docker-devkit.yaml` under `generate-score:` (`ScoreConfig`, model in `config.py`); `load_score_config()` reads it, `ensure_score_tools()` fetches the pinned score-compose/score-k8s release binaries (for CI staleness gates). Output and state paths resolve against the repo root: the compose half runs in `score_dir` (its `.score-compose` state stays there, gitignored), the k8s half runs in the configured `k8s_state` parent so the committed `.score-k8s` state lands in the generated tier. `--local` writes the k8s artifact to the configured gitignored local path with the local storage class. |
25
+ | `config.py` | `DockerDevkitConfig` — the root `docker-devkit.yaml` schema: a required `requires` PEP 440 specifier plus optional `lifecycle` / `mirror` / `generate-score` sections, `extra="forbid"` on every model. `load_config(root)` is the single strictyaml entry point: parses the file (returns `None` when absent — the no-config → single-file-default path for `up`/`down`), then self-checks the installed docker-devkit version against `requires` (refuses loudly outside range; the `0.0.0.dev0` dev sentinel bypasses the check, not the field). The section models (`LifecycleConfig`, `MirrorConfig`, `ScoreConfig`) live here; `load_lifecycle_config` / `load_mirror_config` / `load_score_config` in their modules are thin wrappers over it. |
26
+ | `lifecycle.py` | `LifecycleConfig` (model in `config.py`) — the `lifecycle:` section declaration (`files`: compose files in layering order with `{gpu}` templating; `dev_file`: optional dev overlay); `load_lifecycle_config()` (reads the `lifecycle:` section via `config.load_config`, `None` when the section or file is absent), `resolve_manifest()` / `require_manifest()` (the raising form) / `resolve_lock()` (the image manifest and its lock are a named pair — `workloads/images.yml` + `workloads/images.lock`, with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved so repos can upgrade before moving files), `enforce_bake_declaration()` (a manifest under either name requires a declaration), `expand_compose_files()` (assembles the `-f` list; a `{gpu}` entry is dropped when the GPU resolves to `none`; `dev_file` appended when a dev overlay applies). |
26
27
  | `detect_gpu.py` | `detect_gpu()` → `cuda`/`rocm`/`none` from host devices; the `Gpu` literal. |
27
28
  | `modes.py` | `resolve_auth_mode()` — validates `PUBLIC_URL` + `AUTH_MODE` from `.env`, rejecting `keycloak` over cleartext `http://`. |
28
29
  | `context_sha.py` | `compute_service_shas(repo_root, bake: BakeDocument)` — per-service `tree-<hash>` image tags over the `.dockerignore`-allowlisted git tree. The workload-tree root is derived as the single top-level directory containing every tagged service's Dockerfile (a bake graph spanning several top-level directories is a loud error); files under it that belong to no tagged service are excluded from every SHA rather than shared into all of them. |
29
30
  | `documents.py` | `BakeDocument` + `parse_bake(path)` — the typed bake-file model every consumer of bake data goes through (build, up, down, generate-score, the reference collectors). A field validator enforces at parse time that every `x-base-images` value is `path:tag` or `path@digest` (a `${…}` hole or a bare path is a validation error, killing the silent-unpinned `FROM` → `:latest` failure mode). `digest_pinned(reference)` is the shared tail test. |
30
31
  | `image_refs.py` | Image-reference collection, split by source. `declared_references(root)` enumerates bake `x-base-images` declarations from `workloads/images.yml` and the legacy `compose*.bake.yml` names (parsed and validated via `BakeDocument`) — the single source consumer mirroring consumes. `stray_references(root, …globs)` scans Dockerfile `FROM`, Dockerfile `COPY --from=` (slash-bearing captures only, so build-stage names are skipped), and yaml `image:` lines — hand-written literal refs that bypass the declaration system — across the `workloads/` and `docker/` Dockerfile trees and the `stack/score/` + `score/` source trees; `unpinned_references(root)` polices exactly those strays (every slash-bearing reference must carry a tag, a digest, or a trailing `${build-arg}` suffix — consumer CIs fail on its result). `bake_base_image_refs(bake: BakeDocument)` adapts declarations to `ImageReference` pairs; `version_coupling_violations(root, couplings)` (each `VersionCoupling` names a pyproject-declared version and the `VersionSite` regexes that must restate it — bake `x-base-images` values or raw file text — so non-derivable couplings like uv `required-version` ↔ uv-base-image tag can't drift silently; the mechanism lives here, the registry in consumer repos' preflight) is unchanged. Remote digest resolution goes through `imagetools inspect`. Compose files are parsed by nothing in this package. |
31
- | `mirror.py` | The mirror namespace's config, inversion, and verbs. `MirrorConfig` (`[tool.docker-devkit.mirror]`, key `prefix`), `load_mirror_config()` (walks `tool → docker-devkit → mirror`, `None` when absent), `is_mirrored`/`upstream_ref` (prefix inversion preserving the `:tag@sha256:…` tail), `reference_exists` (imagetools existence probe honoring docker config creds), `mirror_targets` (declared refs filtered by prefix, sorted), the `mirror` verb (crane install + per-target `crane copy`), and `up`'s fallback helpers (`upstream_fallback`, `write_fallback_overlay`). Constraints in "Mirror namespace and upstream fallback". |
32
+ | `mirror.py` | The mirror namespace's config, inversion, and verbs. `MirrorConfig` (model in `config.py`, the `mirror:` section, key `prefix`), `load_mirror_config()` (reads the `mirror:` section via `config.load_config`, `None` when the section or file is absent), `is_mirrored`/`upstream_ref` (prefix inversion preserving the `:tag@sha256:…` tail), `reference_exists` (imagetools existence probe honoring docker config creds), `mirror_targets` (declared refs filtered by prefix, sorted), the `mirror` verb (crane install + per-target `crane copy`), and `up`'s fallback helpers (`upstream_fallback`, `write_fallback_overlay`). Constraints in "Mirror namespace and upstream fallback". |
32
33
 
33
34
  ## Constraints
34
35
 
@@ -36,16 +37,16 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
36
37
 
37
38
  **Context.** A repo that authors its own stack builds its own images and assembles a multi-file compose graph (e.g. `compose.yml` + `compose.postgres.yml` + a GPU layer + `compose.dev.yml`), injecting per-service `${*_SHA}` image tags and an image lock of third-party digests. A consumer repo has none of that: it ships a single self-contained compose file that OCI-includes the upstream artifact already baked (image digests and internal vars frozen as literals) or includes a sibling checkout (which supplies its own lock through the include's `env_file`).
38
39
 
39
- **Constraint.** `up`/`down` assemble the graph from `[tool.docker-devkit.lifecycle]` in the consumer's root pyproject — one code path, declared data. Keys: `files` (compose files in layering order, `{gpu}` templating, a `{gpu}` entry dropped when the GPU resolves to `none`; defaults to empty), `dev_file` (dev overlay, appended by `up --dev` and always by `down` so teardown removes containers from prior dev bring-ups). No table → single-file default (`-f compose.yml`). `--compose-file` does not exist; raw `docker compose` is the pressure valve. `--dev` is opt-in — production-shaped bring-up is the default. The image lock is required unconditionally, no knob; consumers pin stock images via `${…_IMAGE:?err}` holes filled by a committed lock. An image manifest present under either resolved name without a lifecycle declaration is a hard error (declare the table, or the file doesn't belong under that name) — guards are budgeted for silent failures only, and a missing lock already fails loudly. The manifest remains the build/up contract: SHA injection and `--build` acceptance key on the manifest existing, and `build` always reads the resolved manifest. `up`/`down` pass `--project-directory` (the invocation root) explicitly, so compose files living in nested trees (`stack/generated/compose/`, overlays) anchor their relative paths at the repo root instead of the first `-f` file's directory. An empty assembly (`files` empty/omitted, no dev overlay) is a loud `up`/`down` error — the honest state for a builds-only repo.
40
+ **Constraint.** `up`/`down` assemble the graph from the `lifecycle:` section of the consumer's root `docker-devkit.yaml` — one code path, declared data. Keys: `files` (compose files in layering order, `{gpu}` templating, a `{gpu}` entry dropped when the GPU resolves to `none`; defaults to empty), `dev_file` (dev overlay, appended by `up --dev` and always by `down` so teardown removes containers from prior dev bring-ups). No section → single-file default (`-f compose.yml`). `--compose-file` does not exist; raw `docker compose` is the pressure valve. `--dev` is opt-in — production-shaped bring-up is the default. The image lock is required unconditionally, no knob; consumers pin stock images via `${…_IMAGE:?err}` holes filled by a committed lock. An image manifest present under either resolved name without a lifecycle declaration is a hard error (declare the section, or the file doesn't belong under that name) — guards are budgeted for silent failures only, and a missing lock already fails loudly. The manifest remains the build/up contract: SHA injection and `--build` acceptance key on the manifest existing, and `build` always reads the resolved manifest. `up`/`down` pass `--project-directory` (the invocation root) explicitly, so compose files living in nested trees (`stack/generated/compose/`, overlays) anchor their relative paths at the repo root instead of the first `-f` file's directory. An empty assembly (`files` empty/omitted, no dev overlay) is a loud `up`/`down` error — the honest state for a builds-only repo.
40
41
 
41
- **Consequences.** placeframe's shape lives in placeframe's pyproject, not in this package: `files = ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"]`, `dev_file = "compose.dev.yml"`. A consumer repo declares a bare table (builds images, no repo-local runtime graph) and runs `uv run up` against its own `.env` + committed lock.
42
+ **Consequences.** placeframe's shape lives in placeframe's `docker-devkit.yaml`, not in this package: a `lifecycle` section with `files` = `compose.yml`, `compose.postgres.yml`, `compose.{gpu}.yml` (in layering order) and `dev_file` = `compose.dev.yml`. A consumer repo with no repo-local runtime graph (a builds-only repo) omits the `lifecycle` section; `up`/`down` on such a repo hits `enforce_bake_declaration` (manifest present, no lifecycle) — the honest signal there is no stack to bring up.
42
43
 
43
44
  ### File and env conventions the commands assume
44
45
 
45
46
  - `.env` (required) — carries `PUBLIC_URL` and `AUTH_MODE`; `modes.py` reads it and rejects `keycloak` over cleartext `http://`.
46
47
  - `workloads/images.lock` (required in every repo, checked in) — third-party and base-image pins, written by `build`: one `NAME=path:tag@sha256:…` entry per manifest declaration. `up`/`down` refuse without it; consumers satisfy it with a committed lock filling the `${…_IMAGE:?err}` holes. The legacy name `.env.lock` resolves while the manifest still carries its legacy name; the lock always sits beside its manifest as the resolved pair.
47
48
  - `workloads/images.yml` (where a local build graph exists, required) — the image manifest and buildx-bake description; its top-level `x-cross-compile-targets` list (optional) is the set of services excluded from the default target list because they need per-arch treatment, opt-in via `--targets`. The legacy root name `compose.bake.yml` resolves identically. `build` reads exactly one manifest: the first of `workloads/images.yml`, `compose.bake.yml` that exists. `buildx bake` resolves the manifest's relative paths against its invocation cwd, so `build` always invokes from the repo root.
48
- - the compose layers declared in `[tool.docker-devkit.lifecycle]` — e.g. placeframe's `compose.yml` / `compose.postgres.yml` / `compose.<gpu>.yml` / `compose.dev.yml`; the GPU one is picked by `detect_gpu()` (or the `--gpu` override).
49
+ - the compose layers declared in the `lifecycle:` section of `docker-devkit.yaml` — e.g. placeframe's `compose.yml` / `compose.postgres.yml` / `compose.<gpu>.yml` / `compose.dev.yml`; the GPU one is picked by `detect_gpu()` (or the `--gpu` override).
49
50
  - `.dockerignore` (where images are built locally) — allowlist that `context_sha.py` hashes over to compute per-service `tree-<hash>` tags. See "Docker build context" in `AGENTS-SHARED.md` for the allowlist rationale.
50
51
 
51
52
  `build` injects the `tree-<hash>` tags of locally-built images into the environment rather than writing them anywhere; `up`/`down` do the same via `compute_service_shas`. The lock stays a pure third-party pin file — a CI invariant forbids built-image digests in the committed, reviewed lock.
@@ -54,7 +55,7 @@ Entry points (`[project.scripts]`): `up` → `up.py:app`, `down` → `down.py:ap
54
55
 
55
56
  **Context.** Third-party images are consumed through an org-owned mirror namespace (`ghcr.io/outernet-foundation/mirror/<upstream-host>/<path>…`) instead of pulling upstream registries directly: manifests at upstream get GC'd, and an upstream yank once broke external cloners — digest pins alone don't save you when the manifest is gone. Durability for strangers beats upstream egress: strangers, CI, and prod always pull the mirror. Populating it is CI's job, single writer — engineers never push to the org namespace, and error text must never teach them to (no "run this populate command" hints).
56
57
 
57
- **Constraint.** The mirror is declared, not inferred: `[tool.docker-devkit.mirror]` with `prefix` in the consumer's root pyproject. Absent table → all mirror behavior off; `up`/`build` behave exactly as they did before it existed. Under the table:
58
+ **Constraint.** The mirror is declared, not inferred: the `mirror:` section with `prefix` in the consumer's root `docker-devkit.yaml`. Absent section → all mirror behavior off; `up`/`build` behave exactly as they did before it existed. Under the section:
58
59
 
59
60
  - `build` resolves lock digests for mirror-prefixed declarations against the upstream reference (prefix stripped, tag/digest tail preserved) and records them under the mirror name. Valid because `crane copy` produces byte-identical manifests — upstream and mirror digests are the same digest; a manifest re-wrap (`docker buildx imagetools`-shaped tooling) would change the top-level digest and break lock equality. This makes declare → re-lock walkable before CI has mirrored anything; lock lines are never hand-authored.
60
61
  - `up` fails closed: after lock resolution it probes each mirror-prefixed lock entry; a missing mirror image is a hard `RuntimeError`, not a warning — people ignore warnings, and warnings are errors someone was too lazy to fix. The only escape is the per-run `--allow-upstream-fallback` flag. Under the flag, missing entries become digest-preserving address substitutions (same bytes from the upstream host), printed as plain statements — the operator asked for fallback, the run reports what it did. The rewrite is authoritative on both consumption paths: an appended `--env-file` overlay (compose's later env files win) plus `os.environ` before `build`'s bake (shell env wins over env files; `run_build` re-applies the overlay after its own lock update so bake is never clobbered). CI never passes the flag — the mirror job runs before anything needs the images.
@@ -1,13 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: docker-devkit
3
- Version: 0.1.19
3
+ Version: 0.1.21
4
4
  Summary: Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly
5
5
  License-File: LICENSE
6
6
  License-File: NOTICE
7
7
  Requires-Python: >=3.13
8
8
  Requires-Dist: bashrun>=0.1.0
9
+ Requires-Dist: packaging>=24.1
9
10
  Requires-Dist: pathspec>=1.0.4
10
11
  Requires-Dist: pydantic-settings>=2.9.1
11
12
  Requires-Dist: pydantic>=2.11.7
12
13
  Requires-Dist: pyyaml>=6.0
14
+ Requires-Dist: strictyaml>=1.7.3
13
15
  Requires-Dist: typer>=0.17.4
@@ -1,6 +1,6 @@
1
1
  # docker-devkit
2
2
 
3
- Docker-stack lifecycle commands — `up`, `down`, `build` — with the compose assembly declared per repo in `[tool.docker-devkit.lifecycle]`. A repo that authors its own stack (Dockerfiles + `workloads/images.yml`) declares its file layers and gets multi-file assembly, per-service SHA injection, and `workloads/images.lock` resolution; a consumer repo that OCI-includes an already-baked upstream stack declares a bare table and runs the same commands as a thin wrapper over its own `.env` + committed lock.
3
+ Docker-stack lifecycle commands — `up`, `down`, `build` — with the compose assembly declared per repo in the `lifecycle:` section of `docker-devkit.yaml`. A repo that authors its own stack (Dockerfiles + `workloads/images.yml`) declares its file layers and gets multi-file assembly, per-service SHA injection, and `workloads/images.lock` resolution; a consumer repo that OCI-includes an already-baked upstream stack omits the `lifecycle` section (single-file default) and runs the same commands as a thin wrapper over its own `.env` + committed lock.
4
4
 
5
5
  See [`AGENTS.md`](./AGENTS.md) for the file/layout conventions the commands assume (`workloads/images.yml` + `workloads/images.lock` — with the legacy root `compose.bake.yml` + `.env.lock` pair still resolved — and `PUBLIC_URL` / `AUTH_MODE` in `.env`) and the declaration contract.
6
6
 
@@ -27,11 +27,11 @@ uv run down -v # also remove named volumes
27
27
  uv run build # cross-build all images per workloads/images.yml
28
28
  uv run build --lock-only # refresh the image lock without building
29
29
  uv run mirror # populate the org mirror namespace from mirror-prefixed declarations (CI)
30
- uv run generate-score # regenerate Score compose/k8s manifests per [tool.docker-devkit.generate-score]
30
+ uv run generate-score # regenerate Score compose/k8s manifests per the generate-score: section of docker-devkit.yaml
31
31
  uv run generate-score --local # local-cluster variant (local storage class, gitignored output)
32
32
  ```
33
33
 
34
- Repos that consume third-party images through the org mirror namespace declare `[tool.docker-devkit.mirror]` (`prefix = "ghcr.io/outernet-foundation/mirror"`); `up` then fails closed on any lock entry not mirrored yet, and `build` resolves lock digests against upstream. See the "Mirror namespace and upstream fallback" section in [`AGENTS.md`](./AGENTS.md).
34
+ Repos that consume third-party images through the org mirror namespace declare a `mirror:` section in `docker-devkit.yaml` (`prefix: ghcr.io/outernet-foundation/mirror`); `up` then fails closed on any lock entry not mirrored yet, and `build` resolves lock digests against upstream. See the "Mirror namespace and upstream fallback" section in [`AGENTS.md`](./AGENTS.md).
35
35
 
36
36
  ## Consuming from another repo
37
37
 
@@ -1,15 +1,17 @@
1
1
  [project]
2
2
  name = "docker-devkit"
3
- version = "0.1.19"
3
+ version = "0.1.21"
4
4
  description = "Docker-stack lifecycle commands (up/down/build) driven by a pyproject-declared compose assembly"
5
5
  requires-python = ">=3.13"
6
6
  dependencies = [
7
7
  "bashrun>=0.1.0",
8
+ "packaging>=24.1",
9
+ "pathspec>=1.0.4",
8
10
  "pydantic>=2.11.7",
9
11
  "pydantic-settings>=2.9.1",
10
- "typer>=0.17.4",
11
12
  "pyyaml>=6.0",
12
- "pathspec>=1.0.4",
13
+ "strictyaml>=1.7.3",
14
+ "typer>=0.17.4",
13
15
  ]
14
16
 
15
17
  [dependency-groups]
@@ -18,6 +20,7 @@ dev = [
18
20
  "ruff>=0.14.11",
19
21
  "pytest>=8.0.0",
20
22
  "types-pyyaml>=6.0.12.20260906",
23
+ "types-strictyaml>=1.7.3.3",
21
24
  ]
22
25
 
23
26
  [build-system]
@@ -0,0 +1,8 @@
1
+ requires: ">=0.1"
2
+ ci_workflow: ci.yml
3
+ packages:
4
+ docker-devkit:
5
+ path: .
6
+ major_minor: "0.1"
7
+ registries:
8
+ pypi: docker-devkit
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ from pathlib import Path
5
+
6
+ from packaging.specifiers import SpecifierSet
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
8
+ from strictyaml import load as load_strict_yaml
9
+
10
+ DEFAULT_CONFIG_PATH = Path("docker-devkit.yaml")
11
+ DEV_SENTINEL = "0.0.0.dev0"
12
+
13
+
14
+ class LifecycleConfig(BaseModel):
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+ files: list[str] = Field(default_factory=list)
18
+ dev_file: str | None = None
19
+
20
+
21
+ class MirrorConfig(BaseModel):
22
+ model_config = ConfigDict(extra="forbid")
23
+
24
+ prefix: str
25
+
26
+ @field_validator("prefix")
27
+ @classmethod
28
+ def _require_bare_prefix(cls, prefix: str) -> str:
29
+ if not prefix or prefix.endswith("/"):
30
+ raise ValueError("mirror prefix must be non-empty and carry no trailing slash")
31
+ return prefix
32
+
33
+
34
+ class ScoreConfig(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ workloads: list[str]
38
+ project_name: str
39
+ cloud_storage_class: str
40
+ local_storage_class: str
41
+ score_dir: Path = Path("stack/score")
42
+ bake_file: Path = Path("workloads/images.yml")
43
+ compose_output: Path = Path("stack/generated/compose/compose.yaml")
44
+ k8s_output: Path = Path("stack/generated/k8s/manifests.yaml")
45
+ # --local writes here instead of the committed k8s output; consumers gitignore this path so a
46
+ # local-only storage class cannot reach the artifact their cluster deploys.
47
+ k8s_local_output: Path = Path("stack/generated/k8s/manifests.local.yaml")
48
+ k8s_state: Path = Path("stack/generated/k8s/.score-k8s")
49
+ storage_class_var: str = "SCORE_STORAGE_CLASS"
50
+ compose_provisioners: list[str] = Field(default_factory=list)
51
+ compose_patch_templates: list[str] = Field(default_factory=list)
52
+ k8s_provisioners: list[str] = Field(default_factory=list)
53
+ publishes: list[str] = Field(default_factory=list)
54
+ score_k8s_version: str = "0.15.0"
55
+ score_compose_version: str = "0.42.0"
56
+
57
+
58
+ class DockerDevkitConfig(BaseModel):
59
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
60
+
61
+ requires: str
62
+ lifecycle: LifecycleConfig | None = None
63
+ mirror: MirrorConfig | None = None
64
+ generate_score: ScoreConfig | None = Field(default=None, alias="generate-score")
65
+
66
+ @field_validator("requires")
67
+ @classmethod
68
+ def _validate_requires(cls, value: str) -> str:
69
+ if not value:
70
+ raise ValueError("requires must be a non-empty PEP 440 specifier; use '>=0.0' for no constraint")
71
+ SpecifierSet(value)
72
+ return value
73
+
74
+
75
+ def load_config(root: Path) -> DockerDevkitConfig | None:
76
+ path = root / DEFAULT_CONFIG_PATH
77
+ if not path.exists():
78
+ return None
79
+ config = DockerDevkitConfig.model_validate(load_strict_yaml(path.read_text(encoding="utf-8")).data)
80
+ installed = importlib.metadata.version("docker-devkit")
81
+ if installed == DEV_SENTINEL:
82
+ return config
83
+ if not SpecifierSet(config.requires).contains(installed, prereleases=True):
84
+ raise SystemExit(f"docker-devkit {installed} does not satisfy requires={config.requires!r}")
85
+ return config
@@ -1,10 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- import tomllib
4
3
  from pathlib import Path
5
4
 
6
- from pydantic import BaseModel, ConfigDict, Field
7
-
5
+ from .config import LifecycleConfig, load_config
8
6
  from .detect_gpu import Gpu
9
7
 
10
8
  DEFAULT_COMPOSE_FILE = Path("compose.yml")
@@ -17,22 +15,9 @@ MANIFEST_LOCK_PAIR = {"images.yml": "images.lock", "compose.bake.yml": ".env.loc
17
15
  LOCK_CANDIDATES = (Path("workloads/images.lock"), Path(".env.lock"))
18
16
 
19
17
 
20
- class LifecycleConfig(BaseModel):
21
- model_config = ConfigDict(extra="forbid")
22
-
23
- files: list[str] = Field(default_factory=list)
24
- dev_file: str | None = None
25
-
26
-
27
18
  def load_lifecycle_config(root: Path) -> LifecycleConfig | None:
28
- pyproject_path = root / "pyproject.toml"
29
- if not pyproject_path.exists():
30
- return None
31
- data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
32
- section = data.get("tool", {}).get("docker-devkit", {}).get("lifecycle")
33
- if section is None:
34
- return None
35
- return LifecycleConfig.model_validate(section)
19
+ config = load_config(root)
20
+ return config.lifecycle if config is not None else None
36
21
 
37
22
 
38
23
  def resolve_manifest(root: Path) -> Path | None:
@@ -56,7 +41,7 @@ def resolve_lock(root: Path, manifest: Path | None) -> Path:
56
41
  def enforce_bake_declaration(root: Path, config: LifecycleConfig | None) -> None:
57
42
  if config is None and any((root / candidate).exists() for candidate in MANIFEST_CANDIDATES):
58
43
  raise RuntimeError(
59
- "image manifest present without [tool.docker-devkit.lifecycle]; declare the table or rename the file"
44
+ "image manifest present without a lifecycle section in docker-devkit.yaml; declare the section or rename the file"
60
45
  )
61
46
 
62
47
 
@@ -3,13 +3,12 @@ from __future__ import annotations
3
3
  import os
4
4
  import shlex
5
5
  import tempfile
6
- import tomllib
7
6
  from pathlib import Path
8
7
 
9
8
  import typer
10
9
  from bashrun.bash import bash, bash_check
11
- from pydantic import BaseModel, ConfigDict, field_validator
12
10
 
11
+ from .config import MirrorConfig, load_config
13
12
  from .image_refs import ImageReference, declared_references
14
13
 
15
14
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
@@ -17,25 +16,12 @@ app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
17
16
  CRANE_VERSION = "v0.22.1"
18
17
 
19
18
 
20
- class MirrorConfig(BaseModel):
21
- model_config = ConfigDict(extra="forbid")
22
-
23
- prefix: str
24
-
25
- @field_validator("prefix")
26
- @classmethod
27
- def _require_bare_prefix(cls, prefix: str) -> str:
28
- if not prefix or prefix.endswith("/"):
29
- raise ValueError("mirror prefix must be non-empty and carry no trailing slash")
30
- return prefix
31
-
32
-
33
19
  @app.command()
34
20
  def mirror() -> None:
35
21
  root = Path.cwd()
36
22
  config = load_mirror_config(root)
37
23
  if config is None:
38
- raise RuntimeError("[tool.docker-devkit.mirror] with 'prefix' is required to mirror")
24
+ raise RuntimeError("a mirror section with 'prefix' is required in docker-devkit.yaml to mirror")
39
25
 
40
26
  targets = mirror_targets(root, config.prefix)
41
27
  print(f"Install crane {CRANE_VERSION}")
@@ -52,14 +38,8 @@ def mirror() -> None:
52
38
 
53
39
 
54
40
  def load_mirror_config(root: Path) -> MirrorConfig | None:
55
- pyproject_path = root / "pyproject.toml"
56
- if not pyproject_path.exists():
57
- return None
58
- data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
59
- section = data.get("tool", {}).get("docker-devkit", {}).get("mirror")
60
- if section is None:
61
- return None
62
- return MirrorConfig.model_validate(section)
41
+ config = load_config(root)
42
+ return config.mirror if config is not None else None
63
43
 
64
44
 
65
45
  def is_mirrored(reference: str, prefix: str) -> bool:
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import os
4
4
  import re
5
5
  import shlex
6
- import tomllib
7
6
  from collections.abc import Generator
8
7
  from contextlib import contextmanager
9
8
  from pathlib import Path
@@ -13,11 +12,11 @@ from typing import Annotated, Literal
13
12
  import typer
14
13
  import yaml
15
14
  from bashrun.bash import bash
16
- from pydantic import BaseModel, ConfigDict, Field
15
+ from pydantic import BaseModel, ConfigDict
17
16
 
17
+ from .config import ScoreConfig, load_config
18
18
  from .context_sha import compute_service_shas
19
19
  from .documents import parse_bake
20
- from .image_refs import TomlDocument, TomlValue
21
20
 
22
21
  # Placeholders in the workload files are ALL-CAPS, so this cannot collide with Score's own
23
22
  # lowercase dotted ${resources.db.host} references.
@@ -40,30 +39,6 @@ class ManifestDocument(BaseModel):
40
39
  metadata: ManifestMetadata | None = None
41
40
 
42
41
 
43
- class ScoreConfig(BaseModel):
44
- model_config = ConfigDict(extra="forbid")
45
-
46
- workloads: list[str]
47
- project_name: str
48
- cloud_storage_class: str
49
- local_storage_class: str
50
- score_dir: Path = Path("stack/score")
51
- bake_file: Path = Path("workloads/images.yml")
52
- compose_output: Path = Path("stack/generated/compose/compose.yaml")
53
- k8s_output: Path = Path("stack/generated/k8s/manifests.yaml")
54
- # --local writes here instead of the committed k8s output; consumers gitignore this path so a
55
- # local-only storage class cannot reach the artifact their cluster deploys.
56
- k8s_local_output: Path = Path("stack/generated/k8s/manifests.local.yaml")
57
- k8s_state: Path = Path("stack/generated/k8s/.score-k8s")
58
- storage_class_var: str = "SCORE_STORAGE_CLASS"
59
- compose_provisioners: list[str] = Field(default_factory=list)
60
- compose_patch_templates: list[str] = Field(default_factory=list)
61
- k8s_provisioners: list[str] = Field(default_factory=list)
62
- publishes: list[str] = Field(default_factory=list)
63
- score_k8s_version: str = "0.15.0"
64
- score_compose_version: str = "0.42.0"
65
-
66
-
67
42
  app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
68
43
 
69
44
 
@@ -82,16 +57,10 @@ def cli(
82
57
 
83
58
 
84
59
  def load_score_config(root: Path) -> ScoreConfig:
85
- value: TomlValue = TomlDocument.model_validate(
86
- tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
87
- ).root
88
- for component in ("tool", "docker-devkit", "generate-score"):
89
- if not isinstance(value, dict) or component not in value:
90
- raise RuntimeError(
91
- f"[tool.docker-devkit.generate-score] is missing at {component} in {root / 'pyproject.toml'}"
92
- )
93
- value = value[component]
94
- return ScoreConfig.model_validate(value)
60
+ config = load_config(root)
61
+ if config is None or config.generate_score is None:
62
+ raise RuntimeError("a generate-score section is required in docker-devkit.yaml")
63
+ return config.generate_score
95
64
 
96
65
 
97
66
  def generate(config: ScoreConfig, target: Target = "both", local: bool = False) -> None:
@@ -57,7 +57,7 @@ def up(
57
57
  lock_file = resolve_lock(root, manifest)
58
58
 
59
59
  if dev and (config is None or config.dev_file is None):
60
- raise RuntimeError("--dev requires a dev_file in [tool.docker-devkit.lifecycle]")
60
+ raise RuntimeError("--dev requires a dev_file in the lifecycle section of docker-devkit.yaml")
61
61
 
62
62
  if not ENV_FILE.exists():
63
63
  raise RuntimeError("No .env file found; create one first (e.g., copy .env.example)")
@@ -0,0 +1,151 @@
1
+ import importlib.metadata
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+ import yaml
6
+ from pydantic import ValidationError
7
+
8
+ from docker_devkit.config import DEV_SENTINEL, load_config
9
+
10
+
11
+ def write_config(tmp_path: Path, payload: dict[str, object]) -> None:
12
+ (tmp_path / "docker-devkit.yaml").write_text(
13
+ yaml.safe_dump(payload, default_flow_style=False, sort_keys=False), encoding="utf-8"
14
+ )
15
+
16
+
17
+ def test_load_config_returns_none_when_file_absent(tmp_path: Path) -> None:
18
+ assert load_config(tmp_path) is None
19
+
20
+
21
+ def test_requires_omitted_is_required(tmp_path: Path) -> None:
22
+ write_config(tmp_path, {"lifecycle": {"files": ["compose.yml"]}})
23
+
24
+ with pytest.raises(ValidationError, match="requires"):
25
+ load_config(tmp_path)
26
+
27
+
28
+ def test_requires_empty_string_rejected(tmp_path: Path) -> None:
29
+ write_config(tmp_path, {"requires": ""})
30
+
31
+ with pytest.raises(ValidationError, match="requires"):
32
+ load_config(tmp_path)
33
+
34
+
35
+ def test_requires_invalid_specifier_rejected(tmp_path: Path) -> None:
36
+ write_config(tmp_path, {"requires": "not-a-specifier"})
37
+
38
+ with pytest.raises(ValidationError, match="requires"):
39
+ load_config(tmp_path)
40
+
41
+
42
+ def _version_dev_sentinel(_name: str) -> str:
43
+ return DEV_SENTINEL
44
+
45
+
46
+ def _version_0_1_18(_name: str) -> str:
47
+ return "0.1.18"
48
+
49
+
50
+ def _version_0_1_19_dev5(_name: str) -> str:
51
+ return "0.1.19.dev5"
52
+
53
+
54
+ def _version_0_1_17(_name: str) -> str:
55
+ return "0.1.17"
56
+
57
+
58
+ def test_requires_dev_sentinel_skips_check(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
59
+ monkeypatch.setattr(importlib.metadata, "version", _version_dev_sentinel)
60
+ write_config(tmp_path, {"requires": ">=0.1.18"})
61
+
62
+ config = load_config(tmp_path)
63
+ assert config is not None
64
+
65
+ assert config.requires == ">=0.1.18"
66
+
67
+
68
+ def test_requires_in_range_loads(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
69
+ monkeypatch.setattr(importlib.metadata, "version", _version_0_1_18)
70
+ write_config(tmp_path, {"requires": ">=0.1.18"})
71
+
72
+ config = load_config(tmp_path)
73
+ assert config is not None
74
+
75
+ assert config.requires == ">=0.1.18"
76
+
77
+
78
+ def test_requires_dev_channel_prerelease_satisfies(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
79
+ monkeypatch.setattr(importlib.metadata, "version", _version_0_1_19_dev5)
80
+ write_config(tmp_path, {"requires": ">=0.1.18"})
81
+
82
+ config = load_config(tmp_path)
83
+ assert config is not None
84
+
85
+ assert config.requires == ">=0.1.18"
86
+
87
+
88
+ def test_requires_out_of_range_refuses(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
89
+ monkeypatch.setattr(importlib.metadata, "version", _version_0_1_17)
90
+ write_config(tmp_path, {"requires": ">=0.1.18"})
91
+
92
+ with pytest.raises(SystemExit, match="does not satisfy requires"):
93
+ load_config(tmp_path)
94
+
95
+
96
+ def test_load_config_rejects_duplicate_keys(tmp_path: Path) -> None:
97
+ (tmp_path / "docker-devkit.yaml").write_text('requires: ">=0.1"\nrequires: ">=0.2"\n', encoding="utf-8")
98
+
99
+ with pytest.raises(Exception, match=r"Duplicate key"):
100
+ load_config(tmp_path)
101
+
102
+
103
+ def test_load_config_rejects_unknown_top_level_keys(tmp_path: Path) -> None:
104
+ write_config(tmp_path, {"requires": ">=0.1", "lifecycle_prefix": "ghcr.io/x/mirror"})
105
+
106
+ with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
107
+ load_config(tmp_path)
108
+
109
+
110
+ def test_load_config_loads_lifecycle_and_mirror(tmp_path: Path) -> None:
111
+ write_config(
112
+ tmp_path,
113
+ {
114
+ "requires": ">=0.1",
115
+ "lifecycle": {"files": ["compose.yml", "compose.{gpu}.yml"], "dev_file": "compose.dev.yml"},
116
+ "mirror": {"prefix": "ghcr.io/outernet-foundation/mirror"},
117
+ },
118
+ )
119
+
120
+ config = load_config(tmp_path)
121
+ assert config is not None
122
+
123
+ assert config.lifecycle is not None
124
+ assert config.lifecycle.files == ["compose.yml", "compose.{gpu}.yml"]
125
+ assert config.lifecycle.dev_file == "compose.dev.yml"
126
+ assert config.mirror is not None
127
+ assert config.mirror.prefix == "ghcr.io/outernet-foundation/mirror"
128
+ assert config.generate_score is None
129
+
130
+
131
+ def test_load_config_loads_generate_score_section(tmp_path: Path) -> None:
132
+ write_config(
133
+ tmp_path,
134
+ {
135
+ "requires": ">=0.1",
136
+ "generate-score": {
137
+ "workloads": ["api.yaml"],
138
+ "project_name": "placeframe",
139
+ "cloud_storage_class": "hcloud-volumes",
140
+ "local_storage_class": "local-path",
141
+ },
142
+ },
143
+ )
144
+
145
+ config = load_config(tmp_path)
146
+ assert config is not None
147
+
148
+ assert config.generate_score is not None
149
+ assert config.generate_score.workloads == ["api.yaml"]
150
+ assert config.generate_score.project_name == "placeframe"
151
+ assert config.lifecycle is None
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
3
  import pytest
4
+ import yaml
4
5
  from pydantic import ValidationError
5
6
  from docker_devkit.lifecycle import (
6
7
  LifecycleConfig,
@@ -12,18 +13,25 @@ from docker_devkit.lifecycle import (
12
13
  )
13
14
 
14
15
 
15
- def _write_pyproject(root: Path, lifecycle_toml: str) -> None:
16
- content = f"[project]\nname = 'stack'\n\n{lifecycle_toml}"
17
- (root / "pyproject.toml").write_text(content, encoding="utf-8")
16
+ def _write_config(root: Path, sections: dict[str, object] | None = None) -> None:
17
+ payload: dict[str, object] = {"requires": ">=0.0"}
18
+ if sections is not None:
19
+ payload.update(sections)
20
+ (root / "docker-devkit.yaml").write_text(
21
+ yaml.safe_dump(payload, default_flow_style=False, sort_keys=False), encoding="utf-8"
22
+ )
18
23
 
19
24
 
20
25
  class TestLoadLifecycleConfig:
21
- def test_loads_declared_table(self, tmp_path: Path):
22
- _write_pyproject(
26
+ def test_loads_declared_files_and_dev_file(self, tmp_path: Path):
27
+ _write_config(
23
28
  tmp_path,
24
- "[tool.docker-devkit.lifecycle]\n"
25
- 'files = ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"]\n'
26
- 'dev_file = "compose.dev.yml"\n',
29
+ {
30
+ "lifecycle": {
31
+ "files": ["compose.yml", "compose.postgres.yml", "compose.{gpu}.yml"],
32
+ "dev_file": "compose.dev.yml",
33
+ }
34
+ },
27
35
  )
28
36
 
29
37
  config = load_lifecycle_config(tmp_path)
@@ -33,34 +41,36 @@ class TestLoadLifecycleConfig:
33
41
  dev_file="compose.dev.yml",
34
42
  )
35
43
 
36
- def test_bare_table_defaults_to_empty(self, tmp_path: Path):
37
- _write_pyproject(tmp_path, "[tool.docker-devkit.lifecycle]\n")
44
+ def test_dev_file_defaults_to_none_when_only_files_declared(self, tmp_path: Path):
45
+ _write_config(tmp_path, {"lifecycle": {"files": ["compose.yml"]}})
38
46
 
39
47
  config = load_lifecycle_config(tmp_path)
40
48
 
41
- assert config == LifecycleConfig()
49
+ assert config == LifecycleConfig(files=["compose.yml"])
42
50
 
43
- def test_absent_table_returns_none(self, tmp_path: Path):
44
- _write_pyproject(tmp_path, "[tool.docker-devkit.generate-score]\nworkloads = []\n")
51
+ def test_files_defaults_to_empty_when_only_dev_file_declared(self, tmp_path: Path):
52
+ _write_config(tmp_path, {"lifecycle": {"dev_file": "compose.dev.yml"}})
45
53
 
46
- assert load_lifecycle_config(tmp_path) is None
54
+ config = load_lifecycle_config(tmp_path)
55
+
56
+ assert config == LifecycleConfig(dev_file="compose.dev.yml")
47
57
 
48
- def test_absent_tool_section_returns_none(self, tmp_path: Path):
49
- _write_pyproject(tmp_path, "")
58
+ def test_absent_section_returns_none(self, tmp_path: Path):
59
+ _write_config(tmp_path, {"mirror": {"prefix": "ghcr.io/x/mirror"}})
50
60
 
51
61
  assert load_lifecycle_config(tmp_path) is None
52
62
 
53
- def test_missing_pyproject_returns_none(self, tmp_path: Path):
63
+ def test_missing_config_returns_none(self, tmp_path: Path):
54
64
  assert load_lifecycle_config(tmp_path) is None
55
65
 
56
66
  def test_rejects_unknown_keys(self, tmp_path: Path):
57
- _write_pyproject(tmp_path, "[tool.docker-devkit.lifecycle]\nfiles = []\nbogus = true\n")
67
+ _write_config(tmp_path, {"lifecycle": {"files": ["compose.yml"], "bogus": "true"}})
58
68
 
59
69
  with pytest.raises(ValidationError):
60
70
  load_lifecycle_config(tmp_path)
61
71
 
62
- def test_rejects_non_string_entries(self, tmp_path: Path):
63
- _write_pyproject(tmp_path, "[tool.docker-devkit.lifecycle]\nfiles = [1]\n")
72
+ def test_rejects_scalar_files(self, tmp_path: Path):
73
+ _write_config(tmp_path, {"lifecycle": {"files": "compose.yml"}})
64
74
 
65
75
  with pytest.raises(ValidationError):
66
76
  load_lifecycle_config(tmp_path)
@@ -3,6 +3,7 @@ import tempfile
3
3
  from pathlib import Path
4
4
 
5
5
  import pytest
6
+ import yaml
6
7
  from pydantic import ValidationError
7
8
  from typer.testing import CliRunner
8
9
 
@@ -42,6 +43,15 @@ LOCKED_ERROR = (
42
43
  runner = CliRunner()
43
44
 
44
45
 
46
+ def _write_config(root: Path, sections: dict[str, object] | None = None) -> None:
47
+ payload: dict[str, object] = {"requires": ">=0.0"}
48
+ if sections is not None:
49
+ payload.update(sections)
50
+ (root / "docker-devkit.yaml").write_text(
51
+ yaml.safe_dump(payload, default_flow_style=False, sort_keys=False), encoding="utf-8"
52
+ )
53
+
54
+
45
55
  class SelectiveExistence:
46
56
  def __init__(self, present: str) -> None:
47
57
  self.present = present
@@ -139,32 +149,26 @@ class TestInversion:
139
149
 
140
150
  class TestLoadMirrorConfig:
141
151
  def test_loads_declared_prefix(self, tmp_path: Path):
142
- (tmp_path / "pyproject.toml").write_text(
143
- f"[tool.docker-devkit.mirror]\nprefix = '{MIRROR_PREFIX}'\n", encoding="utf-8"
144
- )
152
+ _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
145
153
 
146
154
  assert mirror.load_mirror_config(tmp_path) == MirrorConfig(prefix=MIRROR_PREFIX)
147
155
 
148
- def test_absent_table_returns_none(self, tmp_path: Path):
149
- (tmp_path / "pyproject.toml").write_text("[tool.docker-devkit.lifecycle]\nfiles = []\n", encoding="utf-8")
156
+ def test_absent_section_returns_none(self, tmp_path: Path):
157
+ _write_config(tmp_path, {"lifecycle": {"files": ["compose.yml"]}})
150
158
 
151
159
  assert mirror.load_mirror_config(tmp_path) is None
152
160
 
153
- def test_missing_pyproject_returns_none(self, tmp_path: Path):
161
+ def test_missing_config_returns_none(self, tmp_path: Path):
154
162
  assert mirror.load_mirror_config(tmp_path) is None
155
163
 
156
164
  def test_rejects_unknown_keys(self, tmp_path: Path):
157
- (tmp_path / "pyproject.toml").write_text(
158
- "[tool.docker-devkit.mirror]\nprefix = 'ghcr.io/x/mirror'\nbogus = true\n", encoding="utf-8"
159
- )
165
+ _write_config(tmp_path, {"mirror": {"prefix": "ghcr.io/x/mirror", "bogus": "true"}})
160
166
 
161
167
  with pytest.raises(ValidationError):
162
168
  mirror.load_mirror_config(tmp_path)
163
169
 
164
170
  def test_rejects_trailing_slash_prefix(self, tmp_path: Path):
165
- (tmp_path / "pyproject.toml").write_text(
166
- "[tool.docker-devkit.mirror]\nprefix = 'ghcr.io/x/mirror/'\n", encoding="utf-8"
167
- )
171
+ _write_config(tmp_path, {"mirror": {"prefix": "ghcr.io/x/mirror/"}})
168
172
 
169
173
  with pytest.raises(ValidationError):
170
174
  mirror.load_mirror_config(tmp_path)
@@ -251,9 +255,7 @@ class TestWriteFallbackOverlay:
251
255
 
252
256
  class TestMirrorVerb:
253
257
  def _write_tree(self, tmp_path: Path) -> None:
254
- (tmp_path / "pyproject.toml").write_text(
255
- f"[tool.docker-devkit.mirror]\nprefix = '{MIRROR_PREFIX}'\n", encoding="utf-8"
256
- )
258
+ _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
257
259
  (tmp_path / "workloads").mkdir()
258
260
  (tmp_path / "workloads" / "images.yml").write_text(MANIFEST, encoding="utf-8")
259
261
 
@@ -281,14 +283,14 @@ class TestMirrorVerb:
281
283
 
282
284
 
283
285
  class TestUpstreamAwareLockResolution:
284
- def _write_tree(self, tmp_path: Path, *, mirror_table: bool) -> None:
285
- table = f"[tool.docker-devkit.mirror]\nprefix = '{MIRROR_PREFIX}'\n" if mirror_table else ""
286
- (tmp_path / "pyproject.toml").write_text(f"[project]\nname = 'stack'\n\n{table}", encoding="utf-8")
286
+ def _write_tree(self, tmp_path: Path, *, mirror: bool) -> None:
287
+ if mirror:
288
+ _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
287
289
  (tmp_path / "workloads").mkdir()
288
290
  (tmp_path / "workloads" / "images.yml").write_text(MANIFEST, encoding="utf-8")
289
291
 
290
292
  def test_resolves_mirror_declarations_against_upstream(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
291
- self._write_tree(tmp_path, mirror_table=True)
293
+ self._write_tree(tmp_path, mirror=True)
292
294
  monkeypatch.chdir(tmp_path)
293
295
  recorder = DigestRecorder()
294
296
  monkeypatch.setattr(build_docker, "resolve_remote_digest", recorder.resolve)
@@ -305,7 +307,7 @@ class TestUpstreamAwareLockResolution:
305
307
  assert f"UV_IMAGE={MIRROR_PREFIX}/ghcr.io/astral-sh/uv:0.12.15@{FAKE_DIGEST}" in lock
306
308
 
307
309
  def test_without_table_inspects_the_declared_reference(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
308
- self._write_tree(tmp_path, mirror_table=False)
310
+ self._write_tree(tmp_path, mirror=False)
309
311
  monkeypatch.chdir(tmp_path)
310
312
  recorder = DigestRecorder()
311
313
  monkeypatch.setattr(build_docker, "resolve_remote_digest", recorder.resolve)
@@ -321,10 +323,7 @@ class TestUpstreamAwareLockResolution:
321
323
 
322
324
  class TestBakeConsumesEnvOverlay:
323
325
  def _write_tree(self, tmp_path: Path) -> None:
324
- (tmp_path / "pyproject.toml").write_text(
325
- f"[project]\nname = 'stack'\n\n[tool.docker-devkit.mirror]\nprefix = '{MIRROR_PREFIX}'\n",
326
- encoding="utf-8",
327
- )
326
+ _write_config(tmp_path, {"mirror": {"prefix": MIRROR_PREFIX}})
328
327
  (tmp_path / "workloads").mkdir()
329
328
  (tmp_path / "workloads" / "images.yml").write_text(MANIFEST, encoding="utf-8")
330
329
 
@@ -1,6 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
3
  import pytest
4
+ import yaml
4
5
  from pydantic import ValidationError
5
6
 
6
7
  from docker_devkit.score_generate import (
@@ -13,6 +14,14 @@ from docker_devkit.score_generate import (
13
14
  )
14
15
 
15
16
 
17
+ def _write_config(root: Path, sections: dict[str, object]) -> None:
18
+ payload: dict[str, object] = {"requires": ">=0.0"}
19
+ payload.update(sections)
20
+ (root / "docker-devkit.yaml").write_text(
21
+ yaml.safe_dump(payload, default_flow_style=False, sort_keys=False), encoding="utf-8"
22
+ )
23
+
24
+
16
25
  def make_config(**overrides: str | list[str]) -> ScoreConfig:
17
26
  values: dict[str, str | list[str]] = {
18
27
  "workloads": ["api.yaml", "gateway.yaml"],
@@ -93,15 +102,18 @@ def test_write_normalises_file_mode(tmp_path: Path) -> None:
93
102
  assert path.read_text(encoding="utf-8") == "content\n"
94
103
 
95
104
 
96
- def test_load_score_config_reads_consumer_table(tmp_path: Path) -> None:
97
- (tmp_path / "pyproject.toml").write_text(
98
- "[tool.docker-devkit.generate-score]\n"
99
- 'workloads = ["api.yaml"]\n'
100
- 'project_name = "placeframe"\n'
101
- 'cloud_storage_class = "hcloud-volumes"\n'
102
- 'local_storage_class = "local-path"\n'
103
- 'publishes = ["8000:api:8000"]\n',
104
- encoding="utf-8",
105
+ def test_load_score_config_reads_consumer_section(tmp_path: Path) -> None:
106
+ _write_config(
107
+ tmp_path,
108
+ {
109
+ "generate-score": {
110
+ "workloads": ["api.yaml"],
111
+ "project_name": "placeframe",
112
+ "cloud_storage_class": "hcloud-volumes",
113
+ "local_storage_class": "local-path",
114
+ "publishes": ["8000:api:8000"],
115
+ }
116
+ },
105
117
  )
106
118
 
107
119
  config = load_score_config(tmp_path)
@@ -118,9 +130,7 @@ def test_load_score_config_reads_consumer_table(tmp_path: Path) -> None:
118
130
  assert config.score_compose_version == "0.42.0"
119
131
 
120
132
 
121
- def test_load_score_config_missing_table_raises(tmp_path: Path) -> None:
122
- (tmp_path / "pyproject.toml").write_text('[project]\nname = "consumer"\n', encoding="utf-8")
123
-
133
+ def test_load_score_config_missing_section_raises(tmp_path: Path) -> None:
124
134
  with pytest.raises(RuntimeError, match="generate-score"):
125
135
  load_score_config(tmp_path)
126
136
 
@@ -56,10 +56,12 @@ version = "0.0.0.dev0"
56
56
  source = { editable = "." }
57
57
  dependencies = [
58
58
  { name = "bashrun" },
59
+ { name = "packaging" },
59
60
  { name = "pathspec" },
60
61
  { name = "pydantic" },
61
62
  { name = "pydantic-settings" },
62
63
  { name = "pyyaml" },
64
+ { name = "strictyaml" },
63
65
  { name = "typer" },
64
66
  ]
65
67
 
@@ -69,15 +71,18 @@ dev = [
69
71
  { name = "pytest" },
70
72
  { name = "ruff" },
71
73
  { name = "types-pyyaml" },
74
+ { name = "types-strictyaml" },
72
75
  ]
73
76
 
74
77
  [package.metadata]
75
78
  requires-dist = [
76
79
  { name = "bashrun", specifier = ">=0.1.0" },
80
+ { name = "packaging", specifier = ">=24.1" },
77
81
  { name = "pathspec", specifier = ">=1.0.4" },
78
82
  { name = "pydantic", specifier = ">=2.11.7" },
79
83
  { name = "pydantic-settings", specifier = ">=2.9.1" },
80
84
  { name = "pyyaml", specifier = ">=6.0" },
85
+ { name = "strictyaml", specifier = ">=1.7.3" },
81
86
  { name = "typer", specifier = ">=0.17.4" },
82
87
  ]
83
88
 
@@ -87,6 +92,7 @@ dev = [
87
92
  { name = "pytest", specifier = ">=8.0.0" },
88
93
  { name = "ruff", specifier = ">=0.14.11" },
89
94
  { name = "types-pyyaml", specifier = ">=6.0.12.20260906" },
95
+ { name = "types-strictyaml", specifier = ">=1.7.3.3" },
90
96
  ]
91
97
 
92
98
  [[package]]
@@ -272,6 +278,18 @@ wheels = [
272
278
  { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
273
279
  ]
274
280
 
281
+ [[package]]
282
+ name = "python-dateutil"
283
+ version = "2.9.0.post0"
284
+ source = { registry = "https://pypi.org/simple" }
285
+ dependencies = [
286
+ { name = "six" },
287
+ ]
288
+ sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
289
+ wheels = [
290
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
291
+ ]
292
+
275
293
  [[package]]
276
294
  name = "python-dotenv"
277
295
  version = "1.2.2"
@@ -364,6 +382,27 @@ wheels = [
364
382
  { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
365
383
  ]
366
384
 
385
+ [[package]]
386
+ name = "six"
387
+ version = "1.17.0"
388
+ source = { registry = "https://pypi.org/simple" }
389
+ sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
390
+ wheels = [
391
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
392
+ ]
393
+
394
+ [[package]]
395
+ name = "strictyaml"
396
+ version = "1.7.3"
397
+ source = { registry = "https://pypi.org/simple" }
398
+ dependencies = [
399
+ { name = "python-dateutil" },
400
+ ]
401
+ sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" }
402
+ wheels = [
403
+ { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" },
404
+ ]
405
+
367
406
  [[package]]
368
407
  name = "typer"
369
408
  version = "0.26.8"
@@ -388,6 +427,18 @@ wheels = [
388
427
  { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" },
389
428
  ]
390
429
 
430
+ [[package]]
431
+ name = "types-strictyaml"
432
+ version = "1.7.3.3"
433
+ source = { registry = "https://pypi.org/simple" }
434
+ dependencies = [
435
+ { name = "strictyaml" },
436
+ ]
437
+ sdist = { url = "https://files.pythonhosted.org/packages/2b/81/50a74541bb5526033909fda5f343b3fc57c88b7a07fd7ff1eaa9eda98c65/types_strictyaml-1.7.3.3.tar.gz", hash = "sha256:61aeb8a13f44fd15cc348e34fac002f5a9b6189f258d2c7acfe79abccc22d57d", size = 46683, upload-time = "2026-09-12T14:57:49.605Z" }
438
+ wheels = [
439
+ { url = "https://files.pythonhosted.org/packages/7b/ef/c3b4a95c43b615f8f61a1f6c3b4ca23a1cade47e15c209b2e9a3c9a863db/types_strictyaml-1.7.3.3-py3-none-any.whl", hash = "sha256:21f5eefb69dd274a72bc1946c51ceeb2f73031dcc2495072dc88f645a0d9888b", size = 67467, upload-time = "2026-09-12T14:57:48.123Z" },
440
+ ]
441
+
391
442
  [[package]]
392
443
  name = "typing-extensions"
393
444
  version = "4.16.0"
@@ -1,13 +0,0 @@
1
- {
2
- "packages": [
3
- {
4
- "name": "docker-devkit",
5
- "path": ".",
6
- "major_minor": "0.1",
7
- "registries": {
8
- "pypi": "docker-devkit"
9
- }
10
- }
11
- ],
12
- "ci_workflow": "ci.yml"
13
- }
File without changes
File without changes
File without changes
File without changes
File without changes