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>
63 lines
2 KiB
Python
63 lines
2 KiB
Python
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
|
|
_INSTANCE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
|
|
|
|
DEFAULT_LEFT4ME_ROOT = Path("/var/lib/left4me")
|
|
|
|
|
|
def get_left4me_root() -> Path:
|
|
raw = os.environ.get("LEFT4ME_ROOT")
|
|
if raw is None:
|
|
return DEFAULT_LEFT4ME_ROOT
|
|
root = raw.strip()
|
|
if not root:
|
|
raise ValueError("LEFT4ME_ROOT must not be empty")
|
|
if root != raw:
|
|
raise ValueError("LEFT4ME_ROOT must not contain leading or trailing whitespace")
|
|
path = Path(root)
|
|
if not path.is_absolute():
|
|
raise ValueError("LEFT4ME_ROOT must be absolute")
|
|
return path
|
|
|
|
|
|
def validate_instance_name(name: str) -> str:
|
|
if not _INSTANCE_NAME_RE.fullmatch(name):
|
|
raise ValueError(
|
|
"instance name must match [a-z0-9][a-z0-9_-]{0,63} "
|
|
"(lowercase, no path separators, no whitespace)"
|
|
)
|
|
return name
|
|
|
|
|
|
def validate_overlay_ref(ref: str) -> str:
|
|
stripped = ref.strip()
|
|
if stripped != ref:
|
|
raise ValueError("overlay ref must not contain leading or trailing whitespace")
|
|
if stripped in {"", ".", ".."}:
|
|
raise ValueError("overlay ref must not be empty or current/parent directory")
|
|
if Path(stripped).is_absolute():
|
|
raise ValueError("overlay ref must be relative")
|
|
|
|
components = stripped.split("/")
|
|
if any(component in {"", ".", ".."} for component in components):
|
|
raise ValueError("overlay ref must not contain empty, current, or parent components")
|
|
|
|
return stripped
|
|
|
|
|
|
def overlay_path(ref: str, *, root: Path | None = None) -> Path:
|
|
safe_ref = validate_overlay_ref(ref)
|
|
left4me_root = get_left4me_root() if root is None else Path(root)
|
|
overlays_root = left4me_root / "overlays"
|
|
candidate = overlays_root / safe_ref
|
|
|
|
resolved_overlays_root = overlays_root.resolve()
|
|
resolved_candidate = candidate.resolve()
|
|
if resolved_candidate != resolved_overlays_root and resolved_overlays_root not in resolved_candidate.parents:
|
|
raise ValueError("overlay path escapes overlay root")
|
|
|
|
return candidate
|