sima-cli 0.0.20__py3-none-any.whl → 0.0.22__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,447 @@
1
+ import os
2
+ import re
3
+ import tempfile
4
+ import click
5
+ import json
6
+ import sys
7
+ import shutil
8
+ import tarfile
9
+ import zipfile
10
+ from urllib.parse import urlparse
11
+
12
+ from typing import Dict
13
+ from tqdm import tqdm
14
+ from urllib.parse import urljoin
15
+ from pathlib import Path
16
+ import subprocess
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ from huggingface_hub import snapshot_download
22
+
23
+ from sima_cli.utils.disk import check_disk_space
24
+ from sima_cli.utils.env import get_environment_type, get_exact_devkit_type
25
+ from sima_cli.download.downloader import download_file_from_url
26
+ from sima_cli.install.metadata_validator import validate_metadata, MetadataValidationError
27
+ from sima_cli.install.metadata_info import print_metadata_summary, parse_size_string_to_bytes
28
+
29
+ console = Console()
30
+
31
+ def _download_assets(metadata: dict, base_url: str, dest_folder: str, internal: bool = False, skip_models: bool = False) -> list:
32
+ """
33
+ Downloads resources defined in metadata to a local destination folder.
34
+
35
+ Args:
36
+ metadata (dict): Parsed and validated metadata
37
+ base_url (str): Base URL of the metadata file (used to resolve relative resource paths)
38
+ dest_folder (str): Local path to download resources into
39
+ internal (bool): Whether to use internal download routing (if applicable)
40
+ skip_models (bool): If True, skips downloading any file path starting with 'models/'
41
+
42
+ Returns:
43
+ list: Paths to the downloaded local files
44
+ """
45
+ resources = metadata.get("resources", [])
46
+ if not resources:
47
+ raise click.ClickException("❌ No 'resources' defined in metadata.")
48
+
49
+ os.makedirs(dest_folder, exist_ok=True)
50
+ local_paths = []
51
+
52
+ filtered_resources = []
53
+ for r in resources:
54
+ if skip_models and r.strip().lower().startswith("models/"):
55
+ click.echo(f"⏭️ Skipping model file: {r}")
56
+ continue
57
+ filtered_resources.append(r)
58
+
59
+ if not filtered_resources:
60
+ click.echo("ℹ️ No non-model resources to download.")
61
+ return []
62
+
63
+ click.echo(f"📥 Downloading {len(filtered_resources)} resource(s) to: {dest_folder}\n")
64
+
65
+ for resource in filtered_resources:
66
+ try:
67
+ # Handle Hugging Face snapshot-style URL: "hf:<repo_id>@version"
68
+ if resource.startswith("hf:"):
69
+ # Strip prefix and split by @
70
+ resource_spec = resource[3:]
71
+ if "@" in resource_spec:
72
+ repo_id, revision = resource_spec.split("@", 1)
73
+ else:
74
+ repo_id, revision = resource_spec, None
75
+
76
+ if "/" not in repo_id:
77
+ raise click.ClickException(f"❌ Invalid Hugging Face repo spec: {resource}")
78
+
79
+ org, name = repo_id.split("/", 1)
80
+ target_dir = os.path.join(dest_folder, name)
81
+
82
+ click.echo(f"🤗 Downloading Hugging Face repo: {repo_id}" + (f"@{revision}" if revision else ""))
83
+ model_path = snapshot_download(
84
+ repo_id=repo_id,
85
+ local_dir=target_dir,
86
+ revision=revision # None if not specified
87
+ )
88
+ local_paths.append(model_path)
89
+ continue
90
+
91
+ # Handle normal relative or absolute URLs
92
+ resource_url = urljoin(base_url, resource)
93
+ local_path = download_file_from_url(
94
+ url=resource_url,
95
+ dest_folder=dest_folder,
96
+ internal=internal
97
+ )
98
+ click.echo(f"✅ Downloaded: {resource}")
99
+ local_paths.append(local_path)
100
+
101
+ except Exception as e:
102
+ raise click.ClickException(f"❌ Failed to download resource '{resource}': {e}")
103
+
104
+ return local_paths
105
+
106
+ def _download_and_validate_metadata(metadata_url, internal=False):
107
+ """
108
+ Downloads (if remote), validates, and parses metadata from a given URL or local file path.
109
+
110
+ Args:
111
+ metadata_url (str): URL or local path to a metadata.json file
112
+ internal (bool): Whether to use internal mirrors or logic in downloader
113
+
114
+ Returns:
115
+ tuple: (parsed metadata dict, folder containing the metadata file)
116
+ """
117
+ try:
118
+ parsed = urlparse(metadata_url)
119
+
120
+ # Case 1: Local file (e.g., /path/to/file or ./file)
121
+ if parsed.scheme == "" or parsed.scheme == "file":
122
+ metadata_path = parsed.path
123
+ if not os.path.isfile(metadata_path):
124
+ raise FileNotFoundError(f"File not found: {metadata_path}")
125
+ click.echo(f"📄 Using local metadata file: {metadata_path}")
126
+
127
+ # Case 2: Remote URL
128
+ else:
129
+ with tempfile.TemporaryDirectory() as tmpdir:
130
+ metadata_path = download_file_from_url(
131
+ url=metadata_url,
132
+ dest_folder=tmpdir,
133
+ internal=internal
134
+ )
135
+ click.echo(f"⬇️ Downloaded metadata to: {metadata_path}")
136
+
137
+ # Must copy to outside tmpdir since tmpdir will be deleted
138
+ # But since we're returning contents only, no need to keep file
139
+ with open(metadata_path, "r", encoding="utf-8") as f:
140
+ metadata = json.load(f)
141
+ validate_metadata(metadata)
142
+ click.echo("✅ Metadata validated successfully.")
143
+ return metadata, os.path.dirname(metadata_path)
144
+
145
+ # Common validation logic for local file
146
+ with open(metadata_path, "r", encoding="utf-8") as f:
147
+ metadata = json.load(f)
148
+
149
+ validate_metadata(metadata)
150
+ click.echo("✅ Metadata validated successfully.")
151
+ return metadata, os.path.dirname(os.path.abspath(metadata_path))
152
+
153
+ except MetadataValidationError as e:
154
+ click.echo(f"❌ Metadata validation failed: {e}")
155
+ raise click.Abort()
156
+
157
+ except Exception as e:
158
+ click.echo(f"❌ Failed to retrieve or parse metadata from {metadata_url}: {e}")
159
+ raise click.Abort()
160
+
161
+ def _check_whether_disk_is_big_enough(metadata: dict):
162
+ # Step 3: Disk space check
163
+ try:
164
+ install_size_str = metadata.get("size", {}).get("install")
165
+ if install_size_str:
166
+ required_bytes = parse_size_string_to_bytes(install_size_str)
167
+ if not check_disk_space(required_bytes, folder="."):
168
+ required_gb = required_bytes / 1e9
169
+ raise click.ClickException(
170
+ f"Not enough disk space. At least {required_gb:.2f} GB required the in current directory."
171
+ )
172
+
173
+ available_bytes = shutil.disk_usage(".").free
174
+ available_gb = available_bytes / 1e9
175
+ required_gb = required_bytes / 1e9
176
+ click.echo(f"🗄️ Available disk space: {available_gb:.2f} GB")
177
+ click.echo(f"✅ Enough disk space for installation: requires {required_gb:.2f} GB")
178
+ return True
179
+ except Exception as e:
180
+ click.echo(f"❌ Failed to validate disk space: {e}")
181
+ raise click.Abort()
182
+
183
+ return False
184
+
185
+ def _extract_tar_streaming(tar_path: Path, extract_dir: Path):
186
+ """
187
+ Extract tar while preserving full folder structure.
188
+ """
189
+ extracted_files = 0
190
+ with tarfile.open(tar_path, "r:*") as tar:
191
+ with tqdm(desc=f"📦 Extracting {tar_path.name}", unit=" file") as pbar:
192
+ while True:
193
+ member = tar.next()
194
+ if member is None:
195
+ break
196
+
197
+ # Don't strip anything — preserve full path
198
+ if not member.name.strip():
199
+ print(f"⚠️ Skipping empty member in archive: {member}")
200
+ continue
201
+
202
+ tar.extract(member, path=extract_dir)
203
+ extracted_files += 1
204
+ pbar.update(1)
205
+
206
+ print(f"✅ Extracted {extracted_files} files to {extract_dir}/")
207
+
208
+ def _extract_zip_streaming(zip_path: Path, extract_dir: Path):
209
+ """
210
+ Extract a .zip file using streaming to avoid NFS slowness from metadata calls,
211
+ and flatten one top-level directory if present.
212
+ """
213
+ with zipfile.ZipFile(zip_path, "r") as zipf:
214
+ members = zipf.infolist()
215
+ with tqdm(total=len(members), desc=f"📦 Extracting {zip_path.name}", unit="file") as pbar:
216
+ for member in members:
217
+ # Strip one top-level directory if it exists
218
+ parts = Path(member.filename).parts
219
+ if len(parts) > 1:
220
+ stripped_path = Path(*parts[1:])
221
+ else:
222
+ stripped_path = Path(parts[-1])
223
+
224
+ target_path = extract_dir / stripped_path
225
+ target_path.parent.mkdir(parents=True, exist_ok=True)
226
+
227
+ with zipf.open(member) as src, open(target_path, "wb") as dst:
228
+ shutil.copyfileobj(src, dst)
229
+
230
+ pbar.update(1)
231
+
232
+ print(f"✅ Extracted {len(members)} files to {extract_dir}/")
233
+
234
+ def _combine_multipart_files(folder: str):
235
+ """
236
+ Scan a folder for multipart files like name-split-aa, -ab, etc.,
237
+ combine them into a single file, and remove the split parts.
238
+ Then auto-extract .tar files with progress.
239
+ """
240
+ folder = Path(folder)
241
+ parts_by_base = {}
242
+
243
+ # Step 1: Group parts by base name
244
+ for file in folder.iterdir():
245
+ if not file.is_file():
246
+ continue
247
+
248
+ match = re.match(r"(.+)-split-([a-z]{2})$", file.name)
249
+ if match:
250
+ base, part = match.groups()
251
+ parts_by_base.setdefault(base, []).append((part, file))
252
+
253
+ # Step 2: Process each group
254
+ for base, parts in parts_by_base.items():
255
+ parts.sort(key=lambda x: x[0])
256
+ output_file = folder / f"{base}.tar"
257
+ total_size = sum(part_file.stat().st_size for _, part_file in parts)
258
+
259
+ print(f"\n🧩 Reassembling: {output_file.name} from {len(parts)} parts")
260
+
261
+ if not output_file.exists():
262
+ with open(output_file, "wb") as outfile, tqdm(
263
+ total=total_size,
264
+ unit="B",
265
+ unit_scale=True,
266
+ unit_divisor=1024,
267
+ desc=f"Combining {output_file.name}",
268
+ ) as pbar:
269
+ for _, part_file in parts:
270
+ with open(part_file, "rb") as infile:
271
+ while True:
272
+ chunk = infile.read(1024 * 1024) # 1MB
273
+ if not chunk:
274
+ break
275
+ outfile.write(chunk)
276
+ pbar.update(len(chunk))
277
+
278
+ # Step 3: Remove original parts
279
+ # for _, part_file in parts:
280
+ # part_file.unlink()
281
+
282
+ print(f"✅ Created: {output_file.name} ({output_file.stat().st_size / 1e6:.2f} MB)")
283
+
284
+ # Step 4: Auto-extract .tar
285
+ extract_dir = folder / base
286
+ print(f"📦 Extracting {output_file.name} to {extract_dir}/")
287
+ _extract_tar_streaming(output_file, extract_dir)
288
+
289
+ print(f"✅ Extracted to: {extract_dir}/")
290
+
291
+ def _extract_archives_in_folder(folder: str):
292
+ """
293
+ Extract all .tar.gz and .zip files in the given folder into subdirectories.
294
+ Uses streaming to avoid NFS performance issues.
295
+ """
296
+ folder = Path(folder)
297
+ for file in folder.iterdir():
298
+ if not file.is_file():
299
+ continue
300
+
301
+ # TAR.GZ
302
+ if file.suffixes == [".tar", ".gz"] or file.name.endswith(".tar.gz"):
303
+ extract_dir = folder / file.stem.replace(".tar", "")
304
+ print(f"📦 Extracting TAR.GZ: {file.name} to {extract_dir}/")
305
+ _extract_tar_streaming(file, extract_dir)
306
+
307
+ # ZIP
308
+ elif file.suffix == ".zip":
309
+ extract_dir = folder / file.stem
310
+ print(f"📦 Extracting ZIP: {file.name} to {extract_dir}/")
311
+ _extract_zip_streaming(file, extract_dir)
312
+
313
+ def _is_platform_compatible(metadata: dict) -> bool:
314
+ """
315
+ Determines if the current environment is compatible with the package metadata.
316
+
317
+ Args:
318
+ metadata (dict): Metadata that includes a 'platforms' section
319
+
320
+ Returns:
321
+ bool: True if compatible, False otherwise
322
+ """
323
+ env_type, env_subtype = get_environment_type()
324
+ exact_devkit_type = get_exact_devkit_type()
325
+ platforms = metadata.get("platforms", [])
326
+
327
+ for i, platform_entry in enumerate(platforms):
328
+ platform_type = platform_entry.get("type")
329
+ if platform_type != env_type:
330
+ continue
331
+
332
+ # For board/devkit: check compatible_with list
333
+ if env_type == "board":
334
+ compat = platform_entry.get("compatible_with", [])
335
+ if env_subtype not in compat and exact_devkit_type not in compat:
336
+ continue
337
+
338
+ # For host/sdk/generic: optionally check OS match
339
+ if "os" in platform_entry:
340
+ supported_oses = [os_name.lower() for os_name in platform_entry["os"]]
341
+ if env_subtype.lower() not in supported_oses:
342
+ continue
343
+
344
+ # Passed all checks
345
+ return True
346
+
347
+ click.echo(f"❌ Current environment [{env_type}:{env_subtype}] is not compatible with the package")
348
+ return False
349
+
350
+
351
+ def _print_post_install_message(metadata: Dict):
352
+ """
353
+ Print post-installation instructions from the metadata in a compact box.
354
+
355
+ Args:
356
+ metadata (Dict): The package metadata dictionary.
357
+ """
358
+ msg = metadata.get("installation", {}).get("post-message", "").strip()
359
+
360
+ if msg:
361
+ panel = Panel.fit(
362
+ msg,
363
+ title="[bold green]Post-Installation Instructions[/bold green]",
364
+ title_align="left",
365
+ border_style="green",
366
+ padding=(1, 2)
367
+ )
368
+ console.print(panel)
369
+
370
+ def _run_installation_script(metadata: Dict, extract_path: str = "."):
371
+ """
372
+ Run the installation script specified in the metadata.
373
+
374
+ Args:
375
+ metadata (dict): Metadata dictionary with an 'installation' key.
376
+ extract_path (str): Path where the files were extracted.
377
+ """
378
+ script = metadata.get("installation", {}).get("script", "").strip()
379
+ if not script:
380
+ print("⚠️ No installation script provided. Follow package documentation to install the package.")
381
+ return
382
+
383
+ print(f"🚀 Running installation script in: {os.path.abspath(extract_path)}")
384
+ print(f"📜 Script: {script}")
385
+
386
+ # Determine shell type based on platform
387
+ shell_executable = os.environ.get("COMSPEC") if os.name == "nt" else None
388
+
389
+ try:
390
+ subprocess.run(
391
+ script,
392
+ shell=True,
393
+ executable=shell_executable,
394
+ cwd=extract_path,
395
+ check=True
396
+ )
397
+ _print_post_install_message(metadata=metadata)
398
+ except subprocess.CalledProcessError as e:
399
+ print("❌ Installation failed with return code:", e.returncode)
400
+ sys.exit(e.returncode)
401
+
402
+ print("✅ Installation completed successfully.")
403
+
404
+ def install_from_metadata(metadata_url: str, internal: bool, install_dir: str = '.'):
405
+ try:
406
+ metadata, _ = _download_and_validate_metadata(metadata_url, internal)
407
+ print_metadata_summary(metadata=metadata)
408
+
409
+ if _check_whether_disk_is_big_enough(metadata):
410
+ if _is_platform_compatible(metadata):
411
+ local_paths = _download_assets(metadata, metadata_url, install_dir, internal)
412
+ if len(local_paths) > 0:
413
+ _combine_multipart_files(install_dir)
414
+ _extract_archives_in_folder(install_dir)
415
+ _run_installation_script(metadata=metadata, extract_path=install_dir)
416
+
417
+ except Exception as e:
418
+ click.echo(f"❌ Failed to install from metadata URL {metadata_url}: {e}")
419
+ exit(1)
420
+
421
+ return False
422
+
423
+ def metadata_resolver(component: str, version: str = None, tag: str = None) -> str:
424
+ """
425
+ Resolve the metadata.json URL for a given component and version/tag.
426
+
427
+ Args:
428
+ component (str): Component name (e.g., "examples.llima")
429
+ version (str): Optional SDK version string (e.g., "1.7.0")
430
+ tag (str): Optional tag to use (e.g., "dev")
431
+
432
+ Returns:
433
+ str: Fully qualified metadata URL
434
+ """
435
+ if not version:
436
+ raise ValueError("Version (-v) is required for non-hardcoded components.")
437
+
438
+ # Normalize version for URL path
439
+ sdk_path = f"SDK{version}"
440
+ base = f"https://docs.sima.ai/pkg_downloads/{sdk_path}/{component}"
441
+
442
+ if tag:
443
+ metadata_name = f"metadata-{tag}.json"
444
+ else:
445
+ metadata_name = "metadata.json"
446
+
447
+ return f"{base}/{metadata_name}"
@@ -0,0 +1,138 @@
1
+ import re
2
+ import sys
3
+ import json
4
+ from pathlib import Path
5
+
6
+ class MetadataValidationError(Exception):
7
+ pass
8
+
9
+ VALID_TYPES = {"board", "palette", "host"}
10
+ VALID_OS = {"linux", "windows", "mac"}
11
+
12
+ def validate_metadata(data: dict):
13
+ # Top-level required fields
14
+ required_fields = ["name", "version", "release", "platforms", "resources"]
15
+ for field in required_fields:
16
+ if field not in data:
17
+ raise MetadataValidationError(f"Missing required field: '{field}'")
18
+
19
+ # Validate platforms
20
+ if not isinstance(data["platforms"], list):
21
+ raise MetadataValidationError("'platforms' must be a list")
22
+
23
+ for i, platform in enumerate(data["platforms"]):
24
+ if "type" not in platform:
25
+ raise MetadataValidationError(f"Missing 'type' in platform entry {i}")
26
+ if platform["type"] not in VALID_TYPES:
27
+ raise MetadataValidationError(
28
+ f"Invalid platform type '{platform['type']}' in entry {i}. Must be one of {VALID_TYPES}"
29
+ )
30
+
31
+ if platform["type"] == "board":
32
+ if "compatible_with" not in platform:
33
+ raise MetadataValidationError(f"'compatible_with' is required for board in entry {i}")
34
+ if not isinstance(platform["compatible_with"], list):
35
+ raise MetadataValidationError(f"'compatible_with' must be a list in entry {i}")
36
+
37
+ if "os" in platform:
38
+ if not isinstance(platform["os"], list):
39
+ raise MetadataValidationError(f"'os' must be a list in entry {i}")
40
+ for os_value in platform["os"]:
41
+ if os_value.lower() not in VALID_OS:
42
+ raise MetadataValidationError(
43
+ f"Invalid OS '{os_value}' in platform entry {i}. Supported: {VALID_OS}"
44
+ )
45
+
46
+ # Validate resources
47
+ if not isinstance(data["resources"], list) or not data["resources"]:
48
+ raise MetadataValidationError("'resources' must be a non-empty list")
49
+
50
+ # Validate prerequisite (optional)
51
+ if "prerequisite" in data:
52
+ prereq = data["prerequisite"]
53
+ if "wheel_url" not in prereq or "entry_point" not in prereq:
54
+ raise MetadataValidationError("Both 'wheel_url' and 'entry_point' are required in 'prerequisite'")
55
+ _validate_entry_point_format(prereq["entry_point"], field="prerequisite.entry_point")
56
+
57
+ # Validate installation (optional)
58
+ if "installation" in data:
59
+ install = data["installation"]
60
+ if "script" not in install:
61
+ raise MetadataValidationError("Missing 'script' in 'installation'")
62
+ if not isinstance(install["script"], str):
63
+ raise MetadataValidationError("'installation.script' must be a string")
64
+
65
+ # Validate size (optional)
66
+ if "size" in data:
67
+ size = data["size"]
68
+ if not isinstance(size, dict):
69
+ raise MetadataValidationError("'size' must be a dictionary with 'download' and 'install' fields")
70
+
71
+ for key in ["download", "install"]:
72
+ if key not in size:
73
+ raise MetadataValidationError(f"Missing '{key}' in 'size'")
74
+ if not isinstance(size[key], str):
75
+ raise MetadataValidationError(f"'size.{key}' must be a string")
76
+
77
+ size_str = size[key].strip().upper()
78
+ if not any(size_str.endswith(unit) for unit in ["KB", "MB", "GB"]):
79
+ raise MetadataValidationError(
80
+ f"'size.{key}' must end with one of: KB, MB, GB (e.g., '30GB')"
81
+ )
82
+
83
+ try:
84
+ # Extract number (e.g., "30GB" → 30.0)
85
+ float(size_str[:-2].strip())
86
+ except ValueError:
87
+ raise MetadataValidationError(
88
+ f"'size.{key}' must start with a numeric value (e.g., '30GB')"
89
+ )
90
+
91
+ return True
92
+
93
+
94
+ def _validate_entry_point_format(entry_point: str, field: str):
95
+ if not re.match(r"^[a-zA-Z0-9_.\-]+:[a-zA-Z0-9_]+$", entry_point):
96
+ raise MetadataValidationError(
97
+ f"Invalid format for {field}. Must be in the form 'module:function'"
98
+ )
99
+
100
+
101
+ def validate_file(filepath):
102
+ try:
103
+ with open(filepath, "r") as f:
104
+ metadata = json.load(f)
105
+ validate_metadata(metadata)
106
+ print(f"✅ {filepath} is valid.")
107
+ except FileNotFoundError:
108
+ print(f"❌ File not found: {filepath}")
109
+ except json.JSONDecodeError as e:
110
+ print(f"❌ JSON parse error in {filepath}: {e}")
111
+ except MetadataValidationError as e:
112
+ print(f"❌ Validation failed in {filepath}: {e}")
113
+
114
+ def main():
115
+ if len(sys.argv) != 2:
116
+ print("Usage: python validate_metadata.py <file-or-folder>")
117
+ sys.exit(1)
118
+
119
+ path = Path(sys.argv[1])
120
+
121
+ if not path.exists():
122
+ print(f"❌ Path does not exist: {path}")
123
+ sys.exit(1)
124
+
125
+ if path.is_file():
126
+ validate_file(path)
127
+ elif path.is_dir():
128
+ json_files = list(path.rglob("*.json"))
129
+ if not json_files:
130
+ print(f"⚠️ No JSON files found in directory: {path}")
131
+ for file in json_files:
132
+ validate_file(file)
133
+ else:
134
+ print(f"❌ Unsupported path type: {path}")
135
+ sys.exit(1)
136
+
137
+ if __name__ == "__main__":
138
+ main()