Source code for model_repository.compatibility

"""Check model release versions against the simtools dependency catalog."""

import logging
import re
import subprocess
from pathlib import Path

from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version

logger = logging.getLogger(__name__)
MODEL_KEY = "model-repository"
LEGACY_MODEL_KEY = "model-database"
RANGE_KEY = "compatible-versions"
DEFAULT_KEY = "default"
ALLOWED_KEY = "allowed"
POLICY_KEY = "policy"
VERSION_KEY = "version"
SOURCE_KEY = "source"
STATUS_KEY = "status"
LEGACY_POLICY = "legacy exact-default fallback"
CATALOG_POLICY = "target catalog range"
DEVELOPMENT_STATUS = "development; not production-qualified"
TAG_SELECTION_ERROR = "Tags at selected revision: {tags}. Required: exactly one stable release tag."


def _contract(catalog):
    """Read a model range, including catalogs bundled with historical images."""
    model = catalog.get(MODEL_KEY) or catalog.get(LEGACY_MODEL_KEY)
    if not model:
        raise ValueError("Target catalog has no model dependency declaration.")
    default = next(
        (model[key] for key in ("default-ref", "default-tag", "default-version") if model.get(key)),
        None,
    )
    try:
        version = Version(str(default))
        constraint = model.get(RANGE_KEY, f"=={version}")
        allowed = SpecifierSet(constraint)
    except (InvalidVersion, InvalidSpecifier, TypeError) as exc:
        raise ValueError(f"Invalid model release default or compatible-versions: {model}") from exc
    if not str(allowed):
        raise ValueError("An explicit nonempty model compatibility constraint is required.")
    if (
        version.is_prerelease
        or version.is_devrelease
        or not allowed.contains(version, prereleases=False)
    ):
        raise ValueError(f"Model default {default} is not a supported stable release in {allowed}.")
    return {
        DEFAULT_KEY: default,
        ALLOWED_KEY: str(allowed),
        POLICY_KEY: CATALOG_POLICY if RANGE_KEY in model else LEGACY_POLICY,
    }


[docs] def validate_release(path, catalog, revision=None, development=False): """Check an exact release tag without changing the selected model checkout.""" contract = _contract(catalog) source = str(Path(path).expanduser().resolve()) result = {**contract, SOURCE_KEY: source, STATUS_KEY: DEVELOPMENT_STATUS} try: tags = subprocess.run( ["git", "-C", source, "tag", "--points-at", revision or "HEAD"], check=True, capture_output=True, text=True, ).stdout.splitlines() versions = {str(Version(tag)) for tag in tags if re.fullmatch(r"v?\d+\.\d+\.\d+", tag)} if len(versions) != 1: found_tags = ", ".join(tags) if tags else "none" raise ValueError(TAG_SELECTION_ERROR.format(tags=found_tags)) result[VERSION_KEY] = versions.pop() if not SpecifierSet(contract[ALLOWED_KEY]).contains(result[VERSION_KEY], prereleases=False): raise ValueError( f"simulation-models {result[VERSION_KEY]} is outside the allowed range." ) if not development: result[STATUS_KEY] = "compatible" except (ValueError, subprocess.CalledProcessError) as exc: if not development: raise ValueError( f"Model compatibility failed for {source}: {exc} " f"Allowed {contract[ALLOWED_KEY]} ({contract[POLICY_KEY]}). " f"Select a compatible checkout, for example {contract[DEFAULT_KEY]}." ) from exc log = logger.warning if development else logger.info log( "Models %s; version %s at %s; allowed %s (%s)", result[STATUS_KEY], result.get(VERSION_KEY, "unknown"), source, contract[ALLOWED_KEY], contract[POLICY_KEY], ) return result