hub_manifest.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 )