parser.py python
614 lines 25.0 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """Knowtation frontmatter schema — typed parser, validator, and migrator.
2
3 This module is the **single source of truth** for every frontmatter field that
4 the Knowtation Muse plugin reads, writes, or validates. It replaces the
5 lightweight regex key-extractor in :mod:`~muse.plugins.knowtation.detector` for
6 cases where field *values* matter (diff, merge, schema migration, domain-info).
7
8 Schema version
9 --------------
10 :data:`FRONTMATTER_SCHEMA_VERSION` is a plain integer string (``"1"``, ``"2"``,
11 …) **independent** of the Muse package semver (which lives in
12 :mod:`muse._version`). The schema version is bumped when:
13
14 - A field is added with a non-trivial coercion rule or mandatory default, OR
15 - A field is renamed or removed.
16
17 Adding a new optional field with a ``None`` / empty-list default does **not**
18 require a version bump (backward-compatible).
19
20 Field reference (SPEC §2)
21 --------------------------
22 §2.1 Common (any note):
23
24 title, project, tags, date, updated
25
26 §2.2 Inbox (notes written by capture/import pipeline):
27
28 source (canonical), source_id (recommended), source_type (import alias)
29
30 §2.3 Intention and temporal (all optional):
31
32 follows, causal_chain_id, entity, episode_id, summarizes,
33 summarizes_range, state_snapshot
34
35 Phase 1.7 — mist attachment IDs (reserved, schema v2):
36
37 attachments
38
39 §2.4 Reserved for Phase 12 (blockchain; parsers MUST NOT require them):
40
41 network, wallet_address, tx_hash, payment_status
42
43 All other keys are preserved verbatim in :attr:`ParsedFrontmatter.extra` so
44 that round-trips through ``parse → to_dict`` are lossless.
45 """
46
47 from __future__ import annotations
48
49 import logging
50 import re
51 from dataclasses import dataclass, field
52 from typing import Any
53
54 import yaml # pyyaml — safe_load only; no arbitrary object instantiation
55
56 logger = logging.getLogger(__name__)
57
58
59 # ──────────────────────────────────────────────────────────────────────────────
60 # Schema version
61 # ──────────────────────────────────────────────────────────────────────────────
62
63 #: Current frontmatter schema version. Bumped independently of Muse semver.
64 #: Phase 1.3 baseline = "1". Phase 1.7 (``attachments`` field) = "2".
65 FRONTMATTER_SCHEMA_VERSION: str = "2"
66
67 #: All schema version strings this module knows how to migrate through, ordered.
68 SCHEMA_VERSIONS: tuple[str, ...] = ("1", "2")
69
70
71 # ──────────────────────────────────────────────────────────────────────────────
72 # Slug / tag normalisation (mirrors SPEC §1)
73 # ──────────────────────────────────────────────────────────────────────────────
74
75 _NON_SLUG_RE: re.Pattern[str] = re.compile(r"[^a-z0-9\-]")
76 _EDGE_HYPHEN_RE: re.Pattern[str] = re.compile(r"^-+|-+$")
77
78
79 def normalize_slug(value: str) -> str:
80 """Normalise *value* to a SPEC §1 slug.
81
82 Lowercases the string, replaces every character that is not ``[a-z0-9-]``
83 with a hyphen, then strips leading/trailing hyphens.
84
85 Args:
86 value: Arbitrary string (e.g. a project name or tag).
87
88 Returns:
89 Slug string that satisfies SPEC §1 normalisation.
90
91 Examples::
92
93 >>> normalize_slug("Born Free")
94 'born-free'
95 >>> normalize_slug(" hello_world! ")
96 'hello-world-' # trailing hyphen stripped → 'hello-world'
97 """
98 lowered = value.lower()
99 slugged = _NON_SLUG_RE.sub("-", lowered)
100 return _EDGE_HYPHEN_RE.sub("", slugged)
101
102
103 # ──────────────────────────────────────────────────────────────────────────────
104 # Value coercion helpers
105 # ──────────────────────────────────────────────────────────────────────────────
106
107
108 def _coerce_optional_str(value: Any) -> str | None:
109 """Return *value* as ``str`` when non-``None``, else ``None``."""
110 if value is None:
111 return None
112 return str(value)
113
114
115 def _coerce_str_or_list(value: Any) -> list[str]:
116 """Coerce a scalar-or-list YAML value to a ``list[str]``.
117
118 Handles:
119 - ``None`` → ``[]``
120 - YAML list → each element stringified
121 - A bare string → split on commas (``"a, b, c"`` → ``["a", "b", "c"]``)
122 - Any other scalar → ``[str(value)]``
123
124 This covers both the ``tags: [foo, bar]`` (YAML list) and
125 ``tags: foo, bar`` (comma-separated scalar) forms permitted by SPEC §2.1.
126 """
127 if value is None:
128 return []
129 if isinstance(value, list):
130 return [str(v) for v in value]
131 if isinstance(value, str):
132 parts = [p.strip() for p in value.split(",")]
133 return [p for p in parts if p]
134 return [str(value)]
135
136
137 # ──────────────────────────────────────────────────────────────────────────────
138 # All recognised top-level keys (used to populate ``extra``)
139 # ──────────────────────────────────────────────────────────────────────────────
140
141 _KNOWN_KEYS: frozenset[str] = frozenset(
142 {
143 # §2.1 Common
144 "title",
145 "project",
146 "tags",
147 "date",
148 "updated",
149 # §2.2 Inbox
150 "source",
151 "source_id",
152 # Import-pipeline alias
153 "source_type",
154 # §2.3 Temporal / intention
155 "follows",
156 "causal_chain_id",
157 "entity",
158 "episode_id",
159 "summarizes",
160 "summarizes_range",
161 "state_snapshot",
162 # Phase 1.7 — mist attachment IDs
163 "attachments",
164 # §2.4 Reserved Phase 12
165 "network",
166 "wallet_address",
167 "tx_hash",
168 "payment_status",
169 }
170 )
171
172
173 # ──────────────────────────────────────────────────────────────────────────────
174 # ParsedFrontmatter dataclass
175 # ──────────────────────────────────────────────────────────────────────────────
176
177
178 @dataclass
179 class ParsedFrontmatter:
180 """Fully typed representation of a Knowtation note's YAML frontmatter.
181
182 Every field mirrors SPEC §2 (§2.1 common, §2.2 inbox, §2.3 temporal, §2.4
183 reserved Phase-12) plus the import-pipeline ``source_type`` alias and the
184 Phase 1.7 ``attachments`` list.
185
186 All list fields default to ``[]``; optional string fields default to
187 ``None``; ``state_snapshot`` defaults to ``False``.
188
189 Unknown keys are preserved verbatim in :attr:`extra` so that
190 ``parse → to_dict → parse`` round-trips are lossless.
191 """
192
193 # ── §2.1 Common ──────────────────────────────────────────────────────────
194 title: str | None = None
195 project: str | None = None
196 tags: list[str] = field(default_factory=list)
197 date: str | None = None
198 updated: str | None = None
199
200 # ── §2.2 Inbox ───────────────────────────────────────────────────────────
201 source: str | None = None
202 source_id: str | None = None
203
204 #: Import-pipeline alias for ``source``. Kept for backward-compat reading;
205 #: new notes should use ``source`` only (SPEC §2.2).
206 source_type: str | None = None
207
208 # ── §2.3 Intention and temporal ──────────────────────────────────────────
209 follows: list[str] = field(default_factory=list)
210 causal_chain_id: str | None = None
211 entity: list[str] = field(default_factory=list)
212 episode_id: str | None = None
213 summarizes: list[str] = field(default_factory=list)
214 summarizes_range: str | None = None
215 state_snapshot: bool = False
216
217 # ── Phase 1.7 — mist attachment IDs ─────────────────────────────────────
218 attachments: list[str] = field(default_factory=list)
219
220 # ── §2.4 Reserved Phase 12 ───────────────────────────────────────────────
221 #: Parsers MUST NOT require these; stored as-is without validation.
222 network: str | None = None
223 wallet_address: str | None = None
224 tx_hash: str | None = None
225 payment_status: str | None = None
226
227 # ── Pass-through for unknown / future keys ────────────────────────────────
228 extra: dict[str, Any] = field(default_factory=dict)
229
230 # ── Schema metadata ───────────────────────────────────────────────────────
231 #: Schema version at parse time, from :data:`FRONTMATTER_SCHEMA_VERSION`.
232 schema_version: str = FRONTMATTER_SCHEMA_VERSION
233
234 # ── Computed properties ───────────────────────────────────────────────────
235
236 @property
237 def effective_source(self) -> str | None:
238 """Return ``source`` when set, else ``source_type`` (import-pipeline alias).
239
240 Callers that only care *whether* a source is present should use this
241 rather than checking both fields separately.
242 """
243 return self.source or self.source_type
244
245 @property
246 def is_inbox_note(self) -> bool:
247 """Return ``True`` when the note satisfies the SPEC §2.2 inbox contract.
248
249 An inbox note must have both ``source`` (or ``source_type``) and ``date``.
250 """
251 return self.effective_source is not None and self.date is not None
252
253 def to_dict(self) -> dict[str, Any]:
254 """Serialise back to a plain ``dict`` (suitable for ``yaml.dump``).
255
256 Only non-empty / non-None / non-False fields are included so that the
257 output remains clean for human readers. ``extra`` keys are merged last
258 (known fields take precedence if there is a collision).
259 """
260 out: dict[str, Any] = {}
261
262 # §2.1
263 if self.title is not None:
264 out["title"] = self.title
265 if self.project is not None:
266 out["project"] = self.project
267 if self.tags:
268 out["tags"] = self.tags
269 if self.date is not None:
270 out["date"] = self.date
271 if self.updated is not None:
272 out["updated"] = self.updated
273
274 # §2.2
275 if self.source is not None:
276 out["source"] = self.source
277 if self.source_id is not None:
278 out["source_id"] = self.source_id
279 if self.source_type is not None:
280 out["source_type"] = self.source_type
281
282 # §2.3
283 if self.follows:
284 out["follows"] = self.follows
285 if self.causal_chain_id is not None:
286 out["causal_chain_id"] = self.causal_chain_id
287 if self.entity:
288 out["entity"] = self.entity
289 if self.episode_id is not None:
290 out["episode_id"] = self.episode_id
291 if self.summarizes:
292 out["summarizes"] = self.summarizes
293 if self.summarizes_range is not None:
294 out["summarizes_range"] = self.summarizes_range
295 if self.state_snapshot:
296 out["state_snapshot"] = self.state_snapshot
297
298 # Phase 1.7
299 if self.attachments:
300 out["attachments"] = self.attachments
301
302 # §2.4 reserved
303 if self.network is not None:
304 out["network"] = self.network
305 if self.wallet_address is not None:
306 out["wallet_address"] = self.wallet_address
307 if self.tx_hash is not None:
308 out["tx_hash"] = self.tx_hash
309 if self.payment_status is not None:
310 out["payment_status"] = self.payment_status
311
312 # Unknown pass-through keys (known fields already written above)
313 for k, v in self.extra.items():
314 if k not in out:
315 out[k] = v
316
317 return out
318
319
320 # ──────────────────────────────────────────────────────────────────────────────
321 # Raw YAML block extraction
322 # ──────────────────────────────────────────────────────────────────────────────
323
324
325 def _extract_frontmatter_block(content: bytes) -> str | None:
326 """Return the raw YAML string inside the leading ``---`` delimiters.
327
328 Recognises both ``---`` … ``---`` and ``---`` … ``...`` (YAML end-of-document
329 marker). Returns ``None`` when no valid frontmatter block is found.
330
331 Args:
332 content: Raw bytes of the note file.
333
334 Returns:
335 YAML string between the delimiters, or ``None``.
336 """
337 try:
338 text = content.decode("utf-8", errors="replace")
339 except Exception:
340 return None
341
342 if not text.startswith("---"):
343 return None
344
345 rest = text[3:]
346 end = -1
347 for delim in ("\n---", "\n..."):
348 pos = rest.find(delim)
349 if pos != -1 and (end == -1 or pos < end):
350 end = pos
351
352 if end == -1:
353 return None
354
355 return rest[:end]
356
357
358 # ──────────────────────────────────────────────────────────────────────────────
359 # Public parse API
360 # ──────────────────────────────────────────────────────────────────────────────
361
362
363 def parse_frontmatter(content: bytes) -> ParsedFrontmatter | None:
364 """Parse the YAML frontmatter block of a note into a typed object.
365
366 Uses ``yaml.safe_load`` — no arbitrary Python object instantiation.
367
368 Args:
369 content: Raw bytes of the note file (Markdown, CRLF or LF, UTF-8).
370
371 Returns:
372 A :class:`ParsedFrontmatter` when a valid frontmatter block is found,
373 or ``None`` when the file has no frontmatter, has a YAML parse error,
374 or the frontmatter is not a mapping.
375
376 Notes:
377 - ``tags``, ``follows``, ``entity``, ``summarizes``, ``attachments``
378 accept either a YAML list or a comma-separated string.
379 - ``source_type`` is stored as-is; call :attr:`~ParsedFrontmatter.effective_source`
380 to get the canonical source regardless of which key was used.
381 - Unknown keys are preserved in :attr:`~ParsedFrontmatter.extra`.
382 """
383 raw = _extract_frontmatter_block(content)
384 if raw is None:
385 return None
386
387 try:
388 data = yaml.safe_load(raw)
389 except yaml.YAMLError as exc:
390 logger.debug("YAMLError parsing frontmatter block: %s", exc)
391 return None
392
393 if not isinstance(data, dict):
394 return None
395
396 extra: dict[str, Any] = {k: v for k, v in data.items() if k not in _KNOWN_KEYS}
397
398 return ParsedFrontmatter(
399 # §2.1
400 title=_coerce_optional_str(data.get("title")),
401 project=_coerce_optional_str(data.get("project")),
402 tags=_coerce_str_or_list(data.get("tags")),
403 date=_coerce_optional_str(data.get("date")),
404 updated=_coerce_optional_str(data.get("updated")),
405 # §2.2
406 source=_coerce_optional_str(data.get("source")),
407 source_id=_coerce_optional_str(data.get("source_id")),
408 source_type=_coerce_optional_str(data.get("source_type")),
409 # §2.3
410 follows=_coerce_str_or_list(data.get("follows")),
411 causal_chain_id=_coerce_optional_str(data.get("causal_chain_id")),
412 entity=_coerce_str_or_list(data.get("entity")),
413 episode_id=_coerce_optional_str(data.get("episode_id")),
414 summarizes=_coerce_str_or_list(data.get("summarizes")),
415 summarizes_range=_coerce_optional_str(data.get("summarizes_range")),
416 state_snapshot=bool(data.get("state_snapshot", False)),
417 # Phase 1.7
418 attachments=_coerce_str_or_list(data.get("attachments")),
419 # §2.4
420 network=_coerce_optional_str(data.get("network")),
421 wallet_address=_coerce_optional_str(data.get("wallet_address")),
422 tx_hash=_coerce_optional_str(data.get("tx_hash")),
423 payment_status=_coerce_optional_str(data.get("payment_status")),
424 extra=extra,
425 schema_version=FRONTMATTER_SCHEMA_VERSION,
426 )
427
428
429 # ──────────────────────────────────────────────────────────────────────────────
430 # Mist-ID validation constants (Phase 1.7)
431 # ──────────────────────────────────────────────────────────────────────────────
432
433 # Mist attachment IDs are the first 12 characters of the base58-encoded
434 # SHA-256 hash of the blob content, per the Mist object-store manifest spec.
435 # Base58 alphabet excludes 0/O/I/l to prevent transcription errors.
436 _BASE58_ALPHABET: str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
437 _MIST_ID_RE: re.Pattern[str] = re.compile(
438 r"^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{12}$"
439 )
440
441
442 # ──────────────────────────────────────────────────────────────────────────────
443 # Validation
444 # ──────────────────────────────────────────────────────────────────────────────
445
446
447 def validate_inbox_note(fm: ParsedFrontmatter) -> list[str]:
448 """Validate *fm* against the SPEC §2.2 inbox-note required-field contract.
449
450 Args:
451 fm: A parsed frontmatter object (from :func:`parse_frontmatter`).
452
453 Returns:
454 List of human-readable error strings. Empty list means valid.
455 """
456 errors: list[str] = []
457 if fm.effective_source is None:
458 errors.append(
459 "Inbox note MUST have 'source' (or 'source_type') field (SPEC §2.2)."
460 )
461 if fm.date is None:
462 errors.append("Inbox note MUST have 'date' field (SPEC §2.2).")
463 return errors
464
465
466 def validate_attachment_ids(fm: ParsedFrontmatter) -> list[str]:
467 """Validate that every entry in *fm.attachments* is a well-formed mist ID.
468
469 A valid mist ID is exactly 12 characters from the base58 alphabet
470 (``123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz``).
471 This matches the first 12 characters of the base58-encoded SHA-256 hash
472 produced by the Mist object-store push command.
473
474 Args:
475 fm: A parsed frontmatter object.
476
477 Returns:
478 List of human-readable error strings; empty list means all IDs are
479 valid (or the ``attachments`` field is empty).
480 """
481 errors: list[str] = []
482 for i, mist_id in enumerate(fm.attachments):
483 if not isinstance(mist_id, str):
484 errors.append(
485 f"attachments[{i}]: expected a string, got {type(mist_id).__name__!r}."
486 )
487 elif not _MIST_ID_RE.match(mist_id):
488 errors.append(
489 f"attachments[{i}]: {mist_id!r} is not a valid mist ID "
490 f"(must be exactly 12 base58 characters)."
491 )
492 return errors
493
494
495 def validate_slug_field(value: str, field_name: str) -> list[str]:
496 """Return validation errors if *value* is not a valid SPEC §1 slug.
497
498 Args:
499 value: The field value to check.
500 field_name: Name used in error messages (e.g. ``"project"``).
501
502 Returns:
503 List of error strings; empty means the value is a valid slug.
504 """
505 normalized = normalize_slug(value)
506 if normalized != value:
507 return [
508 f"Field '{field_name}' value '{value}' is not a valid slug; "
509 f"normalized form is '{normalized}'."
510 ]
511 return []
512
513
514 # ──────────────────────────────────────────────────────────────────────────────
515 # Schema migration
516 # ──────────────────────────────────────────────────────────────────────────────
517
518
519 def migrate_frontmatter(data: dict[str, Any], from_version: str) -> dict[str, Any]:
520 """Migrate a raw frontmatter dict from *from_version* to the current schema.
521
522 Migrations are applied sequentially so ``from_version="1"`` will apply
523 v1→v2, v2→v3, … until :data:`FRONTMATTER_SCHEMA_VERSION` is reached.
524
525 When *from_version* already equals :data:`FRONTMATTER_SCHEMA_VERSION`, the
526 dict is returned unchanged (no copy).
527
528 Defined migrations
529 ------------------
530 v1 → v2 (stub, Phase 1.7):
531 Phase 1.7 will make ``attachments`` a first-class field. At that
532 point this step will normalise any legacy attachment key that lived in
533 ``extra``. For now the migration is a no-op so callers can exercise
534 the pipeline end-to-end before the field name is locked.
535
536 Args:
537 data: Raw frontmatter dict as returned by ``yaml.safe_load``.
538 from_version: Schema version string stored alongside the data.
539
540 Returns:
541 Migrated dict, or the original dict when no migration is needed.
542
543 Raises:
544 ValueError: When *from_version* is not in :data:`SCHEMA_VERSIONS`.
545 """
546 if from_version == FRONTMATTER_SCHEMA_VERSION:
547 return data
548
549 if from_version not in SCHEMA_VERSIONS:
550 raise ValueError(
551 f"Unknown frontmatter schema version '{from_version}'. "
552 f"Known versions: {SCHEMA_VERSIONS}."
553 )
554
555 current = data.copy()
556 start_idx = SCHEMA_VERSIONS.index(from_version)
557 target_idx = SCHEMA_VERSIONS.index(FRONTMATTER_SCHEMA_VERSION)
558
559 for step in range(start_idx, target_idx):
560 step_key = (SCHEMA_VERSIONS[step], SCHEMA_VERSIONS[step + 1])
561 migration_fn = _MIGRATION_TABLE.get(step_key)
562 if migration_fn is not None:
563 current = migration_fn(current)
564
565 return current
566
567
568 def _migrate_v1_to_v2(data: dict[str, Any]) -> dict[str, Any]:
569 """v1 → v2: promote the ``attachments`` field to first-class status.
570
571 In schema v1, notes that stored mist blob IDs placed them under various
572 ad-hoc keys (``attachment``, ``mist_id``, ``mist_ids``). v2 normalises
573 all of these into the canonical ``attachments: [<mist-id>, ...]`` list.
574
575 Migration steps applied (in order):
576 1. ``attachment`` (singular) → appended to ``attachments`` list, key removed.
577 2. ``mist_id`` (singular) → appended to ``attachments`` list, key removed.
578 3. ``mist_ids`` (list) → items merged into ``attachments`` list, key removed.
579 4. Existing ``attachments`` value is coerced to a list via
580 :func:`_coerce_str_or_list` so that a bare scalar is accepted.
581
582 Args:
583 data: Raw frontmatter dict (from ``yaml.safe_load``).
584
585 Returns:
586 New dict with the normalised ``attachments`` key.
587 """
588 result = data.copy()
589
590 # Collect attachments from all legacy locations.
591 merged: list[str] = []
592
593 existing = result.pop("attachments", None)
594 if existing is not None:
595 merged.extend(_coerce_str_or_list(existing))
596
597 for legacy_key in ("attachment", "mist_id"):
598 val = result.pop(legacy_key, None)
599 if val is not None:
600 merged.extend(_coerce_str_or_list(val))
601
602 legacy_list = result.pop("mist_ids", None)
603 if legacy_list is not None:
604 merged.extend(_coerce_str_or_list(legacy_list))
605
606 if merged:
607 result["attachments"] = merged
608
609 return result
610
611
612 _MIGRATION_TABLE: dict[tuple[str, str], Any] = {
613 ("1", "2"): _migrate_v1_to_v2,
614 }
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago