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,120 @@
1
+ """``bookwright integration use <key>`` — switch the project's agent integration.
2
+
3
+ Re-materializes one ``SKILL.md`` per source command under the chosen integration's
4
+ skills directory (reusing the shared materializer + the plugin registry —
5
+ Principles V/VI/VII), updates the manifest's ``[integration]`` block, and leaves
6
+ any previously-materialized skills directory **untouched** (the swap-residue
7
+ policy: no cleanup in v0). The operation is atomic — a materialization or lint
8
+ failure rolls the whole change back through a ``BackupLedger``.
9
+
10
+ This is the supported integration-swap mechanism. ``init`` deliberately refuses to
11
+ re-initialize an existing project (the ``.bookwright/`` guard), so switching the
12
+ *integration* of a live book is its own intention-revealing command rather than a
13
+ re-init flag. Principle IX: under ``--json`` exactly one JSON document on stdout;
14
+ all human prose goes to stderr.
15
+
16
+ Fault model: missing project / unparseable manifest / unknown integration key →
17
+ exit 2; a skill materialization or lint failure → exit 3 (fully rolled back).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sys
24
+ from typing import Any
25
+
26
+ import typer
27
+ from rich.console import Console
28
+
29
+ from bookwright.core.errors import ManifestError
30
+ from bookwright.core.manifest import Manifest
31
+ from bookwright.integrations import UnknownIntegrationError, get
32
+ from bookwright.integrations.errors import SkillLintError, SkillMaterializationError
33
+ from bookwright.io.errors import ProjectNotFoundError
34
+ from bookwright.io.fs import BackupLedger
35
+ from bookwright.io.project import find_project_root
36
+
37
+ from .._envelope import invalid_manifest_payload
38
+ from . import app
39
+
40
+ EXIT_CONFIG = 2
41
+ EXIT_MATERIALIZE = 3
42
+
43
+
44
+ @app.command("use")
45
+ def run(
46
+ key: str = typer.Argument(..., help="Integration key to switch to (e.g. 'claude', 'generic')."),
47
+ json_output: bool = typer.Option(
48
+ False, "--json", help="Emit the result as one JSON document on stdout."
49
+ ),
50
+ ) -> None:
51
+ """Switch the project to integration ``key`` and re-materialize its skills."""
52
+ try:
53
+ payload = _use(key)
54
+ except ProjectNotFoundError as exc:
55
+ _emit_error(exc.to_json(), json_output)
56
+ raise typer.Exit(EXIT_CONFIG) from exc
57
+ except ManifestError as exc:
58
+ _emit_error(invalid_manifest_payload(exc), json_output)
59
+ raise typer.Exit(EXIT_CONFIG) from exc
60
+ except UnknownIntegrationError as exc:
61
+ _emit_error(exc.to_json(), json_output)
62
+ raise typer.Exit(EXIT_CONFIG) from exc
63
+ except (SkillLintError, SkillMaterializationError) as exc:
64
+ _emit_error(exc.to_json(), json_output)
65
+ raise typer.Exit(EXIT_MATERIALIZE) from exc
66
+
67
+ if json_output:
68
+ _emit_json(payload)
69
+ else:
70
+ Console(stderr=True, highlight=False).print(
71
+ f"bookwright: switched integration to '{payload['integration']}' "
72
+ f"({payload['count']} skills → {payload['skills_dir']})"
73
+ )
74
+
75
+
76
+ def _use(key: str) -> dict[str, Any]:
77
+ """Materialize the new integration, update the manifest, and return the report.
78
+
79
+ Resolves the integration *before* any filesystem mutation (an unknown key is a
80
+ no-op exit-2). All writes — the skills and the manifest overwrite — go through a
81
+ single ``BackupLedger`` so a failure leaves the project byte-identical.
82
+ """
83
+ root = find_project_root()
84
+ manifest_path = root / "manifest.toml"
85
+ manifest = Manifest.load(manifest_path)
86
+
87
+ integration = get(key)() # raises UnknownIntegrationError on an unknown key
88
+ skills_dir = integration.resolve_skills_dir().as_posix()
89
+
90
+ ledger = BackupLedger(root)
91
+ try:
92
+ integration.setup(root, manifest, ledger=ledger)
93
+ ledger.record_overwrite(manifest_path)
94
+ manifest.set_integration(key=key, skills_dir=skills_dir)
95
+ manifest.dump(manifest_path, overwrite=True)
96
+ except BaseException:
97
+ ledger.rollback()
98
+ raise
99
+ ledger.commit()
100
+
101
+ materialized = sorted(p.parent.name for p in (root / skills_dir).rglob("SKILL.md"))
102
+ return {
103
+ "status": "ok",
104
+ "integration": key,
105
+ "skills_dir": skills_dir,
106
+ "materialized": materialized,
107
+ "count": len(materialized),
108
+ }
109
+
110
+
111
+ def _emit_json(payload: dict[str, Any]) -> None:
112
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
113
+
114
+
115
+ def _emit_error(payload: dict[str, Any], json_output: bool) -> None:
116
+ if json_output:
117
+ _emit_json(payload)
118
+ else:
119
+ message = payload.get("message", payload.get("code", "error"))
120
+ sys.stderr.write(f"bookwright: error: {message}\n")
@@ -0,0 +1,160 @@
1
+ """``bookwright validate`` — run the active validators and report violations.
2
+
3
+ Binding: contracts/cli-validate.md, FR-008..014, Principle IX. The CI gate (exit 1)
4
+ is computed from the **unfiltered** error-severity set, so ``--scope`` / ``--severity``
5
+ (display filters) can never hide an error. Under ``--json`` exactly one JSON document
6
+ goes to stdout; all prose goes to stderr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Annotated, Any
15
+
16
+ import typer
17
+ from rich.console import Console
18
+
19
+ from bookwright.core.errors import ManifestError
20
+ from bookwright.core.manifest import Manifest
21
+ from bookwright.errors import BookwrightError
22
+ from bookwright.indexers import GraphLoadError, UnknownIndexerError, resolve_indexer
23
+ from bookwright.io.errors import ProjectNotFoundError
24
+ from bookwright.io.project import find_project_root
25
+ from bookwright.validation import (
26
+ ScopeFilter,
27
+ Severity,
28
+ UnknownValidatorError,
29
+ ValidationContext,
30
+ ValidationReport,
31
+ discover_validators,
32
+ resolve_active,
33
+ run_validators,
34
+ )
35
+
36
+ from ._envelope import INVALID_MANIFEST_CODE
37
+
38
+ EXIT_OK = 0
39
+ EXIT_GATE = 1
40
+ EXIT_CONFIG = 2
41
+
42
+ _CUSTOM_SUBPATH = (".bookwright", "validators")
43
+
44
+
45
+ class _UsageError(BookwrightError):
46
+ """An exit-2 config/usage failure carrying a contract error envelope.
47
+
48
+ A single class whose ``code`` is set per instance (``no_project`` /
49
+ ``invalid_manifest`` / ``unknown_validator`` / ``empty_scope``) — hence the
50
+ per-instance ``self.code`` override the base supports (research Decision 2).
51
+ """
52
+
53
+ def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
54
+ self.code = code
55
+ super().__init__(message, details)
56
+
57
+
58
+ def run(
59
+ scope: Annotated[
60
+ Path | None,
61
+ typer.Option("--scope", help="Limit the reported violations to this file or directory."),
62
+ ] = None,
63
+ severity: Annotated[
64
+ Severity,
65
+ typer.Option("--severity", help="Report this level and above (error>warning>info)."),
66
+ ] = Severity.info,
67
+ json_output: Annotated[
68
+ bool, typer.Option("--json", help="Emit one JSON document on stdout and nothing else.")
69
+ ] = False,
70
+ ) -> None:
71
+ """Validate the project and report coherence violations (exit 1 gates on errors)."""
72
+ try:
73
+ report, scope_filter = _validate(scope)
74
+ except _UsageError as exc:
75
+ _emit_error(exc.to_json(), json_output)
76
+ raise typer.Exit(EXIT_CONFIG) from exc
77
+
78
+ if json_output:
79
+ _emit_json(report.to_json(scope=scope_filter, severity=severity))
80
+ else:
81
+ report.render(Console(), scope=scope_filter, severity=severity)
82
+
83
+ raise typer.Exit(EXIT_GATE if report.failed else EXIT_OK)
84
+
85
+
86
+ def _validate(scope: Path | None) -> tuple[ValidationReport, ScopeFilter | None]:
87
+ """Run the pipeline, raising :class:`_UsageError` for every exit-2 condition."""
88
+ try:
89
+ root = find_project_root()
90
+ except ProjectNotFoundError as exc:
91
+ raise _UsageError("no_project", str(exc), {"start": exc.start}) from exc
92
+
93
+ try:
94
+ manifest = Manifest.load(root / "manifest.toml")
95
+ except ManifestError as exc:
96
+ raise _UsageError(INVALID_MANIFEST_CODE, str(exc)) from exc
97
+
98
+ indexer = _load_indexer(manifest, root)
99
+ project = ValidationContext(root=root, manifest=manifest)
100
+
101
+ builtins, customs, load_errors = discover_validators(root.joinpath(*_CUSTOM_SUBPATH))
102
+ try:
103
+ active = resolve_active(builtins, customs, manifest.validators)
104
+ except UnknownValidatorError as exc:
105
+ # UnknownValidatorError is itself a BookwrightError carrying the exact
106
+ # ``unknown_validator`` envelope; re-thread its code/message/details into
107
+ # the exit-2 funnel rather than rebuilding them by hand.
108
+ raise _UsageError(exc.code, exc.message, exc.details) from exc
109
+
110
+ scope_filter = _resolve_scope(scope, root)
111
+
112
+ violations, run_errors, ran = run_validators(active, project, indexer)
113
+ report = ValidationReport(
114
+ violations=tuple(violations),
115
+ errors=(*load_errors, *run_errors),
116
+ ran=tuple(ran),
117
+ )
118
+ return report, scope_filter
119
+
120
+
121
+ def _load_indexer(manifest: Manifest, root: Path) -> Any:
122
+ """The manifest-selected engine, with ``graph.ttl`` loaded when it exists."""
123
+ try:
124
+ engine_cls = resolve_indexer(manifest.bookwright.indexer)
125
+ except UnknownIndexerError as exc:
126
+ raise _UsageError("invalid_manifest", str(exc)) from exc
127
+ engine = engine_cls()
128
+ graph_path = root / manifest.paths.graph
129
+ if graph_path.is_file():
130
+ try:
131
+ engine.load(graph_path)
132
+ except GraphLoadError as exc:
133
+ raise _UsageError("invalid_manifest", str(exc)) from exc
134
+ return engine
135
+
136
+
137
+ def _resolve_scope(scope: Path | None, root: Path) -> ScopeFilter | None:
138
+ """Resolve ``--scope`` under the project root, or exit 2 ``empty_scope`` (D10)."""
139
+ if scope is None:
140
+ return None
141
+ resolved = scope if scope.is_absolute() else (Path.cwd() / scope)
142
+ resolved = resolved.resolve()
143
+ if not resolved.exists():
144
+ raise _UsageError("empty_scope", f"scope path does not exist: {scope}")
145
+ try:
146
+ rel = resolved.relative_to(root).as_posix()
147
+ except ValueError as exc:
148
+ raise _UsageError("empty_scope", f"scope path is outside the project: {scope}") from exc
149
+ return ScopeFilter(rel=rel, is_dir=resolved.is_dir())
150
+
151
+
152
+ def _emit_json(payload: dict[str, Any]) -> None:
153
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
154
+
155
+
156
+ def _emit_error(payload: dict[str, Any], json_output: bool) -> None:
157
+ if json_output:
158
+ _emit_json(payload)
159
+ else:
160
+ sys.stderr.write(f"bookwright: error: {payload['message']}\n")
@@ -0,0 +1,35 @@
1
+ """`bookwright version` — report the package and GOLEM schema versions."""
2
+
3
+ import json
4
+ import sys
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from bookwright import __version__
10
+ from bookwright.resources.schemas import load_schema_version
11
+
12
+
13
+ def _read_golem_schema_version() -> str:
14
+ try:
15
+ return load_schema_version()
16
+ except FileNotFoundError:
17
+ return "unknown"
18
+
19
+
20
+ def run(
21
+ json_output: bool = typer.Option(
22
+ False, "--json", help="Emit a single JSON document on stdout."
23
+ ),
24
+ ) -> None:
25
+ """Print the bookwright package version and the bundled GOLEM schema version."""
26
+ payload = {
27
+ "package_version": __version__,
28
+ "golem_schema_version": _read_golem_schema_version(),
29
+ }
30
+ if json_output:
31
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
32
+ return
33
+ console = Console()
34
+ console.print(f"bookwright {payload['package_version']}")
35
+ console.print(f"GOLEM schema: {payload['golem_schema_version']}")
@@ -0,0 +1,35 @@
1
+ """Public API for the Bookwright core domain model.
2
+
3
+ The members re-exported below are the stable iteration-2 contract; anything
4
+ else is implementation detail. See specs/002-manifest-model/contracts/manifest_api.md.
5
+ """
6
+
7
+ from bookwright.core._research_block import ResearchBlock
8
+ from bookwright.core.errors import (
9
+ ManifestError,
10
+ ManifestNotFoundError,
11
+ ManifestOverwriteError,
12
+ ManifestSyntaxError,
13
+ ManifestValidationError,
14
+ ManifestWarning,
15
+ )
16
+ from bookwright.core.manifest import (
17
+ BOOK_STATUSES,
18
+ BOOK_TYPES,
19
+ KNOWN_MANIFEST_VERSIONS,
20
+ Manifest,
21
+ )
22
+
23
+ __all__ = [
24
+ "BOOK_STATUSES",
25
+ "BOOK_TYPES",
26
+ "KNOWN_MANIFEST_VERSIONS",
27
+ "Manifest",
28
+ "ManifestError",
29
+ "ManifestNotFoundError",
30
+ "ManifestOverwriteError",
31
+ "ManifestSyntaxError",
32
+ "ManifestValidationError",
33
+ "ManifestWarning",
34
+ "ResearchBlock",
35
+ ]
@@ -0,0 +1,239 @@
1
+ """TOML block models for the Bookwright manifest.
2
+
3
+ Internal helper extracted from `bookwright.core.manifest` to honour the
4
+ Principle IV 500-line ceiling, mirroring the established `_build`,
5
+ `_translate`, and `_research_block` split. The root `Manifest` model and the
6
+ load/dump/build entry points stay in `manifest.py`, which re-exports the
7
+ public members defined here (`BOOK_TYPES`, `BOOK_STATUSES`) so the
8
+ `bookwright.core` surface is unchanged.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from typing import Any, Literal, get_args
15
+ from urllib.parse import urlsplit
16
+
17
+ from packaging.version import InvalidVersion, Version
18
+ from pydantic import (
19
+ BaseModel,
20
+ ConfigDict,
21
+ Field,
22
+ field_validator,
23
+ )
24
+ from pydantic_core import PydanticCustomError
25
+
26
+ from bookwright.core.iso639_1 import ISO_639_1_CODES
27
+
28
+ _MANIFEST_VERSION_RE = re.compile(r"^[1-9][0-9]*$")
29
+
30
+ BookType = Literal["novel", "essay", "memoir", "non-fiction-narrative", "other"]
31
+ BookStatus = Literal["idea", "structuring", "drafting", "revising", "done"]
32
+
33
+ BOOK_TYPES: frozenset[str] = frozenset(get_args(BookType))
34
+ BOOK_STATUSES: frozenset[str] = frozenset(get_args(BookStatus))
35
+
36
+
37
+ def _parse_manifest_version(raw: str) -> int:
38
+ """Parse the `bookwright.manifest_version` string into a positive int.
39
+
40
+ Raises a Pydantic-friendly error when the value does not match the
41
+ `^[1-9][0-9]*$` shape required by FR-013.
42
+ """
43
+
44
+ if not _MANIFEST_VERSION_RE.match(raw):
45
+ raise PydanticCustomError(
46
+ "not_positive_integer_string",
47
+ "manifest_version must match ^[1-9][0-9]*$ (got '{value}')",
48
+ {"value": raw},
49
+ )
50
+ return int(raw)
51
+
52
+
53
+ class BookwrightBlock(BaseModel):
54
+ """`[bookwright]` block — CLI/schema floor and project URI namespace."""
55
+
56
+ model_config = ConfigDict(extra="forbid", strict=True)
57
+
58
+ cli_version_min: str
59
+ schema_version: str
60
+ manifest_version: str
61
+ uri_base: str
62
+ indexer: str = "rdflib"
63
+
64
+ @field_validator("cli_version_min", mode="after")
65
+ @classmethod
66
+ def _check_cli_version_min(cls, value: str) -> str:
67
+ try:
68
+ Version(value)
69
+ except InvalidVersion as exc:
70
+ raise PydanticCustomError(
71
+ "not_pep440",
72
+ "cli_version_min '{value}' is not a valid PEP 440 version",
73
+ {"value": value},
74
+ ) from exc
75
+ return value
76
+
77
+ @field_validator("schema_version", mode="after")
78
+ @classmethod
79
+ def _check_schema_version(cls, value: str) -> str:
80
+ stripped = value.strip()
81
+ if not stripped:
82
+ raise PydanticCustomError(
83
+ "empty",
84
+ "schema_version must be a non-empty string",
85
+ {"value": value},
86
+ )
87
+ if stripped != value:
88
+ raise PydanticCustomError(
89
+ "whitespace",
90
+ "schema_version must not have leading or trailing whitespace",
91
+ {"value": value},
92
+ )
93
+ return value
94
+
95
+ @field_validator("manifest_version", mode="after")
96
+ @classmethod
97
+ def _check_manifest_version(cls, value: str) -> str:
98
+ _parse_manifest_version(value)
99
+ return value
100
+
101
+ @field_validator("uri_base", mode="after")
102
+ @classmethod
103
+ def _check_uri_base(cls, value: str) -> str:
104
+ try:
105
+ parts = urlsplit(value)
106
+ except ValueError as exc:
107
+ raise PydanticCustomError(
108
+ "invalid_uri",
109
+ "uri_base '{value}' is not a parseable URI",
110
+ {"value": value},
111
+ ) from exc
112
+ scheme = parts.scheme.lower()
113
+ if scheme not in {"http", "https"}:
114
+ raise PydanticCustomError(
115
+ "wrong_scheme",
116
+ "uri_base must use http or https (got scheme '{scheme}')",
117
+ {"scheme": parts.scheme, "value": value},
118
+ )
119
+ if not parts.netloc:
120
+ raise PydanticCustomError(
121
+ "empty_host",
122
+ "uri_base must include a non-empty host",
123
+ {"value": value},
124
+ )
125
+ if parts.query:
126
+ raise PydanticCustomError(
127
+ "has_query",
128
+ "uri_base must not include a query string",
129
+ {"value": value},
130
+ )
131
+ if parts.fragment:
132
+ raise PydanticCustomError(
133
+ "has_fragment",
134
+ "uri_base must not include a fragment",
135
+ {"value": value},
136
+ )
137
+ if not value.endswith("/"):
138
+ raise PydanticCustomError(
139
+ "no_trailing_slash",
140
+ "uri_base must end with '/'",
141
+ {"value": value},
142
+ )
143
+ return value
144
+
145
+
146
+ class BookBlock(BaseModel):
147
+ """`[book]` block — author-facing metadata about the work."""
148
+
149
+ model_config = ConfigDict(extra="forbid", strict=True)
150
+
151
+ title: str
152
+ type: BookType
153
+ language: str
154
+ authors: list[str]
155
+ subtitle: str = ""
156
+ genre: list[str] = Field(default_factory=list)
157
+ target_length_words: int | None = None
158
+ status: BookStatus = "drafting"
159
+ metadata: dict[str, Any] = Field(default_factory=dict)
160
+
161
+ @field_validator("title", mode="after")
162
+ @classmethod
163
+ def _check_title(cls, value: str) -> str:
164
+ if not value.strip():
165
+ raise PydanticCustomError(
166
+ "empty",
167
+ "title must be a non-empty string",
168
+ {"value": value},
169
+ )
170
+ return value
171
+
172
+ @field_validator("language", mode="after")
173
+ @classmethod
174
+ def _check_language(cls, value: str) -> str:
175
+ if value not in ISO_639_1_CODES:
176
+ raise PydanticCustomError(
177
+ "not_iso_639_1",
178
+ "language '{value}' is not a valid ISO 639-1 code",
179
+ {"value": value},
180
+ )
181
+ return value
182
+
183
+ @field_validator("authors", mode="after")
184
+ @classmethod
185
+ def _check_authors(cls, value: list[str]) -> list[str]:
186
+ if not value:
187
+ raise PydanticCustomError(
188
+ "empty",
189
+ "authors must contain at least one entry",
190
+ {"value": value},
191
+ )
192
+ for index, entry in enumerate(value):
193
+ if not entry.strip():
194
+ raise PydanticCustomError(
195
+ "entry.empty",
196
+ "authors[{index}] must be a non-empty string",
197
+ {"index": index, "value": entry},
198
+ )
199
+ return value
200
+
201
+
202
+ class VocabulariesBlock(BaseModel):
203
+ """`[vocabularies]` block — names of active vocabulary lists."""
204
+
205
+ model_config = ConfigDict(extra="forbid", strict=True)
206
+
207
+ active: list[str] = Field(default_factory=list)
208
+
209
+
210
+ class ValidatorsBlock(BaseModel):
211
+ """`[validators]` block — built-in and custom validator names."""
212
+
213
+ model_config = ConfigDict(extra="forbid", strict=True)
214
+
215
+ enabled: list[str] = Field(default_factory=list)
216
+ disabled: list[str] = Field(default_factory=list)
217
+ custom: list[str] = Field(default_factory=list)
218
+
219
+
220
+ class IntegrationBlock(BaseModel):
221
+ """`[integration]` block — opaque data the loader never dispatches on."""
222
+
223
+ model_config = ConfigDict(extra="forbid", strict=True)
224
+
225
+ key: str
226
+ skills_dir: str
227
+ options: dict[str, Any] = Field(default_factory=dict)
228
+
229
+
230
+ class PathsBlock(BaseModel):
231
+ """`[paths]` block — project-relative content roots."""
232
+
233
+ model_config = ConfigDict(extra="forbid", strict=True)
234
+
235
+ manuscript: str = "manuscript/"
236
+ bible: str = "bible/"
237
+ outline: str = "outline/"
238
+ graph: str = "bible/graph.ttl"
239
+ constitution: str = "bible/constitution.md"