tracecite 0.0.2__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.
- tracecite/__init__.py +35 -0
- tracecite/__main__.py +3 -0
- tracecite/cli.py +656 -0
- tracecite/docs/__init__.py +56 -0
- tracecite/docs/build.py +381 -0
- tracecite/docs/config.py +114 -0
- tracecite/docs/contract.py +17 -0
- tracecite/docs/modes.py +240 -0
- tracecite/docs/stage.py +407 -0
- tracecite/docs/vectorize.py +457 -0
- tracecite/evidence/__init__.py +3 -0
- tracecite/evidence/chunking.py +214 -0
- tracecite/evidence/commands.py +552 -0
- tracecite/evidence/config.py +49 -0
- tracecite/evidence/embedding.py +59 -0
- tracecite/evidence/manifest.py +197 -0
- tracecite/evidence/page_extraction.py +200 -0
- tracecite/evidence/page_output.py +80 -0
- tracecite/evidence/page_selection.py +116 -0
- tracecite/evidence/parsers/__init__.py +1 -0
- tracecite/evidence/parsers/base.py +65 -0
- tracecite/evidence/parsers/markdown.py +142 -0
- tracecite/evidence/parsers/pdf.py +315 -0
- tracecite/evidence/parsers/workbook.py +334 -0
- tracecite/evidence/paths.py +94 -0
- tracecite/evidence/schema.py +570 -0
- tracecite/evidence/source_links.py +185 -0
- tracecite/evidence/sync.py +1434 -0
- tracecite/evidence/vector_backend.py +130 -0
- tracecite/evidence/verify.py +368 -0
- tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
- tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
- tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
- tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
- tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
- tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
- tracecite/skills/using-tracecite/SKILL.md +97 -0
- tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
- tracecite/skills/using-tracecite/references/architecture.md +68 -0
- tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
- tracecite/skills/using-tracecite/references/retrieval.md +45 -0
- tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
- tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
- tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
- tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
- tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
- tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
- tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
- tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
- tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
- tracecite/tables/__init__.py +41 -0
- tracecite/tables/_text.py +142 -0
- tracecite/tables/document.py +582 -0
- tracecite/tables/html.py +299 -0
- tracecite/tables/models.py +119 -0
- tracecite/tables/normalise.py +786 -0
- tracecite/tables/pandoc.py +503 -0
- tracecite/tables/publish.py +238 -0
- tracecite/tables/render.py +128 -0
- tracecite/tables/site.py +457 -0
- tracecite-0.0.2.dist-info/METADATA +320 -0
- tracecite-0.0.2.dist-info/RECORD +76 -0
- tracecite-0.0.2.dist-info/WHEEL +5 -0
- tracecite-0.0.2.dist-info/entry_points.txt +2 -0
- tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
- tracecite-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Reusable documentation build and evidence-contract APIs."""
|
|
2
|
+
|
|
3
|
+
from .build import (
|
|
4
|
+
BuildSelection,
|
|
5
|
+
BuildVariant,
|
|
6
|
+
DocsBuildResult,
|
|
7
|
+
Language,
|
|
8
|
+
build_docs,
|
|
9
|
+
changed_retained_markdown,
|
|
10
|
+
classify_render_inputs,
|
|
11
|
+
discover_render_inputs,
|
|
12
|
+
select_build_variant,
|
|
13
|
+
snapshot_retained_markdown,
|
|
14
|
+
stage_retained_markdown,
|
|
15
|
+
)
|
|
16
|
+
from .config import load_docs_contract
|
|
17
|
+
from .contract import DocsEvidenceContract
|
|
18
|
+
from .modes import DocsModeResult, author_docs, check_docs
|
|
19
|
+
from .stage import DocsStageResult, stage_docs
|
|
20
|
+
from .vectorize import (
|
|
21
|
+
DocsIndexProfile,
|
|
22
|
+
DocsIndexResult,
|
|
23
|
+
doctor_docs_index,
|
|
24
|
+
prepare_docs_index_input,
|
|
25
|
+
resolve_docs_index_profile,
|
|
26
|
+
search_docs_index,
|
|
27
|
+
sync_docs_index,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BuildSelection",
|
|
32
|
+
"BuildVariant",
|
|
33
|
+
"DocsBuildResult",
|
|
34
|
+
"DocsEvidenceContract",
|
|
35
|
+
"DocsIndexProfile",
|
|
36
|
+
"DocsIndexResult",
|
|
37
|
+
"DocsModeResult",
|
|
38
|
+
"DocsStageResult",
|
|
39
|
+
"Language",
|
|
40
|
+
"author_docs",
|
|
41
|
+
"build_docs",
|
|
42
|
+
"changed_retained_markdown",
|
|
43
|
+
"check_docs",
|
|
44
|
+
"classify_render_inputs",
|
|
45
|
+
"discover_render_inputs",
|
|
46
|
+
"doctor_docs_index",
|
|
47
|
+
"load_docs_contract",
|
|
48
|
+
"prepare_docs_index_input",
|
|
49
|
+
"resolve_docs_index_profile",
|
|
50
|
+
"search_docs_index",
|
|
51
|
+
"select_build_variant",
|
|
52
|
+
"snapshot_retained_markdown",
|
|
53
|
+
"stage_docs",
|
|
54
|
+
"stage_retained_markdown",
|
|
55
|
+
"sync_docs_index",
|
|
56
|
+
]
|
tracecite/docs/build.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Reusable Quarto documentation-build orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from ..tables import SiteExportResult, export_embedding_site
|
|
17
|
+
|
|
18
|
+
Language = Literal["python", "julia"]
|
|
19
|
+
BuildVariant = Literal["combined", "python", "julia", "prose"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class BuildSelection:
|
|
24
|
+
variant: BuildVariant
|
|
25
|
+
profile: str | None
|
|
26
|
+
included: tuple[Path, ...]
|
|
27
|
+
skipped: tuple[Path, ...]
|
|
28
|
+
warning: str | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class DocsBuildResult:
|
|
33
|
+
project_root: Path
|
|
34
|
+
output_root: Path
|
|
35
|
+
selection: BuildSelection
|
|
36
|
+
retained_count: int
|
|
37
|
+
inspection: SiteExportResult | None
|
|
38
|
+
changed_retained: tuple[Path, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_config(project_root: Path) -> dict:
|
|
42
|
+
path = project_root / "_quarto.yml"
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
raise ValueError(f"Quarto project configuration does not exist: {path}")
|
|
45
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
46
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def discover_render_inputs(project_root: Path) -> tuple[Path, ...]:
|
|
50
|
+
"""Expand the base project's ordered ``project.render`` entries."""
|
|
51
|
+
project_root = Path(project_root).resolve()
|
|
52
|
+
config = _load_config(project_root)
|
|
53
|
+
render = (config.get("project") or {}).get("render", [])
|
|
54
|
+
if isinstance(render, str):
|
|
55
|
+
render = [render]
|
|
56
|
+
found: list[Path] = []
|
|
57
|
+
for pattern in render:
|
|
58
|
+
if not isinstance(pattern, str) or pattern.startswith("!"):
|
|
59
|
+
continue
|
|
60
|
+
matches = sorted(project_root.glob(pattern))
|
|
61
|
+
for match in matches:
|
|
62
|
+
if (
|
|
63
|
+
match.is_file()
|
|
64
|
+
and not match.name.endswith(".html.md")
|
|
65
|
+
and match not in found
|
|
66
|
+
):
|
|
67
|
+
found.append(match)
|
|
68
|
+
return tuple(found)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _qmd_language(path: Path) -> Language | None:
|
|
72
|
+
text = path.read_text(encoding="utf-8")
|
|
73
|
+
if not text.startswith("---"):
|
|
74
|
+
return None
|
|
75
|
+
end = text.find("\n---", 3)
|
|
76
|
+
if end < 0:
|
|
77
|
+
return None
|
|
78
|
+
metadata = yaml.safe_load(text[3:end]) or {}
|
|
79
|
+
engine = str(metadata.get("jupyter", metadata.get("engine", ""))).lower()
|
|
80
|
+
if "julia" in engine:
|
|
81
|
+
return "julia"
|
|
82
|
+
if engine in {"python", "python3", "jupyter"} or "python" in engine:
|
|
83
|
+
return "python"
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def classify_render_inputs(paths: Sequence[Path]) -> dict[Language, tuple[Path, ...]]:
|
|
88
|
+
result: dict[Language, list[Path]] = {"python": [], "julia": []}
|
|
89
|
+
for path in paths:
|
|
90
|
+
suffix = path.suffix.lower()
|
|
91
|
+
language = {".py": "python", ".jl": "julia"}.get(suffix)
|
|
92
|
+
if suffix == ".qmd":
|
|
93
|
+
language = _qmd_language(path)
|
|
94
|
+
if language:
|
|
95
|
+
result[language].append(path)
|
|
96
|
+
return {key: tuple(value) for key, value in result.items()}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_input_path(path: Path, project_root: Path) -> Path:
|
|
100
|
+
"""Resolve an input path when it stays inside the project root."""
|
|
101
|
+
candidate = path if path.is_absolute() else project_root / path
|
|
102
|
+
resolved = candidate.resolve()
|
|
103
|
+
try:
|
|
104
|
+
resolved.relative_to(project_root)
|
|
105
|
+
except ValueError:
|
|
106
|
+
return candidate
|
|
107
|
+
return resolved
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def select_build_variant(
|
|
111
|
+
project_root: Path,
|
|
112
|
+
inputs: Sequence[Path],
|
|
113
|
+
*,
|
|
114
|
+
only: Language | None = None,
|
|
115
|
+
julia: str | None = None,
|
|
116
|
+
) -> BuildSelection:
|
|
117
|
+
project_root = Path(project_root).resolve()
|
|
118
|
+
inputs = tuple(_normalize_input_path(Path(path), project_root) for path in inputs)
|
|
119
|
+
classified = classify_render_inputs(inputs)
|
|
120
|
+
available = {language for language, paths in classified.items() if paths}
|
|
121
|
+
if only and not classified[only]:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Requested language is absent from configured render inputs: {only}"
|
|
124
|
+
)
|
|
125
|
+
if only == "julia" and not julia:
|
|
126
|
+
raise ValueError("Julia-only documentation build requires Julia on PATH")
|
|
127
|
+
if only and len(available) > 1:
|
|
128
|
+
overlay = project_root / f"_quarto-{only}.yml"
|
|
129
|
+
if not overlay.is_file():
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"Explicit {only} build requires reduced overlay: {overlay}"
|
|
132
|
+
)
|
|
133
|
+
if only:
|
|
134
|
+
skipped = tuple(
|
|
135
|
+
path
|
|
136
|
+
for language, paths in classified.items()
|
|
137
|
+
if language != only
|
|
138
|
+
for path in paths
|
|
139
|
+
)
|
|
140
|
+
profile = only if len(available) > 1 else None
|
|
141
|
+
return BuildSelection(
|
|
142
|
+
only,
|
|
143
|
+
profile,
|
|
144
|
+
tuple(path for path in inputs if path not in skipped),
|
|
145
|
+
skipped,
|
|
146
|
+
None,
|
|
147
|
+
)
|
|
148
|
+
if available == {"python", "julia"}:
|
|
149
|
+
if julia:
|
|
150
|
+
return BuildSelection("combined", None, tuple(inputs), (), None)
|
|
151
|
+
overlay = project_root / "_quarto-python.yml"
|
|
152
|
+
if not overlay.is_file():
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Automatic Python fallback requires reduced overlay: {overlay}"
|
|
155
|
+
)
|
|
156
|
+
skipped = classified["julia"]
|
|
157
|
+
warning = "Julia unavailable; skipping configured files:\n" + "\n".join(
|
|
158
|
+
f"- {path.relative_to(project_root)}" for path in skipped
|
|
159
|
+
)
|
|
160
|
+
included = tuple(path for path in inputs if path not in skipped)
|
|
161
|
+
return BuildSelection("python", "python", included, skipped, warning)
|
|
162
|
+
if available == {"julia"}:
|
|
163
|
+
if julia:
|
|
164
|
+
return BuildSelection("julia", None, tuple(inputs), (), None)
|
|
165
|
+
raise ValueError("Configured Julia documentation inputs require Julia on PATH")
|
|
166
|
+
if available == {"python"}:
|
|
167
|
+
return BuildSelection("python", None, tuple(inputs), (), None)
|
|
168
|
+
return BuildSelection("prose", None, tuple(inputs), (), None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def snapshot_retained_markdown(project_root: Path) -> dict[Path, bytes]:
|
|
172
|
+
project_root = Path(project_root).resolve()
|
|
173
|
+
output = _output_root(project_root)
|
|
174
|
+
return {
|
|
175
|
+
path.relative_to(project_root): path.read_bytes()
|
|
176
|
+
for path in project_root.rglob("*.html.md")
|
|
177
|
+
if not path.is_relative_to(output) and ".quarto" not in path.parts
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def changed_retained_markdown(
|
|
182
|
+
before: Mapping[Path, bytes],
|
|
183
|
+
project_root: Path,
|
|
184
|
+
) -> tuple[Path, ...]:
|
|
185
|
+
after = snapshot_retained_markdown(project_root)
|
|
186
|
+
return tuple(
|
|
187
|
+
sorted(
|
|
188
|
+
path
|
|
189
|
+
for path in before.keys() | after.keys()
|
|
190
|
+
if before.get(path) != after.get(path)
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def stage_retained_markdown(
|
|
196
|
+
project_root: Path,
|
|
197
|
+
output_root: Path,
|
|
198
|
+
inputs: Sequence[Path],
|
|
199
|
+
) -> int:
|
|
200
|
+
project_root = Path(project_root).resolve()
|
|
201
|
+
output_root = Path(output_root).resolve()
|
|
202
|
+
for stale in output_root.rglob("*.html.md") if output_root.exists() else ():
|
|
203
|
+
stale.unlink()
|
|
204
|
+
count = 0
|
|
205
|
+
for authored in inputs:
|
|
206
|
+
retained = _retained_destination(authored)
|
|
207
|
+
if not retained.is_file():
|
|
208
|
+
continue
|
|
209
|
+
relative = authored.relative_to(project_root)
|
|
210
|
+
destination = output_root / relative.with_name(relative.stem + ".html.md")
|
|
211
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
shutil.copy2(retained, destination)
|
|
213
|
+
count += 1
|
|
214
|
+
return count
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _output_root(project_root: Path) -> Path:
|
|
218
|
+
project = _load_config(project_root).get("project") or {}
|
|
219
|
+
return project_root / project.get("output-dir", "_site")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _contains_source_symlinks(project_root: Path, output_root: Path) -> bool:
|
|
223
|
+
"""Return whether a Quarto source tree contains symlinked inputs/resources."""
|
|
224
|
+
project_root = project_root.resolve()
|
|
225
|
+
output_root = output_root.resolve()
|
|
226
|
+
for path in project_root.rglob("*"):
|
|
227
|
+
if path.is_relative_to(output_root) or ".quarto" in path.parts:
|
|
228
|
+
continue
|
|
229
|
+
if path.is_symlink():
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _copy_project_without_build_outputs(
|
|
235
|
+
project_root: Path,
|
|
236
|
+
staged_root: Path,
|
|
237
|
+
output_root: Path,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Materialise a symlinked Quarto project as ordinary files for rendering."""
|
|
240
|
+
try:
|
|
241
|
+
output_relative = output_root.relative_to(project_root)
|
|
242
|
+
except ValueError as error:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"Symlinked Quarto projects require project.output-dir to remain "
|
|
245
|
+
f"inside the project root: {output_root}"
|
|
246
|
+
) from error
|
|
247
|
+
if not output_root.resolve().is_relative_to(project_root.resolve()):
|
|
248
|
+
raise ValueError(
|
|
249
|
+
"Symlinked Quarto projects require project.output-dir to remain "
|
|
250
|
+
f"inside the project root: {output_root}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
254
|
+
relative = Path(directory).relative_to(project_root)
|
|
255
|
+
ignored: set[str] = set()
|
|
256
|
+
for name in names:
|
|
257
|
+
candidate = relative / name
|
|
258
|
+
if candidate == output_relative or name == ".quarto":
|
|
259
|
+
ignored.add(name)
|
|
260
|
+
return ignored
|
|
261
|
+
|
|
262
|
+
shutil.copytree(
|
|
263
|
+
project_root,
|
|
264
|
+
staged_root,
|
|
265
|
+
symlinks=False,
|
|
266
|
+
ignore=ignore,
|
|
267
|
+
dirs_exist_ok=True,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _retained_destination(authored: Path) -> Path:
|
|
272
|
+
"""Return the canonical retained-Markdown destination for an authored input."""
|
|
273
|
+
canonical = authored.resolve() if authored.is_symlink() else authored
|
|
274
|
+
return canonical.with_name(canonical.stem + ".html.md")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _publish_materialised_render(
|
|
278
|
+
staged_root: Path,
|
|
279
|
+
project_root: Path,
|
|
280
|
+
inputs: Sequence[Path],
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Publish a successful staged render and its retained Markdown."""
|
|
283
|
+
staged_output = _output_root(staged_root)
|
|
284
|
+
output_root = _output_root(project_root)
|
|
285
|
+
if not staged_output.is_dir():
|
|
286
|
+
raise RuntimeError(
|
|
287
|
+
f"Quarto did not create the configured output: {staged_output}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
for authored in inputs:
|
|
291
|
+
relative = authored.relative_to(project_root)
|
|
292
|
+
staged_authored = staged_root / relative
|
|
293
|
+
staged_retained = staged_authored.with_name(staged_authored.stem + ".html.md")
|
|
294
|
+
if not staged_retained.is_file():
|
|
295
|
+
continue
|
|
296
|
+
destination = _retained_destination(authored)
|
|
297
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
shutil.copy2(staged_retained, destination)
|
|
299
|
+
|
|
300
|
+
if output_root.exists() or output_root.is_symlink():
|
|
301
|
+
if output_root.is_symlink() or output_root.is_file():
|
|
302
|
+
output_root.unlink()
|
|
303
|
+
else:
|
|
304
|
+
shutil.rmtree(output_root)
|
|
305
|
+
shutil.copytree(staged_output, output_root, symlinks=False)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@contextmanager
|
|
309
|
+
def _render_project_root(
|
|
310
|
+
project_root: Path,
|
|
311
|
+
output_root: Path,
|
|
312
|
+
) -> Iterator[tuple[Path, bool]]:
|
|
313
|
+
"""Yield a Quarto-safe project root, materialising symlinks when required."""
|
|
314
|
+
if not _contains_source_symlinks(project_root, output_root):
|
|
315
|
+
yield project_root, False
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
staged_root = Path(
|
|
319
|
+
tempfile.mkdtemp(
|
|
320
|
+
prefix=f".{project_root.name}-render-",
|
|
321
|
+
dir=project_root.parent,
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
try:
|
|
325
|
+
_copy_project_without_build_outputs(project_root, staged_root, output_root)
|
|
326
|
+
yield staged_root, True
|
|
327
|
+
finally:
|
|
328
|
+
shutil.rmtree(staged_root, ignore_errors=True)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_docs(
|
|
332
|
+
project_root: str | Path,
|
|
333
|
+
*,
|
|
334
|
+
only: Language | None = None,
|
|
335
|
+
quarto: str | Path | None = None,
|
|
336
|
+
inspection: bool = True,
|
|
337
|
+
render_inspection: bool = True,
|
|
338
|
+
strict_tables: bool = False,
|
|
339
|
+
check_retained: bool = False,
|
|
340
|
+
) -> DocsBuildResult:
|
|
341
|
+
project_root = Path(project_root).resolve()
|
|
342
|
+
quarto_command = str(quarto) if quarto else shutil.which("quarto")
|
|
343
|
+
if not quarto_command:
|
|
344
|
+
raise FileNotFoundError("Quarto is required. Install Quarto or pass --quarto.")
|
|
345
|
+
inputs = discover_render_inputs(project_root)
|
|
346
|
+
julia = shutil.which("julia")
|
|
347
|
+
selection = select_build_variant(project_root, inputs, only=only, julia=julia)
|
|
348
|
+
if selection.warning:
|
|
349
|
+
print(selection.warning)
|
|
350
|
+
before = snapshot_retained_markdown(project_root) if check_retained else {}
|
|
351
|
+
output_root = _output_root(project_root)
|
|
352
|
+
with _render_project_root(project_root, output_root) as (render_root, staged):
|
|
353
|
+
command = [quarto_command, "render", render_root.name]
|
|
354
|
+
if selection.profile:
|
|
355
|
+
command.extend(["--profile", selection.profile])
|
|
356
|
+
subprocess.run(command, cwd=render_root.parent, check=True)
|
|
357
|
+
if staged:
|
|
358
|
+
_publish_materialised_render(
|
|
359
|
+
render_root,
|
|
360
|
+
project_root,
|
|
361
|
+
selection.included,
|
|
362
|
+
)
|
|
363
|
+
retained_count = stage_retained_markdown(
|
|
364
|
+
project_root, output_root, selection.included
|
|
365
|
+
)
|
|
366
|
+
exported = None
|
|
367
|
+
if inspection:
|
|
368
|
+
exported = export_embedding_site(
|
|
369
|
+
output_root,
|
|
370
|
+
project_root.parent / ".tracecite" / "embedding-site",
|
|
371
|
+
project_config=project_root / "_quarto.yml",
|
|
372
|
+
project_profile=selection.profile,
|
|
373
|
+
source_project=project_root,
|
|
374
|
+
strict=strict_tables,
|
|
375
|
+
render=render_inspection,
|
|
376
|
+
quarto=quarto_command,
|
|
377
|
+
)
|
|
378
|
+
changed = changed_retained_markdown(before, project_root) if check_retained else ()
|
|
379
|
+
return DocsBuildResult(
|
|
380
|
+
project_root, output_root, selection, retained_count, exported, changed
|
|
381
|
+
)
|
tracecite/docs/config.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""TOML loading and deterministic validation for the docs contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .contract import DocsEvidenceContract
|
|
9
|
+
|
|
10
|
+
_FIELDS = {
|
|
11
|
+
"authored_root",
|
|
12
|
+
"retained_root",
|
|
13
|
+
"staged_root",
|
|
14
|
+
"source_links",
|
|
15
|
+
"index_output",
|
|
16
|
+
"publication_exclude",
|
|
17
|
+
"host_render_command",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _path(value: object, *, name: str, repo_root: Path) -> Path:
|
|
22
|
+
if not isinstance(value, str) or not value:
|
|
23
|
+
raise ValueError(f"{name} must be a non-empty repository-relative path")
|
|
24
|
+
raw = Path(value)
|
|
25
|
+
if raw.is_absolute():
|
|
26
|
+
raise ValueError(f"{name} must be repository-relative")
|
|
27
|
+
if ".." in raw.parts:
|
|
28
|
+
raise ValueError(f"{name} must not escape the repository")
|
|
29
|
+
resolved = (repo_root / raw).resolve(strict=False)
|
|
30
|
+
if not resolved.is_relative_to(repo_root):
|
|
31
|
+
raise ValueError(f"{name} resolves outside the repository")
|
|
32
|
+
return resolved
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _overlap(left: Path, right: Path) -> bool:
|
|
36
|
+
return left.is_relative_to(right) or right.is_relative_to(left)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_docs_contract(
|
|
40
|
+
path: str | Path, *, repo_root: str | Path
|
|
41
|
+
) -> DocsEvidenceContract:
|
|
42
|
+
config_path = Path(path).resolve()
|
|
43
|
+
root = Path(repo_root).resolve()
|
|
44
|
+
try:
|
|
45
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
46
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
47
|
+
raise ValueError(f"invalid docs configuration: {exc}") from exc
|
|
48
|
+
if data.get("schema_version") != 1:
|
|
49
|
+
raise ValueError("docs configuration requires schema_version = 1")
|
|
50
|
+
if set(data) - {"schema_version", "docs"} or not isinstance(data.get("docs"), dict):
|
|
51
|
+
raise ValueError("docs configuration requires a [docs] table")
|
|
52
|
+
docs = data["docs"]
|
|
53
|
+
unknown = set(docs) - _FIELDS
|
|
54
|
+
if unknown:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"unknown docs configuration field(s): {', '.join(sorted(unknown))}"
|
|
57
|
+
)
|
|
58
|
+
missing = _FIELDS - set(docs) - {"host_render_command"}
|
|
59
|
+
if missing:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"missing docs configuration field(s): {', '.join(sorted(missing))}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
authored = _path(docs.get("authored_root"), name="authored_root", repo_root=root)
|
|
65
|
+
retained = _path(docs.get("retained_root"), name="retained_root", repo_root=root)
|
|
66
|
+
staged = _path(docs.get("staged_root"), name="staged_root", repo_root=root)
|
|
67
|
+
source_links = _path(docs.get("source_links"), name="source_links", repo_root=root)
|
|
68
|
+
index_output = _path(docs.get("index_output"), name="index_output", repo_root=root)
|
|
69
|
+
if not authored.is_dir() or not retained.is_dir():
|
|
70
|
+
raise ValueError("authored_root and retained_root must be existing directories")
|
|
71
|
+
if not source_links.is_file():
|
|
72
|
+
raise ValueError("source_links must be an existing file")
|
|
73
|
+
if _overlap(retained, staged):
|
|
74
|
+
raise ValueError("retained_root and staged_root must not overlap")
|
|
75
|
+
roots = (authored, retained, staged, source_links, index_output)
|
|
76
|
+
for index, left in enumerate(roots):
|
|
77
|
+
for right in roots[index + 1 :]:
|
|
78
|
+
if (left == authored and right == retained) or (
|
|
79
|
+
left == retained and right == authored
|
|
80
|
+
):
|
|
81
|
+
if not _overlap(left, right) or left == right:
|
|
82
|
+
continue
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"authored_root and retained_root may overlap only when equal"
|
|
85
|
+
)
|
|
86
|
+
if _overlap(left, right):
|
|
87
|
+
raise ValueError("docs contract paths must not contain one another")
|
|
88
|
+
|
|
89
|
+
excludes = docs.get("publication_exclude")
|
|
90
|
+
if not isinstance(excludes, list):
|
|
91
|
+
raise ValueError("publication_exclude must be an array") # noqa: TRY004
|
|
92
|
+
publication_exclude = tuple(
|
|
93
|
+
_path(value, name="publication_exclude", repo_root=root) for value in excludes
|
|
94
|
+
)
|
|
95
|
+
command = docs.get("host_render_command")
|
|
96
|
+
if command is not None:
|
|
97
|
+
if (
|
|
98
|
+
not isinstance(command, list)
|
|
99
|
+
or not command
|
|
100
|
+
or not all(isinstance(item, str) and item for item in command)
|
|
101
|
+
):
|
|
102
|
+
raise ValueError("host_render_command must be a non-empty argument array")
|
|
103
|
+
command_tuple = tuple(command)
|
|
104
|
+
else:
|
|
105
|
+
command_tuple = None
|
|
106
|
+
return DocsEvidenceContract(
|
|
107
|
+
authored,
|
|
108
|
+
retained,
|
|
109
|
+
staged,
|
|
110
|
+
source_links,
|
|
111
|
+
index_output,
|
|
112
|
+
publication_exclude,
|
|
113
|
+
command_tuple,
|
|
114
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Validated, repository-neutral documentation evidence contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class DocsEvidenceContract:
|
|
11
|
+
authored_root: Path
|
|
12
|
+
retained_root: Path
|
|
13
|
+
staged_root: Path
|
|
14
|
+
source_links: Path
|
|
15
|
+
index_output: Path
|
|
16
|
+
publication_exclude: tuple[Path, ...]
|
|
17
|
+
host_render_command: tuple[str, ...] | None = None
|