ygg 0.1.20__py3-none-any.whl → 0.1.23__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ygg-0.1.23.dist-info/METADATA +367 -0
- {ygg-0.1.20.dist-info → ygg-0.1.23.dist-info}/RECORD +12 -10
- ygg-0.1.23.dist-info/entry_points.txt +2 -0
- ygg-0.1.23.dist-info/licenses/LICENSE +201 -0
- yggdrasil/databricks/compute/cluster.py +61 -14
- yggdrasil/databricks/compute/execution_context.py +22 -20
- yggdrasil/databricks/compute/remote.py +0 -2
- yggdrasil/pyutils/__init__.py +2 -0
- yggdrasil/pyutils/callable_serde.py +563 -0
- yggdrasil/pyutils/python_env.py +1351 -0
- ygg-0.1.20.dist-info/METADATA +0 -163
- yggdrasil/ser/__init__.py +0 -1
- yggdrasil/ser/callable_serde.py +0 -661
- {ygg-0.1.20.dist-info → ygg-0.1.23.dist-info}/WHEEL +0 -0
- {ygg-0.1.20.dist-info → ygg-0.1.23.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1351 @@
|
|
|
1
|
+
# yggdrasil/pyutils/python_env.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import base64
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import zipfile
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
from typing import Any, Iterable, Iterator, Mapping, MutableMapping, Optional, Union
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PythonEnvError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -----------------------
|
|
30
|
+
# module-level locks (requested)
|
|
31
|
+
# -----------------------
|
|
32
|
+
_ENV_LOCKS: dict[str, threading.RLock] = {}
|
|
33
|
+
_UV_LOCK: threading.RLock = threading.RLock()
|
|
34
|
+
_LOCKS_GUARD: threading.RLock = threading.RLock()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -----------------------
|
|
38
|
+
# tiny helpers
|
|
39
|
+
# -----------------------
|
|
40
|
+
|
|
41
|
+
# Installed into newly created envs (and on create() upsert when env already exists)
|
|
42
|
+
DEFAULT_CREATE_PACKAGES: tuple[str, ...] = ("uv", "ygg")
|
|
43
|
+
EXCLUDED_PACKAGES = {
|
|
44
|
+
"python-apt"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_windows() -> bool:
|
|
49
|
+
return os.name == "nt"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _norm_env(base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
|
53
|
+
env = dict(base or os.environ)
|
|
54
|
+
env.setdefault("PYTHONUNBUFFERED", "1")
|
|
55
|
+
return env
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split_on_tag(stdout: str, tag: str) -> tuple[list[str], Optional[str]]:
|
|
59
|
+
lines = (stdout or "").splitlines()
|
|
60
|
+
before: list[str] = []
|
|
61
|
+
payload: Optional[str] = None
|
|
62
|
+
for line in lines:
|
|
63
|
+
if payload is None and line.startswith(tag):
|
|
64
|
+
payload = line[len(tag) :]
|
|
65
|
+
continue
|
|
66
|
+
if payload is None:
|
|
67
|
+
before.append(line)
|
|
68
|
+
return before, payload
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _dedupe_keep_order(items: Iterable[str]) -> list[str]:
|
|
72
|
+
seen: set[str] = set()
|
|
73
|
+
out: list[str] = []
|
|
74
|
+
for x in items:
|
|
75
|
+
x = str(x).strip()
|
|
76
|
+
if not x or x in seen:
|
|
77
|
+
continue
|
|
78
|
+
seen.add(x)
|
|
79
|
+
out.append(x)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_cmd(
|
|
84
|
+
cmd: list[str],
|
|
85
|
+
*,
|
|
86
|
+
cwd: Optional[Path] = None,
|
|
87
|
+
env: Optional[Mapping[str, str]] = None,
|
|
88
|
+
check: bool = True,
|
|
89
|
+
) -> subprocess.CompletedProcess[str]:
|
|
90
|
+
cmd_s = [str(x) for x in cmd]
|
|
91
|
+
log.debug("exec: %s", " ".join(cmd_s))
|
|
92
|
+
if cwd:
|
|
93
|
+
log.debug("cwd: %s", str(cwd))
|
|
94
|
+
|
|
95
|
+
p = subprocess.run(
|
|
96
|
+
cmd_s,
|
|
97
|
+
cwd=str(cwd) if cwd else None,
|
|
98
|
+
env=_norm_env(env),
|
|
99
|
+
text=True,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
)
|
|
102
|
+
if p.returncode != 0:
|
|
103
|
+
log.warning("fail: rc=%s cmd=%s", p.returncode, " ".join(cmd_s))
|
|
104
|
+
|
|
105
|
+
if check and p.returncode != 0:
|
|
106
|
+
raise PythonEnvError(
|
|
107
|
+
f"Command failed ({p.returncode})\n"
|
|
108
|
+
f"--- cmd ---\n{cmd_s}\n"
|
|
109
|
+
f"--- stdout ---\n{p.stdout}\n"
|
|
110
|
+
f"--- stderr ---\n{p.stderr}\n"
|
|
111
|
+
)
|
|
112
|
+
return p
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# -----------------------
|
|
116
|
+
# user dirs + naming
|
|
117
|
+
# -----------------------
|
|
118
|
+
|
|
119
|
+
def _user_python_dir() -> Path:
|
|
120
|
+
return (Path.home() / ".python").expanduser().resolve()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _user_envs_dir() -> Path:
|
|
124
|
+
return (_user_python_dir() / "envs").resolve()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _safe_env_name(name: str) -> str:
|
|
128
|
+
n = (name or "").strip()
|
|
129
|
+
if not n:
|
|
130
|
+
raise PythonEnvError("Env name cannot be empty")
|
|
131
|
+
n = re.sub(r"[^A-Za-z0-9._-]+", "-", n).strip("-")
|
|
132
|
+
if not n or n in (".", ".."):
|
|
133
|
+
raise PythonEnvError(f"Invalid env name after sanitizing: {name!r}")
|
|
134
|
+
return n
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# -----------------------
|
|
138
|
+
# uv install (pip-based)
|
|
139
|
+
# -----------------------
|
|
140
|
+
|
|
141
|
+
def _uv_exe_on_path() -> Optional[str]:
|
|
142
|
+
uv = shutil.which("uv")
|
|
143
|
+
return uv
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _current_env_script(name: str) -> Optional[Path]:
|
|
147
|
+
exe = Path(sys.executable).resolve()
|
|
148
|
+
bindir = exe.parent
|
|
149
|
+
if _is_windows():
|
|
150
|
+
if bindir.name.lower() != "scripts":
|
|
151
|
+
scripts = Path(sys.prefix).resolve() / "Scripts"
|
|
152
|
+
bindir = scripts if scripts.is_dir() else bindir
|
|
153
|
+
cand = bindir / (name + ".exe")
|
|
154
|
+
return cand if cand.exists() else None
|
|
155
|
+
cand = bindir / name
|
|
156
|
+
return cand if cand.exists() else None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _ensure_pip_available(*, check: bool = True) -> None:
|
|
160
|
+
log.debug("checking pip availability")
|
|
161
|
+
p = subprocess.run(
|
|
162
|
+
[sys.executable, "-m", "pip", "--version"],
|
|
163
|
+
text=True,
|
|
164
|
+
capture_output=True,
|
|
165
|
+
env=_norm_env(),
|
|
166
|
+
)
|
|
167
|
+
if p.returncode == 0:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
log.warning("pip not available; attempting ensurepip bootstrap")
|
|
171
|
+
b = subprocess.run(
|
|
172
|
+
[sys.executable, "-m", "ensurepip", "--upgrade"],
|
|
173
|
+
text=True,
|
|
174
|
+
capture_output=True,
|
|
175
|
+
env=_norm_env(),
|
|
176
|
+
)
|
|
177
|
+
if check and b.returncode != 0:
|
|
178
|
+
raise PythonEnvError(
|
|
179
|
+
"pip is not available and ensurepip failed.\n"
|
|
180
|
+
f"--- stdout ---\n{b.stdout}\n"
|
|
181
|
+
f"--- stderr ---\n{b.stderr}\n"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pip_install_uv_in_current(
|
|
186
|
+
*,
|
|
187
|
+
upgrade: bool = True,
|
|
188
|
+
user: bool = True,
|
|
189
|
+
index_url: Optional[str] = None,
|
|
190
|
+
extra_pip_args: Optional[Iterable[str]] = None,
|
|
191
|
+
check: bool = True,
|
|
192
|
+
) -> None:
|
|
193
|
+
_ensure_pip_available(check=check)
|
|
194
|
+
|
|
195
|
+
cmd = [sys.executable, "-m", "pip", "install"]
|
|
196
|
+
if upgrade:
|
|
197
|
+
cmd.append("-U")
|
|
198
|
+
if user:
|
|
199
|
+
cmd.append("--user")
|
|
200
|
+
if index_url:
|
|
201
|
+
cmd += ["--index-url", index_url]
|
|
202
|
+
if extra_pip_args:
|
|
203
|
+
cmd += list(extra_pip_args)
|
|
204
|
+
cmd.append("uv")
|
|
205
|
+
|
|
206
|
+
log.info("installing uv via pip (upgrade=%s user=%s)", upgrade, user)
|
|
207
|
+
_run_cmd(cmd, env=None, check=check)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# -----------------------
|
|
211
|
+
# locking (module-level)
|
|
212
|
+
# -----------------------
|
|
213
|
+
|
|
214
|
+
def _env_lock_key(root: Path) -> str:
|
|
215
|
+
return str(Path(root).expanduser().resolve())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _get_env_lock(root: Path) -> threading.RLock:
|
|
219
|
+
key = _env_lock_key(root)
|
|
220
|
+
with _LOCKS_GUARD:
|
|
221
|
+
lk = _ENV_LOCKS.get(key)
|
|
222
|
+
if lk is None:
|
|
223
|
+
lk = threading.RLock()
|
|
224
|
+
_ENV_LOCKS[key] = lk
|
|
225
|
+
return lk
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@contextmanager
|
|
229
|
+
def _locked_env(root: Path):
|
|
230
|
+
lk = _get_env_lock(root)
|
|
231
|
+
lk.acquire()
|
|
232
|
+
try:
|
|
233
|
+
yield
|
|
234
|
+
finally:
|
|
235
|
+
lk.release()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# -----------------------
|
|
239
|
+
# PythonEnv
|
|
240
|
+
# -----------------------
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True)
|
|
243
|
+
class PythonEnv:
|
|
244
|
+
root: Path
|
|
245
|
+
|
|
246
|
+
def __post_init__(self) -> None:
|
|
247
|
+
object.__setattr__(self, "root", Path(self.root).expanduser().resolve())
|
|
248
|
+
|
|
249
|
+
# -----------------------
|
|
250
|
+
# current env + singleton
|
|
251
|
+
# -----------------------
|
|
252
|
+
|
|
253
|
+
@classmethod
|
|
254
|
+
def get_current(cls) -> "PythonEnv":
|
|
255
|
+
venv = os.environ.get("VIRTUAL_ENV")
|
|
256
|
+
if venv:
|
|
257
|
+
log.debug("current env from VIRTUAL_ENV=%s", venv)
|
|
258
|
+
return cls(Path(venv))
|
|
259
|
+
|
|
260
|
+
exe = Path(sys.executable).expanduser().resolve()
|
|
261
|
+
parent = exe.parent
|
|
262
|
+
if parent.name in ("bin", "Scripts"):
|
|
263
|
+
log.debug("current env inferred from sys.executable=%s", str(exe))
|
|
264
|
+
return cls(parent.parent)
|
|
265
|
+
|
|
266
|
+
log.debug("current env fallback to sys.prefix=%s", sys.prefix)
|
|
267
|
+
return cls(Path(sys.prefix))
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def ensure_uv(
|
|
271
|
+
cls,
|
|
272
|
+
*,
|
|
273
|
+
check: bool = True,
|
|
274
|
+
upgrade: bool = True,
|
|
275
|
+
user: bool = True,
|
|
276
|
+
index_url: Optional[str] = None,
|
|
277
|
+
extra_pip_args: Optional[Iterable[str]] = None,
|
|
278
|
+
) -> str:
|
|
279
|
+
uv = _uv_exe_on_path()
|
|
280
|
+
if uv:
|
|
281
|
+
return uv
|
|
282
|
+
|
|
283
|
+
with _UV_LOCK:
|
|
284
|
+
uv = _uv_exe_on_path()
|
|
285
|
+
if uv:
|
|
286
|
+
return uv
|
|
287
|
+
|
|
288
|
+
log.info("uv not found; attempting pip install (thread-safe)")
|
|
289
|
+
_pip_install_uv_in_current(
|
|
290
|
+
upgrade=upgrade,
|
|
291
|
+
user=user,
|
|
292
|
+
index_url=index_url,
|
|
293
|
+
extra_pip_args=extra_pip_args,
|
|
294
|
+
check=check,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
uv = _uv_exe_on_path()
|
|
298
|
+
if uv:
|
|
299
|
+
return uv
|
|
300
|
+
|
|
301
|
+
uv_path = _current_env_script("uv")
|
|
302
|
+
if uv_path:
|
|
303
|
+
log.debug("uv found in current env scripts: %s", str(uv_path))
|
|
304
|
+
return str(uv_path)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
import site
|
|
308
|
+
|
|
309
|
+
user_base = Path(site.USER_BASE).expanduser().resolve()
|
|
310
|
+
scripts = user_base / ("Scripts" if _is_windows() else "bin")
|
|
311
|
+
cand = scripts / ("uv.exe" if _is_windows() else "uv")
|
|
312
|
+
if cand.exists():
|
|
313
|
+
log.debug("uv found in site.USER_BASE scripts: %s", str(cand))
|
|
314
|
+
return str(cand)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
log.debug("failed checking site.USER_BASE for uv: %r", e)
|
|
317
|
+
|
|
318
|
+
raise PythonEnvError("uv install completed, but uv executable still not found")
|
|
319
|
+
|
|
320
|
+
# -----------------------
|
|
321
|
+
# user env discovery
|
|
322
|
+
# -----------------------
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def iter_user_envs(
|
|
326
|
+
cls,
|
|
327
|
+
*,
|
|
328
|
+
max_depth: int = 2,
|
|
329
|
+
include_hidden: bool = False,
|
|
330
|
+
require_python: bool = True,
|
|
331
|
+
dedupe: bool = True,
|
|
332
|
+
) -> Iterator["PythonEnv"]:
|
|
333
|
+
base = _user_envs_dir()
|
|
334
|
+
if not base.exists() or not base.is_dir():
|
|
335
|
+
return
|
|
336
|
+
|
|
337
|
+
seen: set[str] = set()
|
|
338
|
+
|
|
339
|
+
def _python_exe(d: Path) -> Path:
|
|
340
|
+
if os.name == "nt":
|
|
341
|
+
return d / "Scripts" / "python.exe"
|
|
342
|
+
return d / "bin" / "python"
|
|
343
|
+
|
|
344
|
+
base_parts = len(base.parts)
|
|
345
|
+
|
|
346
|
+
for p in base.rglob("*"):
|
|
347
|
+
if not p.is_dir():
|
|
348
|
+
continue
|
|
349
|
+
depth = len(p.parts) - base_parts
|
|
350
|
+
if depth > max_depth:
|
|
351
|
+
continue
|
|
352
|
+
if not include_hidden and p.name.startswith("."):
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if require_python and not _python_exe(p).exists():
|
|
356
|
+
continue
|
|
357
|
+
if not require_python and not (p / "pyvenv.cfg").exists() and not _python_exe(p).exists():
|
|
358
|
+
continue
|
|
359
|
+
|
|
360
|
+
env = cls(p)
|
|
361
|
+
if require_python and not env.exists():
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
key = str(env.root)
|
|
365
|
+
if dedupe:
|
|
366
|
+
if key in seen:
|
|
367
|
+
continue
|
|
368
|
+
seen.add(key)
|
|
369
|
+
|
|
370
|
+
yield env
|
|
371
|
+
|
|
372
|
+
# -----------------------
|
|
373
|
+
# CRUD by name in ~/.python/envs (thread-safe)
|
|
374
|
+
# -----------------------
|
|
375
|
+
|
|
376
|
+
@classmethod
|
|
377
|
+
def _user_env_root(cls, name: str) -> Path:
|
|
378
|
+
return _user_envs_dir() / _safe_env_name(name)
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def get(cls, name: str, *, require_python: bool = False) -> Optional["PythonEnv"]:
|
|
382
|
+
root = cls._user_env_root(name)
|
|
383
|
+
if not root.exists() or not root.is_dir():
|
|
384
|
+
return None
|
|
385
|
+
env = cls(root)
|
|
386
|
+
if require_python and not env.exists():
|
|
387
|
+
return None
|
|
388
|
+
return env
|
|
389
|
+
|
|
390
|
+
@classmethod
|
|
391
|
+
def create(
|
|
392
|
+
cls,
|
|
393
|
+
name: str,
|
|
394
|
+
*,
|
|
395
|
+
python: Union[str, Path] = "python",
|
|
396
|
+
clear: bool = False,
|
|
397
|
+
packages: Optional[Iterable[str]] = None,
|
|
398
|
+
requirements: Optional[Union[str, Path]] = None,
|
|
399
|
+
pip_args: Optional[Iterable[str]] = None,
|
|
400
|
+
cwd: Optional[Path] = None,
|
|
401
|
+
env: Optional[Mapping[str, str]] = None,
|
|
402
|
+
check: bool = True,
|
|
403
|
+
uv_upgrade: bool = True,
|
|
404
|
+
uv_user: bool = True,
|
|
405
|
+
uv_index_url: Optional[str] = None,
|
|
406
|
+
uv_extra_pip_args: Optional[Iterable[str]] = None,
|
|
407
|
+
) -> "PythonEnv":
|
|
408
|
+
"""
|
|
409
|
+
Create env under ~/.python/envs/<name>.
|
|
410
|
+
|
|
411
|
+
Important behavior:
|
|
412
|
+
- If env already exists and clear=False: UPSERT (install baseline + requested deps).
|
|
413
|
+
- Always installs DEFAULT_CREATE_PACKAGES + user packages, plus optional requirements.
|
|
414
|
+
- Thread-safe per-env root.
|
|
415
|
+
"""
|
|
416
|
+
root = cls._user_env_root(name)
|
|
417
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
|
|
419
|
+
if isinstance(packages, str):
|
|
420
|
+
packages = [packages]
|
|
421
|
+
|
|
422
|
+
install_pkgs = _dedupe_keep_order([*DEFAULT_CREATE_PACKAGES, *(packages or [])])
|
|
423
|
+
|
|
424
|
+
with _locked_env(root):
|
|
425
|
+
if root.exists():
|
|
426
|
+
if clear:
|
|
427
|
+
log.info("removing existing env (clear=True): %s", str(root))
|
|
428
|
+
shutil.rmtree(root)
|
|
429
|
+
else:
|
|
430
|
+
env_obj = cls(root)
|
|
431
|
+
if not env_obj.exists():
|
|
432
|
+
import datetime as _dt
|
|
433
|
+
|
|
434
|
+
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
435
|
+
moved = root.with_name(root.name + f".broken-{ts}")
|
|
436
|
+
log.warning("env exists but python missing; moving aside: %s -> %s", str(root), str(moved))
|
|
437
|
+
root.rename(moved)
|
|
438
|
+
else:
|
|
439
|
+
env_obj.update(
|
|
440
|
+
packages=install_pkgs,
|
|
441
|
+
requirements=requirements,
|
|
442
|
+
pip_args=pip_args,
|
|
443
|
+
python=python,
|
|
444
|
+
create_if_missing=False,
|
|
445
|
+
recreate_if_broken=True,
|
|
446
|
+
cwd=cwd,
|
|
447
|
+
env=env,
|
|
448
|
+
check=check,
|
|
449
|
+
uv_upgrade=uv_upgrade,
|
|
450
|
+
uv_user=uv_user,
|
|
451
|
+
uv_index_url=uv_index_url,
|
|
452
|
+
uv_extra_pip_args=uv_extra_pip_args,
|
|
453
|
+
)
|
|
454
|
+
return env_obj
|
|
455
|
+
|
|
456
|
+
uv = cls.ensure_uv(
|
|
457
|
+
check=check,
|
|
458
|
+
upgrade=uv_upgrade,
|
|
459
|
+
user=uv_user,
|
|
460
|
+
index_url=uv_index_url,
|
|
461
|
+
extra_pip_args=uv_extra_pip_args,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
py = str(Path(python).expanduser()) if isinstance(python, Path) else str(python)
|
|
465
|
+
log.info("creating env: name=%s root=%s python=%s", name, str(root), py)
|
|
466
|
+
_run_cmd([uv, "venv", str(root), "--python", py], cwd=cwd, env=env, check=check)
|
|
467
|
+
|
|
468
|
+
env_obj = cls(root)
|
|
469
|
+
if not env_obj.exists():
|
|
470
|
+
raise PythonEnvError(f"Created env but python missing: {env_obj.python_executable}")
|
|
471
|
+
|
|
472
|
+
env_obj.update(
|
|
473
|
+
packages=install_pkgs,
|
|
474
|
+
requirements=requirements,
|
|
475
|
+
pip_args=pip_args,
|
|
476
|
+
python=python,
|
|
477
|
+
create_if_missing=False,
|
|
478
|
+
recreate_if_broken=True,
|
|
479
|
+
cwd=cwd,
|
|
480
|
+
env=env,
|
|
481
|
+
check=check,
|
|
482
|
+
uv_upgrade=uv_upgrade,
|
|
483
|
+
uv_user=uv_user,
|
|
484
|
+
uv_index_url=uv_index_url,
|
|
485
|
+
uv_extra_pip_args=uv_extra_pip_args,
|
|
486
|
+
)
|
|
487
|
+
return env_obj
|
|
488
|
+
|
|
489
|
+
def update(
|
|
490
|
+
self,
|
|
491
|
+
*,
|
|
492
|
+
packages: Optional[Iterable[str], str] = None,
|
|
493
|
+
requirements: Optional[Union[str, Path]] = None,
|
|
494
|
+
pip_args: Optional[Iterable[str]] = None,
|
|
495
|
+
python: Union[str, Path] = "python",
|
|
496
|
+
create_if_missing: bool = True,
|
|
497
|
+
recreate_if_broken: bool = True,
|
|
498
|
+
cwd: Optional[Path] = None,
|
|
499
|
+
env: Optional[Mapping[str, str]] = None,
|
|
500
|
+
check: bool = True,
|
|
501
|
+
upgrade_pip: bool = False,
|
|
502
|
+
uv_upgrade: bool = True,
|
|
503
|
+
uv_user: bool = True,
|
|
504
|
+
uv_index_url: Optional[str] = None,
|
|
505
|
+
uv_extra_pip_args: Optional[Iterable[str]] = None,
|
|
506
|
+
) -> "PythonEnv":
|
|
507
|
+
"""
|
|
508
|
+
Install deps into *this* env (uv-only).
|
|
509
|
+
Thread-safe per-env root.
|
|
510
|
+
Returns the (possibly recreated) env object.
|
|
511
|
+
"""
|
|
512
|
+
root = self.root
|
|
513
|
+
|
|
514
|
+
with _locked_env(root):
|
|
515
|
+
env_obj: PythonEnv = self
|
|
516
|
+
|
|
517
|
+
if (not root.exists() or not root.is_dir()) and create_if_missing:
|
|
518
|
+
name = root.name or "env"
|
|
519
|
+
log.info("env root missing; creating: %s", str(root))
|
|
520
|
+
env_obj = self.__class__.create(
|
|
521
|
+
name,
|
|
522
|
+
python=python,
|
|
523
|
+
clear=False,
|
|
524
|
+
cwd=cwd,
|
|
525
|
+
env=env,
|
|
526
|
+
check=check,
|
|
527
|
+
uv_upgrade=uv_upgrade,
|
|
528
|
+
uv_user=uv_user,
|
|
529
|
+
uv_index_url=uv_index_url,
|
|
530
|
+
uv_extra_pip_args=uv_extra_pip_args,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if not env_obj.exists():
|
|
534
|
+
if not recreate_if_broken:
|
|
535
|
+
raise PythonEnvError(f"Env exists but python missing: {env_obj.root}")
|
|
536
|
+
import datetime as _dt
|
|
537
|
+
|
|
538
|
+
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
539
|
+
moved = env_obj.root.with_name(env_obj.root.name + f".broken-{ts}")
|
|
540
|
+
log.warning("env broken; moving aside: %s -> %s", str(env_obj.root), str(moved))
|
|
541
|
+
env_obj.root.rename(moved)
|
|
542
|
+
|
|
543
|
+
name = env_obj.root.name or "env"
|
|
544
|
+
env_obj = self.__class__.create(
|
|
545
|
+
name,
|
|
546
|
+
python=python,
|
|
547
|
+
clear=False,
|
|
548
|
+
cwd=cwd,
|
|
549
|
+
env=env,
|
|
550
|
+
check=check,
|
|
551
|
+
uv_upgrade=uv_upgrade,
|
|
552
|
+
uv_user=uv_user,
|
|
553
|
+
uv_index_url=uv_index_url,
|
|
554
|
+
uv_extra_pip_args=uv_extra_pip_args,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
uv = self.__class__.ensure_uv(
|
|
558
|
+
check=check,
|
|
559
|
+
upgrade=uv_upgrade,
|
|
560
|
+
user=uv_user,
|
|
561
|
+
index_url=uv_index_url,
|
|
562
|
+
extra_pip_args=uv_extra_pip_args,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
extra = list(pip_args or [])
|
|
566
|
+
base_uv = [uv, "pip", "install", "--python", str(env_obj.python_executable)]
|
|
567
|
+
|
|
568
|
+
if upgrade_pip:
|
|
569
|
+
log.info("upgrading pip in env: %s", str(env_obj.root))
|
|
570
|
+
_run_cmd(base_uv + ["-U", "pip"] + extra, cwd=cwd, env=env, check=check)
|
|
571
|
+
|
|
572
|
+
if packages:
|
|
573
|
+
pkgs = [packages] if isinstance(packages, str) else list(packages)
|
|
574
|
+
|
|
575
|
+
if pkgs:
|
|
576
|
+
log.info("installing packages into env %s: %s", str(env_obj.root), pkgs)
|
|
577
|
+
_run_cmd(base_uv + ["-U"] + pkgs + extra, cwd=cwd, env=env, check=check)
|
|
578
|
+
|
|
579
|
+
if requirements:
|
|
580
|
+
# requirements can be:
|
|
581
|
+
# - Path / str path to an existing file
|
|
582
|
+
# - raw requirements content (str) if path doesn't exist
|
|
583
|
+
req_path: Optional[Path] = None
|
|
584
|
+
tmp_ctx: Optional[tempfile.TemporaryDirectory] = None
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
if isinstance(requirements, Path):
|
|
588
|
+
req_path = requirements.expanduser().resolve()
|
|
589
|
+
elif isinstance(requirements, str):
|
|
590
|
+
s = requirements.strip()
|
|
591
|
+
if not s:
|
|
592
|
+
raise PythonEnvError("requirements cannot be empty")
|
|
593
|
+
|
|
594
|
+
# treat as path if it exists, otherwise treat as raw content
|
|
595
|
+
if os.path.exists(s):
|
|
596
|
+
req_path = Path(s).expanduser().resolve()
|
|
597
|
+
else:
|
|
598
|
+
tmp_ctx = tempfile.TemporaryDirectory(prefix="pythonenv-req-")
|
|
599
|
+
req_path = Path(tmp_ctx.name) / "requirements.txt"
|
|
600
|
+
req_path.write_text(s + ("\n" if not s.endswith("\n") else ""), encoding="utf-8")
|
|
601
|
+
else:
|
|
602
|
+
raise PythonEnvError("requirements must be a path-like string/Path or raw requirements text")
|
|
603
|
+
|
|
604
|
+
log.info("installing requirements into env %s: %s", str(env_obj.root), str(req_path))
|
|
605
|
+
_run_cmd(base_uv + ["-U", "-r", str(req_path)] + extra, cwd=cwd, env=env, check=check)
|
|
606
|
+
|
|
607
|
+
finally:
|
|
608
|
+
if tmp_ctx is not None:
|
|
609
|
+
tmp_ctx.cleanup()
|
|
610
|
+
|
|
611
|
+
return env_obj
|
|
612
|
+
|
|
613
|
+
@classmethod
|
|
614
|
+
def delete(cls, name: str, *, missing_ok: bool = True) -> None:
|
|
615
|
+
root = cls._user_env_root(name)
|
|
616
|
+
with _locked_env(root):
|
|
617
|
+
if not root.exists():
|
|
618
|
+
if missing_ok:
|
|
619
|
+
return
|
|
620
|
+
raise PythonEnvError(f"Env not found: {root}")
|
|
621
|
+
log.info("deleting env: %s", str(root))
|
|
622
|
+
shutil.rmtree(root)
|
|
623
|
+
|
|
624
|
+
# -----------------------
|
|
625
|
+
# env info
|
|
626
|
+
# -----------------------
|
|
627
|
+
|
|
628
|
+
@property
|
|
629
|
+
def bindir(self) -> Path:
|
|
630
|
+
if _is_windows():
|
|
631
|
+
scripts = self.root / "Scripts"
|
|
632
|
+
return scripts if scripts.is_dir() else self.root
|
|
633
|
+
return self.root / "bin"
|
|
634
|
+
|
|
635
|
+
@property
|
|
636
|
+
def name(self) -> str:
|
|
637
|
+
n = self.root.name
|
|
638
|
+
return n if n else str(self.root)
|
|
639
|
+
|
|
640
|
+
@property
|
|
641
|
+
def python_executable(self) -> Path:
|
|
642
|
+
exe = "python.exe" if _is_windows() else "python"
|
|
643
|
+
return self.bindir / exe
|
|
644
|
+
|
|
645
|
+
def exists(self) -> bool:
|
|
646
|
+
return self.python_executable.exists()
|
|
647
|
+
|
|
648
|
+
@property
|
|
649
|
+
def version(self) -> str:
|
|
650
|
+
out = self.exec_code("import sys; print(sys.version.split()[0])", check=True)
|
|
651
|
+
return out.strip()
|
|
652
|
+
|
|
653
|
+
@property
|
|
654
|
+
def version_info(self) -> tuple[int, int, int]:
|
|
655
|
+
v = self.version
|
|
656
|
+
m = re.match(r"^\s*(\d+)\.(\d+)\.(\d+)\s*$", v)
|
|
657
|
+
if not m:
|
|
658
|
+
raise PythonEnvError(f"Unexpected python version string: {v!r}")
|
|
659
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
660
|
+
|
|
661
|
+
# -----------------------
|
|
662
|
+
# python version switch
|
|
663
|
+
# -----------------------
|
|
664
|
+
|
|
665
|
+
def change_python_version(self, version: str | tuple[int, ...], *, keep_packages: bool = False) -> "PythonEnv":
|
|
666
|
+
"""
|
|
667
|
+
Recreate this env with a different python interpreter/version.
|
|
668
|
+
|
|
669
|
+
If the requested version matches the current env's Python version, returns self.
|
|
670
|
+
|
|
671
|
+
version:
|
|
672
|
+
- "3.12" / "3.12.1" (version spec)
|
|
673
|
+
- tuple like (3, 12) or (3, 12, 1)
|
|
674
|
+
- OR an interpreter request like "python3.12" or full path (will recreate)
|
|
675
|
+
|
|
676
|
+
keep_packages:
|
|
677
|
+
- If True, freezes current env and re-installs into the new one.
|
|
678
|
+
- Default False to avoid surprise network/resolution in CI.
|
|
679
|
+
"""
|
|
680
|
+
req_str: str
|
|
681
|
+
req_parts: Optional[tuple[int, ...]] = None
|
|
682
|
+
|
|
683
|
+
if isinstance(version, tuple):
|
|
684
|
+
if len(version) < 2:
|
|
685
|
+
raise PythonEnvError("version tuple must be at least (major, minor)")
|
|
686
|
+
if not all(isinstance(x, int) and x >= 0 for x in version):
|
|
687
|
+
raise PythonEnvError(f"version tuple must be non-negative ints: {version!r}")
|
|
688
|
+
req_parts = tuple(version)
|
|
689
|
+
req_str = ".".join(str(x) for x in req_parts)
|
|
690
|
+
else:
|
|
691
|
+
req_str = str(version).strip()
|
|
692
|
+
if not req_str:
|
|
693
|
+
raise PythonEnvError("version cannot be empty")
|
|
694
|
+
|
|
695
|
+
m = re.match(r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$", req_str)
|
|
696
|
+
if m:
|
|
697
|
+
parts = [int(m.group(1))]
|
|
698
|
+
if m.group(2) is not None:
|
|
699
|
+
parts.append(int(m.group(2)))
|
|
700
|
+
if m.group(3) is not None:
|
|
701
|
+
parts.append(int(m.group(3)))
|
|
702
|
+
req_parts = tuple(parts)
|
|
703
|
+
|
|
704
|
+
if self.exists() and req_parts is not None:
|
|
705
|
+
cur = self.version_info # (major, minor, patch)
|
|
706
|
+
if tuple(cur[: len(req_parts)]) == req_parts:
|
|
707
|
+
return self
|
|
708
|
+
|
|
709
|
+
def _slug(s: str) -> str:
|
|
710
|
+
s = (s or "").strip()
|
|
711
|
+
s = re.sub(r"[^A-Za-z0-9._+-]+", "-", s)
|
|
712
|
+
return s.strip("-") or "unknown"
|
|
713
|
+
|
|
714
|
+
root = Path(self.root).expanduser().resolve()
|
|
715
|
+
parent = root.parent
|
|
716
|
+
|
|
717
|
+
uv = self.__class__.ensure_uv(check=True)
|
|
718
|
+
|
|
719
|
+
frozen_text: Optional[str] = None
|
|
720
|
+
if keep_packages and self.exists():
|
|
721
|
+
p = _run_cmd([uv, "pip", "freeze", "--python", str(self.python_executable)], check=True)
|
|
722
|
+
frozen_text = p.stdout or ""
|
|
723
|
+
|
|
724
|
+
with _locked_env(root):
|
|
725
|
+
if root.exists():
|
|
726
|
+
import datetime as _dt
|
|
727
|
+
|
|
728
|
+
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
729
|
+
backup = parent / f"{root.name}.pychange-{_slug(req_str)}-{ts}"
|
|
730
|
+
log.info("changing python version; moving aside: %s -> %s", str(root), str(backup))
|
|
731
|
+
root.rename(backup)
|
|
732
|
+
|
|
733
|
+
log.info("recreating env with python=%s at %s", req_str, str(root))
|
|
734
|
+
_run_cmd([uv, "venv", str(root), "--python", req_str], check=True)
|
|
735
|
+
|
|
736
|
+
new_env = self.__class__(root)
|
|
737
|
+
if not new_env.exists():
|
|
738
|
+
raise PythonEnvError(f"Recreated env but python missing: {new_env.python_executable}")
|
|
739
|
+
|
|
740
|
+
if keep_packages and frozen_text and frozen_text.strip():
|
|
741
|
+
import datetime as _dt
|
|
742
|
+
|
|
743
|
+
ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
744
|
+
req_path = parent / f".{root.name}.freeze-{ts}.txt"
|
|
745
|
+
req_path.write_text(frozen_text, encoding="utf-8")
|
|
746
|
+
try:
|
|
747
|
+
_run_cmd([uv, "pip", "install", "--python", str(new_env.python_executable), "-r", str(req_path)], check=True)
|
|
748
|
+
finally:
|
|
749
|
+
try:
|
|
750
|
+
req_path.unlink(missing_ok=True)
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
|
|
754
|
+
return new_env
|
|
755
|
+
|
|
756
|
+
# -----------------------
|
|
757
|
+
# requirements matrix
|
|
758
|
+
# -----------------------
|
|
759
|
+
|
|
760
|
+
def export_requirements_matrix(
|
|
761
|
+
self,
|
|
762
|
+
python_versions: Iterable[Union[str, Path]] = None,
|
|
763
|
+
*,
|
|
764
|
+
out_dir: Optional[Union[str, Path]] = None,
|
|
765
|
+
base_name: str = "requirements",
|
|
766
|
+
include_frozen: bool = True,
|
|
767
|
+
include_input: bool = True,
|
|
768
|
+
check: bool = True,
|
|
769
|
+
buffers: Optional[MutableMapping[str, str]] = None,
|
|
770
|
+
# ensure_uv knobs
|
|
771
|
+
uv_upgrade: bool = True,
|
|
772
|
+
uv_user: bool = True,
|
|
773
|
+
uv_index_url: Optional[str] = None,
|
|
774
|
+
uv_extra_pip_args: Optional[Iterable[str]] = None,
|
|
775
|
+
) -> dict[str, Union[Path, str]]:
|
|
776
|
+
"""
|
|
777
|
+
Generate requirements to duplicate this env across multiple Python versions.
|
|
778
|
+
|
|
779
|
+
If out_dir is provided:
|
|
780
|
+
- writes files into out_dir
|
|
781
|
+
- returns dict[python_req -> Path]
|
|
782
|
+
|
|
783
|
+
If out_dir is None:
|
|
784
|
+
- writes to a temp workdir (needed for uv compile input/output files)
|
|
785
|
+
- returns dict[python_req -> compiled requirements text (str)]
|
|
786
|
+
|
|
787
|
+
Outputs (when out_dir given):
|
|
788
|
+
- {base_name}.in
|
|
789
|
+
- {base_name}.frozen.txt
|
|
790
|
+
- {base_name}-py<slug>.txt
|
|
791
|
+
"""
|
|
792
|
+
if not python_versions:
|
|
793
|
+
return self.export_requirements_matrix(
|
|
794
|
+
python_versions=[self.python_executable],
|
|
795
|
+
out_dir=out_dir, base_name=base_name, include_frozen=include_frozen,
|
|
796
|
+
include_input=include_input, check=check, buffers=buffers or {},
|
|
797
|
+
uv_upgrade=uv_upgrade, uv_user=uv_user, uv_index_url=uv_index_url,
|
|
798
|
+
uv_extra_pip_args=uv_extra_pip_args
|
|
799
|
+
)[str(self.python_executable)]
|
|
800
|
+
|
|
801
|
+
def _slug(s: str) -> str:
|
|
802
|
+
s = (s or "").strip()
|
|
803
|
+
if not s:
|
|
804
|
+
return "unknown"
|
|
805
|
+
s = re.sub(r"[^A-Za-z0-9._+-]+", "-", s)
|
|
806
|
+
return s.strip("-") or "unknown"
|
|
807
|
+
|
|
808
|
+
targets = _dedupe_keep_order([str(v) for v in python_versions])
|
|
809
|
+
if not targets:
|
|
810
|
+
raise PythonEnvError("python_versions cannot be empty")
|
|
811
|
+
|
|
812
|
+
if not self.exists():
|
|
813
|
+
raise PythonEnvError(f"Python executable not found in env: {self.python_executable}")
|
|
814
|
+
|
|
815
|
+
uv = self.__class__.ensure_uv(
|
|
816
|
+
check=check,
|
|
817
|
+
upgrade=uv_upgrade,
|
|
818
|
+
user=uv_user,
|
|
819
|
+
index_url=uv_index_url,
|
|
820
|
+
extra_pip_args=uv_extra_pip_args,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
write_files = out_dir is not None
|
|
824
|
+
|
|
825
|
+
tmp_ctx: Optional[tempfile.TemporaryDirectory] = None
|
|
826
|
+
if write_files:
|
|
827
|
+
out_root = Path(out_dir).expanduser().resolve()
|
|
828
|
+
out_root.mkdir(parents=True, exist_ok=True)
|
|
829
|
+
else:
|
|
830
|
+
tmp_ctx = tempfile.TemporaryDirectory(prefix="pythonenv-reqs-")
|
|
831
|
+
out_root = Path(tmp_ctx.name).resolve()
|
|
832
|
+
|
|
833
|
+
try:
|
|
834
|
+
with _locked_env(self.root):
|
|
835
|
+
# 1) top-level deps
|
|
836
|
+
req_in_path = out_root / f"{base_name}.in"
|
|
837
|
+
if include_input:
|
|
838
|
+
log.info("exporting top-level requirements: %s", str(req_in_path))
|
|
839
|
+
|
|
840
|
+
code = r"""import importlib.metadata as md, json, re
|
|
841
|
+
|
|
842
|
+
def norm(name: str) -> str:
|
|
843
|
+
return re.sub(r"[-_.]+", "-", (name or "").strip()).lower()
|
|
844
|
+
|
|
845
|
+
dists = list(md.distributions())
|
|
846
|
+
installed = {}
|
|
847
|
+
for d in dists:
|
|
848
|
+
n = d.metadata.get("Name") or d.metadata.get("Summary") or ""
|
|
849
|
+
n = (n or "").strip()
|
|
850
|
+
if not n:
|
|
851
|
+
continue
|
|
852
|
+
installed[norm(n)] = n
|
|
853
|
+
|
|
854
|
+
required = set()
|
|
855
|
+
for d in dists:
|
|
856
|
+
for r in (d.requires or []):
|
|
857
|
+
r = (r or "").strip()
|
|
858
|
+
if not r:
|
|
859
|
+
continue
|
|
860
|
+
name = re.split(r"[ ;<>=!~\[\]]", r, 1)[0].strip()
|
|
861
|
+
if name:
|
|
862
|
+
required.add(norm(name))
|
|
863
|
+
|
|
864
|
+
top_level = [installed[k] for k in installed.keys() if k not in required]
|
|
865
|
+
drop = {norm("pip"), norm("setuptools"), norm("wheel")}
|
|
866
|
+
top_level = [x for x in top_level if norm(x) not in drop]
|
|
867
|
+
top_level = sorted(set(top_level), key=lambda s: s.lower())
|
|
868
|
+
print("RESULT:" + json.dumps(top_level))""".strip()
|
|
869
|
+
|
|
870
|
+
top_level = self.exec_code_and_return(
|
|
871
|
+
code,
|
|
872
|
+
result_tag="RESULT:",
|
|
873
|
+
parse_json=True,
|
|
874
|
+
print_prefix_lines=False,
|
|
875
|
+
strip_payload=True,
|
|
876
|
+
check=check,
|
|
877
|
+
uv_upgrade=uv_upgrade,
|
|
878
|
+
uv_user=uv_user,
|
|
879
|
+
uv_index_url=uv_index_url,
|
|
880
|
+
uv_extra_pip_args=uv_extra_pip_args,
|
|
881
|
+
)
|
|
882
|
+
if not isinstance(top_level, list) or not all(isinstance(x, str) for x in top_level):
|
|
883
|
+
raise PythonEnvError(f"Unexpected top-level requirements payload: {top_level!r}")
|
|
884
|
+
|
|
885
|
+
# exclude python-apt (yanked/unmaintained; not usable on Databricks)
|
|
886
|
+
_pyapt_re = re.compile(r"^\s*python-apt(\s*(?:==|~=|!=|<=|>=|<|>|\[)|\s*(?:;|$))", re.IGNORECASE)
|
|
887
|
+
|
|
888
|
+
filtered = [line for line in top_level if not _pyapt_re.match(line)]
|
|
889
|
+
|
|
890
|
+
req_in_text = "\n".join(filtered) + "\n"
|
|
891
|
+
req_in_path.write_text(req_in_text, encoding="utf-8")
|
|
892
|
+
if buffers is not None:
|
|
893
|
+
buffers[f"{base_name}.in"] = req_in_text
|
|
894
|
+
|
|
895
|
+
# 2) frozen snapshot
|
|
896
|
+
frozen_path = out_root / f"{base_name}.frozen.txt"
|
|
897
|
+
if include_frozen:
|
|
898
|
+
log.info("exporting frozen requirements: %s", str(frozen_path))
|
|
899
|
+
p = _run_cmd([uv, "pip", "freeze", "--python", str(self.python_executable)], check=check)
|
|
900
|
+
frozen_text = p.stdout or ""
|
|
901
|
+
if write_files:
|
|
902
|
+
frozen_path.write_text(frozen_text, encoding="utf-8")
|
|
903
|
+
if buffers is not None:
|
|
904
|
+
buffers[f"{base_name}.frozen.txt"] = frozen_text
|
|
905
|
+
|
|
906
|
+
# 3) compile per target
|
|
907
|
+
if include_input and not req_in_path.exists():
|
|
908
|
+
raise PythonEnvError(f"Missing requirements input file: {req_in_path}")
|
|
909
|
+
|
|
910
|
+
compiled_paths: dict[str, Path] = {}
|
|
911
|
+
compiled_texts: dict[str, str] = {}
|
|
912
|
+
|
|
913
|
+
for py_req in targets:
|
|
914
|
+
out_path = out_root / f"{base_name}-py{_slug(py_req)}.txt"
|
|
915
|
+
log.info("compiling requirements for python=%s -> %s", py_req, str(out_path))
|
|
916
|
+
|
|
917
|
+
cmd = [uv, "pip", "compile", str(req_in_path), "--python", py_req, "-o", str(out_path)]
|
|
918
|
+
_run_cmd(cmd, check=check)
|
|
919
|
+
|
|
920
|
+
if write_files:
|
|
921
|
+
compiled_paths[py_req] = out_path
|
|
922
|
+
if buffers is not None:
|
|
923
|
+
buffers[out_path.name] = out_path.read_text(encoding="utf-8")
|
|
924
|
+
else:
|
|
925
|
+
txt = out_path.read_text(encoding="utf-8")
|
|
926
|
+
compiled_texts[py_req] = txt
|
|
927
|
+
if buffers is not None:
|
|
928
|
+
buffers[out_path.name] = txt
|
|
929
|
+
|
|
930
|
+
return compiled_paths if write_files else compiled_texts
|
|
931
|
+
|
|
932
|
+
finally:
|
|
933
|
+
if tmp_ctx is not None:
|
|
934
|
+
tmp_ctx.cleanup()
|
|
935
|
+
|
|
936
|
+
# -----------------------
|
|
937
|
+
# execute (uv run always)
|
|
938
|
+
# -----------------------
|
|
939
|
+
|
|
940
|
+
def exec_code(
|
|
941
|
+
self,
|
|
942
|
+
code: str,
|
|
943
|
+
*,
|
|
944
|
+
python: Optional[Union[str, Path]] = None,
|
|
945
|
+
cwd: Optional[Path] = None,
|
|
946
|
+
env: Optional[Mapping[str, str]] = None,
|
|
947
|
+
check: bool = True,
|
|
948
|
+
uv_upgrade: bool = True,
|
|
949
|
+
uv_user: bool = True,
|
|
950
|
+
uv_index_url: Optional[str] = None,
|
|
951
|
+
uv_extra_pip_args: Optional[Iterable[str]] = None,
|
|
952
|
+
) -> str:
|
|
953
|
+
# pick interpreter (default = env python)
|
|
954
|
+
if python is None:
|
|
955
|
+
if not self.exists():
|
|
956
|
+
raise PythonEnvError(f"Python executable not found in env: {self.python_executable}")
|
|
957
|
+
py = self.python_executable
|
|
958
|
+
else:
|
|
959
|
+
py = Path(python).expanduser().resolve() if isinstance(python, Path) else Path(str(python)).expanduser().resolve()
|
|
960
|
+
if not py.exists():
|
|
961
|
+
raise PythonEnvError(f"Python executable not found: {py}")
|
|
962
|
+
|
|
963
|
+
uv = PythonEnv.ensure_uv(
|
|
964
|
+
check=check,
|
|
965
|
+
upgrade=uv_upgrade,
|
|
966
|
+
user=uv_user,
|
|
967
|
+
index_url=uv_index_url,
|
|
968
|
+
extra_pip_args=uv_extra_pip_args,
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
cmd = [uv, "run", "--python", str(py), "--", "python", "-c", code]
|
|
972
|
+
log.debug("exec_code env=%s python=%s", str(self.root), str(py))
|
|
973
|
+
p = _run_cmd(cmd, cwd=cwd, env=env, check=check)
|
|
974
|
+
return p.stdout or ""
|
|
975
|
+
|
|
976
|
+
def exec_code_and_return(
|
|
977
|
+
self,
|
|
978
|
+
code: str,
|
|
979
|
+
*,
|
|
980
|
+
result_tag: str = "RESULT:",
|
|
981
|
+
python: Optional[Union[str, Path]] = None,
|
|
982
|
+
cwd: Optional[Path] = None,
|
|
983
|
+
env: Optional[Mapping[str, str]] = None,
|
|
984
|
+
check: bool = True,
|
|
985
|
+
parse_json: bool = False,
|
|
986
|
+
print_prefix_lines: bool = True,
|
|
987
|
+
strip_payload: bool = True,
|
|
988
|
+
uv_upgrade: bool = True,
|
|
989
|
+
uv_user: bool = True,
|
|
990
|
+
uv_index_url: Optional[str] = None,
|
|
991
|
+
uv_extra_pip_args: Optional[Iterable[str]] = None,
|
|
992
|
+
) -> Any:
|
|
993
|
+
stdout = self.exec_code(
|
|
994
|
+
code,
|
|
995
|
+
python=python,
|
|
996
|
+
cwd=cwd,
|
|
997
|
+
env=env,
|
|
998
|
+
check=check,
|
|
999
|
+
uv_upgrade=uv_upgrade,
|
|
1000
|
+
uv_user=uv_user,
|
|
1001
|
+
uv_index_url=uv_index_url,
|
|
1002
|
+
uv_extra_pip_args=uv_extra_pip_args,
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
before_lines, payload = _split_on_tag(stdout, result_tag)
|
|
1006
|
+
|
|
1007
|
+
if print_prefix_lines and before_lines:
|
|
1008
|
+
for line in before_lines:
|
|
1009
|
+
print(line)
|
|
1010
|
+
|
|
1011
|
+
if payload is None:
|
|
1012
|
+
raise PythonEnvError(
|
|
1013
|
+
f"Result tag not found in stdout (tag={result_tag!r}).\n"
|
|
1014
|
+
f"--- stdout ---\n{stdout}\n"
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
if strip_payload:
|
|
1018
|
+
payload = payload.strip()
|
|
1019
|
+
|
|
1020
|
+
def _try_parse_obj(s: str) -> Optional[Any]:
|
|
1021
|
+
s2 = s.strip()
|
|
1022
|
+
if not s2:
|
|
1023
|
+
return None
|
|
1024
|
+
try:
|
|
1025
|
+
return json.loads(s2)
|
|
1026
|
+
except Exception:
|
|
1027
|
+
pass
|
|
1028
|
+
try:
|
|
1029
|
+
return ast.literal_eval(s2)
|
|
1030
|
+
except Exception:
|
|
1031
|
+
return None
|
|
1032
|
+
|
|
1033
|
+
def _decode_value(val: Any, encoding: Optional[str]) -> Any:
|
|
1034
|
+
enc = (encoding or "").strip().lower()
|
|
1035
|
+
if enc in ("", "none", "raw", "plain"):
|
|
1036
|
+
return val
|
|
1037
|
+
if enc in ("text", "str", "utf-8"):
|
|
1038
|
+
if isinstance(val, (bytes, bytearray)):
|
|
1039
|
+
return bytes(val).decode("utf-8", errors="replace")
|
|
1040
|
+
return str(val)
|
|
1041
|
+
if enc in ("dill+b64", "dill_base64", "dill-b64"):
|
|
1042
|
+
if val is None:
|
|
1043
|
+
return None
|
|
1044
|
+
if isinstance(val, str):
|
|
1045
|
+
b = base64.b64decode(val.encode("utf-8"))
|
|
1046
|
+
elif isinstance(val, (bytes, bytearray)):
|
|
1047
|
+
b = base64.b64decode(bytes(val))
|
|
1048
|
+
else:
|
|
1049
|
+
raise PythonEnvError(f"Cannot decode dill+b64 from type: {type(val)}")
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
import dill
|
|
1053
|
+
except Exception as e:
|
|
1054
|
+
raise PythonEnvError("dill is required to decode dill+b64 payloads") from e
|
|
1055
|
+
|
|
1056
|
+
try:
|
|
1057
|
+
return dill.loads(b)
|
|
1058
|
+
except Exception as e:
|
|
1059
|
+
raise PythonEnvError(f"Failed to dill-decode payload: {e}") from e
|
|
1060
|
+
|
|
1061
|
+
raise PythonEnvError(f"Unknown encoding: {encoding!r}")
|
|
1062
|
+
|
|
1063
|
+
obj = _try_parse_obj(payload)
|
|
1064
|
+
if isinstance(obj, dict) and "ok" in obj and "encoding" in obj:
|
|
1065
|
+
ok = bool(obj.get("ok"))
|
|
1066
|
+
encoding = obj.get("encoding")
|
|
1067
|
+
|
|
1068
|
+
ret_raw = obj.get("return")
|
|
1069
|
+
err_raw = obj.get("error")
|
|
1070
|
+
|
|
1071
|
+
decoded_return = _decode_value(ret_raw, encoding) if ret_raw is not None else None
|
|
1072
|
+
|
|
1073
|
+
decoded_error = None
|
|
1074
|
+
if err_raw is not None:
|
|
1075
|
+
try:
|
|
1076
|
+
decoded_error = _decode_value(err_raw, encoding)
|
|
1077
|
+
except Exception:
|
|
1078
|
+
decoded_error = _decode_value(err_raw, "text")
|
|
1079
|
+
|
|
1080
|
+
if ok:
|
|
1081
|
+
return decoded_return
|
|
1082
|
+
|
|
1083
|
+
raise PythonEnvError(
|
|
1084
|
+
"Remote code reported failure.\n"
|
|
1085
|
+
f"error={decoded_error!r}\n"
|
|
1086
|
+
f"envelope={obj!r}"
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
if parse_json:
|
|
1090
|
+
try:
|
|
1091
|
+
return json.loads(payload)
|
|
1092
|
+
except json.JSONDecodeError as e:
|
|
1093
|
+
raise PythonEnvError(
|
|
1094
|
+
f"Failed to parse JSON payload after tag {result_tag!r}: {e}\n"
|
|
1095
|
+
f"--- payload ---\n{payload}\n"
|
|
1096
|
+
) from e
|
|
1097
|
+
|
|
1098
|
+
return payload
|
|
1099
|
+
|
|
1100
|
+
# -----------------------
|
|
1101
|
+
# zip helpers
|
|
1102
|
+
# -----------------------
|
|
1103
|
+
|
|
1104
|
+
def zip_bytes(
|
|
1105
|
+
self,
|
|
1106
|
+
*,
|
|
1107
|
+
include_cache: bool = False,
|
|
1108
|
+
include_pycache: bool = False,
|
|
1109
|
+
include_dist_info: bool = True,
|
|
1110
|
+
extra_exclude_globs: Optional[Iterable[str]] = None,
|
|
1111
|
+
compression: int = zipfile.ZIP_DEFLATED,
|
|
1112
|
+
) -> bytes:
|
|
1113
|
+
buf = io.BytesIO()
|
|
1114
|
+
self._zip_to_fileobj(
|
|
1115
|
+
buf,
|
|
1116
|
+
include_cache=include_cache,
|
|
1117
|
+
include_pycache=include_pycache,
|
|
1118
|
+
include_dist_info=include_dist_info,
|
|
1119
|
+
extra_exclude_globs=extra_exclude_globs,
|
|
1120
|
+
compression=compression,
|
|
1121
|
+
)
|
|
1122
|
+
return buf.getvalue()
|
|
1123
|
+
|
|
1124
|
+
def zip_to(
|
|
1125
|
+
self,
|
|
1126
|
+
out_zip: Path,
|
|
1127
|
+
*,
|
|
1128
|
+
include_cache: bool = False,
|
|
1129
|
+
include_pycache: bool = False,
|
|
1130
|
+
include_dist_info: bool = True,
|
|
1131
|
+
extra_exclude_globs: Optional[Iterable[str]] = None,
|
|
1132
|
+
compression: int = zipfile.ZIP_DEFLATED,
|
|
1133
|
+
in_memory: bool = False,
|
|
1134
|
+
) -> Union[Path, bytes]:
|
|
1135
|
+
if in_memory:
|
|
1136
|
+
return self.zip_bytes(
|
|
1137
|
+
include_cache=include_cache,
|
|
1138
|
+
include_pycache=include_pycache,
|
|
1139
|
+
include_dist_info=include_dist_info,
|
|
1140
|
+
extra_exclude_globs=extra_exclude_globs,
|
|
1141
|
+
compression=compression,
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
out_zip = Path(out_zip).expanduser().resolve()
|
|
1145
|
+
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
|
1146
|
+
|
|
1147
|
+
with out_zip.open("wb") as f:
|
|
1148
|
+
self._zip_to_fileobj(
|
|
1149
|
+
f,
|
|
1150
|
+
include_cache=include_cache,
|
|
1151
|
+
include_pycache=include_pycache,
|
|
1152
|
+
include_dist_info=include_dist_info,
|
|
1153
|
+
extra_exclude_globs=extra_exclude_globs,
|
|
1154
|
+
compression=compression,
|
|
1155
|
+
out_zip_path=out_zip,
|
|
1156
|
+
)
|
|
1157
|
+
return out_zip
|
|
1158
|
+
|
|
1159
|
+
def _zip_to_fileobj(
|
|
1160
|
+
self,
|
|
1161
|
+
fileobj,
|
|
1162
|
+
*,
|
|
1163
|
+
include_cache: bool,
|
|
1164
|
+
include_pycache: bool,
|
|
1165
|
+
include_dist_info: bool,
|
|
1166
|
+
extra_exclude_globs: Optional[Iterable[str]],
|
|
1167
|
+
compression: int,
|
|
1168
|
+
out_zip_path: Optional[Path] = None,
|
|
1169
|
+
) -> None:
|
|
1170
|
+
root = Path(self.root).expanduser().resolve()
|
|
1171
|
+
if not root.exists():
|
|
1172
|
+
raise PythonEnvError(f"Env root does not exist: {root}")
|
|
1173
|
+
if not root.is_dir():
|
|
1174
|
+
raise PythonEnvError(f"Env root is not a directory: {root}")
|
|
1175
|
+
|
|
1176
|
+
extra_exclude_globs = list(extra_exclude_globs or [])
|
|
1177
|
+
|
|
1178
|
+
def _match_any(rel_posix: str, patterns: list[str]) -> bool:
|
|
1179
|
+
# IMPORTANT: use PurePosixPath for stable glob matching across OSes
|
|
1180
|
+
p = PurePosixPath(rel_posix)
|
|
1181
|
+
for pat in patterns:
|
|
1182
|
+
if p.match(pat):
|
|
1183
|
+
return True
|
|
1184
|
+
return False
|
|
1185
|
+
|
|
1186
|
+
exclude_patterns: list[str] = []
|
|
1187
|
+
if not include_pycache:
|
|
1188
|
+
exclude_patterns += ["**/__pycache__/**", "**/*.pyc", "**/*.pyo"]
|
|
1189
|
+
if not include_cache:
|
|
1190
|
+
exclude_patterns += [
|
|
1191
|
+
"**/.cache/**",
|
|
1192
|
+
"**/pip-cache/**",
|
|
1193
|
+
"**/pip/**/cache/**",
|
|
1194
|
+
"**/Cache/**",
|
|
1195
|
+
"**/Caches/**",
|
|
1196
|
+
]
|
|
1197
|
+
if not include_dist_info:
|
|
1198
|
+
exclude_patterns += ["**/*.dist-info/**"]
|
|
1199
|
+
|
|
1200
|
+
exclude_patterns += extra_exclude_globs
|
|
1201
|
+
|
|
1202
|
+
if out_zip_path is not None:
|
|
1203
|
+
try:
|
|
1204
|
+
rel_out = out_zip_path.relative_to(root).as_posix()
|
|
1205
|
+
exclude_patterns.append(rel_out)
|
|
1206
|
+
except ValueError:
|
|
1207
|
+
pass
|
|
1208
|
+
|
|
1209
|
+
with zipfile.ZipFile(fileobj, mode="w", compression=compression) as zf:
|
|
1210
|
+
for abs_path in root.rglob("*"):
|
|
1211
|
+
if abs_path.is_dir():
|
|
1212
|
+
continue
|
|
1213
|
+
rel_posix = abs_path.relative_to(root).as_posix()
|
|
1214
|
+
if _match_any(rel_posix, exclude_patterns):
|
|
1215
|
+
continue
|
|
1216
|
+
zf.write(abs_path, rel_posix)
|
|
1217
|
+
|
|
1218
|
+
# -----------------------
|
|
1219
|
+
# CLI (uv everywhere)
|
|
1220
|
+
# -----------------------
|
|
1221
|
+
|
|
1222
|
+
@classmethod
|
|
1223
|
+
def cli(cls, argv: Optional[list[str]] = None) -> int:
|
|
1224
|
+
import argparse
|
|
1225
|
+
|
|
1226
|
+
parser = argparse.ArgumentParser(prog="python_env", description="User env CRUD + exec (uv everywhere)")
|
|
1227
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
1228
|
+
|
|
1229
|
+
p_uv = sub.add_parser("ensure-uv", help="Ensure uv is installed via pip")
|
|
1230
|
+
p_uv.add_argument("--no-upgrade", action="store_true", help="Do not upgrade uv if already installed")
|
|
1231
|
+
p_uv.add_argument("--no-user", action="store_true", help="Install uv without --user")
|
|
1232
|
+
p_uv.add_argument("--index-url", default=None, help="pip --index-url")
|
|
1233
|
+
p_uv.add_argument("--pip-arg", action="append", default=[], help="Extra pip args for installing uv")
|
|
1234
|
+
|
|
1235
|
+
p_list = sub.add_parser("list", help="List envs under ~/.python/envs")
|
|
1236
|
+
p_list.add_argument("--max-depth", type=int, default=2)
|
|
1237
|
+
p_list.add_argument("--include-hidden", action="store_true")
|
|
1238
|
+
p_list.add_argument("--no-require-python", action="store_true")
|
|
1239
|
+
|
|
1240
|
+
p_create = sub.add_parser("create", help="Create env (or update if it already exists)")
|
|
1241
|
+
p_create.add_argument("name")
|
|
1242
|
+
p_create.add_argument("--python", default="python")
|
|
1243
|
+
p_create.add_argument("--clear", action="store_true")
|
|
1244
|
+
p_create.add_argument("--pkg", action="append", default=[], help="Package to install (repeatable)")
|
|
1245
|
+
p_create.add_argument("--requirements", default=None)
|
|
1246
|
+
p_create.add_argument("--pip-arg", action="append", default=[], help="Extra uv pip args (repeatable)")
|
|
1247
|
+
|
|
1248
|
+
p_delete = sub.add_parser("delete", help="Delete env under ~/.python/envs/<name>")
|
|
1249
|
+
p_delete.add_argument("name")
|
|
1250
|
+
p_delete.add_argument("--missing-ok", action="store_true")
|
|
1251
|
+
|
|
1252
|
+
p_exec = sub.add_parser("exec", help="Exec python -c in an env (uv run)")
|
|
1253
|
+
p_exec.add_argument("name")
|
|
1254
|
+
p_exec.add_argument("--code", required=True)
|
|
1255
|
+
|
|
1256
|
+
p_ret = sub.add_parser("exec-return", help="Exec and parse RESULT: payload (uv run)")
|
|
1257
|
+
p_ret.add_argument("name")
|
|
1258
|
+
p_ret.add_argument("--code", required=True)
|
|
1259
|
+
p_ret.add_argument("--tag", default="RESULT:")
|
|
1260
|
+
p_ret.add_argument("--json", action="store_true")
|
|
1261
|
+
|
|
1262
|
+
if argv is None:
|
|
1263
|
+
argv = sys.argv[1:]
|
|
1264
|
+
if not argv:
|
|
1265
|
+
parser.print_help()
|
|
1266
|
+
return 0
|
|
1267
|
+
|
|
1268
|
+
args = parser.parse_args(argv)
|
|
1269
|
+
|
|
1270
|
+
try:
|
|
1271
|
+
if args.cmd == "ensure-uv":
|
|
1272
|
+
uv = cls.ensure_uv(
|
|
1273
|
+
upgrade=not args.no_upgrade,
|
|
1274
|
+
user=not args.no_user,
|
|
1275
|
+
index_url=args.index_url,
|
|
1276
|
+
extra_pip_args=args.pip_arg or None,
|
|
1277
|
+
)
|
|
1278
|
+
print(uv)
|
|
1279
|
+
return 0
|
|
1280
|
+
|
|
1281
|
+
if args.cmd == "list":
|
|
1282
|
+
require_python = not args.no_require_python
|
|
1283
|
+
for e in cls.iter_user_envs(
|
|
1284
|
+
max_depth=args.max_depth,
|
|
1285
|
+
include_hidden=args.include_hidden,
|
|
1286
|
+
require_python=require_python,
|
|
1287
|
+
dedupe=True,
|
|
1288
|
+
):
|
|
1289
|
+
py = str(e.python_executable) if e.exists() else "-"
|
|
1290
|
+
print(f"{e.name}\t{e.root}\t{py}")
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
if args.cmd == "create":
|
|
1294
|
+
env_obj = cls.create(
|
|
1295
|
+
args.name,
|
|
1296
|
+
python=args.python,
|
|
1297
|
+
clear=args.clear,
|
|
1298
|
+
packages=args.pkg or None,
|
|
1299
|
+
requirements=Path(args.requirements).expanduser() if args.requirements else None,
|
|
1300
|
+
pip_args=args.pip_arg or None,
|
|
1301
|
+
)
|
|
1302
|
+
print(env_obj.root)
|
|
1303
|
+
return 0
|
|
1304
|
+
|
|
1305
|
+
if args.cmd == "delete":
|
|
1306
|
+
cls.delete(args.name, missing_ok=args.missing_ok)
|
|
1307
|
+
return 0
|
|
1308
|
+
|
|
1309
|
+
if args.cmd == "exec":
|
|
1310
|
+
env_obj = cls.get(args.name, require_python=True)
|
|
1311
|
+
if env_obj is None:
|
|
1312
|
+
raise PythonEnvError(f"Env not found: {args.name!r}")
|
|
1313
|
+
out = env_obj.exec_code(args.code)
|
|
1314
|
+
print(out, end="")
|
|
1315
|
+
return 0
|
|
1316
|
+
|
|
1317
|
+
if args.cmd == "exec-return":
|
|
1318
|
+
env_obj = cls.get(args.name, require_python=True)
|
|
1319
|
+
if env_obj is None:
|
|
1320
|
+
raise PythonEnvError(f"Env not found: {args.name!r}")
|
|
1321
|
+
val = env_obj.exec_code_and_return(
|
|
1322
|
+
args.code,
|
|
1323
|
+
result_tag=args.tag,
|
|
1324
|
+
parse_json=args.json,
|
|
1325
|
+
)
|
|
1326
|
+
if isinstance(val, (dict, list, int, float, bool)) or val is None:
|
|
1327
|
+
print(json.dumps(val))
|
|
1328
|
+
else:
|
|
1329
|
+
print(val)
|
|
1330
|
+
return 0
|
|
1331
|
+
|
|
1332
|
+
if args.cmd == "current":
|
|
1333
|
+
cur = cls.get_current()
|
|
1334
|
+
print(f"root={cur.root}")
|
|
1335
|
+
print(f"python={cur.python_executable}")
|
|
1336
|
+
return 0
|
|
1337
|
+
|
|
1338
|
+
raise PythonEnvError(f"Unknown command: {args.cmd}")
|
|
1339
|
+
|
|
1340
|
+
except PythonEnvError as e:
|
|
1341
|
+
log.error("python_env CLI error: %s", e)
|
|
1342
|
+
print(f"ERROR: {e}", file=sys.stderr)
|
|
1343
|
+
return 2
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
# Snapshot singleton (import-time)
|
|
1347
|
+
CURRENT_PYTHON_ENV: PythonEnv = PythonEnv.get_current()
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
if __name__ == "__main__":
|
|
1351
|
+
raise SystemExit(PythonEnv.cli())
|