bookwright-cli 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.
Files changed (149) hide show
  1. bookwright/__init__.py +3 -0
  2. bookwright/__main__.py +6 -0
  3. bookwright/cli.py +19 -0
  4. bookwright/commands/__init__.py +0 -0
  5. bookwright/commands/_envelope.py +36 -0
  6. bookwright/commands/check.py +75 -0
  7. bookwright/commands/graph/__init__.py +23 -0
  8. bookwright/commands/graph/build.py +157 -0
  9. bookwright/commands/graph/envelope.py +26 -0
  10. bookwright/commands/graph/query.py +98 -0
  11. bookwright/commands/init/__init__.py +5 -0
  12. bookwright/commands/init/conflict.py +107 -0
  13. bookwright/commands/init/envelope.py +322 -0
  14. bookwright/commands/init/git.py +96 -0
  15. bookwright/commands/init/main.py +263 -0
  16. bookwright/commands/init/resolve.py +193 -0
  17. bookwright/commands/init/scaffold.py +242 -0
  18. bookwright/commands/init/validate.py +172 -0
  19. bookwright/commands/integration/__init__.py +22 -0
  20. bookwright/commands/integration/use.py +120 -0
  21. bookwright/commands/validate.py +160 -0
  22. bookwright/commands/version.py +35 -0
  23. bookwright/core/__init__.py +35 -0
  24. bookwright/core/_blocks.py +239 -0
  25. bookwright/core/_build.py +154 -0
  26. bookwright/core/_research_block.py +56 -0
  27. bookwright/core/_translate.py +90 -0
  28. bookwright/core/errors.py +127 -0
  29. bookwright/core/iso639_1.py +200 -0
  30. bookwright/core/manifest.py +343 -0
  31. bookwright/errors.py +47 -0
  32. bookwright/golem/__init__.py +71 -0
  33. bookwright/golem/base.py +200 -0
  34. bookwright/golem/errors.py +29 -0
  35. bookwright/golem/modules/__init__.py +1 -0
  36. bookwright/golem/modules/character.py +109 -0
  37. bookwright/golem/modules/event.py +91 -0
  38. bookwright/golem/modules/feature.py +161 -0
  39. bookwright/golem/modules/inference.py +41 -0
  40. bookwright/golem/modules/narrative.py +55 -0
  41. bookwright/golem/modules/provenance.py +197 -0
  42. bookwright/golem/modules/relationship.py +38 -0
  43. bookwright/golem/modules/setting.py +30 -0
  44. bookwright/golem/namespaces.py +332 -0
  45. bookwright/golem/serialize.py +25 -0
  46. bookwright/golem/slug.py +22 -0
  47. bookwright/indexers/__init__.py +47 -0
  48. bookwright/indexers/base.py +55 -0
  49. bookwright/indexers/errors.py +80 -0
  50. bookwright/indexers/rdflib_indexer.py +89 -0
  51. bookwright/integrations/__init__.py +155 -0
  52. bookwright/integrations/base.py +117 -0
  53. bookwright/integrations/claude/__init__.py +29 -0
  54. bookwright/integrations/constants.py +38 -0
  55. bookwright/integrations/descriptions.py +48 -0
  56. bookwright/integrations/errors.py +170 -0
  57. bookwright/integrations/generic/__init__.py +56 -0
  58. bookwright/integrations/lint.py +160 -0
  59. bookwright/integrations/materialize.py +202 -0
  60. bookwright/integrations/options.py +203 -0
  61. bookwright/io/__init__.py +1 -0
  62. bookwright/io/bible.py +500 -0
  63. bookwright/io/errors.py +98 -0
  64. bookwright/io/frontmatter.py +61 -0
  65. bookwright/io/fs.py +226 -0
  66. bookwright/io/manuscript.py +15 -0
  67. bookwright/io/project.py +21 -0
  68. bookwright/io/report.py +107 -0
  69. bookwright/io/research.py +427 -0
  70. bookwright/resources/__init__.py +1 -0
  71. bookwright/resources/commands/bookwright-analyze.md +66 -0
  72. bookwright/resources/commands/bookwright-bible.md +96 -0
  73. bookwright/resources/commands/bookwright-checklist.md +67 -0
  74. bookwright/resources/commands/bookwright-clarify.md +65 -0
  75. bookwright/resources/commands/bookwright-constitution.md +79 -0
  76. bookwright/resources/commands/bookwright-continuity.md +70 -0
  77. bookwright/resources/commands/bookwright-draft.md +74 -0
  78. bookwright/resources/commands/bookwright-outline.md +71 -0
  79. bookwright/resources/commands/bookwright-research.md +107 -0
  80. bookwright/resources/commands/bookwright-scenes.md +66 -0
  81. bookwright/resources/commands/bookwright-synopsis.md +67 -0
  82. bookwright/resources/commands/bookwright-verify.md +136 -0
  83. bookwright/resources/commands/references/golem-character.md +65 -0
  84. bookwright/resources/commands/references/golem-events-timeline.md +56 -0
  85. bookwright/resources/commands/references/golem-relationships.md +53 -0
  86. bookwright/resources/commands/references/greimas-actants.md +57 -0
  87. bookwright/resources/commands/references/pending-protocol.md +72 -0
  88. bookwright/resources/commands/references/propp-functions.md +54 -0
  89. bookwright/resources/commands/references/research-format.md +136 -0
  90. bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
  91. bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
  92. bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
  93. bookwright/resources/project/.gitignore +23 -0
  94. bookwright/resources/project/README.md.j2 +40 -0
  95. bookwright/resources/project/__init__.py +6 -0
  96. bookwright/resources/project/bible/characters/.gitkeep +0 -0
  97. bookwright/resources/project/bible/constitution.md.j2 +74 -0
  98. bookwright/resources/project/bible/glossary.md +36 -0
  99. bookwright/resources/project/bible/locations/.gitkeep +0 -0
  100. bookwright/resources/project/bible/pov-structure.md +43 -0
  101. bookwright/resources/project/bible/relationships.md +36 -0
  102. bookwright/resources/project/bible/research/_index.md +28 -0
  103. bookwright/resources/project/bible/research/sources.md +23 -0
  104. bookwright/resources/project/bible/settings/.gitkeep +0 -0
  105. bookwright/resources/project/bible/subplots.md +35 -0
  106. bookwright/resources/project/bible/themes.md +36 -0
  107. bookwright/resources/project/bible/timeline.md +38 -0
  108. bookwright/resources/project/manuscript/.gitkeep +0 -0
  109. bookwright/resources/project/outline/arcs.md +34 -0
  110. bookwright/resources/project/outline/scenes.md +31 -0
  111. bookwright/resources/project/outline/structure.md +35 -0
  112. bookwright/resources/project/outline/synopsis.md +25 -0
  113. bookwright/resources/schemas/__init__.py +19 -0
  114. bookwright/resources/schemas/golem-1.1/VERSION +1 -0
  115. bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
  116. bookwright/resources/schemas/golem-1.1/version.json +8 -0
  117. bookwright/resources/templates/__init__.py +1 -0
  118. bookwright/resources/templates/bible/character.md.tmpl +63 -0
  119. bookwright/resources/templates/bible/location.md.tmpl +37 -0
  120. bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
  121. bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
  122. bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
  123. bookwright/resources/templates/bible/setting.md.tmpl +38 -0
  124. bookwright/resources/templates/manifest.template.toml +79 -0
  125. bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
  126. bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
  127. bookwright/resources/vocabularies/__init__.py +6 -0
  128. bookwright/resources/vocabularies/greimas.ttl +4 -0
  129. bookwright/resources/vocabularies/propp.ttl +4 -0
  130. bookwright/resources/vocabularies/sources.ttl +82 -0
  131. bookwright/validation/__init__.py +33 -0
  132. bookwright/validation/anchor_queries.py +223 -0
  133. bookwright/validation/base.py +233 -0
  134. bookwright/validation/queries.py +197 -0
  135. bookwright/validation/registry.py +185 -0
  136. bookwright/validation/report.py +106 -0
  137. bookwright/validation/runner.py +65 -0
  138. bookwright/validation/validators/__init__.py +9 -0
  139. bookwright/validation/validators/character_presence.py +202 -0
  140. bookwright/validation/validators/factual_anchor.py +291 -0
  141. bookwright/validation/validators/focalization.py +152 -0
  142. bookwright/validation/validators/setting_continuity.py +100 -0
  143. bookwright/validation/validators/temporal.py +277 -0
  144. bookwright_cli-0.2.0.dist-info/METADATA +218 -0
  145. bookwright_cli-0.2.0.dist-info/RECORD +149 -0
  146. bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
  147. bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
  148. bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  149. bookwright_cli-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,343 @@
