Skip to content

Commit 3a26ab0

Browse files
refactor: split into Python package, concat for release
Single-file helmfile2compose.py is now a build artifact produced by build.py. Source lives in src/helmfile2compose/ (pacts/, core/, io/). CI builds and uploads the single file on tag push.
1 parent d473002 commit 3a26ab0

26 files changed

Lines changed: 2163 additions & 1866 deletions

.github/workflows/release.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Build & Release
2+
3+
on:
4+
push:
5+
tags: ["v*"]
6+
7+
permissions:
8+
contents: write
9+
10+
jobs:
11+
release:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.12"
19+
20+
- name: Install dependencies
21+
run: pip install pyyaml
22+
23+
- name: Build single-file distribution
24+
run: python build.py
25+
26+
- name: Upload release asset
27+
uses: softprops/action-gh-release@v2
28+
with:
29+
files: helmfile2compose.py

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ CLAUDE.local.md
1414
# Regression test outputs (contain resolved secrets)
1515
tests/regression/
1616

17+
# Build artifact (produced by build.py)
18+
helmfile2compose.py
19+
1720
# Python
1821
__pycache__/
1922
*.pyc

CLAUDE.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,36 @@ Part of the [helmfile2compose](https://github.com/helmfile2compose) org. This re
77
- [helmfile2compose.github.io](https://github.com/helmfile2compose/helmfile2compose.github.io) — full documentation site
88
- Extension repos: [h2c-provider-keycloak](https://github.com/helmfile2compose/h2c-provider-keycloak), [h2c-provider-servicemonitor](https://github.com/helmfile2compose/h2c-provider-servicemonitor), [h2c-converter-cert-manager](https://github.com/helmfile2compose/h2c-converter-cert-manager), [h2c-converter-trust-manager](https://github.com/helmfile2compose/h2c-converter-trust-manager), [h2c-transform-bitnami](https://github.com/helmfile2compose/h2c-transform-bitnami)
99

10-
## Workflow
10+
## Package structure
1111

12-
Lint often: run `pylint helmfile2compose.py` and `pyflakes helmfile2compose.py` after any change. Fix real issues (unused imports, actual bugs, f-strings without placeholders). Pylint style warnings (too-many-locals, line-too-long, etc.) are acceptable.
12+
Python package under `src/helmfile2compose/` with three layers:
1313

14-
Complexity: run `radon cc helmfile2compose.py -a -s -n C` to check cyclomatic complexity. Target: no D/E/F ratings. Current: 14 C-rated functions, average C (~14).
14+
- **`pacts/`** — public contracts for extensions (`ConvertContext`, `ConvertResult`, `IngressRewriter`, helpers). Stable API — extensions import from here (or from `helmfile2compose` directly via re-exports).
15+
- **`core/`** — internal conversion engine (`constants`, `env`, `volumes`, `services`, `workloads`, `ingress`, `extensions`, `convert`). Not public API.
16+
- **`io/`** — input/output (`parsing`, `config`, `output`). Not public API.
17+
- **`cli.py`** — CLI entry point.
1518

16-
**Null-safe YAML access:** `.get("key", {})` returns `None` when the key exists with an explicit `null` value (Helm conditional blocks). Always use `.get("key") or {}` / `.get("key") or []` for fields that Helm may render as null (`annotations`, `ports`, `initContainers`, `data`, `rules`, `selector`, etc.).
19+
The single-file `helmfile2compose.py` is a **build artifact** produced by `build.py` (concat script). It is not committed — CI builds it on tag push and uploads as a release asset. Users and h2c-manager see no change.
20+
21+
```bash
22+
# Development: run from package
23+
PYTHONPATH=src python -m helmfile2compose --from-dir /tmp/rendered --output-dir .
24+
25+
# Build single-file distribution
26+
python build.py
27+
# → helmfile2compose.py (gitignored)
1728

18-
## What exists
29+
# Validate with testsuite
30+
cd ../h2c-testsuite && ./run-tests.sh --local-core ../h2c-core/helmfile2compose.py
31+
```
1932

20-
Single script `helmfile2compose.py` (~1860 lines). No packages, no setup.py. Dependency: `pyyaml`.
33+
Dependency: `pyyaml`.
34+
35+
## Workflow
36+
37+
Lint often: run `pylint src/helmfile2compose/` and `pyflakes src/helmfile2compose/` after any change. Fix real issues (unused imports, actual bugs, f-strings without placeholders). Pylint style warnings (too-many-locals, line-too-long, etc.) are acceptable.
38+
39+
**Null-safe YAML access:** `.get("key", {})` returns `None` when the key exists with an explicit `null` value (Helm conditional blocks). Always use `.get("key") or {}` / `.get("key") or []` for fields that Helm may render as null (`annotations`, `ports`, `initContainers`, `data`, `rules`, `selector`, etc.).
2140

2241
### CLI
2342

@@ -70,10 +89,10 @@ Three extension types, loaded from the same `--extensions-dir`:
7089

7190
Extensions import `ConvertContext`/`ConvertResult`/`IngressRewriter` from `helmfile2compose`. `get_ingress_class(manifest, ingress_types)` and `resolve_backend(path_entry, manifest, ctx)` are public helpers for rewriters. `apply_replacements(text, replacements)` and `resolve_env(container, configmaps, secrets, workload_name, warnings, replacements=None, service_port_map=None)` are also public — available to extensions that need string replacement or env resolution. Available extensions:
7291
- **keycloak** — provider: `Keycloak`, `KeycloakRealmImport` (priority 50)
73-
- **cert-manager** — converter: `Certificate`, `ClusterIssuer`, `Issuer` (priority 10, requires `cryptography`)
92+
- **cert-manager** — converter: `Certificate`, `ClusterIssuer`, `Issuer` (priority 10, requires `cryptography`, incompatible with flatten-internal-urls)
7493
- **trust-manager** — converter: `Bundle` (priority 20, depends on cert-manager)
7594
- **servicemonitor** — provider: `Prometheus`, `ServiceMonitor` (priority 60, requires `pyyaml`)
76-
- **flatten-internal-urls** — transform: strip aliases, rewrite FQDNs (priority 200, incompatible with cert-manager)
95+
- **flatten-internal-urls** — transform: strip aliases, rewrite FQDNs (priority 200)
7796
- **bitnami** — transform: Bitnami Redis, PostgreSQL, Keycloak workarounds (priority 150)
7897
- **nginx** — ingress rewriter: Nginx annotations (rewrite-target, backend-protocol, CORS, proxy-body-size)
7998
- **traefik** — ingress rewriter: Traefik annotations (router.tls, standard path rules). POC.

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ CRDs (Keycloak, cert-manager, trust-manager) are handled by [external extensions
5656
- `helmfile2compose.yaml` — persistent config (volumes, excludes, overrides)
5757
- `configmaps/` / `secrets/` — generated files from volume mounts
5858

59+
## Development
60+
61+
The codebase is a Python package under `src/helmfile2compose/`. The single-file `helmfile2compose.py` is a build artifact produced by `build.py` — it concatenates the package into one file for distribution.
62+
63+
```bash
64+
# Run from the package
65+
PYTHONPATH=src python -m helmfile2compose --help
66+
67+
# Build the single-file distribution
68+
python build.py
69+
```
70+
71+
See the [core architecture docs](https://helmfile2compose.github.io/developer/core-architecture/) for the full package structure.
72+
5973
## Documentation
6074

6175
Full docs at [helmfile2compose.github.io](https://helmfile2compose.github.io).

build.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env python3
2+
"""Concatenate the helmfile2compose package into a single-file distribution."""
3+
4+
import re
5+
import subprocess
6+
import sys
7+
from pathlib import Path
8+
9+
SRC = Path(__file__).parent / "src" / "helmfile2compose"
10+
OUTPUT = Path(__file__).parent / "helmfile2compose.py"
11+
12+
# Module order respects the dependency graph (no forward references)
13+
MODULES = [
14+
"core/constants.py",
15+
"pacts/types.py",
16+
"pacts/helpers.py",
17+
"pacts/ingress.py",
18+
"core/env.py",
19+
"core/volumes.py",
20+
"core/services.py",
21+
"core/workloads.py",
22+
"core/ingress.py",
23+
"core/extensions.py",
24+
"core/convert.py",
25+
"io/parsing.py",
26+
"io/config.py",
27+
"io/output.py",
28+
"cli.py",
29+
]
30+
31+
# Imports to strip (internal cross-references, any indentation level)
32+
INTERNAL_IMPORT_RE = re.compile(
33+
r'^\s*(?:from helmfile2compose[\w.]* import .+|import helmfile2compose[\w.]*)\s*$'
34+
)
35+
36+
SHEBANG = "#!/usr/bin/env python3\n"
37+
DOCSTRING = '"""helmfile2compose — convert helmfile template output to compose.yml + Caddyfile."""\n'
38+
PYLINT_DISABLE = "# pylint: disable=too-many-locals\n"
39+
40+
41+
def collect_imports_and_body(path: Path) -> tuple[list[str], list[str]]:
42+
"""Split a module into stdlib/external imports and body lines."""
43+
imports = []
44+
body = []
45+
in_docstring = False
46+
docstring_delim = None
47+
in_internal_import = False # inside a multi-line internal import
48+
49+
for line in path.read_text().splitlines(keepends=True):
50+
stripped = line.strip()
51+
52+
# Skip module docstrings
53+
if not in_docstring and not body and not imports:
54+
if stripped.startswith(('"""', "'''")):
55+
delim = stripped[:3]
56+
if stripped.count(delim) >= 2:
57+
continue # single-line docstring
58+
in_docstring = True
59+
docstring_delim = delim
60+
continue
61+
if in_docstring:
62+
if docstring_delim in stripped:
63+
in_docstring = False
64+
continue
65+
66+
# Skip multi-line internal imports (continuation after opening paren)
67+
if in_internal_import:
68+
if ")" in stripped:
69+
in_internal_import = False
70+
continue
71+
72+
# Skip internal imports (single-line or start of multi-line)
73+
if INTERNAL_IMPORT_RE.match(line):
74+
if "(" in stripped and ")" not in stripped:
75+
in_internal_import = True
76+
continue
77+
78+
# Collect stdlib/external imports
79+
if stripped.startswith(("import ", "from ")) and not stripped.startswith("from ."):
80+
imports.append(line)
81+
continue
82+
83+
body.append(line)
84+
85+
return imports, body
86+
87+
88+
def main():
89+
all_imports: dict[str, str] = {} # dedup by stripped content
90+
all_bodies: list[str] = []
91+
92+
for mod_path in MODULES:
93+
full_path = SRC / mod_path
94+
if not full_path.exists():
95+
print(f"Error: {full_path} not found", file=sys.stderr)
96+
sys.exit(1)
97+
98+
imports, body = collect_imports_and_body(full_path)
99+
100+
for imp in imports:
101+
key = imp.strip()
102+
if key and key not in all_imports:
103+
all_imports[key] = imp
104+
105+
# Add section comment + body
106+
section = mod_path.replace(".py", "").replace("/", ".")
107+
all_bodies.append(f"\n# --- {section} ---\n")
108+
all_bodies.extend(body)
109+
110+
# Assemble
111+
lines = [SHEBANG, DOCSTRING, PYLINT_DISABLE, "\n"]
112+
lines.extend(all_imports.values())
113+
lines.append("\n")
114+
lines.extend(all_bodies)
115+
116+
# Add __main__ guard
117+
lines.append('\n\nif __name__ == "__main__":\n')
118+
lines.append(" main()\n")
119+
120+
OUTPUT.write_text("".join(lines))
121+
print(f"Built {OUTPUT} ({sum(1 for l in lines if l.strip())} non-empty lines)")
122+
123+
# Smoke test
124+
result = subprocess.run(
125+
[sys.executable, str(OUTPUT), "--help"],
126+
capture_output=True, text=True,
127+
)
128+
if result.returncode != 0:
129+
print(f"Smoke test FAILED:\n{result.stderr}", file=sys.stderr)
130+
sys.exit(1)
131+
print("Smoke test passed (--help)")
132+
133+
134+
if __name__ == "__main__":
135+
main()

0 commit comments

Comments
 (0)