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
bookwright/io/fs.py ADDED
@@ -0,0 +1,226 @@
1
+ """Shared transactional-filesystem layer (extracted from ``init/scaffold.py``).
2
+
3
+ The :class:`BackupLedger` is the atomic-or-nothing primitive: every filesystem
4
+ mutation a caller performs is recorded here BEFORE the bytes hit disk; on success
5
+ the backups are unlinked, on any exception the ledger is replayed in reverse to
6
+ restore the tree to byte-for-byte its pre-mutation state. The writer goes through
7
+ ``os.replace`` for local atomicity.
8
+
9
+ Two consumers depend on this module: ``bookwright.commands.init`` (the original
10
+ home) and ``bookwright.integrations`` (the iteration-9 skills materializer). To
11
+ keep them decoupled from the concrete ledger, this module also defines the narrow
12
+ :class:`FileLedger` ``Protocol`` (structurally satisfied by :class:`BackupLedger`)
13
+ and a no-op :class:`NullLedger` for standalone callers.
14
+
15
+ Dependency direction is acyclic by design: this module imports **only** stdlib —
16
+ it imports neither ``commands`` nor ``integrations``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import os
23
+ import secrets
24
+ import shutil
25
+ import tempfile
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Protocol, runtime_checkable
29
+
30
+ _BACKUP_SUBDIR = Path(".bookwright/cache/backup")
31
+
32
+
33
+ class BackupCreationError(Exception):
34
+ """Raised when a pre-overwrite backup copy could not be created (FR-030 last sentence)."""
35
+
36
+ code = "backup_creation_error"
37
+
38
+ def __init__(self, *, target: Path, reason: str) -> None:
39
+ self.target = target
40
+ self.reason = reason
41
+ super().__init__(f"could not create backup for {target}: {reason}")
42
+
43
+
44
+ class TargetOutsideProjectRootError(Exception):
45
+ """Raised when a writer would touch a path outside ``project_root`` (FR-014)."""
46
+
47
+ def __init__(self, *, target: Path, project_root: Path) -> None:
48
+ self.target = target
49
+ self.project_root = project_root
50
+ super().__init__(f"target {target} is outside project root {project_root}")
51
+
52
+
53
+ @runtime_checkable
54
+ class FileLedger(Protocol):
55
+ """Narrow rollback-recording surface the materializer depends on.
56
+
57
+ Structurally satisfied by :class:`BackupLedger` (and any future ledger).
58
+ The integrations layer depends on this Protocol, never on the concrete
59
+ ledger, so it stays decoupled from ``init`` internals (Principle V).
60
+ """
61
+
62
+ def record_new_file(self, target: Path) -> None: ...
63
+
64
+ def record_new_directory(self, target: Path) -> None: ...
65
+
66
+ def record_overwrite(self, target: Path) -> Path: ...
67
+
68
+
69
+ class NullLedger:
70
+ """No-op :class:`FileLedger` for standalone callers (no rollback needed)."""
71
+
72
+ def record_new_file(self, target: Path) -> None:
73
+ del target
74
+
75
+ def record_new_directory(self, target: Path) -> None:
76
+ del target
77
+
78
+ def record_overwrite(self, target: Path) -> Path:
79
+ return target
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class BackupEntry:
84
+ """One ledger entry (data-model §3)."""
85
+
86
+ target: Path
87
+ backup_path: Path | None
88
+ was_directory: bool
89
+
90
+
91
+ class BackupLedger:
92
+ """In-memory rollback record for one ``init`` invocation."""
93
+
94
+ def __init__(self, project_root: Path) -> None:
95
+ self._project_root = project_root.resolve()
96
+ self._entries: list[BackupEntry] = []
97
+
98
+ @property
99
+ def project_root(self) -> Path:
100
+ return self._project_root
101
+
102
+ @property
103
+ def entries(self) -> tuple[BackupEntry, ...]:
104
+ return tuple(self._entries)
105
+
106
+ def _ensure_under_root(self, target: Path) -> Path:
107
+ resolved = target.resolve() if target.exists() else (target.parent.resolve() / target.name)
108
+ if not resolved.is_relative_to(self._project_root):
109
+ raise TargetOutsideProjectRootError(target=resolved, project_root=self._project_root)
110
+ return resolved
111
+
112
+ def record_new_file(self, target: Path) -> None:
113
+ resolved = self._ensure_under_root(target)
114
+ self._entries.append(BackupEntry(target=resolved, backup_path=None, was_directory=False))
115
+
116
+ def record_new_directory(self, target: Path) -> None:
117
+ resolved = self._ensure_under_root(target)
118
+ self._entries.append(BackupEntry(target=resolved, backup_path=None, was_directory=True))
119
+
120
+ def record_overwrite(self, target: Path) -> Path:
121
+ """Copy ``target`` into the cache before allowing the overwrite.
122
+
123
+ Raises ``BackupCreationError`` on copy failure — caller must abort
124
+ before any byte hits the target (FR-030 last sentence).
125
+ """
126
+
127
+ resolved = self._ensure_under_root(target)
128
+ token = secrets.token_hex(6)
129
+ relative = resolved.relative_to(self._project_root)
130
+ backup_path = self._project_root / _BACKUP_SUBDIR / token / relative
131
+ try:
132
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
133
+ shutil.copy2(resolved, backup_path)
134
+ except OSError as exc:
135
+ raise BackupCreationError(target=resolved, reason=str(exc)) from exc
136
+ self._entries.append(
137
+ BackupEntry(target=resolved, backup_path=backup_path, was_directory=False)
138
+ )
139
+ return backup_path
140
+
141
+ def commit(self) -> None:
142
+ """Success path — delete every backup file and prune empty parents."""
143
+
144
+ for entry in self._entries:
145
+ if entry.backup_path is not None:
146
+ with contextlib.suppress(OSError):
147
+ entry.backup_path.unlink()
148
+ # Prune the per-invocation backup root if it ended up empty.
149
+ backup_root = self._project_root / _BACKUP_SUBDIR
150
+ if backup_root.exists():
151
+ with contextlib.suppress(OSError):
152
+ shutil.rmtree(backup_root)
153
+
154
+ def rollback(self) -> None:
155
+ """Failure path — walk in reverse, restore overwrites, unlink new entries."""
156
+
157
+ for entry in reversed(self._entries):
158
+ if entry.backup_path is not None:
159
+ with contextlib.suppress(OSError):
160
+ shutil.move(str(entry.backup_path), str(entry.target))
161
+ continue
162
+ if entry.was_directory:
163
+ if entry.target.exists():
164
+ with contextlib.suppress(OSError):
165
+ shutil.rmtree(entry.target)
166
+ continue
167
+ if entry.target.exists():
168
+ with contextlib.suppress(OSError):
169
+ entry.target.unlink()
170
+ # Always try to clear the backup cache directory itself.
171
+ backup_root = self._project_root / _BACKUP_SUBDIR
172
+ if backup_root.exists():
173
+ with contextlib.suppress(OSError):
174
+ shutil.rmtree(backup_root)
175
+
176
+
177
+ def _register_target(target: Path, ledger: FileLedger) -> None:
178
+ """Record ``target`` with the ledger: new file or overwrite."""
179
+
180
+ if target.exists():
181
+ ledger.record_overwrite(target)
182
+ else:
183
+ ledger.record_new_file(target)
184
+
185
+
186
+ def write_bytes_atomic(target: Path, payload: bytes, ledger: FileLedger) -> None:
187
+ """Atomic file write via ``tempfile.mkstemp`` + ``os.fsync`` + ``os.replace``.
188
+
189
+ Registers the target with the ledger BEFORE any byte hits disk
190
+ (so a copy failure during overwrite-backup aborts cleanly).
191
+ """
192
+
193
+ _register_target(target, ledger)
194
+ target.parent.mkdir(parents=True, exist_ok=True)
195
+ tmp_fd, tmp_path = tempfile.mkstemp(
196
+ dir=str(target.parent),
197
+ prefix=f".{target.name}.",
198
+ suffix=".tmp",
199
+ )
200
+ try:
201
+ with os.fdopen(tmp_fd, "wb") as handle:
202
+ handle.write(payload)
203
+ handle.flush()
204
+ os.fsync(handle.fileno())
205
+ os.replace(tmp_path, target)
206
+ except BaseException:
207
+ with contextlib.suppress(OSError):
208
+ os.unlink(tmp_path)
209
+ raise
210
+
211
+
212
+ def mkdir_tracked(target: Path, ledger: FileLedger) -> None:
213
+ """``mkdir(parents=True, exist_ok=True)`` while recording the new directory."""
214
+
215
+ if target.exists():
216
+ return
217
+ # Walk up to find the first non-existent parent — every newly created
218
+ # directory must be registered so rollback can prune them in reverse.
219
+ to_create: list[Path] = []
220
+ cursor = target
221
+ while not cursor.exists():
222
+ to_create.append(cursor)
223
+ cursor = cursor.parent
224
+ for path in reversed(to_create):
225
+ ledger.record_new_directory(path)
226
+ path.mkdir(exist_ok=True)
@@ -0,0 +1,15 @@
1
+ """Manuscript presence check (FR-012).
2
+
3
+ v0 does **no** prose mining — extraction is bible-frontmatter-driven. This module
4
+ only confirms the manuscript directory exists so ``graph build`` can fail fast on
5
+ a malformed project layout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+
13
+ def manuscript_present(manuscript_dir: Path) -> bool:
14
+ """Return whether the manuscript directory exists."""
15
+ return manuscript_dir.is_dir()
@@ -0,0 +1,21 @@
1
+ """Locate the project root by walking up for ``manifest.toml`` (R8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import ProjectNotFoundError
8
+
9
+ MANIFEST_NAME = "manifest.toml"
10
+
11
+
12
+ def find_project_root(start: Path | None = None) -> Path:
13
+ """Return the nearest ancestor of ``start`` (default cwd) holding ``manifest.toml``.
14
+
15
+ Raises :class:`ProjectNotFoundError` when no ancestor contains one (R8).
16
+ """
17
+ origin = (start or Path.cwd()).resolve()
18
+ for candidate in (origin, *origin.parents):
19
+ if (candidate / MANIFEST_NAME).is_file():
20
+ return candidate
21
+ raise ProjectNotFoundError(str(origin))
@@ -0,0 +1,107 @@
1
+ """The ``graph build`` report models (data-model § 5).
2
+
3
+ ``BuildReport`` and its three soft-warning collections are frozen Pydantic
4
+ models. ``skipped`` drives the exit code (≥ 1 skip → exit 4); ``unknown_keys``
5
+ and ``unresolved_participants`` are soft warnings that never change it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import BaseModel, ConfigDict
13
+
14
+ EXIT_OK = 0
15
+ EXIT_SKIPPED = 4
16
+
17
+
18
+ class SkippedFile(BaseModel):
19
+ """A source file skipped because its frontmatter was unusable (FR-013)."""
20
+
21
+ model_config = ConfigDict(frozen=True, extra="forbid")
22
+
23
+ path: str
24
+ reason: str
25
+
26
+
27
+ class UnknownKey(BaseModel):
28
+ """A frontmatter key not recognised for its concept — recorded, not fatal."""
29
+
30
+ model_config = ConfigDict(frozen=True, extra="forbid")
31
+
32
+ path: str
33
+ key: str
34
+
35
+
36
+ class UnresolvedParticipant(BaseModel):
37
+ """A ``participants:`` reference matching no built character (FR-019).
38
+
39
+ The owning event/relationship is still constructed; only that participation
40
+ edge is omitted.
41
+ """
42
+
43
+ model_config = ConfigDict(frozen=True, extra="forbid")
44
+
45
+ path: str
46
+ entity: str
47
+ name: str
48
+
49
+
50
+ class ResearchTargetWarning(BaseModel):
51
+ """A research ``bears_on``/``constrains`` target that did not resolve (D12).
52
+
53
+ The link triple was omitted and the build still succeeds (exit code unchanged) —
54
+ existence/kind enforcement is the iteration-15 ``factual_anchor`` validator's job.
55
+ """
56
+
57
+ model_config = ConfigDict(frozen=True, extra="forbid")
58
+
59
+ path: str
60
+ field: str
61
+ name: str
62
+
63
+
64
+ class BuildReport(BaseModel):
65
+ """The full outcome of a ``graph build`` (data-model § 5)."""
66
+
67
+ model_config = ConfigDict(frozen=True, extra="forbid")
68
+
69
+ files_processed: int
70
+ entities: int
71
+ triples: int
72
+ graph_path: str
73
+ skipped: tuple[SkippedFile, ...] = ()
74
+ unknown_keys: tuple[UnknownKey, ...] = ()
75
+ unresolved_participants: tuple[UnresolvedParticipant, ...] = ()
76
+ # Optional research metrics (iteration 012). Absent/zero on a research-free build
77
+ # so existing build/`--json` output is byte-stable (research D8). Research warnings
78
+ # never change the exit code (D12).
79
+ sources: int = 0
80
+ findings: int = 0
81
+ anchors: int = 0
82
+ research_warnings: tuple[ResearchTargetWarning, ...] = ()
83
+
84
+ @property
85
+ def exit_code(self) -> int:
86
+ """Exit 4 when any file was skipped, else exit 0 (R7).
87
+
88
+ Research warnings (D12) are deliberately **not** part of the exit code.
89
+ """
90
+ return EXIT_SKIPPED if self.skipped else EXIT_OK
91
+
92
+ def to_json(self) -> dict[str, Any]:
93
+ """The contract success envelope (cli-graph.md): ``status:"ok"`` + metrics."""
94
+ return {
95
+ "status": "ok",
96
+ "files_processed": self.files_processed,
97
+ "entities": self.entities,
98
+ "triples": self.triples,
99
+ "skipped": [s.model_dump() for s in self.skipped],
100
+ "unknown_keys": [u.model_dump() for u in self.unknown_keys],
101
+ "unresolved_participants": [u.model_dump() for u in self.unresolved_participants],
102
+ "sources": self.sources,
103
+ "findings": self.findings,
104
+ "anchors": self.anchors,
105
+ "research_warnings": [w.model_dump() for w in self.research_warnings],
106
+ "graph_path": self.graph_path,
107
+ }