bakefile 0.0.4__py3-none-any.whl → 0.0.6__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.
Files changed (73) hide show
  1. bake/__init__.py +9 -0
  2. bake/bakebook/bakebook.py +85 -0
  3. bake/bakebook/decorator.py +50 -0
  4. bake/bakebook/get.py +175 -0
  5. bake/cli/bake/__init__.py +3 -0
  6. bake/cli/bake/__main__.py +5 -0
  7. bake/cli/bake/main.py +74 -0
  8. bake/cli/bake/reinvocation.py +63 -0
  9. bake/cli/bakefile/__init__.py +3 -0
  10. bake/cli/bakefile/__main__.py +5 -0
  11. bake/cli/bakefile/add_inline.py +29 -0
  12. bake/cli/bakefile/export.py +212 -0
  13. bake/cli/bakefile/find_python.py +18 -0
  14. bake/cli/bakefile/init.py +56 -0
  15. bake/cli/bakefile/lint.py +77 -0
  16. bake/cli/bakefile/main.py +43 -0
  17. bake/cli/bakefile/uv.py +146 -0
  18. bake/cli/common/app.py +54 -0
  19. bake/cli/common/callback.py +13 -0
  20. bake/cli/common/context.py +145 -0
  21. bake/cli/common/exception_handler.py +57 -0
  22. bake/cli/common/obj.py +216 -0
  23. bake/cli/common/params.py +72 -0
  24. bake/cli/utils/__init__.py +0 -0
  25. bake/cli/utils/version.py +18 -0
  26. bake/manage/__init__.py +0 -0
  27. bake/manage/add_inline.py +71 -0
  28. bake/manage/find_python.py +210 -0
  29. bake/manage/lint.py +101 -0
  30. bake/manage/run_uv.py +88 -0
  31. bake/manage/write_bakefile.py +20 -0
  32. bake/py.typed +0 -0
  33. bake/samples/__init__.py +0 -0
  34. bake/samples/simple.py +8 -0
  35. bake/ui/__init__.py +11 -0
  36. bake/ui/console.py +58 -0
  37. bake/ui/logger/__init__.py +33 -0
  38. bake/ui/logger/capsys.py +158 -0
  39. bake/ui/logger/setup.py +53 -0
  40. bake/ui/logger/utils.py +215 -0
  41. bake/ui/params.py +5 -0
  42. bake/ui/run/__init__.py +5 -0
  43. bake/ui/run/run.py +546 -0
  44. bake/ui/run/script.py +74 -0
  45. bake/ui/run/splitter.py +249 -0
  46. bake/ui/run/uv.py +83 -0
  47. bake/ui/style.py +2 -0
  48. bake/utils/__init__.py +11 -0
  49. bake/utils/constants.py +21 -0
  50. {bakefile → bake/utils}/env.py +3 -1
  51. bake/utils/exceptions.py +17 -0
  52. {bakefile-0.0.4.dist-info → bakefile-0.0.6.dist-info}/METADATA +15 -2
  53. bakefile-0.0.6.dist-info/RECORD +63 -0
  54. {bakefile-0.0.4.dist-info → bakefile-0.0.6.dist-info}/WHEEL +2 -2
  55. bakefile-0.0.6.dist-info/entry_points.txt +5 -0
  56. bakelib/__init__.py +4 -0
  57. bakelib/space/__init__.py +0 -0
  58. bakelib/space/base.py +193 -0
  59. bakelib/space/python.py +80 -0
  60. bakelib/space/utils.py +118 -0
  61. bakefile/__init__.py +0 -13
  62. bakefile/cli/bake/__init__.py +0 -3
  63. bakefile/cli/bake/main.py +0 -127
  64. bakefile/cli/bake/resolve_bakebook.py +0 -103
  65. bakefile/cli/bake/utils.py +0 -25
  66. bakefile/cli/bakefile.py +0 -19
  67. bakefile/cli/utils/version.py +0 -9
  68. bakefile/exceptions.py +0 -9
  69. bakefile-0.0.4.dist-info/RECORD +0 -16
  70. bakefile-0.0.4.dist-info/entry_points.txt +0 -4
  71. {bakefile/cli/utils → bake/bakebook}/__init__.py +0 -0
  72. {bakefile → bake}/cli/__init__.py +0 -0
  73. /bakefile/py.typed → /bake/cli/common/__init__.py +0 -0
