exonware-xwlazy 0.1.0.10__py3-none-any.whl → 0.1.0.11__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.
@@ -0,0 +1,489 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ xwlazy.lazy.manifest
5
+ --------------------
6
+
7
+ Centralized loader for per-package dependency manifests. A manifest can be
8
+ declared either as a JSON file located in the target project's root directory
9
+ or inline inside ``pyproject.toml`` under the ``[tool.xwlazy]`` namespace.
10
+
11
+ The loader consolidates the following pieces of information:
12
+
13
+ * Explicit import -> package mappings
14
+ * Serialization/watch prefixes that should be handled by the import hook
15
+ * Async installation preferences (queue enabled + worker count)
16
+
17
+ It also keeps lightweight caches with file-modification tracking so repeated
18
+ lookups do not hit the filesystem unnecessarily.
19
+ """
20
+
21
+ from dataclasses import dataclass, field
22
+ import importlib.util
23
+ import json
24
+ import os
25
+ from pathlib import Path
26
+ from threading import RLock
27
+ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
28
+
29
+ try: # Python 3.11+
30
+ import tomllib # type: ignore[attr-defined]
31
+ except Exception: # pragma: no cover - fallback for <=3.10
32
+ try:
33
+ import tomli as tomllib # type: ignore[attr-defined]
34
+ except ImportError: # pragma: no cover
35
+ tomllib = None # type: ignore
36
+
37
+ DEFAULT_MANIFEST_FILENAMES: Tuple[str, ...] = (
38
+ "xwlazy.manifest.json",
39
+ "lazy.manifest.json",
40
+ ".xwlazy.manifest.json",
41
+ )
42
+
43
+ ENV_MANIFEST_PATH = "XWLAZY_MANIFEST_PATH"
44
+
45
+
46
+ def _normalize_package_name(package_name: Optional[str]) -> str:
47
+ return (package_name or "global").strip().lower()
48
+
49
+
50
+ def _normalize_prefix(prefix: str) -> str:
51
+ prefix = prefix.strip()
52
+ if not prefix:
53
+ return ""
54
+ if not prefix.endswith("."):
55
+ prefix = f"{prefix}."
56
+ return prefix
57
+
58
+
59
+ def _normalize_wrap_hints(values: Iterable[Any]) -> List[str]:
60
+ hints: List[str] = []
61
+ for value in values:
62
+ if value is None:
63
+ continue
64
+ hint = str(value).strip().lower()
65
+ if hint:
66
+ hints.append(hint)
67
+ return hints
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class PackageManifest:
72
+ """Resolved manifest data for a single package."""
73
+
74
+ package: str
75
+ dependencies: Dict[str, str] = field(default_factory=dict)
76
+ watched_prefixes: Tuple[str, ...] = ()
77
+ async_installs: bool = False
78
+ async_workers: int = 1
79
+ class_wrap_prefixes: Tuple[str, ...] = ()
80
+ metadata: Dict[str, Any] = field(default_factory=dict)
81
+
82
+ def get_dependency(self, import_name: str) -> Optional[str]:
83
+ """Return the declared package for the given import name."""
84
+ if not import_name:
85
+ return None
86
+ direct = self.dependencies.get(import_name)
87
+ if direct is not None:
88
+ return direct
89
+ # Case-insensitive fallback for convenience
90
+ return self.dependencies.get(import_name.lower())
91
+
92
+
93
+ class LazyManifestLoader:
94
+ """
95
+ Loads and caches manifest data per package.
96
+
97
+ Args:
98
+ default_root: Optional fallback root directory used when a package
99
+ root cannot be auto-detected.
100
+ package_roots: Optional explicit mapping used mainly for tests.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ default_root: Optional[Path] = None,
106
+ package_roots: Optional[Dict[str, Path]] = None,
107
+ ) -> None:
108
+ self._default_root = default_root
109
+ self._provided_roots = {
110
+ _normalize_package_name(name): Path(path)
111
+ for name, path in (package_roots or {}).items()
112
+ }
113
+ self._manifest_cache: Dict[str, PackageManifest] = {}
114
+ self._source_signatures: Dict[str, Tuple[str, float, float]] = {}
115
+ self._pyproject_cache: Dict[Path, Tuple[float, Dict[str, Any]]] = {}
116
+ self._shared_dependency_maps: Dict[
117
+ Tuple[str, float, float], Dict[str, Dict[str, str]]
118
+ ] = {}
119
+ self._lock = RLock()
120
+ self._generation = 0
121
+
122
+ @property
123
+ def generation(self) -> int:
124
+ """Incremented whenever any manifest content changes."""
125
+ return self._generation
126
+
127
+ def clear_cache(self) -> None:
128
+ """Forcefully clear cached manifests."""
129
+ with self._lock:
130
+ self._manifest_cache.clear()
131
+ self._source_signatures.clear()
132
+ self._pyproject_cache.clear()
133
+ self._shared_dependency_maps.clear()
134
+ self._generation += 1
135
+
136
+ # --------------------------------------------------------------------- #
137
+ # Public API
138
+ # --------------------------------------------------------------------- #
139
+ def get_manifest(self, package_name: Optional[str]) -> Optional[PackageManifest]:
140
+ """Return manifest for the provided package."""
141
+ key = _normalize_package_name(package_name)
142
+ with self._lock:
143
+ signature = self._compute_signature(key)
144
+ cached_signature = self._source_signatures.get(key)
145
+ if (
146
+ cached_signature is not None
147
+ and signature is not None
148
+ and cached_signature == signature
149
+ and key in self._manifest_cache
150
+ ):
151
+ return self._manifest_cache[key]
152
+
153
+ manifest = self._load_manifest(key)
154
+ if manifest is None:
155
+ # Cache miss is still tracked so we don't re-read files
156
+ self._manifest_cache.pop(key, None)
157
+ self._source_signatures[key] = signature or ("", 0.0, 0.0)
158
+ return None
159
+
160
+ self._manifest_cache[key] = manifest
161
+ if signature is not None:
162
+ self._source_signatures[key] = signature
163
+ per_signature = self._shared_dependency_maps.setdefault(signature, {})
164
+ per_signature[manifest.package] = manifest.dependencies.copy()
165
+ self._generation += 1
166
+ return manifest
167
+
168
+ def get_manifest_signature(self, package_name: Optional[str]) -> Optional[Tuple[str, float, float]]:
169
+ key = _normalize_package_name(package_name)
170
+ with self._lock:
171
+ signature = self._source_signatures.get(key)
172
+ if signature is not None:
173
+ return signature
174
+ signature = self._compute_signature(key)
175
+ if signature is not None:
176
+ self._source_signatures[key] = signature
177
+ return signature
178
+
179
+ def get_shared_dependencies(
180
+ self,
181
+ package_name: Optional[str],
182
+ signature: Optional[Tuple[str, float, float]],
183
+ ) -> Optional[Dict[str, str]]:
184
+ if signature is None:
185
+ return None
186
+ with self._lock:
187
+ package_maps = self._shared_dependency_maps.get(signature)
188
+ if not package_maps:
189
+ return None
190
+ key = _normalize_package_name(package_name)
191
+ return package_maps.get(key)
192
+
193
+ # ------------------------------------------------------------------ #
194
+ # Internal helpers
195
+ # ------------------------------------------------------------------ #
196
+ def _load_manifest(self, package_key: str) -> Optional[PackageManifest]:
197
+ root = self._resolve_project_root(package_key)
198
+ pyproject_path = root / "pyproject.toml"
199
+ pyproject_data = self._load_pyproject(pyproject_path)
200
+ json_data, manifest_path = self._load_json_manifest(root, pyproject_data)
201
+
202
+ data = self._merge_sources(package_key, pyproject_data, json_data)
203
+ if not data["dependencies"] and not data["watched_prefixes"] and not data["async_installs"]:
204
+ return None
205
+
206
+ wrap_prefixes = tuple(data.get("wrap_class_prefixes", ()))
207
+
208
+ manifest = PackageManifest(
209
+ package=package_key,
210
+ dependencies=data["dependencies"],
211
+ watched_prefixes=tuple(
212
+ _normalize_prefix(prefix)
213
+ for prefix in data["watched_prefixes"]
214
+ if _normalize_prefix(prefix)
215
+ ),
216
+ async_installs=bool(data.get("async_installs")),
217
+ async_workers=max(1, int(data.get("async_workers", 1))),
218
+ class_wrap_prefixes=wrap_prefixes,
219
+ metadata={
220
+ "root": str(root),
221
+ "manifest_path": str(manifest_path) if manifest_path else None,
222
+ "wrap_class_prefixes": wrap_prefixes,
223
+ },
224
+ )
225
+ return manifest
226
+
227
+ def _compute_signature(self, package_key: str) -> Optional[Tuple[str, float, float]]:
228
+ root = self._resolve_project_root(package_key)
229
+ pyproject_path = root / "pyproject.toml"
230
+ pyproject_mtime = pyproject_path.stat().st_mtime if pyproject_path.exists() else 0.0
231
+ manifest_path = self._resolve_manifest_path(root, pyproject_path)
232
+ json_mtime = manifest_path.stat().st_mtime if manifest_path and manifest_path.exists() else 0.0
233
+ env_token = os.environ.get(ENV_MANIFEST_PATH, "")
234
+ if not manifest_path and not pyproject_path.exists() and not env_token:
235
+ return None
236
+ return (env_token + str(manifest_path), pyproject_mtime, json_mtime)
237
+
238
+ def _resolve_project_root(self, package_key: str) -> Path:
239
+ if package_key in self._provided_roots:
240
+ return self._provided_roots[package_key]
241
+
242
+ module_candidates: Iterable[str]
243
+ if package_key == "global":
244
+ module_candidates = ()
245
+ else:
246
+ module_candidates = (f"exonware.{package_key}", package_key)
247
+
248
+ for module_name in module_candidates:
249
+ spec = importlib.util.find_spec(module_name)
250
+ if spec and spec.origin:
251
+ origin_path = Path(spec.origin).resolve()
252
+ root = self._walk_to_project_root(origin_path.parent)
253
+ if root:
254
+ self._provided_roots[package_key] = root
255
+ return root
256
+
257
+ if self._default_root:
258
+ return self._default_root
259
+ return Path.cwd()
260
+
261
+ @staticmethod
262
+ def _walk_to_project_root(start: Path) -> Optional[Path]:
263
+ current = start
264
+ while True:
265
+ if (current / "pyproject.toml").exists():
266
+ return current
267
+ parent = current.parent
268
+ if parent == current:
269
+ break
270
+ current = parent
271
+ return None
272
+
273
+ # ------------------------------- #
274
+ # Pyproject helpers
275
+ # ------------------------------- #
276
+ def _load_pyproject(self, path: Path) -> Dict[str, Any]:
277
+ if not path.exists() or tomllib is None:
278
+ return {}
279
+
280
+ cached = self._pyproject_cache.get(path)
281
+ current_mtime = path.stat().st_mtime
282
+ if cached and cached[0] == current_mtime:
283
+ return cached[1]
284
+
285
+ try:
286
+ with path.open("rb") as handle:
287
+ data = tomllib.load(handle)
288
+ except Exception:
289
+ data = {}
290
+
291
+ self._pyproject_cache[path] = (current_mtime, data)
292
+ return data
293
+
294
+ def _extract_pyproject_entry(
295
+ self,
296
+ pyproject_data: Dict[str, Any],
297
+ package_key: str,
298
+ ) -> Dict[str, Any]:
299
+ tool_section = pyproject_data.get("tool", {})
300
+ lazy_section = tool_section.get("xwlazy", {})
301
+ packages = lazy_section.get("packages", {})
302
+ entry = packages.get(package_key, {}) or packages.get(package_key.upper(), {})
303
+
304
+ dependencies = {}
305
+ watched = []
306
+ async_installs = lazy_section.get("async_installs") or entry.get("async_installs")
307
+ async_workers = entry.get("async_workers") or lazy_section.get("async_workers")
308
+ wrap_hints = []
309
+
310
+ global_deps = lazy_section.get("dependencies", {})
311
+ if isinstance(global_deps, dict):
312
+ dependencies.update({str(k): str(v) for k, v in global_deps.items()})
313
+
314
+ if "dependencies" in entry and isinstance(entry["dependencies"], dict):
315
+ dependencies.update({str(k): str(v) for k, v in entry["dependencies"].items()})
316
+
317
+ for key in ("watched-prefixes", "watched_prefixes", "watch"):
318
+ values = entry.get(key) or lazy_section.get(key)
319
+ if isinstance(values, list):
320
+ watched.extend(str(v) for v in values)
321
+
322
+ global_wrap = lazy_section.get("wrap_class_prefixes") or lazy_section.get("wrap_classes")
323
+ if isinstance(global_wrap, list):
324
+ wrap_hints.extend(_normalize_wrap_hints(global_wrap))
325
+ entry_wrap = entry.get("wrap_class_prefixes") or entry.get("wrap_classes")
326
+ if isinstance(entry_wrap, list):
327
+ wrap_hints.extend(_normalize_wrap_hints(entry_wrap))
328
+
329
+ return {
330
+ "dependencies": dependencies,
331
+ "watched_prefixes": watched,
332
+ "async_installs": bool(async_installs),
333
+ "async_workers": async_workers or 1,
334
+ "wrap_class_prefixes": wrap_hints,
335
+ }
336
+
337
+ # ------------------------------- #
338
+ # Manifest helpers
339
+ # ------------------------------- #
340
+ def _resolve_manifest_path(self, root: Path, pyproject_path: Path) -> Optional[Path]:
341
+ env_value = os.environ.get(ENV_MANIFEST_PATH)
342
+ if env_value:
343
+ for raw in env_value.split(os.pathsep):
344
+ candidate = Path(raw).expanduser()
345
+ if candidate.exists():
346
+ return candidate
347
+
348
+ if pyproject_path.exists() and tomllib is not None:
349
+ py_data = self._load_pyproject(pyproject_path)
350
+ tool_section = py_data.get("tool", {}).get("xwlazy", {})
351
+ manifest_path = tool_section.get("manifest") or tool_section.get("manifest_path")
352
+ if manifest_path:
353
+ candidate = (root / manifest_path).resolve()
354
+ if candidate.exists():
355
+ return candidate
356
+
357
+ for filename in DEFAULT_MANIFEST_FILENAMES:
358
+ candidate = root / filename
359
+ if candidate.exists():
360
+ return candidate
361
+
362
+ return None
363
+
364
+ def _load_json_manifest(
365
+ self,
366
+ root: Path,
367
+ pyproject_data: Dict[str, Any],
368
+ ) -> Tuple[Dict[str, Any], Optional[Path]]:
369
+ manifest_path = self._resolve_manifest_path(root, root / "pyproject.toml")
370
+ if not manifest_path:
371
+ return {}, None
372
+
373
+ try:
374
+ with manifest_path.open("r", encoding="utf-8") as handle:
375
+ data = json.load(handle)
376
+ if isinstance(data, dict):
377
+ return data, manifest_path
378
+ except Exception:
379
+ pass
380
+
381
+ return {}, manifest_path
382
+
383
+ def _merge_sources(
384
+ self,
385
+ package_key: str,
386
+ pyproject_data: Dict[str, Any],
387
+ json_data: Dict[str, Any],
388
+ ) -> Dict[str, Any]:
389
+ merged_dependencies: Dict[str, str] = {}
390
+ merged_watched: List[str] = []
391
+ merged_wrap_hints: List[str] = []
392
+
393
+ # Pyproject first (acts as baseline)
394
+ py_entry = self._extract_pyproject_entry(pyproject_data, package_key)
395
+ merged_dependencies.update({k.lower(): v for k, v in py_entry["dependencies"].items()})
396
+ merged_watched.extend(py_entry["watched_prefixes"])
397
+ merged_wrap_hints.extend(_normalize_wrap_hints(py_entry.get("wrap_class_prefixes", [])))
398
+
399
+ async_installs = py_entry.get("async_installs", False)
400
+ async_workers = py_entry.get("async_workers", 1)
401
+
402
+ # JSON global settings
403
+ global_deps = json_data.get("dependencies", {})
404
+ if isinstance(global_deps, dict):
405
+ merged_dependencies.update({str(k).lower(): str(v) for k, v in global_deps.items()})
406
+
407
+ global_watch = json_data.get("watch") or json_data.get("watched_prefixes")
408
+ if isinstance(global_watch, list):
409
+ merged_watched.extend(str(item) for item in global_watch)
410
+ global_wrap = json_data.get("wrap_class_prefixes") or json_data.get("wrap_classes")
411
+ if isinstance(global_wrap, list):
412
+ merged_wrap_hints.extend(_normalize_wrap_hints(global_wrap))
413
+
414
+ global_async = json_data.get("async_installs")
415
+ if global_async is not None:
416
+ async_installs = bool(global_async)
417
+ global_workers = json_data.get("async_workers")
418
+ if global_workers is not None:
419
+ async_workers = global_workers
420
+
421
+ packages_section = json_data.get("packages", {})
422
+ if isinstance(packages_section, dict):
423
+ entry = packages_section.get(package_key) or packages_section.get(package_key.upper())
424
+ if isinstance(entry, dict):
425
+ entry_deps = entry.get("dependencies", {})
426
+ if isinstance(entry_deps, dict):
427
+ merged_dependencies.update({str(k).lower(): str(v) for k, v in entry_deps.items()})
428
+
429
+ entry_watch = entry.get("watched_prefixes") or entry.get("watch")
430
+ if isinstance(entry_watch, list):
431
+ merged_watched.extend(str(item) for item in entry_watch)
432
+ entry_wrap = entry.get("wrap_class_prefixes") or entry.get("wrap_classes")
433
+ if isinstance(entry_wrap, list):
434
+ merged_wrap_hints.extend(_normalize_wrap_hints(entry_wrap))
435
+
436
+ if "async_installs" in entry:
437
+ async_installs = bool(entry["async_installs"])
438
+ if "async_workers" in entry:
439
+ async_workers = entry.get("async_workers", async_workers)
440
+
441
+ seen_wrap: Set[str] = set()
442
+ ordered_wrap_hints: List[str] = []
443
+ for hint in merged_wrap_hints:
444
+ if hint not in seen_wrap:
445
+ seen_wrap.add(hint)
446
+ ordered_wrap_hints.append(hint)
447
+
448
+ return {
449
+ "dependencies": merged_dependencies,
450
+ "watched_prefixes": merged_watched,
451
+ "async_installs": async_installs,
452
+ "async_workers": async_workers,
453
+ "wrap_class_prefixes": ordered_wrap_hints,
454
+ }
455
+
456
+
457
+ _manifest_loader: Optional[LazyManifestLoader] = None
458
+ _manifest_loader_lock = RLock()
459
+
460
+
461
+ def get_manifest_loader() -> LazyManifestLoader:
462
+ """
463
+ Return the process-wide manifest loader instance.
464
+
465
+ Calling this function does not force any manifest to be loaded, but the
466
+ loader keeps shared caches that allow multiple subsystems (dependency mapper,
467
+ installer, hook configuration) to observe manifest changes consistently.
468
+ """
469
+ global _manifest_loader
470
+ with _manifest_loader_lock:
471
+ if _manifest_loader is None:
472
+ _manifest_loader = LazyManifestLoader()
473
+ return _manifest_loader
474
+
475
+
476
+ def refresh_manifest_cache() -> None:
477
+ """Forcefully clear the shared manifest loader cache."""
478
+ loader = get_manifest_loader()
479
+ loader.clear_cache()
480
+
481
+
482
+ __all__ = [
483
+ "PackageManifest",
484
+ "LazyManifestLoader",
485
+ "get_manifest_loader",
486
+ "refresh_manifest_cache",
487
+ ]
488
+
489
+
xwlazy/version.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ Centralized version management for eXonware projects.
3
+
4
+ Company: eXonware.com
5
+ Author: Eng. Muhammad AlShehri
6
+ Email: connect@exonware.com
7
+
8
+ This module provides centralized version management for the entire project.
9
+ All version references should import from this module to ensure consistency.
10
+ """
11
+
12
+ # =============================================================================
13
+ # VERSION CONFIGURATION
14
+ # =============================================================================
15
+
16
+ # Main version - update this to change version across entire project
17
+ __version__ = "0.1.0.11"
18
+
19
+ # Version components for programmatic access
20
+ VERSION_MAJOR = 0
21
+ VERSION_MINOR = 1
22
+ VERSION_PATCH = 0
23
+ VERSION_BUILD = 11 # Set to None for releases, or build number for dev builds
24
+
25
+ # Version metadata
26
+ VERSION_SUFFIX = "" # e.g., "dev", "alpha", "beta", "rc1"
27
+ VERSION_STRING = __version__ + VERSION_SUFFIX
28
+
29
+ # =============================================================================
30
+ # VERSION UTILITIES
31
+ # =============================================================================
32
+
33
+ def get_version() -> str:
34
+ """Get the current version string."""
35
+ return VERSION_STRING
36
+
37
+ def get_version_info() -> tuple:
38
+ """Get version as a tuple (major, minor, patch, build)."""
39
+ return (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH, VERSION_BUILD)
40
+
41
+ def get_version_dict() -> dict:
42
+ """Get version information as a dictionary."""
43
+ return {
44
+ "version": VERSION_STRING,
45
+ "major": VERSION_MAJOR,
46
+ "minor": VERSION_MINOR,
47
+ "patch": VERSION_PATCH,
48
+ "build": VERSION_BUILD,
49
+ "suffix": VERSION_SUFFIX
50
+ }
51
+
52
+ def is_dev_version() -> bool:
53
+ """Check if this is a development version."""
54
+ return VERSION_SUFFIX in ("dev", "alpha", "beta") or VERSION_BUILD is not None
55
+
56
+ def is_release_version() -> bool:
57
+ """Check if this is a release version."""
58
+ return not is_dev_version()
59
+
60
+ # =============================================================================
61
+ # EXPORTS
62
+ # =============================================================================
63
+
64
+ __all__ = [
65
+ "__version__",
66
+ "VERSION_MAJOR",
67
+ "VERSION_MINOR",
68
+ "VERSION_PATCH",
69
+ "VERSION_BUILD",
70
+ "VERSION_SUFFIX",
71
+ "VERSION_STRING",
72
+ "get_version",
73
+ "get_version_info",
74
+ "get_version_dict",
75
+ "is_dev_version",
76
+ "is_release_version"
77
+ ]
Binary file
@@ -1,6 +0,0 @@
1
- exonware/xwlazy/__init__.py,sha256=ZXpGiNGjj5KRB6q4nDas863XgPcfOa89X8YTC2M_W2o,448
2
- exonware/xwlazy/version.py,sha256=Y0qdE6fMxvnPdXCN7cBnCUDe9cVyriw9mVrZO6o6Ap0,2351
3
- exonware_xwlazy-0.1.0.10.dist-info/METADATA,sha256=gPufZiVZr3HhSBQNpeZz1VS-HvQ-kUdm7AL7az399jI,1099
4
- exonware_xwlazy-0.1.0.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
5
- exonware_xwlazy-0.1.0.10.dist-info/licenses/LICENSE,sha256=w42ohoEUfhyT0NgiivAL4fWg2AMRLGnfXPMAR4EO-MU,1094
6
- exonware_xwlazy-0.1.0.10.dist-info/RECORD,,