exonware-xwlazy 0.1.0.21__py3-none-any.whl → 0.1.0.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.
Files changed (71) hide show
  1. exonware/__init__.py +22 -6
  2. exonware/xwlazy/__init__.py +14 -2
  3. exonware/xwlazy/common/__init__.py +8 -0
  4. exonware/xwlazy/common/base.py +11 -2
  5. exonware/xwlazy/common/cache.py +5 -5
  6. exonware/xwlazy/common/logger.py +5 -5
  7. exonware/xwlazy/common/services/dependency_mapper.py +31 -13
  8. exonware/xwlazy/common/services/install_async_utils.py +5 -0
  9. exonware/xwlazy/common/services/install_cache_utils.py +4 -4
  10. exonware/xwlazy/common/services/spec_cache.py +2 -2
  11. exonware/xwlazy/common/services/state_manager.py +4 -4
  12. exonware/xwlazy/common/strategies/caching_dict.py +2 -2
  13. exonware/xwlazy/common/strategies/caching_lfu.py +2 -2
  14. exonware/xwlazy/common/strategies/caching_ttl.py +2 -2
  15. exonware/xwlazy/common/utils.py +142 -0
  16. exonware/xwlazy/config.py +1 -1
  17. exonware/xwlazy/contracts.py +162 -25
  18. exonware/xwlazy/defs.py +15 -15
  19. exonware/xwlazy/facade.py +175 -29
  20. exonware/xwlazy/host/__init__.py +8 -0
  21. exonware/xwlazy/host/conf.py +16 -0
  22. exonware/xwlazy/module/base.py +61 -4
  23. exonware/xwlazy/module/facade.py +1 -1
  24. exonware/xwlazy/module/importer_engine.py +1017 -170
  25. exonware/xwlazy/module/partial_module_detector.py +275 -0
  26. exonware/xwlazy/module/strategies/module_helper_lazy.py +3 -3
  27. exonware/xwlazy/package/base.py +106 -41
  28. exonware/xwlazy/package/conf.py +6 -6
  29. exonware/xwlazy/package/services/config_manager.py +20 -16
  30. exonware/xwlazy/package/services/discovery.py +81 -16
  31. exonware/xwlazy/package/services/host_packages.py +41 -6
  32. exonware/xwlazy/package/services/install_async.py +16 -2
  33. exonware/xwlazy/package/services/install_cache.py +4 -4
  34. exonware/xwlazy/package/services/install_policy.py +14 -14
  35. exonware/xwlazy/package/services/install_registry.py +3 -3
  36. exonware/xwlazy/package/services/install_sbom.py +1 -1
  37. exonware/xwlazy/package/services/installer_engine.py +3 -3
  38. exonware/xwlazy/package/services/lazy_installer.py +102 -17
  39. exonware/xwlazy/package/services/manifest.py +43 -36
  40. exonware/xwlazy/package/services/strategy_registry.py +150 -12
  41. exonware/xwlazy/package/strategies/package_discovery_file.py +2 -2
  42. exonware/xwlazy/package/strategies/package_discovery_hybrid.py +2 -2
  43. exonware/xwlazy/package/strategies/package_discovery_manifest.py +2 -2
  44. exonware/xwlazy/package/strategies/package_execution_async.py +3 -3
  45. exonware/xwlazy/package/strategies/package_execution_cached.py +2 -2
  46. exonware/xwlazy/package/strategies/package_execution_pip.py +2 -2
  47. exonware/xwlazy/package/strategies/package_execution_wheel.py +2 -2
  48. exonware/xwlazy/package/strategies/package_mapping_discovery_first.py +2 -2
  49. exonware/xwlazy/package/strategies/package_mapping_hybrid.py +2 -2
  50. exonware/xwlazy/package/strategies/package_mapping_manifest_first.py +2 -2
  51. exonware/xwlazy/package/strategies/package_policy_allow_list.py +4 -4
  52. exonware/xwlazy/package/strategies/package_policy_deny_list.py +4 -4
  53. exonware/xwlazy/package/strategies/package_policy_permissive.py +3 -3
  54. exonware/xwlazy/package/strategies/package_timing_clean.py +2 -2
  55. exonware/xwlazy/package/strategies/package_timing_full.py +2 -2
  56. exonware/xwlazy/package/strategies/package_timing_smart.py +2 -2
  57. exonware/xwlazy/package/strategies/package_timing_temporary.py +2 -2
  58. exonware/xwlazy/runtime/adaptive_learner.py +7 -7
  59. exonware/xwlazy/runtime/base.py +14 -14
  60. exonware/xwlazy/runtime/facade.py +7 -7
  61. exonware/xwlazy/runtime/intelligent_selector.py +6 -6
  62. exonware/xwlazy/runtime/metrics.py +6 -6
  63. exonware/xwlazy/runtime/performance.py +5 -5
  64. exonware/xwlazy/version.py +2 -2
  65. {exonware_xwlazy-0.1.0.21.dist-info → exonware_xwlazy-0.1.0.23.dist-info}/METADATA +2 -6
  66. exonware_xwlazy-0.1.0.23.dist-info/RECORD +93 -0
  67. xwlazy/__init__.py +14 -0
  68. xwlazy/lazy.py +30 -0
  69. exonware_xwlazy-0.1.0.21.dist-info/RECORD +0 -87
  70. {exonware_xwlazy-0.1.0.21.dist-info → exonware_xwlazy-0.1.0.23.dist-info}/WHEEL +0 -0
  71. {exonware_xwlazy-0.1.0.21.dist-info → exonware_xwlazy-0.1.0.23.dist-info}/licenses/LICENSE +0 -0
