@softspark/ai-toolkit 1.7.0 → 1.8.0

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,507 @@
1
+ #!/usr/bin/env python3
2
+ """Config resolver for ai-toolkit extends system.
3
+
4
+ Resolves base configs from npm packages, git URLs, or local paths.
5
+ Caches resolved configs in ~/.ai-toolkit/config-cache/.
6
+
7
+ Stdlib-only — no external dependencies.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tarfile
18
+ import tempfile
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Constants
26
+ # ---------------------------------------------------------------------------
27
+
28
+ MAX_EXTENDS_DEPTH = 5
29
+ CONFIG_FILENAME = "ai-toolkit.config.json"
30
+ PROJECT_CONFIG_FILENAME = ".ai-toolkit.json"
31
+ CACHE_DIR_NAME = "config-cache"
32
+
33
+
34
+ def _cache_root() -> Path:
35
+ """Return the cache root directory."""
36
+ return Path(os.environ.get("AI_TOOLKIT_HOME", Path.home() / ".ai-toolkit")) / CACHE_DIR_NAME
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Data classes
41
+ # ---------------------------------------------------------------------------
42
+
43
+ @dataclass
44
+ class BaseConfig:
45
+ """A resolved base configuration."""
46
+
47
+ source: str # original extends string
48
+ root: Path # directory containing the config
49
+ data: dict[str, Any] # parsed ai-toolkit.config.json
50
+ version: str = "" # resolved version (if npm)
51
+ integrity: str = "" # sha256 hash of config file
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ return self.data.get("name", self.source)
56
+
57
+ @property
58
+ def extends(self) -> str | None:
59
+ return self.data.get("extends") or None
60
+
61
+
62
+ @dataclass
63
+ class ResolutionResult:
64
+ """Result of resolving an extends chain."""
65
+
66
+ configs: list[BaseConfig] = field(default_factory=list)
67
+ warnings: list[str] = field(default_factory=list)
68
+
69
+
70
+ class ConfigResolverError(Exception):
71
+ """Raised when config resolution fails."""
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Public API
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def resolve_extends(
79
+ extends_value: str,
80
+ project_root: str | Path,
81
+ *,
82
+ refresh: bool = False,
83
+ ) -> ResolutionResult:
84
+ """Resolve an extends chain into an ordered list of base configs.
85
+
86
+ Args:
87
+ extends_value: The extends string from .ai-toolkit.json.
88
+ project_root: The project directory (for resolving relative paths).
89
+ refresh: Force re-fetch ignoring cache.
90
+
91
+ Returns:
92
+ ResolutionResult with ordered configs (deepest ancestor first).
93
+
94
+ Raises:
95
+ ConfigResolverError: On resolution failure.
96
+ """
97
+ result = ResolutionResult()
98
+ _resolve_chain(extends_value, Path(project_root), set(), result, refresh=refresh)
99
+ return result
100
+
101
+
102
+ def load_project_config(project_root: str | Path) -> dict[str, Any] | None:
103
+ """Load .ai-toolkit.json from the project root.
104
+
105
+ Returns None if the file doesn't exist.
106
+ """
107
+ config_path = Path(project_root) / PROJECT_CONFIG_FILENAME
108
+ if not config_path.is_file():
109
+ return None
110
+ return _load_json(config_path)
111
+
112
+
113
+ def load_base_config(config_dir: str | Path) -> dict[str, Any]:
114
+ """Load ai-toolkit.config.json from a directory."""
115
+ config_path = Path(config_dir) / CONFIG_FILENAME
116
+ if not config_path.is_file():
117
+ raise ConfigResolverError(
118
+ f"Base config not found: {config_path}\n"
119
+ f"Expected {CONFIG_FILENAME} in the extends target directory."
120
+ )
121
+ return _load_json(config_path)
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Internal: chain resolution
126
+ # ---------------------------------------------------------------------------
127
+
128
+ def _resolve_chain(
129
+ extends_value: str,
130
+ project_root: Path,
131
+ visited: set[str],
132
+ result: ResolutionResult,
133
+ *,
134
+ refresh: bool = False,
135
+ ) -> None:
136
+ """Recursively resolve extends chain with cycle + depth detection."""
137
+ # Cycle detection
138
+ canonical = _canonical_source(extends_value)
139
+ if canonical in visited:
140
+ raise ConfigResolverError(
141
+ f"Circular extends detected: '{extends_value}' already in chain "
142
+ f"{' -> '.join(visited)}.\n"
143
+ f"Check your base config's 'extends' field."
144
+ )
145
+
146
+ # Depth check
147
+ if len(visited) >= MAX_EXTENDS_DEPTH:
148
+ raise ConfigResolverError(
149
+ f"Extends chain too deep (max {MAX_EXTENDS_DEPTH} levels).\n"
150
+ f"Chain: {' -> '.join(visited)}"
151
+ )
152
+
153
+ visited.add(canonical)
154
+
155
+ # Resolve this source
156
+ base_config = _resolve_source(extends_value, project_root, result, refresh=refresh)
157
+
158
+ # Recurse if this base also extends something
159
+ if base_config.extends:
160
+ _resolve_chain(
161
+ base_config.extends,
162
+ base_config.root,
163
+ visited,
164
+ result,
165
+ refresh=refresh,
166
+ )
167
+
168
+ # Append after recursion (deepest ancestor first)
169
+ result.configs.append(base_config)
170
+
171
+
172
+ def _resolve_source(
173
+ source: str,
174
+ project_root: Path,
175
+ result: ResolutionResult,
176
+ *,
177
+ refresh: bool = False,
178
+ ) -> BaseConfig:
179
+ """Resolve a single extends source."""
180
+ if source.startswith("git+"):
181
+ return _resolve_git(source, result, refresh=refresh)
182
+ if source.startswith(".") or source.startswith("/") or source.startswith("~"):
183
+ return _resolve_local(source, project_root)
184
+ # Default: npm package
185
+ return _resolve_npm(source, result, refresh=refresh)
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Resolvers: npm
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def _resolve_npm(
193
+ source: str,
194
+ result: ResolutionResult,
195
+ *,
196
+ refresh: bool = False,
197
+ ) -> BaseConfig:
198
+ """Resolve from npm registry via npm pack."""
199
+ package_name, version_spec = _parse_npm_source(source)
200
+ cache_dir = _npm_cache_dir(package_name)
201
+
202
+ # Check cache first (unless refresh)
203
+ if not refresh:
204
+ cached = _find_cached_npm(cache_dir)
205
+ if cached:
206
+ # If we have a cached version and are offline, use it
207
+ try:
208
+ return _load_cached_config(cached, source)
209
+ except ConfigResolverError:
210
+ pass # cache corrupt, re-fetch
211
+
212
+ # Fetch via npm pack
213
+ pack_source = f"{package_name}@{version_spec}" if version_spec else package_name
214
+
215
+ with tempfile.TemporaryDirectory(prefix="ai-toolkit-npm-") as tmp:
216
+ tmp_path = Path(tmp)
217
+ try:
218
+ proc = subprocess.run(
219
+ ["npm", "pack", pack_source, "--pack-destination", str(tmp_path)],
220
+ capture_output=True,
221
+ text=True,
222
+ timeout=60,
223
+ )
224
+ except FileNotFoundError:
225
+ return _offline_fallback(cache_dir, source, result, "npm not found in PATH")
226
+ except subprocess.TimeoutExpired:
227
+ return _offline_fallback(cache_dir, source, result, "npm pack timed out (60s)")
228
+
229
+ if proc.returncode != 0:
230
+ return _offline_fallback(
231
+ cache_dir, source, result,
232
+ f"npm pack failed: {proc.stderr.strip()}"
233
+ )
234
+
235
+ # Find the tarball
236
+ tarballs = list(tmp_path.glob("*.tgz"))
237
+ if not tarballs:
238
+ return _offline_fallback(cache_dir, source, result, "npm pack produced no tarball")
239
+
240
+ tarball = tarballs[0]
241
+ version = _extract_version_from_tarball(tarball.name, package_name)
242
+
243
+ # Extract to cache
244
+ dest = cache_dir / version
245
+ dest.mkdir(parents=True, exist_ok=True)
246
+ _extract_tarball(tarball, dest)
247
+
248
+ return _load_cached_config(dest, source, version=version)
249
+
250
+
251
+ def _parse_npm_source(source: str) -> tuple[str, str]:
252
+ """Parse 'package@version' into (package, version) tuple."""
253
+ # Handle scoped packages: @scope/pkg@version
254
+ if source.startswith("@"):
255
+ # Find the second @ (version separator)
256
+ rest = source[1:]
257
+ if "@" in rest.split("/", 1)[-1]:
258
+ # @scope/pkg@version
259
+ parts = source.rsplit("@", 1)
260
+ return parts[0], parts[1]
261
+ return source, ""
262
+
263
+ if "@" in source:
264
+ parts = source.rsplit("@", 1)
265
+ return parts[0], parts[1]
266
+
267
+ return source, ""
268
+
269
+
270
+ def _npm_cache_dir(package_name: str) -> Path:
271
+ """Cache directory for an npm package."""
272
+ # @scope/pkg -> @scope/pkg/
273
+ return _cache_root() / package_name
274
+
275
+
276
+ def _find_cached_npm(cache_dir: Path) -> Path | None:
277
+ """Find the latest cached version directory."""
278
+ if not cache_dir.is_dir():
279
+ return None
280
+ versions = sorted(
281
+ [d for d in cache_dir.iterdir() if d.is_dir()],
282
+ key=lambda d: d.name,
283
+ reverse=True,
284
+ )
285
+ return versions[0] if versions else None
286
+
287
+
288
+ def _extract_tarball(tarball: Path, dest: Path) -> None:
289
+ """Extract npm tarball (which has a package/ prefix) to dest."""
290
+ with tarfile.open(tarball, "r:gz") as tf:
291
+ for member in tf.getmembers():
292
+ # npm tarballs have a "package/" prefix
293
+ if member.name.startswith("package/"):
294
+ member.name = member.name[len("package/"):]
295
+ if member.name: # skip empty (the "package/" dir itself)
296
+ tf.extract(member, dest)
297
+
298
+
299
+ def _extract_version_from_tarball(filename: str, package_name: str) -> str:
300
+ """Extract version from tarball filename."""
301
+ # Pattern: scope-pkg-1.0.0.tgz or pkg-1.0.0.tgz
302
+ name = filename.removesuffix(".tgz")
303
+ # Remove scope prefix if present
304
+ clean_pkg = package_name.replace("@", "").replace("/", "-")
305
+ if name.startswith(clean_pkg + "-"):
306
+ return name[len(clean_pkg) + 1:]
307
+ return name
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Resolvers: git
312
+ # ---------------------------------------------------------------------------
313
+
314
+ def _resolve_git(
315
+ source: str,
316
+ result: ResolutionResult,
317
+ *,
318
+ refresh: bool = False,
319
+ ) -> BaseConfig:
320
+ """Resolve from git URL (git+https://...)."""
321
+ url = source.removeprefix("git+")
322
+ cache_key = hashlib.sha256(url.encode()).hexdigest()[:16]
323
+ cache_dir = _cache_root() / "git" / cache_key
324
+
325
+ if not refresh and cache_dir.is_dir() and (cache_dir / CONFIG_FILENAME).is_file():
326
+ return _load_cached_config(cache_dir, source)
327
+
328
+ # Clone (shallow)
329
+ cache_dir.mkdir(parents=True, exist_ok=True)
330
+ try:
331
+ # Clean previous clone if refreshing
332
+ if cache_dir.is_dir():
333
+ shutil.rmtree(cache_dir)
334
+ cache_dir.mkdir(parents=True)
335
+
336
+ proc = subprocess.run(
337
+ ["git", "clone", "--depth", "1", url, str(cache_dir)],
338
+ capture_output=True,
339
+ text=True,
340
+ timeout=120,
341
+ )
342
+ except FileNotFoundError:
343
+ return _offline_fallback(cache_dir, source, result, "git not found in PATH")
344
+ except subprocess.TimeoutExpired:
345
+ return _offline_fallback(cache_dir, source, result, "git clone timed out (120s)")
346
+
347
+ if proc.returncode != 0:
348
+ return _offline_fallback(
349
+ cache_dir, source, result,
350
+ f"git clone failed: {proc.stderr.strip()}"
351
+ )
352
+
353
+ return _load_cached_config(cache_dir, source)
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # Resolvers: local path
358
+ # ---------------------------------------------------------------------------
359
+
360
+ def _resolve_local(source: str, project_root: Path) -> BaseConfig:
361
+ """Resolve from a local path (relative or absolute)."""
362
+ if source.startswith("~"):
363
+ resolved = Path(source).expanduser()
364
+ else:
365
+ resolved = (project_root / source).resolve()
366
+
367
+ if not resolved.is_dir():
368
+ raise ConfigResolverError(
369
+ f"Local extends path not found: {resolved}\n"
370
+ f"Source: '{source}' resolved from project root: {project_root}"
371
+ )
372
+
373
+ return _load_cached_config(resolved, source)
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Shared helpers
378
+ # ---------------------------------------------------------------------------
379
+
380
+ def _load_cached_config(
381
+ config_dir: Path,
382
+ source: str,
383
+ version: str = "",
384
+ ) -> BaseConfig:
385
+ """Load a BaseConfig from a directory."""
386
+ config_path = config_dir / CONFIG_FILENAME
387
+ if not config_path.is_file():
388
+ raise ConfigResolverError(
389
+ f"Base config not found: {config_path}\n"
390
+ f"Expected '{CONFIG_FILENAME}' in extends target.\n"
391
+ f"Source: {source}"
392
+ )
393
+
394
+ data = _load_json(config_path)
395
+ integrity = _file_hash(config_path)
396
+
397
+ return BaseConfig(
398
+ source=source,
399
+ root=config_dir,
400
+ data=data,
401
+ version=version or data.get("version", ""),
402
+ integrity=integrity,
403
+ )
404
+
405
+
406
+ def _offline_fallback(
407
+ cache_dir: Path,
408
+ source: str,
409
+ result: ResolutionResult,
410
+ reason: str,
411
+ ) -> BaseConfig:
412
+ """Fall back to cached config when fetch fails."""
413
+ cached = _find_cached_npm(cache_dir) if cache_dir.is_dir() else None
414
+
415
+ # For git caches, check directly
416
+ if cached is None and cache_dir.is_dir() and (cache_dir / CONFIG_FILENAME).is_file():
417
+ cached = cache_dir
418
+
419
+ if cached:
420
+ result.warnings.append(
421
+ f"Using cached config for '{source}' (offline). Reason: {reason}"
422
+ )
423
+ return _load_cached_config(cached, source)
424
+
425
+ raise ConfigResolverError(
426
+ f"Cannot resolve extends '{source}': {reason}\n"
427
+ f"No cached version found.\n"
428
+ f"Run 'ai-toolkit update --local --refresh-base' when online."
429
+ )
430
+
431
+
432
+ def _load_json(path: Path) -> dict[str, Any]:
433
+ """Load and parse a JSON file."""
434
+ try:
435
+ with open(path, encoding="utf-8") as f:
436
+ return json.load(f)
437
+ except json.JSONDecodeError as e:
438
+ raise ConfigResolverError(f"Invalid JSON in {path}: {e}") from e
439
+ except OSError as e:
440
+ raise ConfigResolverError(f"Cannot read {path}: {e}") from e
441
+
442
+
443
+ def _file_hash(path: Path) -> str:
444
+ """SHA-256 hash of a file."""
445
+ h = hashlib.sha256()
446
+ with open(path, "rb") as f:
447
+ for chunk in iter(lambda: f.read(8192), b""):
448
+ h.update(chunk)
449
+ return f"sha256:{h.hexdigest()}"
450
+
451
+
452
+ def _canonical_source(source: str) -> str:
453
+ """Normalize source string for cycle detection."""
454
+ # Strip version specifiers for comparison
455
+ s = source.strip()
456
+ if s.startswith("git+"):
457
+ return s.lower()
458
+ # npm: strip version
459
+ name, _ = _parse_npm_source(s)
460
+ return name.lower()
461
+
462
+
463
+ # ---------------------------------------------------------------------------
464
+ # CLI entry point (for testing)
465
+ # ---------------------------------------------------------------------------
466
+
467
+ def main() -> None:
468
+ """CLI: resolve extends and print result as JSON."""
469
+ if len(sys.argv) < 2:
470
+ print("Usage: config_resolver.py <project-dir> [--refresh]", file=sys.stderr)
471
+ sys.exit(1)
472
+
473
+ project_dir = Path(sys.argv[1])
474
+ refresh = "--refresh" in sys.argv
475
+
476
+ config = load_project_config(project_dir)
477
+ if config is None:
478
+ print(json.dumps({"error": f"No {PROJECT_CONFIG_FILENAME} found in {project_dir}"}))
479
+ sys.exit(1)
480
+
481
+ extends = config.get("extends")
482
+ if not extends:
483
+ print(json.dumps({"configs": [], "warnings": []}))
484
+ sys.exit(0)
485
+
486
+ try:
487
+ result = resolve_extends(extends, project_dir, refresh=refresh)
488
+ print(json.dumps({
489
+ "configs": [
490
+ {
491
+ "source": c.source,
492
+ "name": c.name,
493
+ "version": c.version,
494
+ "root": str(c.root),
495
+ "integrity": c.integrity,
496
+ }
497
+ for c in result.configs
498
+ ],
499
+ "warnings": result.warnings,
500
+ }, indent=2))
501
+ except ConfigResolverError as e:
502
+ print(json.dumps({"error": str(e)}))
503
+ sys.exit(1)
504
+
505
+
506
+ if __name__ == "__main__":
507
+ main()