mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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
|
||||
Executable
+188
@@ -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())
|
||||
Executable
+5
@@ -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" "$@"
|
||||
Reference in New Issue
Block a user