@@ -20,7 +20,7 @@ import subprocess
20
20
  import importlib
21
21
  import importlib.util
22
22
  from pathlib import Path
23
- from typing import Dict, List, Optional, Tuple, Set, Any
23
+ from typing import Optional, Any
24
24
  from collections import OrderedDict
25
25
  from types import ModuleType
26
26
 
@@ -154,10 +154,13 @@ class LazyInstaller(
154
154
  self._async_enabled = False
155
155
  self._async_workers = 1
156
156
  self._async_loop: Optional[asyncio.AbstractEventLoop] = None
157
- self._async_tasks: Dict[str, Any] = {}
157
+ self._async_tasks: dict[str, Any] = {}
158
158
  self._known_missing: OrderedDict[str, float] = OrderedDict()
159
159
  self._async_cache_dir = _DEFAULT_ASYNC_CACHE_DIR
160
160
  self._loop_thread: Optional[threading.Thread] = None
161
+ # Hard force disable async installs to prevent any potential issues
162
+ # This overrides any env vars or manifest settings
163
+ self._async_enabled = False
161
164
 
162
165
  # ROOT CAUSE FIX: Load persistent installation cache
163
166
  # This cache tracks installed packages across Python restarts
@@ -166,6 +169,7 @@ class LazyInstaller(
166
169
 
167
170
  def install_package(self, package_name: str, module_name: str = None) -> bool:
168
171
  """Install a package using pip."""
172
+ sys.stderr.write(f"DEBUG: install_package called for {package_name}\n")
169
173
  _ensure_logging_initialized()
170
174
  # CRITICAL: Set flag FIRST before ANY operations to prevent recursion
171
175
  if getattr(_installing, 'active', False):
@@ -188,11 +192,22 @@ class LazyInstaller(
188
192
  if package_name in self._installed_packages:
189
193
  return True
190
194
 
195
+ # ROOT CAUSE FIX: Check if already importable (handles pre-installed packages not in cache)
196
+ # This prevents unnecessary pip calls and handles cache desync
197
+ check_name = module_name or package_name.replace('-', '_')
198
+ if check_name and self._is_module_importable(check_name):
199
+ _log("install", logger.info, f"Package {package_name} found in environment, marking as installed")
200
+ # Update caches
201
+ self._installed_packages.add(package_name)
202
+ version = self._get_installed_version(package_name)
203
+ self._install_cache.mark_installed(package_name, version)
204
+ return True
205
+
191
206
  if package_name in self._failed_packages:
192
207
  return False
193
208
 
194
209
  if self._mode == LazyInstallMode.DISABLED or self._mode == LazyInstallMode.NONE:
195
- _log("install", f"Lazy installation disabled for {self._package_name}, skipping {package_name}")
210
+ _log("install", logger.info, f"Lazy installation disabled for {self._package_name}, skipping {package_name}")
196
211
  return False
197
212
 
198
213
  if self._mode == LazyInstallMode.WARN:
@@ -211,7 +226,7 @@ class LazyInstaller(
211
226
 
212
227
  if self._mode == LazyInstallMode.INTERACTIVE:
213
228
  if not self._ask_user_permission(package_name, module_name or package_name):
214
- _log("install", f"User declined installation of {package_name}")
229
+ _log("install", logger.info, f"User declined installation of {package_name}")
215
230
  self._failed_packages.add(package_name)
216
231
  return False
217
232
 
@@ -414,6 +429,12 @@ class LazyInstaller(
414
429
 
415
430
  def _is_module_importable(self, module_name: str) -> bool:
416
431
  """Check if module can be imported without installation."""
432
+ # CRITICAL FIX: Check if import is already in progress to prevent recursion
433
+ from ...module.importer_engine import _is_import_in_progress
434
+ if _is_import_in_progress(module_name):
435
+ # Import is already in progress, don't check again to avoid recursion
436
+ return False
437
+
417
438
  try:
418
439
  spec = importlib.util.find_spec(module_name)
419
440
  return spec is not None and spec.loader is not None
@@ -457,15 +478,20 @@ class LazyInstaller(
457
478
 
458
479
  return False
459
480
 
460
- def install_and_import(self, module_name: str, package_name: str = None) -> Tuple[Optional[ModuleType], bool]:
481
+ def install_and_import(self, module_name: str, package_name: str = None) -> tuple[Optional[ModuleType], bool]:
461
482
  """
462
483
  Install package and import module.
463
484
 
464
485
  ROOT CAUSE FIX: Check if module is importable FIRST before attempting
465
486
  installation. This prevents circular imports and unnecessary installations.
466
487
  """
488
+ # CRITICAL: Initialize lazy imports first
489
+ _ensure_logging_initialized()
490
+
491
+ sys.stderr.write(f"DEBUG: install_and_import called for {module_name}\n")
467
492
  # CRITICAL: Prevent recursion - if installation is already in progress, skip
468
493
  if getattr(_installing, 'active', False):
494
+ sys.stderr.write(f"DEBUG: Recursion guard hit for {module_name}\n")
469
495
  logger.debug(
470
496
  f"Installation in progress, skipping install_and_import for {module_name} "
471
497
  f"to prevent recursion"
@@ -473,27 +499,71 @@ class LazyInstaller(
473
499
  return None, False
474
500
 
475
501
  if not self.is_enabled():
502
+ sys.stderr.write(f"DEBUG: Installer disabled for {module_name}\n")
476
503
  return None, False
477
504
 
478
505
  # Get package name early for cache check
479
506
  if package_name is None:
480
507
  package_name = self._dependency_mapper.get_package_name(module_name)
481
508
 
509
+ sys.stderr.write(f"DEBUG: package_name for {module_name} is {package_name}\n")
510
+
482
511
  # ROOT CAUSE FIX: Check persistent cache FIRST (fastest, no importability check)
483
512
  if package_name and self._install_cache.is_installed(package_name):
484
513
  # Package is in persistent cache - import directly
514
+ # CRITICAL: Remove finders before importing to prevent recursion
515
+ xwlazy_finder_names = {'LazyMetaPathFinder', 'LazyPathFinder', 'LazyLoader'}
516
+ xwlazy_finders = [f for f in sys.meta_path if type(f).__name__ in xwlazy_finder_names]
517
+ for finder in xwlazy_finders:
518
+ try:
519
+ sys.meta_path.remove(finder)
520
+ except ValueError:
521
+ pass
522
+
485
523
  try:
486
524
  module = importlib.import_module(module_name)
487
525
  self._clear_module_missing(module_name)
488
- _spec_cache_put(module_name, importlib.util.find_spec(module_name))
526
+ # Also remove finders before find_spec to prevent recursion
527
+ spec = importlib.util.find_spec(module_name)
528
+ _spec_cache_put(module_name, spec)
489
529
  logger.debug(f"Module {module_name} is in persistent cache, imported directly")
490
530
  return module, True
491
531
  except ImportError as e:
492
- logger.debug(f"Module {module_name} in cache but import failed: {e}")
493
- # Cache might be stale - fall through to importability check
532
+ _log("install", logger.warning, f"Module {module_name} in cache but import failed: {e}")
533
+ # ROOT CAUSE FIX: Cache is stale - invalidate it so we try to install properly
534
+ if package_name:
535
+ logger.debug(f"Invalidating stale cache entry for {package_name}")
536
+ self._install_cache.mark_uninstalled(package_name)
537
+ with self._lock:
538
+ self._installed_packages.discard(package_name)
539
+ # Also clear from failed packages so we can retry installation
540
+ self._failed_packages.discard(package_name)
541
+ # Fall through to importability check and installation
542
+ finally:
543
+ # Restore finders
544
+ for finder in reversed(xwlazy_finders):
545
+ if finder not in sys.meta_path:
546
+ sys.meta_path.insert(0, finder)
494
547
 
495
548
  # ROOT CAUSE FIX: Check if module is ALREADY importable BEFORE doing anything else
496
- if self._is_module_importable(module_name):
549
+ # But first, remove finders to prevent recursion
550
+ xwlazy_finder_names = {'LazyMetaPathFinder', 'LazyPathFinder', 'LazyLoader'}
551
+ xwlazy_finders = [f for f in sys.meta_path if type(f).__name__ in xwlazy_finder_names]
552
+ for finder in xwlazy_finders:
553
+ try:
554
+ sys.meta_path.remove(finder)
555
+ except ValueError:
556
+ pass
557
+
558
+ try:
559
+ is_importable = self._is_module_importable(module_name)
560
+ finally:
561
+ # Restore finders
562
+ for finder in reversed(xwlazy_finders):
563
+ if finder not in sys.meta_path:
564
+ sys.meta_path.insert(0, finder)
565
+
566
+ if is_importable:
497
567
  # Module is already importable - import it directly
498
568
  if package_name:
499
569
  version = self._get_installed_version(package_name)
@@ -505,7 +575,15 @@ class LazyInstaller(
505
575
  logger.debug(f"Module {module_name} is already importable, imported directly")
506
576
  return module, True
507
577
  except ImportError as e:
508
- logger.debug(f"Module {module_name} appeared importable but import failed: {e}")
578
+ _log("install", logger.warning, f"Module {module_name} appeared importable but import failed: {e}")
579
+ # ROOT CAUSE FIX: If importability check passed but import failed, invalidate cache
580
+ if package_name:
581
+ logger.debug(f"Invalidating stale cache entry for {package_name} (importability check was wrong)")
582
+ self._install_cache.mark_uninstalled(package_name)
583
+ with self._lock:
584
+ self._installed_packages.discard(package_name)
585
+ # Also clear from failed packages so we can retry installation
586
+ self._failed_packages.discard(package_name)
509
587
 
510
588
  # Package name should already be set from cache check above
511
589
  if package_name is None:
@@ -573,6 +651,7 @@ class LazyInstaller(
573
651
  if finder not in sys.meta_path:
574
652
  sys.meta_path.insert(0, finder)
575
653
  except ImportError as e:
654
+ _log("install", logger.warning, f"Import retry {attempt} failed for {module_name}: {e}")
576
655
  if attempt < 2:
577
656
  time.sleep(0.1 * (attempt + 1))
578
657
  else:
@@ -582,11 +661,11 @@ class LazyInstaller(
582
661
  self._mark_module_missing(module_name)
583
662
  return None, False
584
663
 
585
- def _check_security_policy(self, package_name: str) -> Tuple[bool, str]:
664
+ def _check_security_policy(self, package_name: str) -> tuple[bool, str]:
586
665
  """Check security policy for package."""
587
666
  return LazyInstallPolicy.is_package_allowed(self._package_name, package_name)
588
667
 
589
- def _run_pip_install(self, package_name: str, args: List[str]) -> bool:
668
+ def _run_pip_install(self, package_name: str, args: list[str]) -> bool:
590
669
  """Run pip install with arguments."""
591
670
  if self._install_from_cached_wheel(package_name):
592
671
  return True
@@ -599,6 +678,7 @@ class LazyInstaller(
599
678
  '--disable-pip-version-check',
600
679
  '--no-input',
601
680
  ] + args + [package_name]
681
+ _log("install", logger.info, f"Running pip install for {package_name}...")
602
682
  result = subprocess.run(
603
683
  pip_args,
604
684
  capture_output=True,
@@ -607,22 +687,27 @@ class LazyInstaller(
607
687
  )
608
688
  if result.returncode == 0:
609
689
  self._ensure_cached_wheel(package_name)
690
+ _log("install", logger.info, f"Pip install successful for {package_name}")
610
691
  return True
692
+ _log("install", logger.error, f"Pip failed for {package_name}: {result.stderr}")
693
+ print(f"DEBUG: Pip failed for {package_name}: {result.stderr}")
611
694
  return False
612
- except subprocess.CalledProcessError:
695
+ except subprocess.CalledProcessError as e:
696
+ _log("install", logger.error, f"Pip error for {package_name}: {e.stderr if hasattr(e, 'stderr') else e}")
697
+ print(f"DEBUG: Pip error for {package_name}: {e.stderr if hasattr(e, 'stderr') else e}")
613
698
  return False
614
699
 
615
- def get_installed_packages(self) -> Set[str]:
700
+ def get_installed_packages(self) -> set[str]:
616
701
  """Get set of installed package names."""
617
702
  with self._lock:
618
703
  return self._installed_packages.copy()
619
704
 
620
- def get_failed_packages(self) -> Set[str]:
705
+ def get_failed_packages(self) -> set[str]:
621
706
  """Get set of failed package names."""
622
707
  with self._lock:
623
708
  return self._failed_packages.copy()
624
709
 
625
- def get_async_tasks(self) -> Dict[str, Any]:
710
+ def get_async_tasks(self) -> dict[str, Any]:
626
711
  """Get dictionary of async installation tasks."""
627
712
  with self._lock:
628
713
  return {
@@ -633,7 +718,7 @@ class LazyInstaller(
633
718
  for module_name, task in self._async_tasks.items()
634
719
  }
635
720
 
636
- def get_stats(self) -> Dict[str, Any]:
721
+ def get_stats(self) -> dict[str, Any]:
637
722
  """Get installation statistics (extends base class method)."""
638
723
  base_stats = super().get_stats()
639
724
  with self._lock:
@@ -24,7 +24,7 @@ import json
24
24
  import os
25
25
  from pathlib import Path
26
26
  from threading import RLock
27
- from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
27
+ from typing import Any, Iterable, Optional
28
28
 
29
29
  try: # Python 3.11+
30
30
  import tomllib # type: ignore[attr-defined]
@@ -34,7 +34,7 @@ except Exception: # pragma: no cover - fallback for <=3.10
34
34
  except ImportError: # pragma: no cover
35
35
  tomllib = None # type: ignore
36
36
 
37
- DEFAULT_MANIFEST_FILENAMES: Tuple[str, ...] = (
37
+ DEFAULT_MANIFEST_FILENAMES: tuple[str, ...] = (
38
38
  "xwlazy.manifest.json",
39
39
  "lazy.manifest.json",
40
40
  ".xwlazy.manifest.json",
@@ -53,8 +53,8 @@ def _normalize_prefix(prefix: str) -> str:
53
53
  prefix = f"{prefix}."
54
54
  return prefix
55
55
 
56
- def _normalize_wrap_hints(values: Iterable[Any]) -> List[str]:
57
- hints: List[str] = []
56
+ def _normalize_wrap_hints(values: Iterable[Any]) -> list[str]:
57
+ hints: list[str] = []
58
58
  for value in values:
59
59
  if value is None:
60
60
  continue
@@ -79,18 +79,18 @@ class LazyManifestLoader:
79
79
  def __init__(
80
80
  self,
81
81
  default_root: Optional[Path] = None,
82
- package_roots: Optional[Dict[str, Path]] = None,
82
+ package_roots: Optional[dict[str, Path]] = None,
83
83
  ) -> None:
84
84
  self._default_root = default_root
85
85
  self._provided_roots = {
86
86
  _normalize_package_name(name): Path(path)
87
87
  for name, path in (package_roots or {}).items()
88
88
  }
89
- self._manifest_cache: Dict[str, PackageManifest] = {}
90
- self._source_signatures: Dict[str, Tuple[str, float, float]] = {}
91
- self._pyproject_cache: Dict[Path, Tuple[float, Dict[str, Any]]] = {}
92
- self._shared_dependency_maps: Dict[
93
- Tuple[str, float, float], Dict[str, Dict[str, str]]
89
+ self._manifest_cache: dict[str, PackageManifest] = {}
90
+ self._source_signatures: dict[str, tuple[str, float, float]] = {}
91
+ self._pyproject_cache: dict[Path, tuple[float, dict[str, Any]]] = {}
92
+ self._shared_dependency_maps: dict[
93
+ tuple[str, float, float], dict[str, dict[str, str]]
94
94
  ] = {}
95
95
  self._lock = RLock()
96
96
  self._generation = 0
@@ -163,7 +163,7 @@ class LazyManifestLoader:
163
163
  self._generation += 1
164
164
  return manifest
165
165
 
166
- def get_manifest_signature(self, package_name: Optional[str]) -> Optional[Tuple[str, float, float]]:
166
+ def get_manifest_signature(self, package_name: Optional[str]) -> Optional[tuple[str, float, float]]:
167
167
  key = _normalize_package_name(package_name)
168
168
  with self._lock:
169
169
  signature = self._source_signatures.get(key)
@@ -177,8 +177,8 @@ class LazyManifestLoader:
177
177
  def get_shared_dependencies(
178
178
  self,
179
179
  package_name: Optional[str],
180
- signature: Optional[Tuple[str, float, float]],
181
- ) -> Optional[Dict[str, str]]:
180
+ signature: Optional[tuple[str, float, float]],
181
+ ) -> Optional[dict[str, str]]:
182
182
  if signature is None:
183
183
  return None
184
184
  with self._lock:
@@ -222,7 +222,7 @@ class LazyManifestLoader:
222
222
  )
223
223
  return manifest
224
224
 
225
- def _compute_signature(self, package_key: str) -> Optional[Tuple[str, float, float]]:
225
+ def _compute_signature(self, package_key: str) -> Optional[tuple[str, float, float]]:
226
226
  root = self._resolve_project_root(package_key)
227
227
  pyproject_path = root / "pyproject.toml"
228
228
  pyproject_mtime = pyproject_path.stat().st_mtime if pyproject_path.exists() else 0.0
@@ -258,20 +258,27 @@ class LazyManifestLoader:
258
258
 
259
259
  @staticmethod
260
260
  def _walk_to_project_root(start: Path) -> Optional[Path]:
261
- current = start
262
- while True:
263
- if (current / "pyproject.toml").exists():
264
- return current
265
- parent = current.parent
266
- if parent == current:
267
- break
268
- current = parent
269
- return None
261
+ """Walk up from start path to find project root."""
262
+ from ...common.utils import find_project_root
263
+ # Use utility function, but start from the provided path
264
+ try:
265
+ return find_project_root(start)
266
+ except Exception:
267
+ # Fallback: simple walk-up logic
268
+ current = start
269
+ while True:
270
+ if (current / "pyproject.toml").exists():
271
+ return current
272
+ parent = current.parent
273
+ if parent == current:
274
+ break
275
+ current = parent
276
+ return None
270
277
 
271
278
  # ------------------------------- #
272
279
  # Pyproject helpers
273
280
  # ------------------------------- #
274
- def _load_pyproject(self, path: Path) -> Dict[str, Any]:
281
+ def _load_pyproject(self, path: Path) -> dict[str, Any]:
275
282
  if not path.exists() or tomllib is None:
276
283
  return {}
277
284
 
@@ -291,9 +298,9 @@ class LazyManifestLoader:
291
298
 
292
299
  def _extract_pyproject_entry(
293
300
  self,
294
- pyproject_data: Dict[str, Any],
301
+ pyproject_data: dict[str, Any],
295
302
  package_key: str,
296
- ) -> Dict[str, Any]:
303
+ ) -> dict[str, Any]:
297
304
  tool_section = pyproject_data.get("tool", {})
298
305
  lazy_section = tool_section.get("xwlazy", {})
299
306
  packages = lazy_section.get("packages", {})
@@ -362,8 +369,8 @@ class LazyManifestLoader:
362
369
  def _load_json_manifest(
363
370
  self,
364
371
  root: Path,
365
- pyproject_data: Dict[str, Any],
366
- ) -> Tuple[Dict[str, Any], Optional[Path]]:
372
+ pyproject_data: dict[str, Any],
373
+ ) -> tuple[dict[str, Any], Optional[Path]]:
367
374
  manifest_path = self._resolve_manifest_path(root, root / "pyproject.toml")
368
375
  if not manifest_path:
369
376
  return {}, None
@@ -381,12 +388,12 @@ class LazyManifestLoader:
381
388
  def _merge_sources(
382
389
  self,
383
390
  package_key: str,
384
- pyproject_data: Dict[str, Any],
385
- json_data: Dict[str, Any],
386
- ) -> Dict[str, Any]:
387
- merged_dependencies: Dict[str, str] = {}
388
- merged_watched: List[str] = []
389
- merged_wrap_hints: List[str] = []
391
+ pyproject_data: dict[str, Any],
392
+ json_data: dict[str, Any],
393
+ ) -> dict[str, Any]:
394
+ merged_dependencies: dict[str, str] = {}
395
+ merged_watched: list[str] = []
396
+ merged_wrap_hints: list[str] = []
390
397
 
391
398
  # Pyproject first (acts as baseline)
392
399
  py_entry = self._extract_pyproject_entry(pyproject_data, package_key)
@@ -436,8 +443,8 @@ class LazyManifestLoader:
436
443
  if "async_workers" in entry:
437
444
  async_workers = entry.get("async_workers", async_workers)
438
445
 
439
- seen_wrap: Set[str] = set()
440
- ordered_wrap_hints: List[str] = []
446
+ seen_wrap: set[str] = set()
447
+ ordered_wrap_hints: list[str] = []
441
448
  for hint in merged_wrap_hints:
442
449
  if hint not in seen_wrap:
443
450
  seen_wrap.add(hint)
@@ -11,7 +11,7 @@ Registry to store custom strategies per package for both package and module oper
11
11
  """
12
12
 
13
13
  import threading
14
- from typing import Dict, Optional, Any, TYPE_CHECKING
14
+ from typing import Optional, Any, TYPE_CHECKING
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from ...contracts import (
@@ -23,22 +23,39 @@ if TYPE_CHECKING:
23
23
  IModuleHelperStrategy,
24
24
  IModuleManagerStrategy,
25
25
  ICachingStrategy,
26
+ IInstallStrategy,
27
+ ILoadStrategy,
28
+ ICacheStrategy,
26
29
  )
27
30
 
28
31
  class StrategyRegistry:
29
- """Registry to store custom strategies per package."""
32
+ """
33
+ Registry to store custom strategies per package and enable runtime strategy swapping.
30
34
 
31
- # Package strategies
32
- _package_execution_strategies: Dict[str, 'IInstallExecutionStrategy'] = {}
33
- _package_timing_strategies: Dict[str, 'IInstallTimingStrategy'] = {}
34
- _package_discovery_strategies: Dict[str, 'IDiscoveryStrategy'] = {}
35
- _package_policy_strategies: Dict[str, 'IPolicyStrategy'] = {}
36
- _package_mapping_strategies: Dict[str, 'IMappingStrategy'] = {}
35
+ Supports both:
36
+ - Per-package strategies (different strategies for different packages)
37
+ - Global strategy swapping (change default strategies used by all packages)
38
+ """
37
39
 
38
- # Module strategies
39
- _module_helper_strategies: Dict[str, 'IModuleHelperStrategy'] = {}
40
- _module_manager_strategies: Dict[str, 'IModuleManagerStrategy'] = {}
41
- _module_caching_strategies: Dict[str, 'ICachingStrategy'] = {}
40
+ # Package strategies (per-package)
41
+ _package_execution_strategies: dict[str, 'IInstallExecutionStrategy'] = {}
42
+ _package_timing_strategies: dict[str, 'IInstallTimingStrategy'] = {}
43
+ _package_discovery_strategies: dict[str, 'IDiscoveryStrategy'] = {}
44
+ _package_policy_strategies: dict[str, 'IPolicyStrategy'] = {}
45
+ _package_mapping_strategies: dict[str, 'IMappingStrategy'] = {}
46
+
47
+ # Module strategies (per-package)
48
+ _module_helper_strategies: dict[str, 'IModuleHelperStrategy'] = {}
49
+ _module_manager_strategies: dict[str, 'IModuleManagerStrategy'] = {}
50
+ _module_caching_strategies: dict[str, 'ICachingStrategy'] = {}
51
+
52
+ # Global strategies (for runtime swapping)
53
+ _global_install_strategies: dict[str, 'IInstallStrategy'] = {}
54
+ _global_load_strategies: dict[str, 'ILoadStrategy'] = {}
55
+ _global_cache_strategies: dict[str, 'ICacheStrategy'] = {}
56
+ _default_install_strategy: Optional[str] = None
57
+ _default_load_strategy: Optional[str] = None
58
+ _default_cache_strategy: Optional[str] = None
42
59
 
43
60
  _lock = threading.RLock()
44
61
 
@@ -181,6 +198,127 @@ class StrategyRegistry:
181
198
  """Clear all strategies (package and module) for a package."""
182
199
  cls.clear_package_strategies(package_name)
183
200
  cls.clear_module_strategies(package_name)
201
+
202
+ # ========================================================================
203
+ # Global Strategy Management (for runtime swapping)
204
+ # ========================================================================
205
+
206
+ @classmethod
207
+ def register_install_strategy(cls, name: str, strategy: 'IInstallStrategy') -> None:
208
+ """
209
+ Register a global installation strategy for runtime swapping.
210
+
211
+ Args:
212
+ name: Strategy name (e.g., 'pip', 'wheel', 'async', 'cached')
213
+ strategy: Strategy instance implementing IInstallStrategy
214
+ """
215
+ with cls._lock:
216
+ cls._global_install_strategies[name] = strategy
217
+ if cls._default_install_strategy is None:
218
+ cls._default_install_strategy = name
219
+
220
+ @classmethod
221
+ def get_install_strategy(cls, name: Optional[str] = None) -> Optional['IInstallStrategy']:
222
+ """
223
+ Get global installation strategy by name.
224
+
225
+ Args:
226
+ name: Strategy name (uses default if None)
227
+
228
+ Returns:
229
+ Strategy instance or None if not found
230
+ """
231
+ with cls._lock:
232
+ if name is None:
233
+ name = cls._default_install_strategy
234
+ return cls._global_install_strategies.get(name) if name else None
235
+
236
+ @classmethod
237
+ def register_load_strategy(cls, name: str, strategy: 'ILoadStrategy') -> None:
238
+ """
239
+ Register a global loading strategy for runtime swapping.
240
+
241
+ Args:
242
+ name: Strategy name (e.g., 'lazy', 'simple', 'advanced')
243
+ strategy: Strategy instance implementing ILoadStrategy
244
+ """
245
+ with cls._lock:
246
+ cls._global_load_strategies[name] = strategy
247
+ if cls._default_load_strategy is None:
248
+ cls._default_load_strategy = name
249
+
250
+ @classmethod
251
+ def get_load_strategy(cls, name: Optional[str] = None) -> Optional['ILoadStrategy']:
252
+ """
253
+ Get global loading strategy by name.
254
+
255
+ Args:
256
+ name: Strategy name (uses default if None)
257
+
258
+ Returns:
259
+ Strategy instance or None if not found
260
+ """
261
+ with cls._lock:
262
+ if name is None:
263
+ name = cls._default_load_strategy
264
+ return cls._global_load_strategies.get(name) if name else None
265
+
266
+ @classmethod
267
+ def register_cache_strategy(cls, name: str, strategy: 'ICacheStrategy') -> None:
268
+ """
269
+ Register a global caching strategy for runtime swapping.
270
+
271
+ Args:
272
+ name: Strategy name (e.g., 'lru', 'lfu', 'ttl', 'multitier')
273
+ strategy: Strategy instance implementing ICacheStrategy
274
+ """
275
+ with cls._lock:
276
+ cls._global_cache_strategies[name] = strategy
277
+ if cls._default_cache_strategy is None:
278
+ cls._default_cache_strategy = name
279
+
280
+ @classmethod
281
+ def get_cache_strategy(cls, name: Optional[str] = None) -> Optional['ICacheStrategy']:
282
+ """
283
+ Get global caching strategy by name.
284
+
285
+ Args:
286
+ name: Strategy name (uses default if None)
287
+
288
+ Returns:
289
+ Strategy instance or None if not found
290
+ """
291
+ with cls._lock:
292
+ if name is None:
293
+ name = cls._default_cache_strategy
294
+ return cls._global_cache_strategies.get(name) if name else None
295
+
296
+ @classmethod
297
+ def swap_install_strategy(cls, name: str) -> bool:
298
+ """Swap to a different global installation strategy."""
299
+ with cls._lock:
300
+ if name in cls._global_install_strategies:
301
+ cls._default_install_strategy = name
302
+ return True
303
+ return False
304
+
305
+ @classmethod
306
+ def swap_load_strategy(cls, name: str) -> bool:
307
+ """Swap to a different global loading strategy."""
308
+ with cls._lock:
309
+ if name in cls._global_load_strategies:
310
+ cls._default_load_strategy = name
311
+ return True
312
+ return False
313
+
314
+ @classmethod
315
+ def swap_cache_strategy(cls, name: str) -> bool:
316
+ """Swap to a different global caching strategy."""
317
+ with cls._lock:
318
+ if name in cls._global_cache_strategies:
319
+ cls._default_cache_strategy = name
320
+ return True
321
+ return False
184
322
 
185
323
  __all__ = ['StrategyRegistry']
186
324
 
@@ -11,7 +11,7 @@ File-based discovery - discovers dependencies from project files.
11
11
  """
12
12
 
13
13
  from pathlib import Path
14
- from typing import Dict, Optional, Any
14
+ from typing import Optional, Any
15
15
  from ...package.base import ADiscoveryStrategy
16
16
 
17
17
  class FileBasedDiscovery(ADiscoveryStrategy):
@@ -49,7 +49,7 @@ class FileBasedDiscovery(ADiscoveryStrategy):
49
49
 
50
50
  return cwd
51
51
 
52
- def discover(self, project_root: Any = None) -> Dict[str, str]:
52
+ def discover(self, project_root: Any = None) -> dict[str, str]:
53
53
  """
54
54
  Discover dependencies from project files.
55
55
 
@@ -11,7 +11,7 @@ Hybrid discovery - combines file-based and manifest-based discovery.
11
11
  """
12
12
 
13
13
  from pathlib import Path
14
- from typing import Dict, Optional, Any
14
+ from typing import Optional, Any
15
15
  from ...package.base import ADiscoveryStrategy
16
16
  from .package_discovery_file import FileBasedDiscovery
17
17
  from .package_discovery_manifest import ManifestBasedDiscovery
@@ -36,7 +36,7 @@ class HybridDiscovery(ADiscoveryStrategy):
36
36
  self._file_discovery = FileBasedDiscovery(project_root)
37
37
  self._manifest_discovery = ManifestBasedDiscovery(package_name, project_root)
38
38
 
39
- def discover(self, project_root: Any = None) -> Dict[str, str]:
39
+ def discover(self, project_root: Any = None) -> dict[str, str]:
40
40
  """
41
41
  Discover dependencies from all sources.
42
42
 
@@ -11,7 +11,7 @@ Manifest-based discovery - discovers dependencies from manifest files.
11
11
  """
12
12
 
13
13
  from pathlib import Path
14
- from typing import Dict, Optional, Any
14
+ from typing import Optional, Any
15
15
  from ...package.base import ADiscoveryStrategy
16
16
 
17
17
  class ManifestBasedDiscovery(ADiscoveryStrategy):
@@ -51,7 +51,7 @@ class ManifestBasedDiscovery(ADiscoveryStrategy):
51
51
 
52
52
  return cwd
53
53
 
54
- def discover(self, project_root: Any = None) -> Dict[str, str]:
54
+ def discover(self, project_root: Any = None) -> dict[str, str]:
55
55
  """
56
56
  Discover dependencies from manifest files.
57
57