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.
Files changed (61) hide show
  1. {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/METADATA +1 -1
  2. ygg-0.1.33.dist-info/RECORD +60 -0
  3. yggdrasil/__init__.py +2 -0
  4. yggdrasil/databricks/__init__.py +2 -0
  5. yggdrasil/databricks/compute/__init__.py +2 -0
  6. yggdrasil/databricks/compute/cluster.py +244 -3
  7. yggdrasil/databricks/compute/execution_context.py +100 -11
  8. yggdrasil/databricks/compute/remote.py +24 -0
  9. yggdrasil/databricks/jobs/__init__.py +5 -0
  10. yggdrasil/databricks/jobs/config.py +29 -4
  11. yggdrasil/databricks/sql/__init__.py +2 -0
  12. yggdrasil/databricks/sql/engine.py +217 -36
  13. yggdrasil/databricks/sql/exceptions.py +1 -0
  14. yggdrasil/databricks/sql/statement_result.py +147 -0
  15. yggdrasil/databricks/sql/types.py +33 -1
  16. yggdrasil/databricks/workspaces/__init__.py +2 -1
  17. yggdrasil/databricks/workspaces/filesytem.py +183 -0
  18. yggdrasil/databricks/workspaces/io.py +387 -9
  19. yggdrasil/databricks/workspaces/path.py +297 -2
  20. yggdrasil/databricks/workspaces/path_kind.py +3 -0
  21. yggdrasil/databricks/workspaces/workspace.py +202 -5
  22. yggdrasil/dataclasses/__init__.py +2 -0
  23. yggdrasil/dataclasses/dataclass.py +42 -1
  24. yggdrasil/libs/__init__.py +2 -0
  25. yggdrasil/libs/databrickslib.py +9 -0
  26. yggdrasil/libs/extensions/__init__.py +2 -0
  27. yggdrasil/libs/extensions/polars_extensions.py +72 -0
  28. yggdrasil/libs/extensions/spark_extensions.py +116 -0
  29. yggdrasil/libs/pandaslib.py +7 -0
  30. yggdrasil/libs/polarslib.py +7 -0
  31. yggdrasil/libs/sparklib.py +41 -0
  32. yggdrasil/pyutils/__init__.py +4 -0
  33. yggdrasil/pyutils/callable_serde.py +106 -0
  34. yggdrasil/pyutils/exceptions.py +16 -0
  35. yggdrasil/pyutils/modules.py +44 -1
  36. yggdrasil/pyutils/parallel.py +29 -0
  37. yggdrasil/pyutils/python_env.py +301 -0
  38. yggdrasil/pyutils/retry.py +57 -0
  39. yggdrasil/requests/__init__.py +4 -0
  40. yggdrasil/requests/msal.py +124 -3
  41. yggdrasil/requests/session.py +18 -0
  42. yggdrasil/types/__init__.py +2 -0
  43. yggdrasil/types/cast/__init__.py +2 -1
  44. yggdrasil/types/cast/arrow_cast.py +123 -1
  45. yggdrasil/types/cast/cast_options.py +119 -1
  46. yggdrasil/types/cast/pandas_cast.py +29 -0
  47. yggdrasil/types/cast/polars_cast.py +47 -0
  48. yggdrasil/types/cast/polars_pandas_cast.py +29 -0
  49. yggdrasil/types/cast/registry.py +176 -0
  50. yggdrasil/types/cast/spark_cast.py +76 -0
  51. yggdrasil/types/cast/spark_pandas_cast.py +29 -0
  52. yggdrasil/types/cast/spark_polars_cast.py +28 -0
  53. yggdrasil/types/libs.py +2 -0
  54. yggdrasil/types/python_arrow.py +191 -0
  55. yggdrasil/types/python_defaults.py +73 -0
  56. yggdrasil/version.py +1 -0
  57. ygg-0.1.31.dist-info/RECORD +0 -59
  58. {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/WHEEL +0 -0
  59. {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/entry_points.txt +0 -0
  60. {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/licenses/LICENSE +0 -0
  61. {ygg-0.1.31.dist-info → ygg-0.1.33.dist-info}/top_level.txt +0 -0
@@ -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
 
@@ -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)")
@@ -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")
@@ -1 +1,5 @@
1
+ """Convenience imports for request session helpers."""
2
+
1
3
  from .msal import MSALSession, MSALAuth
4
+
5
+ __all__ = ["MSALSession", "MSALAuth"]