moca-reader 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. moca_reader/__init__.py +84 -0
  2. moca_reader/_js.py +291 -0
  3. moca_reader/attestations.py +271 -0
  4. moca_reader/audience.py +49 -0
  5. moca_reader/backends/__init__.py +1 -0
  6. moca_reader/backends/bm25.py +118 -0
  7. moca_reader/backends/dense.py +146 -0
  8. moca_reader/backends/hybrid.py +73 -0
  9. moca_reader/backends/lexical.py +112 -0
  10. moca_reader/backends/memory_store.py +122 -0
  11. moca_reader/citation.py +213 -0
  12. moca_reader/codes.py +110 -0
  13. moca_reader/conformance.py +249 -0
  14. moca_reader/content.py +312 -0
  15. moca_reader/diagnostics.py +45 -0
  16. moca_reader/digest.py +92 -0
  17. moca_reader/dsse.py +97 -0
  18. moca_reader/ed25519.py +83 -0
  19. moca_reader/embedders.py +224 -0
  20. moca_reader/evidence_text.py +187 -0
  21. moca_reader/frontmatter.py +151 -0
  22. moca_reader/library.py +176 -0
  23. moca_reader/manifest.py +186 -0
  24. moca_reader/metadata.py +58 -0
  25. moca_reader/profiles.py +5 -0
  26. moca_reader/py.typed +0 -0
  27. moca_reader/reader.py +468 -0
  28. moca_reader/relations.py +46 -0
  29. moca_reader/resolver.py +33 -0
  30. moca_reader/reviews.py +18 -0
  31. moca_reader/schemas/ag-ui-citations.schema.json +36 -0
  32. moca_reader/schemas/citation-record.schema.json +326 -0
  33. moca_reader/schemas/manifest.schema.json +171 -0
  34. moca_reader/schemas/node.schema.json +372 -0
  35. moca_reader/schemas/review-predicate.schema.json +42 -0
  36. moca_reader/schemas/search-hit.schema.json +62 -0
  37. moca_reader/schemas/sidecar-index.schema.json +155 -0
  38. moca_reader/schemas/structure.schema.json +93 -0
  39. moca_reader/schemas/trust-root.schema.json +130 -0
  40. moca_reader/schemas.py +44 -0
  41. moca_reader/search.py +346 -0
  42. moca_reader/sidecar.py +228 -0
  43. moca_reader/source.py +285 -0
  44. moca_reader/state.py +119 -0
  45. moca_reader/strategies/__init__.py +1 -0
  46. moca_reader/strategies/diversify.py +28 -0
  47. moca_reader/strategies/ontology_guided.py +63 -0
  48. moca_reader/structure.py +385 -0
  49. moca_reader/structure_ops.py +160 -0
  50. moca_reader/unicode.py +33 -0
  51. moca_reader-0.6.0.dist-info/METADATA +209 -0
  52. moca_reader-0.6.0.dist-info/RECORD +55 -0
  53. moca_reader-0.6.0.dist-info/WHEEL +4 -0
  54. moca_reader-0.6.0.dist-info/licenses/LICENSE +201 -0
  55. moca_reader-0.6.0.dist-info/licenses/NOTICE +15 -0
