From 83b60c626efb612a61c0452fc67c7087ef739fa6 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 10 Jul 2026 11:43:17 +0700 Subject: [PATCH] feat: Phase I I6 versioned schema export -- SEM- manifest with stability tiers, tier counts, validation, format; 63 tests (#909) --- .../versioning/schema_export.py | 254 ++++++++++++++ tests/versioning/test_schema_export.py | 310 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 src/openamp_foundry/versioning/schema_export.py create mode 100644 tests/versioning/test_schema_export.py diff --git a/src/openamp_foundry/versioning/schema_export.py b/src/openamp_foundry/versioning/schema_export.py new file mode 100644 index 00000000..7ae42a7d --- /dev/null +++ b/src/openamp_foundry/versioning/schema_export.py @@ -0,0 +1,254 @@ +""" +Versioned schema export for partner consumption. + +I6: Stable API for partners. Provides a manifest of all exported schemas +with version metadata, stability tiers, and schema IDs. Partners can use +this manifest to discover available schemas and their current versions. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +SCHEMA_EXPORT_ID_PREFIX: str = "SEM-" + +VALID_STABILITY_TIERS: frozenset[str] = frozenset({ + "stable", + "experimental", + "internal", + "deprecated", +}) + +STABILITY_TIER_DESCRIPTIONS: dict[str, str] = { + "stable": "Tier 1 — breaking changes require MAJOR version bump", + "experimental": "Tier 2 — may change without notice, not for production", + "internal": "Tier 3 — no stability promise, not for external use", + "deprecated": "Tier 0 — scheduled for removal, do not use", +} + + +@dataclass +class SchemaExportEntry: + """A single schema in a versioned export manifest.""" + + schema_name: str + schema_path: str + schema_id: str + version: str + stability_tier: str + description: str + is_dry_lab_only: bool + + +@dataclass +class SchemaExportManifest: + """ + Versioned export manifest for all schemas available to partner tools. + + Export manifests use the SEM- prefix and record which schemas are + available, at what version, and at what stability tier. + """ + + manifest_id: str + generated_at: str + total_schemas: int + stable_schemas: int + experimental_schemas: int + deprecated_schemas: int + entries: list[SchemaExportEntry] + is_stable_export: bool + export_summary: str + + +@dataclass +class ExportValidationResult: + """Result of validating a SchemaExportManifest.""" + + is_valid: bool + violations: list[str] + warnings: list[str] + manifest_id: str + validation_summary: str + + +def build_schema_export_entry( + schema_name: str, + schema_path: str, + schema_id: str, + version: str, + stability_tier: str, + description: str, + is_dry_lab_only: bool = True, +) -> SchemaExportEntry: + """ + Build a SchemaExportEntry for a single schema. + + All schemas exported from OpenAMP Foundry are dry-lab-only by default. + """ + return SchemaExportEntry( + schema_name=schema_name, + schema_path=schema_path, + schema_id=schema_id, + version=version, + stability_tier=stability_tier, + description=description, + is_dry_lab_only=is_dry_lab_only, + ) + + +def build_schema_export_manifest( + manifest_id: str, + generated_at: str, + entries: list[SchemaExportEntry], +) -> SchemaExportManifest: + """ + Build a SchemaExportManifest from a list of SchemaExportEntry instances. + + Computes tier counts and stability flag automatically. + """ + stable = sum(1 for e in entries if e.stability_tier == "stable") + experimental = sum(1 for e in entries if e.stability_tier == "experimental") + deprecated = sum(1 for e in entries if e.stability_tier == "deprecated") + is_stable = len(entries) > 0 and experimental == 0 and deprecated == 0 + + summary = ( + f"SEM manifest {manifest_id}: " + f"{len(entries)} schema(s), " + f"{stable} stable, " + f"{experimental} experimental, " + f"{deprecated} deprecated." + ) + + return SchemaExportManifest( + manifest_id=manifest_id, + generated_at=generated_at, + total_schemas=len(entries), + stable_schemas=stable, + experimental_schemas=experimental, + deprecated_schemas=deprecated, + entries=entries, + is_stable_export=is_stable, + export_summary=summary, + ) + + +def validate_schema_export_manifest( + manifest: SchemaExportManifest, +) -> ExportValidationResult: + """ + Validate a SchemaExportManifest against integrity rules. + + Rules: + 1. manifest_id must start with 'SEM-' + 2. manifest_id must have content after the prefix + 3. generated_at must not be empty + 4. total_schemas must equal len(entries) + 5. all entries must have a non-empty schema_name + 6. all entries must have a non-empty schema_id + 7. all entries must have a stability_tier in VALID_STABILITY_TIERS + 8. schema names must not contain duplicates + 9. stable_schemas + experimental_schemas + deprecated_schemas must equal + the count of entries with those three tiers (internal counts separately) + 10. is_stable_export must be False if any experimental or deprecated entries exist + """ + violations: list[str] = [] + warnings: list[str] = [] + + if not manifest.manifest_id.startswith(SCHEMA_EXPORT_ID_PREFIX): + violations.append( + f"manifest_id must start with '{SCHEMA_EXPORT_ID_PREFIX}', " + f"got: {manifest.manifest_id!r}" + ) + + if len(manifest.manifest_id) <= len(SCHEMA_EXPORT_ID_PREFIX): + violations.append("manifest_id must have content after the prefix.") + + if not manifest.generated_at.strip(): + violations.append("generated_at must not be empty.") + + if manifest.total_schemas != len(manifest.entries): + violations.append( + f"total_schemas ({manifest.total_schemas}) must equal " + f"len(entries) ({len(manifest.entries)})." + ) + + for i, entry in enumerate(manifest.entries): + if not entry.schema_name.strip(): + violations.append(f"Entry {i}: schema_name must not be empty.") + if not entry.schema_id.strip(): + violations.append(f"Entry {i}: schema_id must not be empty.") + if entry.stability_tier not in VALID_STABILITY_TIERS: + violations.append( + f"Entry {i} ({entry.schema_name!r}): stability_tier must be one of " + f"{sorted(VALID_STABILITY_TIERS)}, got: {entry.stability_tier!r}" + ) + + names = [e.schema_name for e in manifest.entries] + if len(names) != len(set(names)): + violations.append("schema_names must not contain duplicates.") + + has_experimental = any(e.stability_tier == "experimental" for e in manifest.entries) + has_deprecated = any(e.stability_tier == "deprecated" for e in manifest.entries) + if manifest.is_stable_export and (has_experimental or has_deprecated): + violations.append( + "is_stable_export must be False when experimental or deprecated entries exist." + ) + + if manifest.deprecated_schemas > 0: + warnings.append( + f"{manifest.deprecated_schemas} deprecated schema(s) in export. " + "Consider removing them before next major release." + ) + + is_valid = len(violations) == 0 + if is_valid: + summary = ( + f"SchemaExportManifest {manifest.manifest_id} is valid: " + f"{manifest.total_schemas} schema(s)." + ) + else: + summary = ( + f"SchemaExportManifest {manifest.manifest_id} has " + f"{len(violations)} violation(s): " + + "; ".join(violations[:2]) + + ("..." if len(violations) > 2 else "") + ) + + return ExportValidationResult( + is_valid=is_valid, + violations=violations, + warnings=warnings, + manifest_id=manifest.manifest_id, + validation_summary=summary, + ) + + +def format_schema_export_manifest(manifest: SchemaExportManifest) -> str: + """Return a human-readable schema export manifest.""" + lines: list[str] = [ + f"Schema Export Manifest: {manifest.manifest_id}", + f" Generated: {manifest.generated_at}", + f" Total schemas: {manifest.total_schemas}", + f" Stable: {manifest.stable_schemas}", + f" Experimental: {manifest.experimental_schemas}", + f" Deprecated: {manifest.deprecated_schemas}", + f" Stable export: {manifest.is_stable_export}", + "", + "Schemas:", + ] + for entry in manifest.entries: + tier_mark = { + "stable": "+", + "experimental": "~", + "internal": ".", + "deprecated": "!", + }.get(entry.stability_tier, "?") + lines.append( + f" [{tier_mark}] {entry.schema_name} v{entry.version} " + f"({entry.stability_tier}) " + f"{'[dry-lab-only]' if entry.is_dry_lab_only else '[wet-lab]'}" + ) + if entry.description: + lines.append(f" {entry.description}") + return "\n".join(lines) diff --git a/tests/versioning/test_schema_export.py b/tests/versioning/test_schema_export.py new file mode 100644 index 00000000..c19e9660 --- /dev/null +++ b/tests/versioning/test_schema_export.py @@ -0,0 +1,310 @@ +"""Tests for I6 versioned schema export.""" + +from __future__ import annotations + +import pytest + +from openamp_foundry.versioning.schema_export import ( + SCHEMA_EXPORT_ID_PREFIX, + STABILITY_TIER_DESCRIPTIONS, + VALID_STABILITY_TIERS, + ExportValidationResult, + SchemaExportEntry, + SchemaExportManifest, + build_schema_export_entry, + build_schema_export_manifest, + format_schema_export_manifest, + validate_schema_export_manifest, +) + + +def _make_entry(**kwargs) -> SchemaExportEntry: + defaults = dict( + schema_name="external_review_packet", + schema_path="src/openamp_foundry/evidence/external_review_packet.py", + schema_id="https://openamp-foundry.org/schemas/erp/1.0.0", + version="1.0.0", + stability_tier="stable", + description="ERP- schema for external review packets.", + is_dry_lab_only=True, + ) + defaults.update(kwargs) + return SchemaExportEntry(**defaults) + + +def _make_manifest(**kwargs) -> SchemaExportManifest: + entries = kwargs.pop("entries", [_make_entry()]) + manifest_id = kwargs.pop("manifest_id", "SEM-2026-001") + generated_at = kwargs.pop("generated_at", "2026-01-01T00:00:00Z") + return build_schema_export_manifest(manifest_id, generated_at, entries) + + +class TestConstants: + def test_prefix(self): + assert SCHEMA_EXPORT_ID_PREFIX == "SEM-" + + def test_valid_tiers_frozenset(self): + assert isinstance(VALID_STABILITY_TIERS, frozenset) + + def test_stable_in_tiers(self): + assert "stable" in VALID_STABILITY_TIERS + + def test_experimental_in_tiers(self): + assert "experimental" in VALID_STABILITY_TIERS + + def test_internal_in_tiers(self): + assert "internal" in VALID_STABILITY_TIERS + + def test_deprecated_in_tiers(self): + assert "deprecated" in VALID_STABILITY_TIERS + + def test_tier_descriptions_dict(self): + assert isinstance(STABILITY_TIER_DESCRIPTIONS, dict) + + def test_tier_descriptions_has_stable(self): + assert "stable" in STABILITY_TIER_DESCRIPTIONS + + def test_tier_descriptions_has_deprecated(self): + assert "deprecated" in STABILITY_TIER_DESCRIPTIONS + + +class TestBuildSchemaExportEntry: + def test_builds_entry(self): + entry = build_schema_export_entry( + schema_name="foo", + schema_path="src/foo.py", + schema_id="https://example.org/foo", + version="1.0.0", + stability_tier="stable", + description="Foo schema.", + ) + assert isinstance(entry, SchemaExportEntry) + + def test_default_dry_lab_only(self): + entry = build_schema_export_entry("x", "path", "id", "1.0", "stable", "desc") + assert entry.is_dry_lab_only is True + + def test_explicit_dry_lab_false(self): + entry = build_schema_export_entry("x", "p", "i", "1.0", "stable", "d", False) + assert entry.is_dry_lab_only is False + + def test_all_fields_stored(self): + entry = build_schema_export_entry( + "foo", "src/foo.py", "id://foo", "2.0.0", "experimental", "desc" + ) + assert entry.schema_name == "foo" + assert entry.schema_path == "src/foo.py" + assert entry.schema_id == "id://foo" + assert entry.version == "2.0.0" + assert entry.stability_tier == "experimental" + assert entry.description == "desc" + + +class TestBuildSchemaExportManifest: + def test_builds_manifest(self): + manifest = _make_manifest() + assert isinstance(manifest, SchemaExportManifest) + + def test_total_schemas_count(self): + entries = [_make_entry(schema_name=f"s{i}") for i in range(3)] + manifest = _make_manifest(entries=entries) + assert manifest.total_schemas == 3 + + def test_stable_count(self): + entries = [ + _make_entry(schema_name="a", stability_tier="stable"), + _make_entry(schema_name="b", stability_tier="experimental"), + ] + manifest = _make_manifest(entries=entries) + assert manifest.stable_schemas == 1 + + def test_experimental_count(self): + entries = [ + _make_entry(schema_name="a", stability_tier="experimental"), + _make_entry(schema_name="b", stability_tier="experimental"), + ] + manifest = _make_manifest(entries=entries) + assert manifest.experimental_schemas == 2 + + def test_deprecated_count(self): + entries = [ + _make_entry(schema_name="a", stability_tier="deprecated"), + ] + manifest = _make_manifest(entries=entries) + assert manifest.deprecated_schemas == 1 + + def test_is_stable_when_all_stable(self): + entries = [_make_entry(schema_name="a", stability_tier="stable")] + manifest = _make_manifest(entries=entries) + assert manifest.is_stable_export is True + + def test_not_stable_with_experimental(self): + entries = [_make_entry(schema_name="a", stability_tier="experimental")] + manifest = _make_manifest(entries=entries) + assert manifest.is_stable_export is False + + def test_not_stable_with_deprecated(self): + entries = [_make_entry(schema_name="a", stability_tier="deprecated")] + manifest = _make_manifest(entries=entries) + assert manifest.is_stable_export is False + + def test_empty_entries_not_stable(self): + manifest = _make_manifest(entries=[]) + assert manifest.is_stable_export is False + + def test_summary_nonempty(self): + manifest = _make_manifest() + assert len(manifest.export_summary) > 0 + + def test_manifest_id_stored(self): + manifest = _make_manifest() + assert manifest.manifest_id == "SEM-2026-001" + + def test_generated_at_stored(self): + manifest = _make_manifest() + assert manifest.generated_at == "2026-01-01T00:00:00Z" + + +class TestValidateSchemaExportManifest: + def test_valid_manifest_passes(self): + manifest = _make_manifest() + result = validate_schema_export_manifest(manifest) + assert result.is_valid is True + + def test_valid_no_violations(self): + manifest = _make_manifest() + result = validate_schema_export_manifest(manifest) + assert result.violations == [] + + def test_result_type(self): + manifest = _make_manifest() + result = validate_schema_export_manifest(manifest) + assert isinstance(result, ExportValidationResult) + + def test_bad_prefix_fails(self): + manifest = _make_manifest() + manifest.manifest_id = "BAD-001" + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_prefix_only_fails(self): + manifest = _make_manifest() + manifest.manifest_id = "SEM-" + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_empty_generated_at_fails(self): + manifest = _make_manifest() + manifest.generated_at = "" + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_wrong_total_fails(self): + manifest = _make_manifest() + manifest.total_schemas = 999 + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_empty_schema_name_fails(self): + entries = [_make_entry(schema_name="")] + manifest = _make_manifest(entries=entries) + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_empty_schema_id_fails(self): + entries = [_make_entry(schema_id="")] + manifest = _make_manifest(entries=entries) + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_invalid_tier_fails(self): + entries = [_make_entry(stability_tier="beta")] + manifest = _make_manifest(entries=entries) + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_duplicate_names_fail(self): + entries = [ + _make_entry(schema_name="same"), + _make_entry(schema_name="same"), + ] + manifest = _make_manifest(entries=entries) + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_stable_export_with_experimental_fails(self): + entries = [_make_entry(schema_name="a", stability_tier="experimental")] + manifest = _make_manifest(entries=entries) + manifest.is_stable_export = True + result = validate_schema_export_manifest(manifest) + assert not result.is_valid + + def test_deprecated_warns(self): + entries = [_make_entry(schema_name="a", stability_tier="deprecated")] + manifest = _make_manifest(entries=entries) + result = validate_schema_export_manifest(manifest) + assert result.is_valid + assert len(result.warnings) > 0 + + def test_all_tiers_valid(self): + for tier in VALID_STABILITY_TIERS: + entries = [_make_entry(schema_name="a", stability_tier=tier)] + manifest = _make_manifest(entries=entries) + result = validate_schema_export_manifest(manifest) + if tier in ("deprecated",): + assert result.is_valid + else: + assert result.is_valid, f"tier {tier!r} should be valid" + + def test_summary_nonempty(self): + manifest = _make_manifest() + result = validate_schema_export_manifest(manifest) + assert len(result.validation_summary) > 0 + + +class TestFormatSchemaExportManifest: + def setup_method(self): + self.entries = [ + _make_entry( + schema_name="external_review_packet", + version="1.0.0", + stability_tier="stable", + ), + _make_entry( + schema_name="pilot_preregistration", + version="0.9.0", + stability_tier="experimental", + ), + ] + self.manifest = _make_manifest(entries=self.entries) + self.text = format_schema_export_manifest(self.manifest) + + def test_returns_string(self): + assert isinstance(self.text, str) + + def test_contains_manifest_id(self): + assert "SEM-2026-001" in self.text + + def test_contains_schema_names(self): + assert "external_review_packet" in self.text + assert "pilot_preregistration" in self.text + + def test_contains_versions(self): + assert "1.0.0" in self.text + assert "0.9.0" in self.text + + def test_contains_tier_labels(self): + assert "stable" in self.text + assert "experimental" in self.text + + def test_stable_marker(self): + assert "[+]" in self.text + + def test_experimental_marker(self): + assert "[~]" in self.text + + def test_dry_lab_label(self): + assert "dry-lab-only" in self.text + + def test_contains_counts(self): + assert "2" in self.text