123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
"""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
|