puild 0.1.2__tar.gz → 0.2.1__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.2
3
+ Version: 0.2.1
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
@@ -116,3 +116,29 @@ copy("src/config.json", "build/config.json")
116
116
  # Remove files or directories
117
117
  rm("build", recursive=True)
118
118
  ```
119
+
120
+ ### 7. Logging & File Routing
121
+
122
+ `puild` uses Python's standard `logging` library (`logging.getLogger("puild")`). You can route logs to a file, rotate files, or customize levels:
123
+
124
+ ```python
125
+ from puild import Command, configure_logging
126
+
127
+ # Route logs to both console and a log file
128
+ configure_logging(log_file="build.log")
129
+
130
+ # Or log strictly to a file without console output
131
+ configure_logging(log_file="build.log", console=False)
132
+
133
+ # Or with log rotation
134
+ configure_logging(log_file="build.log", max_bytes=10_000_000, backup_count=3)
135
+ ```
136
+
137
+ You can also attach standard Python `logging` handlers directly:
138
+
139
+ ```python
140
+ import logging
141
+
142
+ handler = logging.FileHandler("custom.log")
143
+ logging.getLogger("puild").addHandler(handler)
144
+ ```
@@ -108,3 +108,29 @@ copy("src/config.json", "build/config.json")
108
108
  # Remove files or directories
109
109
  rm("build", recursive=True)
110
110
  ```
