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.
Files changed (76) hide show
  1. tracecite/__init__.py +35 -0
  2. tracecite/__main__.py +3 -0
  3. tracecite/cli.py +656 -0
  4. tracecite/docs/__init__.py +56 -0
  5. tracecite/docs/build.py +381 -0
  6. tracecite/docs/config.py +114 -0
  7. tracecite/docs/contract.py +17 -0
  8. tracecite/docs/modes.py +240 -0
  9. tracecite/docs/stage.py +407 -0
  10. tracecite/docs/vectorize.py +457 -0
  11. tracecite/evidence/__init__.py +3 -0
  12. tracecite/evidence/chunking.py +214 -0
  13. tracecite/evidence/commands.py +552 -0
  14. tracecite/evidence/config.py +49 -0
  15. tracecite/evidence/embedding.py +59 -0
  16. tracecite/evidence/manifest.py +197 -0
  17. tracecite/evidence/page_extraction.py +200 -0
  18. tracecite/evidence/page_output.py +80 -0
  19. tracecite/evidence/page_selection.py +116 -0
  20. tracecite/evidence/parsers/__init__.py +1 -0
  21. tracecite/evidence/parsers/base.py +65 -0
  22. tracecite/evidence/parsers/markdown.py +142 -0
  23. tracecite/evidence/parsers/pdf.py +315 -0
  24. tracecite/evidence/parsers/workbook.py +334 -0
  25. tracecite/evidence/paths.py +94 -0
  26. tracecite/evidence/schema.py +570 -0
  27. tracecite/evidence/source_links.py +185 -0
  28. tracecite/evidence/sync.py +1434 -0
  29. tracecite/evidence/vector_backend.py +130 -0
  30. tracecite/evidence/verify.py +368 -0
  31. tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
  32. tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
  33. tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
  34. tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
  35. tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
  36. tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
  37. tracecite/skills/using-tracecite/SKILL.md +97 -0
  38. tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
  39. tracecite/skills/using-tracecite/references/architecture.md +68 -0
  40. tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
  41. tracecite/skills/using-tracecite/references/retrieval.md +45 -0
  42. tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
  43. tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
  44. tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
  45. tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
  46. tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
  47. tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
  48. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
  49. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
  50. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
  51. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
  52. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
  53. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
  54. tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
  55. tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
  56. tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
  57. tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
  58. tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
  59. tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
  60. tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
  61. tracecite/tables/__init__.py +41 -0
  62. tracecite/tables/_text.py +142 -0
  63. tracecite/tables/document.py +582 -0
  64. tracecite/tables/html.py +299 -0
  65. tracecite/tables/models.py +119 -0
  66. tracecite/tables/normalise.py +786 -0
  67. tracecite/tables/pandoc.py +503 -0
  68. tracecite/tables/publish.py +238 -0
  69. tracecite/tables/render.py +128 -0
  70. tracecite/tables/site.py +457 -0
  71. tracecite-0.0.2.dist-info/METADATA +320 -0
  72. tracecite-0.0.2.dist-info/RECORD +76 -0
  73. tracecite-0.0.2.dist-info/WHEEL +5 -0
  74. tracecite-0.0.2.dist-info/entry_points.txt +2 -0
  75. tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
  76. tracecite-0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,334 @@
