diff --git a/scripts/wine_env.py b/scripts/wine_env.py new file mode 100644 index 0000000..e2600b3 --- /dev/null +++ b/scripts/wine_env.py @@ -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 diff --git a/scripts/wine_pdm.py b/scripts/wine_pdm.py new file mode 100755 index 0000000..4e2a72c --- /dev/null +++ b/scripts/wine_pdm.py @@ -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 + python scripts/wine_pdm.py run + python scripts/wine_pdm.py pytest [args...] + python scripts/wine_pdm.py basedpyright [paths...] + python scripts/wine_pdm.py uvx + python scripts/wine_pdm.py python + 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()) diff --git a/scripts/wine_pdm.sh b/scripts/wine_pdm.sh new file mode 100755 index 0000000..7cba3df --- /dev/null +++ b/scripts/wine_pdm.sh @@ -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" "$@"