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