bake/ui/run/run.py ADDED
@@ -0,0 +1,546 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import threading
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal, overload
12
+
13
+ import typer
14
+
15
+ from bake.ui import console
16
+ from bake.ui.run.splitter import OutputSplitter
17
+
18
+ # Import pty on Unix systems for color-preserving PTY support
19
+ if sys.platform != "win32":
20
+ import pty
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Lock for subprocess.Popen calls - subprocess is not thread-safe by design
25
+ # See: https://bugs.python.org/issue2320, https://bugs.python.org/issue12739
26
+ _subprocess_create_lock = threading.Lock()
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class StreamSetup:
31
+ proc: subprocess.Popen
32
+ splitter: OutputSplitter
33
+ threads: list
34
+
35
+
36
+ def _parse_shebang(script: str) -> str | None:
37
+ """Parse shebang line, return interpreter path or None."""
38
+ lines = script.strip().splitlines()
39
+ if not lines or not lines[0].startswith("#!"):
40
+ return None
41
+
42
+ shebang = lines[0][2:].strip()
43
+
44
+ # Handle /usr/bin/env XXX
45
+ if shebang.startswith("/usr/bin/env "):
46
+ interpreter = shebang.split()[1] # Get "python3" from "/usr/bin/env python3"
47
+ return _resolve_interpreter(interpreter)
48
+
49
+ # Direct path like /usr/bin/python3
50
+ return shebang
51
+
52
+
53
+ def _resolve_interpreter(interpreter: str) -> str | None:
54
+ """Resolve interpreter path, handling cross-platform differences."""
55
+ # If it's an absolute path, use as-is
56
+ if os.path.isabs(interpreter):
57
+ return interpreter if os.path.exists(interpreter) else None
58
+
59
+ # Search in PATH
60
+ return shutil.which(interpreter)
61
+
62
+
63
+ def _run_with_temp_file(
64
+ cmd: str,
65
+ capture_output: bool,
66
+ check: bool,
67
+ cwd: Path | str | None,
68
+ stream: bool,
69
+ keep_temp_file: bool = False,
70
+ env: dict[str, str] | None = None,
71
+ _encoding: str = "utf-8",
72
+ **kwargs,
73
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[None]:
74
+ """Run multi-line script using temp file with shebang support.
75
+
76
+ On Windows: Parse shebang and use interpreter explicitly, or use cmd.exe /c.
77
+ On Unix: Make file executable and run directly (kernel handles shebang).
78
+
79
+ Parameters
80
+ ----------
81
+ keep_temp_file : bool, optional
82
+ If True, skip deletion of temp file for debugging. Default is False.
83
+ _encoding : str, optional
84
+ Encoding to use for subprocess output. Defaults to "utf-8" to ensure
85
+ cross-platform UTF-8 support for temp file scripts.
86
+
87
+ Notes
88
+ -----
89
+ Cross-platform UTF-8 support: On Windows, console encoding defaults to cp1252.
90
+ For scripts that output UTF-8 characters (non-ASCII, emoji, etc.), users should
91
+ pass appropriate environment variables:
92
+
93
+ - Python: env={"PYTHONIOENCODING": "utf-8"}
94
+ - Node.js: env={"NODE_OPTIONS": "--input-type=module"} or similar
95
+ - Other interpreters: consult their documentation for UTF-8 environment variables
96
+ """
97
+ # Create temp file with appropriate extension
98
+ suffix = ".bat" if sys.platform == "win32" else ".sh"
99
+ fd, path = tempfile.mkstemp(suffix=suffix)
100
+
101
+ try:
102
+ # Write script to temp file
103
+ os.write(fd, cmd.encode("utf-8"))
104
+ os.close(fd)
105
+
106
+ # Check for shebang
107
+ interpreter = _parse_shebang(cmd)
108
+
109
+ # Determine command based on platform
110
+ if sys.platform == "win32":
111
+ # Windows: Parse shebang and use interpreter explicitly
112
+ cmd_to_run: list[str] = [interpreter, path] if interpreter else ["cmd.exe", "/c", path]
113
+ else:
114
+ # Unix: Make file executable and run directly (kernel handles shebang)
115
+ os.chmod(path, 0o700) # rwx------ (owner only, more secure)
116
+ cmd_to_run: list[str] = [path]
117
+
118
+ return run(
119
+ cmd=cmd_to_run,
120
+ capture_output=capture_output,
121
+ check=check,
122
+ cwd=cwd,
123
+ stream=stream,
124
+ echo=False,
125
+ env=env,
126
+ _encoding=_encoding,
127
+ **kwargs,
128
+ )
129
+ finally:
130
+ # Clean up temp file unless keep_temp_file is True
131
+ if keep_temp_file:
132
+ logger.debug(f"Temp file kept for debugging: {path}")
133
+ elif os.path.exists(path):
134
+ os.unlink(path)
135
+
136
+
137
+ CmdType = str | list[str] | tuple[str, ...]
138
+
139
+
140
+ @overload
141
+ def run(
142
+ cmd: CmdType,
143
+ *,
144
+ capture_output: Literal[True] = True,
145
+ check: bool = True,
146
+ cwd: Path | str | None = None,
147
+ stream: bool = True,
148
+ shell: bool | None = None,
149
+ echo: bool = True,
150
+ dry_run: bool = False,
151
+ keep_temp_file: bool = False,
152
+ env: dict[str, str] | None = None,
153
+ _encoding: str | None = None,
154
+ **kwargs,
155
+ ) -> subprocess.CompletedProcess[str]: ...
156
+
157
+
158
+ @overload
159
+ def run(
160
+ cmd: CmdType,
161
+ *,
162
+ capture_output: Literal[False],
163
+ check: bool = True,
164
+ cwd: Path | str | None = None,
165
+ stream: bool = True,
166
+ shell: bool | None = None,
167
+ echo: bool = True,
168
+ dry_run: bool = False,
169
+ keep_temp_file: bool = False,
170
+ env: dict[str, str] | None = None,
171
+ _encoding: str | None = None,
172
+ **kwargs,
173
+ ) -> subprocess.CompletedProcess[None]: ...
174
+
175
+
176
+ def run(
177
+ cmd: CmdType,
178
+ *,
179
+ capture_output: bool = True,
180
+ check: bool = True,
181
+ cwd: Path | str | None = None,
182
+ stream: bool = True,
183
+ shell: bool | None = None,
184
+ echo: bool = True,
185
+ dry_run: bool = False,
186
+ keep_temp_file: bool = False,
187
+ env: dict[str, str] | None = None,
188
+ _encoding: str | None = None,
189
+ **kwargs,
190
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[None]:
191
+ """Run a command with optional streaming and output capture.
192
+
193
+ Parameters
194
+ ----------
195
+ cmd : str | list[str] | tuple[str, ...]
196
+ Command as string, list, or tuple of strings.
197
+ String commands automatically use shell=True for shell features
198
+ (pipes, wildcards, chaining). List/tuple commands use shell=False
199
+ for safer direct execution.
200
+ capture_output : bool, optional
201
+ Whether to capture stdout/stderr, by default True.
202
+ check : bool, optional
203
+ Raise typer.Exit on non-zero exit code, by default True.
204
+ cwd : Path | str | None, optional
205
+ Working directory, by default None.
206
+ stream : bool, optional
207
+ Stream output to terminal in real-time, by default True.
208
+ On Unix, uses PTY to preserve ANSI color codes.
209
+ shell : bool | None, optional
210
+ Whether to use shell for command execution, by default None.
211
+ When None (default), auto-detected from command type:
212
+ str → True, list/tuple → False.
213
+ **Security Warning:** Shell=True can be vulnerable to injection
214
+ with untrusted input. Only use with trusted commands.
215
+ echo : bool, optional
216
+ Display command before execution using console.cmd().
217
+ Default is True. Set to False for silent execution.
218
+ dry_run : bool, optional
219
+ Display command without executing (dry-run mode).
220
+ Default is False. Does NOT auto-echo; combine with echo=True
221
+ to preview commands.
222
+ keep_temp_file : bool, optional
223
+ Keep temporary script files for debugging instead of deleting them.
224
+ Only applies when temp files are created (multi-line scripts on Windows
225
+ or scripts with shebang). Default is False. Logs temp file path when True.
226
+ env : dict[str, str] | None, optional
227
+ Environment variables for the subprocess. Merged with system environment
228
+ to preserve critical variables like SYSTEMROOT on Windows. User-provided
229
+ variables override defaults. Default is None (use system environment).
230
+ **kwargs
231
+ Additional arguments passed to subprocess.
232
+
233
+ Returns
234
+ -------
235
+ subprocess.CompletedProcess[str | None]
236
+ CompletedProcess with stdout/stderr as strings
237
+ (or None if not captured).
238
+
239
+ Raises
240
+ ------
241
+ typer.Exit
242
+ When check=True and command returns non-zero exit code.
243
+
244
+ Examples
245
+ --------
246
+ >>> run("echo hello") # Shows and runs command
247
+ >>> run("echo hello", echo=False) # Silent execution
248
+ >>> run("echo hello", dry_run=True) # Silent dry-run
249
+ >>> run("echo hello", echo=True, dry_run=True) # Show but don't run
250
+ >>> run("ls *.py | wc -l") # Pipes and wildcards
251
+ >>> run(["echo", "hello"]) # List for direct execution
252
+ """
253
+ _validate_params(stream=stream, capture_output=capture_output)
254
+ shell = _detect_shell(cmd=cmd, shell=shell)
255
+ cmd_str = _format_cmd_str(cmd=cmd)
256
+
257
+ if echo:
258
+ console.cmd(cmd_str)
259
+
260
+ if dry_run:
261
+ return _dry_run_result(cmd=cmd, capture_output=capture_output, cwd=cwd)
262
+
263
+ # Handle multi-line scripts that require temp file approach:
264
+ # - Windows: Any multi-line script with shell=True (cmd.exe limitation)
265
+ # - Any platform: Scripts with shebang (need file for kernel/interpreter)
266
+ cmd_str_for_shebang = cmd if isinstance(cmd, str) else ""
267
+ has_shebang = cmd_str_for_shebang.strip().startswith("#!")
268
+
269
+ # Main condition: string command with shell=True
270
+ if isinstance(cmd, str) and shell:
271
+ # Type narrowing: string_cmd is now known to be str
272
+ string_cmd = cmd
273
+ # Sub-conditions that require temp file:
274
+ # 1. Windows with multi-line script
275
+ # 2. Any platform with shebang
276
+ needs_temp_file = (sys.platform == "win32" and "\n" in string_cmd) or has_shebang
277
+ else:
278
+ needs_temp_file = False
279
+ string_cmd = "" # Placeholder, won't be used when needs_temp_file=False
280
+
281
+ if needs_temp_file:
282
+ return _run_with_temp_file(
283
+ cmd=string_cmd,
284
+ capture_output=capture_output,
285
+ check=check,
286
+ cwd=cwd,
287
+ stream=stream,
288
+ keep_temp_file=keep_temp_file,
289
+ env=env,
290
+ **kwargs,
291
+ )
292
+
293
+ logger.debug(f"[run] {cmd_str}", extra={"cwd": cwd})
294
+ start = time.perf_counter()
295
+
296
+ _run = _run_with_stream if stream else _run_without_stream
297
+
298
+ result = _run(
299
+ cmd=cmd,
300
+ shell=shell,
301
+ cwd=cwd,
302
+ capture_output=capture_output,
303
+ env=env,
304
+ _encoding=_encoding,
305
+ **kwargs,
306
+ )
307
+
308
+ _check_exit_code(returncode=result.returncode, check=check, cmd_str=cmd_str)
309
+
310
+ _log_completion(cmd_str=cmd_str, result=result, start=start)
311
+ return result
312
+
313
+
314
+ def _validate_params(stream: bool, capture_output: bool) -> None:
315
+ if stream is False and capture_output is False:
316
+ raise ValueError("At least one of `stream` or `capture_output` must be True")
317
+
318
+
319
+ def _detect_shell(cmd: str | list[str] | tuple[str, ...], shell: bool | None) -> bool:
320
+ if shell is None:
321
+ return isinstance(cmd, str)
322
+ return shell
323
+
324
+
325
+ def _format_cmd_str(cmd: str | list[str] | tuple[str, ...]) -> str:
326
+ return cmd if isinstance(cmd, str) else " ".join(cmd)
327
+
328
+
329
+ def _dry_run_result(
330
+ cmd: str | list[str] | tuple[str, ...],
331
+ capture_output: bool,
332
+ cwd: Path | str | None,
333
+ ) -> subprocess.CompletedProcess[str]:
334
+ cmd_str = _format_cmd_str(cmd)
335
+ logger.debug(f"[dry-run] {cmd_str}", extra={"cwd": cwd})
336
+ return subprocess.CompletedProcess(
337
+ args=cmd,
338
+ returncode=0,
339
+ stdout="" if capture_output else None,
340
+ stderr="" if capture_output else None,
341
+ )
342
+
343
+
344
+ def _check_exit_code(returncode: int, check: bool, cmd_str: str) -> None:
345
+ if check and returncode != 0:
346
+ logger.debug(f"[error] {cmd_str}", extra={"returncode": returncode})
347
+ raise typer.Exit(returncode)
348
+
349
+
350
+ def _process_stream_output(
351
+ splitter: OutputSplitter,
352
+ proc: subprocess.Popen,
353
+ cmd: str | list[str] | tuple[str, ...],
354
+ capture_output: bool,
355
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[None]:
356
+ stdout: str | None
357
+ stderr: str | None
358
+
359
+ if capture_output:
360
+ encoding = splitter._encoding or "utf-8"
361
+ stdout = splitter.stdout.decode(encoding, errors="replace")
362
+ stderr = splitter.stderr.decode(encoding, errors="replace")
363
+ # Normalize PTY line endings (\r\n -> \n)
364
+ stdout = stdout.replace("\r\n", "\n")
365
+ stderr = stderr.replace("\r\n", "\n")
366
+ else:
367
+ stdout = None
368
+ stderr = None
369
+
370
+ return subprocess.CompletedProcess(
371
+ args=cmd, returncode=proc.returncode, stdout=stdout, stderr=stderr
372
+ )
373
+
374
+
375
+ def _prepare_subprocess_env(env: dict[str, str] | None = None) -> dict[str, str]:
376
+ # Always start with system environment to preserve critical variables like
377
+ # SYSTEMROOT on Windows (required for Python initialization - see Prefect #4923)
378
+ merged_env = os.environ.copy()
379
+ if env:
380
+ merged_env.update(env)
381
+ merged_env.setdefault("FORCE_COLOR", "1")
382
+ merged_env.setdefault("CLICOLOR_FORCE", "1")
383
+ try:
384
+ terminal_size = os.get_terminal_size()
385
+ merged_env.setdefault("COLUMNS", str(terminal_size.columns))
386
+ merged_env.setdefault("LINES", str(terminal_size.lines))
387
+ except OSError:
388
+ pass
389
+ return merged_env
390
+
391
+
392
+ def _setup_pty_stream(
393
+ cmd: str | list[str] | tuple[str, ...],
394
+ shell: bool,
395
+ cwd: Path | str | None,
396
+ capture_output: bool,
397
+ env: dict[str, str] | None = None,
398
+ _encoding: str | None = None,
399
+ **kwargs,
400
+ ) -> StreamSetup:
401
+ # subprocess.Popen is not thread-safe, protect with lock
402
+ # See: https://bugs.python.org/issue2320
403
+ with _subprocess_create_lock:
404
+ stdout_fd, slave_stdout = pty.openpty()
405
+
406
+ # Always create stderr PTY when streaming to ensure output goes through
407
+ # our thread which writes to sys.stderr (allows pytest to capture it)
408
+ stderr_fd, slave_stderr = pty.openpty()
409
+
410
+ env = _prepare_subprocess_env(env)
411
+ proc = subprocess.Popen(
412
+ cmd,
413
+ cwd=cwd,
414
+ stdout=slave_stdout,
415
+ stderr=slave_stderr,
416
+ shell=shell,
417
+ env=env,
418
+ **kwargs,
419
+ )
420
+ os.close(slave_stdout)
421
+ os.close(slave_stderr)
422
+
423
+ # Attach threads BEFORE releasing lock to ensure reader is ready
424
+ # when fast-exiting processes complete
425
+ splitter = OutputSplitter(
426
+ stream=True,
427
+ capture=capture_output,
428
+ pty_fd=stdout_fd,
429
+ stderr_pty_fd=stderr_fd,
430
+ encoding=_encoding,
431
+ )
432
+ threads = splitter.attach(proc)
433
+
434
+ return StreamSetup(proc=proc, splitter=splitter, threads=threads)
435
+
436
+
437
+ def _setup_pipe_stream(
438
+ cmd: str | list[str] | tuple[str, ...],
439
+ shell: bool,
440
+ cwd: Path | str | None,
441
+ capture_output: bool,
442
+ env: dict[str, str] | None = None,
443
+ _encoding: str | None = None,
444
+ **kwargs,
445
+ ) -> StreamSetup:
446
+ # subprocess.Popen is not thread-safe, protect with lock
447
+ # See: https://bugs.python.org/issue2320
448
+ with _subprocess_create_lock:
449
+ env = _prepare_subprocess_env(env)
450
+ proc = subprocess.Popen(
451
+ cmd,
452
+ cwd=cwd,
453
+ stdout=subprocess.PIPE,
454
+ stderr=subprocess.PIPE,
455
+ shell=shell,
456
+ env=env,
457
+ **kwargs,
458
+ )
459
+ # Attach threads BEFORE releasing lock to ensure reader is ready
460
+ # when fast-exiting processes complete
461
+ splitter = OutputSplitter(stream=True, capture=capture_output, encoding=_encoding)
462
+ threads = splitter.attach(proc)
463
+
464
+ return StreamSetup(proc=proc, splitter=splitter, threads=threads)
465
+
466
+
467
+ def _run_with_stream(
468
+ cmd: str | list[str] | tuple[str, ...],
469
+ shell: bool,
470
+ cwd: Path | str | None,
471
+ capture_output: bool,
472
+ env: dict[str, str] | None = None,
473
+ _encoding: str | None = None,
474
+ **kwargs,
475
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[None]:
476
+ use_pty = sys.platform != "win32"
477
+
478
+ _setup = _setup_pty_stream if use_pty else _setup_pipe_stream
479
+
480
+ setup = _setup(
481
+ cmd=cmd,
482
+ shell=shell,
483
+ cwd=cwd,
484
+ capture_output=capture_output,
485
+ env=env,
486
+ _encoding=_encoding,
487
+ **kwargs,
488
+ )
489
+
490
+ setup.proc.wait()
491
+ setup.splitter.finalize(setup.threads)
492
+
493
+ return _process_stream_output(setup.splitter, setup.proc, cmd, capture_output)
494
+
495
+
496
+ def _run_without_stream(
497
+ cmd: str | list[str] | tuple[str, ...],
498
+ shell: bool,
499
+ cwd: Path | str | None,
500
+ capture_output: bool,
501
+ env: dict[str, str] | None = None,
502
+ _encoding: str | None = None,
503
+ **kwargs,
504
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[None]:
505
+ # Prepare environment (merges with system env to preserve SYSTEMROOT on Windows)
506
+ env = _prepare_subprocess_env(env)
507
+
508
+ # Use specified encoding with errors="replace", or fall back to text=True (platform default)
509
+ if _encoding:
510
+ result = subprocess.run(
511
+ cmd,
512
+ cwd=cwd,
513
+ capture_output=capture_output,
514
+ check=False,
515
+ shell=shell,
516
+ env=env,
517
+ encoding=_encoding,
518
+ errors="replace",
519
+ **kwargs,
520
+ )
521
+ else:
522
+ result = subprocess.run(
523
+ cmd,
524
+ cwd=cwd,
525
+ capture_output=capture_output,
526
+ text=True,
527
+ check=False,
528
+ shell=shell,
529
+ env=env,
530
+ **kwargs,
531
+ )
532
+
533
+ return result
534
+
535
+
536
+ def _log_completion(cmd_str: str, result: subprocess.CompletedProcess, start: float) -> None:
537
+ elapsed_seconds = time.perf_counter() - start
538
+ logger.debug(
539
+ f"[done] {cmd_str}",
540
+ extra={
541
+ "returncode": result.returncode,
542
+ "stdout": result.stdout,
543
+ "stderr": result.stderr,
544
+ "elapsed_seconds": elapsed_seconds,
545
+ },
546
+ )
bake/ui/run/script.py ADDED
@@ -0,0 +1,74 @@
1
+ import logging
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from bake.ui import console
6
+ from bake.ui.run import run
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def run_script(
12
+ title: str,
13
+ script: str,
14
+ *,
15
+ capture_output: bool = True,
16
+ check: bool = True,
17
+ cwd: Path | str | None = None,
18
+ stream: bool = True,
19
+ echo: bool = True,
20
+ dry_run: bool = False,
21
+ keep_temp_file: bool = False,
22
+ env: dict[str, str] | None = None,
23
+ **kwargs,
24
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[None]:
25
+ """Run a multi-line script with shebang support.
26
+
27
+ Creates a temporary file with the script content and executes it. On Unix,
28
+ the file is made executable and run directly (kernel handles shebang). On
29
+ Windows, the shebang is parsed and the interpreter is invoked explicitly.
30
+
31
+ For cross-platform UTF-8 support in scripts with non-ASCII characters, pass
32
+ appropriate environment variables. Example for Python scripts:
33
+ run_script("My Script", script, env={"PYTHONIOENCODING": "utf-8"})
34
+
35
+ Parameters
36
+ ----------
37
+ title : str
38
+ Display title for the script (shown in console output).
39
+ script : str
40
+ Multi-line script content to execute.
41
+ env : dict[str, str] | None, optional
42
+ Environment variables for the subprocess. Merged with system environment
43
+ to preserve critical variables like SYSTEMROOT on Windows. User-provided
44
+ variables override defaults.
45
+ **kwargs
46
+ Additional arguments passed to :func:`run`. Common options include:
47
+ - keep_temp_file: bool to skip temp file cleanup (for debugging)
48
+ """
49
+ script = script.strip()
50
+
51
+ if echo:
52
+ console.script_block(title, script)
53
+
54
+ if dry_run:
55
+ logger.debug(f"[dry-run] {title}", extra={"cwd": cwd})
56
+ return subprocess.CompletedProcess(
57
+ args=script,
58
+ returncode=0,
59
+ stdout="" if capture_output else None,
60
+ stderr="" if capture_output else None,
61
+ )
62
+
63
+ return run(
64
+ script,
65
+ capture_output=capture_output,
66
+ check=check,
67
+ cwd=cwd,
68
+ stream=stream,
69
+ echo=False,
70
+ shell=True,
71
+ keep_temp_file=keep_temp_file,
72
+ env=env,
73
+ **kwargs,
74
+ )