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.
- bake/__init__.py +9 -0
- bake/bakebook/bakebook.py +85 -0
- bake/bakebook/decorator.py +50 -0
- bake/bakebook/get.py +175 -0
- bake/cli/bake/__init__.py +3 -0
- bake/cli/bake/__main__.py +5 -0
- bake/cli/bake/main.py +74 -0
- bake/cli/bake/reinvocation.py +63 -0
- bake/cli/bakefile/__init__.py +3 -0
- bake/cli/bakefile/__main__.py +5 -0
- bake/cli/bakefile/add_inline.py +29 -0
- bake/cli/bakefile/find_python.py +18 -0
- bake/cli/bakefile/init.py +56 -0
- bake/cli/bakefile/lint.py +77 -0
- bake/cli/bakefile/main.py +41 -0
- bake/cli/bakefile/uv.py +146 -0
- bake/cli/common/app.py +54 -0
- bake/cli/common/callback.py +13 -0
- bake/cli/common/context.py +145 -0
- bake/cli/common/exception_handler.py +57 -0
- bake/cli/common/obj.py +214 -0
- bake/cli/common/params.py +72 -0
- bake/cli/utils/__init__.py +0 -0
- bake/cli/utils/version.py +18 -0
- bake/manage/__init__.py +0 -0
- bake/manage/add_inline.py +71 -0
- bake/manage/find_python.py +210 -0
- bake/manage/lint.py +101 -0
- bake/manage/run_uv.py +88 -0
- bake/manage/write_bakefile.py +20 -0
- bake/py.typed +0 -0
- bake/samples/__init__.py +0 -0
- bake/samples/simple.py +9 -0
- bake/ui/__init__.py +10 -0
- bake/ui/console.py +58 -0
- bake/ui/logger/__init__.py +33 -0
- bake/ui/logger/capsys.py +158 -0
- bake/ui/logger/setup.py +53 -0
- bake/ui/logger/utils.py +215 -0
- bake/ui/run/__init__.py +11 -0
- bake/ui/run/run.py +541 -0
- bake/ui/run/script.py +74 -0
- bake/ui/run/splitter.py +237 -0
- bake/ui/run/uv.py +83 -0
- bake/ui/style.py +2 -0
- bake/utils/__init__.py +11 -0
- bake/utils/constants.py +21 -0
- {bakefile → bake/utils}/env.py +3 -1
- bake/utils/exceptions.py +17 -0
- {bakefile-0.0.4.dist-info → bakefile-0.0.5.dist-info}/METADATA +14 -2
- bakefile-0.0.5.dist-info/RECORD +61 -0
- {bakefile-0.0.4.dist-info → bakefile-0.0.5.dist-info}/WHEEL +1 -1
- bakefile-0.0.5.dist-info/entry_points.txt +5 -0
- bakelib/__init__.py +4 -0
- bakelib/space/__init__.py +0 -0
- bakelib/space/base.py +73 -0
- bakelib/space/python.py +42 -0
- bakelib/space/utils.py +55 -0
- bakefile/__init__.py +0 -13
- bakefile/cli/bake/__init__.py +0 -3
- bakefile/cli/bake/main.py +0 -127
- bakefile/cli/bake/resolve_bakebook.py +0 -103
- bakefile/cli/bake/utils.py +0 -25
- bakefile/cli/bakefile.py +0 -19
- bakefile/cli/utils/version.py +0 -9
- bakefile/exceptions.py +0 -9
- bakefile-0.0.4.dist-info/RECORD +0 -16
- bakefile-0.0.4.dist-info/entry_points.txt +0 -4
- {bakefile/cli/utils → bake/bakebook}/__init__.py +0 -0
- {bakefile → bake}/cli/__init__.py +0 -0
- /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
|
+
)
|