111
+
112
+ ### 7. Logging & File Routing
113
+
114
+ `puild` uses Python's standard `logging` library (`logging.getLogger("puild")`). You can route logs to a file, rotate files, or customize levels:
115
+
116
+ ```python
117
+ from puild import Command, configure_logging
118
+
119
+ # Route logs to both console and a log file
120
+ configure_logging(log_file="build.log")
121
+
122
+ # Or log strictly to a file without console output
123
+ configure_logging(log_file="build.log", console=False)
124
+
125
+ # Or with log rotation
126
+ configure_logging(log_file="build.log", max_bytes=10_000_000, backup_count=3)
127
+ ```
128
+
129
+ You can also attach standard Python `logging` handlers directly:
130
+
131
+ ```python
132
+ import logging
133
+
134
+ handler = logging.FileHandler("custom.log")
135
+ logging.getLogger("puild").addHandler(handler)
136
+ ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puild"
3
- version = "0.1.2"
3
+ version = "0.2.1"
4
4
  description = "A lightweight, modern build tool and command runner for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "puild"
3
- version = "0.1.2"
3
+ version = "0.2.1"
4
4
  description = "A lightweight, modern build tool and command runner for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -1,10 +1,12 @@
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
3
  from puild.logging import (
4
+ configure_logging,
4
5
  get_default_indent,
5
6
  is_quiet,
6
7
  log_action,
7
8
  log_indented,
9
+ logger,
8
10
  set_default_indent,
9
11
  set_quiet,
10
12
  )
@@ -13,6 +15,7 @@ __all__ = [
13
15
  "Command",
14
16
  "Pipeline",
15
17
  "Result",
18
+ "configure_logging",
16
19
  "copy",
17
20
  "find_files",
18
21
  "get_default_indent",
@@ -20,6 +23,7 @@ __all__ = [
20
23
  "is_quiet",
21
24
  "log_action",
22
25
  "log_indented",
26
+ "logger",
23
27
  "mkdir",
24
28
  "needs_rebuild",
25
29
  "rm",
@@ -14,7 +14,7 @@ from puild.logging import (
14
14
  get_default_indent,
15
15
  log_action,
16
16
  log_indented,
17
- set_default_indent,
17
+ write_stream_to_file_handlers,
18
18
  )
19
19
 
20
20
  _DRY_RUN = False
@@ -262,6 +262,7 @@ class Command:
262
262
  input=input,
263
263
  stdout=f,
264
264
  stderr=subprocess.PIPE if capture else None,
265
+ check=False,
265
266
  )
266
267
  stdout_res = empty_out
267
268
  stderr_res = res.stderr if capture and res.stderr is not None else empty_out
@@ -275,6 +276,7 @@ class Command:
275
276
  env=merged_env,
276
277
  input=input,
277
278
  capture_output=False,
279
+ check=False,
278
280
  )
279
281
  stdout_res = empty_out
280
282
  stderr_res = empty_out
@@ -299,6 +301,7 @@ class Command:
299
301
  env=merged_env,
300
302
  input=input,
301
303
  capture_output=True,
304
+ check=False,
302
305
  )
303
306
  stdout_res = res.stdout
304
307
  stderr_res = res.stderr
@@ -366,9 +369,9 @@ class Command:
366
369
  )
367
370
 
368
371
  if input_data is not None and proc.stdin:
369
- if text and isinstance(input_data, str):
370
- proc.stdin.write(input_data)
371
- elif not text and isinstance(input_data, bytes):
372
+ if (text and isinstance(input_data, str)) or (
373
+ not text and isinstance(input_data, bytes)
374
+ ):
372
375
  proc.stdin.write(input_data)
373
376
  proc.stdin.close()
374
377
 
@@ -385,8 +388,11 @@ class Command:
385
388
  chunks.append(line)
386
389
  if not line.strip():
387
390
  dest_stream.write(line)
391
+ write_stream_to_file_handlers(line)
388
392
  else:
389
- dest_stream.write(f"{indent}{line}")
393
+ formatted = f"{indent}{line}"
394
+ dest_stream.write(formatted)
395
+ write_stream_to_file_handlers(formatted)
390
396
  dest_stream.flush()
391
397
  else:
392
398
  target = getattr(dest_stream, "buffer", dest_stream)
@@ -396,8 +402,11 @@ class Command:
396
402
  chunks.append(line)
397
403
  if not line.strip():
398
404
  target.write(line)
405
+ write_stream_to_file_handlers(line.decode("utf-8", errors="replace"))
399
406
  else:
400
- target.write(indent_bytes + line)
407
+ formatted_bytes = indent_bytes + line
408
+ target.write(formatted_bytes)
409
+ write_stream_to_file_handlers(formatted_bytes.decode("utf-8", errors="replace"))
401
410
  target.flush()
402
411
  finally:
403
412
  pipe.close()
@@ -527,8 +536,11 @@ class Pipeline:
527
536
  chunks.append(line)
528
537
  if not line.strip():
529
538
  dest_stream.write(line)
539
+ write_stream_to_file_handlers(line)
530
540
  else:
531
- dest_stream.write(f"{effective_indent}{line}")
541
+ formatted = f"{effective_indent}{line}"
542
+ dest_stream.write(formatted)
543
+ write_stream_to_file_handlers(formatted)
532
544
  dest_stream.flush()
533
545
  else:
534
546
  target = getattr(dest_stream, "buffer", dest_stream)
@@ -537,8 +549,11 @@ class Pipeline:
537
549
  chunks.append(line)
538
550
  if not line.strip():
539
551
  target.write(line)
552
+ write_stream_to_file_handlers(line.decode("utf-8", errors="replace"))
540
553
  else:
541
- target.write(indent_bytes + line)
554
+ formatted_bytes = indent_bytes + line
555
+ target.write(formatted_bytes)
556
+ write_stream_to_file_handlers(formatted_bytes.decode("utf-8", errors="replace"))
542
557
  target.flush()
543
558
  finally:
544
559
  pipe.close()
@@ -554,9 +569,9 @@ class Pipeline:
554
569
 
555
570
  if input is not None and processes[0].stdin:
556
571
  try:
557
- if text and isinstance(input, str):
558
- processes[0].stdin.write(input)
559
- elif not text and isinstance(input, bytes):
572
+ if (text and isinstance(input, str)) or (
573
+ not text and isinstance(input, bytes)
574
+ ):
560
575
  processes[0].stdin.write(input)
561
576
  processes[0].stdin.close()
562
577
  except BrokenPipeError:
@@ -575,9 +590,9 @@ class Pipeline:
575
590
  )
576
591
  if len(processes) > 1 and processes[0].stdin:
577
592
  try:
578
- if text and isinstance(input, str):
579
- processes[0].stdin.write(input)
580
- elif not text and isinstance(input, bytes):
593
+ if (text and isinstance(input, str)) or (
594
+ not text and isinstance(input, bytes)
595
+ ):
581
596
  processes[0].stdin.write(input)
582
597
  processes[0].stdin.close()
583
598
  except BrokenPipeError:
@@ -137,7 +137,7 @@ def find_files(
137
137
  directory: str | Path = ".",
138
138
  pattern: str = "*",
139
139
  *,
140
- recursive: bool = True,
140
+ recursive: bool = False,
141
141
  ) -> list[Path]:
142
142
  """Find files matching pattern in directory."""
143
143
  dir_path = Path(directory)
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ import re
6
+ import sys
7
+ from logging.handlers import RotatingFileHandler
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ from rich import print as rich_print
12
+ from rich.markup import escape
13
+
14
+ ActionColor = Literal["red", "green", "yellow", "blue", "magenta", "cyan", "white"]
15
+
16
+ _QUIET = False
17
+ _DEFAULT_INDENT = " "
18
+ _ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
19
+
20
+
21
+ def strip_ansi(text: str) -> str:
22
+ """Remove ANSI escape sequences from text."""
23
+ return _ANSI_RE.sub("", text)
24
+
25
+
26
+ def set_quiet(quiet: bool) -> None:
27
+ """Set global quiet mode for logging."""
28
+ global _QUIET
29
+ _QUIET = quiet
30
+
31
+
32
+ def is_quiet() -> bool:
33
+ """Check if quiet mode is enabled."""
34
+ return _QUIET
35
+
36
+
37
+ def set_default_indent(indent: str) -> None:
38
+ """Set global default indentation string for command outputs."""
39
+ global _DEFAULT_INDENT
40
+ _DEFAULT_INDENT = indent
41
+
42
+
43
+ def get_default_indent() -> str:
44
+ """Get the global default indentation string."""
45
+ return _DEFAULT_INDENT
46
+
47
+
48
+ class ConsoleActionHandler(logging.Handler):
49
+ """Handler that formats action logs to the terminal using rich."""
50
+
51
+ def emit(self, record: logging.LogRecord) -> None:
52
+ if _QUIET:
53
+ return
54
+ try:
55
+ action = getattr(record, "action", None)
56
+ color = getattr(record, "color", "white")
57
+ msg = record.getMessage()
58
+
59
+ if action:
60
+ prefix = f"[bold {color}]{action:>10}[/bold {color}]: "
61
+ lines = msg.splitlines()
62
+ if not lines:
63
+ rich_print(prefix.rstrip())
64
+ return
65
+ rich_print(f"{prefix}{escape(lines[0])}")
66
+ indent_pad = " " * 12
67
+ for line in lines[1:]:
68
+ rich_print(f"{indent_pad}{escape(line)}")
69
+ else:
70
+ # Direct message or indented block
71
+ if "\x1b" in msg:
72
+ sys.stdout.write(msg + "\n")
73
+ sys.stdout.flush()
74
+ else:
75
+ for line in msg.splitlines():
76
+ if not line.strip():
77
+ sys.stdout.write("\n")
78
+ else:
79
+ sys.stdout.write(f"{line}\n")
80
+ sys.stdout.flush()
81
+ except Exception: # noqa: BLE001
82
+ self.handleError(record)
83
+
84
+
85
+ class FileActionFormatter(logging.Formatter):
86
+ """Formats log records for file output without ANSI codes or markup."""
87
+
88
+ def format(self, record: logging.LogRecord) -> str:
89
+ action = getattr(record, "action", None)
90
+ raw_msg = record.getMessage()
91
+ clean_msg = strip_ansi(raw_msg)
92
+ asctime = self.formatTime(record, self.datefmt or "%Y-%m-%d %H:%M:%S")
93
+
94
+ if action:
95
+ lines = clean_msg.splitlines()
96
+ if not lines:
97
+ return f"{asctime} [{record.levelname:<5}] {action:>10}:"
98
+ first_line = f"{asctime} [{record.levelname:<5}] {action:>10}: {lines[0]}"
99
+ if len(lines) == 1:
100
+ return first_line
101
+ indent_pad = " " * 12
102
+ rest = [f"{asctime} [{record.levelname:<5}] {indent_pad}{line}" for line in lines[1:]]
103
+ return "\n".join([first_line, *rest])
104
+ else:
105
+ lines = clean_msg.splitlines()
106
+ if not lines:
107
+ return ""
108
+ return "\n".join([f"{asctime} [{record.levelname:<5}] {line}" for line in lines])
109
+
110
+
111
+ # Core package logger
112
+ logger = logging.getLogger("puild")
113
+ logger.setLevel(logging.INFO)
114
+ # Attach default console handler
115
+ _default_console_handler = ConsoleActionHandler()
116
+ logger.addHandler(_default_console_handler)
117
+
118
+
119
+ def configure_logging(
120
+ log_file: str | Path | None = None,
121
+ level: int | str = logging.INFO,
122
+ console: bool = True,
123
+ file_mode: str = "a",
124
+ max_bytes: int = 0,
125
+ backup_count: int = 0,
126
+ datefmt: str = "%Y-%m-%d %H:%M:%S",
127
+ ) -> logging.Logger:
128
+ """Configure puild logger to console and an optional file destination."""
129
+ if isinstance(level, str):
130
+ level = getattr(logging, level.upper(), logging.INFO)
131
+ logger.setLevel(level)
132
+
133
+ # Clean and close existing handlers
134
+ for h in logger.handlers:
135
+ h.close()
136
+ logger.handlers.clear()
137
+
138
+ if console:
139
+ c_handler = ConsoleActionHandler()
140
+ c_handler.setLevel(level)
141
+ logger.addHandler(c_handler)
142
+
143
+ if log_file:
144
+ p = Path(log_file)
145
+ p.parent.mkdir(parents=True, exist_ok=True)
146
+ if max_bytes > 0:
147
+ f_handler = RotatingFileHandler(
148
+ p, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
149
+ )
150
+ else:
151
+ f_handler = logging.FileHandler(p, mode=file_mode, encoding="utf-8")
152
+ f_handler.setLevel(level)
153
+ f_handler.setFormatter(FileActionFormatter(datefmt=datefmt))
154
+ logger.addHandler(f_handler)
155
+
156
+ return logger
157
+
158
+
159
+ def log_action(
160
+ action_name: str,
161
+ action_message: str,
162
+ action_color: ActionColor = "white",
163
+ level: int = logging.INFO,
164
+ ) -> None:
165
+ """Log an action using the standard puild logger."""
166
+ if action_name in ("Failed", "Error"):
167
+ level = logging.ERROR
168
+ logger.log(
169
+ level,
170
+ action_message,
171
+ extra={"action": action_name, "color": action_color},
172
+ )
173
+
174
+
175
+ def log_indented(
176
+ message: str,
177
+ indent: str | None = None,
178
+ stream: Any = sys.stdout,
179
+ level: int = logging.INFO,
180
+ ) -> None:
181
+ """Log multiline text with each line indented."""
182
+ if not message:
183
+ return
184
+ eff_indent = _DEFAULT_INDENT if indent is None else indent
185
+ if stream is not sys.stdout:
186
+ for line in message.splitlines():
187
+ if not line.strip():
188
+ stream.write("\n")
189
+ else:
190
+ stream.write(f"{eff_indent}{line}\n")
191
+ stream.flush()
192
+ return
193
+
194
+ lines: list[str] = []
195
+ for line in message.splitlines():
196
+ if not line.strip():
197
+ lines.append("")
198
+ else:
199
+ lines.append(f"{eff_indent}{line}")
200
+ formatted = "\n".join(lines)
201
+ logger.log(level, formatted, extra={"is_indented": True})
202
+
203
+
204
+ def write_stream_to_file_handlers(text: str) -> None:
205
+ """Write streaming command line output directly to registered FileHandlers."""
206
+ clean = strip_ansi(text)
207
+ for h in logger.handlers:
208
+ if isinstance(h, logging.FileHandler) and h.stream is not None:
209
+ with contextlib.suppress(OSError):
210
+ h.stream.write(clean)
211
+ h.stream.flush()
@@ -1,71 +0,0 @@
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()