curu-memory 0.2.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.
@@ -0,0 +1,440 @@
1
+ """The index use case: read the concept files, report, and build the index.
2
+
3
+ `build_index` is the only path from the canonical files to the index (constitution,
4
+ article I). It writes nothing until each file is read and each vector is made, so a
5
+ failure keeps the old index.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from curu_memory.errors import EmbedderFailed, IndexRefused
17
+ from curu_memory.media import MediaFile, read_media
18
+ from curu_memory.okf import SUMMARY, ConceptFault, ParsedConcept, parse_concept, split_units
19
+ from curu_memory.ports import (
20
+ GLOBAL,
21
+ ConceptMedia,
22
+ Embedder,
23
+ IndexMeta,
24
+ IndexStore,
25
+ Link,
26
+ Prompts,
27
+ Unit,
28
+ parse_space,
29
+ )
30
+ from curu_memory.store_sqlite import SqliteEmbeddingCache
31
+
32
+ BATCH_SIZE = 32
33
+ #: Fewer media files than texts go in one request, because a file can be 10 MB.
34
+ MEDIA_BATCH_SIZE = 4
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _PendingMedia:
39
+ """One accepted image of a concept, before the embedder makes its vector."""
40
+
41
+ concept_id: str
42
+ concept_path: str
43
+ #: The path as the concept wrote it, for a warning.
44
+ written: str
45
+ #: The place of the image in the `media:` list, from 1.
46
+ number: int
47
+ slug: str
48
+ ordinal: int
49
+ media: MediaFile
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class IndexReport:
54
+ files_read: int
55
+ concepts_indexed: int
56
+ #: Each (path, reason) of a file that is not in the index.
57
+ skipped: tuple[tuple[str, str], ...]
58
+ #: Each (path, reason) of a problem that did not stop the file.
59
+ warnings: tuple[tuple[str, str], ...]
60
+ #: Each (source id, target id, kind) of a link to a missing concept.
61
+ broken_links: tuple[tuple[str, str, str], ...]
62
+ #: The model identifier of the embedder, or "none".
63
+ embedder: str
64
+ #: The media units in the new index: images and audio.
65
+ media_embedded: int = 0
66
+ #: The media references that are not in the index. Each one is also a warning.
67
+ media_skipped: int = 0
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "files_read": self.files_read,
72
+ "concepts_indexed": self.concepts_indexed,
73
+ "skipped": [{"path": p, "reason": r} for p, r in self.skipped],
74
+ "warnings": [{"path": p, "reason": r} for p, r in self.warnings],
75
+ "broken_links": [
76
+ {"source_id": s, "target_id": t, "kind": k} for s, t, k in self.broken_links
77
+ ],
78
+ "embedder": self.embedder,
79
+ "media_embedded": self.media_embedded,
80
+ "media_skipped": self.media_skipped,
81
+ }
82
+
83
+ def render(self) -> str:
84
+ lines = [
85
+ f"files read: {self.files_read}",
86
+ f"concepts indexed: {self.concepts_indexed}",
87
+ f"skipped: {len(self.skipped)}",
88
+ f"warnings: {len(self.warnings)}",
89
+ f"broken links: {len(self.broken_links)}",
90
+ f"embedder: {self.embedder}",
91
+ f"media embedded: {self.media_embedded}",
92
+ f"media skipped: {self.media_skipped}",
93
+ ]
94
+ if self.skipped:
95
+ lines += ["", "Skipped:"] + [f" {p}: {r}" for p, r in self.skipped]
96
+ if self.warnings:
97
+ lines += ["", "Warnings:"] + [f" {p}: {r}" for p, r in self.warnings]
98
+ if self.broken_links:
99
+ lines += ["", "Broken links:"] + [
100
+ f" {s} -> {t} ({k})" for s, t, k in self.broken_links
101
+ ]
102
+ return "\n".join(lines)
103
+
104
+
105
+ def concept_files(root: Path) -> list[tuple[str, Path]]:
106
+ """Each (relative path, path) of a concept file, sorted by the relative path.
107
+
108
+ A file in a hidden directory, for example `.git` or `.obsidian`, is not a concept.
109
+ A file with the name `README.md` is not a concept.
110
+ """
111
+ found = []
112
+ for path in root.rglob("*.md"):
113
+ relative = path.relative_to(root)
114
+ if any(part.startswith(".") for part in relative.parts):
115
+ continue
116
+ if path.name.lower() == "readme.md" or not path.is_file():
117
+ continue
118
+ found.append((relative.as_posix(), path))
119
+ return sorted(found)
120
+
121
+
122
+ def build_index(
123
+ concepts_dir: Path,
124
+ store: IndexStore,
125
+ embedder: Embedder | None = None,
126
+ *,
127
+ cache: SqliteEmbeddingCache | None = None,
128
+ tier_space: str = GLOBAL,
129
+ prompts: Prompts | None = None,
130
+ ) -> IndexReport:
131
+ """Index one directory into one space, under the lock of the data directory (ADR-016).
132
+
133
+ Raise `ValueError` for a bad space name, `IndexLocked` when another run holds the
134
+ lock and `IndexRefused` when the embedder model is not the model of another space.
135
+ """
136
+ tier_space = parse_space(tier_space)
137
+ with store.index_lock():
138
+ others = [space for space in store.spaces() if space != tier_space]
139
+ _check_model(store, others, embedder)
140
+ taken = {cid: space for space in others for cid in store.concept_ids(space)}
141
+ return _build(concepts_dir, store, embedder, cache, tier_space, prompts, taken)
142
+
143
+
144
+ def _check_model(store: IndexStore, others: Sequence[str], embedder: Embedder | None) -> None:
145
+ """All spaces use one embedder model, so their vectors are comparable (ADR-016)."""
146
+ if embedder is None:
147
+ return
148
+ for space in others:
149
+ meta = store.meta(space)
150
+ model = meta.embedder_model if meta is not None else None
151
+ if model is not None and model != embedder.model:
152
+ raise IndexRefused(
153
+ f"The space {space} was indexed with the embedder {model!r}, but the embedder "
154
+ f"now is {embedder.model!r}. All spaces must use one embedder model. "
155
+ "Index each space again with one model."
156
+ )
157
+
158
+
159
+ def _build(
160
+ concepts_dir: Path,
161
+ store: IndexStore,
162
+ embedder: Embedder | None,
163
+ cache: SqliteEmbeddingCache | None,
164
+ tier_space: str,
165
+ prompts: Prompts | None,
166
+ taken: dict[str, str],
167
+ ) -> IndexReport:
168
+ files = concept_files(concepts_dir)
169
+ skipped: list[tuple[str, str]] = []
170
+ warnings: list[tuple[str, str]] = []
171
+ parsed: list[ParsedConcept] = []
172
+ for relative, path in files:
173
+ try:
174
+ item = parse_concept(relative, path.read_text(encoding="utf-8"), tier_space)
175
+ except ConceptFault as fault:
176
+ reason = f"line {fault.line}: {fault.reason}" if fault.line else fault.reason
177
+ skipped.append((relative, reason))
178
+ continue
179
+ except UnicodeDecodeError:
180
+ skipped.append((relative, "the file is not UTF-8 text"))
181
+ continue
182
+ parsed.append(item)
183
+ warnings.extend((relative, w) for w in item.warnings)
184
+
185
+ by_id: dict[str, list[ParsedConcept]] = defaultdict(list)
186
+ for item in parsed:
187
+ by_id[item.concept.id].append(item)
188
+ kept: list[ParsedConcept] = []
189
+ for identifier, items in sorted(by_id.items()):
190
+ if len(items) == 1:
191
+ kept.append(items[0])
192
+ continue
193
+ paths = [i.concept.path for i in items]
194
+ for item in items:
195
+ others = ", ".join(p for p in paths if p != item.concept.path)
196
+ skipped.append(
197
+ (
198
+ item.concept.path,
199
+ f"the identifier {identifier!r} is also used by {others}, "
200
+ "so neither file is indexed. Rename one of them.",
201
+ )
202
+ )
203
+
204
+ # An identifier is unique in the instance, so each link has one target (ADR-016).
205
+ unique: list[ParsedConcept] = []
206
+ for item in kept:
207
+ space = taken.get(item.concept.id)
208
+ if space is None:
209
+ unique.append(item)
210
+ continue
211
+ skipped.append(
212
+ (
213
+ item.concept.path,
214
+ f"the identifier {item.concept.id!r} is in the space {space}. "
215
+ "An identifier must be unique in the instance, so rename one of them.",
216
+ )
217
+ )
218
+ kept = unique
219
+
220
+ # A link to a concept of another space is not broken. A read resolves it again over
221
+ # the readable spaces of the caller (ADR-016).
222
+ known = {item.concept.id for item in kept} | set(taken)
223
+ links = [
224
+ Link(item.concept.id, target, kind, target in known)
225
+ for item in kept
226
+ for target, kind in item.links
227
+ ]
228
+ units = [unit for item in kept for unit in _units(item)]
229
+ titles = {item.concept.id: item.concept.title for item in kept}
230
+ units, meta = _embed(units, titles, embedder, cache, tier_space, prompts or Prompts())
231
+
232
+ paths = dict(files)
233
+ pending: list[_PendingMedia] = []
234
+ media_warnings: list[tuple[str, str]] = []
235
+ last_ordinal: dict[str, int] = defaultdict(int)
236
+ for unit in units:
237
+ last_ordinal[unit.concept_id] = max(last_ordinal[unit.concept_id], unit.ordinal)
238
+ for item in kept:
239
+ concept = item.concept
240
+ found, problems = _media(item, paths[concept.path], concepts_dir, last_ordinal[concept.id])
241
+ pending.extend(found)
242
+ media_warnings.extend(problems)
243
+ media_units, failures = _embed_media(pending, embedder, cache, meta.embedder_dimension)
244
+ media_warnings.extend(failures)
245
+ warnings.extend(media_warnings)
246
+ units = units + media_units
247
+
248
+ # Each accepted image is recorded with its bytes, with or without a vector (ADR-009).
249
+ media = [
250
+ ConceptMedia(p.concept_id, p.number, p.media.path, p.media.sha256, p.media.mime)
251
+ for p in pending
252
+ ]
253
+ blobs = [p.media for p in pending]
254
+ store.build(meta, [item.concept for item in kept], units, links, media, blobs)
255
+ return IndexReport(
256
+ files_read=len(files),
257
+ concepts_indexed=len(kept),
258
+ skipped=tuple(sorted(skipped)),
259
+ warnings=tuple(sorted(warnings)),
260
+ broken_links=tuple(
261
+ sorted((lk.source_id, lk.target_id, lk.kind) for lk in links if not lk.resolved)
262
+ ),
263
+ embedder=meta.embedder_model or "none",
264
+ media_embedded=len(media_units),
265
+ media_skipped=len(media_warnings),
266
+ )
267
+
268
+
269
+ def _units(item: ParsedConcept) -> list[Unit]:
270
+ concept = item.concept
271
+ units = [Unit(concept.id, SUMMARY, 0, f"{concept.title}\n\n{concept.description}")]
272
+ for ordinal, (slug, text) in enumerate(split_units(concept.body), start=1):
273
+ units.append(Unit(concept.id, slug, ordinal, text))
274
+ return units
275
+
276
+
277
+ def _media(
278
+ item: ParsedConcept, path: Path, concepts_dir: Path, ordinal_base: int
279
+ ) -> tuple[list[_PendingMedia], list[tuple[str, str]]]:
280
+ """The accepted images of one concept, and a warning for each problem (ADR-007).
281
+
282
+ The slug is `image.<n>` or `audio.<n>`, from its place in the `media:` list, so it does
283
+ not change when an image before it is missing. A section slug has no `.`, so the
284
+ two never meet. Its ordinal comes after the text units.
285
+ """
286
+ concept = item.concept
287
+ found: list[_PendingMedia] = []
288
+ problems: list[tuple[str, str]] = []
289
+ for number, written in enumerate(item.media, start=1):
290
+ files, refused = read_media(path, concepts_dir, [written])
291
+ problems.extend((concept.path, f"media {p.path!r}: {p.reason}") for p in refused)
292
+ for media in files:
293
+ found.append(
294
+ _PendingMedia(
295
+ concept.id,
296
+ concept.path,
297
+ written,
298
+ number,
299
+ f"{media.kind}.{number}",
300
+ ordinal_base + number,
301
+ media,
302
+ )
303
+ )
304
+ return found, problems
305
+
306
+
307
+ def _embed_media(
308
+ pending: list[_PendingMedia],
309
+ embedder: Embedder | None,
310
+ cache: SqliteEmbeddingCache | None,
311
+ dimension: int | None,
312
+ ) -> tuple[list[Unit], list[tuple[str, str]]]:
313
+ """The image units, and a warning for each image that has no vector.
314
+
315
+ The embedder gets each distinct file one time, with no prompt. The cache keys a
316
+ file by the model and `<kind>:<sha256>` (ADR-011). A failure gives a warning for
317
+ each file that fails alone, and does not stop the run (FR-006).
318
+ """
319
+ if embedder is None:
320
+ reason = "no embedder is set, so a search by meaning cannot find the {kind}"
321
+ return [], [
322
+ (p.concept_path, f"media {p.written!r}: " + reason.format(kind=p.media.kind))
323
+ for p in pending
324
+ ]
325
+ model = embedder.model
326
+ vectors: dict[str, tuple[float, ...]] = {}
327
+ failed: dict[str, str] = {}
328
+ missing: list[MediaFile] = []
329
+ for media in {p.media.sha256: p.media for p in pending}.values():
330
+ cached = cache.get(model, f"{media.kind}:{media.sha256}") if cache is not None else None
331
+ if cached is not None:
332
+ vectors[media.sha256] = cached
333
+ else:
334
+ missing.append(media)
335
+ made: list[tuple[MediaFile, list[float]]] = []
336
+ for start in range(0, len(missing), MEDIA_BATCH_SIZE):
337
+ batch = missing[start : start + MEDIA_BATCH_SIZE]
338
+ try:
339
+ made.extend(zip(batch, _call_media(embedder, batch), strict=True))
340
+ continue
341
+ except Exception: # noqa: S110 - each image of the request goes again, alone.
342
+ pass
343
+ # One bad image fails its whole request, so each image goes again alone (FR-006).
344
+ for media in batch:
345
+ try:
346
+ made.extend(zip([media], _call_media(embedder, [media]), strict=True))
347
+ except Exception as error:
348
+ detail = str(error) or type(error).__name__
349
+ failed[media.sha256] = f"the embedder did not embed the {media.kind} ({detail})"
350
+ for media, vector in made:
351
+ if dimension is not None and len(vector) != dimension:
352
+ failed[media.sha256] = (
353
+ f"the embedder gave an image vector of {len(vector)} values, "
354
+ f"not {dimension} as for text"
355
+ )
356
+ continue
357
+ vectors[media.sha256] = tuple(vector)
358
+ if cache is not None:
359
+ cache.put(model, f"{media.kind}:{media.sha256}", vector)
360
+ units: list[Unit] = []
361
+ warnings: list[tuple[str, str]] = []
362
+ for p in pending:
363
+ sha256 = p.media.sha256
364
+ if sha256 in failed:
365
+ warnings.append((p.concept_path, f"media {p.written!r}: {failed[sha256]}"))
366
+ continue
367
+ units.append(
368
+ Unit(
369
+ p.concept_id,
370
+ p.slug,
371
+ p.ordinal,
372
+ p.media.path,
373
+ vectors[sha256],
374
+ kind=p.media.kind,
375
+ media_path=p.media.path,
376
+ media_sha256=sha256,
377
+ )
378
+ )
379
+ return units, warnings
380
+
381
+
382
+ def _embed(
383
+ units: list[Unit],
384
+ titles: dict[str, str],
385
+ embedder: Embedder | None,
386
+ cache: SqliteEmbeddingCache | None,
387
+ tier_space: str,
388
+ prompts: Prompts,
389
+ ) -> tuple[list[Unit], IndexMeta]:
390
+ count = len(titles)
391
+ if embedder is None:
392
+ return units, IndexMeta(tier_space, count, None, None)
393
+ model = embedder.model
394
+ # The embedder gets each unit in the document prompt of the model (ADR-003).
395
+ inputs = {
396
+ (u.concept_id, u.slug): prompts.for_document(titles[u.concept_id], u.text) for u in units
397
+ }
398
+ vectors: dict[str, tuple[float, ...]] = {}
399
+ missing: list[str] = []
400
+ for text in dict.fromkeys(inputs.values()):
401
+ cached = cache.get(model, text) if cache is not None else None
402
+ if cached is not None:
403
+ vectors[text] = cached
404
+ else:
405
+ missing.append(text)
406
+ for start in range(0, len(missing), BATCH_SIZE):
407
+ batch = missing[start : start + BATCH_SIZE]
408
+ made = _call(embedder, batch)
409
+ for text, vector in zip(batch, made, strict=True):
410
+ vectors[text] = tuple(vector)
411
+ if cache is not None:
412
+ cache.put(model, text, vector)
413
+ dimensions = {len(v) for v in vectors.values()}
414
+ if len(dimensions) > 1:
415
+ raise EmbedderFailed(f"it gave vectors of different sizes: {sorted(dimensions)}")
416
+ dimension = dimensions.pop() if dimensions else embedder.dimension
417
+ embedded = [
418
+ Unit(u.concept_id, u.slug, u.ordinal, u.text, vectors[inputs[(u.concept_id, u.slug)]])
419
+ for u in units
420
+ ]
421
+ return embedded, IndexMeta(tier_space, count, model, dimension, prompts.fingerprint)
422
+
423
+
424
+ def _call_media(embedder: Embedder, files: Sequence[MediaFile]) -> list[list[float]]:
425
+ made = embedder.embed_media(files)
426
+ if len(made) != len(files):
427
+ raise RuntimeError(f"it gave {len(made)} vectors for {len(files)} media files")
428
+ return made
429
+
430
+
431
+ def _call(embedder: Embedder, texts: Sequence[str]) -> list[list[float]]:
432
+ try:
433
+ made = embedder.embed(texts)
434
+ except EmbedderFailed:
435
+ raise
436
+ except Exception as error:
437
+ raise EmbedderFailed(str(error) or type(error).__name__) from error
438
+ if len(made) != len(texts):
439
+ raise EmbedderFailed(f"it gave {len(made)} vectors for {len(texts)} texts")
440
+ return made
curu_memory/media.py ADDED
@@ -0,0 +1,199 @@
1
+ """The rules of the media files of a concept (ADR-007).
2
+
3
+ A concept references media with a front-matter list `media:` of paths relative to the
4
+ concept file. Each path must resolve inside the concept directory. The format comes from
5
+ the first bytes of the file, not from its name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import hashlib
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from curu_memory.errors import InvalidArgument
18
+
19
+ MAX_BYTES = 10 * 1024 * 1024
20
+ UNKNOWN_FORMAT = "the file is not an image, audio or video file of a known format"
21
+ _SIGNATURES = (
22
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
23
+ (b"\xff\xd8\xff", "image/jpeg"),
24
+ (b"fLaC", "audio/flac"),
25
+ (b"ID3", "audio/mpeg"),
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MediaFile:
31
+ #: The path relative to the concept directory, with `/` as separator.
32
+ path: str
33
+ mime: str
34
+ sha256: str
35
+ data: bytes = field(repr=False)
36
+
37
+ @property
38
+ def kind(self) -> str:
39
+ """`image`, `audio` or `video`, from the MIME type."""
40
+ return self.mime.partition("/")[0]
41
+
42
+ def data_uri(self) -> str:
43
+ return f"data:{self.mime};base64," + base64.b64encode(self.data).decode()
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class MediaProblem:
48
+ #: The path as the concept wrote it.
49
+ path: str
50
+ reason: str
51
+
52
+
53
+ def read_media(
54
+ concept_file: Path, concepts_dir: Path, paths: Sequence[str]
55
+ ) -> tuple[list[MediaFile], list[MediaProblem]]:
56
+ """Read each media path of one concept. Give the accepted files and each problem."""
57
+ root = concepts_dir.resolve()
58
+ files: list[MediaFile] = []
59
+ problems: list[MediaProblem] = []
60
+ for written in paths:
61
+ if Path(written).is_absolute():
62
+ problems.append(MediaProblem(written, "the path is not relative to the concept file"))
63
+ continue
64
+ resolved = (concept_file.parent / written).resolve()
65
+ if not resolved.is_relative_to(root):
66
+ problems.append(MediaProblem(written, "the path is outside the concept directory"))
67
+ continue
68
+ if not resolved.exists():
69
+ problems.append(MediaProblem(written, "the file does not exist"))
70
+ continue
71
+ if not resolved.is_file():
72
+ problems.append(MediaProblem(written, "the path is not a file"))
73
+ continue
74
+ if resolved.stat().st_size > MAX_BYTES:
75
+ problems.append(MediaProblem(written, "the file is larger than 10 MB"))
76
+ continue
77
+ data = resolved.read_bytes()
78
+ mime = sniff_mime(data[:SNIFF_BYTES])
79
+ if mime is None:
80
+ problems.append(MediaProblem(written, UNKNOWN_FORMAT))
81
+ continue
82
+ files.append(
83
+ MediaFile(
84
+ path=resolved.relative_to(root).as_posix(),
85
+ mime=mime,
86
+ sha256=hashlib.sha256(data).hexdigest(),
87
+ data=data,
88
+ )
89
+ )
90
+ return files, problems
91
+
92
+
93
+ #: The codec of the first packet of an Ogg audio file, at byte 28: Vorbis, Opus or FLAC.
94
+ #: A video-only Ogg file, for example Theora, is not audio.
95
+ _OGG_AUDIO = (b"\x01vorbis", b"OpusHead", b"\x7fFLAC")
96
+ #: The bytes that `sniff_mime` reads. The Ogg codec ends at byte 36.
97
+ SNIFF_BYTES = 64
98
+
99
+
100
+ def sniff_mime(head: bytes) -> str | None:
101
+ """The MIME type of a media file from its first bytes, or None."""
102
+ for signature, mime in _SIGNATURES:
103
+ if head.startswith(signature):
104
+ return mime
105
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
106
+ return "image/webp"
107
+ if head[:4] == b"RIFF" and head[8:12] == b"WAVE":
108
+ return "audio/wav"
109
+ if head[:4] == b"OggS" and head[28:36].startswith(_OGG_AUDIO):
110
+ return "audio/ogg"
111
+ if _is_mp3_frame(head):
112
+ return "audio/mpeg"
113
+ return _video_mime(head)
114
+
115
+
116
+ #: The ISO media brands of a video. HEIF and AVIF images and M4A audio use `ftyp` too,
117
+ #: so a brand that is not in this set is not a video.
118
+ _MP4_BRANDS = frozenset(
119
+ {b"isom", b"iso2", b"iso3", b"iso4", b"iso5", b"iso6", b"mp41", b"mp42", b"avc1",
120
+ b"M4V ", b"dash", b"mmp4", b"3gp4", b"3gp5", b"3gp6", b"3g2a"}
121
+ ) # fmt: skip
122
+ #: The EBML header of a Matroska or WebM file.
123
+ _EBML = b"\x1a\x45\xdf\xa3"
124
+
125
+
126
+ def _video_mime(head: bytes) -> str | None:
127
+ """The MIME type of an MP4, MOV, WebM or MKV video from its first bytes, or None."""
128
+ if head[4:8] == b"ftyp":
129
+ brand = head[8:12]
130
+ if brand == b"qt ":
131
+ return "video/quicktime"
132
+ return "video/mp4" if brand in _MP4_BRANDS else None
133
+ if head.startswith(_EBML):
134
+ # The DocType element (0x42 0x82) names the kind of the Matroska file.
135
+ if b"\x42\x82\x84webm" in head:
136
+ return "video/webm"
137
+ if b"\x42\x82\x88matroska" in head:
138
+ return "video/x-matroska"
139
+ return None
140
+
141
+
142
+ def _is_mp3_frame(head: bytes) -> bool:
143
+ """True for an MPEG audio frame header of Layer II or III, with no ID3 tag.
144
+
145
+ The header has 11 sync bits, a valid version, Layer II or III, a bitrate index from
146
+ 1 to 14 and a valid sampling rate. So text, AAC and other data do not pass.
147
+ """
148
+ if len(head) < 3 or head[0] != 0xFF or head[1] & 0xE0 != 0xE0:
149
+ return False
150
+ version = (head[1] >> 3) & 0b11
151
+ layer = (head[1] >> 1) & 0b11
152
+ bitrate = head[2] >> 4
153
+ rate = (head[2] >> 2) & 0b11
154
+ return version != 0b01 and layer in (0b01, 0b10) and bitrate not in (0, 15) and rate != 3
155
+
156
+
157
+ #: The most base64 characters that can hold MAX_BYTES, with some room for padding.
158
+ _MAX_BASE64 = (MAX_BYTES + 2) // 3 * 4 + 4
159
+ _TOO_LARGE = "The file is larger than 10 MB."
160
+
161
+
162
+ def decode_media_argument(text: str) -> bytes:
163
+ """The bytes of a media argument: base64 text, or a base64 `data:` URI (ADR-012).
164
+
165
+ The length is checked before the decode, so a large argument costs no memory.
166
+ """
167
+ value = text.strip()
168
+ if value.startswith("data:"):
169
+ header, comma, value = value.partition(",")
170
+ if not comma or not header.endswith(";base64"):
171
+ raise _bad_media("The media is a data: URI with no base64 bytes.")
172
+ # base64 tools break the text into lines of 76 characters, with up to 2 more each.
173
+ if len(value) > _MAX_BASE64 + _MAX_BASE64 // 38 + 2:
174
+ raise _bad_media(_TOO_LARGE)
175
+ value = "".join(value.split())
176
+ if len(value) > _MAX_BASE64:
177
+ raise _bad_media(_TOO_LARGE)
178
+ try:
179
+ data = base64.b64decode(value, validate=True)
180
+ except (binascii.Error, ValueError):
181
+ # A character that is not ASCII gives ValueError, not binascii.Error.
182
+ data = b""
183
+ if not data:
184
+ raise _bad_media("The media is not base64 text or a data: URI.")
185
+ return data
186
+
187
+
188
+ def read_query_media(data: bytes) -> MediaFile:
189
+ """A media question from its bytes: an image, a clip or a video, as `read_media` takes."""
190
+ if len(data) > MAX_BYTES:
191
+ raise _bad_media(_TOO_LARGE)
192
+ mime = sniff_mime(data[:SNIFF_BYTES])
193
+ if mime is None:
194
+ raise _bad_media(f"{UNKNOWN_FORMAT.capitalize()}.")
195
+ return MediaFile("question", mime, hashlib.sha256(data).hexdigest(), data)
196
+
197
+
198
+ def _bad_media(message: str) -> InvalidArgument:
199
+ return InvalidArgument("media", message)