Migrate from pip-install-e + setuptools to a uv workspace with a
committed uv.lock for deterministic deps. Switch both members to
hatchling, and move package sources into nested standard layout
(l4d2host/l4d2host/, l4d2web/l4d2web/) so builds work from a
read-only source tree — setuptools wrote egg-info to source under
the old layout, which broke uv sync on the root-owned /opt/left4me/src.
Local dev install: `pip install -e ./l4d2host -e ./l4d2web` -> `uv sync`.
.envrc switches from `layout python python3.13` to `use uv`. Python
pinned to 3.13 via .python-version.
l4d2web now declares its cross-dep on l4d2host explicitly via
[tool.uv.sources] (workspace = true). l4d2web/alembic.ini and
l4d2web/alembic/ stay at the project root (standard alembic layout).
Test fixes:
- tests/__init__.py added to both test dirs so pytest doesn't shadow
l4d2host as a namespace package via outer-dir walk.
- 3 CWD-relative paths in tests (l4d2web/static/css/{tokens,layout}.css
and js/sse.js) anchored to Path(__file__) so they survive layout
changes.
- Two test_install.py tests now monkeypatch HOME to tmp_path so they
stop silently mutating ~/.steam/sdk32 on every run.
628 tests pass under sandboxed `uv run pytest`.
Per docs/superpowers/plans/2026-05-15-uv-workspace-execution.md;
prereq for the ckn-bw bundle's uv-sync action (queued).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
138 lines
3.7 KiB
Python
138 lines
3.7 KiB
Python
from dataclasses import dataclass
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Callable, Sequence
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class CommandResult:
|
|
returncode: int
|
|
stdout: str
|
|
stderr: str
|
|
|
|
|
|
class CommandCancelledError(subprocess.CalledProcessError):
|
|
pass
|
|
|
|
|
|
def run_command(
|
|
cmd: Sequence[str],
|
|
*,
|
|
on_stdout: Callable[[str], None] | None = None,
|
|
on_stderr: Callable[[str], None] | None = None,
|
|
passthrough: bool = False,
|
|
should_cancel: Callable[[], bool] | None = None,
|
|
cancel_poll_seconds: float = 0.2,
|
|
cancel_terminate_timeout: float = 2.0,
|
|
) -> CommandResult:
|
|
stdout_lines: list[str] = []
|
|
stderr_lines: list[str] = []
|
|
|
|
proc = subprocess.Popen(
|
|
list(cmd),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
start_new_session=should_cancel is not None,
|
|
)
|
|
|
|
def emit_stderr_message(line: str) -> None:
|
|
stderr_lines.append(line)
|
|
if on_stderr is not None:
|
|
on_stderr(line)
|
|
if passthrough:
|
|
print(line, file=sys.stderr, flush=True)
|
|
|
|
def terminate_process() -> None:
|
|
emit_stderr_message("cancellation requested; terminating subprocess")
|
|
if should_cancel is not None:
|
|
try:
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
else:
|
|
proc.terminate()
|
|
|
|
def kill_process() -> None:
|
|
emit_stderr_message("subprocess did not exit after cancellation; killing subprocess")
|
|
if should_cancel is not None:
|
|
try:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
else:
|
|
proc.kill()
|
|
|
|
def pump(
|
|
stream,
|
|
sink: list[str],
|
|
callback: Callable[[str], None] | None,
|
|
output_stream,
|
|
) -> None:
|
|
if stream is None:
|
|
return
|
|
for raw in iter(stream.readline, ""):
|
|
line = raw.rstrip("\n")
|
|
sink.append(line)
|
|
if callback is not None:
|
|
callback(line)
|
|
if passthrough:
|
|
print(line, file=output_stream, flush=True)
|
|
stream.close()
|
|
|
|
stdout_thread = threading.Thread(
|
|
target=pump,
|
|
args=(proc.stdout, stdout_lines, on_stdout, sys.stdout),
|
|
daemon=True,
|
|
)
|
|
stderr_thread = threading.Thread(
|
|
target=pump,
|
|
args=(proc.stderr, stderr_lines, on_stderr, sys.stderr),
|
|
daemon=True,
|
|
)
|
|
stdout_thread.start()
|
|
stderr_thread.start()
|
|
|
|
cancelled = False
|
|
while True:
|
|
returncode = proc.poll()
|
|
if returncode is not None:
|
|
break
|
|
if should_cancel is not None and should_cancel():
|
|
cancelled = True
|
|
terminate_process()
|
|
try:
|
|
returncode = proc.wait(timeout=cancel_terminate_timeout)
|
|
except subprocess.TimeoutExpired:
|
|
kill_process()
|
|
returncode = proc.wait()
|
|
break
|
|
time.sleep(cancel_poll_seconds)
|
|
stdout_thread.join()
|
|
stderr_thread.join()
|
|
|
|
result = CommandResult(
|
|
returncode=returncode,
|
|
stdout="\n".join(stdout_lines),
|
|
stderr="\n".join(stderr_lines),
|
|
)
|
|
if cancelled:
|
|
raise CommandCancelledError(
|
|
returncode=returncode,
|
|
cmd=list(cmd),
|
|
output=result.stdout,
|
|
stderr=result.stderr,
|
|
)
|
|
if returncode != 0:
|
|
raise subprocess.CalledProcessError(
|
|
returncode=returncode,
|
|
cmd=list(cmd),
|
|
output=result.stdout,
|
|
stderr=result.stderr,
|
|
)
|
|
return result
|