1
+ """OOXML workbook parser with worksheet- and A1-range-aware locators.
2
+
3
+ The parser reads ``.xlsx`` and ``.xlsm`` packages directly with the Python
4
+ standard library. It does not execute macros, refresh external connections,
5
+ or recalculate formulae. Formula cells retain both the stored formula and any
6
+ cached value present in the workbook package.
7
+
8
+ Each non-empty worksheet row becomes one or more chunk-input units. Style-only
9
+ empty cells are ignored, which is important for workbooks whose declared used
10
+ range extends to Excel's maximum row or column even though only a small subset
11
+ contains values.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import posixpath
18
+ import re
19
+ import xml.etree.ElementTree as ET
20
+ import zipfile
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ from .base import ParsedChunkUnit, ParsedPage, ParseResult
25
+
26
+ NAME = "workbook-ooxml"
27
+ DEFAULT_MAX_UNIT_CHARS = 1000
28
+ DEFAULT_MAX_FORMULA_CHARS = 240
29
+
30
+ _MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
31
+ _OFFICE_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
32
+ _PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
33
+ _WORKSHEET_REL_SUFFIX = "/worksheet"
34
+ _CELL_REF_RE = re.compile(r"^([A-Z]+)([0-9]+)$")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _Sheet:
39
+ index: int
40
+ name: str
41
+ state: str
42
+ part: str
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class _Cell:
47
+ reference: str
48
+ rendered: str
49
+
50
+
51
+ def _tag(name: str) -> str:
52
+ return f"{{{_MAIN_NS}}}{name}"
53
+
54
+
55
+ def _normalise_part(base_part: str, target: str) -> str:
56
+ target = target.replace("\\", "/")
57
+ if target.startswith("/"):
58
+ resolved = posixpath.normpath(target.lstrip("/"))
59
+ else:
60
+ resolved = posixpath.normpath(
61
+ posixpath.join(posixpath.dirname(base_part), target)
62
+ )
63
+ if resolved == ".." or resolved.startswith("../"):
64
+ raise ValueError(f"workbook relationship escapes the OOXML package: {target}")
65
+ return resolved
66
+
67
+
68
+ def _shared_strings(archive: zipfile.ZipFile) -> list[str]:
69
+ part = "xl/sharedStrings.xml"
70
+ if part not in archive.namelist():
71
+ return []
72
+ strings: list[str] = []
73
+ with archive.open(part) as handle:
74
+ for _event, element in ET.iterparse(handle, events=("end",)):
75
+ if element.tag != _tag("si"):
76
+ continue
77
+ strings.append("".join(node.text or "" for node in element.iter(_tag("t"))))
78
+ element.clear()
79
+ return strings
80
+
81
+
82
+ def _sheets(archive: zipfile.ZipFile) -> list[_Sheet]:
83
+ workbook_part = "xl/workbook.xml"
84
+ relationships_part = "xl/_rels/workbook.xml.rels"
85
+ workbook = ET.fromstring(archive.read(workbook_part))
86
+ relationships = ET.fromstring(archive.read(relationships_part))
87
+ targets: dict[str, tuple[str, str | None]] = {}
88
+ for relationship in relationships.findall(f"{{{_PACKAGE_REL_NS}}}Relationship"):
89
+ targets[relationship.attrib["Id"]] = (
90
+ relationship.attrib["Target"],
91
+ relationship.attrib.get("Type"),
92
+ )
93
+
94
+ sheets: list[_Sheet] = []
95
+ for index, sheet in enumerate(workbook.findall(f".//{{{_MAIN_NS}}}sheet"), start=1):
96
+ relationship_id = sheet.attrib[f"{{{_OFFICE_REL_NS}}}id"]
97
+ target, relationship_type = targets[relationship_id]
98
+ if relationship_type and not relationship_type.endswith(_WORKSHEET_REL_SUFFIX):
99
+ continue
100
+ sheets.append(
101
+ _Sheet(
102
+ index=index,
103
+ name=sheet.attrib["name"],
104
+ state=sheet.attrib.get("state", "visible"),
105
+ part=_normalise_part(workbook_part, target),
106
+ )
107
+ )
108
+ return sheets
109
+
110
+
111
+ def _inline_string(cell: ET.Element) -> str:
112
+ inline = cell.find(_tag("is"))
113
+ if inline is None:
114
+ return ""
115
+ return "".join(node.text or "" for node in inline.iter(_tag("t")))
116
+
117
+
118
+ def _stored_value(cell: ET.Element, shared_strings: list[str]) -> tuple[str, bool]:
119
+ cell_type = cell.attrib.get("t")
120
+ value_element = cell.find(_tag("v"))
121
+ raw = (
122
+ value_element.text
123
+ if value_element is not None and value_element.text is not None
124
+ else ""
125
+ )
126
+
127
+ if cell_type == "inlineStr":
128
+ value = _inline_string(cell)
129
+ return value, True
130
+ if cell_type == "s":
131
+ if raw == "":
132
+ return "", True
133
+ try:
134
+ return shared_strings[int(raw)], True
135
+ except (ValueError, IndexError) as exc:
136
+ raise ValueError(f"invalid shared-string index: {raw}") from exc
137
+ if cell_type == "b":
138
+ return ("TRUE" if raw == "1" else "FALSE"), False
139
+ if cell_type == "e":
140
+ return f"#ERROR {raw}", True
141
+ if cell_type in {"str", "d"}:
142
+ return raw, True
143
+ return raw, False
144
+
145
+
146
+ def _render_cell(
147
+ cell: ET.Element,
148
+ shared_strings: list[str],
149
+ *,
150
+ max_formula_chars: int,
151
+ ) -> _Cell | None:
152
+ reference = cell.attrib.get("r")
153
+ if not reference or _CELL_REF_RE.fullmatch(reference) is None:
154
+ return None
155
+
156
+ formula_element = cell.find(_tag("f"))
157
+ formula = formula_element.text if formula_element is not None else None
158
+ value, is_text = _stored_value(cell, shared_strings)
159
+ if formula is None and value == "":
160
+ return None
161
+
162
+ if formula is not None:
163
+ original_formula_length = len(formula)
164
+ if original_formula_length > max_formula_chars:
165
+ formula = (
166
+ formula[:max_formula_chars]
167
+ + f"… [truncated; {original_formula_length} characters]"
168
+ )
169
+ rendered_formula = json.dumps(f"={formula}", ensure_ascii=False)
170
+ if value == "":
171
+ rendered = f"FORMULA {rendered_formula}"
172
+ else:
173
+ cached = json.dumps(value, ensure_ascii=False) if is_text else value
174
+ rendered = f"FORMULA {rendered_formula}; cached = {cached}"
175
+ else:
176
+ rendered = json.dumps(value, ensure_ascii=False) if is_text else value
177
+ return _Cell(reference=reference, rendered=rendered)
178
+
179
+
180
+ def _row_number(row: ET.Element, cells: list[_Cell], fallback: int) -> int:
181
+ declared = row.attrib.get("r")
182
+ if declared and declared.isdigit():
183
+ return int(declared)
184
+ if cells:
185
+ match = _CELL_REF_RE.fullmatch(cells[0].reference)
186
+ if match is not None:
187
+ return int(match.group(2))
188
+ return fallback
189
+
190
+
191
+ def _unit_text(sheet: _Sheet, row_number: int, cells: list[_Cell]) -> str:
192
+ cell_range = (
193
+ cells[0].reference
194
+ if len(cells) == 1
195
+ else f"{cells[0].reference}:{cells[-1].reference}"
196
+ )
197
+ heading = f"Worksheet {json.dumps(sheet.name, ensure_ascii=False)}, row {row_number}, cells {cell_range}"
198
+ values = "\n".join(f"{cell.reference} = {cell.rendered}" for cell in cells)
199
+ return f"{heading}\n{values}"
200
+
201
+
202
+ def _split_row(
203
+ sheet: _Sheet, row_number: int, cells: list[_Cell], max_unit_chars: int
204
+ ) -> list[list[_Cell]]:
205
+ parts: list[list[_Cell]] = []
206
+ current: list[_Cell] = []
207
+ for cell in cells:
208
+ candidate = current + [cell]
209
+ if current and len(_unit_text(sheet, row_number, candidate)) > max_unit_chars:
210
+ parts.append(current)
211
+ current = [cell]
212
+ else:
213
+ current = candidate
214
+ if current:
215
+ parts.append(current)
216
+ return parts
217
+
218
+
219
+ def _sheet_units(
220
+ archive: zipfile.ZipFile,
221
+ sheet: _Sheet,
222
+ shared_strings: list[str],
223
+ *,
224
+ max_unit_chars: int,
225
+ max_formula_chars: int,
226
+ ) -> list[ParsedChunkUnit]:
227
+ units: list[ParsedChunkUnit] = []
228
+ fallback_row = 0
229
+ with archive.open(sheet.part) as handle:
230
+ for _event, row in ET.iterparse(handle, events=("end",)):
231
+ if row.tag != _tag("row"):
232
+ continue
233
+ fallback_row += 1
234
+ cells = [
235
+ parsed
236
+ for cell in row.findall(_tag("c"))
237
+ if (
238
+ parsed := _render_cell(
239
+ cell,
240
+ shared_strings,
241
+ max_formula_chars=max_formula_chars,
242
+ )
243
+ )
244
+ is not None
245
+ ]
246
+ if cells:
247
+ row_number = _row_number(row, cells, fallback_row)
248
+ for part_index, part in enumerate(
249
+ _split_row(sheet, row_number, cells, max_unit_chars), start=1
250
+ ):
251
+ start_cell = part[0].reference
252
+ end_cell = part[-1].reference
253
+ cell_range = (
254
+ start_cell
255
+ if start_cell == end_cell
256
+ else f"{start_cell}:{end_cell}"
257
+ )
258
+ units.append(
259
+ ParsedChunkUnit(
260
+ text=_unit_text(sheet, row_number, part),
261
+ logical_key=(
262
+ f"sheet:{sheet.index:04d}:row:{row_number:07d}:part:{part_index:04d}"
263
+ ),
264
+ heading_path=[sheet.name],
265
+ symbol=None,
266
+ content_type="workbook-row",
267
+ physical_page=None,
268
+ locator={
269
+ "kind": "excel-range",
270
+ "sheet": sheet.name,
271
+ "sheet_index": sheet.index,
272
+ "sheet_state": sheet.state,
273
+ "range": cell_range,
274
+ "start_cell": start_cell,
275
+ "end_cell": end_cell,
276
+ "row_start": row_number,
277
+ "row_end": row_number,
278
+ },
279
+ )
280
+ )
281
+ row.clear()
282
+ return units
283
+
284
+
285
+ def parse(path: Path, config: dict | None = None) -> ParseResult:
286
+ config = config or {}
287
+ max_unit_chars = int(config.get("max_unit_chars", DEFAULT_MAX_UNIT_CHARS))
288
+ max_formula_chars = int(config.get("max_formula_chars", DEFAULT_MAX_FORMULA_CHARS))
289
+ if max_unit_chars <= 0:
290
+ raise ValueError("max_unit_chars must be positive")
291
+ if max_formula_chars <= 0:
292
+ raise ValueError("max_formula_chars must be positive")
293
+
294
+ pages: list[ParsedPage] = []
295
+ units: list[ParsedChunkUnit] = []
296
+ with zipfile.ZipFile(path) as archive:
297
+ shared_strings = _shared_strings(archive)
298
+ for sheet in _sheets(archive):
299
+ sheet_units = _sheet_units(
300
+ archive,
301
+ sheet,
302
+ shared_strings,
303
+ max_unit_chars=max_unit_chars,
304
+ max_formula_chars=max_formula_chars,
305
+ )
306
+ units.extend(sheet_units)
307
+ layout_units = [
308
+ {
309
+ "text": unit.text,
310
+ "logical_key": unit.logical_key,
311
+ "heading_path": unit.heading_path,
312
+ "content_type": unit.content_type,
313
+ "locator": unit.locator,
314
+ }
315
+ for unit in sheet_units
316
+ ]
317
+ pages.append(
318
+ ParsedPage(
319
+ physical_page=sheet.index,
320
+ printed_label=sheet.name,
321
+ text="\n\n".join(unit.text for unit in sheet_units),
322
+ extraction_method=NAME,
323
+ extraction_status="ok" if sheet_units else "empty",
324
+ section_candidates=[sheet.name],
325
+ layout={
326
+ "kind": "workbook-sheet",
327
+ "sheet": sheet.name,
328
+ "sheet_index": sheet.index,
329
+ "sheet_state": sheet.state,
330
+ "units": layout_units,
331
+ },
332
+ )
333
+ )
334
+ return ParseResult(pages=pages, units=units)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import posixpath
5
+ from pathlib import Path
6
+
7
+
8
+ class PathAuthorityError(ValueError):
9
+ pass
10
+
11
+
12
+ def _lexical_posix(value: str | Path) -> str:
13
+ return str(value).replace("\\", "/")
14
+
15
+
16
+ def file_sha256(path: Path) -> str:
17
+ with Path(path).open("rb") as handle:
18
+ return hashlib.file_digest(handle, "sha256").hexdigest()
19
+
20
+
21
+ def normalise_relative_declaration(value: str | Path) -> str:
22
+ """Normalise a manifest path or glob to a root-relative POSIX form."""
23
+ text = _lexical_posix(value)
24
+ if text.startswith("/") or (len(text) >= 3 and text[1:3] == ":/"):
25
+ raise PathAuthorityError(f"absolute source path is not allowed: {value}")
26
+ parts: list[str] = []
27
+ for part in text.split("/"):
28
+ if part in ("", "."):
29
+ continue
30
+ if part == "..":
31
+ raise PathAuthorityError(f"source path may not escape root: {value}")
32
+ parts.append(part)
33
+ if not parts:
34
+ raise PathAuthorityError("source path is empty")
35
+ return "/".join(parts)
36
+
37
+
38
+ def normalise_source_path(
39
+ root: Path, value: str | Path, *, base: Path | None = None
40
+ ) -> str:
41
+ root = Path(root).resolve()
42
+ text = _lexical_posix(value)
43
+ if text == "":
44
+ raise PathAuthorityError("source path is empty")
45
+
46
+ raw_path = Path(text)
47
+ if raw_path.is_absolute():
48
+ candidate = raw_path
49
+ else:
50
+ parts = [part for part in text.split("/") if part not in ("", ".")]
51
+ depth = 0
52
+ for part in parts:
53
+ if part == "..":
54
+ depth -= 1
55
+ else:
56
+ depth += 1
57
+ if depth < 0 and base is None:
58
+ raise PathAuthorityError(f"source path may not escape root: {value}")
59
+ candidate = (Path(base).resolve() if base is not None else root) / Path(*parts)
60
+
61
+ resolved_parent = candidate.parent.resolve()
62
+ resolved = (
63
+ (resolved_parent / candidate.name).resolve()
64
+ if candidate.exists()
65
+ else resolved_parent / candidate.name
66
+ )
67
+ try:
68
+ relative = resolved.relative_to(root)
69
+ except ValueError as exc:
70
+ message = (
71
+ "symlink resolves outside root"
72
+ if candidate.is_symlink()
73
+ else "source path is outside root"
74
+ )
75
+ raise PathAuthorityError(f"{message}: {value}") from exc
76
+ if candidate.is_symlink() and candidate.resolve() != resolved:
77
+ raise PathAuthorityError(f"symlink resolves outside root: {value}")
78
+ normalised = relative.as_posix()
79
+ if normalised == "." or normalised.startswith("../"):
80
+ raise PathAuthorityError(f"source path may not escape root: {value}")
81
+ return normalised
82
+
83
+
84
+ def source_row_for_path(conn, path: str):
85
+ normalised = _lexical_posix(path)
86
+ row = conn.execute("SELECT * FROM sources WHERE path = ?", (normalised,)).fetchone()
87
+ if row is not None:
88
+ return row
89
+ collapsed = posixpath.normpath(normalised)
90
+ if collapsed != normalised:
91
+ return conn.execute(
92
+ "SELECT * FROM sources WHERE path = ?", (collapsed,)
93
+ ).fetchone()
94
+ return None