exonware-xwlazy 0.1.0.11__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/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
- {xwlazy/lazy → exonware/xwlazy}/config.py +52 -20
- exonware/xwlazy/contracts.py +1410 -0
- exonware/xwlazy/defs.py +397 -0
- xwlazy/lazy/lazy_errors.py → exonware/xwlazy/errors.py +21 -8
- 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
- xwlazy/lazy/host_conf.py → exonware/xwlazy/package/conf.py +62 -10
- 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
- {xwlazy/lazy → exonware/xwlazy/package/services}/host_packages.py +43 -16
- 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
- {xwlazy/lazy → exonware/xwlazy/package/services}/manifest.py +42 -25
- 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.11.dist-info → exonware_xwlazy-0.1.0.19.dist-info}/METADATA +86 -10
- exonware_xwlazy-0.1.0.19.dist-info/RECORD +87 -0
- exonware_xwlazy-0.1.0.11.dist-info/RECORD +0 -20
- xwlazy/__init__.py +0 -34
- xwlazy/lazy/__init__.py +0 -301
- xwlazy/lazy/bootstrap.py +0 -106
- xwlazy/lazy/lazy_base.py +0 -465
- xwlazy/lazy/lazy_contracts.py +0 -290
- xwlazy/lazy/lazy_core.py +0 -3727
- xwlazy/lazy/logging_utils.py +0 -194
- xwlazy/version.py +0 -77
- /xwlazy/lazy/lazy_state.py → /exonware/xwlazy/common/services/state_manager.py +0 -0
- {exonware_xwlazy-0.1.0.11.dist-info → exonware_xwlazy-0.1.0.19.dist-info}/WHEEL +0 -0
- {exonware_xwlazy-0.1.0.11.dist-info → exonware_xwlazy-0.1.0.19.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async Installation Mixin
|
|
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
|
+
Mixin for async installation operations.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import asyncio
|
|
15
|
+
import threading
|
|
16
|
+
import importlib
|
|
17
|
+
from typing import Optional, Dict, Any
|
|
18
|
+
|
|
19
|
+
from .async_install_handle import AsyncInstallHandle
|
|
20
|
+
from .manifest import PackageManifest
|
|
21
|
+
from .config_manager import LazyInstallConfig
|
|
22
|
+
from .install_policy import LazyInstallPolicy
|
|
23
|
+
from ...defs import LazyInstallMode
|
|
24
|
+
|
|
25
|
+
# Lazy imports
|
|
26
|
+
def _get_logger():
|
|
27
|
+
"""Get logger (lazy import to avoid circular dependency)."""
|
|
28
|
+
from ...common.logger import get_logger
|
|
29
|
+
return get_logger("xwlazy.lazy_installer")
|
|
30
|
+
|
|
31
|
+
def _get_log_event():
|
|
32
|
+
"""Get log_event function (lazy import to avoid circular dependency)."""
|
|
33
|
+
from ...common.logger import log_event
|
|
34
|
+
return log_event
|
|
35
|
+
|
|
36
|
+
logger = None
|
|
37
|
+
_log = None
|
|
38
|
+
|
|
39
|
+
# Environment variables
|
|
40
|
+
_ENV_ASYNC_INSTALL = os.environ.get("XWLAZY_ASYNC_INSTALL", "").strip().lower() in {"1", "true", "yes", "on"}
|
|
41
|
+
_ENV_ASYNC_WORKERS = int(os.environ.get("XWLAZY_ASYNC_WORKERS", "0") or 0)
|
|
42
|
+
|
|
43
|
+
def _ensure_logging_initialized():
|
|
44
|
+
"""Ensure logging utilities are initialized."""
|
|
45
|
+
global logger, _log
|
|
46
|
+
if logger is None:
|
|
47
|
+
logger = _get_logger()
|
|
48
|
+
if _log is None:
|
|
49
|
+
_log = _get_log_event()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AsyncInstallMixin:
|
|
53
|
+
"""Mixin for async installation operations."""
|
|
54
|
+
|
|
55
|
+
def _ensure_async_loop(self) -> asyncio.AbstractEventLoop:
|
|
56
|
+
"""Ensure async event loop is running in background thread."""
|
|
57
|
+
if self._async_loop is not None and self._async_loop.is_running(): # type: ignore[attr-defined]
|
|
58
|
+
return self._async_loop # type: ignore[attr-defined]
|
|
59
|
+
|
|
60
|
+
with self._lock: # type: ignore[attr-defined]
|
|
61
|
+
if self._async_loop is None or not self._async_loop.is_running(): # type: ignore[attr-defined]
|
|
62
|
+
loop_ready = threading.Event()
|
|
63
|
+
loop_ref = [None]
|
|
64
|
+
|
|
65
|
+
def _run_loop():
|
|
66
|
+
loop = asyncio.new_event_loop()
|
|
67
|
+
asyncio.set_event_loop(loop)
|
|
68
|
+
loop_ref[0] = loop
|
|
69
|
+
self._async_loop = loop # type: ignore[attr-defined]
|
|
70
|
+
loop_ready.set()
|
|
71
|
+
loop.run_forever()
|
|
72
|
+
|
|
73
|
+
self._loop_thread = threading.Thread( # type: ignore[attr-defined]
|
|
74
|
+
target=_run_loop,
|
|
75
|
+
daemon=True,
|
|
76
|
+
name=f"xwlazy-{self._package_name}-async" # type: ignore[attr-defined]
|
|
77
|
+
)
|
|
78
|
+
self._loop_thread.start() # type: ignore[attr-defined]
|
|
79
|
+
|
|
80
|
+
if not loop_ready.wait(timeout=5.0):
|
|
81
|
+
raise RuntimeError(f"Failed to start async loop for {self._package_name}") # type: ignore[attr-defined]
|
|
82
|
+
|
|
83
|
+
return self._async_loop # type: ignore[attr-defined]
|
|
84
|
+
|
|
85
|
+
def apply_manifest(self, manifest: Optional[PackageManifest]) -> None:
|
|
86
|
+
"""Apply manifest-driven configuration such as async installs."""
|
|
87
|
+
env_override = _ENV_ASYNC_INSTALL
|
|
88
|
+
desired_async = bool(env_override or (manifest and manifest.async_installs))
|
|
89
|
+
desired_workers = _ENV_ASYNC_WORKERS or (manifest.async_workers if manifest else 1)
|
|
90
|
+
desired_workers = max(1, desired_workers)
|
|
91
|
+
|
|
92
|
+
with self._lock: # type: ignore[attr-defined]
|
|
93
|
+
self._async_workers = desired_workers # type: ignore[attr-defined]
|
|
94
|
+
|
|
95
|
+
if desired_async:
|
|
96
|
+
self._ensure_async_loop()
|
|
97
|
+
else:
|
|
98
|
+
if self._async_loop is not None: # type: ignore[attr-defined]
|
|
99
|
+
for task in list(self._async_tasks.values()): # type: ignore[attr-defined]
|
|
100
|
+
if not task.done():
|
|
101
|
+
task.cancel()
|
|
102
|
+
self._async_tasks.clear() # type: ignore[attr-defined]
|
|
103
|
+
|
|
104
|
+
self._async_enabled = desired_async # type: ignore[attr-defined]
|
|
105
|
+
|
|
106
|
+
def is_async_enabled(self) -> bool:
|
|
107
|
+
"""Return True if async installers are enabled for this package."""
|
|
108
|
+
return self._async_enabled # type: ignore[attr-defined]
|
|
109
|
+
|
|
110
|
+
def ensure_async_install(self, module_name: str) -> Optional[AsyncInstallHandle]:
|
|
111
|
+
"""Schedule (or reuse) an async install job for module_name if async is enabled."""
|
|
112
|
+
if not self._async_enabled: # type: ignore[attr-defined]
|
|
113
|
+
return None
|
|
114
|
+
return self.schedule_async_install(module_name)
|
|
115
|
+
|
|
116
|
+
async def _get_package_size_mb(self, package_name: str) -> Optional[float]:
|
|
117
|
+
"""Get package size in MB by checking pip show or download size."""
|
|
118
|
+
from ...common.services.install_async_utils import get_package_size_mb
|
|
119
|
+
return await get_package_size_mb(package_name)
|
|
120
|
+
|
|
121
|
+
async def _async_install_package(self, package_name: str, module_name: str) -> bool:
|
|
122
|
+
"""Async version of install_package using asyncio subprocess."""
|
|
123
|
+
_ensure_logging_initialized()
|
|
124
|
+
# SIZE_AWARE mode: Check package size before installing
|
|
125
|
+
if self._mode == LazyInstallMode.SIZE_AWARE: # type: ignore[attr-defined]
|
|
126
|
+
mode_config = LazyInstallConfig.get_mode_config(self._package_name) # type: ignore[attr-defined]
|
|
127
|
+
threshold_mb = mode_config.large_package_threshold_mb if mode_config else 50.0
|
|
128
|
+
|
|
129
|
+
size_mb = await self._get_package_size_mb(package_name)
|
|
130
|
+
if size_mb is not None and size_mb >= threshold_mb:
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"Package '{package_name}' is {size_mb:.1f}MB (>= {threshold_mb}MB threshold), "
|
|
133
|
+
f"skipping installation in SIZE_AWARE mode"
|
|
134
|
+
)
|
|
135
|
+
print(
|
|
136
|
+
f"[SIZE_AWARE] Skipping large package '{package_name}' "
|
|
137
|
+
f"({size_mb:.1f}MB >= {threshold_mb}MB)"
|
|
138
|
+
)
|
|
139
|
+
self._failed_packages.add(package_name) # type: ignore[attr-defined]
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
# Check cache first
|
|
143
|
+
if self._install_from_cached_tree(package_name): # type: ignore[attr-defined]
|
|
144
|
+
self._finalize_install_success(package_name, "cache-tree") # type: ignore[attr-defined]
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
# Use asyncio subprocess for pip install
|
|
148
|
+
try:
|
|
149
|
+
policy_args = LazyInstallPolicy.get_pip_args(self._package_name) or [] # type: ignore[attr-defined]
|
|
150
|
+
from ...common.services.install_async_utils import async_install_package
|
|
151
|
+
success, error_msg = await async_install_package(package_name, policy_args)
|
|
152
|
+
|
|
153
|
+
if success:
|
|
154
|
+
self._finalize_install_success(package_name, "pip-async") # type: ignore[attr-defined]
|
|
155
|
+
|
|
156
|
+
# For CLEAN mode, schedule async uninstall after completion
|
|
157
|
+
if self._mode == LazyInstallMode.CLEAN: # type: ignore[attr-defined]
|
|
158
|
+
asyncio.create_task(self._schedule_clean_uninstall(package_name))
|
|
159
|
+
|
|
160
|
+
# For TEMPORARY mode, uninstall immediately after installation
|
|
161
|
+
if self._mode == LazyInstallMode.TEMPORARY: # type: ignore[attr-defined]
|
|
162
|
+
asyncio.create_task(self.uninstall_package_async(package_name, quiet=True))
|
|
163
|
+
|
|
164
|
+
return True
|
|
165
|
+
else:
|
|
166
|
+
self._failed_packages.add(package_name) # type: ignore[attr-defined]
|
|
167
|
+
return False
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Error in async install of {package_name}: {e}")
|
|
170
|
+
self._failed_packages.add(package_name) # type: ignore[attr-defined]
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
async def _schedule_clean_uninstall(self, package_name: str) -> None:
|
|
174
|
+
"""Schedule uninstall for CLEAN mode after a delay."""
|
|
175
|
+
await asyncio.sleep(1.0)
|
|
176
|
+
await self.uninstall_package_async(package_name, quiet=True)
|
|
177
|
+
|
|
178
|
+
async def uninstall_package_async(self, package_name: str, quiet: bool = True) -> bool:
|
|
179
|
+
"""Uninstall a package asynchronously in quiet mode."""
|
|
180
|
+
with self._lock: # type: ignore[attr-defined]
|
|
181
|
+
if package_name not in self._installed_packages: # type: ignore[attr-defined]
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
from ...common.services.install_async_utils import async_uninstall_package
|
|
185
|
+
success = await async_uninstall_package(package_name, quiet)
|
|
186
|
+
|
|
187
|
+
if success:
|
|
188
|
+
with self._lock: # type: ignore[attr-defined]
|
|
189
|
+
self._installed_packages.discard(package_name) # type: ignore[attr-defined]
|
|
190
|
+
|
|
191
|
+
return success
|
|
192
|
+
|
|
193
|
+
def schedule_async_install(self, module_name: str) -> Optional[AsyncInstallHandle]:
|
|
194
|
+
"""Schedule installation of a dependency in the background using asyncio."""
|
|
195
|
+
_ensure_logging_initialized()
|
|
196
|
+
if not self._async_enabled: # type: ignore[attr-defined]
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
package_name = self._dependency_mapper.get_package_name(module_name) or module_name # type: ignore[attr-defined]
|
|
200
|
+
if not package_name:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
with self._lock: # type: ignore[attr-defined]
|
|
204
|
+
task = self._async_tasks.get(module_name) # type: ignore[attr-defined]
|
|
205
|
+
if task is None or task.done():
|
|
206
|
+
self._mark_module_missing(module_name) # type: ignore[attr-defined]
|
|
207
|
+
loop = self._ensure_async_loop()
|
|
208
|
+
|
|
209
|
+
async def _install_and_cleanup():
|
|
210
|
+
try:
|
|
211
|
+
result = await self._async_install_package(package_name, module_name)
|
|
212
|
+
if result:
|
|
213
|
+
self._clear_module_missing(module_name) # type: ignore[attr-defined]
|
|
214
|
+
try:
|
|
215
|
+
imported_module = importlib.import_module(module_name)
|
|
216
|
+
if self._mode == LazyInstallMode.TEMPORARY: # type: ignore[attr-defined]
|
|
217
|
+
asyncio.create_task(self.uninstall_package_async(package_name, quiet=True))
|
|
218
|
+
except Exception:
|
|
219
|
+
pass
|
|
220
|
+
return result
|
|
221
|
+
finally:
|
|
222
|
+
with self._lock: # type: ignore[attr-defined]
|
|
223
|
+
self._async_tasks.pop(module_name, None) # type: ignore[attr-defined]
|
|
224
|
+
|
|
225
|
+
task = asyncio.run_coroutine_threadsafe(_install_and_cleanup(), loop)
|
|
226
|
+
self._async_tasks[module_name] = task # type: ignore[attr-defined]
|
|
227
|
+
|
|
228
|
+
return AsyncInstallHandle(task, module_name, package_name, self._package_name) # type: ignore[attr-defined]
|
|
229
|
+
|
|
230
|
+
async def install_all_dependencies(self) -> None:
|
|
231
|
+
"""Install all dependencies from discovered requirements (FULL mode)."""
|
|
232
|
+
_ensure_logging_initialized()
|
|
233
|
+
if self._mode != LazyInstallMode.FULL: # type: ignore[attr-defined]
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Lazy import to avoid circular dependency
|
|
238
|
+
from .discovery import get_lazy_discovery
|
|
239
|
+
discovery = get_lazy_discovery()
|
|
240
|
+
if discovery:
|
|
241
|
+
all_deps = discovery.discover_all_dependencies()
|
|
242
|
+
if not all_deps:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
packages_to_install = [
|
|
246
|
+
(import_name, package_name)
|
|
247
|
+
for import_name, package_name in all_deps.items()
|
|
248
|
+
if package_name not in self._installed_packages # type: ignore[attr-defined]
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
if not packages_to_install:
|
|
252
|
+
_log("install", f"All dependencies already installed for {self._package_name}") # type: ignore[attr-defined]
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
_log(
|
|
256
|
+
"install",
|
|
257
|
+
f"Installing {len(packages_to_install)} dependencies for {self._package_name} (FULL mode)"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
batch_size = min(self._async_workers * 2, 10) # type: ignore[attr-defined]
|
|
261
|
+
for i in range(0, len(packages_to_install), batch_size):
|
|
262
|
+
batch = packages_to_install[i:i + batch_size]
|
|
263
|
+
tasks = [
|
|
264
|
+
self._async_install_package(package_name, import_name)
|
|
265
|
+
for import_name, package_name in batch
|
|
266
|
+
]
|
|
267
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
268
|
+
|
|
269
|
+
for (import_name, package_name), result in zip(batch, results):
|
|
270
|
+
if isinstance(result, Exception):
|
|
271
|
+
logger.error(f"Failed to install {package_name}: {result}")
|
|
272
|
+
elif result:
|
|
273
|
+
_log("install", f"✓ Installed {package_name}")
|
|
274
|
+
|
|
275
|
+
_log("install", f"Completed installing all dependencies for {self._package_name}") # type: ignore[attr-defined]
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.warning(f"Failed to install all dependencies for {self._package_name}: {e}") # type: ignore[attr-defined]
|
|
278
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Installation Cache Mixin
|
|
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
|
+
Mixin for cache management (wheels, install trees, known missing modules).
|
|
11
|
+
Uses shared utilities from common/services/install_cache_utils.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, List
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
|
|
20
|
+
# Import shared utilities
|
|
21
|
+
from ...common.services.install_cache_utils import (
|
|
22
|
+
get_cache_dir,
|
|
23
|
+
get_wheel_path,
|
|
24
|
+
get_install_tree_dir,
|
|
25
|
+
get_site_packages_dir,
|
|
26
|
+
pip_install_from_path,
|
|
27
|
+
ensure_cached_wheel,
|
|
28
|
+
install_from_cached_tree as _install_from_cached_tree_util,
|
|
29
|
+
materialize_cached_tree as _materialize_cached_tree_util,
|
|
30
|
+
has_cached_install_tree as _has_cached_install_tree_util,
|
|
31
|
+
install_from_cached_wheel as _install_from_cached_wheel_util,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Lazy imports
|
|
35
|
+
def _get_spec_cache_clear():
|
|
36
|
+
"""Get spec_cache_clear function (lazy import to avoid circular dependency)."""
|
|
37
|
+
from ...common.services.spec_cache import _spec_cache_clear
|
|
38
|
+
return _spec_cache_clear
|
|
39
|
+
|
|
40
|
+
_spec_cache_clear = None
|
|
41
|
+
|
|
42
|
+
# Environment variables
|
|
43
|
+
_KNOWN_MISSING_CACHE_LIMIT = int(os.environ.get("XWLAZY_MISSING_CACHE_MAX", "128") or 128)
|
|
44
|
+
_KNOWN_MISSING_CACHE_TTL = float(os.environ.get("XWLAZY_MISSING_CACHE_TTL", "120") or 120.0)
|
|
45
|
+
|
|
46
|
+
def _ensure_spec_cache_initialized():
|
|
47
|
+
"""Ensure spec cache utilities are initialized."""
|
|
48
|
+
global _spec_cache_clear
|
|
49
|
+
if _spec_cache_clear is None:
|
|
50
|
+
_spec_cache_clear = _get_spec_cache_clear()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InstallCacheMixin:
|
|
54
|
+
"""Mixin for cache management (wheels, install trees, known missing modules)."""
|
|
55
|
+
|
|
56
|
+
def _prune_known_missing(self) -> None:
|
|
57
|
+
"""Remove stale entries from the known-missing cache."""
|
|
58
|
+
if not self._known_missing: # type: ignore[attr-defined]
|
|
59
|
+
return
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
with self._lock: # type: ignore[attr-defined]
|
|
62
|
+
while self._known_missing: # type: ignore[attr-defined]
|
|
63
|
+
_, ts = next(iter(self._known_missing.items())) # type: ignore[attr-defined]
|
|
64
|
+
if now - ts <= _KNOWN_MISSING_CACHE_TTL:
|
|
65
|
+
break
|
|
66
|
+
self._known_missing.popitem(last=False) # type: ignore[attr-defined]
|
|
67
|
+
|
|
68
|
+
def _mark_module_missing(self, module_name: str) -> None:
|
|
69
|
+
"""Remember modules that failed to import recently."""
|
|
70
|
+
_ensure_spec_cache_initialized()
|
|
71
|
+
with self._lock: # type: ignore[attr-defined]
|
|
72
|
+
self._prune_known_missing()
|
|
73
|
+
_spec_cache_clear(module_name)
|
|
74
|
+
self._known_missing[module_name] = time.monotonic() # type: ignore[attr-defined]
|
|
75
|
+
while len(self._known_missing) > _KNOWN_MISSING_CACHE_LIMIT: # type: ignore[attr-defined]
|
|
76
|
+
self._known_missing.popitem(last=False) # type: ignore[attr-defined]
|
|
77
|
+
|
|
78
|
+
def _clear_module_missing(self, module_name: str) -> None:
|
|
79
|
+
"""Remove a module from the known-missing cache."""
|
|
80
|
+
with self._lock: # type: ignore[attr-defined]
|
|
81
|
+
self._known_missing.pop(module_name, None) # type: ignore[attr-defined]
|
|
82
|
+
|
|
83
|
+
def is_module_known_missing(self, module_name: str) -> bool:
|
|
84
|
+
"""Return True if module recently failed to import."""
|
|
85
|
+
self._prune_known_missing()
|
|
86
|
+
with self._lock: # type: ignore[attr-defined]
|
|
87
|
+
return module_name in self._known_missing # type: ignore[attr-defined]
|
|
88
|
+
|
|
89
|
+
def _get_async_cache_dir(self) -> Path:
|
|
90
|
+
"""Get the async cache directory."""
|
|
91
|
+
return get_cache_dir(self._async_cache_dir) # type: ignore[attr-defined]
|
|
92
|
+
|
|
93
|
+
def _cached_wheel_name(self, package_name: str) -> Path:
|
|
94
|
+
"""Get the cached wheel file path for a package."""
|
|
95
|
+
return get_wheel_path(package_name, self._async_cache_dir) # type: ignore[attr-defined]
|
|
96
|
+
|
|
97
|
+
def _install_from_cached_wheel(self, package_name: str, policy_args: Optional[List[str]] = None) -> bool:
|
|
98
|
+
"""Install from a cached wheel file."""
|
|
99
|
+
return _install_from_cached_wheel_util(
|
|
100
|
+
package_name,
|
|
101
|
+
policy_args,
|
|
102
|
+
self._async_cache_dir # type: ignore[attr-defined]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _pip_install_from_path(self, wheel_path: Path, policy_args: Optional[List[str]] = None) -> bool:
|
|
106
|
+
"""Install a wheel file using pip."""
|
|
107
|
+
return pip_install_from_path(wheel_path, policy_args)
|
|
108
|
+
|
|
109
|
+
def _ensure_cached_wheel(self, package_name: str, policy_args: Optional[List[str]] = None) -> Optional[Path]:
|
|
110
|
+
"""Ensure a wheel is cached, downloading it if necessary."""
|
|
111
|
+
return ensure_cached_wheel(
|
|
112
|
+
package_name,
|
|
113
|
+
policy_args,
|
|
114
|
+
self._async_cache_dir # type: ignore[attr-defined]
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _cached_install_dir(self, package_name: str) -> Path:
|
|
118
|
+
"""Get the cached install directory for a package."""
|
|
119
|
+
return get_install_tree_dir(package_name, self._async_cache_dir) # type: ignore[attr-defined]
|
|
120
|
+
|
|
121
|
+
def _has_cached_install_tree(self, package_name: str) -> bool:
|
|
122
|
+
"""Check if a cached install tree exists."""
|
|
123
|
+
return _has_cached_install_tree_util(
|
|
124
|
+
package_name,
|
|
125
|
+
self._async_cache_dir # type: ignore[attr-defined]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _site_packages_dir(self) -> Path:
|
|
129
|
+
"""Get the site-packages directory."""
|
|
130
|
+
return get_site_packages_dir()
|
|
131
|
+
|
|
132
|
+
def _install_from_cached_tree(self, package_name: str) -> bool:
|
|
133
|
+
"""Install from a cached install tree."""
|
|
134
|
+
return _install_from_cached_tree_util(
|
|
135
|
+
package_name,
|
|
136
|
+
self._async_cache_dir # type: ignore[attr-defined]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _materialize_cached_tree(self, package_name: str, wheel_path: Path) -> None:
|
|
140
|
+
"""Materialize a cached install tree from a wheel file."""
|
|
141
|
+
_materialize_cached_tree_util(
|
|
142
|
+
package_name,
|
|
143
|
+
wheel_path,
|
|
144
|
+
self._async_cache_dir # type: ignore[attr-defined]
|
|
145
|
+
)
|
|
146
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive Installation Mixin
|
|
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
|
+
Mixin for interactive user prompts during installation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .lazy_installer import LazyInstaller
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InteractiveInstallMixin:
|
|
20
|
+
"""Mixin for interactive user prompts during installation."""
|
|
21
|
+
|
|
22
|
+
def _ask_user_permission(self, package_name: str, module_name: str) -> bool:
|
|
23
|
+
"""Ask user for permission to install a package."""
|
|
24
|
+
if self._auto_approve_all: # type: ignore[attr-defined]
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
print(f"\n{'='*60}")
|
|
28
|
+
print(f"Lazy Installation Active - {self._package_name}") # type: ignore[attr-defined]
|
|
29
|
+
print(f"{'='*60}")
|
|
30
|
+
print(f"Package: {package_name}")
|
|
31
|
+
print(f"Module: {module_name}")
|
|
32
|
+
print(f"{'='*60}")
|
|
33
|
+
print(f"\nThe module '{module_name}' is not installed.")
|
|
34
|
+
print(f"Would you like to install '{package_name}'?")
|
|
35
|
+
print(f"\nOptions:")
|
|
36
|
+
print(f" [Y] Yes - Install this package")
|
|
37
|
+
print(f" [N] No - Skip this package")
|
|
38
|
+
print(f" [A] All - Install this and all future packages without asking")
|
|
39
|
+
print(f" [Q] Quit - Cancel and raise ImportError")
|
|
40
|
+
print(f"{'='*60}")
|
|
41
|
+
|
|
42
|
+
while True:
|
|
43
|
+
try:
|
|
44
|
+
choice = input("Your choice [Y/N/A/Q]: ").strip().upper()
|
|
45
|
+
|
|
46
|
+
if choice in ('Y', 'YES', ''):
|
|
47
|
+
return True
|
|
48
|
+
elif choice in ('N', 'NO'):
|
|
49
|
+
return False
|
|
50
|
+
elif choice in ('A', 'ALL'):
|
|
51
|
+
self._auto_approve_all = True # type: ignore[attr-defined]
|
|
52
|
+
return True
|
|
53
|
+
elif choice in ('Q', 'QUIT'):
|
|
54
|
+
raise KeyboardInterrupt("User cancelled installation")
|
|
55
|
+
else:
|
|
56
|
+
print(f"Invalid choice '{choice}'. Please enter Y, N, A, or Q.")
|
|
57
|
+
except (EOFError, KeyboardInterrupt):
|
|
58
|
+
print("\n❌ Installation cancelled by user")
|
|
59
|
+
return False
|
|
60
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Install Policy
|
|
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
|
+
Security and policy configuration for lazy installation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
15
|
+
|
|
16
|
+
# Lazy import to avoid circular dependency
|
|
17
|
+
def _get_log_event():
|
|
18
|
+
"""Get log_event function (lazy import to avoid circular dependency)."""
|
|
19
|
+
from ...common.logger import log_event
|
|
20
|
+
return log_event
|
|
21
|
+
|
|
22
|
+
_log = None # Will be initialized on first use
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LazyInstallPolicy:
|
|
26
|
+
"""
|
|
27
|
+
Security and policy configuration for lazy installation.
|
|
28
|
+
Per-package allow/deny lists, index URLs, and security settings.
|
|
29
|
+
"""
|
|
30
|
+
__slots__ = ()
|
|
31
|
+
|
|
32
|
+
_allow_lists: Dict[str, Set[str]] = {}
|
|
33
|
+
_deny_lists: Dict[str, Set[str]] = {}
|
|
34
|
+
_index_urls: Dict[str, str] = {}
|
|
35
|
+
_extra_index_urls: Dict[str, List[str]] = {}
|
|
36
|
+
_trusted_hosts: Dict[str, List[str]] = {}
|
|
37
|
+
_require_hashes: Dict[str, bool] = {}
|
|
38
|
+
_verify_ssl: Dict[str, bool] = {}
|
|
39
|
+
_lockfile_paths: Dict[str, str] = {}
|
|
40
|
+
_lock = threading.RLock()
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def _ensure_logging(cls):
|
|
44
|
+
"""Ensure logging is initialized."""
|
|
45
|
+
global _log
|
|
46
|
+
if _log is None:
|
|
47
|
+
_log = _get_log_event()
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def set_allow_list(cls, package_name: str, allowed_packages: List[str]) -> None:
|
|
51
|
+
"""Set allow list for a package (only these can be installed)."""
|
|
52
|
+
cls._ensure_logging()
|
|
53
|
+
with cls._lock:
|
|
54
|
+
cls._allow_lists[package_name] = set(allowed_packages)
|
|
55
|
+
_log("config", f"Set allow list for {package_name}: {len(allowed_packages)} packages")
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def set_deny_list(cls, package_name: str, denied_packages: List[str]) -> None:
|
|
59
|
+
"""Set deny list for a package (these cannot be installed)."""
|
|
60
|
+
cls._ensure_logging()
|
|
61
|
+
with cls._lock:
|
|
62
|
+
cls._deny_lists[package_name] = set(denied_packages)
|
|
63
|
+
_log("config", f"Set deny list for {package_name}: {len(denied_packages)} packages")
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def add_to_allow_list(cls, package_name: str, allowed_package: str) -> None:
|
|
67
|
+
"""Add single package to allow list."""
|
|
68
|
+
with cls._lock:
|
|
69
|
+
if package_name not in cls._allow_lists:
|
|
70
|
+
cls._allow_lists[package_name] = set()
|
|
71
|
+
cls._allow_lists[package_name].add(allowed_package)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def add_to_deny_list(cls, package_name: str, denied_package: str) -> None:
|
|
75
|
+
"""Add single package to deny list."""
|
|
76
|
+
with cls._lock:
|
|
77
|
+
if package_name not in cls._deny_lists:
|
|
78
|
+
cls._deny_lists[package_name] = set()
|
|
79
|
+
cls._deny_lists[package_name].add(denied_package)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def is_package_allowed(cls, installer_package: str, target_package: str) -> Tuple[bool, str]:
|
|
83
|
+
"""Check if target_package can be installed by installer_package."""
|
|
84
|
+
with cls._lock:
|
|
85
|
+
if installer_package in cls._deny_lists:
|
|
86
|
+
if target_package in cls._deny_lists[installer_package]:
|
|
87
|
+
return False, f"Package '{target_package}' is in deny list"
|
|
88
|
+
|
|
89
|
+
if installer_package in cls._allow_lists:
|
|
90
|
+
if target_package not in cls._allow_lists[installer_package]:
|
|
91
|
+
return False, f"Package '{target_package}' not in allow list"
|
|
92
|
+
|
|
93
|
+
return True, "OK"
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def set_index_url(cls, package_name: str, index_url: str) -> None:
|
|
97
|
+
"""Set PyPI index URL for a package."""
|
|
98
|
+
cls._ensure_logging()
|
|
99
|
+
with cls._lock:
|
|
100
|
+
cls._index_urls[package_name] = index_url
|
|
101
|
+
_log("config", f"Set index URL for {package_name}: {index_url}")
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def set_extra_index_urls(cls, package_name: str, urls: List[str]) -> None:
|
|
105
|
+
"""Set extra index URLs for a package."""
|
|
106
|
+
cls._ensure_logging()
|
|
107
|
+
with cls._lock:
|
|
108
|
+
cls._extra_index_urls[package_name] = urls
|
|
109
|
+
_log("config", f"Set {len(urls)} extra index URLs for {package_name}")
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def add_trusted_host(cls, package_name: str, host: str) -> None:
|
|
113
|
+
"""Add trusted host for a package."""
|
|
114
|
+
with cls._lock:
|
|
115
|
+
if package_name not in cls._trusted_hosts:
|
|
116
|
+
cls._trusted_hosts[package_name] = []
|
|
117
|
+
cls._trusted_hosts[package_name].append(host)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def get_pip_args(cls, package_name: str) -> List[str]:
|
|
121
|
+
"""Get pip install arguments for a package based on policy."""
|
|
122
|
+
args = []
|
|
123
|
+
|
|
124
|
+
with cls._lock:
|
|
125
|
+
if package_name in cls._index_urls:
|
|
126
|
+
args.extend(['--index-url', cls._index_urls[package_name]])
|
|
127
|
+
|
|
128
|
+
if package_name in cls._extra_index_urls:
|
|
129
|
+
for url in cls._extra_index_urls[package_name]:
|
|
130
|
+
args.extend(['--extra-index-url', url])
|
|
131
|
+
|
|
132
|
+
if package_name in cls._trusted_hosts:
|
|
133
|
+
for host in cls._trusted_hosts[package_name]:
|
|
134
|
+
args.extend(['--trusted-host', host])
|
|
135
|
+
|
|
136
|
+
if cls._require_hashes.get(package_name, False):
|
|
137
|
+
args.append('--require-hashes')
|
|
138
|
+
|
|
139
|
+
if not cls._verify_ssl.get(package_name, True):
|
|
140
|
+
args.append('--no-verify-ssl')
|
|
141
|
+
|
|
142
|
+
return args
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def set_lockfile_path(cls, package_name: str, path: str) -> None:
|
|
146
|
+
"""Set lockfile path for a package."""
|
|
147
|
+
with cls._lock:
|
|
148
|
+
cls._lockfile_paths[package_name] = path
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def get_lockfile_path(cls, package_name: str) -> Optional[str]:
|
|
152
|
+
"""Get lockfile path for a package."""
|
|
153
|
+
with cls._lock:
|
|
154
|
+
return cls._lockfile_paths.get(package_name)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ['LazyInstallPolicy']
|
|
158
|
+
|