mirror of https://github.com/razor-ai/soup.git
173 lines
5.8 KiB
Python
173 lines
5.8 KiB
Python
"""Pack registry entries into ``.can`` artifacts (v0.26.0 Part E)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import tarfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
|
|
from soup_cli.cans.schema import CAN_FORMAT_VERSION, Manifest
|
|
from soup_cli.registry.store import RegistryStore
|
|
from soup_cli.utils.paths import is_under_cwd
|
|
|
|
_MAX_CAN_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
|
|
|
|
|
|
def _add_text(tar: tarfile.TarFile, name: str, content: str) -> None:
|
|
data = content.encode("utf-8")
|
|
info = tarfile.TarInfo(name=name)
|
|
info.size = len(data)
|
|
info.mtime = int(datetime.now().timestamp())
|
|
tar.addfile(info, io.BytesIO(data))
|
|
|
|
|
|
def pack_entry(
|
|
*, entry_id: str, out_path: str, author: str = "unknown",
|
|
description: Optional[str] = None,
|
|
) -> Path:
|
|
"""Pack a registry entry as a ``.can`` tarball.
|
|
|
|
Args:
|
|
entry_id: registry entry id (full id or prefix).
|
|
out_path: destination ``.can`` file. Must stay under the cwd captured
|
|
at call time.
|
|
author: author handle baked into the manifest.
|
|
description: optional free-form description.
|
|
|
|
Returns:
|
|
The absolute path to the written file.
|
|
"""
|
|
out = Path(out_path)
|
|
if not is_under_cwd(out):
|
|
raise ValueError(f"out path '{out_path}' is outside cwd - refusing")
|
|
|
|
with RegistryStore() as store:
|
|
resolved = store.resolve(entry_id)
|
|
if resolved is None:
|
|
raise ValueError(f"registry entry not found: {entry_id}")
|
|
entry = store.get(resolved)
|
|
if entry is None:
|
|
raise ValueError(f"registry entry not found: {entry_id}")
|
|
try:
|
|
config = json.loads(entry.get("config_json") or "{}")
|
|
except (TypeError, ValueError):
|
|
config = {}
|
|
|
|
manifest = Manifest(
|
|
can_format_version=CAN_FORMAT_VERSION,
|
|
name=entry["name"],
|
|
author=author,
|
|
created_at=datetime.now().isoformat(timespec="seconds"),
|
|
base_hash=entry.get("entry_hash", ""),
|
|
description=description or entry.get("notes"),
|
|
tags=list(entry.get("tags", [])),
|
|
)
|
|
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
with tarfile.open(out, mode="w:gz") as tar:
|
|
_add_text(tar, "manifest.yaml", yaml.safe_dump(
|
|
manifest.model_dump(), sort_keys=False,
|
|
))
|
|
_add_text(tar, "config.yaml", yaml.safe_dump(config, sort_keys=False))
|
|
_add_text(tar, "data_ref.yaml", yaml.safe_dump(
|
|
{"kind": "local", "note": "user must supply data locally"},
|
|
sort_keys=False,
|
|
))
|
|
_add_text(tar, "recipe.md",
|
|
f"# {manifest.name}\n\nBase: {entry['base_model']}\n"
|
|
f"Task: {entry['task']}\n\n"
|
|
f"{manifest.description or ''}\n")
|
|
|
|
# Enforce size cap — reject anything larger than 100 MB
|
|
if out.stat().st_size > _MAX_CAN_SIZE_BYTES:
|
|
out.unlink(missing_ok=True)
|
|
raise ValueError(
|
|
f"can exceeds max size ({_MAX_CAN_SIZE_BYTES // 1024 // 1024} MB) - "
|
|
"trim metadata or exclude large fields"
|
|
)
|
|
return out
|
|
|
|
|
|
_FORBIDDEN_MOD_KEYS = frozenset({
|
|
"__class__", "__init__", "__dict__", "__globals__", "__builtins__",
|
|
"__import__", "__bases__", "__mro__",
|
|
})
|
|
|
|
|
|
def _apply_modification(config: dict, mod: str) -> None:
|
|
"""Apply a single ``dotted.path=value`` modification in-place."""
|
|
if "=" not in mod:
|
|
raise ValueError(
|
|
f"modification '{mod}' must be 'dotted.path=value'"
|
|
)
|
|
path, raw_value = mod.split("=", 1)
|
|
if "\x00" in path:
|
|
raise ValueError(f"modification key contains null byte: {path!r}")
|
|
keys = path.split(".")
|
|
if any(key in _FORBIDDEN_MOD_KEYS or key.startswith("__") for key in keys):
|
|
raise ValueError(
|
|
f"modification key '{path}' references a dunder / forbidden name"
|
|
)
|
|
# Try parsing as JSON (number, bool, null, string) for proper typing.
|
|
try:
|
|
value = json.loads(raw_value)
|
|
except json.JSONDecodeError:
|
|
value = raw_value
|
|
target = config
|
|
for key in keys[:-1]:
|
|
target = target.setdefault(key, {})
|
|
if not isinstance(target, dict):
|
|
raise ValueError(
|
|
f"cannot modify '{path}': '{key}' is not a dict"
|
|
)
|
|
target[keys[-1]] = value
|
|
|
|
|
|
def fork_can(
|
|
*, source: str, out_path: str, modifications: list[str],
|
|
author: str = "unknown",
|
|
) -> Path:
|
|
"""Apply ``modifications`` to a can's config and re-pack."""
|
|
from soup_cli.cans.unpack import inspect_can, read_config
|
|
|
|
src = Path(source)
|
|
if not is_under_cwd(src):
|
|
raise ValueError(f"source '{source}' is outside cwd - refusing")
|
|
if not src.exists():
|
|
raise FileNotFoundError(f"source can not found: {source}")
|
|
|
|
out = Path(out_path)
|
|
if not is_under_cwd(out):
|
|
raise ValueError(f"out path '{out_path}' is outside cwd - refusing")
|
|
|
|
manifest = inspect_can(source)
|
|
config = read_config(source)
|
|
for mod in modifications:
|
|
_apply_modification(config, mod)
|
|
|
|
with tarfile.open(out, mode="w:gz") as tar:
|
|
_add_text(tar, "manifest.yaml", yaml.safe_dump({
|
|
**manifest.model_dump(),
|
|
"author": author,
|
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
|
"name": f"{manifest.name}-fork",
|
|
}, sort_keys=False))
|
|
_add_text(tar, "config.yaml", yaml.safe_dump(config, sort_keys=False))
|
|
_add_text(tar, "data_ref.yaml", yaml.safe_dump(
|
|
{"kind": "local", "note": "forked"},
|
|
sort_keys=False,
|
|
))
|
|
|
|
if out.stat().st_size > _MAX_CAN_SIZE_BYTES:
|
|
out.unlink(missing_ok=True)
|
|
raise ValueError(
|
|
f"forked can exceeds max size "
|
|
f"({_MAX_CAN_SIZE_BYTES // 1024 // 1024} MB)"
|
|
)
|
|
return out
|