puild 0.2.0__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.2.0
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.2.0"
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.2.0"
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,6 +14,7 @@ from puild.logging import (
14
14
  get_default_indent,
15
15
  log_action,
16
16
  log_indented,
17
+ write_stream_to_file_handlers,
17
18
  )
18
19
 
19
20
  _DRY_RUN = False
@@ -261,6 +262,7 @@ class Command:
261
262
  input=input,
262
263
  stdout=f,
263
264
  stderr=subprocess.PIPE if capture else None,
265
+ check=False,
264
266
  )
265
267
  stdout_res = empty_out
266
268
  stderr_res = res.stderr if capture and res.stderr is not None else empty_out
@@ -274,6 +276,7 @@ class Command:
274
276
  env=merged_env,
275
277
  input=input,
276
278
  capture_output=False,
279
+ check=False,
277
280
  )
278
281
  stdout_res = empty_out
279
282
  stderr_res = empty_out
@@ -298,6 +301,7 @@ class Command:
298
301
  env=merged_env,
299
302
  input=input,
300
303
  capture_output=True,
304
+ check=False,
301
305
  )
302
306
  stdout_res = res.stdout
303
307
  stderr_res = res.stderr
@@ -365,9 +369,9 @@ class Command:
365
369
  )
366
370
 
367
371
  if input_data is not None and proc.stdin:
368
- if text and isinstance(input_data, str):
369
- proc.stdin.write(input_data)
370
- 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
+ ):
371
375
  proc.stdin.write(input_data)
372
376
  proc.stdin.close()
373
377
 
@@ -384,8 +388,11 @@ class Command:
384
388
  chunks.append(line)
385
389
  if not line.strip():
386
390
  dest_stream.write(line)
391
+ write_stream_to_file_handlers(line)
387
392
  else:
388
- dest_stream.write(f"{indent}{line}")
393
+ formatted = f"{indent}{line}"
394
+ dest_stream.write(formatted)
395
+ write_stream_to_file_handlers(formatted)
389
396
  dest_stream.flush()
390
397
  else:
391
398
  target = getattr(dest_stream, "buffer", dest_stream)
@@ -395,8 +402,11 @@ class Command:
395
402
  chunks.append(line)
396
403
  if not line.strip():
397
404
  target.write(line)
405
+ write_stream_to_file_handlers(line.decode("utf-8", errors="replace"))
398
406
  else:
399
- 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"))
400
410
  target.flush()
401
411
  finally:
402
412
  pipe.close()
@@ -526,8 +536,11 @@ class Pipeline:
526
536
  chunks.append(line)
527
537
  if not line.strip():
528
538
  dest_stream.write(line)
539
+ write_stream_to_file_handlers(line)
529
540
  else:
530
- 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)
531
544
  dest_stream.flush()
532
545
  else:
533
546
  target = getattr(dest_stream, "buffer", dest_stream)
@@ -536,8 +549,11 @@ class Pipeline:
536
549
  chunks.append(line)
537
550
  if not line.strip():
538
551
  target.write(line)
552
+ write_stream_to_file_handlers(line.decode("utf-8", errors="replace"))
539
553
  else:
540
- 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"))
541
557
  target.flush()
542
558
  finally:
543
559
  pipe.close()
@@ -553,9 +569,9 @@ class Pipeline:
553
569
 
554
570
  if input is not None and processes[0].stdin:
555
571
  try:
556
- if text and isinstance(input, str):
557
- processes[0].stdin.write(input)
558
- elif not text and isinstance(input, bytes):
572
+ if (text and isinstance(input, str)) or (
573
+ not text and isinstance(input, bytes)
574
+ ):
559
575
  processes[0].stdin.write(input)
560
576
  processes[0].stdin.close()
561
577
  except BrokenPipeError:
@@ -574,9 +590,9 @@ class Pipeline:
574
590
  )
575
591
  if len(processes) > 1 and processes[0].stdin:
576
592
  try:
577
- if text and isinstance(input, str):
578
- processes[0].stdin.write(input)
579
- elif not text and isinstance(input, bytes):
593
+ if (text and isinstance(input, str)) or (
594
+ not text and isinstance(input, bytes)
595
+ ):
580
596
  processes[0].stdin.write(input)
581
597
  processes[0].stdin.close()
582
598
  except BrokenPipeError:
@@ -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()
File without changes