reforge-build 0.1.0__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.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ build/
7
+ .reforge/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Winston Ewert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: reforge-build
3
+ Version: 0.1.0
4
+ Summary: Explicit, cached subprocess.run for ad-hoc build pipelines.
5
+ Project-URL: Homepage, https://github.com/winstonewert/reforge
6
+ Project-URL: Issues, https://github.com/winstonewert/reforge/issues
7
+ Author-email: Winston Ewert <winstonewert@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: build,cache,make,pipeline,subprocess
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Build Tools
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+
24
+ # reforge
25
+
26
+ An explicit, cached `subprocess.run` for ad-hoc build pipelines.
27
+
28
+ Reforge is a tiny build helper: you write your pipeline as a plain Python
29
+ script that calls `run(...)` for each step, and reforge skips steps whose
30
+ declared inputs haven't changed. No DAG, no rules file, no DSL — just
31
+ `subprocess.run` with a content-addressed cache.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ pip install reforge-build
37
+ # or
38
+ uv add reforge-build
39
+ ```
40
+
41
+ The PyPI distribution name is `reforge-build` (the bare `reforge` name was
42
+ already taken on PyPI). The import name is still `reforge`.
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from reforge import input, output, run
48
+
49
+ # Inputs and outputs must be declared explicitly.
50
+ run(
51
+ "pandoc",
52
+ input("docs/index.md"),
53
+ "-o",
54
+ output("build/index.html"),
55
+ )
56
+
57
+ # stdout/stderr can be captured to declared outputs.
58
+ run(
59
+ "python",
60
+ input("scripts/list_things.py"),
61
+ stdout=output("build/things.tsv"),
62
+ )
63
+
64
+ # When an output is a directory containing a known sentinel file,
65
+ # pass the sentinel as the second argument so reforge can check it.
66
+ run(
67
+ "iqtree2",
68
+ "-s", input("data/aln.fasta"),
69
+ "--prefix", output("build/iqtree/output", ".log"),
70
+ )
71
+ ```
72
+
73
+ ### Rules
74
+
75
+ - `run(...)` executes immediately.
76
+ - Inputs must be wrapped with `input(...)`; missing inputs raise `FileNotFoundError`.
77
+ - Outputs must be wrapped with `output(...)`; their parent directories are auto-created.
78
+ - A step is skipped only when **all** declared outputs exist *and* the fingerprint
79
+ (command + content hash of every input) matches the cached fingerprint.
80
+ - A step with **no inputs always runs** — there is no way to fingerprint it safely.
81
+ - Commands run in a fresh temporary working directory, so they can't accidentally
82
+ read or write relative paths in your tree.
83
+ - Cache metadata lives in `.reforge/` at the current working directory.
84
+
85
+ ### Note on `input`
86
+
87
+ The module exports `input` and `output`, which shadow the Python builtin
88
+ `input()` when star-imported. If you need the builtin, import it explicitly:
89
+
90
+ ```python
91
+ from reforge import input as rf_input, output, run
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT
@@ -0,0 +1,73 @@
1
+ # reforge
2
+
3
+ An explicit, cached `subprocess.run` for ad-hoc build pipelines.
4
+
5
+ Reforge is a tiny build helper: you write your pipeline as a plain Python
6
+ script that calls `run(...)` for each step, and reforge skips steps whose
7
+ declared inputs haven't changed. No DAG, no rules file, no DSL — just
8
+ `subprocess.run` with a content-addressed cache.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pip install reforge-build
14
+ # or
15
+ uv add reforge-build
16
+ ```
17
+
18
+ The PyPI distribution name is `reforge-build` (the bare `reforge` name was
19
+ already taken on PyPI). The import name is still `reforge`.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ from reforge import input, output, run
25
+
26
+ # Inputs and outputs must be declared explicitly.
27
+ run(
28
+ "pandoc",
29
+ input("docs/index.md"),
30
+ "-o",
31
+ output("build/index.html"),
32
+ )
33
+
34
+ # stdout/stderr can be captured to declared outputs.
35
+ run(
36
+ "python",
37
+ input("scripts/list_things.py"),
38
+ stdout=output("build/things.tsv"),
39
+ )
40
+
41
+ # When an output is a directory containing a known sentinel file,
42
+ # pass the sentinel as the second argument so reforge can check it.
43
+ run(
44
+ "iqtree2",
45
+ "-s", input("data/aln.fasta"),
46
+ "--prefix", output("build/iqtree/output", ".log"),
47
+ )
48
+ ```
49
+
50
+ ### Rules
51
+
52
+ - `run(...)` executes immediately.
53
+ - Inputs must be wrapped with `input(...)`; missing inputs raise `FileNotFoundError`.
54
+ - Outputs must be wrapped with `output(...)`; their parent directories are auto-created.
55
+ - A step is skipped only when **all** declared outputs exist *and* the fingerprint
56
+ (command + content hash of every input) matches the cached fingerprint.
57
+ - A step with **no inputs always runs** — there is no way to fingerprint it safely.
58
+ - Commands run in a fresh temporary working directory, so they can't accidentally
59
+ read or write relative paths in your tree.
60
+ - Cache metadata lives in `.reforge/` at the current working directory.
61
+
62
+ ### Note on `input`
63
+
64
+ The module exports `input` and `output`, which shadow the Python builtin
65
+ `input()` when star-imported. If you need the builtin, import it explicitly:
66
+
67
+ ```python
68
+ from reforge import input as rf_input, output, run
69
+ ```
70
+
71
+ ## License
72
+
73
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "reforge-build"
7
+ version = "0.1.0"
8
+ description = "Explicit, cached subprocess.run for ad-hoc build pipelines."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Winston Ewert", email = "winstonewert@gmail.com" }]
13
+ keywords = ["build", "cache", "subprocess", "pipeline", "make"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Build Tools",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/winstonewert/reforge"
29
+ Issues = "https://github.com/winstonewert/reforge/issues"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/reforge"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["src/reforge", "README.md", "LICENSE", "pyproject.toml"]
@@ -0,0 +1,265 @@
1
+ """
2
+ Reforge - explicit and safe build tool helper.
3
+
4
+ Key behavioral rules:
5
+ - run(...) executes immediately
6
+ - No inputs -> ALWAYS RUN (no skip)
7
+ - Inputs must be declared Input(...)
8
+ - Outputs must be declared Output(...)
9
+ - Command runs in a temporary clean working dir (no accidental paths!)
10
+ - Cache fingerprint stored only when inputs exist and fingerprints match
11
+ """
12
+
13
+ import atexit
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import tempfile
19
+ import time
20
+ from dataclasses import asdict, dataclass
21
+ from pathlib import Path
22
+ from typing import List, Optional, Union
23
+
24
+ # --------------------------
25
+ # Artifact types
26
+ # --------------------------
27
+
28
+ __all__ = ["input", "output", "run"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Input:
33
+ path: Path
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Output:
38
+ path: Path
39
+ output_path: Path
40
+
41
+
42
+ def input(path: str) -> Input:
43
+ return Input(project_root() / path)
44
+
45
+
46
+ def output(path: str, subpath: str = '') -> Output:
47
+ return Output(project_root() / path, project_root() / (path + subpath))
48
+
49
+
50
+ # --------------------------
51
+ # Cache utilities
52
+ # --------------------------
53
+
54
+ CACHE_DIR_NAME = ".reforge"
55
+
56
+
57
+ def project_root() -> Path:
58
+ return Path.cwd().absolute()
59
+
60
+
61
+ def cache_dir() -> Path:
62
+ p = project_root() / CACHE_DIR_NAME
63
+ p.mkdir(parents=True, exist_ok=True)
64
+ return p
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Meta:
69
+ fingerprint: str
70
+ command: str
71
+ timestamp: float
72
+
73
+
74
+ def _safe_meta_name(path: Path) -> str:
75
+ relpath = path.relative_to(project_root())
76
+ return str(relpath).replace(os.sep, "__") + ".meta.json"
77
+
78
+
79
+ def _meta_path(relpath: Path) -> Path:
80
+ return cache_dir() / _safe_meta_name(relpath)
81
+
82
+
83
+ # --------------------------
84
+ # Hashing helpers
85
+ # --------------------------
86
+
87
+
88
+ _HASH_CACHE_FILE = "_file_hash_cache.json"
89
+ _hash_cache: Optional[dict] = None
90
+ _hash_cache_dirty = False
91
+
92
+
93
+ def _load_hash_cache() -> dict:
94
+ global _hash_cache
95
+ if _hash_cache is not None:
96
+ return _hash_cache
97
+ p = cache_dir() / _HASH_CACHE_FILE
98
+ try:
99
+ _hash_cache = json.loads(p.read_text())
100
+ if not isinstance(_hash_cache, dict):
101
+ _hash_cache = {}
102
+ except (FileNotFoundError, json.JSONDecodeError):
103
+ _hash_cache = {}
104
+ atexit.register(_save_hash_cache)
105
+ return _hash_cache
106
+
107
+
108
+ def _save_hash_cache():
109
+ if not _hash_cache_dirty or _hash_cache is None:
110
+ return
111
+ (cache_dir() / _HASH_CACHE_FILE).write_text(json.dumps(_hash_cache))
112
+
113
+
114
+ def _hash_file(path: Path) -> str:
115
+ global _hash_cache_dirty
116
+ cache = _load_hash_cache()
117
+ st = path.stat()
118
+ key = str(path)
119
+ entry = cache.get(key)
120
+ if (
121
+ entry is not None
122
+ and entry.get("mtime_ns") == st.st_mtime_ns
123
+ and entry.get("size") == st.st_size
124
+ ):
125
+ return entry["digest"]
126
+
127
+ h = hashlib.sha256()
128
+ with path.open("rb") as f:
129
+ while chunk := f.read(1 << 16):
130
+ h.update(chunk)
131
+ digest = h.hexdigest()
132
+ cache[key] = {
133
+ "mtime_ns": st.st_mtime_ns,
134
+ "size": st.st_size,
135
+ "digest": digest,
136
+ }
137
+ _hash_cache_dirty = True
138
+ return digest
139
+
140
+
141
+ # --------------------------
142
+ # Run + caching
143
+ # --------------------------
144
+
145
+
146
+ def _command_id(cmd: List[str]) -> str:
147
+ return json.dumps(cmd)
148
+
149
+
150
+ def _fingerprint(cmd: List[str], input_paths: List[Path]) -> str:
151
+ h = hashlib.sha256()
152
+ h.update(_command_id(cmd).encode())
153
+ for p in sorted(input_paths, key=lambda x: str(x)):
154
+ h.update(_hash_file(p).encode())
155
+ return h.hexdigest()
156
+
157
+
158
+ def _load_meta(rel: Path) -> Optional[Meta]:
159
+ mp = _meta_path(rel)
160
+ if not mp.exists():
161
+ return None
162
+ try:
163
+ return Meta(**json.loads(mp.read_text()))
164
+ except Exception:
165
+ return None
166
+
167
+
168
+ def _save_meta(outputs: List[Path], fingerprint: str, command_id: str):
169
+ now = time.time()
170
+ for rel in outputs:
171
+ _meta_path(rel).write_text(
172
+ json.dumps(
173
+ asdict(Meta(fingerprint=fingerprint, command=command_id, timestamp=now))
174
+ )
175
+ )
176
+
177
+
178
+ def run(
179
+ *args: Union[str, Input, Output],
180
+ stdin: Optional[Input] = None,
181
+ stdout: Optional[Output] = None,
182
+ stderr: Optional[Output] = None,
183
+ ):
184
+
185
+ cmd: List[str] = []
186
+ inputs: List[Path] = []
187
+ outputs: List[Path] = []
188
+
189
+ # collect inputs and outputs from positional args
190
+ for a in args:
191
+ if isinstance(a, Input):
192
+ inputs.append(a.path)
193
+ cmd.append(str(a.path))
194
+ elif isinstance(a, Output):
195
+ outputs.append(a.output_path)
196
+ cmd.append(str(a.path))
197
+ else:
198
+ cmd.append(a)
199
+
200
+ # keyword stdout/stderr
201
+ if stdin:
202
+ inputs.append(stdin.path)
203
+ if stdout:
204
+ outputs.append(stdout.path)
205
+ if stderr:
206
+ outputs.append(stderr.path)
207
+
208
+ # dedupe outputs
209
+ outputs = list(dict.fromkeys(outputs))
210
+
211
+ # verify inputs exist
212
+ for p in inputs:
213
+ if not p.exists():
214
+ raise FileNotFoundError(f"Missing input: {p}")
215
+
216
+ command_id = _command_id(cmd)
217
+ fingerprint = _fingerprint(cmd, inputs)
218
+ if not inputs:
219
+ skip = False
220
+ else:
221
+ skip = True
222
+ for rel in outputs:
223
+ outp = project_root() / rel
224
+ if not outp.exists():
225
+ skip = False
226
+ break
227
+ meta = _load_meta(rel)
228
+ if not meta or meta.fingerprint != fingerprint:
229
+ skip = False
230
+ break
231
+
232
+ cmd_display = " ".join(cmd)
233
+
234
+ if skip:
235
+ return
236
+
237
+ print(f"RUN: {cmd_display}")
238
+
239
+ # ensure output dirs exist
240
+ for rel in outputs:
241
+ (project_root() / rel).parent.mkdir(parents=True, exist_ok=True)
242
+
243
+ # execute in isolated temp dir
244
+ with tempfile.TemporaryDirectory() as tmp:
245
+ tmp = Path(tmp)
246
+
247
+ result = subprocess.run(
248
+ cmd,
249
+ cwd=tmp,
250
+ stdin=open(stdin.path, "rb") if stdin else None,
251
+ stdout=subprocess.PIPE if stdout else None,
252
+ stderr=subprocess.PIPE if stderr else None,
253
+ check=True,
254
+ )
255
+
256
+ if stdout:
257
+ with open(project_root() / stdout.path, "wb") as output:
258
+ output.write(result.stdout)
259
+ if stderr:
260
+ with open(project_root() / stderr.path, "wb") as output:
261
+ output.write(result.stderr)
262
+
263
+ # Only save cache metadata when inputs exist
264
+ if inputs:
265
+ _save_meta(outputs, fingerprint, command_id)