@@ -0,0 +1,224 @@
1
+ """MOCA's standard embedding models (sidecar index specification §5.1), run locally, as Reader embedders.
2
+
3
+ An index built with one of them (``moca-index build --model``) and a question embedded with the same one match,
4
+ whichever tool did each: this Reader, the TypeScript and .NET Readers, or the MOCA MCP server. Each model is a local
5
+ ONNX file pinned by Hugging Face commit and SHA-256, run with the convention every tool shares: the role's prefix, the
6
+ model's own tokenizer cut as Hugging Face's Python tokenizer cuts (the start token, the first tokens that fit, the end
7
+ token), one text per run (the 8-bit files quantize activations per run), then CLS or mean pooling and unit length.
8
+
9
+ The files live in a model store, one folder per model: ``~/.moca/models``, or ``MOCA_MODELS``. This module never
10
+ downloads: install a model with ``npx @openmoca/moca-embed models add <model>`` or ``moca-mcp models add <model>``.
11
+ It needs the ``onnx`` extra: ``pip install "moca-reader[onnx]"``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ ONNX_FILE = "onnx/model_quantized.onnx"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class StandardModel:
28
+ """One standard model: its short id, the name and version an index records, and how it is run."""
29
+
30
+ id: str
31
+ name: str
32
+ version: str
33
+ dimensions: int
34
+ pooling: str
35
+ max_tokens: int
36
+ query_prefix: str
37
+ document_prefix: str
38
+ repository: str
39
+ commit: str
40
+ licence: str
41
+ files: dict[str, str] = field(default_factory=dict)
42
+
43
+
44
+ STANDARD_MODELS: tuple[StandardModel, ...] = (
45
+ StandardModel(
46
+ "bge-small-en-v1.5",
47
+ "BAAI/bge-small-en-v1.5",
48
+ "onnx-q8",
49
+ 384,
50
+ "cls",
51
+ 512,
52
+ "Represent this sentence for searching relevant passages: ",
53
+ "",
54
+ "Xenova/bge-small-en-v1.5",
55
+ "ea104dacec62c0de699686887e3f920caeb4f3e3",
56
+ "MIT",
57
+ {
58
+ "vocab.txt": "07eced375cec144d27c900241f3e339478dec958f92fddbc551f295c992038a3",
59
+ "config.json": "fa73f90bf92c8cace1fbcb709626306f2bdbc9ea3e5b5f94b440df9b6aa56350",
60
+ "tokenizer.json": "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66",
61
+ "tokenizer_config.json": "9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3",
62
+ "special_tokens_map.json": "b6d346be366a7d1d48332dbc9fdf3bf8960b5d879522b7799ddba59e76237ee3",
63
+ ONNX_FILE: "6c9c6101a956d62dfb5e7190c538226c0c5bb9cb27b651234b6df063ee7dbfe4",
64
+ },
65
+ ),
66
+ StandardModel(
67
+ "nomic-embed-text-v1.5",
68
+ "nomic-ai/nomic-embed-text-v1.5",
69
+ "onnx-q8",
70
+ 768,
71
+ "mean",
72
+ 2048,
73
+ "search_query: ",
74
+ "search_document: ",
75
+ "nomic-ai/nomic-embed-text-v1.5",
76
+ "e9b6763023c676ca8431644204f50c2b100d9aab",
77
+ "Apache-2.0",
78
+ {
79
+ "vocab.txt": "07eced375cec144d27c900241f3e339478dec958f92fddbc551f295c992038a3",
80
+ "config.json": "9ab00bd92cee80a569f708140b7b6c1661a65891ff3765b1519e181ba2f2c92b",
81
+ "tokenizer.json": "d241a60d5e8f04cc1b2b3e9ef7a4921b27bf526d9f6050ab90f9267a1f9e5c66",
82
+ "tokenizer_config.json": "d7e0000bcc80134debd2222220427e6bf5fa20a669f40a0d0d1409cc18e0a9bc",
83
+ "special_tokens_map.json": "5d5b662e421ea9fac075174bb0688ee0d9431699900b90662acd44b2a350503a",
84
+ ONNX_FILE: "b4342336debaea79de872370664b0aaeb67dea4605513d00ee236ea871a81f27",
85
+ },
86
+ ),
87
+ StandardModel(
88
+ "all-MiniLM-L6-v2",
89
+ "sentence-transformers/all-MiniLM-L6-v2",
90
+ "onnx-q8",
91
+ 384,
92
+ "mean",
93
+ 256,
94
+ "",
95
+ "",
96
+ "Xenova/all-MiniLM-L6-v2",
97
+ "751bff37182d3f1213fa05d7196b954e230abad9",
98
+ "Apache-2.0",
99
+ {
100
+ "vocab.txt": "07eced375cec144d27c900241f3e339478dec958f92fddbc551f295c992038a3",
101
+ "config.json": "7135149f7cffa1a573466c6e4d8423ed73b62fd2332c575bf738a0d033f70df7",
102
+ "tokenizer.json": "da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0",
103
+ "tokenizer_config.json": "9261e7d79b44c8195c1cada2b453e55b00aeb81e907a6664974b4d7776172ab3",
104
+ "special_tokens_map.json": "b6d346be366a7d1d48332dbc9fdf3bf8960b5d879522b7799ddba59e76237ee3",
105
+ ONNX_FILE: "afdb6f1a0e45b715d0bb9b11772f032c399babd23bfc31fed1c170afc848bdb1",
106
+ },
107
+ ),
108
+ StandardModel(
109
+ "multilingual-e5-small",
110
+ "intfloat/multilingual-e5-small",
111
+ "onnx-q8",
112
+ 384,
113
+ "mean",
114
+ 512,
115
+ "query: ",
116
+ "passage: ",
117
+ "Xenova/multilingual-e5-small",
118
+ "761b726dd34fb83930e26aab4e9ac3899aa1fa78",
119
+ "MIT",
120
+ {
121
+ "sentencepiece.bpe.model": "cfc8146abe2a0488e9e2a0c56de7952f7c11ab059eca145a0a727afce0db2865",
122
+ "config.json": "cb99455288675345e1a4f411438d5d0adbba5fbd3a67ea4fb03c015433b996c1",
123
+ "tokenizer.json": "0b44a9d7b51c3c62626640cda0e2c2f70fdacdc25bbbd68038369d14ebdf4c39",
124
+ "tokenizer_config.json": "a1d6bc8734a6f635dc158508bef000f8e2e5a759c7d92f984b2c86e5ff53425b",
125
+ "special_tokens_map.json": "d05497f1da52c5e09554c0cd874037a083e1dc1b9cfd48034d1c717f1afc07a7",
126
+ ONNX_FILE: "f80102d3f2a1229f387d3c81909990d8945513e347b0eab049f7de3c6f98c193",
127
+ },
128
+ ),
129
+ )
130
+
131
+
132
+ def default_model_store() -> Path:
133
+ """``MOCA_MODELS``, else ``~/.moca/models``."""
134
+ configured = os.environ.get("MOCA_MODELS")
135
+ return Path(configured) if configured else Path.home() / ".moca" / "models"
136
+
137
+
138
+ def find_standard_model(id_or_name: str) -> StandardModel | None:
139
+ """A standard model by short id (any case) or recorded name."""
140
+ return next((m for m in STANDARD_MODELS if m.id.lower() == id_or_name.lower() or m.name == id_or_name), None)
141
+
142
+
143
+ def installed_models(store: str | os.PathLike[str] | None = None) -> list[StandardModel]:
144
+ """The standard models whose files are all present in a store (not yet checked)."""
145
+ root = Path(store) if store is not None else default_model_store()
146
+ return [m for m in STANDARD_MODELS if all((root / m.id / f).exists() for f in m.files)]
147
+
148
+
149
+ def _sha256(path: Path) -> str:
150
+ digest = hashlib.sha256()
151
+ with path.open("rb") as stream:
152
+ for chunk in iter(lambda: stream.read(1 << 20), b""):
153
+ digest.update(chunk)
154
+ return digest.hexdigest()
155
+
156
+
157
+ def _require(id_or_name: str) -> StandardModel:
158
+ model = find_standard_model(id_or_name)
159
+ if model is None:
160
+ raise ValueError(f"not a standard model: {id_or_name} ({', '.join(m.id for m in STANDARD_MODELS)})")
161
+ return model
162
+
163
+
164
+ class StandardEmbedder:
165
+ """A standard model as a Reader embedder: ``embed(texts, role)`` with role ``"query"`` or ``"document"``."""
166
+
167
+ def __init__(self, model: StandardModel, folder: Path) -> None:
168
+ try:
169
+ import numpy as np
170
+ import onnxruntime # type: ignore[import-untyped,import-not-found,unused-ignore]
171
+ from tokenizers import Tokenizer # type: ignore[import-not-found,unused-ignore]
172
+ except ImportError as error: # pragma: no cover - depends on the environment
173
+ raise ImportError('the standard models need the onnx extra: pip install "moca-reader[onnx]"') from error
174
+ self._np: Any = np
175
+ self.model = model
176
+ self.name = model.name
177
+ self.version = model.version
178
+ self.dimensions = model.dimensions
179
+ self.query_prefix = model.query_prefix
180
+ self.document_prefix = model.document_prefix
181
+ self._tokenizer: Any = Tokenizer.from_file(str(folder / "tokenizer.json"))
182
+ self._tokenizer.no_truncation()
183
+ self._tokenizer.no_padding()
184
+ options = onnxruntime.SessionOptions()
185
+ options.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
186
+ self._session: Any = onnxruntime.InferenceSession(
187
+ str(folder / ONNX_FILE), options, providers=["CPUExecutionProvider"]
188
+ )
189
+
190
+ def embed(self, texts: Sequence[str], role: str = "document") -> list[list[float]]:
191
+ prefix = self.query_prefix if role == "query" else self.document_prefix
192
+ return [self._embed_one(prefix + text) for text in texts]
193
+
194
+ def _embed_one(self, text: str) -> list[float]:
195
+ np = self._np
196
+ ids = self._tokenizer.encode(text).ids
197
+ if len(ids) > self.model.max_tokens:
198
+ ids = [*ids[: self.model.max_tokens - 1], ids[-1]]
199
+ shape = (1, len(ids))
200
+ inputs = {
201
+ "input_ids": np.array(ids, dtype=np.int64).reshape(shape),
202
+ "attention_mask": np.ones(shape, dtype=np.int64),
203
+ "token_type_ids": np.zeros(shape, dtype=np.int64),
204
+ }
205
+ hidden: Any = self._session.run(None, inputs)[0][0].astype(np.float64)
206
+ vector = hidden[0] if self.model.pooling == "cls" else hidden.mean(axis=0)
207
+ norm = float(np.linalg.norm(vector))
208
+ result: list[float] = (vector / norm if norm > 0 else vector).astype(np.float32).tolist()
209
+ return result
210
+
211
+
212
+ def standard_embedder(id_or_name: str, store: str | os.PathLike[str] | None = None) -> StandardEmbedder:
213
+ """Loads a standard model from a store, after checking each file against its pinned SHA-256."""
214
+ model = _require(id_or_name)
215
+ folder = (Path(store) if store is not None else default_model_store()) / model.id
216
+ for file, expected in model.files.items():
217
+ path = folder / file
218
+ if not path.exists():
219
+ raise FileNotFoundError(
220
+ f"{model.id} is not installed in {folder.parent}: run: npx @openmoca/moca-embed models add {model.id}"
221
+ )
222
+ if _sha256(path) != expected:
223
+ raise ValueError(f"{model.id}: {file} does not match its pinned SHA-256")
224
+ return StandardEmbedder(model, folder)
@@ -0,0 +1,187 @@
1
+ """Evidence checks against sources inside the package (Reader contract §7).
2
+
3
+ W3C Web Annotation says selector text "MUST be normalized", with HTML and XML tags removed and character
4
+ entities replaced. These are MOCA's exact normalisation rules per source type, so every Reader reaches the
5
+ same answer. Positions count Unicode code points of the normalised text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from ._js import js_split_ws, js_trim
16
+
17
+ _KINDS = [
18
+ (re.compile(r"\.(txt|text)\Z", re.I | re.A), "text"),
19
+ (re.compile(r"\.(md|markdown)\Z", re.I | re.A), "markdown"),
20
+ (re.compile(r"\.(html|htm)\Z", re.I | re.A), "html"),
21
+ (re.compile(r"\.vtt\Z", re.I | re.A), "webvtt"),
22
+ ]
23
+ _MEDIA_FRAGMENTS = re.compile(r"^https?://www\.w3\.org/TR/media-frags/?\Z")
24
+ _ENTITIES = {"amp": "&", "lt": "<", "gt": ">", "quot": '"', "apos": "'", "nbsp": "\u00a0"}
25
+ _ENTITY = re.compile(r"&(#x[0-9a-f]+|#[0-9]+|[a-z]+);", re.I | re.A)
26
+
27
+
28
+ def source_kind(path: str) -> str | None:
29
+ for pattern, kind in _KINDS:
30
+ if pattern.search(path):
31
+ return kind
32
+ return None
33
+
34
+
35
+ def _decode_entities(text: str) -> str:
36
+ def entity(m: re.Match[str]) -> str:
37
+ e = m[1]
38
+ if e[0] == "#":
39
+ cp = int(e[2:], 16) if e[1] in "xX" else int(e[1:], 10)
40
+ return chr(cp) if cp <= 0x10FFFF else m[0]
41
+ return _ENTITIES.get(e.lower(), m[0])
42
+
43
+ return _ENTITY.sub(entity, text)
44
+
45
+
46
+ def markdown_text(source: str) -> str:
47
+ """Markdown to text: images and links become their text; heading and list markers go; *, _ and ` go."""
48
+ text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", source)
49
+ lines = [
50
+ re.sub(
51
+ r"^[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*(?:[-*+]|[0-9]+[.)])[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+",
52
+ "",
53
+ re.sub(
54
+ r"^[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*#{1,6}[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+",
55
+ "",
56
+ line,
57
+ ),
58
+ )
59
+ for line in text.split("\n")
60
+ ]
61
+ return re.sub(r"[*_`]", "", "\n".join(lines))
62
+
63
+
64
+ def html_text(source: str) -> str:
65
+ """HTML to text: script and style elements removed, tags removed, entities decoded."""
66
+ without = re.sub(
67
+ r"<(script|style)\b[^>]*>[\s\S]*?</\1[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]*>",
68
+ "",
69
+ source,
70
+ flags=re.I | re.A,
71
+ )
72
+ return _decode_entities(re.sub(r"<[^>]*>", "", without))
73
+
74
+
75
+ def _number(text: str) -> float:
76
+ try:
77
+ return float(text) if js_trim(text) else 0.0
78
+ except ValueError:
79
+ return math.nan
80
+
81
+
82
+ def _seconds(ts: str) -> float:
83
+ t = js_trim(ts)
84
+ m = re.match(r"^(?:([0-9]+):)?([0-9]{1,2}):([0-9]{2}(?:\.[0-9]+)?)\Z", t)
85
+ if m:
86
+ return _number(m[1] or "0") * 3600 + _number(m[2]) * 60 + _number(m[3])
87
+ m = re.match(r"^([0-9]+(?:\.[0-9]+)?)\Z", t)
88
+ return _number(m[1]) if m else math.nan
89
+
90
+
91
+ @dataclass
92
+ class Cue:
93
+ start: float
94
+ end: float
95
+ text: str
96
+
97
+
98
+ def webvtt(source: str) -> tuple[list[Cue], str]:
99
+ """WebVTT cues and their payload text, cue tags removed and entities decoded, joined with LF."""
100
+ blocks = re.split(r"\n{2,}", re.sub(r"\r\n?", "\n", source))
101
+ cues: list[Cue] = []
102
+ for block in blocks[1:]:
103
+ lines = block.split("\n")
104
+ at = next((i for i, line in enumerate(lines) if "-->" in line), -1)
105
+ if at < 0:
106
+ continue
107
+ start_text, _, rest = lines[at].partition("-->")
108
+ start = _seconds(start_text)
109
+ end = _seconds((js_split_ws(rest) or [""])[0])
110
+ text = "\n".join(_decode_entities(re.sub(r"<[^>]*>", "", line)) for line in lines[at + 1 :])
111
+ if math.isfinite(start) and math.isfinite(end):
112
+ cues.append(Cue(start, end, text))
113
+ return cues, "\n".join(c.text for c in cues)
114
+
115
+
116
+ def normalised_text(raw: str, kind: str) -> str:
117
+ if kind == "markdown":
118
+ return markdown_text(raw)
119
+ if kind == "html":
120
+ return html_text(raw)
121
+ if kind == "webvtt":
122
+ return webvtt(raw)[1]
123
+ return raw
124
+
125
+
126
+ def media_time_range(value: Any) -> tuple[float, float] | None:
127
+ """A Media Fragments temporal value ``t=start[,end]`` (seconds or hh:mm:ss), or None."""
128
+ m = re.search(r"(?:^|&)t=(?:npt:)?([^,&]*)(?:,([^&]*))?", "" if value is None else str(value))
129
+ if not m:
130
+ return None
131
+ start = 0.0 if m[1] == "" else _seconds(m[1])
132
+ end = math.inf if m[2] is None else _seconds(m[2])
133
+ if not math.isfinite(start) or math.isnan(end) or end < start:
134
+ return None
135
+ return start, end
136
+
137
+
138
+ def _is_integer(value: Any) -> bool:
139
+ return (isinstance(value, int) and not isinstance(value, bool)) or (isinstance(value, float) and value.is_integer())
140
+
141
+
142
+ def selector_matches(selector: Any, text: str) -> bool | None:
143
+ """None when the selector type is not checked."""
144
+ kind = selector.get("type") if isinstance(selector, dict) else None
145
+ if kind == "TextQuoteSelector":
146
+ exact = selector.get("exact")
147
+ if not isinstance(exact, str) or exact == "":
148
+ return False
149
+ prefix = selector["prefix"] if isinstance(selector.get("prefix"), str) else ""
150
+ suffix = selector["suffix"] if isinstance(selector.get("suffix"), str) else ""
151
+ # One search for prefix + exact + suffix: the same answer as checking each occurrence of exact, without
152
+ # rescanning the text for every one.
153
+ return (prefix + exact + suffix) in text
154
+ # Contract §7: a TextPositionSelector on its own proves only that its offsets are in range, so it is not
155
+ # checked: `matched` stays absent.
156
+ if kind == "TextPositionSelector":
157
+ return None
158
+ return None
159
+
160
+
161
+ def check_selector(selector: dict[str, Any], raw: str, kind: str, prepared: str | None = None) -> bool | None:
162
+ """Checks one selector against a source's raw text; None when it cannot be checked against this source."""
163
+ if selector.get("type") == "FragmentSelector":
164
+ conforms = selector.get("conformsTo")
165
+ if not _MEDIA_FRAGMENTS.match(conforms if isinstance(conforms, str) else "") or kind != "webvtt":
166
+ return None
167
+ window = media_time_range(selector.get("value"))
168
+ if window is None:
169
+ return False
170
+ return any(window[0] < c.end and window[1] > c.start for c in webvtt(raw)[0])
171
+ # The quote is normalised as the source is, so markup in either never decides a match.
172
+ quote = _normalise_quote(selector, kind) if selector.get("type") == "TextQuoteSelector" else selector
173
+ return selector_matches(quote, prepared if prepared is not None else normalised_text(raw, kind))
174
+
175
+
176
+ def _normalise_quote(selector: dict[str, Any], kind: str) -> dict[str, Any]:
177
+ # A quote from captions is cue text already; Markdown and HTML quotes lose markup as their sources do.
178
+ if kind not in ("markdown", "html"):
179
+ return selector
180
+ return {
181
+ **selector,
182
+ **{
183
+ k: normalised_text(selector[k], kind)
184
+ for k in ("exact", "prefix", "suffix")
185
+ if isinstance(selector.get(k), str)
186
+ },
187
+ }
@@ -0,0 +1,151 @@
1
+ """YAML frontmatter for OKF concept documents.
2
+
3
+ Parsed with the YAML 1.2 core schema (package spec §5): timestamps, ``yes``, ``no`` and ``on`` stay strings
4
+ exactly as written, and aliases are refused (Reader contract §3). PyYAML's own resolvers follow YAML 1.1, so
5
+ this reads PyYAML's event stream and resolves plain scalars itself, as the .NET Reader does.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from ._js import js_trim, number_to_string
18
+ from .digest import utf8
19
+
20
+ _OPEN = re.compile(r"^---[ \t]*\r?\n")
21
+ # Package spec §5.1: only LF ends a line (CRLF is LF after CR).
22
+ _CLOSE = re.compile(r"(?:^|(?<=\n))---[ \t]*(?:\r?\n|\Z)")
23
+
24
+
25
+ @dataclass
26
+ class FrontmatterSplit:
27
+ present: bool
28
+ body: str
29
+ body_offset: int
30
+ line: int = 1
31
+ raw: str | None = None
32
+ data: Any = None
33
+ error: str | None = None
34
+
35
+
36
+ def split_frontmatter(text: str, max_bytes: float = math.inf) -> FrontmatterSplit:
37
+ """Splits a document into frontmatter and body; ``body_offset`` is the UTF-8 byte offset of the body."""
38
+ bom = "\ufeff" if text.startswith("\ufeff") else ""
39
+ rest = text[len(bom) :]
40
+ opened = _OPEN.match(rest)
41
+ if not opened:
42
+ return FrontmatterSplit(False, text, len(utf8(bom)))
43
+ after_open = rest[opened.end() :]
44
+ closed = _CLOSE.search(after_open)
45
+ if not closed:
46
+ return FrontmatterSplit(True, text, 0, error="frontmatter block is not closed with ---")
47
+ raw = after_open[: closed.start()]
48
+ consumed = bom + opened.group(0) + after_open[: closed.end()]
49
+ result = FrontmatterSplit(True, text[len(consumed) :], len(utf8(consumed)), raw=raw)
50
+ if len(utf8(raw)) > max_bytes:
51
+ result.error = f"frontmatter is larger than the {int(max_bytes)}-byte limit"
52
+ return result
53
+ try:
54
+ result.data = {} if js_trim(raw) == "" else load_core_schema(raw)
55
+ except yaml.YAMLError as err:
56
+ mark = getattr(err, "problem_mark", None) or getattr(err, "context_mark", None)
57
+ result.error = str(getattr(err, "problem", None) or err)
58
+ if mark is not None:
59
+ result.line = mark.line + 2
60
+ return result
61
+ if not isinstance(result.data, dict):
62
+ result.error = "frontmatter is not a YAML mapping"
63
+ return result
64
+
65
+
66
+ _NULL = re.compile(r"^(?:null|Null|NULL|~)?\Z")
67
+ _BOOL = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)\Z")
68
+ _INT = re.compile(r"^[-+]?[0-9]+\Z")
69
+ _OCT = re.compile(r"^0o[0-7]+\Z")
70
+ _HEX = re.compile(r"^0x[0-9a-fA-F]+\Z")
71
+ _FLOAT = re.compile(r"^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?\Z")
72
+ _STR_TAG = "tag:yaml.org,2002:str"
73
+
74
+
75
+ class _Error(yaml.MarkedYAMLError):
76
+ pass
77
+
78
+
79
+ def _error(message: str, event: Any) -> _Error:
80
+ """An error at an event, with its position, for C002's line number."""
81
+ return _Error(None, None, message, event.start_mark)
82
+
83
+
84
+ def _resolve(event: yaml.ScalarEvent) -> Any:
85
+ value = event.value
86
+ # Quoted and block scalars, and explicitly tagged strings, are always strings.
87
+ if event.style is not None or event.tag in (_STR_TAG, "!"):
88
+ return value
89
+ if _NULL.match(value):
90
+ return None
91
+ if _BOOL.match(value):
92
+ return value.lower() == "true"
93
+ if _INT.match(value):
94
+ return int(value)
95
+ if _OCT.match(value):
96
+ return int(value[2:], 8)
97
+ if _HEX.match(value):
98
+ return int(value[2:], 16)
99
+ if _FLOAT.match(value):
100
+ return float(value)
101
+ return value
102
+
103
+
104
+ def _key_text(key: Any) -> str:
105
+ if key is None:
106
+ return "null"
107
+ if isinstance(key, str):
108
+ return key
109
+ return number_to_string(key)
110
+
111
+
112
+ def load_core_schema(text: str) -> Any:
113
+ """Loads one YAML document with the YAML 1.2 core schema; aliases and duplicate keys are errors."""
114
+ events = iter(yaml.parse(text, Loader=yaml.SafeLoader))
115
+
116
+ def node(event: yaml.Event) -> Any:
117
+ if isinstance(event, yaml.AliasEvent):
118
+ # Alias expansion is a denial-of-service vector (Reader contract §3, "YAML aliases in frontmatter: 0").
119
+ raise _error("YAML aliases are not allowed in frontmatter", event)
120
+ if isinstance(event, yaml.ScalarEvent):
121
+ return _resolve(event)
122
+ if isinstance(event, yaml.SequenceStartEvent):
123
+ out: list[Any] = []
124
+ for e in events:
125
+ if isinstance(e, yaml.SequenceEndEvent):
126
+ return out
127
+ out.append(node(e))
128
+ if isinstance(event, yaml.MappingStartEvent):
129
+ mapping: dict[str, Any] = {}
130
+ for e in events:
131
+ if isinstance(e, yaml.MappingEndEvent):
132
+ return mapping
133
+ if not isinstance(e, yaml.ScalarEvent):
134
+ raise _error("mapping keys must be scalars", e)
135
+ key = _key_text(_resolve(e))
136
+ if key in mapping:
137
+ raise _error(f'duplicated mapping key "{key}"', e)
138
+ mapping[key] = node(next(events))
139
+ raise _error(f"unexpected YAML event {type(event).__name__}", event)
140
+
141
+ first = next(events) # StreamStart
142
+ del first
143
+ start = next(events)
144
+ if isinstance(start, yaml.StreamEndEvent):
145
+ return None
146
+ value = node(next(events))
147
+ next(events) # DocumentEnd
148
+ after = next(events)
149
+ if not isinstance(after, yaml.StreamEndEvent):
150
+ raise _Error(None, None, "expected a single document in the frontmatter", after.start_mark)
151
+ return value