left4me/l4d2host/l4d2host/paths.py
mwiegand 49992b3a26
refactor(repo): uv workspace + hatchling + layout restructure
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>
2026-05-15 22:04:29 +02:00

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