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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ puild = puild.main:main
3
+ run = puild.main:main
4
+