exonware-xwlazy 0.1.0.22__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.
- exonware/__init__.py +20 -1
- exonware/xwlazy/__init__.py +14 -2
- exonware/xwlazy/common/__init__.py +8 -0
- exonware/xwlazy/common/base.py +11 -2
- exonware/xwlazy/common/cache.py +5 -5
- exonware/xwlazy/common/logger.py +5 -5
- exonware/xwlazy/common/services/dependency_mapper.py +31 -13
- exonware/xwlazy/common/services/install_async_utils.py +5 -0
- exonware/xwlazy/common/services/install_cache_utils.py +4 -4
- exonware/xwlazy/common/services/spec_cache.py +2 -2
- exonware/xwlazy/common/services/state_manager.py +4 -4
- exonware/xwlazy/common/strategies/caching_dict.py +2 -2
- exonware/xwlazy/common/strategies/caching_lfu.py +2 -2
- exonware/xwlazy/common/strategies/caching_ttl.py +2 -2
- exonware/xwlazy/common/utils.py +142 -0
- exonware/xwlazy/config.py +1 -1
- exonware/xwlazy/contracts.py +162 -25
- exonware/xwlazy/defs.py +15 -15
- exonware/xwlazy/facade.py +175 -29
- exonware/xwlazy/host/__init__.py +8 -0
- exonware/xwlazy/host/conf.py +16 -0
- exonware/xwlazy/module/base.py +61 -4
- exonware/xwlazy/module/facade.py +1 -1
- exonware/xwlazy/module/importer_engine.py +1017 -170
- exonware/xwlazy/module/partial_module_detector.py +275 -0
- exonware/xwlazy/module/strategies/module_helper_lazy.py +3 -3
- exonware/xwlazy/package/base.py +106 -41
- exonware/xwlazy/package/conf.py +6 -6
- exonware/xwlazy/package/services/config_manager.py +20 -16
- exonware/xwlazy/package/services/discovery.py +81 -16
- exonware/xwlazy/package/services/host_packages.py +41 -6
- exonware/xwlazy/package/services/install_async.py +16 -2
- exonware/xwlazy/package/services/install_cache.py +4 -4
- exonware/xwlazy/package/services/install_policy.py +14 -14
- exonware/xwlazy/package/services/install_registry.py +3 -3
- exonware/xwlazy/package/services/install_sbom.py +1 -1
- exonware/xwlazy/package/services/installer_engine.py +3 -3
- exonware/xwlazy/package/services/lazy_installer.py +102 -17
- exonware/xwlazy/package/services/manifest.py +43 -36
- exonware/xwlazy/package/services/strategy_registry.py +150 -12
- exonware/xwlazy/package/strategies/package_discovery_file.py +2 -2
- exonware/xwlazy/package/strategies/package_discovery_hybrid.py +2 -2
- exonware/xwlazy/package/strategies/package_discovery_manifest.py +2 -2
- exonware/xwlazy/package/strategies/package_execution_async.py +3 -3
- exonware/xwlazy/package/strategies/package_execution_cached.py +2 -2
- exonware/xwlazy/package/strategies/package_execution_pip.py +2 -2
- exonware/xwlazy/package/strategies/package_execution_wheel.py +2 -2
- exonware/xwlazy/package/strategies/package_mapping_discovery_first.py +2 -2
- exonware/xwlazy/package/strategies/package_mapping_hybrid.py +2 -2
- exonware/xwlazy/package/strategies/package_mapping_manifest_first.py +2 -2
- exonware/xwlazy/package/strategies/package_policy_allow_list.py +4 -4
- exonware/xwlazy/package/strategies/package_policy_deny_list.py +4 -4
- exonware/xwlazy/package/strategies/package_policy_permissive.py +3 -3
- exonware/xwlazy/package/strategies/package_timing_clean.py +2 -2
- exonware/xwlazy/package/strategies/package_timing_full.py +2 -2
- exonware/xwlazy/package/strategies/package_timing_smart.py +2 -2
- exonware/xwlazy/package/strategies/package_timing_temporary.py +2 -2
- exonware/xwlazy/runtime/adaptive_learner.py +7 -7
- exonware/xwlazy/runtime/base.py +14 -14
- exonware/xwlazy/runtime/facade.py +7 -7
- exonware/xwlazy/runtime/intelligent_selector.py +6 -6
- exonware/xwlazy/runtime/metrics.py +6 -6
- exonware/xwlazy/runtime/performance.py +5 -5
- exonware/xwlazy/version.py +2 -2
- {exonware_xwlazy-0.1.0.22.dist-info → exonware_xwlazy-0.1.0.23.dist-info}/METADATA +2 -6
- exonware_xwlazy-0.1.0.23.dist-info/RECORD +93 -0
- xwlazy/__init__.py +14 -0
- xwlazy/lazy.py +30 -0
- exonware_xwlazy-0.1.0.22.dist-info/RECORD +0 -87
- {exonware_xwlazy-0.1.0.22.dist-info → exonware_xwlazy-0.1.0.23.dist-info}/WHEEL +0 -0
- {exonware_xwlazy-0.1.0.22.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
|
|
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:
|
|
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) ->
|
|
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
|
-
|
|
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.
|
|
493
|
-
# Cache
|
|
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
|
-
|
|
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.
|
|
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) ->
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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,
|
|
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:
|
|
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]) ->
|
|
57
|
-
hints:
|
|
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[
|
|
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:
|
|
90
|
-
self._source_signatures:
|
|
91
|
-
self._pyproject_cache:
|
|
92
|
-
self._shared_dependency_maps:
|
|
93
|
-
|
|
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[
|
|
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[
|
|
181
|
-
) -> Optional[
|
|
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[
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
current =
|
|
269
|
-
|
|
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) ->
|
|
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:
|
|
301
|
+
pyproject_data: dict[str, Any],
|
|
295
302
|
package_key: str,
|
|
296
|
-
) ->
|
|
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:
|
|
366
|
-
) ->
|
|
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:
|
|
385
|
-
json_data:
|
|
386
|
-
) ->
|
|
387
|
-
merged_dependencies:
|
|
388
|
-
merged_watched:
|
|
389
|
-
merged_wrap_hints:
|
|
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:
|
|
440
|
-
ordered_wrap_hints:
|
|
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
|
|
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
|
-
"""
|
|
32
|
+
"""
|
|
33
|
+
Registry to store custom strategies per package and enable runtime strategy swapping.
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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) ->
|
|
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
|
|
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) ->
|
|
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
|
|
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) ->
|
|
54
|
+
def discover(self, project_root: Any = None) -> dict[str, str]:
|
|
55
55
|
"""
|
|
56
56
|
Discover dependencies from manifest files.
|
|
57
57
|
|