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,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
|