Files
plyngent/scripts/wine_env.py
T

315 lines
11 KiB
Python

"""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.
# Short uv request (e.g. ``3.14``); Windows uv.exe installs the matching windows build.
DEFAULT_WIN_PYTHON = "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); pyright default stubPath is ``typings/``.
VIEW_LINKS = ("src", "tests", "scripts", "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_python: 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_python=os.environ.get("WIN_PYTHON", DEFAULT_WIN_PYTHON),
win_uv_version=os.environ.get("WIN_UV_VERSION", DEFAULT_WIN_UV_VERSION),
)
def find_win_python_host(self) -> Path | None:
"""Locate a Windows ``python.exe`` under the uv install dir."""
if not self.uv_python.is_dir():
return None
# Prefer newest full windows install (cpython-*-windows-*/python.exe).
candidates = sorted(
self.uv_python.glob("cpython-*-windows-*/python.exe"),
key=lambda p: p.stat().st_mtime,
)
if candidates:
return candidates[-1]
# Fallback: any python.exe one level down.
for path in sorted(self.uv_python.glob("*/python.exe")):
return path
return None
@property
def win_python_host(self) -> Path:
found = self.find_win_python_host()
if found is not None:
return found
# Placeholder path used in error messages before install.
return self.uv_python / f"cpython-{self.win_python}-windows-x86_64-none" / "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_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/{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_windows_python(layout: WineLayout, env: dict[str, str]) -> None:
"""Install Windows CPython via Wine ``uv.exe`` using a short version request."""
if layout.find_win_python_host() is not None:
return
if not layout.uv_exe.is_file():
msg = "Windows uv.exe missing; call ensure_windows_uv first"
raise RuntimeError(msg)
print(
f"Installing Windows CPython via Wine uv ({layout.win_python}) ...",
file=sys.stderr,
)
code = run(
["wine", str(layout.uv_exe), "python", "install", layout.win_python],
env=env,
)
if code != 0:
msg = f"wine uv python install failed with exit {code}"
raise RuntimeError(msg)
found = layout.find_win_python_host()
if found is None or not found.is_file():
msg = f"Windows python.exe missing after install under {layout.uv_python}"
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"
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