hub_manifest.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
34 days ago
| 1 | """MuseHub domain manifest builder for the Knowtation domain plugin. |
| 2 | |
| 3 | The *hub manifest* is a JSON document that registers the Knowtation domain on |
| 4 | the MuseHub marketplace. It is distinct from the vault-snapshot manifests |
| 5 | in :mod:`~muse.plugins.knowtation.manifest`. |
| 6 | |
| 7 | Two representations of the manifest are maintained in sync: |
| 8 | |
| 9 | 1. **Static file** — :data:`_MANIFEST_FILE` (``manifest.json`` alongside this |
| 10 | module) — content-addressed by its SHA-256 hash. The hash is stable across |
| 11 | runs, making reproducible builds easy to verify. |
| 12 | |
| 13 | 2. **Dynamic builder** — :func:`build_manifest` — derives the same payload from |
| 14 | the live :class:`~muse.plugins.knowtation.plugin.KnowtationPlugin` schema so |
| 15 | that the static file and the live schema can be kept in sync via tests. |
| 16 | |
| 17 | Public API |
| 18 | ---------- |
| 19 | - :data:`MANIFEST_FILE` — absolute path to the static ``manifest.json``. |
| 20 | - :func:`load_manifest` — parse and return the static file as a dict. |
| 21 | - :func:`build_manifest` — derive the manifest from the live plugin schema. |
| 22 | - :func:`manifest_hash` — compute the SHA-256 content hash of a manifest dict. |
| 23 | - :func:`assert_manifest_in_sync` — raise if static and dynamic differ. |
| 24 | """ |
| 25 | |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import hashlib |
| 29 | import json |
| 30 | import pathlib |
| 31 | from typing import Any |
| 32 | |
| 33 | # --------------------------------------------------------------------------- |
| 34 | # Paths |
| 35 | # --------------------------------------------------------------------------- |
| 36 | |
| 37 | #: Absolute path to the static MuseHub manifest JSON file shipped alongside |
| 38 | #: this module. |
| 39 | MANIFEST_FILE: pathlib.Path = pathlib.Path(__file__).with_name("manifest.json") |
| 40 | |
| 41 | # Fields that must be present in a valid hub manifest. |
| 42 | _REQUIRED_FIELDS: frozenset[str] = frozenset({ |
| 43 | "slug", |
| 44 | "display_name", |
| 45 | "description", |
| 46 | "viewer_type", |
| 47 | "merge_semantics", |
| 48 | "version", |
| 49 | "dimensions", |
| 50 | }) |
| 51 | |
| 52 | # Fields that must be present in each dimension entry. |
| 53 | _REQUIRED_DIMENSION_FIELDS: frozenset[str] = frozenset({ |
| 54 | "name", |
| 55 | "kind", |
| 56 | "element_type", |
| 57 | "description", |
| 58 | "independent_merge", |
| 59 | }) |
| 60 | |
| 61 | # Expected dimension names for the knowtation domain (order-insensitive). |
| 62 | EXPECTED_DIMENSIONS: frozenset[str] = frozenset({ |
| 63 | "notes", |
| 64 | "frontmatter", |
| 65 | "sections", |
| 66 | "links", |
| 67 | "entities", |
| 68 | "attachments", |
| 69 | }) |
| 70 | |
| 71 | |
| 72 | # --------------------------------------------------------------------------- |
| 73 | # Load / build |
| 74 | # --------------------------------------------------------------------------- |
| 75 | |
| 76 | |
| 77 | def load_manifest() -> dict[str, Any]: |
| 78 | """Parse and return the static ``manifest.json`` as a plain dict. |
| 79 | |
| 80 | Returns: |
| 81 | Parsed manifest dictionary. |
| 82 | |
| 83 | Raises: |
| 84 | FileNotFoundError: When ``manifest.json`` does not exist. |
| 85 | json.JSONDecodeError: When the file is not valid JSON. |
| 86 | """ |
| 87 | return json.loads(MANIFEST_FILE.read_text(encoding="utf-8")) |
| 88 | |
| 89 | |
| 90 | def build_manifest() -> dict[str, Any]: |
| 91 | """Derive a MuseHub hub manifest from the live ``KnowtationPlugin`` schema. |
| 92 | |
| 93 | Reads the static ``manifest.json`` for fields that cannot be derived from |
| 94 | the plugin schema alone (``viewer_type``, ``artifact_types``, |
| 95 | ``supported_commands``, ``display_name``, ``version``), then overrides |
| 96 | ``dimensions`` and ``description`` from the live schema to keep both in |
| 97 | sync. |
| 98 | |
| 99 | Returns: |
| 100 | A dict in the same shape as :func:`load_manifest`. |
| 101 | |
| 102 | Raises: |
| 103 | NotImplementedError: When the plugin's ``schema()`` method raises. |
| 104 | """ |
| 105 | from muse.plugins.knowtation.plugin import KnowtationPlugin |
| 106 | |
| 107 | static = load_manifest() |
| 108 | plugin = KnowtationPlugin() |
| 109 | schema = plugin.schema() |
| 110 | |
| 111 | # Rebuild dimensions from the live schema — preserves descriptions from |
| 112 | # the static file when names match so human-written text is not lost. |
| 113 | static_dims_by_name = {d["name"]: d for d in static.get("dimensions", [])} |
| 114 | live_dims: list[dict[str, Any]] = [] |
| 115 | for dim in schema.get("dimensions", []): |
| 116 | name = dim["name"] |
| 117 | static_desc = static_dims_by_name.get(name, {}).get("description", "") |
| 118 | live_dims.append({ |
| 119 | "name": name, |
| 120 | "kind": dim["schema"]["kind"], |
| 121 | "element_type": ( |
| 122 | dim["schema"].get("node_type") |
| 123 | or dim["schema"].get("element_type") |
| 124 | or dim["schema"].get("key_type") |
| 125 | or "" |
| 126 | ), |
| 127 | "description": static_desc, |
| 128 | "independent_merge": dim.get("independent_merge", False), |
| 129 | }) |
| 130 | |
| 131 | return { |
| 132 | **static, |
| 133 | "description": schema.get("description", static.get("description", "")), |
| 134 | "merge_semantics": static.get("merge_semantics", "ot"), |
| 135 | "dimensions": live_dims, |
| 136 | } |
| 137 | |
| 138 | |
| 139 | # --------------------------------------------------------------------------- |
| 140 | # Hashing |
| 141 | # --------------------------------------------------------------------------- |
| 142 | |
| 143 | |
| 144 | def manifest_hash(manifest: dict[str, Any]) -> str: |
| 145 | """Return the SHA-256 content hash of *manifest* as ``'sha256:<hex>'``. |
| 146 | |
| 147 | Serialises to JSON with sorted keys and no extra whitespace for |
| 148 | determinism, then hashes the UTF-8 bytes. |
| 149 | |
| 150 | Args: |
| 151 | manifest: Manifest dict (from :func:`load_manifest` or |
| 152 | :func:`build_manifest`). |
| 153 | |
| 154 | Returns: |
| 155 | Hash string in ``'sha256:<64-hex-chars>'`` format. |
| 156 | """ |
| 157 | canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":")) |
| 158 | digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() |
| 159 | return f"sha256:{digest}" |
| 160 | |
| 161 | |
| 162 | # --------------------------------------------------------------------------- |
| 163 | # Validation helpers |
| 164 | # --------------------------------------------------------------------------- |
| 165 | |
| 166 | |
| 167 | def validate_manifest(manifest: dict[str, Any]) -> list[str]: |
| 168 | """Return a list of validation errors for *manifest*. |
| 169 | |
| 170 | Checks required top-level fields, required per-dimension fields, and that |
| 171 | the set of dimension names matches :data:`EXPECTED_DIMENSIONS`. |
| 172 | |
| 173 | Args: |
| 174 | manifest: Manifest dict. |
| 175 | |
| 176 | Returns: |
| 177 | List of human-readable error strings; empty list means valid. |
| 178 | """ |
| 179 | errors: list[str] = [] |
| 180 | |
| 181 | for key in sorted(_REQUIRED_FIELDS): |
| 182 | if key not in manifest: |
| 183 | errors.append(f"Missing required field: '{key}'") |
| 184 | |
| 185 | dims = manifest.get("dimensions") |
| 186 | if not isinstance(dims, list) or not dims: |
| 187 | errors.append("'dimensions' must be a non-empty list") |
| 188 | return errors |
| 189 | |
| 190 | dim_names: set[str] = set() |
| 191 | for i, dim in enumerate(dims): |
| 192 | if not isinstance(dim, dict): |
| 193 | errors.append(f"dimensions[{i}] is not a dict") |
| 194 | continue |
| 195 | for key in sorted(_REQUIRED_DIMENSION_FIELDS): |
| 196 | if key not in dim: |
| 197 | errors.append(f"dimensions[{i}] missing required field '{key}'") |
| 198 | dim_names.add(dim.get("name", "")) |
| 199 | |
| 200 | missing_dims = EXPECTED_DIMENSIONS - dim_names |
| 201 | if missing_dims: |
| 202 | errors.append( |
| 203 | f"Missing expected dimensions: {sorted(missing_dims)}" |
| 204 | ) |
| 205 | |
| 206 | return errors |
| 207 | |
| 208 | |
| 209 | def assert_manifest_in_sync() -> None: |
| 210 | """Raise ``AssertionError`` when static and dynamic manifests diverge. |
| 211 | |
| 212 | Compares the SHA-256 hash of the statically declared ``manifest.json`` |
| 213 | dimension list against the one computed from the live plugin schema. Only |
| 214 | the dimension names are compared (not descriptions) to avoid false failures |
| 215 | from editorial updates to human-written text. |
| 216 | |
| 217 | Raises: |
| 218 | AssertionError: When the set of dimension names differs. |
| 219 | """ |
| 220 | static = load_manifest() |
| 221 | dynamic = build_manifest() |
| 222 | |
| 223 | static_names = {d["name"] for d in static.get("dimensions", [])} |
| 224 | dynamic_names = {d["name"] for d in dynamic.get("dimensions", [])} |
| 225 | |
| 226 | if static_names != dynamic_names: |
| 227 | only_static = static_names - dynamic_names |
| 228 | only_dynamic = dynamic_names - static_names |
| 229 | parts = [] |
| 230 | if only_static: |
| 231 | parts.append(f"only in static: {sorted(only_static)}") |
| 232 | if only_dynamic: |
| 233 | parts.append(f"only in dynamic: {sorted(only_dynamic)}") |
| 234 | raise AssertionError( |
| 235 | "manifest.json dimensions out of sync with live plugin schema: " |
| 236 | + "; ".join(parts) |
| 237 | ) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
34 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
34 days ago