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.
- bookwright/__init__.py +3 -0
- bookwright/__main__.py +6 -0
- bookwright/cli.py +19 -0
- bookwright/commands/__init__.py +0 -0
- bookwright/commands/_envelope.py +36 -0
- bookwright/commands/check.py +75 -0
- bookwright/commands/graph/__init__.py +23 -0
- bookwright/commands/graph/build.py +157 -0
- bookwright/commands/graph/envelope.py +26 -0
- bookwright/commands/graph/query.py +98 -0
- bookwright/commands/init/__init__.py +5 -0
- bookwright/commands/init/conflict.py +107 -0
- bookwright/commands/init/envelope.py +322 -0
- bookwright/commands/init/git.py +96 -0
- bookwright/commands/init/main.py +263 -0
- bookwright/commands/init/resolve.py +193 -0
- bookwright/commands/init/scaffold.py +242 -0
- bookwright/commands/init/validate.py +172 -0
- bookwright/commands/integration/__init__.py +22 -0
- bookwright/commands/integration/use.py +120 -0
- bookwright/commands/validate.py +160 -0
- bookwright/commands/version.py +35 -0
- bookwright/core/__init__.py +35 -0
- bookwright/core/_blocks.py +239 -0
- bookwright/core/_build.py +154 -0
- bookwright/core/_research_block.py +56 -0
- bookwright/core/_translate.py +90 -0
- bookwright/core/errors.py +127 -0
- bookwright/core/iso639_1.py +200 -0
- bookwright/core/manifest.py +343 -0
- bookwright/errors.py +47 -0
- bookwright/golem/__init__.py +71 -0
- bookwright/golem/base.py +200 -0
- bookwright/golem/errors.py +29 -0
- bookwright/golem/modules/__init__.py +1 -0
- bookwright/golem/modules/character.py +109 -0
- bookwright/golem/modules/event.py +91 -0
- bookwright/golem/modules/feature.py +161 -0
- bookwright/golem/modules/inference.py +41 -0
- bookwright/golem/modules/narrative.py +55 -0
- bookwright/golem/modules/provenance.py +197 -0
- bookwright/golem/modules/relationship.py +38 -0
- bookwright/golem/modules/setting.py +30 -0
- bookwright/golem/namespaces.py +332 -0
- bookwright/golem/serialize.py +25 -0
- bookwright/golem/slug.py +22 -0
- bookwright/indexers/__init__.py +47 -0
- bookwright/indexers/base.py +55 -0
- bookwright/indexers/errors.py +80 -0
- bookwright/indexers/rdflib_indexer.py +89 -0
- bookwright/integrations/__init__.py +155 -0
- bookwright/integrations/base.py +117 -0
- bookwright/integrations/claude/__init__.py +29 -0
- bookwright/integrations/constants.py +38 -0
- bookwright/integrations/descriptions.py +48 -0
- bookwright/integrations/errors.py +170 -0
- bookwright/integrations/generic/__init__.py +56 -0
- bookwright/integrations/lint.py +160 -0
- bookwright/integrations/materialize.py +202 -0
- bookwright/integrations/options.py +203 -0
- bookwright/io/__init__.py +1 -0
- bookwright/io/bible.py +500 -0
- bookwright/io/errors.py +98 -0
- bookwright/io/frontmatter.py +61 -0
- bookwright/io/fs.py +226 -0
- bookwright/io/manuscript.py +15 -0
- bookwright/io/project.py +21 -0
- bookwright/io/report.py +107 -0
- bookwright/io/research.py +427 -0
- bookwright/resources/__init__.py +1 -0
- bookwright/resources/commands/bookwright-analyze.md +66 -0
- bookwright/resources/commands/bookwright-bible.md +96 -0
- bookwright/resources/commands/bookwright-checklist.md +67 -0
- bookwright/resources/commands/bookwright-clarify.md +65 -0
- bookwright/resources/commands/bookwright-constitution.md +79 -0
- bookwright/resources/commands/bookwright-continuity.md +70 -0
- bookwright/resources/commands/bookwright-draft.md +74 -0
- bookwright/resources/commands/bookwright-outline.md +71 -0
- bookwright/resources/commands/bookwright-research.md +107 -0
- bookwright/resources/commands/bookwright-scenes.md +66 -0
- bookwright/resources/commands/bookwright-synopsis.md +67 -0
- bookwright/resources/commands/bookwright-verify.md +136 -0
- bookwright/resources/commands/references/golem-character.md +65 -0
- bookwright/resources/commands/references/golem-events-timeline.md +56 -0
- bookwright/resources/commands/references/golem-relationships.md +53 -0
- bookwright/resources/commands/references/greimas-actants.md +57 -0
- bookwright/resources/commands/references/pending-protocol.md +72 -0
- bookwright/resources/commands/references/propp-functions.md +54 -0
- bookwright/resources/commands/references/research-format.md +136 -0
- bookwright/resources/project/.bookwright/cache/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/schema/.gitkeep +0 -0
- bookwright/resources/project/.bookwright/templates/.gitkeep +0 -0
- bookwright/resources/project/.gitignore +23 -0
- bookwright/resources/project/README.md.j2 +40 -0
- bookwright/resources/project/__init__.py +6 -0
- bookwright/resources/project/bible/characters/.gitkeep +0 -0
- bookwright/resources/project/bible/constitution.md.j2 +74 -0
- bookwright/resources/project/bible/glossary.md +36 -0
- bookwright/resources/project/bible/locations/.gitkeep +0 -0
- bookwright/resources/project/bible/pov-structure.md +43 -0
- bookwright/resources/project/bible/relationships.md +36 -0
- bookwright/resources/project/bible/research/_index.md +28 -0
- bookwright/resources/project/bible/research/sources.md +23 -0
- bookwright/resources/project/bible/settings/.gitkeep +0 -0
- bookwright/resources/project/bible/subplots.md +35 -0
- bookwright/resources/project/bible/themes.md +36 -0
- bookwright/resources/project/bible/timeline.md +38 -0
- bookwright/resources/project/manuscript/.gitkeep +0 -0
- bookwright/resources/project/outline/arcs.md +34 -0
- bookwright/resources/project/outline/scenes.md +31 -0
- bookwright/resources/project/outline/structure.md +35 -0
- bookwright/resources/project/outline/synopsis.md +25 -0
- bookwright/resources/schemas/__init__.py +19 -0
- bookwright/resources/schemas/golem-1.1/VERSION +1 -0
- bookwright/resources/schemas/golem-1.1/golem.ttl +1947 -0
- bookwright/resources/schemas/golem-1.1/version.json +8 -0
- bookwright/resources/templates/__init__.py +1 -0
- bookwright/resources/templates/bible/character.md.tmpl +63 -0
- bookwright/resources/templates/bible/location.md.tmpl +37 -0
- bookwright/resources/templates/bible/research/_index.md.tmpl +25 -0
- bookwright/resources/templates/bible/research/sources.md.tmpl +21 -0
- bookwright/resources/templates/bible/research/tema.md.tmpl +37 -0
- bookwright/resources/templates/bible/setting.md.tmpl +38 -0
- bookwright/resources/templates/manifest.template.toml +79 -0
- bookwright/resources/templates/manuscript/chapter.md.tmpl +36 -0
- bookwright/resources/templates/scenes/scene.md.tmpl +37 -0
- bookwright/resources/vocabularies/__init__.py +6 -0
- bookwright/resources/vocabularies/greimas.ttl +4 -0
- bookwright/resources/vocabularies/propp.ttl +4 -0
- bookwright/resources/vocabularies/sources.ttl +82 -0
- bookwright/validation/__init__.py +33 -0
- bookwright/validation/anchor_queries.py +223 -0
- bookwright/validation/base.py +233 -0
- bookwright/validation/queries.py +197 -0
- bookwright/validation/registry.py +185 -0
- bookwright/validation/report.py +106 -0
- bookwright/validation/runner.py +65 -0
- bookwright/validation/validators/__init__.py +9 -0
- bookwright/validation/validators/character_presence.py +202 -0
- bookwright/validation/validators/factual_anchor.py +291 -0
- bookwright/validation/validators/focalization.py +152 -0
- bookwright/validation/validators/setting_continuity.py +100 -0
- bookwright/validation/validators/temporal.py +277 -0
- bookwright_cli-0.2.0.dist-info/METADATA +218 -0
- bookwright_cli-0.2.0.dist-info/RECORD +149 -0
- bookwright_cli-0.2.0.dist-info/WHEEL +4 -0
- bookwright_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bookwright_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
]
|
bookwright/golem/base.py
ADDED
|
@@ -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)."""
|