"""Versioned UTF-8 YAML import and export for the terminology glossary.""" from __future__ import annotations from collections.abc import Sequence import yaml from mka.application.glossary import ( GLOSSARY_CATEGORIES, GlossaryEntry, GlossaryReplacementEntry, ) GLOSSARY_YAML_VERSION = 1 class GlossaryYamlError(ValueError): """Raised when a glossary YAML document is malformed or invalid.""" def export_glossary_yaml(entries: Sequence[GlossaryEntry]) -> str: """Serialize all glossary state in a deterministic, versioned document.""" document = { "version": GLOSSARY_YAML_VERSION, "glossary": [ { "id": entry.id, "canonical_term": entry.canonical_term, "aliases": list(entry.aliases), "category": entry.category, "description": entry.description, "active": entry.is_active, } for entry in entries ], } return yaml.safe_dump(document, allow_unicode=True, sort_keys=False, default_flow_style=False) def import_glossary_yaml(content: str | bytes) -> list[GlossaryReplacementEntry]: """Parse and completely validate a glossary replacement document.""" try: if isinstance(content, bytes): content = content.decode("utf-8") document = yaml.safe_load(content) except (yaml.YAMLError, UnicodeDecodeError) as exc: raise GlossaryYamlError(f"Malformed glossary YAML: {exc}") from exc if not isinstance(document, dict): raise GlossaryYamlError("Glossary YAML must contain a top-level mapping.") version = document.get("version") if type(version) is not int or version != GLOSSARY_YAML_VERSION: raise GlossaryYamlError( f"Unsupported glossary YAML version {version!r}; expected {GLOSSARY_YAML_VERSION}." ) raw_entries = document.get("glossary") if not isinstance(raw_entries, list): raise GlossaryYamlError("Glossary YAML must contain a top-level 'glossary' list.") result: list[GlossaryReplacementEntry] = [] seen_ids: set[int] = set() seen_terms: set[str] = set() for index, raw in enumerate(raw_entries, start=1): if not isinstance(raw, dict): raise GlossaryYamlError(f"Glossary entry {index} must be a mapping.") entry_id = raw.get("id") if type(entry_id) is not int or entry_id <= 0: raise GlossaryYamlError(f"Glossary entry {index} requires a positive integer id.") if entry_id in seen_ids: raise GlossaryYamlError(f"Duplicate glossary entry id: {entry_id}.") seen_ids.add(entry_id) canonical = _required_text(raw, "canonical_term", index).strip() category = raw.get("category") if category not in GLOSSARY_CATEGORIES: allowed = ", ".join(GLOSSARY_CATEGORIES) raise GlossaryYamlError( f"Glossary entry {index} has invalid category; expected one of: {allowed}." ) aliases_value = raw.get("aliases") if not isinstance(aliases_value, list) or any( not isinstance(alias, str) or not alias.strip() for alias in aliases_value ): raise GlossaryYamlError(f"Glossary entry {index} aliases must be a list of text.") aliases = tuple(alias.strip() for alias in aliases_value) folded = [canonical.casefold(), *(alias.casefold() for alias in aliases)] if len(folded) != len(set(folded)): raise GlossaryYamlError( f"Glossary entry {index} aliases must be unique and differ from its canonical term." ) duplicate = next((term for term in folded if term in seen_terms), None) if duplicate is not None: raise GlossaryYamlError( f"Glossary entry {index} contains a duplicate canonical term or alias." ) seen_terms.update(folded) description = raw.get("description") if description is not None and not isinstance(description, str): raise GlossaryYamlError(f"Glossary entry {index} description must be text or null.") active = raw.get("active") if type(active) is not bool: raise GlossaryYamlError(f"Glossary entry {index} active must be true or false.") result.append( GlossaryReplacementEntry( id=entry_id, canonical_term=canonical, aliases=aliases, category=category, description=description, is_active=active, ) ) return result def _required_text(entry: dict[object, object], field: str, index: int) -> str: value = entry.get(field) if not isinstance(value, str) or not value.strip(): raise GlossaryYamlError(f"Glossary entry {index} requires a non-empty {field}.") return value