Skip to content

Commit 287bb86

Browse files
authored
Port the compose-to-podman-pod converter into compose2pod (#1)
* test: add shared chats_compose fixture * feat: healthcheck translation and UnsupportedComposeError * feat: dependency graph and startup order * feat: compose subset validation with non-dict guard Adds parsing module that validates compose documents against the supported subset, including a non-dict guard that raises UnsupportedComposeError for non-mapping inputs. * feat: pod script emission with start_period/retries pass-through * feat: CLI with JSON/YAML input and public API exports Add compose2pod.cli (main, POD_NAME_PATTERN, JSON/YAML auto-detect via --format), the python -m compose2pod entrypoint, and populate the package __init__ with the public API (validate, emit_script, EmitOptions, UnsupportedComposeError). Omit the trivial __main__.py shim from coverage (it is exercised only via subprocess in tests, which the parent coverage process can't measure); this keeps the enforced gate at 100% line coverage without enabling branch measurement. * ci: lint + pytest matrix and tag-driven PyPI trusted publishing * fix: guard missing compose file, align setup-uv version
1 parent 048231e commit 287bb86

19 files changed

Lines changed: 1193 additions & 5 deletions

.github/workflows/_checks.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: checks
2+
on:
3+
workflow_call: {}
4+
5+
jobs:
6+
lint:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v6
10+
- uses: extractions/setup-just@v4
11+
- uses: astral-sh/setup-uv@v8.2.0
12+
with:
13+
enable-cache: true
14+
cache-dependency-glob: "**/pyproject.toml"
15+
- run: uv python install 3.10
16+
- run: uv python pin 3.10
17+
- run: just install lint-ci
18+
19+
pytest:
20+
runs-on: ubuntu-latest
21+
strategy:
22+
fail-fast: false
23+
matrix:
24+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
25+
steps:
26+
- uses: actions/checkout@v6
27+
- uses: extractions/setup-just@v4
28+
- uses: astral-sh/setup-uv@v8.2.0
29+
with:
30+
enable-cache: true
31+
cache-dependency-glob: "**/pyproject.toml"
32+
- run: uv python install ${{ matrix.python-version }}
33+
- run: uv python pin ${{ matrix.python-version }}
34+
- run: just install
35+
- run: just test-ci

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: main
2+
on:
3+
push:
4+
branches:
5+
- main
6+
pull_request: {}
7+
8+
concurrency:
9+
group: ${{ github.head_ref || github.run_id }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
checks:
14+
uses: ./.github/workflows/_checks.yml

.github/workflows/release.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Release
2+
3+
# Tag-driven: pushing a semver tag publishes to PyPI (Trusted Publishing) and
4+
# creates the matching GitHub Release with auto-generated notes.
5+
on:
6+
push:
7+
tags:
8+
- '[0-9]+.[0-9]+.[0-9]+'
9+
- '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+'
10+
11+
permissions:
12+
contents: write
13+
id-token: write
14+
15+
jobs:
16+
release:
17+
runs-on: ubuntu-latest
18+
environment: pypi
19+
steps:
20+
- uses: actions/checkout@v6
21+
- uses: extractions/setup-just@v4
22+
- uses: astral-sh/setup-uv@v8.2.0
23+
- run: just publish
24+
- name: Resolve prerelease flag
25+
id: meta
26+
run: |
27+
set -euo pipefail
28+
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
29+
echo "prerelease=true" >> "$GITHUB_OUTPUT"
30+
else
31+
echo "prerelease=false" >> "$GITHUB_OUTPUT"
32+
fi
33+
- name: Publish GitHub Release
34+
uses: softprops/action-gh-release@v3
35+
with:
36+
generate_release_notes: true
37+
prerelease: ${{ steps.meta.outputs.prerelease }}
38+
draft: false

compose2pod/__init__.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
"""compose2pod: convert a Docker Compose file into a single-Podman-pod run script.
1+
"""compose2pod: convert a Docker Compose file into a single-Podman-pod run script."""
22

3-
Public API (validate, emit_script, EmitOptions, UnsupportedComposeError) is added
4-
during implementation, when the parsing/emit/cli modules land. See
5-
docs spec: 2026-07-03-compose2pod-pypi-design.md.
6-
"""
3+
from compose2pod.emit import EmitOptions, emit_script
4+
from compose2pod.exceptions import UnsupportedComposeError
5+
from compose2pod.parsing import validate
6+
7+
8+
__all__ = [
9+
"EmitOptions",
10+
"UnsupportedComposeError",
11+
"emit_script",
12+
"validate",
13+
]

compose2pod/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""python -m compose2pod entry point."""
2+
3+
import sys
4+
5+
from compose2pod.cli import main
6+
7+
8+
if __name__ == "__main__": # pragma: no cover - exercised via subprocess in tests
9+
sys.exit(main())

compose2pod/cli.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Command-line interface: read a compose document and emit the pod script."""
2+
3+
import argparse
4+
import contextlib
5+
import json
6+
import re
7+
import sys
8+
from pathlib import Path
9+
from types import ModuleType
10+
from typing import Any
11+
12+
from compose2pod.emit import EmitOptions, emit_script
13+
from compose2pod.exceptions import UnsupportedComposeError
14+
from compose2pod.parsing import validate
15+
16+
17+
_yaml: ModuleType | None = None
18+
with contextlib.suppress(ImportError): # the optional [yaml] extra is not installed
19+
import yaml as _yaml
20+
21+
22+
POD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
23+
24+
25+
def _load_yaml(text: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
26+
if _yaml is None:
27+
msg = "YAML input requires the 'yaml' extra: pip install compose2pod[yaml] (or pipe JSON via yq)"
28+
raise UnsupportedComposeError(msg)
29+
try:
30+
return _yaml.safe_load(text)
31+
except _yaml.YAMLError as error:
32+
msg = f"invalid YAML: {error}"
33+
raise UnsupportedComposeError(msg) from error
34+
35+
36+
def _read_compose(text: str, fmt: str) -> Any: # noqa: ANN401 - returns arbitrary parsed compose data
37+
if fmt == "json":
38+
return json.loads(text)
39+
if fmt == "yaml":
40+
return _load_yaml(text)
41+
try:
42+
return json.loads(text)
43+
except json.JSONDecodeError:
44+
return _load_yaml(text)
45+
46+
47+
def main(argv: list[str] | None = None) -> int:
48+
parser = argparse.ArgumentParser(
49+
prog="compose2pod",
50+
description="Convert a Docker Compose document to a podman-pod run script (stdout).",
51+
)
52+
parser.add_argument("file", nargs="?", help="compose file to read (default: stdin)")
53+
parser.add_argument("--target", required=True, help="service to run in the foreground with --command")
54+
parser.add_argument("--image", required=True, help="CI image replacing services that have a build section")
55+
parser.add_argument("--project-dir", default=".", help="host path relative volume/env_file sources resolve to")
56+
parser.add_argument("--command", default="", help="shell command overriding the target service command")
57+
parser.add_argument("--pod-name", default="test-pod")
58+
parser.add_argument("--format", choices=("auto", "json", "yaml"), default="auto")
59+
parser.add_argument(
60+
"--artifact",
61+
action="append",
62+
default=[],
63+
metavar="SRC:DST",
64+
help="file to podman-cp out of the target container after it exits",
65+
)
66+
parser.add_argument(
67+
"--allow-exit-code",
68+
type=int,
69+
action="append",
70+
default=[],
71+
help="target exit code treated as success in addition to 0",
72+
)
73+
args = parser.parse_args(argv)
74+
if not POD_NAME_PATTERN.match(args.pod_name):
75+
sys.stderr.write(f"compose2pod: error: invalid pod name {args.pod_name!r}\n")
76+
return 2
77+
if args.file:
78+
try:
79+
text = Path(args.file).read_text()
80+
except OSError as error:
81+
sys.stderr.write(f"compose2pod: error: could not read file: {error}\n")
82+
return 2
83+
else:
84+
text = sys.stdin.read()
85+
try:
86+
compose = _read_compose(text, args.format)
87+
except (json.JSONDecodeError, UnsupportedComposeError) as error:
88+
sys.stderr.write(f"compose2pod: error: could not parse compose input: {error}\n")
89+
return 2
90+
try:
91+
warnings = validate(compose)
92+
script = emit_script(
93+
compose=compose,
94+
options=EmitOptions(
95+
target=args.target,
96+
ci_image=args.image,
97+
command=args.command,
98+
pod=args.pod_name,
99+
project_dir=args.project_dir,
100+
artifacts=args.artifact,
101+
allow_exit_codes=args.allow_exit_code,
102+
),
103+
)
104+
except UnsupportedComposeError as error:
105+
sys.stderr.write(f"compose2pod: error: {error}\n")
106+
return 2
107+
for warning in warnings:
108+
sys.stderr.write(f"compose2pod: {warning}\n")
109+
sys.stdout.write(script)
110+
return 0

0 commit comments

Comments
 (0)