scripts: portable Wine + uv + PDM helpers for Windows checks

Isolated project-view setup without touching the host venv; typings linked for basedpyright.
This commit is contained in:
2026-07-15 20:01:54 +08:00
parent c466d09173
commit e1547a242c
3 changed files with 484 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
"""Portable Wine + Windows uv/Python paths for plyngent.
All locations are derived from this file's path and env overrides.
Does not mutate the host project virtualenv.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.request import urlretrieve
# Defaults are version tags only — not machine paths.
DEFAULT_WIN_CPYTHON = "3.14"
DEFAULT_WIN_UV_VERSION = "0.11.28"
# typings/ is required for Wine basedpyright: pywinpty's shipped types are incomplete
# (e.g. kill(sig) required) and stubPath=typings is set in pyproject.toml.
VIEW_LINKS = ("src", "tests", "typings", "README.md", "LICENSE", "CLAUDE.md", "doc")
VIEW_COPIES = ("pyproject.toml", "pdm.lock")
def repo_root() -> Path:
"""Repository root (parent of ``scripts/``)."""
env = os.environ.get("PLYNGENT_SRC")
if env:
return Path(env).expanduser().resolve()
return Path(__file__).resolve().parent.parent
def wine_base() -> Path:
"""Isolated work directory (never the project tree)."""
env = os.environ.get("PLYNGENT_WINE_BASE")
if env:
return Path(env).expanduser().resolve()
return Path(tempfile.gettempdir()).resolve() / "plyngent-wine"
def to_wine_z(path: Path) -> str:
"""Map an absolute POSIX path to Wine ``Z:\\...`` form.
On typical Linux Wine installs, ``Z:`` is the host root ``/``.
"""
resolved = path.expanduser().resolve()
if not resolved.is_absolute():
msg = f"path must be absolute for Wine Z: mapping: {path}"
raise ValueError(msg)
# Z:\tmp\foo
return "Z:" + str(resolved).replace("/", "\\")
def to_wine_c(prefix: Path, host_path: Path) -> str:
"""Map a path under the Wine prefix drive_c to ``C:\\...``."""
resolved = host_path.expanduser().resolve()
drive_c = (prefix / "drive_c").resolve()
try:
rel = resolved.relative_to(drive_c)
except ValueError as exc:
msg = f"{resolved} is not under {drive_c}"
raise ValueError(msg) from exc
return "C:\\" + str(rel).replace("/", "\\")
@dataclass(frozen=True, slots=True)
class WineLayout:
"""All host and Wine paths for one isolated environment."""
src: Path
base: Path
prefix: Path
view: Path
bin_dir: Path
uv_python: Path
uv_cache: Path
uv_tools: Path
pdm_bootstrap: Path
win_cpython_tag: str
win_uv_version: str
@classmethod
def from_env(cls) -> WineLayout:
src = repo_root()
base = wine_base()
prefix_env = os.environ.get("PLYNGENT_WINEPREFIX")
prefix = Path(prefix_env).expanduser().resolve() if prefix_env else base / "wineprefix"
return cls(
src=src,
base=base,
prefix=prefix,
view=base / "project-view",
bin_dir=base / "bin",
uv_python=base / "uv-python",
uv_cache=base / "uv-cache",
uv_tools=base / "uv-tools",
pdm_bootstrap=base / "venv-pdm",
win_cpython_tag=os.environ.get("WIN_CPYTHON_TAG", DEFAULT_WIN_CPYTHON),
win_uv_version=os.environ.get("WIN_UV_VERSION", DEFAULT_WIN_UV_VERSION),
)
@property
def win_python_host(self) -> Path:
return self.uv_python / self.win_cpython_tag / "python.exe"
@property
def win_python_z(self) -> str:
return to_wine_z(self.win_python_host)
@property
def uv_exe(self) -> Path:
return self.bin_dir / "uv.exe"
@property
def uvx_exe(self) -> Path:
return self.bin_dir / "uvx.exe"
@property
def pdm_exe(self) -> Path:
return self.pdm_bootstrap / "Scripts" / "pdm.exe"
@property
def pdm_python_z(self) -> str:
return to_wine_z(self.pdm_bootstrap / "Scripts" / "python.exe")
def export_env(self) -> dict[str, str]:
"""Environment for Wine/uv subprocesses (no host VIRTUAL_ENV)."""
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env["WINEPREFIX"] = str(self.prefix)
env["WINEDEBUG"] = os.environ.get("PLYNGENT_WINEDEBUG", "-all")
env["UV_CACHE_DIR"] = to_wine_z(self.uv_cache)
env["UV_PYTHON_INSTALL_DIR"] = to_wine_z(self.uv_python)
env["UV_TOOL_DIR"] = to_wine_z(self.uv_tools)
env["PDM_IGNORE_SAVED_PYTHON"] = "1"
env["PLYNGENT_SRC"] = str(self.src)
env["PLYNGENT_WINE_BASE"] = str(self.base)
return env
def require_cmds(*names: str) -> None:
missing = [n for n in names if shutil.which(n) is None]
if missing:
msg = f"missing command(s): {', '.join(missing)}"
raise RuntimeError(msg)
def run(cmd: list[str], *, env: dict[str, str], cwd: Path | None = None) -> int:
_ = sys.stderr.write("+ " + " ".join(cmd) + "\n")
return subprocess.call(cmd, env=env, cwd=cwd)
def ensure_prefix(layout: WineLayout, env: dict[str, str]) -> None:
for path in (layout.uv_cache, layout.uv_python, layout.uv_tools, layout.bin_dir):
path.mkdir(parents=True, exist_ok=True)
if not (layout.prefix / "drive_c").is_dir():
layout.prefix.mkdir(parents=True, exist_ok=True)
_ = run(["wineboot", "-i"], env=env)
def ensure_windows_python(layout: WineLayout, env: dict[str, str]) -> None:
if layout.win_python_host.is_file():
return
print(f"Installing Windows CPython ({layout.win_cpython_tag}) ...", file=sys.stderr)
host_env = env.copy()
# Host uv installs into host paths, not Wine Z: paths.
host_env["UV_PYTHON_INSTALL_DIR"] = str(layout.uv_python)
host_env["UV_CACHE_DIR"] = str(layout.uv_cache)
code = run(["uv", "python", "install", layout.win_cpython_tag], env=host_env)
if code != 0:
msg = f"uv python install failed with exit {code}"
raise RuntimeError(msg)
if not layout.win_python_host.is_file():
msg = f"Windows python.exe missing after install: {layout.win_python_host}"
raise RuntimeError(msg)
def ensure_windows_uv(layout: WineLayout) -> None:
if layout.uv_exe.is_file():
return
print(f"Fetching Windows uv {layout.win_uv_version} ...", file=sys.stderr)
layout.bin_dir.mkdir(parents=True, exist_ok=True)
url = (
f"https://github.com/astral-sh/uv/releases/download/"
f"{layout.win_uv_version}/uv-x86_64-pc-windows-msvc.zip"
)
zip_path = layout.bin_dir / "uv.zip"
_ = urlretrieve(url, zip_path)
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(layout.bin_dir)
if not layout.uv_exe.is_file():
msg = f"uv.exe missing after extract: {layout.uv_exe}"
raise RuntimeError(msg)
def ensure_pdm_bootstrap(layout: WineLayout, env: dict[str, str]) -> None:
if layout.pdm_exe.is_file():
return
print(f"Creating Windows bootstrap venv + pdm at {layout.pdm_bootstrap} ...", file=sys.stderr)
venv_z = to_wine_z(layout.pdm_bootstrap)
code = run(
["wine", str(layout.uv_exe), "venv", "--python", layout.win_python_z, venv_z],
env=env,
)
if code != 0:
msg = f"uv venv failed with exit {code}"
raise RuntimeError(msg)
code = run(
[
"wine",
str(layout.uv_exe),
"pip",
"install",
"--python",
layout.pdm_python_z,
"pdm",
],
env=env,
)
if code != 0:
msg = f"uv pip install pdm failed with exit {code}"
raise RuntimeError(msg)
def refresh_project_view(layout: WineLayout) -> None:
"""Symlink immutable sources; copy packaging files only."""
if layout.view.exists():
shutil.rmtree(layout.view)
layout.view.mkdir(parents=True)
for name in VIEW_LINKS:
src = layout.src / name
if src.exists():
(layout.view / name).symlink_to(src)
for name in VIEW_COPIES:
src = layout.src / name
if not src.is_file():
msg = f"required file missing: {src}"
raise FileNotFoundError(msg)
_ = shutil.copy2(src, layout.view / name)
def find_project_venv_scripts(layout: WineLayout) -> Path | None:
"""Locate PDM-created Windows project venv Scripts directory.
Prefer in-project ``project-view/.venv`` (common with ``python.use_venv``);
fall back to PDM's AppData venvs under the Wine prefix.
"""
in_project = layout.view / ".venv" / "Scripts"
if in_project.is_dir() and (in_project / "python.exe").is_file():
return in_project
users = layout.prefix / "drive_c" / "users"
if not users.is_dir():
return None
matches = sorted(
p for p in users.glob("**/pdm/pdm/venvs/project-view-*/Scripts") if p.is_dir()
)
return matches[-1] if matches else None
def wine_pdm(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not layout.pdm_exe.is_file():
msg = "bootstrap pdm missing; run setup first"
raise RuntimeError(msg)
if not layout.view.is_dir():
refresh_project_view(layout)
return run(["wine", str(layout.pdm_exe), *args], env=env, cwd=layout.view)
def wine_basedpyright(layout: WineLayout, env: dict[str, str], paths: list[str]) -> int:
scripts = find_project_venv_scripts(layout)
if scripts is None:
msg = "project venv Scripts not found; run setup first"
raise RuntimeError(msg)
# Prefer Z: for in-project .venv; C: only when under prefix drive_c.
try:
scripts_win = to_wine_c(layout.prefix, scripts)
except ValueError:
scripts_win = to_wine_z(scripts)
out = layout.base / "basedpyright-out.txt"
out_z = to_wine_z(out)
# Node under Wine often hits EBADF on stderr; redirect via cmd.
args = " ".join(paths) if paths else "src/plyngent/tools/process"
cmdline = f"{scripts_win}\\basedpyright.exe {args} > {out_z} 2>&1"
code = run(["wine", "cmd", "/c", cmdline], env=env, cwd=layout.view)
if out.is_file():
_ = sys.stdout.write(out.read_text(encoding="utf-8", errors="replace"))
return code
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""CLI for an isolated Wine + Windows uv/Python + PDM environment.
Never installs into or rewrites the host project virtualenv.
Usage:
python scripts/wine_pdm.py setup
python scripts/wine_pdm.py pdm <args...>
python scripts/wine_pdm.py run <args...>
python scripts/wine_pdm.py pytest [args...]
python scripts/wine_pdm.py basedpyright [paths...]
python scripts/wine_pdm.py uvx <args...>
python scripts/wine_pdm.py python <args...>
python scripts/wine_pdm.py shell
Env:
PLYNGENT_SRC, PLYNGENT_WINE_BASE, PLYNGENT_WINEPREFIX, PLYNGENT_WINEDEBUG
WIN_CPYTHON_TAG, WIN_UV_VERSION
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
# Static: sibling wine_env as a module (scripts/ on analysis path).
# Runtime uses importlib (else branch) because scripts/ is not a package.
from wine_env import (
WineLayout,
ensure_pdm_bootstrap,
ensure_prefix,
ensure_windows_python,
ensure_windows_uv,
find_project_venv_scripts,
refresh_project_view,
require_cmds,
run,
wine_basedpyright,
wine_pdm,
)
else:
# Runtime: load sibling by file path (scripts/ is not an installable package).
import importlib.util
def _load_wine_env():
path = Path(__file__).resolve().parent / "wine_env.py"
spec = importlib.util.spec_from_file_location("plyngent_scripts_wine_env", path)
if spec is None or spec.loader is None:
msg = f"cannot load {path}"
raise RuntimeError(msg)
module = importlib.util.module_from_spec(spec)
# dataclasses needs the module registered before exec_module.
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
_we = _load_wine_env()
WineLayout = _we.WineLayout
ensure_pdm_bootstrap = _we.ensure_pdm_bootstrap
ensure_prefix = _we.ensure_prefix
ensure_windows_python = _we.ensure_windows_python
ensure_windows_uv = _we.ensure_windows_uv
find_project_venv_scripts = _we.find_project_venv_scripts
refresh_project_view = _we.refresh_project_view
require_cmds = _we.require_cmds
run = _we.run
wine_basedpyright = _we.wine_basedpyright
wine_pdm = _we.wine_pdm
def cmd_setup(layout: WineLayout, env: dict[str, str], _args: list[str]) -> int:
require_cmds("wine", "uv", "wineboot")
ensure_prefix(layout, env)
ensure_windows_python(layout, env)
ensure_windows_uv(layout)
ensure_pdm_bootstrap(layout, env)
refresh_project_view(layout)
print("Selecting Windows interpreter for PDM project-view ...", file=sys.stderr)
_ = wine_pdm(layout, env, ["use", "-f", layout.pdm_python_z])
print("pdm sync --dev (installs pywinpty on win32) ...", file=sys.stderr)
code = wine_pdm(layout, env, ["sync", "--dev"])
if code != 0:
return code
print()
print("Ready (host project venv untouched).")
print(f" PLYNGENT_SRC={layout.src}")
print(f" PLYNGENT_WINE_BASE={layout.base}")
print(f" WINEPREFIX={layout.prefix}")
print()
print("Examples:")
print(" python scripts/wine_pdm.py pytest")
print(" python scripts/wine_pdm.py basedpyright src/plyngent/tools/process")
print(' python scripts/wine_pdm.py run python -c "import sys, winpty; print(sys.platform, winpty)"')
print(" python scripts/wine_pdm.py uvx --from pdm pdm --version")
return 0
def cmd_pdm(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
return wine_pdm(layout, env, args)
def cmd_run(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
return wine_pdm(layout, env, ["run", *args])
def cmd_pytest(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not args:
args = ["tests/test_tools/test_process.py", "-q"]
return wine_pdm(layout, env, ["run", "pytest", *args])
def cmd_basedpyright(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
return wine_basedpyright(layout, env, args)
def cmd_uvx(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
if not layout.uvx_exe.is_file():
print("error: Windows uvx missing; run setup first", file=sys.stderr)
return 1
return run(["wine", str(layout.uvx_exe), *args], env=env)
def cmd_python(layout: WineLayout, env: dict[str, str], args: list[str]) -> int:
scripts = find_project_venv_scripts(layout)
if scripts is None:
print("error: project venv missing; run setup first", file=sys.stderr)
return 1
return run(["wine", str(scripts / "python.exe"), *args], env=env)
def cmd_shell(layout: WineLayout, _env: dict[str, str], _args: list[str]) -> int:
print(f"PLYNGENT_SRC={layout.src}")
print(f"PLYNGENT_WINE_BASE={layout.base}")
print(f"WINEPREFIX={layout.prefix}")
print(f"WINE_VIEW={layout.view}")
print(f"WIN_PYTHON={layout.win_python_host}")
print(f"WIN_UV={layout.uv_exe}")
print(f"WIN_PDM={layout.pdm_exe}")
print()
print("# Typical flow:")
print("# python scripts/wine_pdm.py setup")
print('# python scripts/wine_pdm.py run python -c "import winpty; print(winpty)"')
print("# python scripts/wine_pdm.py basedpyright src/plyngent/tools/process")
print("# python scripts/wine_pdm.py pytest")
return 0
_COMMANDS: dict[str, Callable[[WineLayout, dict[str, str], list[str]], int]] = {
"setup": cmd_setup,
"pdm": cmd_pdm,
"run": cmd_run,
"pytest": cmd_pytest,
"basedpyright": cmd_basedpyright,
"pyright": cmd_basedpyright,
"uvx": cmd_uvx,
"python": cmd_python,
"shell": cmd_shell,
"env": cmd_shell,
}
def main(argv: list[str] | None = None) -> int:
# Manual dispatch so flags like ``uvx --from`` are not eaten by argparse.
raw = list(sys.argv[1:] if argv is None else argv)
if not raw or raw[0] in {"-h", "--help"}:
print(__doc__ or "")
return 0
cmd = raw[0]
rest = raw[1:]
handler = _COMMANDS.get(cmd)
if handler is None:
print(f"error: unknown command {cmd!r}", file=sys.stderr)
return 2
layout = WineLayout.from_env()
env = layout.export_env()
try:
return handler(layout, env, rest)
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Thin wrapper — all path logic lives in wine_pdm.py / wine_env.py.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec python3 "$root/scripts/wine_pdm.py" "$@"