exonware-xwlazy 0.1.0.23__py3-none-any.whl → 1.0.1.2__py3-none-any.whl

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