puild 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: puild
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A lightweight, modern build tool and command runner for Python.
5
5
  Requires-Dist: rich>=15.0.0
6
6
  Requires-Python: >=3.14
@@ -69,15 +69,20 @@ cmd = Command("echo", "second step completed") >> "build.log"
69
69
  cmd.run()
70
70
  ```
71
71
 
72
- ### 4. Live Streaming Output
72
+ ### 4. Live Streaming Output & Indentation
73
73
 
74
- Stream output to stdout/stderr in real-time while still capturing the result:
74
+ Stream command output to stdout/stderr in real-time. Output is automatically indented (default 4 spaces) so it stays visually separated from puild's action logs:
75
75
 
76
76
  ```python
77
- from puild import Command
77
+ from puild import Command, set_default_indent
78
+
79
+ # Stream with default 4-space indentation
80
+ cmd = Command("npm", "install")
81
+ cmd.run(stream=True, text=True)
78
82
 
79
- cmd = Command("pytest", "-v")
80
- res = cmd.run(stream=True, text=True)
83
+ # Custom indentation per-command or globally
84
+ cmd.run(stream=True, indent=" ") # 2 spaces
85
+ set_default_indent(" ") # Set global default
81
86
  ```
82
87
 
83
88
  ### 5. Incremental Builds (`needs_rebuild`)
@@ -61,15 +61,20 @@ cmd = Command("echo", "second step completed") >> "build.log"
61
61
  cmd.run()
62
62
  ```
63
63
 
64
- ### 4. Live Streaming Output
64
+ ### 4. Live Streaming Output & Indentation
65
65
 
66
- Stream output to stdout/stderr in real-time while still capturing the result:
66
+ Stream command output to stdout/stderr in real-time. Output is automatically indented (default 4 spaces) so it stays visually separated from puild's action logs:
67
67
 
68
68
  ```python
69
- from puild import Command
69
+ from puild import Command, set_default_indent
70
+
71
+ # Stream with default 4-space indentation
72
+ cmd = Command("npm", "install")
73
+ cmd.run(stream=True, text=True)
70
74
 
71
- cmd = Command("pytest", "-v")
72
- res = cmd.run(stream=True, text=True)
75
+ # Custom indentation per-command or globally
76
+ cmd.run(stream=True, indent=" ") # 2 spaces
77
+ set_default_indent(" ") # Set global default
73
78
  ```
74
79
 
75
80
  ### 5. Incremental Builds (`needs_rebuild`)
@@ -1,15 +1,11 @@
1
1
  [project]
2
2
  name = "puild"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "A lightweight, modern build tool and command runner for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  dependencies = ["rich>=15.0.0"]
8
8
 
9
- [project.scripts]
10
- run = "puild.main:main"
11
- puild = "puild.main:main"
12
-
13
9
  [build-system]
14
10
  requires = ["uv_build>=0.12.19,<0.13"]
15
11
  build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puild"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "A lightweight, modern build tool and command runner for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -12,7 +12,3 @@ dependencies = [
12
12
  [build-system]
13
13
  requires = ["uv_build>=0.12.19,<0.13"]
14
14
  build-backend = "uv_build"
15
-
16
- [project.scripts]
17
- run = "puild.main:main"
18
- puild = "puild.main:main"
@@ -1,6 +1,13 @@
1
1
  from puild.command import Command, Pipeline, Result, is_dry_run, set_dry_run
2
2
  from puild.fs import copy, find_files, mkdir, needs_rebuild, rm
3
- from puild.logging import is_quiet, log_action, set_quiet
3
+ from puild.logging import (
4
+ get_default_indent,
5
+ is_quiet,
6
+ log_action,
7
+ log_indented,
8
+ set_default_indent,
9
+ set_quiet,
10
+ )
4
11
 
5
12
  __all__ = [
6
13
  "Command",
@@ -8,12 +15,15 @@ __all__ = [
8
15
  "Result",
9
16
  "copy",
10
17
  "find_files",
18
+ "get_default_indent",
11
19
  "is_dry_run",
12
20
  "is_quiet",
13
21
  "log_action",
22
+ "log_indented",
14
23
  "mkdir",
15
24
  "needs_rebuild",
16
25
  "rm",
26
+ "set_default_indent",
17
27
  "set_dry_run",
18
28
  "set_quiet",
19
29
  ]
@@ -10,7 +10,12 @@ from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
  from typing import Any, Literal, Self, overload
12
12
 
13
- from puild.logging import log_action
13
+ from puild.logging import (
14
+ get_default_indent,
15
+ log_action,
16
+ log_indented,
17
+ set_default_indent,
18
+ )
14
19
 
15
20
  _DRY_RUN = False
16
21
 
@@ -123,6 +128,7 @@ class Command:
123
128
  return Pipeline([self, other])
124
129
  elif isinstance(other, Pipeline):
125
130
  return Pipeline([self, *other.commands])
131
+ return NotImplemented
126
132
 
127
133
  def __gt__(self, target: str | Path) -> Command:
128
134
  """Redirect stdout to file (overwrite): cmd > 'output.txt'"""
@@ -147,9 +153,11 @@ class Command:
147
153
  log: bool = True,
148
154
  capture: bool = True,
149
155
  stream: bool = False,
156
+ indent: str | None = None,
150
157
  env: dict[str, str] | None = None,
151
158
  input: bytes | None = None,
152
159
  check: bool = False,
160
+ raw: bool = False,
153
161
  dry_run: bool | None = None,
154
162
  ) -> Result[bytes]: ...
155
163
 
@@ -162,9 +170,11 @@ class Command:
162
170
  log: bool = True,
163
171
  capture: bool = True,
164
172
  stream: bool = False,
173
+ indent: str | None = None,
165
174
  env: dict[str, str] | None = None,
166
175
  input: str | None = None,
167
176
  check: bool = False,
177
+ raw: bool = False,
168
178
  dry_run: bool | None = None,
169
179
  ) -> Result[str]: ...
