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.
- moca_reader/__init__.py +84 -0
- moca_reader/_js.py +291 -0
- moca_reader/attestations.py +271 -0
- moca_reader/audience.py +49 -0
- moca_reader/backends/__init__.py +1 -0
- moca_reader/backends/bm25.py +118 -0
- moca_reader/backends/dense.py +146 -0
- moca_reader/backends/hybrid.py +73 -0
- moca_reader/backends/lexical.py +112 -0
- moca_reader/backends/memory_store.py +122 -0
- moca_reader/citation.py +213 -0
- moca_reader/codes.py +110 -0
- moca_reader/conformance.py +249 -0
- moca_reader/content.py +312 -0
- moca_reader/diagnostics.py +45 -0
- moca_reader/digest.py +92 -0
- moca_reader/dsse.py +97 -0
- moca_reader/ed25519.py +83 -0
- moca_reader/embedders.py +224 -0
- moca_reader/evidence_text.py +187 -0
- moca_reader/frontmatter.py +151 -0
- moca_reader/library.py +176 -0
- moca_reader/manifest.py +186 -0
- moca_reader/metadata.py +58 -0
- moca_reader/profiles.py +5 -0
- moca_reader/py.typed +0 -0
- moca_reader/reader.py +468 -0
- moca_reader/relations.py +46 -0
- moca_reader/resolver.py +33 -0
- moca_reader/reviews.py +18 -0
- moca_reader/schemas/ag-ui-citations.schema.json +36 -0
- moca_reader/schemas/citation-record.schema.json +326 -0
- moca_reader/schemas/manifest.schema.json +171 -0
- moca_reader/schemas/node.schema.json +372 -0
- moca_reader/schemas/review-predicate.schema.json +42 -0
- moca_reader/schemas/search-hit.schema.json +62 -0
- moca_reader/schemas/sidecar-index.schema.json +155 -0
- moca_reader/schemas/structure.schema.json +93 -0
- moca_reader/schemas/trust-root.schema.json +130 -0
- moca_reader/schemas.py +44 -0
- moca_reader/search.py +346 -0
- moca_reader/sidecar.py +228 -0
- moca_reader/source.py +285 -0
- moca_reader/state.py +119 -0
- moca_reader/strategies/__init__.py +1 -0
- moca_reader/strategies/diversify.py +28 -0
- moca_reader/strategies/ontology_guided.py +63 -0
- moca_reader/structure.py +385 -0
- moca_reader/structure_ops.py +160 -0
- moca_reader/unicode.py +33 -0
- moca_reader-0.6.0.dist-info/METADATA +209 -0
- moca_reader-0.6.0.dist-info/RECORD +55 -0
- moca_reader-0.6.0.dist-info/WHEEL +4 -0
- moca_reader-0.6.0.dist-info/licenses/LICENSE +201 -0
- moca_reader-0.6.0.dist-info/licenses/NOTICE +15 -0
moca_reader/embedders.py
ADDED
|
@@ -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
|