exonware-xwlazy 0.1.0.10__py3-none-any.whl → 0.1.0.19__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 +22 -0
- exonware/xwlazy/__init__.py +0 -0
- exonware/xwlazy/common/__init__.py +47 -0
- exonware/xwlazy/common/base.py +58 -0
- exonware/xwlazy/common/cache.py +506 -0
- exonware/xwlazy/common/logger.py +268 -0
- exonware/xwlazy/common/services/__init__.py +72 -0
- exonware/xwlazy/common/services/dependency_mapper.py +234 -0
- exonware/xwlazy/common/services/install_async_utils.py +169 -0
- exonware/xwlazy/common/services/install_cache_utils.py +257 -0
- exonware/xwlazy/common/services/keyword_detection.py +292 -0
- exonware/xwlazy/common/services/spec_cache.py +173 -0
- exonware/xwlazy/common/services/state_manager.py +86 -0
- exonware/xwlazy/common/strategies/__init__.py +28 -0
- exonware/xwlazy/common/strategies/caching_dict.py +45 -0
- exonware/xwlazy/common/strategies/caching_installation.py +89 -0
- exonware/xwlazy/common/strategies/caching_lfu.py +67 -0
- exonware/xwlazy/common/strategies/caching_lru.py +64 -0
- exonware/xwlazy/common/strategies/caching_multitier.py +60 -0
- exonware/xwlazy/common/strategies/caching_ttl.py +60 -0
- exonware/xwlazy/config.py +195 -0
- exonware/xwlazy/contracts.py +1410 -0
- exonware/xwlazy/defs.py +397 -0
- exonware/xwlazy/errors.py +284 -0
- exonware/xwlazy/facade.py +1049 -0
- exonware/xwlazy/module/__init__.py +18 -0
- exonware/xwlazy/module/base.py +569 -0
- exonware/xwlazy/module/data.py +17 -0
- exonware/xwlazy/module/facade.py +247 -0
- exonware/xwlazy/module/importer_engine.py +2161 -0
- exonware/xwlazy/module/strategies/__init__.py +22 -0
- exonware/xwlazy/module/strategies/module_helper_lazy.py +94 -0
- exonware/xwlazy/module/strategies/module_helper_simple.py +66 -0
- exonware/xwlazy/module/strategies/module_manager_advanced.py +112 -0
- exonware/xwlazy/module/strategies/module_manager_simple.py +96 -0
- exonware/xwlazy/package/__init__.py +18 -0
- exonware/xwlazy/package/base.py +807 -0
- exonware/xwlazy/package/conf.py +331 -0
- exonware/xwlazy/package/data.py +17 -0
- exonware/xwlazy/package/facade.py +481 -0
- exonware/xwlazy/package/services/__init__.py +84 -0
- exonware/xwlazy/package/services/async_install_handle.py +89 -0
- exonware/xwlazy/package/services/config_manager.py +246 -0
- exonware/xwlazy/package/services/discovery.py +374 -0
- exonware/xwlazy/package/services/host_packages.py +149 -0
- exonware/xwlazy/package/services/install_async.py +278 -0
- exonware/xwlazy/package/services/install_cache.py +146 -0
- exonware/xwlazy/package/services/install_interactive.py +60 -0
- exonware/xwlazy/package/services/install_policy.py +158 -0
- exonware/xwlazy/package/services/install_registry.py +56 -0
- exonware/xwlazy/package/services/install_result.py +17 -0
- exonware/xwlazy/package/services/install_sbom.py +154 -0
- exonware/xwlazy/package/services/install_utils.py +83 -0
- exonware/xwlazy/package/services/installer_engine.py +408 -0
- exonware/xwlazy/package/services/lazy_installer.py +720 -0
- exonware/xwlazy/package/services/manifest.py +506 -0
- exonware/xwlazy/package/services/strategy_registry.py +188 -0
- exonware/xwlazy/package/strategies/__init__.py +57 -0
- exonware/xwlazy/package/strategies/package_discovery_file.py +130 -0
- exonware/xwlazy/package/strategies/package_discovery_hybrid.py +85 -0
- exonware/xwlazy/package/strategies/package_discovery_manifest.py +102 -0
- exonware/xwlazy/package/strategies/package_execution_async.py +114 -0
- exonware/xwlazy/package/strategies/package_execution_cached.py +91 -0
- exonware/xwlazy/package/strategies/package_execution_pip.py +100 -0
- exonware/xwlazy/package/strategies/package_execution_wheel.py +107 -0
- exonware/xwlazy/package/strategies/package_mapping_discovery_first.py +101 -0
- exonware/xwlazy/package/strategies/package_mapping_hybrid.py +106 -0
- exonware/xwlazy/package/strategies/package_mapping_manifest_first.py +101 -0
- exonware/xwlazy/package/strategies/package_policy_allow_list.py +58 -0
- exonware/xwlazy/package/strategies/package_policy_deny_list.py +58 -0
- exonware/xwlazy/package/strategies/package_policy_permissive.py +47 -0
- exonware/xwlazy/package/strategies/package_timing_clean.py +68 -0
- exonware/xwlazy/package/strategies/package_timing_full.py +67 -0
- exonware/xwlazy/package/strategies/package_timing_smart.py +69 -0
- exonware/xwlazy/package/strategies/package_timing_temporary.py +67 -0
- exonware/xwlazy/runtime/__init__.py +18 -0
- exonware/xwlazy/runtime/adaptive_learner.py +131 -0
- exonware/xwlazy/runtime/base.py +276 -0
- exonware/xwlazy/runtime/facade.py +95 -0
- exonware/xwlazy/runtime/intelligent_selector.py +173 -0
- exonware/xwlazy/runtime/metrics.py +64 -0
- exonware/xwlazy/runtime/performance.py +39 -0
- exonware/xwlazy/version.py +2 -2
- exonware_xwlazy-0.1.0.19.dist-info/METADATA +456 -0
- exonware_xwlazy-0.1.0.19.dist-info/RECORD +87 -0
- exonware_xwlazy-0.1.0.10.dist-info/METADATA +0 -0
- exonware_xwlazy-0.1.0.10.dist-info/RECORD +0 -6
- {exonware_xwlazy-0.1.0.10.dist-info → exonware_xwlazy-0.1.0.19.dist-info}/WHEEL +0 -0
- {exonware_xwlazy-0.1.0.10.dist-info → exonware_xwlazy-0.1.0.19.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lazy Installer
|
|
3
|
+
|
|
4
|
+
Company: eXonware.com
|
|
5
|
+
Author: Eng. Muhammad AlShehri
|
|
6
|
+
Email: connect@exonware.com
|
|
7
|
+
Version: 0.1.0.19
|
|
8
|
+
Generation Date: 15-Nov-2025
|
|
9
|
+
|
|
10
|
+
Lazy installer that automatically installs missing packages on import failure.
|
|
11
|
+
Each instance is isolated per package to prevent interference.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import asyncio
|
|
18
|
+
import threading
|
|
19
|
+
import subprocess
|
|
20
|
+
import importlib
|
|
21
|
+
import importlib.util
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional, Tuple, Set, Any
|
|
24
|
+
from collections import OrderedDict
|
|
25
|
+
from types import ModuleType
|
|
26
|
+
|
|
27
|
+
from ..base import APackageHelper
|
|
28
|
+
from .config_manager import LazyInstallConfig
|
|
29
|
+
from .manifest import PackageManifest
|
|
30
|
+
from ...defs import LazyInstallMode
|
|
31
|
+
from ...common.cache import InstallationCache
|
|
32
|
+
from .install_policy import LazyInstallPolicy
|
|
33
|
+
from .install_utils import (
|
|
34
|
+
get_trigger_file,
|
|
35
|
+
is_externally_managed,
|
|
36
|
+
check_pip_audit_available
|
|
37
|
+
)
|
|
38
|
+
from .async_install_handle import AsyncInstallHandle
|
|
39
|
+
from .install_interactive import InteractiveInstallMixin
|
|
40
|
+
from .install_cache import InstallCacheMixin
|
|
41
|
+
from .install_async import AsyncInstallMixin
|
|
42
|
+
from .install_sbom import SBOMAuditMixin
|
|
43
|
+
|
|
44
|
+
# Lazy import for DependencyMapper to avoid circular dependency
|
|
45
|
+
def _get_dependency_mapper():
|
|
46
|
+
"""Get DependencyMapper (lazy import to avoid circular dependency)."""
|
|
47
|
+
from ...common.services.dependency_mapper import DependencyMapper
|
|
48
|
+
return DependencyMapper
|
|
49
|
+
|
|
50
|
+
DependencyMapper = None # Will be initialized on first use
|
|
51
|
+
|
|
52
|
+
# Lazy imports to avoid circular dependency
|
|
53
|
+
def _get_logger():
|
|
54
|
+
"""Get logger (lazy import to avoid circular dependency)."""
|
|
55
|
+
from ...common.logger import get_logger
|
|
56
|
+
return get_logger("xwlazy.lazy_installer")
|
|
57
|
+
|
|
58
|
+
def _get_log_event():
|
|
59
|
+
"""Get log_event function (lazy import to avoid circular dependency)."""
|
|
60
|
+
from ...common.logger import log_event
|
|
61
|
+
return log_event
|
|
62
|
+
|
|
63
|
+
def _get_print_formatted():
|
|
64
|
+
"""Get print_formatted function (lazy import to avoid circular dependency)."""
|
|
65
|
+
from ...common.logger import print_formatted
|
|
66
|
+
return print_formatted
|
|
67
|
+
|
|
68
|
+
def _get_installing_state():
|
|
69
|
+
"""Get installing state (lazy import to avoid circular dependency)."""
|
|
70
|
+
from ...module.importer_engine import get_installing_state
|
|
71
|
+
return get_installing_state()
|
|
72
|
+
|
|
73
|
+
def _get_spec_cache_put():
|
|
74
|
+
"""Get spec_cache_put function (lazy import to avoid circular dependency)."""
|
|
75
|
+
from ...common.services.spec_cache import _spec_cache_put
|
|
76
|
+
return _spec_cache_put
|
|
77
|
+
|
|
78
|
+
def _get_spec_cache_clear():
|
|
79
|
+
"""Get spec_cache_clear function (lazy import to avoid circular dependency)."""
|
|
80
|
+
from ...common.services.spec_cache import _spec_cache_clear
|
|
81
|
+
return _spec_cache_clear
|
|
82
|
+
|
|
83
|
+
logger = None # Will be initialized on first use
|
|
84
|
+
_log = None # Will be initialized on first use
|
|
85
|
+
print_formatted = None # Will be initialized on first use
|
|
86
|
+
_installing = None # Will be initialized on first use
|
|
87
|
+
_spec_cache_put = None # Will be initialized on first use
|
|
88
|
+
_spec_cache_clear = None # Will be initialized on first use
|
|
89
|
+
|
|
90
|
+
# Environment variables
|
|
91
|
+
_ENV_ASYNC_INSTALL = os.environ.get("XWLAZY_ASYNC_INSTALL", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
92
|
+
_ENV_ASYNC_WORKERS = int(os.environ.get("XWLAZY_ASYNC_WORKERS", "0") or 0)
|
|
93
|
+
_KNOWN_MISSING_CACHE_LIMIT = int(os.environ.get("XWLAZY_MISSING_CACHE_MAX", "128") or 128)
|
|
94
|
+
_KNOWN_MISSING_CACHE_TTL = float(os.environ.get("XWLAZY_MISSING_CACHE_TTL", "120") or 120.0)
|
|
95
|
+
_DEFAULT_ASYNC_CACHE_DIR = Path(
|
|
96
|
+
os.environ.get(
|
|
97
|
+
"XWLAZY_ASYNC_CACHE_DIR",
|
|
98
|
+
os.path.join(os.path.expanduser("~"), ".xwlazy", "wheel-cache"),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _ensure_logging_initialized():
|
|
103
|
+
"""Ensure logging utilities are initialized (lazy init to avoid circular imports)."""
|
|
104
|
+
global logger, _log, print_formatted, _installing, _spec_cache_put, _spec_cache_clear
|
|
105
|
+
if logger is None:
|
|
106
|
+
logger = _get_logger()
|
|
107
|
+
if _log is None:
|
|
108
|
+
_log = _get_log_event()
|
|
109
|
+
if print_formatted is None:
|
|
110
|
+
print_formatted = _get_print_formatted()
|
|
111
|
+
if _installing is None:
|
|
112
|
+
_installing = _get_installing_state()
|
|
113
|
+
if _spec_cache_put is None:
|
|
114
|
+
_spec_cache_put = _get_spec_cache_put()
|
|
115
|
+
if _spec_cache_clear is None:
|
|
116
|
+
_spec_cache_clear = _get_spec_cache_clear()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class LazyInstaller(
|
|
120
|
+
APackageHelper,
|
|
121
|
+
InteractiveInstallMixin,
|
|
122
|
+
InstallCacheMixin,
|
|
123
|
+
AsyncInstallMixin,
|
|
124
|
+
SBOMAuditMixin
|
|
125
|
+
):
|
|
126
|
+
"""
|
|
127
|
+
Lazy installer that automatically installs missing packages on import failure.
|
|
128
|
+
Each instance is isolated per package to prevent interference.
|
|
129
|
+
|
|
130
|
+
This class extends APackageHelper and provides comprehensive installation functionality.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
__slots__ = APackageHelper.__slots__ + (
|
|
134
|
+
'_dependency_mapper',
|
|
135
|
+
'_auto_approve_all',
|
|
136
|
+
'_async_enabled',
|
|
137
|
+
'_async_workers',
|
|
138
|
+
'_async_loop',
|
|
139
|
+
'_async_tasks',
|
|
140
|
+
'_known_missing',
|
|
141
|
+
'_async_cache_dir',
|
|
142
|
+
'_loop_thread',
|
|
143
|
+
'_install_cache',
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def __init__(self, package_name: str = 'default'):
|
|
147
|
+
"""Initialize lazy installer for a specific package."""
|
|
148
|
+
super().__init__(package_name)
|
|
149
|
+
# Lazy init to avoid circular dependency
|
|
150
|
+
global DependencyMapper
|
|
151
|
+
if DependencyMapper is None:
|
|
152
|
+
DependencyMapper = _get_dependency_mapper()
|
|
153
|
+
self._dependency_mapper = DependencyMapper(package_name)
|
|
154
|
+
self._auto_approve_all = False
|
|
155
|
+
self._async_enabled = False
|
|
156
|
+
self._async_workers = 1
|
|
157
|
+
self._async_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
158
|
+
self._async_tasks: Dict[str, Any] = {}
|
|
159
|
+
self._known_missing: OrderedDict[str, float] = OrderedDict()
|
|
160
|
+
self._async_cache_dir = _DEFAULT_ASYNC_CACHE_DIR
|
|
161
|
+
self._loop_thread: Optional[threading.Thread] = None
|
|
162
|
+
|
|
163
|
+
# ROOT CAUSE FIX: Load persistent installation cache
|
|
164
|
+
# This cache tracks installed packages across Python restarts
|
|
165
|
+
# and prevents unnecessary importability checks and installations
|
|
166
|
+
self._install_cache = InstallationCache()
|
|
167
|
+
|
|
168
|
+
def install_package(self, package_name: str, module_name: str = None) -> bool:
|
|
169
|
+
"""Install a package using pip."""
|
|
170
|
+
_ensure_logging_initialized()
|
|
171
|
+
# CRITICAL: Set flag FIRST before ANY operations to prevent recursion
|
|
172
|
+
if getattr(_installing, 'active', False):
|
|
173
|
+
print(f"[DEBUG] Installation already in progress, skipping {package_name} to prevent recursion")
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
# Check global recursion depth to prevent infinite recursion
|
|
177
|
+
from ...module.importer_engine import _installation_depth, _installation_depth_lock
|
|
178
|
+
with _installation_depth_lock:
|
|
179
|
+
if _installation_depth > 0:
|
|
180
|
+
print(f"[DEBUG] Installation recursion detected (depth={_installation_depth}), skipping {package_name}")
|
|
181
|
+
return False
|
|
182
|
+
_installation_depth += 1
|
|
183
|
+
|
|
184
|
+
# Set flag IMMEDIATELY to prevent any imports during installation from triggering recursion
|
|
185
|
+
_installing.active = True
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
with self._lock:
|
|
189
|
+
if package_name in self._installed_packages:
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
if package_name in self._failed_packages:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
if self._mode == LazyInstallMode.DISABLED or self._mode == LazyInstallMode.NONE:
|
|
196
|
+
_log("install", f"Lazy installation disabled for {self._package_name}, skipping {package_name}")
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
if self._mode == LazyInstallMode.WARN:
|
|
200
|
+
logger.warning(
|
|
201
|
+
f"[WARN] Package '{package_name}' is missing but WARN mode is active - not installing"
|
|
202
|
+
)
|
|
203
|
+
print(
|
|
204
|
+
f"[WARN] ({self._package_name}): Package '{package_name}' is missing "
|
|
205
|
+
f"(not installed in WARN mode)"
|
|
206
|
+
)
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
if self._mode == LazyInstallMode.DRY_RUN:
|
|
210
|
+
print(f"[DRY RUN] ({self._package_name}): Would install package '{package_name}'")
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
if self._mode == LazyInstallMode.INTERACTIVE:
|
|
214
|
+
if not self._ask_user_permission(package_name, module_name or package_name):
|
|
215
|
+
_log("install", f"User declined installation of {package_name}")
|
|
216
|
+
self._failed_packages.add(package_name)
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
# Security checks
|
|
220
|
+
if is_externally_managed():
|
|
221
|
+
logger.error(f"Cannot install {package_name}: Environment is externally managed (PEP 668)")
|
|
222
|
+
print(f"\n[ERROR] This Python environment is externally managed (PEP 668)")
|
|
223
|
+
print(f"Package '{package_name}' cannot be installed in this environment.")
|
|
224
|
+
print(f"\nSuggested solutions:")
|
|
225
|
+
print(f" 1. Create a virtual environment:")
|
|
226
|
+
print(f" python -m venv .venv")
|
|
227
|
+
print(f" .venv\\Scripts\\activate # Windows")
|
|
228
|
+
print(f" source .venv/bin/activate # Linux/macOS")
|
|
229
|
+
print(f" 2. Use pipx for isolated installs:")
|
|
230
|
+
print(f" pipx install {package_name}")
|
|
231
|
+
print(f" 3. Override with --break-system-packages (NOT RECOMMENDED)\n")
|
|
232
|
+
self._failed_packages.add(package_name)
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
allowed, reason = LazyInstallPolicy.is_package_allowed(self._package_name, package_name)
|
|
236
|
+
if not allowed:
|
|
237
|
+
logger.error(f"Cannot install {package_name}: {reason}")
|
|
238
|
+
print(f"\n[SECURITY] Package '{package_name}' blocked: {reason}\n")
|
|
239
|
+
self._failed_packages.add(package_name)
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
# Show warning about missing library with trigger file
|
|
243
|
+
trigger_file = get_trigger_file()
|
|
244
|
+
module_display = module_name or package_name
|
|
245
|
+
if trigger_file:
|
|
246
|
+
used_for = module_display if module_display != package_name else package_name
|
|
247
|
+
print_formatted(
|
|
248
|
+
"WARN",
|
|
249
|
+
f"Missing library {package_name} used for ({used_for}) triggered by {trigger_file}",
|
|
250
|
+
same_line=True
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
print_formatted(
|
|
254
|
+
"WARN",
|
|
255
|
+
f"Missing library {package_name} used for ({module_display})",
|
|
256
|
+
same_line=True
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Proceed with installation
|
|
260
|
+
try:
|
|
261
|
+
print_formatted("INFO", f"Installing package: {package_name}", same_line=True)
|
|
262
|
+
policy_args = LazyInstallPolicy.get_pip_args(self._package_name) or []
|
|
263
|
+
|
|
264
|
+
cache_args = list(policy_args)
|
|
265
|
+
if self._install_from_cached_tree(package_name):
|
|
266
|
+
print_formatted("ACTION", f"Installing {package_name} via pip...", same_line=True)
|
|
267
|
+
time.sleep(0.1)
|
|
268
|
+
self._finalize_install_success(package_name, "cache-tree")
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
if self._install_from_cached_wheel(package_name, cache_args):
|
|
272
|
+
print_formatted("ACTION", f"Installing {package_name} via pip...", same_line=True)
|
|
273
|
+
wheel_path = self._cached_wheel_name(package_name)
|
|
274
|
+
self._materialize_cached_tree(package_name, wheel_path)
|
|
275
|
+
time.sleep(0.1)
|
|
276
|
+
self._finalize_install_success(package_name, "cache")
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
wheel_path = self._ensure_cached_wheel(package_name, cache_args)
|
|
280
|
+
if wheel_path and self._pip_install_from_path(wheel_path, cache_args):
|
|
281
|
+
print_formatted("ACTION", f"Installing {package_name} via pip...", same_line=True)
|
|
282
|
+
self._materialize_cached_tree(package_name, wheel_path)
|
|
283
|
+
time.sleep(0.1)
|
|
284
|
+
self._finalize_install_success(package_name, "wheel")
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
# Show installation message with animated dots
|
|
288
|
+
print_formatted("ACTION", f"Installing {package_name} via pip...", same_line=True)
|
|
289
|
+
|
|
290
|
+
# Animate dots while installing
|
|
291
|
+
stop_animation = threading.Event()
|
|
292
|
+
|
|
293
|
+
def animate_dots():
|
|
294
|
+
dots = ["", ".", "..", "..."]
|
|
295
|
+
i = 0
|
|
296
|
+
while not stop_animation.is_set():
|
|
297
|
+
msg = f"Installing {package_name} via pip{dots[i % len(dots)]}"
|
|
298
|
+
print_formatted("ACTION", msg, same_line=True)
|
|
299
|
+
i += 1
|
|
300
|
+
time.sleep(0.3)
|
|
301
|
+
|
|
302
|
+
animator = threading.Thread(target=animate_dots, daemon=True)
|
|
303
|
+
animator.start()
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
pip_args = [sys.executable, '-m', 'pip', 'install']
|
|
307
|
+
if policy_args:
|
|
308
|
+
pip_args.extend(policy_args)
|
|
309
|
+
logger.debug(f"Using policy args: {policy_args}")
|
|
310
|
+
|
|
311
|
+
pip_args.append(package_name)
|
|
312
|
+
|
|
313
|
+
result = subprocess.run(
|
|
314
|
+
pip_args,
|
|
315
|
+
capture_output=True,
|
|
316
|
+
text=True,
|
|
317
|
+
check=True
|
|
318
|
+
)
|
|
319
|
+
finally:
|
|
320
|
+
stop_animation.set()
|
|
321
|
+
animator.join(timeout=0.5)
|
|
322
|
+
|
|
323
|
+
self._finalize_install_success(package_name, "pip")
|
|
324
|
+
wheel_path = self._ensure_cached_wheel(package_name, cache_args)
|
|
325
|
+
if wheel_path:
|
|
326
|
+
self._materialize_cached_tree(package_name, wheel_path)
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
except subprocess.CalledProcessError as e:
|
|
330
|
+
logger.error(f"Failed to install {package_name}: {e.stderr}")
|
|
331
|
+
print(f"[FAIL] Failed to install {package_name}\n")
|
|
332
|
+
self._failed_packages.add(package_name)
|
|
333
|
+
return False
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Unexpected error installing {package_name}: {e}")
|
|
336
|
+
print(f"[ERROR] Unexpected error: {e}\n")
|
|
337
|
+
self._failed_packages.add(package_name)
|
|
338
|
+
return False
|
|
339
|
+
finally:
|
|
340
|
+
# CRITICAL: Always clear the installing flag
|
|
341
|
+
_installing.active = False
|
|
342
|
+
# Decrement global recursion depth
|
|
343
|
+
from ...module.importer_engine import _installation_depth, _installation_depth_lock
|
|
344
|
+
with _installation_depth_lock:
|
|
345
|
+
_installation_depth = max(0, _installation_depth - 1)
|
|
346
|
+
|
|
347
|
+
def _finalize_install_success(self, package_name: str, source: str) -> None:
|
|
348
|
+
"""Finalize successful installation by updating caches."""
|
|
349
|
+
# Update in-memory cache
|
|
350
|
+
self._installed_packages.add(package_name)
|
|
351
|
+
|
|
352
|
+
# ROOT CAUSE FIX: Mark in persistent cache (survives Python restarts)
|
|
353
|
+
version = self._get_installed_version(package_name)
|
|
354
|
+
self._install_cache.mark_installed(package_name, version)
|
|
355
|
+
|
|
356
|
+
print_formatted("SUCCESS", f"Successfully installed via {source}: {package_name}", same_line=True)
|
|
357
|
+
print()
|
|
358
|
+
|
|
359
|
+
# CRITICAL: Invalidate import caches so Python can see newly installed modules
|
|
360
|
+
importlib.invalidate_caches()
|
|
361
|
+
sys.path_importer_cache.clear()
|
|
362
|
+
|
|
363
|
+
if check_pip_audit_available():
|
|
364
|
+
self._run_vulnerability_audit(package_name)
|
|
365
|
+
self._update_lockfile(package_name)
|
|
366
|
+
|
|
367
|
+
def _get_installed_version(self, package_name: str) -> Optional[str]:
|
|
368
|
+
"""Get installed version of a package."""
|
|
369
|
+
try:
|
|
370
|
+
result = subprocess.run(
|
|
371
|
+
[sys.executable, '-m', 'pip', 'show', package_name],
|
|
372
|
+
capture_output=True,
|
|
373
|
+
text=True,
|
|
374
|
+
timeout=5
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if result.returncode == 0:
|
|
378
|
+
for line in result.stdout.split('\n'):
|
|
379
|
+
if line.startswith('Version:'):
|
|
380
|
+
return line.split(':', 1)[1].strip()
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.debug(f"Could not get version for {package_name}: {e}")
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
def uninstall_package(self, package_name: str, quiet: bool = True) -> bool:
|
|
386
|
+
"""Uninstall a package (synchronous wrapper)."""
|
|
387
|
+
if self._async_loop and self._async_loop.is_running():
|
|
388
|
+
asyncio.run_coroutine_threadsafe(
|
|
389
|
+
self.uninstall_package_async(package_name, quiet=quiet),
|
|
390
|
+
self._async_loop
|
|
391
|
+
)
|
|
392
|
+
return True
|
|
393
|
+
else:
|
|
394
|
+
try:
|
|
395
|
+
result = subprocess.run(
|
|
396
|
+
[sys.executable, '-m', 'pip', 'uninstall', '-y', package_name],
|
|
397
|
+
capture_output=quiet,
|
|
398
|
+
check=False
|
|
399
|
+
)
|
|
400
|
+
if result.returncode == 0:
|
|
401
|
+
with self._lock:
|
|
402
|
+
self._installed_packages.discard(package_name)
|
|
403
|
+
return True
|
|
404
|
+
return False
|
|
405
|
+
except Exception as e:
|
|
406
|
+
logger.debug(f"Failed to uninstall {package_name}: {e}")
|
|
407
|
+
return False
|
|
408
|
+
|
|
409
|
+
def uninstall_all_packages(self, quiet: bool = True) -> None:
|
|
410
|
+
"""Uninstall all packages installed by this installer."""
|
|
411
|
+
with self._lock:
|
|
412
|
+
packages_to_uninstall = list(self._installed_packages)
|
|
413
|
+
for package_name in packages_to_uninstall:
|
|
414
|
+
self.uninstall_package(package_name, quiet=quiet)
|
|
415
|
+
|
|
416
|
+
def _is_module_importable(self, module_name: str) -> bool:
|
|
417
|
+
"""Check if module can be imported without installation."""
|
|
418
|
+
try:
|
|
419
|
+
spec = importlib.util.find_spec(module_name)
|
|
420
|
+
return spec is not None and spec.loader is not None
|
|
421
|
+
except (ValueError, AttributeError, ImportError, Exception):
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
def is_package_installed(self, package_name: str) -> bool:
|
|
425
|
+
"""Check if a package is already installed."""
|
|
426
|
+
# Step 1: Check persistent cache FIRST (fastest, no importability check needed)
|
|
427
|
+
if self._install_cache.is_installed(package_name):
|
|
428
|
+
# Also update in-memory cache for performance
|
|
429
|
+
self._installed_packages.add(package_name)
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
# Step 2: Check in-memory cache (fast, but lost on restart)
|
|
433
|
+
if package_name in self._installed_packages:
|
|
434
|
+
return True
|
|
435
|
+
|
|
436
|
+
# Step 3: Check actual importability (slower, but accurate)
|
|
437
|
+
try:
|
|
438
|
+
# Get module name from package name (heuristic)
|
|
439
|
+
module_name = package_name.replace('-', '_')
|
|
440
|
+
if self._is_module_importable(module_name):
|
|
441
|
+
# Cache in both persistent and in-memory cache
|
|
442
|
+
version = self._get_installed_version(package_name)
|
|
443
|
+
self._install_cache.mark_installed(package_name, version)
|
|
444
|
+
self._installed_packages.add(package_name)
|
|
445
|
+
return True
|
|
446
|
+
except (ImportError, AttributeError, ValueError) as e:
|
|
447
|
+
# Expected errors when checking package installation
|
|
448
|
+
_ensure_logging_initialized()
|
|
449
|
+
if logger:
|
|
450
|
+
logger.debug(f"Package check failed for {package_name}: {e}")
|
|
451
|
+
pass
|
|
452
|
+
except Exception as e:
|
|
453
|
+
# Unexpected errors - log but don't fail
|
|
454
|
+
_ensure_logging_initialized()
|
|
455
|
+
if logger:
|
|
456
|
+
logger.debug(f"Unexpected error checking package {package_name}: {e}")
|
|
457
|
+
pass
|
|
458
|
+
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
def install_and_import(self, module_name: str, package_name: str = None) -> Tuple[Optional[ModuleType], bool]:
|
|
462
|
+
"""
|
|
463
|
+
Install package and import module.
|
|
464
|
+
|
|
465
|
+
ROOT CAUSE FIX: Check if module is importable FIRST before attempting
|
|
466
|
+
installation. This prevents circular imports and unnecessary installations.
|
|
467
|
+
"""
|
|
468
|
+
# CRITICAL: Prevent recursion - if installation is already in progress, skip
|
|
469
|
+
if getattr(_installing, 'active', False):
|
|
470
|
+
logger.debug(
|
|
471
|
+
f"Installation in progress, skipping install_and_import for {module_name} "
|
|
472
|
+
f"to prevent recursion"
|
|
473
|
+
)
|
|
474
|
+
return None, False
|
|
475
|
+
|
|
476
|
+
if not self.is_enabled():
|
|
477
|
+
return None, False
|
|
478
|
+
|
|
479
|
+
# Get package name early for cache check
|
|
480
|
+
if package_name is None:
|
|
481
|
+
package_name = self._dependency_mapper.get_package_name(module_name)
|
|
482
|
+
|
|
483
|
+
# ROOT CAUSE FIX: Check persistent cache FIRST (fastest, no importability check)
|
|
484
|
+
if package_name and self._install_cache.is_installed(package_name):
|
|
485
|
+
# Package is in persistent cache - import directly
|
|
486
|
+
try:
|
|
487
|
+
module = importlib.import_module(module_name)
|
|
488
|
+
self._clear_module_missing(module_name)
|
|
489
|
+
_spec_cache_put(module_name, importlib.util.find_spec(module_name))
|
|
490
|
+
logger.debug(f"Module {module_name} is in persistent cache, imported directly")
|
|
491
|
+
return module, True
|
|
492
|
+
except ImportError as e:
|
|
493
|
+
logger.debug(f"Module {module_name} in cache but import failed: {e}")
|
|
494
|
+
# Cache might be stale - fall through to importability check
|
|
495
|
+
|
|
496
|
+
# ROOT CAUSE FIX: Check if module is ALREADY importable BEFORE doing anything else
|
|
497
|
+
if self._is_module_importable(module_name):
|
|
498
|
+
# Module is already importable - import it directly
|
|
499
|
+
if package_name:
|
|
500
|
+
version = self._get_installed_version(package_name)
|
|
501
|
+
self._install_cache.mark_installed(package_name, version)
|
|
502
|
+
try:
|
|
503
|
+
module = importlib.import_module(module_name)
|
|
504
|
+
self._clear_module_missing(module_name)
|
|
505
|
+
_spec_cache_put(module_name, importlib.util.find_spec(module_name))
|
|
506
|
+
logger.debug(f"Module {module_name} is already importable, imported directly")
|
|
507
|
+
return module, True
|
|
508
|
+
except ImportError as e:
|
|
509
|
+
logger.debug(f"Module {module_name} appeared importable but import failed: {e}")
|
|
510
|
+
|
|
511
|
+
# Package name should already be set from cache check above
|
|
512
|
+
if package_name is None:
|
|
513
|
+
package_name = self._dependency_mapper.get_package_name(module_name)
|
|
514
|
+
if package_name is None:
|
|
515
|
+
logger.debug(f"Module '{module_name}' is a system/built-in module, not installing")
|
|
516
|
+
return None, False
|
|
517
|
+
|
|
518
|
+
# Module is NOT importable - need to install it
|
|
519
|
+
# ROOT CAUSE FIX: Temporarily remove ALL xwlazy finders from sys.meta_path
|
|
520
|
+
xwlazy_finder_names = {'LazyMetaPathFinder', 'LazyPathFinder', 'LazyLoader'}
|
|
521
|
+
xwlazy_finders = [f for f in sys.meta_path if type(f).__name__ in xwlazy_finder_names]
|
|
522
|
+
for finder in xwlazy_finders:
|
|
523
|
+
try:
|
|
524
|
+
sys.meta_path.remove(finder)
|
|
525
|
+
except ValueError:
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
# Try importing again after removing finders (in case it was a false negative)
|
|
530
|
+
module = importlib.import_module(module_name)
|
|
531
|
+
self._clear_module_missing(module_name)
|
|
532
|
+
_spec_cache_put(module_name, importlib.util.find_spec(module_name))
|
|
533
|
+
return module, True
|
|
534
|
+
except ImportError:
|
|
535
|
+
pass
|
|
536
|
+
finally:
|
|
537
|
+
# Restore finders in reverse order to maintain original position
|
|
538
|
+
for finder in reversed(xwlazy_finders):
|
|
539
|
+
if finder not in sys.meta_path:
|
|
540
|
+
sys.meta_path.insert(0, finder)
|
|
541
|
+
|
|
542
|
+
if self._async_enabled:
|
|
543
|
+
handle = self.schedule_async_install(module_name)
|
|
544
|
+
if handle is not None:
|
|
545
|
+
return None, False
|
|
546
|
+
|
|
547
|
+
if self.install_package(package_name, module_name):
|
|
548
|
+
for attempt in range(3):
|
|
549
|
+
try:
|
|
550
|
+
importlib.invalidate_caches()
|
|
551
|
+
sys.path_importer_cache.clear()
|
|
552
|
+
|
|
553
|
+
# ROOT CAUSE FIX: Remove ALL xwlazy finders before importing
|
|
554
|
+
xwlazy_finder_names = {'LazyMetaPathFinder', 'LazyPathFinder', 'LazyLoader'}
|
|
555
|
+
xwlazy_finders = [f for f in sys.meta_path if type(f).__name__ in xwlazy_finder_names]
|
|
556
|
+
for finder in xwlazy_finders:
|
|
557
|
+
try:
|
|
558
|
+
sys.meta_path.remove(finder)
|
|
559
|
+
except ValueError:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
module = importlib.import_module(module_name)
|
|
564
|
+
self._clear_module_missing(module_name)
|
|
565
|
+
_spec_cache_put(module_name, importlib.util.find_spec(module_name))
|
|
566
|
+
# ROOT CAUSE FIX: Mark in both persistent and in-memory cache
|
|
567
|
+
version = self._get_installed_version(package_name)
|
|
568
|
+
self._install_cache.mark_installed(package_name, version)
|
|
569
|
+
self._installed_packages.add(package_name)
|
|
570
|
+
return module, True
|
|
571
|
+
finally:
|
|
572
|
+
# Restore finders in reverse order to maintain original position
|
|
573
|
+
for finder in reversed(xwlazy_finders):
|
|
574
|
+
if finder not in sys.meta_path:
|
|
575
|
+
sys.meta_path.insert(0, finder)
|
|
576
|
+
except ImportError as e:
|
|
577
|
+
if attempt < 2:
|
|
578
|
+
time.sleep(0.1 * (attempt + 1))
|
|
579
|
+
else:
|
|
580
|
+
logger.error(f"Still cannot import {module_name} after installing {package_name}: {e}")
|
|
581
|
+
return None, False
|
|
582
|
+
|
|
583
|
+
self._mark_module_missing(module_name)
|
|
584
|
+
return None, False
|
|
585
|
+
|
|
586
|
+
def _check_security_policy(self, package_name: str) -> Tuple[bool, str]:
|
|
587
|
+
"""Check security policy for package."""
|
|
588
|
+
return LazyInstallPolicy.is_package_allowed(self._package_name, package_name)
|
|
589
|
+
|
|
590
|
+
def _run_pip_install(self, package_name: str, args: List[str]) -> bool:
|
|
591
|
+
"""Run pip install with arguments."""
|
|
592
|
+
if self._install_from_cached_wheel(package_name):
|
|
593
|
+
return True
|
|
594
|
+
try:
|
|
595
|
+
pip_args = [
|
|
596
|
+
sys.executable,
|
|
597
|
+
'-m',
|
|
598
|
+
'pip',
|
|
599
|
+
'install',
|
|
600
|
+
'--disable-pip-version-check',
|
|
601
|
+
'--no-input',
|
|
602
|
+
] + args + [package_name]
|
|
603
|
+
result = subprocess.run(
|
|
604
|
+
pip_args,
|
|
605
|
+
capture_output=True,
|
|
606
|
+
text=True,
|
|
607
|
+
check=True,
|
|
608
|
+
)
|
|
609
|
+
if result.returncode == 0:
|
|
610
|
+
self._ensure_cached_wheel(package_name)
|
|
611
|
+
return True
|
|
612
|
+
return False
|
|
613
|
+
except subprocess.CalledProcessError:
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
def get_installed_packages(self) -> Set[str]:
|
|
617
|
+
"""Get set of installed package names."""
|
|
618
|
+
with self._lock:
|
|
619
|
+
return self._installed_packages.copy()
|
|
620
|
+
|
|
621
|
+
def get_failed_packages(self) -> Set[str]:
|
|
622
|
+
"""Get set of failed package names."""
|
|
623
|
+
with self._lock:
|
|
624
|
+
return self._failed_packages.copy()
|
|
625
|
+
|
|
626
|
+
def get_async_tasks(self) -> Dict[str, Any]:
|
|
627
|
+
"""Get dictionary of async installation tasks."""
|
|
628
|
+
with self._lock:
|
|
629
|
+
return {
|
|
630
|
+
module_name: {
|
|
631
|
+
'done': task.done() if task else False,
|
|
632
|
+
'cancelled': task.cancelled() if task else False,
|
|
633
|
+
}
|
|
634
|
+
for module_name, task in self._async_tasks.items()
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
638
|
+
"""Get installation statistics (extends base class method)."""
|
|
639
|
+
base_stats = super().get_stats()
|
|
640
|
+
with self._lock:
|
|
641
|
+
base_stats.update({
|
|
642
|
+
'async_enabled': self._async_enabled,
|
|
643
|
+
'async_workers': self._async_workers,
|
|
644
|
+
'async_tasks_count': len(self._async_tasks),
|
|
645
|
+
'known_missing_count': len(self._known_missing),
|
|
646
|
+
'auto_approve_all': self._auto_approve_all,
|
|
647
|
+
})
|
|
648
|
+
return base_stats
|
|
649
|
+
|
|
650
|
+
# Abstract method implementations from APackageHelper
|
|
651
|
+
def _discover_from_sources(self) -> None:
|
|
652
|
+
"""Discover dependencies from all sources."""
|
|
653
|
+
# Lazy import to avoid circular dependency
|
|
654
|
+
from ...package.discovery import get_lazy_discovery
|
|
655
|
+
discovery = get_lazy_discovery()
|
|
656
|
+
if discovery:
|
|
657
|
+
all_deps = discovery.discover_all_dependencies()
|
|
658
|
+
for import_name, package_name in all_deps.items():
|
|
659
|
+
from ...defs import DependencyInfo
|
|
660
|
+
self.discovered_dependencies[import_name] = DependencyInfo(
|
|
661
|
+
import_name=import_name,
|
|
662
|
+
package_name=package_name,
|
|
663
|
+
source='discovery',
|
|
664
|
+
category='discovered'
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
def _is_cache_valid(self) -> bool:
|
|
668
|
+
"""Check if cached dependencies are still valid."""
|
|
669
|
+
# Use discovery's cache validation
|
|
670
|
+
# Lazy import to avoid circular dependency
|
|
671
|
+
from ...package.discovery import get_lazy_discovery
|
|
672
|
+
discovery = get_lazy_discovery()
|
|
673
|
+
if discovery:
|
|
674
|
+
return discovery._is_cache_valid()
|
|
675
|
+
return False
|
|
676
|
+
|
|
677
|
+
def _add_common_mappings(self) -> None:
|
|
678
|
+
"""Add common import -> package mappings."""
|
|
679
|
+
# Common mappings are handled by discovery
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
def _update_file_mtimes(self) -> None:
|
|
683
|
+
"""Update file modification times for cache validation."""
|
|
684
|
+
# File mtimes are handled by discovery
|
|
685
|
+
pass
|
|
686
|
+
|
|
687
|
+
def _check_importability(self, package_name: str) -> bool:
|
|
688
|
+
"""Check if package is importable."""
|
|
689
|
+
return self.is_package_installed(package_name)
|
|
690
|
+
|
|
691
|
+
def _check_persistent_cache(self, package_name: str) -> bool:
|
|
692
|
+
"""Check persistent cache for package installation status."""
|
|
693
|
+
return self._install_cache.is_installed(package_name)
|
|
694
|
+
|
|
695
|
+
def _mark_installed_in_persistent_cache(self, package_name: str) -> None:
|
|
696
|
+
"""Mark package as installed in persistent cache."""
|
|
697
|
+
version = self._get_installed_version(package_name)
|
|
698
|
+
self._install_cache.mark_installed(package_name, version)
|
|
699
|
+
|
|
700
|
+
def _mark_uninstalled_in_persistent_cache(self, package_name: str) -> None:
|
|
701
|
+
"""Mark package as uninstalled in persistent cache."""
|
|
702
|
+
self._install_cache.mark_uninstalled(package_name)
|
|
703
|
+
|
|
704
|
+
def _run_install(self, *package_names: str) -> None:
|
|
705
|
+
"""Run pip install for packages."""
|
|
706
|
+
for package_name in package_names:
|
|
707
|
+
self.install_package(package_name)
|
|
708
|
+
|
|
709
|
+
def _run_uninstall(self, *package_names: str) -> None:
|
|
710
|
+
"""Run pip uninstall for packages."""
|
|
711
|
+
for package_name in package_names:
|
|
712
|
+
self.uninstall_package(package_name, quiet=True)
|
|
713
|
+
|
|
714
|
+
def is_cache_valid(self, key: str) -> bool:
|
|
715
|
+
"""Check if cache entry is still valid."""
|
|
716
|
+
return self._is_cache_valid()
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
__all__ = ['LazyInstaller']
|
|
720
|
+
|