1
+ """Typed in-memory model of a Bookwright `manifest.toml`.
2
+
3
+ Public surface is re-exported from `bookwright.core` (see contracts/manifest_api.md).
4
+ This module owns the Pydantic v2 model tree and the load/dump/build entry
5
+ points. The builder body lives in `bookwright.core._build` and the
6
+ `pydantic.ValidationError` translator in `bookwright.core._translate`, both
7
+ internal helpers kept separate to honour the Principle IV 500-line ceiling.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any, Literal
17
+
18
+ import tomlkit
19
+ import tomlkit.exceptions
20
+ from packaging.version import InvalidVersion, Version
21
+ from pydantic import (
22
+ BaseModel,
23
+ ConfigDict,
24
+ Field,
25
+ PrivateAttr,
26
+ ValidationError,
27
+ model_validator,
28
+ )
29
+ from pydantic_core import PydanticCustomError
30
+ from tomlkit.toml_document import TOMLDocument
31
+
32
+ from bookwright import __version__ as _BOOKWRIGHT_VERSION
33
+ from bookwright.core._blocks import (
34
+ BOOK_STATUSES,
35
+ BOOK_TYPES,
36
+ BookBlock,
37
+ BookwrightBlock,
38
+ IntegrationBlock,
39
+ PathsBlock,
40
+ ValidatorsBlock,
41
+ VocabulariesBlock,
42
+ )
43
+ from bookwright.core._build import _build_manifest
44
+ from bookwright.core._research_block import ResearchBlock
45
+ from bookwright.core._translate import _translate_validation_error
46
+ from bookwright.core.errors import (
47
+ ManifestNotFoundError,
48
+ ManifestOverwriteError,
49
+ ManifestSyntaxError,
50
+ ManifestWarning,
51
+ )
52
+
53
+ KNOWN_MANIFEST_VERSIONS: frozenset[int] = frozenset({1})
54
+ """The set of `manifest_version` integers this CLI understands natively."""
55
+
56
+ __all__ = [
57
+ "BOOK_STATUSES",
58
+ "BOOK_TYPES",
59
+ "KNOWN_MANIFEST_VERSIONS",
60
+ "BookBlock",
61
+ "BookwrightBlock",
62
+ "IntegrationBlock",
63
+ "Manifest",
64
+ "PathsBlock",
65
+ "ValidatorsBlock",
66
+ "VocabulariesBlock",
67
+ ]
68
+
69
+
70
+ def _default_skills_dir_map() -> dict[str, str]:
71
+ """Late-imported view of the integrations registry.
72
+
73
+ Used by ``_build_manifest`` to fill the per-key skills_dir default
74
+ (R2). The late import inside the function body keeps ``bookwright.core``
75
+ importable in isolation; no module-top dependency on
76
+ ``bookwright.integrations`` exists.
77
+ """
78
+
79
+ # Late import per R2 — keeps bookwright.core importable in isolation
80
+ # and prevents a load-order cycle between core and integrations.
81
+ from bookwright.integrations import INTEGRATION_REGISTRY # noqa: PLC0415
82
+
83
+ return {key: cls.default_skills_dir for key, cls in INTEGRATION_REGISTRY.items()}
84
+
85
+
86
+ def _installed_version() -> str:
87
+ """Thin indirection so tests can monkey-patch the installed CLI version."""
88
+
89
+ return _BOOKWRIGHT_VERSION
90
+
91
+
92
+ def _classify_manifest_version(parsed: int) -> Literal["known", "future"]:
93
+ """FR-013 vs FR-014 single source of truth."""
94
+
95
+ if parsed in KNOWN_MANIFEST_VERSIONS:
96
+ return "known"
97
+ return "future"
98
+
99
+
100
+ def _classify_manifest_version_warnings(
101
+ raw: str,
102
+ ) -> tuple[ManifestWarning, ...]:
103
+ """Return the (possibly empty) warning tuple for a known/future `manifest_version`.
104
+
105
+ Called only after Pydantic validation accepted `raw`, so `int(raw)` is
106
+ safe — no need to re-run the regex validator (which raises
107
+ `PydanticCustomError` outside Pydantic's machinery).
108
+ """
109
+
110
+ parsed = int(raw)
111
+ if _classify_manifest_version(parsed) == "known":
112
+ return ()
113
+ max_known = max(KNOWN_MANIFEST_VERSIONS)
114
+ return (
115
+ ManifestWarning(
116
+ rule_id="manifest_version.unknown_future",
117
+ field_path="bookwright.manifest_version",
118
+ offending_value=raw,
119
+ message=(
120
+ f"manifest_version {parsed} is newer than this CLI knows about "
121
+ f"(max known: {max_known}); load was best-effort"
122
+ ),
123
+ ),
124
+ )
125
+
126
+
127
+ class Manifest(BaseModel):
128
+ """Root model. Unknown top-level blocks round-trip via `extra='allow'`."""
129
+
130
+ model_config = ConfigDict(extra="allow", strict=True)
131
+
132
+ bookwright: BookwrightBlock
133
+ book: BookBlock
134
+ vocabularies: VocabulariesBlock = Field(default_factory=VocabulariesBlock)
135
+ validators: ValidatorsBlock = Field(default_factory=ValidatorsBlock)
136
+ integration: IntegrationBlock
137
+ paths: PathsBlock = Field(default_factory=PathsBlock)
138
+ research: ResearchBlock = Field(default_factory=ResearchBlock)
139
+
140
+ _document: TOMLDocument | None = PrivateAttr(default=None)
141
+ _warnings: tuple[ManifestWarning, ...] = PrivateAttr(default=())
142
+
143
+ @property
144
+ def warnings(self) -> tuple[ManifestWarning, ...]:
145
+ """Non-fatal notes attached during `load()`; empty for freshly built manifests.
146
+
147
+ Exposed as a read-only property (not a Pydantic field) so a user-authored
148
+ `[warnings]` block in `manifest.toml` rounds-trips via `extra="allow"`
149
+ instead of colliding with a declared field.
150
+ """
151
+
152
+ return self._warnings
153
+
154
+ @model_validator(mode="after")
155
+ def _check_cli_floor(self) -> Manifest:
156
+ # cli_version_min was already validated as PEP 440 by its field validator;
157
+ # field errors short-circuit model_validators, so we never see invalid input here.
158
+ required = Version(self.bookwright.cli_version_min)
159
+ installed_raw = _installed_version()
160
+ try:
161
+ installed = Version(installed_raw)
162
+ except InvalidVersion as exc:
163
+ raise PydanticCustomError(
164
+ "installed_not_pep440",
165
+ "installed CLI version '{installed}' is not valid PEP 440",
166
+ {
167
+ "value": self.bookwright.cli_version_min,
168
+ "installed": installed_raw,
169
+ },
170
+ ) from exc
171
+ if installed < required:
172
+ raise PydanticCustomError(
173
+ "installed_too_old",
174
+ "installed CLI {installed} is older than required {required}",
175
+ {
176
+ "value": self.bookwright.cli_version_min,
177
+ "installed": installed_raw,
178
+ "required": self.bookwright.cli_version_min,
179
+ },
180
+ )
181
+ return self
182
+
183
+ @classmethod
184
+ def load(cls, path: Path | str) -> Manifest:
185
+ """Read, parse, and validate a manifest file. See contracts/manifest_api.md."""
186
+
187
+ resolved = Path(path)
188
+ if not resolved.exists():
189
+ raise ManifestNotFoundError(resolved)
190
+ text = resolved.read_text(encoding="utf-8")
191
+ try:
192
+ document = tomlkit.parse(text)
193
+ except tomlkit.exceptions.ParseError as exc:
194
+ line = getattr(exc, "line", None)
195
+ column = getattr(exc, "col", None)
196
+ raise ManifestSyntaxError(
197
+ path=resolved,
198
+ line=line,
199
+ column=column,
200
+ message=str(exc),
201
+ ) from exc
202
+ try:
203
+ instance = cls.model_validate(document.unwrap())
204
+ except ValidationError as exc:
205
+ raise _translate_validation_error(exc) from exc
206
+ instance._document = document
207
+ instance._warnings = _classify_manifest_version_warnings(
208
+ instance.bookwright.manifest_version
209
+ )
210
+ return instance
211
+
212
+ @classmethod
213
+ def build(
214
+ cls,
215
+ *,
216
+ title: str,
217
+ authors: list[str],
218
+ integration_key: str,
219
+ **overrides: Any,
220
+ ) -> Manifest:
221
+ """Construct a fresh manifest from minimal inputs. See contracts/manifest_api.md."""
222
+
223
+ # R10 — only consult the integrations registry when the caller
224
+ # didn't supply an explicit `integration_skills_dir`. Otherwise
225
+ # `_build_manifest` ignores the map entirely, so building it
226
+ # (and the `bookwright.integrations` import that triggers
227
+ # `_register_builtins()`) is wasted work — and worse, it forces
228
+ # every Manifest.build caller through the registry even when
229
+ # core-only behaviour is what they want.
230
+ #
231
+ # R23 — align the "supplied" predicate with `_build_manifest`'s
232
+ # documented None-as-default contract (see _build.py: explicit-None
233
+ # overrides are dropped from `effective_overrides`). Using
234
+ # ``"integration_skills_dir" in overrides`` here would diverge:
235
+ # ``Manifest.build(..., integration_skills_dir=None)`` would skip
236
+ # the registry, then `_build_manifest` would also skip the
237
+ # override (None filtered), then `default_skills_dir[key]` would
238
+ # raise KeyError → misleading TypeError. ``.get(...) is not None``
239
+ # keeps both call sites in agreement.
240
+ default_skills_dir: dict[str, str] = (
241
+ {} if overrides.get("integration_skills_dir") is not None else _default_skills_dir_map()
242
+ )
243
+
244
+ return _build_manifest(
245
+ cls,
246
+ title=title,
247
+ authors=authors,
248
+ integration_key=integration_key,
249
+ installed_version=_installed_version(),
250
+ default_skills_dir=default_skills_dir,
251
+ **overrides,
252
+ )
253
+
254
+ def set_integration(self, *, key: str, skills_dir: str) -> None:
255
+ """Update the ``[integration]`` block in place, preserving comments and order.
256
+
257
+ Mutates **both** the validated model field and the backing ``tomlkit``
258
+ document, so a subsequent :meth:`dump` writes the new ``key`` /
259
+ ``skills_dir`` while every other key, comment, and the block ordering
260
+ round-trip untouched (FR-020). The ``[integration.options]`` sub-table is
261
+ left as-is — the two v0 integrations both default to no options, and a
262
+ future per-integration option migration is an additive concern.
263
+
264
+ Requires an instance produced by :meth:`load` or :meth:`build` (i.e. one
265
+ carrying a ``tomlkit`` document); a bare construction raises ``RuntimeError``,
266
+ the same contract as :meth:`dump`.
267
+ """
268
+
269
+ document = self._document
270
+ if document is None:
271
+ raise RuntimeError(
272
+ "Manifest.set_integration() requires an instance produced by "
273
+ "Manifest.load() or Manifest.build()."
274
+ )
275
+ table = document["integration"]
276
+ table["key"] = key
277
+ table["skills_dir"] = skills_dir
278
+ self.integration = self.integration.model_copy(
279
+ update={"key": key, "skills_dir": skills_dir}
280
+ )
281
+
282
+ def dump(self, path: Path | str, *, overwrite: bool = False) -> Path:
283
+ """Atomically write the manifest to `path`. See contracts/manifest_api.md."""
284
+
285
+ target = Path(path)
286
+ if target.exists() and not overwrite:
287
+ raise ManifestOverwriteError(target)
288
+
289
+ document = self._document
290
+ if document is None:
291
+ raise RuntimeError(
292
+ "Manifest.dump() requires an instance produced by Manifest.load() "
293
+ "or Manifest.build(); bare constructions have no tomlkit document "
294
+ "to serialize."
295
+ )
296
+
297
+ body = tomlkit.dumps(document)
298
+
299
+ parent = target.parent
300
+ parent.mkdir(parents=True, exist_ok=True)
301
+ tmp_fd, tmp_path = tempfile.mkstemp(
302
+ dir=str(parent),
303
+ prefix=f".{target.name}.",
304
+ suffix=".tmp",
305
+ )
306
+ try:
307
+ # `newline=""` disables Python's universal-newlines translation
308
+ # so the file we hand-fsynced lands byte-identical on every
309
+ # platform (Windows would otherwise rewrite `\n` to `\r\n`,
310
+ # breaking the FR-020 round-trip guarantee).
311
+ with os.fdopen(tmp_fd, "w", encoding="utf-8", newline="") as handle:
312
+ handle.write(body)
313
+ handle.flush()
314
+ os.fsync(handle.fileno())
315
+ if overwrite:
316
+ os.replace(tmp_path, target)
317
+ else:
318
+ # `os.link` is atomic: if the target appears between the
319
+ # early `exists()` check and here, link raises FileExistsError
320
+ # rather than silently clobbering — closes the TOCTOU race.
321
+ try:
322
+ os.link(tmp_path, target)
323
+ except FileExistsError as exc:
324
+ raise ManifestOverwriteError(target) from exc
325
+ # Once `os.link` succeeds, `target` already holds the new
326
+ # bytes (as a hard link). Failing to remove the tmp side of
327
+ # the link does NOT undo that commit — raising here would
328
+ # mislead callers into thinking the write failed and would
329
+ # violate the FR-021 atomicity contract from the opposite
330
+ # direction. Best-effort cleanup; leave a leaked tmp file
331
+ # rather than a phantom failure.
332
+ with contextlib.suppress(OSError):
333
+ os.unlink(tmp_path)
334
+ except BaseException:
335
+ # Suppress *all* OSError variants during cleanup so the real
336
+ # exception (the one that triggered this branch) is not
337
+ # shadowed by a PermissionError / EBUSY / etc. raised by the
338
+ # cleanup itself.
339
+ with contextlib.suppress(OSError):
340
+ os.unlink(tmp_path)
341
+ raise
342
+
343
+ return target.resolve()
bookwright/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """The shared error base — the single source of truth for the JSON-over-stdout
2
+ error envelope (Principle IX, review finding R3).
3
+
4
+ Every Bookwright error that can reach a ``--json`` boundary subclasses
5
+ ``BookwrightError`` and inherits its one canonical ``to_json()``; no error class
6
+ defines its own envelope serializer. This module is the lowest layer: it imports
7
+ **nothing** from ``core``/``golem``/``io``/``indexers``/``validation``/
8
+ ``integrations``/``commands`` (FR-010), so it can be imported by all of them with
9
+ no risk of a cycle.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+
17
+ class BookwrightError(Exception):
18
+ """Base for every Bookwright error that reaches a ``--json`` boundary.
19
+
20
+ Subclasses declare a class-level ``code`` (the machine-readable identifier),
21
+ pass a human ``message`` and optional ``details`` to ``__init__``, and inherit
22
+ the one canonical ``to_json()``. A subclass MAY set ``self.code`` per instance
23
+ (``_UsageError``). Abstract package roots leave ``code`` unset and are never
24
+ serialized.
25
+ """
26
+
27
+ # Class-level default; concrete subclasses assign it (or set ``self.code``).
28
+ # Deliberately a plain annotation, NOT ``ClassVar[str]``: a ``ClassVar`` would
29
+ # forbid ``_UsageError``'s per-instance ``self.code`` override under
30
+ # ``mypy --strict`` (research Decision 2).
31
+ code: str
32
+
33
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
34
+ self.message = message
35
+ self.details = details
36
+ super().__init__(message)
37
+
38
+ def to_json(self) -> dict[str, Any]:
39
+ """The canonical error envelope; ``details`` only when non-empty."""
40
+ payload: dict[str, Any] = {
41
+ "status": "error",
42
+ "code": self.code,
43
+ "message": self.message,
44
+ }
45
+ if self.details:
46
+ payload["details"] = self.details
47
+ return payload
@@ -0,0 +1,71 @@
1
+ """GOLEM domain model: typed, frozen entities with deterministic RDF identity.
2
+
3
+ Public surface: the thirteen GOLEM concept classes, the two
4
+ character-scoped attribute carriers (``CharacterFeature`` / ``Dimension`` —
5
+ exported for iteration-10 introspection but deliberately **not** in
6
+ ``CONCEPTS``), the ``GolemError`` / ``EmptySlugError`` types, the ``CONCEPTS``
7
+ name→class registry, and ``to_turtle``. See
8
+ specs/005-golem-domain-model/contracts/golem_api.md for the stable contract.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from bookwright.golem.base import DerivedAssertion
14
+ from bookwright.golem.errors import EmptySlugError, GolemError
15
+ from bookwright.golem.modules.character import Character, Object
16
+ from bookwright.golem.modules.event import NarrativeEvent, PsychologicalState
17
+ from bookwright.golem.modules.feature import CharacterFeature, Dimension
18
+ from bookwright.golem.modules.inference import AttributeAssignment
19
+ from bookwright.golem.modules.narrative import (
20
+ NarrativeFunction,
21
+ NarrativeRole,
22
+ NarrativeSequence,
23
+ NarrativeUnit,
24
+ )
25
+ from bookwright.golem.modules.provenance import Anchor, Finding, Source
26
+ from bookwright.golem.modules.relationship import RelationshipRole, SocialRelationship
27
+ from bookwright.golem.modules.setting import NarrativeLocation, Setting
28
+ from bookwright.golem.serialize import to_turtle
29
+
30
+ CONCEPTS: dict[str, type] = {
31
+ "Character": Character,
32
+ "Object": Object,
33
+ "SocialRelationship": SocialRelationship,
34
+ "RelationshipRole": RelationshipRole,
35
+ "NarrativeEvent": NarrativeEvent,
36
+ "PsychologicalState": PsychologicalState,
37
+ "Setting": Setting,
38
+ "NarrativeLocation": NarrativeLocation,
39
+ "NarrativeUnit": NarrativeUnit,
40
+ "NarrativeFunction": NarrativeFunction,
41
+ "NarrativeRole": NarrativeRole,
42
+ "NarrativeSequence": NarrativeSequence,
43
+ "AttributeAssignment": AttributeAssignment,
44
+ }
45
+ """Concept name → class, for downstream introspection (contract § surface)."""
46
+
47
+ __all__ = [
48
+ "CONCEPTS",
49
+ "Anchor",
50
+ "AttributeAssignment",
51
+ "Character",
52
+ "CharacterFeature",
53
+ "DerivedAssertion",
54
+ "Dimension",
55
+ "EmptySlugError",
56
+ "Finding",
57
+ "GolemError",
58
+ "NarrativeEvent",
59
+ "NarrativeFunction",
60
+ "NarrativeLocation",
61
+ "NarrativeRole",
62
+ "NarrativeSequence",
63
+ "NarrativeUnit",
64
+ "Object",
65
+ "PsychologicalState",
66
+ "RelationshipRole",
67
+ "Setting",
68
+ "SocialRelationship",
69
+ "Source",
70
+ "to_turtle",
71
+ ]
@@ -0,0 +1,200 @@
1
+ """The frozen-entity base: deterministic identity + the rdf:type triple.
2
+
3
+ Every GOLEM concept is an immutable Pydantic v2 model (research D1). Identity is
4
+ computed once, in ``model_post_init`` — which runs *after* validation, so an
5
+ :class:`~bookwright.golem.errors.EmptySlugError` raised while slugging a name
6
+ propagates unwrapped (not folded into a ``pydantic.ValidationError``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from typing import ClassVar, NamedTuple
13
+
14
+ import uuid_utils
15
+ from pydantic import BaseModel, ConfigDict, PrivateAttr
16
+ from rdflib.namespace import RDF, XSD
17
+ from rdflib.term import Literal, URIRef
18
+
19
+ from bookwright.golem.slug import make_slug
20
+
21
+ Triple = tuple[URIRef, URIRef, URIRef | Literal]
22
+ """An rdflib triple emitted by :meth:`GolemEntity.to_triples`."""
23
+
24
+
25
+ def ref_uri(ref: GolemEntity | URIRef) -> URIRef:
26
+ """Resolve a cross-reference target to the URIRef used in a linking triple."""
27
+ return ref.uri if isinstance(ref, GolemEntity) else ref
28
+
29
+
30
+ class DerivedAssertion(NamedTuple):
31
+ """One source-derived assertion this entity makes, for provenance (FR-011).
32
+
33
+ The indexer turns each into a ``crm:E13_Attribute_Assignment``, resolving
34
+ :attr:`source_field` to a ``file:line`` locator via the frontmatter reader's
35
+ ``key_lines``. The model names the *originating field* — never a file path —
36
+ so it stays source-agnostic: where a value lives on disk is the indexer's
37
+ knowledge, not the ontology's.
38
+
39
+ - ``target``: the entity the assertion is about (e.g. the character).
40
+ - ``attribute``: the materialized node the assertion introduces (a feature /
41
+ role / participant URI), or ``target`` itself for the identity assertion.
42
+ - ``source_field``: the model field — identical to the frontmatter key by
43
+ FR-010 (``born`` / ``died`` / ``features`` / ``narrative_roles`` /
44
+ ``participants``) — that the assertion derived from; ``None`` for the
45
+ identity assertion, which carries only file-level provenance.
46
+ """
47
+
48
+ target: URIRef
49
+ attribute: URIRef
50
+ source_field: str | None
51
+
52
+
53
+ class CrossRef(NamedTuple):
54
+ """A declarative cross-reference edge: one field → its linking predicate (FR-015).
55
+
56
+ Concepts list these in :attr:`GolemEntity.cross_refs` instead of hand-rolling
57
+ a ``to_triples`` override, so the base emits every edge uniformly and a new
58
+ concept can never forget to chain the ``rdf:type`` assertion.
59
+
60
+ - ``multi``: the field is a tuple; emit one triple per item, in tuple order.
61
+ - ``literal``: emit the field value verbatim as an ``xsd:string`` (e.g. a
62
+ source path), not a resolved reference.
63
+ - ``owned``: the targets are sub-nodes this entity owns rather than peer
64
+ entities serialized in their own right; after each link triple, chain the
65
+ target's own ``to_triples()`` so the whole sub-tree is emitted here. (Used
66
+ with ``multi`` — a character owns its feature / role nodes.)
67
+ - otherwise the field is a single optional reference, omitted when ``None``.
68
+ """
69
+
70
+ attr: str
71
+ predicate: URIRef
72
+ multi: bool = False
73
+ literal: bool = False
74
+ owned: bool = False
75
+
76
+
77
+ class GolemEntity(BaseModel):
78
+ """Abstract base for every GOLEM concept.
79
+
80
+ Subclasses set the class-level ``golem_class`` (rdf:type target) and
81
+ ``path_segment`` (FR-004), and provide a token via ``_build_token``.
82
+ """
83
+
84
+ model_config = ConfigDict(
85
+ frozen=True,
86
+ extra="forbid",
87
+ strict=True,
88
+ arbitrary_types_allowed=True,
89
+ )
90
+
91
+ uri_base: str
92
+
93
+ golem_class: ClassVar[URIRef]
94
+ path_segment: ClassVar[str]
95
+ cross_refs: ClassVar[tuple[CrossRef, ...]] = ()
96
+
97
+ _uri: URIRef = PrivateAttr()
98
+
99
+ def model_post_init(self, __context: object) -> None:
100
+ self._uri = URIRef(f"{self.uri_base}{self.path_segment}/{self._build_token()}")
101
+
102
+ def _build_token(self) -> str: # pragma: no cover - overridden by every concrete class
103
+ raise NotImplementedError
104
+
105
+ @property
106
+ def uri(self) -> URIRef:
107
+ """The deterministic, immutable identifier (FR-003/004/007)."""
108
+ return self._uri
109
+
110
+ def to_triples(self) -> Iterable[Triple]:
111
+ """Yield this entity's triples: the ``rdf:type`` assertion (FR-008, always
112
+ first) followed by every edge declared in :attr:`cross_refs` (FR-015) —
113
+ and, for an ``owned`` edge, the target sub-node's own triples chained
114
+ immediately after its link triple.
115
+
116
+ Concepts customize emission declaratively via ``cross_refs`` only when
117
+ their edges are URIRef references or ``xsd:string`` literals — the two
118
+ shapes this method knows how to emit. Concepts whose emission falls
119
+ outside that path override ``to_triples`` deliberately: ``CharacterFeature``
120
+ and ``CharacterRole`` emit an ``rdfs:label`` plain literal (and the former
121
+ a discriminator-keyed sub-tree), and ``Dimension`` emits a typed
122
+ ``xsd:gYear`` literal — none of which ``cross_refs`` can express.
123
+ """
124
+ yield (self.uri, RDF.type, self.golem_class)
125
+ for ref in self.cross_refs:
126
+ value = getattr(self, ref.attr)
127
+ if ref.multi:
128
+ for item in value:
129
+ yield (self.uri, ref.predicate, ref_uri(item))
130
+ if ref.owned:
131
+ yield from item.to_triples()
132
+ elif ref.literal:
133
+ yield (self.uri, ref.predicate, Literal(value, datatype=XSD.string))
134
+ elif value is not None:
135
+ yield (self.uri, ref.predicate, ref_uri(value))
136
+
137
+ def derived_assertions(self) -> Iterable[DerivedAssertion]:
138
+ """Yield one :class:`DerivedAssertion` per source-derived assertion: the
139
+ identity assertion first (``source_field`` ``None`` → file-level
140
+ provenance), then one per cross-reference edge tagged with its
141
+ originating field name.
142
+
143
+ Read declaratively from :attr:`cross_refs`, so an entity whose field name
144
+ already equals its frontmatter key — ``NarrativeEvent`` /
145
+ ``SocialRelationship`` with ``participants`` — needs no override.
146
+ ``literal`` edges (a verbatim source path, not an attribute) are skipped.
147
+ An entity that fans one field out across several origin keys — ``Character``
148
+ splits a single owned-node tuple across ``born`` / ``died`` / ``features`` —
149
+ overrides this, exactly as such concepts already override
150
+ :meth:`to_triples`.
151
+ """
152
+ yield DerivedAssertion(self.uri, self.uri, None)
153
+ for ref in self.cross_refs:
154
+ if ref.literal:
155
+ continue
156
+ value = getattr(self, ref.attr)
157
+ if ref.multi:
158
+ for item in value:
159
+ yield DerivedAssertion(self.uri, ref_uri(item), ref.attr)
160
+ elif value is not None:
161
+ yield DerivedAssertion(self.uri, ref_uri(value), ref.attr)
162
+
163
+
164
+ class SluggedEntity(GolemEntity):
165
+ """A named concept whose identity token is the ASCII slug of its name."""
166
+
167
+ name: str
168
+
169
+ _slug: str = PrivateAttr()
170
+
171
+ def model_post_init(self, __context: object) -> None:
172
+ self._slug = make_slug(self.name)
173
+ super().model_post_init(__context)
174
+
175
+ def _build_token(self) -> str:
176
+ return self._slug
177
+
178
+ @property
179
+ def slug(self) -> str:
180
+ """The slugged token of :attr:`name` (FR-005)."""
181
+ return self._slug
182
+
183
+
184
+ class MintedEntity(GolemEntity):
185
+ """A nameless concept whose identity token is a freshly minted, time-ordered uuid7.
186
+
187
+ Used by the ``crm:E13_Attribute_Assignment`` reifications — the inference
188
+ ``AttributeAssignment`` and the research ``Finding`` / ``Anchor`` — which have
189
+ no natural name to slug. The token is generated once in ``model_post_init`` and
190
+ frozen, so entities created in sequence sort in creation order (FR-013, D3).
191
+ """
192
+
193
+ _token: str = PrivateAttr()
194
+
195
+ def model_post_init(self, __context: object) -> None:
196
+ self._token = str(uuid_utils.uuid7())
197
+ super().model_post_init(__context)
198
+
199
+ def _build_token(self) -> str:
200
+ return self._token
@@ -0,0 +1,29 @@
1
+ """Exception hierarchy for the GOLEM domain model.
2
+
3
+ The error JSON shape is the canonical envelope owned by ``BookwrightError`` (this
4
+ iteration normalized the former flat ``{"error": …}`` body onto it).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from bookwright.errors import BookwrightError
10
+
11
+
12
+ class GolemError(BookwrightError):
13
+ """Base for every failure mode the ``bookwright.golem`` package owns.
14
+
15
+ Abstract: declares no ``code`` and is never serialized directly.
16
+ """
17
+
18
+
19
+ class EmptySlugError(GolemError):
20
+ """A canonical name slugged to the empty string (FR-006).
21
+
22
+ Carries the offending name so a caller can report exactly what was rejected.
23
+ """
24
+
25
+ code = "golem_empty_slug"
26
+
27
+ def __init__(self, name: str) -> None:
28
+ self.name = name
29
+ super().__init__(f"name {name!r} slugifies to an empty string", {"name": name})
@@ -0,0 +1 @@
1
+ """Per-GOLEM-module concept classes (design § 4.2 grouping)."""