puild 0.1.0__py3-none-any.whl
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.
- puild/__init__.py +19 -0
- puild/command.py +508 -0
- puild/fs.py +149 -0
- puild/logging.py +30 -0
- puild-0.1.0.dist-info/METADATA +113 -0
- puild-0.1.0.dist-info/RECORD +8 -0
- puild-0.1.0.dist-info/WHEEL +4 -0
- puild-0.1.0.dist-info/entry_points.txt +4 -0
puild/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from puild.command import Command, Pipeline, Result, is_dry_run, set_dry_run
|
|
2
|
+
from puild.fs import copy, find_files, mkdir, needs_rebuild, rm
|
|
3
|
+
from puild.logging import is_quiet, log_action, set_quiet
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Command",
|
|
7
|
+
"Pipeline",
|
|
8
|
+
"Result",
|
|
9
|
+
"copy",
|
|
10
|
+
"find_files",
|
|
11
|
+
"is_dry_run",
|
|
12
|
+
"is_quiet",
|
|
13
|
+
"log_action",
|
|
14
|
+
"mkdir",
|
|
15
|
+
"needs_rebuild",
|
|
16
|
+
"rm",
|
|
17
|
+
"set_dry_run",
|
|
18
|
+
"set_quiet",
|
|
19
|
+
]
|
puild/command.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal, Self, overload
|
|
12
|
+
|
|
13
|
+
from puild.logging import log_action
|
|
14
|
+
|
|
15
|
+
_DRY_RUN = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def set_dry_run(dry_run: bool) -> None:
|
|
19
|
+
"""Set global dry-run mode."""
|
|
20
|
+
global _DRY_RUN
|
|
21
|
+
_DRY_RUN = dry_run
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_dry_run() -> bool:
|
|
25
|
+
"""Check if dry-run mode is enabled."""
|
|
26
|
+
return _DRY_RUN
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Result[T: bytes | str]:
|
|
31
|
+
cmd: str
|
|
32
|
+
args: list[str]
|
|
33
|
+
cwd: str
|
|
34
|
+
stdout: T
|
|
35
|
+
stderr: T
|
|
36
|
+
return_code: int
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ok(self) -> bool:
|
|
40
|
+
"""Return True if command succeeded (return_code == 0)."""
|
|
41
|
+
return self.return_code == 0
|
|
42
|
+
|
|
43
|
+
def exit_for_error(self, message: str | None = None) -> None:
|
|
44
|
+
"""Exit the process if the command failed with a non-zero exit code."""
|
|
45
|
+
if self.return_code != 0:
|
|
46
|
+
if message:
|
|
47
|
+
log_action("Error", message, "red")
|
|
48
|
+
sys.exit(self.return_code)
|
|
49
|
+
|
|
50
|
+
def unwrap(self) -> T:
|
|
51
|
+
"""Return stdout if command succeeded, otherwise exit with the return code."""
|
|
52
|
+
self.exit_for_error()
|
|
53
|
+
return self.stdout
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Command:
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
cmd: str | Path,
|
|
60
|
+
*args: str | Path | Sequence[str | Path],
|
|
61
|
+
) -> None:
|
|
62
|
+
self._cmd = str(cmd)
|
|
63
|
+
self._args: list[str] = []
|
|
64
|
+
self._env: dict[str, str] = {}
|
|
65
|
+
self._cwd: str = "."
|
|
66
|
+
self._redirect_stdout: Path | None = None
|
|
67
|
+
self._redirect_append: bool = False
|
|
68
|
+
|
|
69
|
+
for arg in args:
|
|
70
|
+
if isinstance(arg, (list, tuple)):
|
|
71
|
+
self._args.extend(str(a) for a in arg)
|
|
72
|
+
else:
|
|
73
|
+
self._args.append(str(arg))
|
|
74
|
+
|
|
75
|
+
def copy(self) -> Command:
|
|
76
|
+
"""Return a shallow copy of the command."""
|
|
77
|
+
cmd = Command(self._cmd, *self._args)
|
|
78
|
+
cmd._env = self._env.copy()
|
|
79
|
+
cmd._cwd = self._cwd
|
|
80
|
+
cmd._redirect_stdout = self._redirect_stdout
|
|
81
|
+
cmd._redirect_append = self._redirect_append
|
|
82
|
+
return cmd
|
|
83
|
+
|
|
84
|
+
def arg(self, *args: str | Path) -> Self:
|
|
85
|
+
"""Append one or more positional arguments to the command."""
|
|
86
|
+
for a in args:
|
|
87
|
+
self._args.append(str(a))
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def args(self, args: Iterable[str | Path]) -> Self:
|
|
91
|
+
"""Append an iterable of arguments to the command."""
|
|
92
|
+
for a in args:
|
|
93
|
+
self._args.append(str(a))
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def env(
|
|
97
|
+
self,
|
|
98
|
+
key_or_dict: str | dict[str, str],
|
|
99
|
+
value: str | None = None,
|
|
100
|
+
) -> Self:
|
|
101
|
+
"""Set environment variables for the command."""
|
|
102
|
+
if isinstance(key_or_dict, dict):
|
|
103
|
+
for k, v in key_or_dict.items():
|
|
104
|
+
self._env[str(k)] = str(v)
|
|
105
|
+
elif value is not None:
|
|
106
|
+
self._env[str(key_or_dict)] = str(value)
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def cwd(self, path: str | Path) -> Self:
|
|
110
|
+
"""Set default working directory for this command."""
|
|
111
|
+
self._cwd = str(path)
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def to_list(self) -> list[str]:
|
|
115
|
+
"""Convert command and arguments to a list of strings."""
|
|
116
|
+
return [self._cmd] + self._args
|
|
117
|
+
|
|
118
|
+
def __repr__(self) -> str:
|
|
119
|
+
return f"Command({self.to_list()!r})"
|
|
120
|
+
|
|
121
|
+
def __or__(self, other: Command | Pipeline) -> Pipeline:
|
|
122
|
+
if isinstance(other, Command):
|
|
123
|
+
return Pipeline([self, other])
|
|
124
|
+
elif isinstance(other, Pipeline):
|
|
125
|
+
return Pipeline([self, *other.commands])
|
|
126
|
+
|
|
127
|
+
def __gt__(self, target: str | Path) -> Command:
|
|
128
|
+
"""Redirect stdout to file (overwrite): cmd > 'output.txt'"""
|
|
129
|
+
cmd = self.copy()
|
|
130
|
+
cmd._redirect_stdout = Path(target)
|
|
131
|
+
cmd._redirect_append = False
|
|
132
|
+
return cmd
|
|
133
|
+
|
|
134
|
+
def __rshift__(self, target: str | Path) -> Command:
|
|
135
|
+
"""Redirect stdout to file (append): cmd >> 'output.txt'"""
|
|
136
|
+
cmd = self.copy()
|
|
137
|
+
cmd._redirect_stdout = Path(target)
|
|
138
|
+
cmd._redirect_append = True
|
|
139
|
+
return cmd
|
|
140
|
+
|
|
141
|
+
@overload
|
|
142
|
+
def run(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
cwd: str | Path | None = None,
|
|
146
|
+
text: Literal[False] = False,
|
|
147
|
+
log: bool = True,
|
|
148
|
+
capture: bool = True,
|
|
149
|
+
stream: bool = False,
|
|
150
|
+
env: dict[str, str] | None = None,
|
|
151
|
+
input: bytes | None = None,
|
|
152
|
+
check: bool = False,
|
|
153
|
+
dry_run: bool | None = None,
|
|
154
|
+
) -> Result[bytes]: ...
|
|
155
|
+
|
|
156
|
+
@overload
|
|
157
|
+
def run(
|
|
158
|
+
self,
|
|
159
|
+
*,
|
|
160
|
+
cwd: str | Path | None = None,
|
|
161
|
+
text: Literal[True],
|
|
162
|
+
log: bool = True,
|
|
163
|
+
capture: bool = True,
|
|
164
|
+
stream: bool = False,
|
|
165
|
+
env: dict[str, str] | None = None,
|
|
166
|
+
input: str | None = None,
|
|
167
|
+
check: bool = False,
|
|
168
|
+
dry_run: bool | None = None,
|
|
169
|
+
) -> Result[str]: ...
|
|
170
|
+
|
|
171
|
+
@overload
|
|
172
|
+
def run(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
cwd: str | Path | None = None,
|
|
176
|
+
text: bool = False,
|
|
177
|
+
log: bool = True,
|
|
178
|
+
capture: bool = True,
|
|
179
|
+
stream: bool = False,
|
|
180
|
+
env: dict[str, str] | None = None,
|
|
181
|
+
input: Any = None,
|
|
182
|
+
check: bool = False,
|
|
183
|
+
dry_run: bool | None = None,
|
|
184
|
+
) -> Result[Any]: ...
|
|
185
|
+
|
|
186
|
+
def run(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
cwd: str | Path | None = None,
|
|
190
|
+
text: bool = False,
|
|
191
|
+
log: bool = True,
|
|
192
|
+
capture: bool = True,
|
|
193
|
+
stream: bool = False,
|
|
194
|
+
env: dict[str, str] | None = None,
|
|
195
|
+
input: Any = None,
|
|
196
|
+
check: bool = False,
|
|
197
|
+
dry_run: bool | None = None,
|
|
198
|
+
) -> Result[Any]:
|
|
199
|
+
effective_cwd = str(cwd) if cwd is not None else self._cwd
|
|
200
|
+
is_dry = _DRY_RUN if dry_run is None else dry_run
|
|
201
|
+
|
|
202
|
+
cmd_display = " ".join(self.to_list())
|
|
203
|
+
if self._redirect_stdout:
|
|
204
|
+
op = ">>" if self._redirect_append else ">"
|
|
205
|
+
cmd_display = f"{cmd_display} {op} {self._redirect_stdout}"
|
|
206
|
+
|
|
207
|
+
if log:
|
|
208
|
+
if effective_cwd != ".":
|
|
209
|
+
log_action("CD", effective_cwd, "yellow")
|
|
210
|
+
if is_dry:
|
|
211
|
+
log_action("Dry-Run", cmd_display, "cyan")
|
|
212
|
+
else:
|
|
213
|
+
log_action("Run", cmd_display, "blue")
|
|
214
|
+
|
|
215
|
+
empty_out = "" if text else b""
|
|
216
|
+
if is_dry:
|
|
217
|
+
return Result(
|
|
218
|
+
cmd=self._cmd,
|
|
219
|
+
args=self._args,
|
|
220
|
+
cwd=effective_cwd,
|
|
221
|
+
stdout=empty_out,
|
|
222
|
+
stderr=empty_out,
|
|
223
|
+
return_code=0,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
merged_env = os.environ.copy()
|
|
227
|
+
if self._env:
|
|
228
|
+
merged_env.update(self._env)
|
|
229
|
+
if env:
|
|
230
|
+
merged_env.update(env)
|
|
231
|
+
|
|
232
|
+
start = time.perf_counter()
|
|
233
|
+
|
|
234
|
+
if self._redirect_stdout:
|
|
235
|
+
mode = "a" if self._redirect_append else "w"
|
|
236
|
+
if not text:
|
|
237
|
+
mode += "b"
|
|
238
|
+
parent = self._redirect_stdout.parent
|
|
239
|
+
if parent and not parent.exists():
|
|
240
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
with open(self._redirect_stdout, mode) as f:
|
|
242
|
+
res = subprocess.run(
|
|
243
|
+
self.to_list(),
|
|
244
|
+
cwd=effective_cwd,
|
|
245
|
+
text=text,
|
|
246
|
+
env=merged_env,
|
|
247
|
+
input=input,
|
|
248
|
+
stdout=f,
|
|
249
|
+
stderr=subprocess.PIPE if capture else None,
|
|
250
|
+
)
|
|
251
|
+
stdout_res = empty_out
|
|
252
|
+
stderr_res = res.stderr if capture and res.stderr is not None else empty_out
|
|
253
|
+
return_code = res.returncode
|
|
254
|
+
elif stream:
|
|
255
|
+
stdout_res, stderr_res, return_code = self._run_stream(
|
|
256
|
+
cmd_list=self.to_list(),
|
|
257
|
+
cwd=effective_cwd,
|
|
258
|
+
text=text,
|
|
259
|
+
env=merged_env,
|
|
260
|
+
input_data=input,
|
|
261
|
+
)
|
|
262
|
+
elif capture:
|
|
263
|
+
res = subprocess.run(
|
|
264
|
+
self.to_list(),
|
|
265
|
+
cwd=effective_cwd,
|
|
266
|
+
text=text,
|
|
267
|
+
env=merged_env,
|
|
268
|
+
input=input,
|
|
269
|
+
capture_output=True,
|
|
270
|
+
)
|
|
271
|
+
stdout_res = res.stdout
|
|
272
|
+
stderr_res = res.stderr
|
|
273
|
+
return_code = res.returncode
|
|
274
|
+
else:
|
|
275
|
+
res = subprocess.run(
|
|
276
|
+
self.to_list(),
|
|
277
|
+
cwd=effective_cwd,
|
|
278
|
+
text=text,
|
|
279
|
+
env=merged_env,
|
|
280
|
+
input=input,
|
|
281
|
+
capture_output=False,
|
|
282
|
+
)
|
|
283
|
+
stdout_res = empty_out
|
|
284
|
+
stderr_res = empty_out
|
|
285
|
+
return_code = res.returncode
|
|
286
|
+
|
|
287
|
+
elapsed = time.perf_counter() - start
|
|
288
|
+
|
|
289
|
+
if log:
|
|
290
|
+
if return_code != 0:
|
|
291
|
+
err_msg = (
|
|
292
|
+
stderr_res
|
|
293
|
+
if isinstance(stderr_res, str)
|
|
294
|
+
else stderr_res.decode("utf-8", errors="replace")
|
|
295
|
+
)
|
|
296
|
+
log_action("Failed", err_msg.strip() or f"Exit code {return_code}", "red")
|
|
297
|
+
else:
|
|
298
|
+
log_action("Success", f"Took {elapsed:.3f}s", "green")
|
|
299
|
+
|
|
300
|
+
result = Result(
|
|
301
|
+
cmd=self._cmd,
|
|
302
|
+
args=self._args,
|
|
303
|
+
cwd=effective_cwd,
|
|
304
|
+
stdout=stdout_res,
|
|
305
|
+
stderr=stderr_res,
|
|
306
|
+
return_code=return_code,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if check:
|
|
310
|
+
result.exit_for_error()
|
|
311
|
+
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
def _run_stream(
|
|
315
|
+
self,
|
|
316
|
+
cmd_list: list[str],
|
|
317
|
+
cwd: str,
|
|
318
|
+
text: bool,
|
|
319
|
+
env: dict[str, str],
|
|
320
|
+
input_data: Any,
|
|
321
|
+
) -> tuple[Any, Any, int]:
|
|
322
|
+
stdin_setting = subprocess.PIPE if input_data is not None else None
|
|
323
|
+
proc = subprocess.Popen(
|
|
324
|
+
cmd_list,
|
|
325
|
+
cwd=cwd,
|
|
326
|
+
text=text,
|
|
327
|
+
env=env,
|
|
328
|
+
stdin=stdin_setting,
|
|
329
|
+
stdout=subprocess.PIPE,
|
|
330
|
+
stderr=subprocess.PIPE,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if input_data is not None and proc.stdin:
|
|
334
|
+
if text and isinstance(input_data, str):
|
|
335
|
+
proc.stdin.write(input_data)
|
|
336
|
+
elif not text and isinstance(input_data, bytes):
|
|
337
|
+
proc.stdin.write(input_data)
|
|
338
|
+
proc.stdin.close()
|
|
339
|
+
|
|
340
|
+
stdout_chunks: list[Any] = []
|
|
341
|
+
stderr_chunks: list[Any] = []
|
|
342
|
+
|
|
343
|
+
def reader(pipe, chunks, dest_stream):
|
|
344
|
+
if pipe is None:
|
|
345
|
+
return
|
|
346
|
+
try:
|
|
347
|
+
if text:
|
|
348
|
+
for line in iter(pipe.readline, ""):
|
|
349
|
+
chunks.append(line)
|
|
350
|
+
dest_stream.write(line)
|
|
351
|
+
dest_stream.flush()
|
|
352
|
+
else:
|
|
353
|
+
target = getattr(dest_stream, "buffer", dest_stream)
|
|
354
|
+
for line in iter(pipe.readline, b""):
|
|
355
|
+
chunks.append(line)
|
|
356
|
+
target.write(line)
|
|
357
|
+
target.flush()
|
|
358
|
+
finally:
|
|
359
|
+
pipe.close()
|
|
360
|
+
|
|
361
|
+
t_out = threading.Thread(
|
|
362
|
+
target=reader, args=(proc.stdout, stdout_chunks, sys.stdout)
|
|
363
|
+
)
|
|
364
|
+
t_err = threading.Thread(
|
|
365
|
+
target=reader, args=(proc.stderr, stderr_chunks, sys.stderr)
|
|
366
|
+
)
|
|
367
|
+
t_out.start()
|
|
368
|
+
t_err.start()
|
|
369
|
+
|
|
370
|
+
proc.wait()
|
|
371
|
+
t_out.join()
|
|
372
|
+
t_err.join()
|
|
373
|
+
|
|
374
|
+
if text:
|
|
375
|
+
return "".join(stdout_chunks), "".join(stderr_chunks), proc.returncode
|
|
376
|
+
else:
|
|
377
|
+
return b"".join(stdout_chunks), b"".join(stderr_chunks), proc.returncode
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class Pipeline:
|
|
381
|
+
def __init__(self, commands: list[Command]) -> None:
|
|
382
|
+
self.commands: list[Command] = commands
|
|
383
|
+
|
|
384
|
+
def __or__(self, other: Command | Pipeline) -> Pipeline:
|
|
385
|
+
if isinstance(other, Command):
|
|
386
|
+
return Pipeline([*self.commands, other])
|
|
387
|
+
elif isinstance(other, Pipeline):
|
|
388
|
+
return Pipeline([*self.commands, *other.commands])
|
|
389
|
+
return NotImplemented
|
|
390
|
+
|
|
391
|
+
def __repr__(self) -> str:
|
|
392
|
+
return f"Pipeline({self.commands!r})"
|
|
393
|
+
|
|
394
|
+
def run(
|
|
395
|
+
self,
|
|
396
|
+
*,
|
|
397
|
+
cwd: str | Path = ".",
|
|
398
|
+
text: bool = True,
|
|
399
|
+
log: bool = True,
|
|
400
|
+
input: str | bytes | None = None,
|
|
401
|
+
check: bool = False,
|
|
402
|
+
dry_run: bool | None = None,
|
|
403
|
+
) -> Result[Any]:
|
|
404
|
+
is_dry = _DRY_RUN if dry_run is None else dry_run
|
|
405
|
+
cmd_display = " | ".join(" ".join(c.to_list()) for c in self.commands)
|
|
406
|
+
effective_cwd = str(cwd)
|
|
407
|
+
|
|
408
|
+
if log:
|
|
409
|
+
if effective_cwd != ".":
|
|
410
|
+
log_action("CD", effective_cwd, "yellow")
|
|
411
|
+
if is_dry:
|
|
412
|
+
log_action("Dry-Run", cmd_display, "cyan")
|
|
413
|
+
else:
|
|
414
|
+
log_action("Run", cmd_display, "blue")
|
|
415
|
+
|
|
416
|
+
empty_out = "" if text else b""
|
|
417
|
+
if is_dry or not self.commands:
|
|
418
|
+
return Result(
|
|
419
|
+
cmd=cmd_display,
|
|
420
|
+
args=[],
|
|
421
|
+
cwd=effective_cwd,
|
|
422
|
+
stdout=empty_out,
|
|
423
|
+
stderr=empty_out,
|
|
424
|
+
return_code=0,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
start = time.perf_counter()
|
|
428
|
+
processes: list[subprocess.Popen] = []
|
|
429
|
+
|
|
430
|
+
for i, cmd in enumerate(self.commands):
|
|
431
|
+
is_first = i == 0
|
|
432
|
+
is_last = i == len(self.commands) - 1
|
|
433
|
+
|
|
434
|
+
if is_first:
|
|
435
|
+
stdin = subprocess.PIPE if input is not None else None
|
|
436
|
+
else:
|
|
437
|
+
stdin = processes[-1].stdout
|
|
438
|
+
|
|
439
|
+
stdout = subprocess.PIPE
|
|
440
|
+
|
|
441
|
+
merged_env = os.environ.copy()
|
|
442
|
+
if cmd._env:
|
|
443
|
+
merged_env.update(cmd._env)
|
|
444
|
+
|
|
445
|
+
proc = subprocess.Popen(
|
|
446
|
+
cmd.to_list(),
|
|
447
|
+
cwd=effective_cwd,
|
|
448
|
+
stdin=stdin,
|
|
449
|
+
stdout=stdout,
|
|
450
|
+
stderr=subprocess.PIPE,
|
|
451
|
+
text=text,
|
|
452
|
+
env=merged_env,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if not is_first and processes[-1].stdout:
|
|
456
|
+
processes[-1].stdout.close()
|
|
457
|
+
|
|
458
|
+
processes.append(proc)
|
|
459
|
+
|
|
460
|
+
last_proc = processes[-1]
|
|
461
|
+
if input is not None and processes[0].stdin:
|
|
462
|
+
stdout_res, stderr_res = last_proc.communicate(input=input if len(processes) == 1 else None)
|
|
463
|
+
if len(processes) > 1 and processes[0].stdin:
|
|
464
|
+
# If there are multiple processes, send input to first
|
|
465
|
+
try:
|
|
466
|
+
if text and isinstance(input, str):
|
|
467
|
+
processes[0].stdin.write(input)
|
|
468
|
+
elif not text and isinstance(input, bytes):
|
|
469
|
+
processes[0].stdin.write(input)
|
|
470
|
+
processes[0].stdin.close()
|
|
471
|
+
except BrokenPipeError:
|
|
472
|
+
pass
|
|
473
|
+
stdout_res, stderr_res = last_proc.communicate()
|
|
474
|
+
else:
|
|
475
|
+
stdout_res, stderr_res = last_proc.communicate()
|
|
476
|
+
|
|
477
|
+
for p in processes[:-1]:
|
|
478
|
+
if p.stderr:
|
|
479
|
+
p.stderr.close()
|
|
480
|
+
p.wait()
|
|
481
|
+
|
|
482
|
+
elapsed = time.perf_counter() - start
|
|
483
|
+
return_code = last_proc.returncode
|
|
484
|
+
|
|
485
|
+
if log:
|
|
486
|
+
if return_code != 0:
|
|
487
|
+
err_msg = (
|
|
488
|
+
stderr_res
|
|
489
|
+
if isinstance(stderr_res, str)
|
|
490
|
+
else stderr_res.decode("utf-8", errors="replace")
|
|
491
|
+
)
|
|
492
|
+
log_action("Failed", err_msg.strip() or f"Exit code {return_code}", "red")
|
|
493
|
+
else:
|
|
494
|
+
log_action("Success", f"Took {elapsed:.3f}s", "green")
|
|
495
|
+
|
|
496
|
+
result = Result(
|
|
497
|
+
cmd=cmd_display,
|
|
498
|
+
args=[],
|
|
499
|
+
cwd=effective_cwd,
|
|
500
|
+
stdout=stdout_res,
|
|
501
|
+
stderr=stderr_res,
|
|
502
|
+
return_code=return_code,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
if check:
|
|
506
|
+
result.exit_for_error()
|
|
507
|
+
|
|
508
|
+
return result
|
puild/fs.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from puild.logging import log_action
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _to_path_list(item: str | Path | Iterable[str | Path]) -> list[Path]:
|
|
11
|
+
if isinstance(item, (str, Path)):
|
|
12
|
+
return [Path(item)]
|
|
13
|
+
return [Path(p) for p in item]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def needs_rebuild(
|
|
17
|
+
target: str | Path | Iterable[str | Path],
|
|
18
|
+
sources: str | Path | Iterable[str | Path],
|
|
19
|
+
*,
|
|
20
|
+
log: bool = False,
|
|
21
|
+
) -> bool:
|
|
22
|
+
"""Check if target needs to be rebuilt based on source modification times.
|
|
23
|
+
|
|
24
|
+
Returns True if:
|
|
25
|
+
- Any target file does not exist.
|
|
26
|
+
- Any source file is newer than the oldest target file.
|
|
27
|
+
|
|
28
|
+
Returns False if:
|
|
29
|
+
- All targets exist and are newer than all source files.
|
|
30
|
+
"""
|
|
31
|
+
targets = _to_path_list(target)
|
|
32
|
+
src_list = _to_path_list(sources)
|
|
33
|
+
|
|
34
|
+
targets_display = ", ".join(str(t) for t in targets)
|
|
35
|
+
|
|
36
|
+
# If any target does not exist, we must rebuild
|
|
37
|
+
missing_targets = [t for t in targets if not t.exists()]
|
|
38
|
+
if missing_targets:
|
|
39
|
+
if log:
|
|
40
|
+
log_action("Rebuild", f"{targets_display} (target missing)", "yellow")
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
# If no sources are specified, target exists so no rebuild
|
|
44
|
+
if not src_list:
|
|
45
|
+
if log:
|
|
46
|
+
log_action("Up-to-date", targets_display, "green")
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# Check for missing sources
|
|
50
|
+
for s in src_list:
|
|
51
|
+
if not s.exists():
|
|
52
|
+
raise FileNotFoundError(f"Source file does not exist: {s}")
|
|
53
|
+
|
|
54
|
+
oldest_target_mtime = min(t.stat().st_mtime for t in targets)
|
|
55
|
+
newest_source_mtime = max(s.stat().st_mtime for s in src_list)
|
|
56
|
+
|
|
57
|
+
if newest_source_mtime > oldest_target_mtime:
|
|
58
|
+
if log:
|
|
59
|
+
log_action("Rebuild", f"{targets_display} (sources modified)", "yellow")
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
if log:
|
|
63
|
+
log_action("Up-to-date", targets_display, "green")
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mkdir(
|
|
68
|
+
path: str | Path,
|
|
69
|
+
*,
|
|
70
|
+
parents: bool = True,
|
|
71
|
+
exist_ok: bool = True,
|
|
72
|
+
log: bool = True,
|
|
73
|
+
) -> Path:
|
|
74
|
+
"""Create a directory with parents and exist_ok enabled by default."""
|
|
75
|
+
p = Path(path)
|
|
76
|
+
p.mkdir(parents=parents, exist_ok=exist_ok)
|
|
77
|
+
if log:
|
|
78
|
+
log_action("Mkdir", str(p), "blue")
|
|
79
|
+
return p
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rm(
|
|
83
|
+
path: str | Path,
|
|
84
|
+
*,
|
|
85
|
+
recursive: bool = True,
|
|
86
|
+
missing_ok: bool = True,
|
|
87
|
+
log: bool = True,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Remove a file or directory."""
|
|
90
|
+
p = Path(path)
|
|
91
|
+
if not p.exists():
|
|
92
|
+
if missing_ok:
|
|
93
|
+
return
|
|
94
|
+
raise FileNotFoundError(f"Path does not exist: {p}")
|
|
95
|
+
|
|
96
|
+
if p.is_dir():
|
|
97
|
+
if recursive:
|
|
98
|
+
shutil.rmtree(p)
|
|
99
|
+
else:
|
|
100
|
+
p.rmdir()
|
|
101
|
+
else:
|
|
102
|
+
p.unlink()
|
|
103
|
+
|
|
104
|
+
if log:
|
|
105
|
+
log_action("Remove", str(p), "yellow")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def copy(
|
|
109
|
+
src: str | Path,
|
|
110
|
+
dst: str | Path,
|
|
111
|
+
*,
|
|
112
|
+
log: bool = True,
|
|
113
|
+
) -> Path:
|
|
114
|
+
"""Copy a file or directory to a destination."""
|
|
115
|
+
src_path = Path(src)
|
|
116
|
+
dst_path = Path(dst)
|
|
117
|
+
|
|
118
|
+
if not src_path.exists():
|
|
119
|
+
raise FileNotFoundError(f"Source does not exist: {src_path}")
|
|
120
|
+
|
|
121
|
+
if src_path.is_dir():
|
|
122
|
+
shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
|
|
123
|
+
else:
|
|
124
|
+
if dst_path.is_dir():
|
|
125
|
+
shutil.copy2(src_path, dst_path / src_path.name)
|
|
126
|
+
else:
|
|
127
|
+
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
shutil.copy2(src_path, dst_path)
|
|
129
|
+
|
|
130
|
+
if log:
|
|
131
|
+
log_action("Copy", f"{src_path} -> {dst_path}", "blue")
|
|
132
|
+
|
|
133
|
+
return dst_path
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def find_files(
|
|
137
|
+
directory: str | Path = ".",
|
|
138
|
+
pattern: str = "*",
|
|
139
|
+
*,
|
|
140
|
+
recursive: bool = True,
|
|
141
|
+
) -> list[Path]:
|
|
142
|
+
"""Find files matching pattern in directory."""
|
|
143
|
+
dir_path = Path(directory)
|
|
144
|
+
if recursive:
|
|
145
|
+
matches = dir_path.rglob(pattern)
|
|
146
|
+
else:
|
|
147
|
+
matches = dir_path.glob(pattern)
|
|
148
|
+
|
|
149
|
+
return sorted([p for p in matches if p.is_file()])
|
puild/logging.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from rich import print as rich_print
|
|
3
|
+
|
|
4
|
+
ActionColor = Literal["red", "green", "yellow", "blue", "magenta", "cyan", "white"]
|
|
5
|
+
|
|
6
|
+
_QUIET = False
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def set_quiet(quiet: bool) -> None:
|
|
10
|
+
"""Set global quiet mode for logging."""
|
|
11
|
+
global _QUIET
|
|
12
|
+
_QUIET = quiet
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_quiet() -> bool:
|
|
16
|
+
"""Check if quiet mode is enabled."""
|
|
17
|
+
return _QUIET
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log_action(
|
|
21
|
+
action_name: str,
|
|
22
|
+
action_message: str,
|
|
23
|
+
action_color: ActionColor = "white",
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Print a formatted action log line if quiet mode is not enabled."""
|
|
26
|
+
if _QUIET:
|
|
27
|
+
return
|
|
28
|
+
rich_print(
|
|
29
|
+
f"[bold {action_color}]{action_name:>10}[/bold {action_color}]: {action_message}"
|
|
30
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: puild
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, modern build tool and command runner for Python.
|
|
5
|
+
Requires-Dist: rich>=15.0.0
|
|
6
|
+
Requires-Python: >=3.14
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# puild
|
|
10
|
+
|
|
11
|
+
A lightweight, modern build tool and command runner for Python.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Command Runner:** Run system commands with fluent argument construction, rich logging, and typed results.
|
|
16
|
+
- **Pipelines & Redirection:** Shell-like piping (`cmd1 | cmd2`) and redirection (`cmd > "file.txt"`).
|
|
17
|
+
- **Streaming & Live Output:** Real-time stdout/stderr streaming while capturing output into typed [`Result`](src/puild/command.py) objects.
|
|
18
|
+
- **Dry-run & Quiet Modes:** Preview commands before execution or silence logs during scripts and testing.
|
|
19
|
+
- **Build Primitives:** Timestamp-based rebuild detection (`needs_rebuild`) and filesystem helpers (`mkdir`, `rm`, `copy`, `find_files`).
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
### 1. Basic Command Execution
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from puild import Command
|
|
27
|
+
|
|
28
|
+
# Direct instantiation
|
|
29
|
+
cmd = Command("gcc", "-Wall", "-O2", "main.c", "-o", "bin/app")
|
|
30
|
+
res = cmd.run(text=True)
|
|
31
|
+
|
|
32
|
+
if not res.ok:
|
|
33
|
+
res.exit_for_error()
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 2. Fluent Argument Builder & Environment
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from puild import Command
|
|
40
|
+
|
|
41
|
+
cmd = (
|
|
42
|
+
Command("gcc")
|
|
43
|
+
.arg("-Wall")
|
|
44
|
+
.arg("-O2")
|
|
45
|
+
.args(["main.c", "-o", "bin/app"])
|
|
46
|
+
.env("CFLAGS", "-march=native")
|
|
47
|
+
.cwd("src")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
res = cmd.run(text=True)
|
|
51
|
+
print(res.stdout)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 3. Piping and File Redirection
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from puild import Command
|
|
58
|
+
|
|
59
|
+
# Pipe between commands
|
|
60
|
+
pipe = Command("cat", "access.log") | Command("grep", "ERROR")
|
|
61
|
+
res = pipe.run(text=True)
|
|
62
|
+
|
|
63
|
+
# File redirection
|
|
64
|
+
cmd = Command("echo", "build completed") > "build.log"
|
|
65
|
+
cmd.run()
|
|
66
|
+
|
|
67
|
+
# Append to file
|
|
68
|
+
cmd = Command("echo", "second step completed") >> "build.log"
|
|
69
|
+
cmd.run()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 4. Live Streaming Output
|
|
73
|
+
|
|
74
|
+
Stream output to stdout/stderr in real-time while still capturing the result:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from puild import Command
|
|
78
|
+
|
|
79
|
+
cmd = Command("pytest", "-v")
|
|
80
|
+
res = cmd.run(stream=True, text=True)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 5. Incremental Builds (`needs_rebuild`)
|
|
84
|
+
|
|
85
|
+
Skip compiling when targets are already up-to-date:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from puild import Command, needs_rebuild, find_files
|
|
89
|
+
|
|
90
|
+
sources = find_files("src", "*.c")
|
|
91
|
+
target = "build/app"
|
|
92
|
+
|
|
93
|
+
if needs_rebuild(target, sources, log=True):
|
|
94
|
+
Command("gcc", *sources, "-o", target).run(check=True)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 6. Filesystem Helpers
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from puild import mkdir, rm, copy, find_files
|
|
101
|
+
|
|
102
|
+
# Create directories
|
|
103
|
+
mkdir("build/bin")
|
|
104
|
+
|
|
105
|
+
# Find files
|
|
106
|
+
c_files = find_files("src", "*.c", recursive=True)
|
|
107
|
+
|
|
108
|
+
# Copy files or directories
|
|
109
|
+
copy("src/config.json", "build/config.json")
|
|
110
|
+
|
|
111
|
+
# Remove files or directories
|
|
112
|
+
rm("build", recursive=True)
|
|
113
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
puild/__init__.py,sha256=XZK5V-ErbJfUE1-B2BPtKAn-Tl1qHchDsv2wTSxQMRM,421
|
|
2
|
+
puild/command.py,sha256=mW0R38rDMqUxsNU4Sg3v60MRko7WezyOZ8MPW-Lq7D4,15543
|
|
3
|
+
puild/fs.py,sha256=FnBhvAjBHeExu7RmsSJ6zQPoHaP90PcnhfgqXXF5vWQ,3833
|
|
4
|
+
puild/logging.py,sha256=QxKf264EaIirqmuF6GtSpvn_Iu4yeKt4soA71jdmPXM,704
|
|
5
|
+
puild-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
6
|
+
puild-0.1.0.dist-info/entry_points.txt,sha256=DV6220xDkXY5EEdPoOm4-VU5y0U715MCFla7MxECz1k,65
|
|
7
|
+
puild-0.1.0.dist-info/METADATA,sha256=8yvOWd7epkyoDLm2wE1wN1HMja7mnwgC5EVZQlKCcTQ,2669
|
|
8
|
+
puild-0.1.0.dist-info/RECORD,,
|