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
moca_reader/library.py ADDED
@@ -0,0 +1,176 @@
1
+ """A library of loaded packages: citation records, relations between packages, and their merged structure with
2
+ the host's overlays (Reader contract §7-§10). Search over a library is in search.py."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from ._js import get, js_string
13
+ from .citation import CitationRecord, RelationIndex, citation_for, pick_representation
14
+ from .diagnostics import Diagnostics
15
+ from .relations import relation_index_of
16
+ from .source import DEFAULT_LIMITS
17
+ from .structure import LAYERS, Fact, parse_structure
18
+
19
+ if TYPE_CHECKING:
20
+ from .reader import PackageResult
21
+ from .structure_ops import StructureIndex
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Overlay:
26
+ """Host-supplied structure (moca-spec ADR-0008), never package content: Turtle text or a file."""
27
+
28
+ id: str
29
+ layer: str # application | organisation
30
+ source: str | bytes | None = None
31
+ path: str | os.PathLike[str] | None = None
32
+
33
+
34
+ @dataclass
35
+ class LoadedPackage:
36
+ result: PackageResult
37
+ chunks: list[dict[str, Any]] | None = None
38
+ index: Any = None
39
+
40
+
41
+ class Library:
42
+ """Holds verified packages, with their sidecar chunks when bound."""
43
+
44
+ def __init__(
45
+ self,
46
+ clock: Callable[[], datetime] | None = None,
47
+ overlays: list[Overlay] | None = None,
48
+ limits: dict[str, int] | None = None,
49
+ allow_unsigned_relations: bool = False,
50
+ ) -> None:
51
+ self.clock: Callable[[], datetime] = clock or (lambda: datetime.now(UTC))
52
+ # Package spec §7.2: off by default. Only for libraries of the host's own packages.
53
+ self.allow_unsigned_relations = allow_unsigned_relations
54
+ self.packages: list[LoadedPackage] = []
55
+ self.diagnostics = Diagnostics()
56
+ self.overlay_facts: list[Fact] = []
57
+ limit_values = {**DEFAULT_LIMITS, **(limits or {})}
58
+ for o in overlays or []:
59
+ if o.layer not in ("application", "organisation"):
60
+ raise ValueError(f"overlay {o.id}: layer must be application or organisation")
61
+ text: str | bytes
62
+ if o.source is not None:
63
+ text = o.source
64
+ elif o.path is not None:
65
+ with open(o.path, "rb") as f:
66
+ text = f.read()
67
+ else:
68
+ raise ValueError(f"overlay {o.id}: give its source or its path")
69
+ facts, _, _ = parse_structure(
70
+ text, f"overlay:{o.id}", o.layer, f"overlay:{o.id}", self.diagnostics, limit_values
71
+ )
72
+ self.overlay_facts.extend(facts)
73
+ self.overlay_facts.sort(key=lambda f: LAYERS.index(f["layer"]))
74
+ self._structure: StructureIndex | None = None
75
+
76
+ @property
77
+ def structure(self) -> StructureIndex:
78
+ """The merged structure of every loaded package and overlay (Reader contract §10)."""
79
+ from .structure_ops import StructureIndex
80
+
81
+ if self._structure is None:
82
+ self._structure = StructureIndex(self, self.overlay_facts)
83
+ return self._structure
84
+
85
+ def add(self, result: PackageResult, chunks: list[dict[str, Any]] | None = None, index: Any = None) -> Library:
86
+ """Adds a package read by read_package(), with a bound sidecar's chunks and index. Invalid packages are refused."""
87
+ if not result.valid or not result.digest:
88
+ raise ValueError(f"refusing to add an invalid package: {get(result.manifest, 'id') or '(unknown)'}")
89
+ key = _key(result)
90
+ same = next((p for p in self.packages if _key(p.result) == key), None)
91
+ if same is not None:
92
+ # Contract §7: one id@version, one digest. A second digest is refused and reported.
93
+ if same.result.digest != result.digest:
94
+ self.diagnostics.add(
95
+ "L001_IDENTITY_CONFLICT",
96
+ f"{key} is already loaded with digest {same.result.digest}; {result.digest} is refused",
97
+ )
98
+ return self
99
+ self.packages.append(LoadedPackage(result, chunks, index))
100
+ self._structure = None
101
+ # A composed package brings its verified members with it.
102
+ for member in result.members:
103
+ if member.status == "resolved" and member.result is not None and member.result.valid:
104
+ self.add(member.result)
105
+ return self
106
+
107
+ def relation_index(self) -> RelationIndex:
108
+ """Package-level relation effects among the loaded packages (package spec §7.2)."""
109
+ return relation_index_of(
110
+ [
111
+ {
112
+ "id": p.result.manifest.get("id"),
113
+ "version": p.result.manifest.get("version"),
114
+ "relations": p.result.manifest.get("relations"),
115
+ "relationScopes": p.result.relation_scopes,
116
+ }
117
+ for p in self.packages
118
+ ],
119
+ self.allow_unsigned_relations,
120
+ )
121
+
122
+ def citations(self, locale: str | None = None, include_text: bool = False) -> list[CitationRecord]:
123
+ """Citation records for every node: one per node, in the requested locale."""
124
+ index = self.relation_index()
125
+ now = self.clock()
126
+ out = []
127
+ for p in self.packages:
128
+ for node in p.result.nodes:
129
+ rep = pick_representation(node, locale)
130
+ out.append(citation_for(p.result, node, rep, now, index, rep.body if include_text else None))
131
+ return out
132
+
133
+ def get(self, node_ref: str, locale: str | None = None, include_text: bool = True) -> CitationRecord | None:
134
+ """A node by its id (``<package id>#<path>``) or versioned reference (``<package id>@<version>#<path>``).
135
+
136
+ An id matches the first loaded version.
137
+ """
138
+ hash_at = node_ref.rfind("#")
139
+ if hash_at < 0:
140
+ return None
141
+ path, head = node_ref[hash_at + 1 :], node_ref[:hash_at]
142
+ index = self.relation_index()
143
+ for p in self.packages:
144
+ pid, version = js_string(p.result.manifest.get("id")), js_string(p.result.manifest.get("version"))
145
+ if head not in (pid, f"{pid}@{version}"):
146
+ continue
147
+ node = next((n for n in p.result.nodes if n.path == path), None)
148
+ if node is None:
149
+ continue
150
+ rep = pick_representation(node, locale)
151
+ return citation_for(p.result, node, rep, self.clock(), index, rep.body if include_text else None)
152
+ return None
153
+
154
+ def search(
155
+ self,
156
+ query: str,
157
+ *,
158
+ limit: int = 5,
159
+ include_all: bool = False,
160
+ locale: str | None = None,
161
+ concepts: list[str] | None = None,
162
+ scope: str | None = None,
163
+ audiences: list[str] | None = None,
164
+ allow_unlabelled: bool = False,
165
+ ) -> list[CitationRecord]:
166
+ """Lexical search over the loaded packages; a shortcut for ``Search(library, LexicalBackend(library))``."""
167
+ from .backends.lexical import LexicalBackend
168
+ from .search import Search
169
+
170
+ return Search(self, LexicalBackend(self), audiences=audiences, allow_unlabelled=allow_unlabelled).search(
171
+ query, limit=limit, include_all=include_all, locale=locale, concepts=concepts, scope=scope
172
+ )
173
+
174
+
175
+ def _key(result: PackageResult) -> str:
176
+ return f"{js_string(result.manifest.get('id'))}@{js_string(result.manifest.get('version'))}"
@@ -0,0 +1,186 @@
1
+ """Manifest checks beyond the JSON Schema (package spec §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from typing import Any
8
+
9
+ from ._js import get, parse_date, truthy
10
+ from .diagnostics import Diagnostics
11
+ from .schemas import validate_against
12
+
13
+ MANIFEST_KEYS = {
14
+ "$schema",
15
+ "mocaVersion",
16
+ "id",
17
+ "version",
18
+ "title",
19
+ "description",
20
+ "language",
21
+ "locales",
22
+ "license",
23
+ "publisher",
24
+ "validFrom",
25
+ "validUntil",
26
+ "members",
27
+ "relations",
28
+ "profiles",
29
+ }
30
+ # URN namespaces registered with IANA that are commonly used as package ids. Not exhaustive: M007 is informational.
31
+ # Package spec §4.2: the IANA formal URN namespaces, as in moca-spec data/urn-namespaces.json (a test keeps
32
+ # them equal). Informal namespaces, urn-<number>, are registered too.
33
+ REGISTERED_URN_NIDS = frozenset(
34
+ {
35
+ "3gpp",
36
+ "3gpp2",
37
+ "adid",
38
+ "alert",
39
+ "bbf",
40
+ "broadband-forum-org",
41
+ "c2pa",
42
+ "cablelabs",
43
+ "ccsds",
44
+ "cdx",
45
+ "cgi",
46
+ "clei",
47
+ "csa",
48
+ "cta",
49
+ "ddi",
50
+ "dev",
51
+ "dgiwg",
52
+ "dnp",
53
+ "doi",
54
+ "dslforum-org",
55
+ "dvb",
56
+ "ebu",
57
+ "eic",
58
+ "eidr",
59
+ "epc",
60
+ "epcglobal",
61
+ "etsi",
62
+ "eurosystem",
63
+ "example",
64
+ "fdc",
65
+ "fipa",
66
+ "gdr",
67
+ "gdst",
68
+ "geant",
69
+ "globus",
70
+ "gs1",
71
+ "gsma",
72
+ "gvat",
73
+ "hbbtv",
74
+ "ieee",
75
+ "ietf",
76
+ "iptc",
77
+ "isan",
78
+ "isbn",
79
+ "isni",
80
+ "iso",
81
+ "issn",
82
+ "itu",
83
+ "ivis",
84
+ "knx",
85
+ "lei",
86
+ "lex",
87
+ "liberty",
88
+ "mace",
89
+ "mef",
90
+ "meta",
91
+ "mpeg",
92
+ "mrn",
93
+ "nan",
94
+ "nato",
95
+ "nbn",
96
+ "nena",
97
+ "newsml",
98
+ "nfc",
99
+ "nfi",
100
+ "nzl",
101
+ "oasis",
102
+ "ogc",
103
+ "ogf",
104
+ "oid",
105
+ "oipf",
106
+ "oma",
107
+ "onem2m",
108
+ "onf",
109
+ "pin",
110
+ "pno",
111
+ "publicid",
112
+ "pwid",
113
+ "reso",
114
+ "s1000d",
115
+ "said",
116
+ "schac",
117
+ "service",
118
+ "smpte",
119
+ "stalwart",
120
+ "swift",
121
+ "thread",
122
+ "trivore",
123
+ "tva",
124
+ "uci",
125
+ "ucode",
126
+ "uic",
127
+ "uuid",
128
+ "web3d",
129
+ "wfa",
130
+ "wmo",
131
+ "xmlorg",
132
+ "xmpp",
133
+ }
134
+ )
135
+ _INFORMAL_URN_NID = re.compile(r"urn-[0-9]+")
136
+ _VENDOR_KEY = re.compile(r"^x-[a-z0-9]+-[A-Za-z0-9-]+\Z")
137
+ _URN = re.compile(r"^urn:([^:]+):", re.IGNORECASE)
138
+
139
+
140
+ def check_manifest(manifest: Any, diagnostics: Diagnostics, known_profiles: Iterable[str] = ()) -> None:
141
+ file = "moca.json"
142
+ for err in validate_against("manifest", manifest):
143
+ diagnostics.add("M002_MANIFEST_SCHEMA", err, file)
144
+ if not isinstance(manifest, dict):
145
+ return
146
+
147
+ for key in manifest:
148
+ if key not in MANIFEST_KEYS and not _VENDOR_KEY.match(key):
149
+ diagnostics.add(
150
+ "M004_UNKNOWN_KEY",
151
+ f'"{key}" is not a MOCA manifest key; it is ignored. Vendor keys use x-<vendor>-<key>.',
152
+ file,
153
+ )
154
+
155
+ seen: list[Any] = []
156
+ members = manifest.get("members")
157
+ for m in members if isinstance(members, list) else []:
158
+ member_id = get(m, "id")
159
+ if not truthy(member_id):
160
+ continue
161
+ if member_id in seen:
162
+ diagnostics.add("M005_DUPLICATE_MEMBER", f"member {member_id} is listed more than once", file)
163
+ seen.append(member_id)
164
+
165
+ start = parse_date(manifest.get("validFrom"))
166
+ until = parse_date(manifest.get("validUntil"))
167
+ if start is not None and until is not None and until <= start:
168
+ diagnostics.add("M006_VALIDITY_WINDOW_INVALID", "validUntil must be later than validFrom", file)
169
+
170
+ urn = _URN.match(manifest["id"]) if isinstance(manifest.get("id"), str) else None
171
+ if urn and urn[1].lower() not in REGISTERED_URN_NIDS and not _INFORMAL_URN_NID.fullmatch(urn[1].lower()):
172
+ diagnostics.add(
173
+ "M007_UNREGISTERED_URN",
174
+ f'"urn:{urn[1]}:" is not a registered URN namespace (RFC 8141); prefer an https: or tag: URI',
175
+ file,
176
+ )
177
+
178
+ known = set(known_profiles)
179
+ profiles = manifest.get("profiles")
180
+ for uri in profiles if isinstance(profiles, dict) else []:
181
+ if uri not in known:
182
+ diagnostics.add(
183
+ "F001_PROFILE_UNRECOGNISED",
184
+ f"profile {uri} is not recognised; its data is preserved but not interpreted",
185
+ file,
186
+ )
@@ -0,0 +1,58 @@
1
+ """Flat metadata from a citation record, for stores and frameworks that need flat fields.
2
+
3
+ The names are the consuming guide's ("Feeding your own retrieval stack", moca-spec docs/guides/consuming.md),
4
+ which the Reader interface §7 requires a binding to use when its framework needs flat metadata. Values are
5
+ strings, numbers, booleans or lists of strings; a field with no value is left out, since many stores refuse null.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .citation import CitationRecord
13
+
14
+ FLAT_FIELDS = (
15
+ "moca_package_id",
16
+ "moca_package_version",
17
+ "moca_package_digest",
18
+ "moca_node_id",
19
+ "moca_node_digest",
20
+ "moca_node_ref",
21
+ "moca_status",
22
+ "moca_in_force_until",
23
+ "moca_stale_after",
24
+ "moca_superseded",
25
+ "moca_attested_reviews",
26
+ "moca_signed",
27
+ "moca_audience",
28
+ "moca_concepts",
29
+ "moca_evidence_matched",
30
+ )
31
+
32
+
33
+ def flat_metadata(record: CitationRecord) -> dict[str, Any]:
34
+ """The consuming guide's ``moca_*`` fields for one citation record.
35
+
36
+ ``moca_attested_reviews`` is how many attested reviews the node has; ``moca_evidence_matched`` is true when
37
+ every checked evidence quote was found in its source, false when one was not, and absent when none was checked.
38
+ """
39
+ package, node, trust = record["package"], record["node"], record["trust"]
40
+ checked = [e["matched"] for e in record.get("evidence", []) if isinstance(e.get("matched"), bool)]
41
+ values: dict[str, Any] = {
42
+ "moca_package_id": package.get("id"),
43
+ "moca_package_version": package.get("version"),
44
+ "moca_package_digest": package.get("digest"),
45
+ "moca_node_id": node.get("id"),
46
+ "moca_node_digest": node.get("digest"),
47
+ "moca_node_ref": node.get("ref"),
48
+ "moca_status": trust.get("status"),
49
+ "moca_in_force_until": trust.get("validUntil"),
50
+ "moca_stale_after": trust.get("staleAfter"),
51
+ "moca_superseded": trust.get("superseded"),
52
+ "moca_attested_reviews": len(trust.get("attestedReviews", [])),
53
+ "moca_signed": package.get("signed"),
54
+ "moca_audience": record.get("audience"),
55
+ "moca_concepts": list(record["concepts"]) if record.get("concepts") else None,
56
+ "moca_evidence_matched": all(checked) if checked else None,
57
+ }
58
+ return {k: v for k, v in values.items() if v is not None}
@@ -0,0 +1,5 @@
1
+ """Profiles this Reader recognises. Recognising a profile only stops F001 from being reported; profile data
2
+ stays opaque to core."""
3
+
4
+ PROFILE_ONTOLOGY = "https://w3id.org/moca/profiles/ontology/v1"
5
+ KNOWN_PROFILES = (PROFILE_ONTOLOGY,)
moca_reader/py.typed ADDED
File without changes