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.
@@ -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())