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,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)
|