lorebase-scribe 0.2.3__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 (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,432 @@
1
+ """What the schema tells the extractors: labels, key patterns, value cues and the tier 2 JSON
2
+ schema of an ontology fragment.
3
+
4
+ Only active Kinds and Relations take part. Attributes that are derived, sensitive (`pii`,
5
+ `confidential`, `restricted`) or of a type extraction does not handle (`json`, `geo_point`,
6
+ `array`, `duration`) are never proposed. Sensitive means by the effective tags, as reads see
7
+ them: the schema's own, those the default branch gives the same Attribute, and those carried
8
+ through Derivations (`lorebase_core.expr.sensitivity.effective_tags`), so a schema forked
9
+ before the default branch tagged an Attribute does not make it extractable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from collections.abc import Iterable, Mapping, Sequence
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from lorebase_core.expr.sensitivity import Tags, effective_tags
20
+ from lorebase_core.model import Attribute, ElementStatus, Kind, Relation, Schema
21
+ from lorebase_core.types import AttributeType
22
+
23
+ EXTRACTABLE_TYPES = frozenset(
24
+ {
25
+ AttributeType.STRING,
26
+ AttributeType.TEXT,
27
+ AttributeType.URI,
28
+ AttributeType.REF,
29
+ AttributeType.ENUM,
30
+ AttributeType.INTEGER,
31
+ AttributeType.DECIMAL,
32
+ AttributeType.FLOAT,
33
+ AttributeType.BOOLEAN,
34
+ AttributeType.DATE,
35
+ AttributeType.TIMESTAMP,
36
+ }
37
+ )
38
+ _STOPWORDS = frozenset(
39
+ [
40
+ "a",
41
+ "an",
42
+ "and",
43
+ "are",
44
+ "as",
45
+ "at",
46
+ "be",
47
+ "by",
48
+ "for",
49
+ "from",
50
+ "in",
51
+ "into",
52
+ "is",
53
+ "it",
54
+ "its",
55
+ "of",
56
+ "on",
57
+ "or",
58
+ "that",
59
+ "the",
60
+ "this",
61
+ "to",
62
+ "when",
63
+ "with",
64
+ "which",
65
+ "who",
66
+ "whose",
67
+ "was",
68
+ "were",
69
+ "will",
70
+ "can",
71
+ "may",
72
+ "per",
73
+ "than",
74
+ "then",
75
+ "there",
76
+ "their",
77
+ "them",
78
+ "they",
79
+ "has",
80
+ "have",
81
+ "had",
82
+ "not",
83
+ "no",
84
+ "yes",
85
+ "true",
86
+ "false",
87
+ "example",
88
+ "code",
89
+ "number",
90
+ "value",
91
+ "values",
92
+ "one",
93
+ "two",
94
+ "three",
95
+ ]
96
+ )
97
+ _CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
98
+ _WORD = re.compile(r"[A-Za-z][A-Za-z0-9]+")
99
+
100
+
101
+ def humanize(api_name: str) -> str:
102
+ """`PurchaseOrder` and `purchase_order` both give `purchase order`."""
103
+ return _CAMEL.sub(" ", api_name).replace("_", " ").strip().lower()
104
+
105
+
106
+ def cue_tokens(attribute: Attribute) -> tuple[str, ...]:
107
+ """Words that announce a value of the Attribute in text (from its names and description)."""
108
+ words = [*humanize(attribute.api_name).split(), *attribute.display_name.lower().split()]
109
+ words += [w.lower() for w in _WORD.findall(attribute.description)[:8]]
110
+ return tuple(dict.fromkeys(w for w in words if len(w) >= 3 and w not in _STOPWORDS))
111
+
112
+
113
+ def key_regex(pattern: str) -> re.Pattern[str] | None:
114
+ """A search pattern for keys from an anchored `pattern` constraint; None when unusable."""
115
+ core = pattern.removeprefix("^").removesuffix("$")
116
+ if not core or core in (".*", ".+"):
117
+ return None
118
+ try:
119
+ return re.compile(rf"(?<![\w-])(?:{core})(?![\w-])")
120
+ except re.error:
121
+ return None
122
+
123
+
124
+ def extractable(attribute: Attribute, tags: Mapping[str, Tags] | None = None) -> bool:
125
+ """Whether extraction may propose values of `attribute`; `tags` are the effective tags
126
+ by Attribute id (the Attribute's own when not given)."""
127
+ sensitive = attribute.sensitivity if tags is None else tags.get(attribute.id)
128
+ return not attribute.is_derived and not sensitive and attribute.type.name in EXTRACTABLE_TYPES
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class KindView:
133
+ """One Kind as the extractors see it."""
134
+
135
+ kind: Kind
136
+ label: str
137
+ key_attribute: Attribute | None
138
+ key_pattern: re.Pattern[str] | None
139
+ title_attribute: Attribute | None
140
+ attributes: tuple[Attribute, ...]
141
+ cues: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
142
+
143
+ @property
144
+ def id(self) -> str:
145
+ return self.kind.id
146
+
147
+ @property
148
+ def api_name(self) -> str:
149
+ return self.kind.api_name
150
+
151
+ def describe(self) -> str:
152
+ """Label description for span extractors: the Kind and what names its Records."""
153
+ named = (
154
+ f" Named by its {humanize(self.title_attribute.api_name)}."
155
+ if self.title_attribute
156
+ else ""
157
+ )
158
+ return f"{self.kind.description}{named}".strip()
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class Ontology:
163
+ """The extraction view of a schema."""
164
+
165
+ schema: Schema
166
+ kinds: Mapping[str, KindView]
167
+ relations: Mapping[str, Relation]
168
+ new_record_kinds: frozenset[str]
169
+ tags: Mapping[str, Tags]
170
+ """Effective sensitivity tags by Attribute id (module doc)."""
171
+
172
+ def kind_by_label(self, label: str) -> KindView | None:
173
+ folded = label.casefold()
174
+ for view in self.kinds.values():
175
+ if folded in (view.label.casefold(), view.api_name.casefold()):
176
+ return view
177
+ return None
178
+
179
+ def allows_new(self, kind_id: str) -> bool:
180
+ return kind_id in self.new_record_kinds
181
+
182
+ def relations_between(self, kind_ids: Iterable[str]) -> list[Relation]:
183
+ present = set(kind_ids)
184
+ return [
185
+ r for r in self.relations.values() if r.from_kind in present and r.to_kind in present
186
+ ]
187
+
188
+
189
+ def build_ontology(
190
+ schema: Schema,
191
+ new_record_kinds: Sequence[str] | None,
192
+ reference: Mapping[str, Tags] | None = None,
193
+ ) -> Ontology:
194
+ """The extraction view of `schema`; `new_record_kinds` as in `scribe.extraction`;
195
+ `reference` the tags of the default branch's schema by Attribute id, added to the
196
+ schema's own (module doc)."""
197
+ tags = effective_tags(schema, reference)
198
+ kinds: dict[str, KindView] = {}
199
+ for kind in schema.kinds.values():
200
+ if kind.status is not ElementStatus.ACTIVE:
201
+ continue
202
+ key_attribute = None
203
+ pattern = None
204
+ if len(kind.primary_key) == 1:
205
+ key_attribute = kind.attributes.get(kind.primary_key[0])
206
+ if key_attribute is not None and key_attribute.constraints.pattern:
207
+ pattern = key_regex(key_attribute.constraints.pattern)
208
+ title = kind.attributes.get(kind.title_attribute or "")
209
+ attributes = tuple(a for a in kind.attributes.values() if extractable(a, tags))
210
+ kinds[kind.id] = KindView(
211
+ kind=kind,
212
+ label=kind.display_name or humanize(kind.api_name),
213
+ key_attribute=key_attribute,
214
+ key_pattern=pattern,
215
+ title_attribute=title,
216
+ attributes=attributes,
217
+ cues={a.id: cue_tokens(a) for a in attributes},
218
+ )
219
+ relations = {
220
+ r.id: r
221
+ for r in schema.relations.values()
222
+ if r.status is ElementStatus.ACTIVE and r.from_kind in kinds and r.to_kind in kinds
223
+ }
224
+ if new_record_kinds is None:
225
+ allowed = {k for k, view in kinds.items() if view.kind.has_natural_key}
226
+ else:
227
+ names = set(new_record_kinds)
228
+ allowed = {k for k, view in kinds.items() if view.api_name in names}
229
+ return Ontology(schema, kinds, relations, frozenset(allowed), tags)
230
+
231
+
232
+ # Tier 2 JSON schema -------------------------------------------------------------------------
233
+
234
+
235
+ def value_schema(attribute: Attribute) -> dict[str, Any]:
236
+ """JSON schema of one Attribute value as the model writes it."""
237
+ spec = attribute.type
238
+ name = spec.name
239
+ schema: dict[str, Any]
240
+ if name is AttributeType.ENUM:
241
+ schema = {"enum": list(spec.enum_values or ())}
242
+ elif name is AttributeType.INTEGER:
243
+ schema = {"type": "integer"}
244
+ elif name is AttributeType.FLOAT:
245
+ schema = {"type": "number"}
246
+ elif name is AttributeType.BOOLEAN:
247
+ schema = {"type": "boolean"}
248
+ elif name is AttributeType.DATE:
249
+ schema = {"type": "string", "format": "date"}
250
+ elif name is AttributeType.TIMESTAMP:
251
+ schema = {"type": "string", "format": "date-time"}
252
+ elif name is AttributeType.DECIMAL:
253
+ schema = {"type": "string", "description": "decimal number, digits and one dot"}
254
+ else:
255
+ schema = {"type": "string"}
256
+ if attribute.description:
257
+ schema["description"] = attribute.description
258
+ return schema
259
+
260
+
261
+ def _record_schema(view: KindView) -> dict[str, Any]:
262
+ values = {
263
+ a.api_name: {
264
+ "type": "object",
265
+ "additionalProperties": False,
266
+ "properties": {
267
+ "value": value_schema(a),
268
+ "evidence": {"type": "string", "description": "Verbatim quote holding the value"},
269
+ },
270
+ "required": ["value", "evidence"],
271
+ }
272
+ for a in view.attributes
273
+ }
274
+ return {
275
+ "type": "object",
276
+ "additionalProperties": False,
277
+ "properties": {
278
+ "kind": {"const": view.api_name},
279
+ "ref": {
280
+ "type": "string",
281
+ "description": "Key of the candidate Record this is, or an empty string for "
282
+ "a Record that is not among the candidates",
283
+ },
284
+ "evidence": {"type": "string", "description": "Verbatim quote naming the Record"},
285
+ "values": {"type": "object", "additionalProperties": False, "properties": values},
286
+ },
287
+ "required": ["kind", "ref", "evidence", "values"],
288
+ }
289
+
290
+
291
+ UNLISTED_SCHEMA: dict[str, Any] = {
292
+ "type": "array",
293
+ "description": "Facts the text states that the schema above has no place for",
294
+ "items": {
295
+ "type": "object",
296
+ "additionalProperties": False,
297
+ "properties": {
298
+ "type": {"enum": ["kind", "attribute", "relation"]},
299
+ "name": {
300
+ "type": "string",
301
+ "description": "Name of the missing kind of thing, field or relationship",
302
+ },
303
+ "subject": {
304
+ "type": "string",
305
+ "description": "Key or name of the record the field or relationship belongs "
306
+ "to; empty for a kind",
307
+ },
308
+ "value": {
309
+ "type": "string",
310
+ "description": "The field value, the key or name of the related record, or "
311
+ "the name of the thing of the missing kind",
312
+ },
313
+ "evidence": {"type": "string", "description": "Verbatim quote of the fact"},
314
+ },
315
+ "required": ["type", "name", "subject", "value", "evidence"],
316
+ },
317
+ }
318
+ """Tier 2 `unlisted` answers: what the model saw outside the schema (schema discovery)."""
319
+
320
+
321
+ def link_attributes(relation: Relation, tags: Mapping[str, Tags] | None = None) -> list[Attribute]:
322
+ """Link Attributes of `relation` that extraction can propose (`tags` as in
323
+ `extractable`)."""
324
+ return [a for a in relation.attributes.values() if extractable(a, tags)]
325
+
326
+
327
+ def _link_schema(
328
+ relations: Sequence[Relation],
329
+ tags: Mapping[str, Tags],
330
+ with_values: Relation | None = None,
331
+ ) -> dict[str, Any]:
332
+ properties: dict[str, Any] = {
333
+ "relation": {"const": with_values.api_name}
334
+ if with_values
335
+ else {"enum": [r.api_name for r in relations]},
336
+ "from": {"type": "string", "description": "Key or name of the from Record"},
337
+ "to": {"type": "string", "description": "Key or name of the to Record"},
338
+ "evidence": {"type": "string", "description": "Verbatim quote of the fact"},
339
+ }
340
+ required = ["relation", "from", "to", "evidence"]
341
+ if with_values is not None:
342
+ attributes = link_attributes(with_values, tags)
343
+ properties["values"] = {
344
+ "type": "object",
345
+ "additionalProperties": False,
346
+ "description": "Link Attributes stated by the text; give every required one",
347
+ "properties": {
348
+ a.api_name: {
349
+ "type": "object",
350
+ "additionalProperties": False,
351
+ "properties": {
352
+ "value": value_schema(a),
353
+ "evidence": {
354
+ "type": "string",
355
+ "description": "Verbatim quote holding the value",
356
+ },
357
+ },
358
+ "required": ["value", "evidence"],
359
+ }
360
+ for a in attributes
361
+ },
362
+ "required": [a.api_name for a in attributes if a.required],
363
+ }
364
+ required.append("values")
365
+ return {
366
+ "type": "object",
367
+ "additionalProperties": False,
368
+ "properties": properties,
369
+ "required": required,
370
+ }
371
+
372
+
373
+ def _links_schema(relations: Sequence[Relation], tags: Mapping[str, Tags]) -> dict[str, Any]:
374
+ """Schema of one link answer: Relations with link Attributes get a branch of their own
375
+ whose `values` lists those Attributes."""
376
+ plain = [r for r in relations if not link_attributes(r, tags)]
377
+ typed = [r for r in relations if link_attributes(r, tags)]
378
+ if not typed:
379
+ return _link_schema(plain, tags)
380
+ branches = [_link_schema(plain, tags)] if plain else []
381
+ branches.extend(_link_schema(relations, tags, r) for r in typed)
382
+ return {"anyOf": branches}
383
+
384
+
385
+ def fragment_schema(
386
+ ontology: Ontology, kind_ids: Sequence[str], *, unlisted: bool = False
387
+ ) -> dict[str, Any]:
388
+ """JSON schema of a tier 2 answer restricted to `kind_ids` and the Relations between them;
389
+ with `unlisted`, the answer also lists facts outside the schema."""
390
+ views = [ontology.kinds[k] for k in kind_ids if k in ontology.kinds]
391
+ properties: dict[str, Any] = {
392
+ "records": {"type": "array", "items": {"anyOf": [_record_schema(v) for v in views]}}
393
+ }
394
+ required = ["records"]
395
+ relations = ontology.relations_between(kind_ids)
396
+ if relations:
397
+ properties["links"] = {"type": "array", "items": _links_schema(relations, ontology.tags)}
398
+ required.append("links")
399
+ if unlisted:
400
+ properties["unlisted"] = UNLISTED_SCHEMA
401
+ required.append("unlisted")
402
+ return {
403
+ "type": "object",
404
+ "additionalProperties": False,
405
+ "properties": properties,
406
+ "required": required,
407
+ }
408
+
409
+
410
+ def fragment_description(ontology: Ontology, kind_ids: Sequence[str]) -> str:
411
+ """The ontology fragment as prompt text: Kinds with Attributes, then Relations."""
412
+ lines: list[str] = []
413
+ for kind_id in kind_ids:
414
+ view = ontology.kinds.get(kind_id)
415
+ if view is None:
416
+ continue
417
+ key = f" (key: {view.key_attribute.api_name})" if view.key_attribute else ""
418
+ lines.append(f"Kind {view.api_name}{key}: {view.kind.description}".rstrip())
419
+ for attribute in view.attributes:
420
+ lines.append(f" - {attribute.api_name}: {attribute.description}".rstrip())
421
+ for relation in ontology.relations_between(kind_ids):
422
+ source = ontology.kinds[relation.from_kind].api_name
423
+ target = ontology.kinds[relation.to_kind].api_name
424
+ lines.append(
425
+ f"Relation {relation.api_name}: {source} -> {target}. {relation.description}".rstrip()
426
+ )
427
+ for attribute in link_attributes(relation, ontology.tags):
428
+ need = " (required: give it with evidence)" if attribute.required else ""
429
+ lines.append(
430
+ f" - link attribute {attribute.api_name}{need}: {attribute.description}".rstrip()
431
+ )
432
+ return "\n".join(lines)
@@ -0,0 +1,114 @@
1
+ """Document bytes to text with stable offsets (design 9.2 step 2).
2
+
3
+ Markdown and plain text are decoded natively (UTF-8, with a BOM tolerated). PDF goes through
4
+ `pypdf` and DOCX through `python-docx`, both from the `documents` extra of this package; when
5
+ the extra is missing the job fails with a message naming it. OCR is out of scope: a PDF whose
6
+ pages hold no text is reported as scanned and yields no text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ MARKDOWN = "text/markdown"
16
+ PLAIN_TEXT = "text/plain"
17
+ PDF = "application/pdf"
18
+ DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
19
+ MEDIA_TYPES = (MARKDOWN, PLAIN_TEXT, PDF, DOCX)
20
+ EXTRA_HINT = "install lorebase-scribe[documents]"
21
+
22
+
23
+ class ParseError(ValueError):
24
+ """The document cannot be turned into text; retrying will not help."""
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ParsedDocument:
29
+ """Parsed text; `pages` holds the start offset of each page (PDF only)."""
30
+
31
+ text: str
32
+ media_type: str
33
+ pages: tuple[int, ...] = ()
34
+ warnings: tuple[str, ...] = field(default_factory=tuple)
35
+
36
+ def metadata(self) -> dict[str, Any]:
37
+ found: dict[str, Any] = {"chars": len(self.text)}
38
+ if self.pages:
39
+ found["pages"] = len(self.pages)
40
+ if self.warnings:
41
+ found["warnings"] = list(self.warnings)
42
+ return found
43
+
44
+
45
+ def _normalize(text: str) -> str:
46
+ return text.replace("\r\n", "\n").replace("\r", "\n").replace("\x00", "")
47
+
48
+
49
+ def _decode(data: bytes) -> str:
50
+ try:
51
+ return data.decode("utf-8-sig")
52
+ except UnicodeDecodeError:
53
+ raise ParseError("text documents must be UTF-8") from None
54
+
55
+
56
+ def _pdf(data: bytes) -> ParsedDocument:
57
+ try:
58
+ from pypdf import PdfReader
59
+ from pypdf.errors import PdfReadError
60
+ except ImportError:
61
+ raise ParseError(f"PDF documents need pypdf: {EXTRA_HINT}") from None
62
+ try:
63
+ reader = PdfReader(io.BytesIO(data))
64
+ pages: list[int] = []
65
+ parts: list[str] = []
66
+ offset = 0
67
+ for page in reader.pages:
68
+ pages.append(offset)
69
+ content = _normalize(page.extract_text() or "").strip()
70
+ parts.append(content)
71
+ offset += len(content) + 2
72
+ except (PdfReadError, ValueError, KeyError) as error:
73
+ raise ParseError(f"unreadable PDF: {error}") from None
74
+ text = "\n\n".join(parts)
75
+ warnings = () if text.strip() else ("no text layer: the PDF looks scanned (OCR is not run)",)
76
+ return ParsedDocument(text, PDF, tuple(pages), warnings)
77
+
78
+
79
+ def _docx(data: bytes) -> ParsedDocument:
80
+ try:
81
+ import docx
82
+ except ImportError:
83
+ raise ParseError(f"DOCX documents need python-docx: {EXTRA_HINT}") from None
84
+ try:
85
+ document = docx.Document(io.BytesIO(data))
86
+ except Exception as error: # python-docx raises zipfile, lxml and its own errors
87
+ raise ParseError(f"unreadable DOCX: {error}") from None
88
+ blocks: list[str] = []
89
+ for paragraph in document.paragraphs:
90
+ content = paragraph.text.strip()
91
+ if not content:
92
+ continue
93
+ style = (paragraph.style.name if paragraph.style is not None else "") or ""
94
+ if style.startswith("Heading"):
95
+ level = style.removeprefix("Heading").strip()
96
+ depth = int(level) if level.isdigit() else 1
97
+ content = f"{'#' * min(depth, 6)} {content}"
98
+ blocks.append(content)
99
+ for table in document.tables:
100
+ for row in table.rows:
101
+ cells = [cell.text.strip().replace("\n", " ") for cell in row.cells]
102
+ blocks.append("| " + " | ".join(cells) + " |")
103
+ return ParsedDocument(_normalize("\n\n".join(blocks)), DOCX)
104
+
105
+
106
+ def parse_document(data: bytes, media_type: str) -> ParsedDocument:
107
+ """Text of a document of a supported media type; raises `ParseError`."""
108
+ if media_type in (MARKDOWN, PLAIN_TEXT):
109
+ return ParsedDocument(_normalize(_decode(data)), media_type)
110
+ if media_type == PDF:
111
+ return _pdf(data)
112
+ if media_type == DOCX:
113
+ return _docx(data)
114
+ raise ParseError(f"unsupported media type {media_type!r}")