170
180
 
@@ -177,9 +187,11 @@ class Command:
177
187
  log: bool = True,
178
188
  capture: bool = True,
179
189
  stream: bool = False,
190
+ indent: str | None = None,
180
191
  env: dict[str, str] | None = None,
181
192
  input: Any = None,
182
193
  check: bool = False,
194
+ raw: bool = False,
183
195
  dry_run: bool | None = None,
184
196
  ) -> Result[Any]: ...
185
197
 
@@ -191,13 +203,16 @@ class Command:
191
203
  log: bool = True,
192
204
  capture: bool = True,
193
205
  stream: bool = False,
206
+ indent: str | None = None,
194
207
  env: dict[str, str] | None = None,
195
208
  input: Any = None,
196
209
  check: bool = False,
210
+ raw: bool = False,
197
211
  dry_run: bool | None = None,
198
212
  ) -> Result[Any]:
199
213
  effective_cwd = str(cwd) if cwd is not None else self._cwd
200
214
  is_dry = _DRY_RUN if dry_run is None else dry_run
215
+ effective_indent = get_default_indent() if indent is None else indent
201
216
 
202
217
  cmd_display = " ".join(self.to_list())
203
218
  if self._redirect_stdout:
@@ -251,15 +266,32 @@ class Command:
251
266
  stdout_res = empty_out
252
267
  stderr_res = res.stderr if capture and res.stderr is not None else empty_out
253
268
  return_code = res.returncode
254
- elif stream:
269
+ elif raw:
270
+ # Raw interactive TTY execution without interception
271
+ res = subprocess.run(
272
+ self.to_list(),
273
+ cwd=effective_cwd,
274
+ text=text,
275
+ env=merged_env,
276
+ input=input,
277
+ capture_output=False,
278
+ )
279
+ stdout_res = empty_out
280
+ stderr_res = empty_out
281
+ return_code = res.returncode
282
+ elif stream or not capture:
283
+ # Stream output live to terminal with indentation
255
284
  stdout_res, stderr_res, return_code = self._run_stream(
256
285
  cmd_list=self.to_list(),
257
286
  cwd=effective_cwd,
258
287
  text=text,
259
288
  env=merged_env,
260
289
  input_data=input,
290
+ indent=effective_indent,
291
+ collect_output=capture,
261
292
  )
262
- elif capture:
293
+ else:
294
+ # Default capture=True, stream=False
263
295
  res = subprocess.run(
264
296
  self.to_list(),
265
297
  cwd=effective_cwd,
@@ -271,29 +303,30 @@ class Command:
271
303
  stdout_res = res.stdout
272
304
  stderr_res = res.stderr
273
305
  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
306
 
287
307
  elapsed = time.perf_counter() - start
288
308
 
289
309
  if log:
290
310
  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")
311
+ if stream or not capture:
312
+ log_action(
313
+ "Failed",
314
+ f"Command failed with exit code {return_code}",
315
+ "red",
316
+ )
317
+ else:
318
+ err_msg = (
319
+ stderr_res
320
+ if isinstance(stderr_res, str)
321
+ else stderr_res.decode("utf-8", errors="replace")
322
+ )
323
+ log_action(
324
+ "Failed",
325
+ f"Command failed with exit code {return_code}",
326
+ "red",
327
+ )
328
+ if err_msg.strip():
329
+ log_indented(err_msg.strip(), indent=effective_indent)
297
330
  else:
298
331
  log_action("Success", f"Took {elapsed:.3f}s", "green")
299
332
 
@@ -318,6 +351,8 @@ class Command:
318
351
  text: bool,
319
352
  env: dict[str, str],
320
353
  input_data: Any,
354
+ indent: str,
355
+ collect_output: bool = True,
321
356
  ) -> tuple[Any, Any, int]:
322
357
  stdin_setting = subprocess.PIPE if input_data is not None else None
323
358
  proc = subprocess.Popen(
@@ -346,14 +381,23 @@ class Command:
346
381
  try:
347
382
  if text:
348
383
  for line in iter(pipe.readline, ""):
349
- chunks.append(line)
350
- dest_stream.write(line)
384
+ if collect_output:
385
+ chunks.append(line)
386
+ if not line.strip():
387
+ dest_stream.write(line)
388
+ else:
389
+ dest_stream.write(f"{indent}{line}")
351
390
  dest_stream.flush()
352
391
  else:
353
392
  target = getattr(dest_stream, "buffer", dest_stream)
393
+ indent_bytes = indent.encode("utf-8")
354
394
  for line in iter(pipe.readline, b""):
355
- chunks.append(line)
356
- target.write(line)
395
+ if collect_output:
396
+ chunks.append(line)
397
+ if not line.strip():
398
+ target.write(line)
399
+ else:
400
+ target.write(indent_bytes + line)
357
401
  target.flush()
358
402
  finally:
359
403
  pipe.close()
@@ -371,10 +415,19 @@ class Command:
371
415
  t_out.join()
372
416
  t_err.join()
373
417
 
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
418
+ empty = "" if text else b""
419
+ out_res = (
420
+ ("".join(stdout_chunks) if text else b"".join(stdout_chunks))
421
+ if collect_output
422
+ else empty
423
+ )
424
+ err_res = (
425
+ ("".join(stderr_chunks) if text else b"".join(stderr_chunks))
426
+ if collect_output
427
+ else empty
428
+ )
429
+
430
+ return out_res, err_res, proc.returncode
378
431
 
379
432
 
380
433
  class Pipeline:
@@ -397,6 +450,8 @@ class Pipeline:
397
450
  cwd: str | Path = ".",
398
451
  text: bool = True,
399
452
  log: bool = True,
453
+ stream: bool = False,
454
+ indent: str | None = None,
400
455
  input: str | bytes | None = None,
401
456
  check: bool = False,
402
457
  dry_run: bool | None = None,
@@ -404,6 +459,7 @@ class Pipeline:
404
459
  is_dry = _DRY_RUN if dry_run is None else dry_run
405
460
  cmd_display = " | ".join(" ".join(c.to_list()) for c in self.commands)
406
461
  effective_cwd = str(cwd)
462
+ effective_indent = get_default_indent() if indent is None else indent
407
463
 
408
464
  if log:
409
465
  if effective_cwd != ".":
@@ -429,7 +485,6 @@ class Pipeline:
429
485
 
430
486
  for i, cmd in enumerate(self.commands):
431
487
  is_first = i == 0
432
- is_last = i == len(self.commands) - 1
433
488
 
434
489
  if is_first:
435
490
  stdin = subprocess.PIPE if input is not None else None
@@ -458,10 +513,46 @@ class Pipeline:
458
513
  processes.append(proc)
459
514
 
460
515
  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
516
+
517
+ if stream:
518
+ stdout_chunks: list[Any] = []
519
+ stderr_chunks: list[Any] = []
520
+
521
+ def reader(pipe, chunks, dest_stream):
522
+ if pipe is None:
523
+ return
524
+ try:
525
+ if text:
526
+ for line in iter(pipe.readline, ""):
527
+ chunks.append(line)
528
+ if not line.strip():
529
+ dest_stream.write(line)
530
+ else:
531
+ dest_stream.write(f"{effective_indent}{line}")
532
+ dest_stream.flush()
533
+ else:
534
+ target = getattr(dest_stream, "buffer", dest_stream)
535
+ indent_bytes = effective_indent.encode("utf-8")
536
+ for line in iter(pipe.readline, b""):
537
+ chunks.append(line)
538
+ if not line.strip():
539
+ target.write(line)
540
+ else:
541
+ target.write(indent_bytes + line)
542
+ target.flush()
543
+ finally:
544
+ pipe.close()
545
+
546
+ t_out = threading.Thread(
547
+ target=reader, args=(last_proc.stdout, stdout_chunks, sys.stdout)
548
+ )
549
+ t_err = threading.Thread(
550
+ target=reader, args=(last_proc.stderr, stderr_chunks, sys.stderr)
551
+ )
552
+ t_out.start()
553
+ t_err.start()
554
+
555
+ if input is not None and processes[0].stdin:
465
556
  try:
466
557
  if text and isinstance(input, str):
467
558
  processes[0].stdin.write(input)
@@ -470,9 +561,30 @@ class Pipeline:
470
561
  processes[0].stdin.close()
471
562
  except BrokenPipeError:
472
563
  pass
473
- stdout_res, stderr_res = last_proc.communicate()
564
+
565
+ last_proc.wait()
566
+ t_out.join()
567
+ t_err.join()
568
+
569
+ stdout_res = "".join(stdout_chunks) if text else b"".join(stdout_chunks)
570
+ stderr_res = "".join(stderr_chunks) if text else b"".join(stderr_chunks)
474
571
  else:
475
- stdout_res, stderr_res = last_proc.communicate()
572
+ if input is not None and processes[0].stdin:
573
+ stdout_res, stderr_res = last_proc.communicate(
574
+ input=input if len(processes) == 1 else None
575
+ )
576
+ if len(processes) > 1 and processes[0].stdin:
577
+ try:
578
+ if text and isinstance(input, str):
579
+ processes[0].stdin.write(input)
580
+ elif not text and isinstance(input, bytes):
581
+ processes[0].stdin.write(input)
582
+ processes[0].stdin.close()
583
+ except BrokenPipeError:
584
+ pass
585
+ stdout_res, stderr_res = last_proc.communicate()
586
+ else:
587
+ stdout_res, stderr_res = last_proc.communicate()
476
588
 
477
589
  for p in processes[:-1]:
478
590
  if p.stderr:
@@ -484,12 +596,25 @@ class Pipeline:
484
596
 
485
597
  if log:
486
598
  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")
599
+ if stream:
600
+ log_action(
601
+ "Failed",
602
+ f"Command failed with exit code {return_code}",
603
+ "red",
604
+ )
605
+ else:
606
+ err_msg = (
607
+ stderr_res
608
+ if isinstance(stderr_res, str)
609
+ else stderr_res.decode("utf-8", errors="replace")
610
+ )
611
+ log_action(
612
+ "Failed",
613
+ f"Command failed with exit code {return_code}",
614
+ "red",
615
+ )
616
+ if err_msg.strip():
617
+ log_indented(err_msg.strip(), indent=effective_indent)
493
618
  else:
494
619
  log_action("Success", f"Took {elapsed:.3f}s", "green")
495
620
 
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any, Literal
5
+ from rich import print as rich_print
6
+ from rich.markup import escape
7
+
8
+ ActionColor = Literal["red", "green", "yellow", "blue", "magenta", "cyan", "white"]
9
+
10
+ _QUIET = False
11
+ _DEFAULT_INDENT = " "
12
+
13
+
14
+ def set_quiet(quiet: bool) -> None:
15
+ """Set global quiet mode for logging."""
16
+ global _QUIET
17
+ _QUIET = quiet
18
+
19
+
20
+ def is_quiet() -> bool:
21
+ """Check if quiet mode is enabled."""
22
+ return _QUIET
23
+
24
+
25
+ def set_default_indent(indent: str) -> None:
26
+ """Set global default indentation string for command outputs."""
27
+ global _DEFAULT_INDENT
28
+ _DEFAULT_INDENT = indent
29
+
30
+
31
+ def get_default_indent() -> str:
32
+ """Get the global default indentation string."""
33
+ return _DEFAULT_INDENT
34
+
35
+
36
+ def log_action(
37
+ action_name: str,
38
+ action_message: str,
39
+ action_color: ActionColor = "white",
40
+ ) -> None:
41
+ """Print a formatted action log line if quiet mode is not enabled."""
42
+ if _QUIET:
43
+ return
44
+ prefix = f"[bold {action_color}]{action_name:>10}[/bold {action_color}]: "
45
+ lines = action_message.splitlines()
46
+ if not lines:
47
+ rich_print(prefix.rstrip())
48
+ return
49
+
50
+ rich_print(f"{prefix}{escape(lines[0])}")
51
+ # Indent subsequent lines of a multiline action message to align after the prefix
52
+ indent_pad = " " * 12
53
+ for line in lines[1:]:
54
+ rich_print(f"{indent_pad}{escape(line)}")
55
+
56
+
57
+ def log_indented(
58
+ message: str,
59
+ indent: str | None = None,
60
+ stream: Any = sys.stdout,
61
+ ) -> None:
62
+ """Print multiline text with each line indented."""
63
+ if _QUIET or not message:
64
+ return
65
+ eff_indent = _DEFAULT_INDENT if indent is None else indent
66
+ for line in message.splitlines():
67
+ if not line.strip():
68
+ stream.write("\n")
69
+ else:
70
+ stream.write(f"{eff_indent}{line}\n")
71
+ stream.flush()
@@ -1,30 +0,0 @@
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
- )
File without changes