ygg 0.1.31__py3-none-any.whl → 0.1.33__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.31.dist-info → ygg-0.1.33.dist-info}/METADATA +1 -1
- ygg-0.1.33.dist-info/RECORD +60 -0
- yggdrasil/__init__.py +2 -0
- yggdrasil/databricks/__init__.py +2 -0
- yggdrasil/databricks/compute/__init__.py +2 -0
- yggdrasil/databricks/compute/cluster.py +244 -3
- yggdrasil/databricks/compute/execution_context.py +100 -11
- yggdrasil/databricks/compute/remote.py +24 -0
- yggdrasil/databricks/jobs/__init__.py +5 -0
- yggdrasil/databricks/jobs/config.py +29 -4
- yggdrasil/databricks/sql/__init__.py +2 -0
- yggdrasil/databricks/sql/engine.py +217 -36
- yggdrasil/databricks/sql/exceptions.py +1 -0
- yggdrasil/databricks/sql/statement_result.py +147 -0
- yggdrasil/databricks/sql/types.py +33 -1
- yggdrasil/databricks/workspaces/__init__.py +2 -1
- yggdrasil/databricks/workspaces/filesytem.py +183 -0
- yggdrasil/databricks/workspaces/io.py +387 -9
- yggdrasil/databricks/workspaces/path.py +297 -2
- yggdrasil/databricks/workspaces/path_kind.py +3 -0
- yggdrasil/databricks/workspaces/workspace.py +202 -5
- yggdrasil/dataclasses/__init__.py +2 -0
- yggdrasil/dataclasses/dataclass.py +42 -1
- yggdrasil/libs/__init__.py +2 -0
- yggdrasil/libs/databrickslib.py +9 -0
- yggdrasil/libs/extensions/__init__.py +2 -0
- yggdrasil/libs/extensions/polars_extensions.py +72 -0
- yggdrasil/libs/extensions/spark_extensions.py +116 -0
- yggdrasil/libs/pandaslib.py +7 -0
- yggdrasil/libs/polarslib.py +7 -0
- yggdrasil/libs/sparklib.py +41 -0
- yggdrasil/pyutils/__init__.py +4 -0
- yggdrasil/pyutils/callable_serde.py +106 -0
- yggdrasil/pyutils/exceptions.py +16 -0
- yggdrasil/pyutils/modules.py +44 -1
- yggdrasil/pyutils/parallel.py +29 -0
- yggdrasil/pyutils/python_env.py +301 -0
- yggdrasil/pyutils/retry.py +57 -0
- yggdrasil/requests/__init__.py +4 -0
- yggdrasil/requests/msal.py +124 -3
- yggdrasil/requests/session.py +18 -0
- yggdrasil/types/__init__.py +2 -0
- yggdrasil/types/cast/__init__.py +2 -1
- yggdrasil/types/cast/arrow_cast.py +123 -1
- yggdrasil/types/cast/cast_options.py +119 -1
- yggdrasil/types/cast/pandas_cast.py +29 -0
- yggdrasil/types/cast/polars_cast.py +47 -0
- yggdrasil/types/cast/polars_pandas_cast.py +29 -0
- yggdrasil/types/cast/registry.py +176 -0
- yggdrasil/types/cast/spark_cast.py +76 -0
- yggdrasil/types/cast/spark_pandas_cast.py +29 -0
- yggdrasil/types/cast/spark_polars_cast.py +28 -0
- yggdrasil/types/libs.py +2 -0
- yggdrasil/types/python_arrow.py +191 -0
- yggdrasil/types/python_defaults.py +73 -0
- yggdrasil/version.py +1 -0
- ygg-0.1.31.dist-info/RECORD +0 -59
- {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/WHEEL +0 -0
- {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/entry_points.txt +0 -0
- {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/licenses/LICENSE +0 -0
- {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/top_level.txt +0 -0
yggdrasil/pyutils/parallel.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Parallel execution decorator utilities."""
|
|
2
|
+
|
|
1
3
|
from __future__ import annotations
|
|
2
4
|
|
|
3
5
|
import concurrent.futures as cf
|
|
@@ -42,8 +44,25 @@ def parallelize(
|
|
|
42
44
|
"""
|
|
43
45
|
|
|
44
46
|
def decorator(func: Callable[P, R]) -> Callable[P, Iterator[R]]:
|
|
47
|
+
"""Wrap a callable to execute over an iterable using an executor.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
func: Callable to wrap.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Wrapped callable that yields results.
|
|
54
|
+
"""
|
|
45
55
|
@wraps(func)
|
|
46
56
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Iterator[R]: # type: ignore[misc]
|
|
57
|
+
"""Execute the wrapped function across items in parallel.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
*args: Positional args for the wrapped function.
|
|
61
|
+
**kwargs: Keyword args for the wrapped function.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Iterator of results.
|
|
65
|
+
"""
|
|
47
66
|
# Basic sanity checks
|
|
48
67
|
if arg_index < 0 or arg_index >= len(args):
|
|
49
68
|
raise ValueError(
|
|
@@ -79,6 +98,11 @@ def parallelize(
|
|
|
79
98
|
|
|
80
99
|
# Generator that will actually submit tasks and yield results
|
|
81
100
|
def gen() -> Iterator[R]:
|
|
101
|
+
"""Yield results from parallel execution in input order.
|
|
102
|
+
|
|
103
|
+
Yields:
|
|
104
|
+
Results in input order.
|
|
105
|
+
"""
|
|
82
106
|
futures: list[cf.Future[R]] = []
|
|
83
107
|
processed = 0 # for progress
|
|
84
108
|
|
|
@@ -87,6 +111,11 @@ def parallelize(
|
|
|
87
111
|
ctx = executor if owns_executor else nullcontext(executor)
|
|
88
112
|
|
|
89
113
|
def _print_progress() -> None:
|
|
114
|
+
"""Emit progress output when enabled.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
None.
|
|
118
|
+
"""
|
|
90
119
|
if not show_progress:
|
|
91
120
|
return
|
|
92
121
|
|
yggdrasil/pyutils/python_env.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
"""Manage isolated Python environments with uv/pip helpers."""
|
|
2
|
+
|
|
1
3
|
# yggdrasil/pyutils/python_env.py
|
|
2
4
|
from __future__ import annotations
|
|
3
5
|
|
|
@@ -24,6 +26,8 @@ log = logging.getLogger(__name__)
|
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
class PythonEnvError(RuntimeError):
|
|
29
|
+
"""Raised when Python environment operations fail."""
|
|
30
|
+
|
|
27
31
|
pass
|
|
28
32
|
|
|
29
33
|
|
|
@@ -93,16 +97,38 @@ def _filter_non_pipable_linux_packages(requirements: Iterable[str]) -> List[str]
|
|
|
93
97
|
|
|
94
98
|
|
|
95
99
|
def _is_windows() -> bool:
|
|
100
|
+
"""Return True when running on Windows.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if the OS is Windows.
|
|
104
|
+
"""
|
|
96
105
|
return os.name == "nt"
|
|
97
106
|
|
|
98
107
|
|
|
99
108
|
def _norm_env(base: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
|
109
|
+
"""Return a normalized environment dict with common defaults.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
base: Optional base environment mapping.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Normalized environment mapping.
|
|
116
|
+
"""
|
|
100
117
|
env = dict(base or os.environ)
|
|
101
118
|
env.setdefault("PYTHONUNBUFFERED", "1")
|
|
102
119
|
return env
|
|
103
120
|
|
|
104
121
|
|
|
105
122
|
def _split_on_tag(stdout: str, tag: str) -> tuple[list[str], Optional[str]]:
|
|
123
|
+
"""Split stdout into lines before the tag and the tag payload.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
stdout: Captured stdout text.
|
|
127
|
+
tag: Prefix tag to split on.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Tuple of lines before the tag and tag payload.
|
|
131
|
+
"""
|
|
106
132
|
lines = (stdout or "").splitlines()
|
|
107
133
|
before: list[str] = []
|
|
108
134
|
payload: Optional[str] = None
|
|
@@ -116,6 +142,14 @@ def _split_on_tag(stdout: str, tag: str) -> tuple[list[str], Optional[str]]:
|
|
|
116
142
|
|
|
117
143
|
|
|
118
144
|
def _dedupe_keep_order(items: Iterable[str]) -> list[str]:
|
|
145
|
+
"""Return a de-duplicated list while preserving order.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
items: Iterable of items to deduplicate.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
De-duplicated list.
|
|
152
|
+
"""
|
|
119
153
|
seen: set[str] = set()
|
|
120
154
|
out: list[str] = []
|
|
121
155
|
for x in items:
|
|
@@ -135,6 +169,18 @@ def _run_cmd(
|
|
|
135
169
|
native_tls: bool = False,
|
|
136
170
|
extra_index_url: Optional[Union[str, List[str]]] = None,
|
|
137
171
|
) -> subprocess.CompletedProcess[str]:
|
|
172
|
+
"""Run a command and raise a PythonEnvError on failure.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
cmd: Command and arguments to execute.
|
|
176
|
+
cwd: Optional working directory.
|
|
177
|
+
env: Optional environment variables.
|
|
178
|
+
native_tls: Whether to enable native TLS for uv.
|
|
179
|
+
extra_index_url: Extra index URL(s) for pip/uv.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Completed process with stdout/stderr.
|
|
183
|
+
"""
|
|
138
184
|
cmd_s = [str(x) for x in cmd]
|
|
139
185
|
|
|
140
186
|
if native_tls and "--native-tls" not in cmd_s:
|
|
@@ -172,14 +218,32 @@ def _run_cmd(
|
|
|
172
218
|
# -----------------------
|
|
173
219
|
|
|
174
220
|
def _user_python_dir() -> Path:
|
|
221
|
+
"""Return the base directory for user Python tooling.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Path to the user python directory.
|
|
225
|
+
"""
|
|
175
226
|
return (Path.home() / ".python").expanduser().resolve()
|
|
176
227
|
|
|
177
228
|
|
|
178
229
|
def _user_envs_dir() -> Path:
|
|
230
|
+
"""Return the directory where user environments are stored.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Path to the envs directory.
|
|
234
|
+
"""
|
|
179
235
|
return (_user_python_dir() / "envs").resolve()
|
|
180
236
|
|
|
181
237
|
|
|
182
238
|
def _safe_env_name(name: str) -> str:
|
|
239
|
+
"""Normalize and validate an environment name.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
name: Environment name string.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Sanitized environment name.
|
|
246
|
+
"""
|
|
183
247
|
n = (name or "").strip()
|
|
184
248
|
if not n:
|
|
185
249
|
raise PythonEnvError("Env name cannot be empty")
|
|
@@ -194,11 +258,24 @@ def _safe_env_name(name: str) -> str:
|
|
|
194
258
|
# -----------------------
|
|
195
259
|
|
|
196
260
|
def _uv_exe_on_path() -> Optional[str]:
|
|
261
|
+
"""Return the uv executable path if available.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Path to uv executable or None.
|
|
265
|
+
"""
|
|
197
266
|
uv = shutil.which("uv")
|
|
198
267
|
return uv
|
|
199
268
|
|
|
200
269
|
|
|
201
270
|
def _current_env_script(name: str) -> Optional[Path]:
|
|
271
|
+
"""Return a script path from the current environment, if present.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Script name to locate.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Path to the script or None.
|
|
278
|
+
"""
|
|
202
279
|
exe = Path(sys.executable).resolve()
|
|
203
280
|
bindir = exe.parent
|
|
204
281
|
if _is_windows():
|
|
@@ -212,6 +289,14 @@ def _current_env_script(name: str) -> Optional[Path]:
|
|
|
212
289
|
|
|
213
290
|
|
|
214
291
|
def _ensure_pip_available(*, check: bool = True) -> None:
|
|
292
|
+
"""Ensure pip is available, optionally raising on failure.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
check: Raise if ensurepip fails.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
None.
|
|
299
|
+
"""
|
|
215
300
|
log.debug("checking pip availability")
|
|
216
301
|
p = subprocess.run(
|
|
217
302
|
[sys.executable, "-m", "pip", "--version"],
|
|
@@ -245,6 +330,18 @@ def _pip_install_uv_in_current(
|
|
|
245
330
|
extra_pip_args: Optional[Iterable[str]] = None,
|
|
246
331
|
check: bool = True,
|
|
247
332
|
) -> None:
|
|
333
|
+
"""Install uv in the current environment using pip.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
upgrade: Whether to upgrade uv.
|
|
337
|
+
user: Install with --user if True.
|
|
338
|
+
index_url: Optional pip index URL.
|
|
339
|
+
extra_pip_args: Additional pip arguments.
|
|
340
|
+
check: Raise if install fails.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
None.
|
|
344
|
+
"""
|
|
248
345
|
_ensure_pip_available(check=check)
|
|
249
346
|
|
|
250
347
|
cmd = [sys.executable, "-m", "pip", "install"]
|
|
@@ -267,10 +364,26 @@ def _pip_install_uv_in_current(
|
|
|
267
364
|
# -----------------------
|
|
268
365
|
|
|
269
366
|
def _env_lock_key(root: Path) -> str:
|
|
367
|
+
"""Return a normalized lock key for an environment path.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
root: Environment root path.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Normalized lock key string.
|
|
374
|
+
"""
|
|
270
375
|
return str(Path(root).expanduser().resolve())
|
|
271
376
|
|
|
272
377
|
|
|
273
378
|
def _get_env_lock(root: Path) -> threading.RLock:
|
|
379
|
+
"""Return a re-entrant lock for a specific environment root.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
root: Environment root path.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Re-entrant lock instance.
|
|
386
|
+
"""
|
|
274
387
|
key = _env_lock_key(root)
|
|
275
388
|
with _LOCKS_GUARD:
|
|
276
389
|
lk = _ENV_LOCKS.get(key)
|
|
@@ -282,6 +395,14 @@ def _get_env_lock(root: Path) -> threading.RLock:
|
|
|
282
395
|
|
|
283
396
|
@contextmanager
|
|
284
397
|
def _locked_env(root: Path):
|
|
398
|
+
"""Context manager that guards environment operations with a lock.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
root: Environment root path.
|
|
402
|
+
|
|
403
|
+
Yields:
|
|
404
|
+
None.
|
|
405
|
+
"""
|
|
285
406
|
lk = _get_env_lock(root)
|
|
286
407
|
lk.acquire()
|
|
287
408
|
try:
|
|
@@ -296,9 +417,15 @@ def _locked_env(root: Path):
|
|
|
296
417
|
|
|
297
418
|
@dataclass(frozen=True)
|
|
298
419
|
class PythonEnv:
|
|
420
|
+
"""Represent a managed Python environment rooted at a filesystem path."""
|
|
299
421
|
root: Path
|
|
300
422
|
|
|
301
423
|
def __post_init__(self) -> None:
|
|
424
|
+
"""Normalize the root path after dataclass initialization.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
None.
|
|
428
|
+
"""
|
|
302
429
|
object.__setattr__(self, "root", Path(self.root).expanduser().resolve())
|
|
303
430
|
|
|
304
431
|
# -----------------------
|
|
@@ -307,6 +434,11 @@ class PythonEnv:
|
|
|
307
434
|
|
|
308
435
|
@classmethod
|
|
309
436
|
def get_current(cls) -> "PythonEnv":
|
|
437
|
+
"""Return the current active environment inferred from the process.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
PythonEnv representing the current environment.
|
|
441
|
+
"""
|
|
310
442
|
venv = os.environ.get("VIRTUAL_ENV")
|
|
311
443
|
if venv:
|
|
312
444
|
log.debug("current env from VIRTUAL_ENV=%s", venv)
|
|
@@ -325,6 +457,11 @@ class PythonEnv:
|
|
|
325
457
|
def ensure_uv(
|
|
326
458
|
cls,
|
|
327
459
|
) -> str:
|
|
460
|
+
"""Ensure uv is installed and return its executable path.
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Path to uv executable.
|
|
464
|
+
"""
|
|
328
465
|
uv = _uv_exe_on_path()
|
|
329
466
|
if uv:
|
|
330
467
|
return uv
|
|
@@ -373,6 +510,17 @@ class PythonEnv:
|
|
|
373
510
|
require_python: bool = True,
|
|
374
511
|
dedupe: bool = True,
|
|
375
512
|
) -> Iterator["PythonEnv"]:
|
|
513
|
+
"""Yield PythonEnv instances from the user env directory.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
max_depth: Maximum directory traversal depth.
|
|
517
|
+
include_hidden: Include hidden directories if True.
|
|
518
|
+
require_python: Require python executable if True.
|
|
519
|
+
dedupe: Deduplicate results if True.
|
|
520
|
+
|
|
521
|
+
Yields:
|
|
522
|
+
PythonEnv instances.
|
|
523
|
+
"""
|
|
376
524
|
base = _user_envs_dir()
|
|
377
525
|
if not base.exists() or not base.is_dir():
|
|
378
526
|
return
|
|
@@ -380,6 +528,14 @@ class PythonEnv:
|
|
|
380
528
|
seen: set[str] = set()
|
|
381
529
|
|
|
382
530
|
def _python_exe(d: Path) -> Path:
|
|
531
|
+
"""Return the python executable path for a candidate env.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
d: Candidate environment directory.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Path to python executable.
|
|
538
|
+
"""
|
|
383
539
|
if os.name == "nt":
|
|
384
540
|
return d / "Scripts" / "python.exe"
|
|
385
541
|
return d / "bin" / "python"
|
|
@@ -418,10 +574,27 @@ class PythonEnv:
|
|
|
418
574
|
|
|
419
575
|
@classmethod
|
|
420
576
|
def _user_env_root(cls, name: str) -> Path:
|
|
577
|
+
"""Return the root path for a named user environment.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
name: Environment name.
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Root path for the named environment.
|
|
584
|
+
"""
|
|
421
585
|
return _user_envs_dir() / _safe_env_name(name)
|
|
422
586
|
|
|
423
587
|
@classmethod
|
|
424
588
|
def get(cls, name: str, *, require_python: bool = False) -> Optional["PythonEnv"]:
|
|
589
|
+
"""Return a PythonEnv by name when it exists.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
name: Environment name.
|
|
593
|
+
require_python: Require python executable if True.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
PythonEnv or None if not found.
|
|
597
|
+
"""
|
|
425
598
|
root = cls._user_env_root(name)
|
|
426
599
|
if not root.exists() or not root.is_dir():
|
|
427
600
|
return None
|
|
@@ -620,6 +793,15 @@ class PythonEnv:
|
|
|
620
793
|
|
|
621
794
|
@classmethod
|
|
622
795
|
def delete(cls, name: str, *, missing_ok: bool = True) -> None:
|
|
796
|
+
"""Delete a user environment by name.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
name: Environment name.
|
|
800
|
+
missing_ok: If True, missing envs do not raise.
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
None.
|
|
804
|
+
"""
|
|
623
805
|
root = cls._user_env_root(name)
|
|
624
806
|
with _locked_env(root):
|
|
625
807
|
if not root.exists():
|
|
@@ -635,6 +817,11 @@ class PythonEnv:
|
|
|
635
817
|
|
|
636
818
|
@property
|
|
637
819
|
def bindir(self) -> Path:
|
|
820
|
+
"""Return the scripts/bin directory for this environment.
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
Path to the bin/Scripts directory.
|
|
824
|
+
"""
|
|
638
825
|
if _is_windows():
|
|
639
826
|
scripts = self.root / "Scripts"
|
|
640
827
|
return scripts if scripts.is_dir() else self.root
|
|
@@ -642,24 +829,49 @@ class PythonEnv:
|
|
|
642
829
|
|
|
643
830
|
@property
|
|
644
831
|
def name(self) -> str:
|
|
832
|
+
"""Return the environment name derived from its root path.
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Environment name.
|
|
836
|
+
"""
|
|
645
837
|
n = self.root.name
|
|
646
838
|
return n if n else str(self.root)
|
|
647
839
|
|
|
648
840
|
@property
|
|
649
841
|
def python_executable(self) -> Path:
|
|
842
|
+
"""Return the full path to the environment's Python executable.
|
|
843
|
+
|
|
844
|
+
Returns:
|
|
845
|
+
Path to python executable.
|
|
846
|
+
"""
|
|
650
847
|
exe = "python.exe" if _is_windows() else "python"
|
|
651
848
|
return self.bindir / exe
|
|
652
849
|
|
|
653
850
|
def exists(self) -> bool:
|
|
851
|
+
"""Return True when the environment's Python executable exists.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
True if python executable exists.
|
|
855
|
+
"""
|
|
654
856
|
return self.python_executable.exists()
|
|
655
857
|
|
|
656
858
|
@property
|
|
657
859
|
def version(self) -> str:
|
|
860
|
+
"""Return the Python version string for the environment.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
Version string.
|
|
864
|
+
"""
|
|
658
865
|
out = self.exec_code("import sys; print(sys.version.split()[0])", check=True)
|
|
659
866
|
return out.strip()
|
|
660
867
|
|
|
661
868
|
@property
|
|
662
869
|
def version_info(self) -> tuple[int, int, int]:
|
|
870
|
+
"""Return the parsed (major, minor, patch) version tuple.
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Tuple of (major, minor, patch).
|
|
874
|
+
"""
|
|
663
875
|
v = self.version
|
|
664
876
|
m = re.match(r"^\s*(\d+)\.(\d+)\.(\d+)\s*$", v)
|
|
665
877
|
if not m:
|
|
@@ -715,6 +927,14 @@ class PythonEnv:
|
|
|
715
927
|
return self
|
|
716
928
|
|
|
717
929
|
def _slug(s: str) -> str:
|
|
930
|
+
"""Return a filesystem-friendly slug from a string.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
s: Input string.
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
Slugified string.
|
|
937
|
+
"""
|
|
718
938
|
s = (s or "").strip()
|
|
719
939
|
s = re.sub(r"[^A-Za-z0-9._+-]+", "-", s)
|
|
720
940
|
return s.strip("-") or "unknown"
|
|
@@ -768,6 +988,18 @@ class PythonEnv:
|
|
|
768
988
|
include_input: bool = True,
|
|
769
989
|
buffers: Optional[MutableMapping[str, str]] = None,
|
|
770
990
|
):
|
|
991
|
+
"""Export requirements for the current environment's Python executable.
|
|
992
|
+
|
|
993
|
+
Args:
|
|
994
|
+
out_dir: Optional output directory.
|
|
995
|
+
base_name: Base filename prefix.
|
|
996
|
+
include_frozen: Include frozen requirements.
|
|
997
|
+
include_input: Include input requirements.
|
|
998
|
+
buffers: Optional mapping for in-memory outputs.
|
|
999
|
+
|
|
1000
|
+
Returns:
|
|
1001
|
+
Requirements output path or content.
|
|
1002
|
+
"""
|
|
771
1003
|
return self.export_requirements_matrix(
|
|
772
1004
|
python_versions=[self.python_executable],
|
|
773
1005
|
out_dir=out_dir, base_name=base_name, include_frozen=include_frozen,
|
|
@@ -801,6 +1033,14 @@ class PythonEnv:
|
|
|
801
1033
|
- {base_name}-py<slug>.txt
|
|
802
1034
|
"""
|
|
803
1035
|
def _slug(s: str) -> str:
|
|
1036
|
+
"""Return a filesystem-friendly slug for a Python version label.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
s: Input string.
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
Slugified string.
|
|
1043
|
+
"""
|
|
804
1044
|
s = (s or "").strip()
|
|
805
1045
|
if not s:
|
|
806
1046
|
return "unknown"
|
|
@@ -923,6 +1163,14 @@ print("RESULT:" + json.dumps(top_level))""".strip()
|
|
|
923
1163
|
tmp_ctx.cleanup()
|
|
924
1164
|
|
|
925
1165
|
def installed_packages(self, parsed: bool = False) -> List[Tuple[str, str]]:
|
|
1166
|
+
"""Return installed packages, optionally parsed into (name, version).
|
|
1167
|
+
|
|
1168
|
+
Args:
|
|
1169
|
+
parsed: Return parsed tuples if True.
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
List of package specs or tuples.
|
|
1173
|
+
"""
|
|
926
1174
|
req = self.requirements()
|
|
927
1175
|
|
|
928
1176
|
r = [
|
|
@@ -950,6 +1198,18 @@ print("RESULT:" + json.dumps(top_level))""".strip()
|
|
|
950
1198
|
env: Optional[Mapping[str, str]] = None,
|
|
951
1199
|
check: bool = True,
|
|
952
1200
|
) -> str:
|
|
1201
|
+
"""Execute Python code inside the environment and return stdout.
|
|
1202
|
+
|
|
1203
|
+
Args:
|
|
1204
|
+
code: Python code string to execute.
|
|
1205
|
+
python: Optional python executable override.
|
|
1206
|
+
cwd: Optional working directory.
|
|
1207
|
+
env: Optional environment variables.
|
|
1208
|
+
check: Raise on failure if True.
|
|
1209
|
+
|
|
1210
|
+
Returns:
|
|
1211
|
+
Captured stdout.
|
|
1212
|
+
"""
|
|
953
1213
|
# pick interpreter (default = env python)
|
|
954
1214
|
if python is None:
|
|
955
1215
|
if not self.exists():
|
|
@@ -979,6 +1239,22 @@ print("RESULT:" + json.dumps(top_level))""".strip()
|
|
|
979
1239
|
print_prefix_lines: bool = True,
|
|
980
1240
|
strip_payload: bool = True,
|
|
981
1241
|
) -> Any:
|
|
1242
|
+
"""Execute code and parse a tagged payload from stdout.
|
|
1243
|
+
|
|
1244
|
+
Args:
|
|
1245
|
+
code: Python code string to execute.
|
|
1246
|
+
result_tag: Tag prefix for the payload line.
|
|
1247
|
+
python: Optional python executable override.
|
|
1248
|
+
cwd: Optional working directory.
|
|
1249
|
+
env: Optional environment variables.
|
|
1250
|
+
check: Raise on failure if True.
|
|
1251
|
+
parse_json: Parse payload as JSON if True.
|
|
1252
|
+
print_prefix_lines: Print lines before the tag.
|
|
1253
|
+
strip_payload: Strip whitespace from payload.
|
|
1254
|
+
|
|
1255
|
+
Returns:
|
|
1256
|
+
Parsed payload value.
|
|
1257
|
+
"""
|
|
982
1258
|
stdout = self.exec_code(
|
|
983
1259
|
code,
|
|
984
1260
|
python=python,
|
|
@@ -1003,6 +1279,14 @@ print("RESULT:" + json.dumps(top_level))""".strip()
|
|
|
1003
1279
|
payload = payload.strip()
|
|
1004
1280
|
|
|
1005
1281
|
def _try_parse_obj(s: str) -> Optional[Any]:
|
|
1282
|
+
"""Try parsing a string as JSON or Python literal.
|
|
1283
|
+
|
|
1284
|
+
Args:
|
|
1285
|
+
s: String to parse.
|
|
1286
|
+
|
|
1287
|
+
Returns:
|
|
1288
|
+
Parsed object or None.
|
|
1289
|
+
"""
|
|
1006
1290
|
s2 = s.strip()
|
|
1007
1291
|
if not s2:
|
|
1008
1292
|
return None
|
|
@@ -1016,6 +1300,15 @@ print("RESULT:" + json.dumps(top_level))""".strip()
|
|
|
1016
1300
|
return None
|
|
1017
1301
|
|
|
1018
1302
|
def _decode_value(val: Any, encoding: Optional[str]) -> Any:
|
|
1303
|
+
"""Decode a serialized value based on an encoding hint.
|
|
1304
|
+
|
|
1305
|
+
Args:
|
|
1306
|
+
val: Value to decode.
|
|
1307
|
+
encoding: Encoding hint string.
|
|
1308
|
+
|
|
1309
|
+
Returns:
|
|
1310
|
+
Decoded value.
|
|
1311
|
+
"""
|
|
1019
1312
|
enc = (encoding or "").strip().lower()
|
|
1020
1313
|
if enc in ("", "none", "raw", "plain"):
|
|
1021
1314
|
return val
|
|
@@ -1088,6 +1381,14 @@ print("RESULT:" + json.dumps(top_level))""".strip()
|
|
|
1088
1381
|
|
|
1089
1382
|
@classmethod
|
|
1090
1383
|
def cli(cls, argv: Optional[list[str]] = None) -> int:
|
|
1384
|
+
"""Run the PythonEnv CLI command.
|
|
1385
|
+
|
|
1386
|
+
Args:
|
|
1387
|
+
argv: Optional argument list.
|
|
1388
|
+
|
|
1389
|
+
Returns:
|
|
1390
|
+
Exit code.
|
|
1391
|
+
"""
|
|
1091
1392
|
import argparse
|
|
1092
1393
|
|
|
1093
1394
|
parser = argparse.ArgumentParser(prog="python_env", description="User env CRUD + exec (uv everywhere)")
|
yggdrasil/pyutils/retry.py
CHANGED
|
@@ -37,6 +37,14 @@ ExceptionTypes = Union[Type[BaseException], Tuple[Type[BaseException], ...]]
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def _ensure_exception_tuple(exc: ExceptionTypes) -> Tuple[Type[BaseException], ...]:
|
|
40
|
+
"""Normalize exception inputs into a tuple of exception classes.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
exc: Exception class or tuple of classes.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Tuple of exception classes.
|
|
47
|
+
"""
|
|
40
48
|
if isinstance(exc, type) and issubclass(exc, BaseException):
|
|
41
49
|
return (exc,)
|
|
42
50
|
return tuple(exc)
|
|
@@ -104,9 +112,26 @@ def retry(
|
|
|
104
112
|
exc_types = _ensure_exception_tuple(exceptions)
|
|
105
113
|
|
|
106
114
|
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
115
|
+
"""Wrap a callable with retry behavior.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
func: Callable to wrap.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Wrapped callable with retry semantics.
|
|
122
|
+
"""
|
|
107
123
|
if inspect.iscoroutinefunction(func):
|
|
108
124
|
|
|
109
125
|
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore[misc]
|
|
126
|
+
"""Execute an async callable with retry behavior.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
*args: Positional args for the callable.
|
|
130
|
+
**kwargs: Keyword args for the callable.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Callable result.
|
|
134
|
+
"""
|
|
110
135
|
_delay = delay
|
|
111
136
|
attempt = 1
|
|
112
137
|
start_time = time.monotonic() if timeout is not None else None
|
|
@@ -170,6 +195,15 @@ def retry(
|
|
|
170
195
|
else:
|
|
171
196
|
|
|
172
197
|
def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # type: ignore[misc]
|
|
198
|
+
"""Execute a sync callable with retry behavior.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
*args: Positional args for the callable.
|
|
202
|
+
**kwargs: Keyword args for the callable.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Callable result.
|
|
206
|
+
"""
|
|
173
207
|
_delay = delay
|
|
174
208
|
attempt = 1
|
|
175
209
|
start_time = time.monotonic() if timeout is not None else None
|
|
@@ -269,6 +303,14 @@ def random_jitter(scale: float = 0.1) -> Callable[[float], float]:
|
|
|
269
303
|
"""
|
|
270
304
|
|
|
271
305
|
def _jitter(d: float) -> float:
|
|
306
|
+
"""Apply random jitter to a delay value.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
d: Base delay value.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Jittered delay value.
|
|
313
|
+
"""
|
|
272
314
|
if d <= 0:
|
|
273
315
|
return d
|
|
274
316
|
delta = d * scale
|
|
@@ -286,6 +328,11 @@ if __name__ == "__main__":
|
|
|
286
328
|
|
|
287
329
|
@retry(tries=4, delay=0.1, backoff=2, logger=log, timeout=5.0)
|
|
288
330
|
def flaky_function() -> str:
|
|
331
|
+
"""Demonstrate retry behavior for a flaky sync function.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
String result.
|
|
335
|
+
"""
|
|
289
336
|
counter["n"] += 1
|
|
290
337
|
if counter["n"] < 3:
|
|
291
338
|
raise ValueError("boom")
|
|
@@ -294,10 +341,20 @@ if __name__ == "__main__":
|
|
|
294
341
|
print("Result:", flaky_function())
|
|
295
342
|
|
|
296
343
|
async def main():
|
|
344
|
+
"""Run an async retry demonstration.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
None.
|
|
348
|
+
"""
|
|
297
349
|
async_counter = {"n": 0}
|
|
298
350
|
|
|
299
351
|
@retry(tries=4, delay=0.1, backoff=2, logger=log, timeout=5.0)
|
|
300
352
|
async def async_flaky() -> str:
|
|
353
|
+
"""Demonstrate retry behavior for a flaky async function.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
String result.
|
|
357
|
+
"""
|
|
301
358
|
async_counter["n"] += 1
|
|
302
359
|
if async_counter["n"] < 3:
|
|
303
360
|
raise RuntimeError("async boom")
|