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.
Files changed (89) hide show
  1. exonware/__init__.py +22 -0
  2. exonware/xwlazy/__init__.py +0 -0
  3. exonware/xwlazy/common/__init__.py +47 -0
  4. exonware/xwlazy/common/base.py +58 -0
  5. exonware/xwlazy/common/cache.py +506 -0
  6. exonware/xwlazy/common/logger.py +268 -0
  7. exonware/xwlazy/common/services/__init__.py +72 -0
  8. exonware/xwlazy/common/services/dependency_mapper.py +234 -0
  9. exonware/xwlazy/common/services/install_async_utils.py +169 -0
  10. exonware/xwlazy/common/services/install_cache_utils.py +257 -0
  11. exonware/xwlazy/common/services/keyword_detection.py +292 -0
  12. exonware/xwlazy/common/services/spec_cache.py +173 -0
  13. exonware/xwlazy/common/services/state_manager.py +86 -0
  14. exonware/xwlazy/common/strategies/__init__.py +28 -0
  15. exonware/xwlazy/common/strategies/caching_dict.py +45 -0
  16. exonware/xwlazy/common/strategies/caching_installation.py +89 -0
  17. exonware/xwlazy/common/strategies/caching_lfu.py +67 -0
  18. exonware/xwlazy/common/strategies/caching_lru.py +64 -0
  19. exonware/xwlazy/common/strategies/caching_multitier.py +60 -0
  20. exonware/xwlazy/common/strategies/caching_ttl.py +60 -0
  21. exonware/xwlazy/config.py +195 -0
  22. exonware/xwlazy/contracts.py +1410 -0
  23. exonware/xwlazy/defs.py +397 -0
  24. exonware/xwlazy/errors.py +284 -0
  25. exonware/xwlazy/facade.py +1049 -0
  26. exonware/xwlazy/module/__init__.py +18 -0
  27. exonware/xwlazy/module/base.py +569 -0
  28. exonware/xwlazy/module/data.py +17 -0
  29. exonware/xwlazy/module/facade.py +247 -0
  30. exonware/xwlazy/module/importer_engine.py +2161 -0
  31. exonware/xwlazy/module/strategies/__init__.py +22 -0
  32. exonware/xwlazy/module/strategies/module_helper_lazy.py +94 -0
  33. exonware/xwlazy/module/strategies/module_helper_simple.py +66 -0
  34. exonware/xwlazy/module/strategies/module_manager_advanced.py +112 -0
  35. exonware/xwlazy/module/strategies/module_manager_simple.py +96 -0
  36. exonware/xwlazy/package/__init__.py +18 -0
  37. exonware/xwlazy/package/base.py +807 -0
  38. exonware/xwlazy/package/conf.py +331 -0
  39. exonware/xwlazy/package/data.py +17 -0
  40. exonware/xwlazy/package/facade.py +481 -0
  41. exonware/xwlazy/package/services/__init__.py +84 -0
  42. exonware/xwlazy/package/services/async_install_handle.py +89 -0
  43. exonware/xwlazy/package/services/config_manager.py +246 -0
  44. exonware/xwlazy/package/services/discovery.py +374 -0
  45. exonware/xwlazy/package/services/host_packages.py +149 -0
  46. exonware/xwlazy/package/services/install_async.py +278 -0
  47. exonware/xwlazy/package/services/install_cache.py +146 -0
  48. exonware/xwlazy/package/services/install_interactive.py +60 -0
  49. exonware/xwlazy/package/services/install_policy.py +158 -0
  50. exonware/xwlazy/package/services/install_registry.py +56 -0
  51. exonware/xwlazy/package/services/install_result.py +17 -0
  52. exonware/xwlazy/package/services/install_sbom.py +154 -0
  53. exonware/xwlazy/package/services/install_utils.py +83 -0
  54. exonware/xwlazy/package/services/installer_engine.py +408 -0
  55. exonware/xwlazy/package/services/lazy_installer.py +720 -0
  56. exonware/xwlazy/package/services/manifest.py +506 -0
  57. exonware/xwlazy/package/services/strategy_registry.py +188 -0
  58. exonware/xwlazy/package/strategies/__init__.py +57 -0
  59. exonware/xwlazy/package/strategies/package_discovery_file.py +130 -0
  60. exonware/xwlazy/package/strategies/package_discovery_hybrid.py +85 -0
  61. exonware/xwlazy/package/strategies/package_discovery_manifest.py +102 -0
  62. exonware/xwlazy/package/strategies/package_execution_async.py +114 -0
  63. exonware/xwlazy/package/strategies/package_execution_cached.py +91 -0
  64. exonware/xwlazy/package/strategies/package_execution_pip.py +100 -0
  65. exonware/xwlazy/package/strategies/package_execution_wheel.py +107 -0
  66. exonware/xwlazy/package/strategies/package_mapping_discovery_first.py +101 -0
  67. exonware/xwlazy/package/strategies/package_mapping_hybrid.py +106 -0
  68. exonware/xwlazy/package/strategies/package_mapping_manifest_first.py +101 -0
  69. exonware/xwlazy/package/strategies/package_policy_allow_list.py +58 -0
  70. exonware/xwlazy/package/strategies/package_policy_deny_list.py +58 -0
  71. exonware/xwlazy/package/strategies/package_policy_permissive.py +47 -0
  72. exonware/xwlazy/package/strategies/package_timing_clean.py +68 -0
  73. exonware/xwlazy/package/strategies/package_timing_full.py +67 -0
  74. exonware/xwlazy/package/strategies/package_timing_smart.py +69 -0
  75. exonware/xwlazy/package/strategies/package_timing_temporary.py +67 -0
  76. exonware/xwlazy/runtime/__init__.py +18 -0
  77. exonware/xwlazy/runtime/adaptive_learner.py +131 -0
  78. exonware/xwlazy/runtime/base.py +276 -0
  79. exonware/xwlazy/runtime/facade.py +95 -0
  80. exonware/xwlazy/runtime/intelligent_selector.py +173 -0
  81. exonware/xwlazy/runtime/metrics.py +64 -0
  82. exonware/xwlazy/runtime/performance.py +39 -0
  83. exonware/xwlazy/version.py +2 -2
  84. exonware_xwlazy-0.1.0.19.dist-info/METADATA +456 -0
  85. exonware_xwlazy-0.1.0.19.dist-info/RECORD +87 -0
  86. exonware_xwlazy-0.1.0.10.dist-info/METADATA +0 -0
  87. exonware_xwlazy-0.1.0.10.dist-info/RECORD +0 -6
  88. {exonware_xwlazy-0.1.0.10.dist-info → exonware_xwlazy-0.1.0.19.dist-info}/WHEEL +0 -0
  89. {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
+