treestamp 0.1.2__tar.gz → 0.2.0__tar.gz
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.
- {treestamp-0.1.2 → treestamp-0.2.0}/CHANGELOG.md +14 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/PKG-INFO +10 -3
- {treestamp-0.1.2 → treestamp-0.2.0}/README.md +9 -2
- {treestamp-0.1.2 → treestamp-0.2.0}/pyproject.toml +1 -1
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/codec.py +68 -173
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/document.py +42 -16
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_codec.py +122 -36
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_document.py +87 -4
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_editor.py +128 -16
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_output_bytes.py +1 -1
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_tui.py +9 -4
- {treestamp-0.1.2 → treestamp-0.2.0}/.gitignore +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/.pre-commit-config.yaml +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/.pre-commit-hooks.yaml +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/.python-version +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/LICENSE +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/Makefile +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/PRIVACY.md +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/docs/MAINTENANCE.md +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/scripts/changed_tests.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/scripts/check_docs.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/scripts/distribution_check.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/scripts/hook_check.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/scripts/installed_editor_check.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/scripts/release_check.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/__init__.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/__main__.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/cli.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/editor.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/errors.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/gitio.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/maintenance.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/model.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/py.typed +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/session.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/sources.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/tui.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/tui_dialogs.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/__init__.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/conftest.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_changed_tests.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_cli.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_gitio.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_maintenance.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_paths.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_release_check.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_session.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_sources.py +0 -0
- {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_tui_dialogs.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0](https://github.com/reponomics/treestamp/compare/v0.1.2...v0.2.0) (2026-10-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### ⚠ BREAKING CHANGES
|
|
7
|
+
|
|
8
|
+
* Documents with path-only special_paths registrations must be regenerated; documents without special registrations remain supported.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* Replace special-path identity backtracking with direct row-number-plus-path registrations.
|
|
14
|
+
|
|
15
|
+
* fix potential DoS threat and improve docs ([#15](https://github.com/reponomics/treestamp/issues/15)) ([f3c77eb](https://github.com/reponomics/treestamp/commit/f3c77eb03a45d22fa47c9fcd7a8fe02339587dd6))
|
|
16
|
+
|
|
3
17
|
## [0.1.2](https://github.com/reponomics/treestamp/compare/v0.1.1...v0.1.2) (2026-10-28)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: treestamp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Generate and maintain directory trees for project documentation
|
|
5
5
|
Project-URL: Homepage, https://treestamp.reponomics.org
|
|
6
6
|
Project-URL: Documentation, https://github.com/reponomics/treestamp#readme
|
|
@@ -36,6 +36,13 @@ Description-Content-Type: text/markdown
|
|
|
36
36
|
</picture>
|
|
37
37
|
</div>
|
|
38
38
|
|
|
39
|
+
<br>
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
[](https://www.bestpractices.dev/projects/15900)
|
|
43
|
+
[](https://github.com/reponomics/treestamp/actions/workflows/github-code-scanning/codeql)
|
|
44
|
+
|
|
45
|
+
|
|
39
46
|
# treestamp
|
|
40
47
|
|
|
41
48
|
Turn your repository into a directory tree for your README. Choose what to show, add short notes, and copy the result—from your terminal or browser.
|
|
@@ -165,7 +172,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
|
|
|
165
172
|
./
|
|
166
173
|
├── .github/ (8 files, 2 folders)
|
|
167
174
|
├── assets/ (2 files, 1 folder)
|
|
168
|
-
├── docs/ (
|
|
175
|
+
├── docs/ (6 files)
|
|
169
176
|
├── scripts/ (6 files) # Installed-package acceptance
|
|
170
177
|
├── src/
|
|
171
178
|
│ └── treestamp/ # Shared model and terminal editor
|
|
@@ -198,7 +205,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
|
|
|
198
205
|
|
|
199
206
|
## Development and support
|
|
200
207
|
|
|
201
|
-
[Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
|
|
208
|
+
[Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Verifying releases](https://github.com/reponomics/treestamp/blob/main/docs/verifying-releases.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
|
|
202
209
|
|
|
203
210
|
Report bugs through [GitHub issues](https://github.com/reponomics/treestamp/issues), vulnerabilities through [private security reporting](https://github.com/reponomics/treestamp/security/advisories/new), and conduct concerns to [conduct@reponomics.org](mailto:conduct@reponomics.org).
|
|
204
211
|
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
</picture>
|
|
5
5
|
</div>
|
|
6
6
|
|
|
7
|
+
<br>
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
[](https://www.bestpractices.dev/projects/15900)
|
|
11
|
+
[](https://github.com/reponomics/treestamp/actions/workflows/github-code-scanning/codeql)
|
|
12
|
+
|
|
13
|
+
|
|
7
14
|
# treestamp
|
|
8
15
|
|
|
9
16
|
Turn your repository into a directory tree for your README. Choose what to show, add short notes, and copy the result—from your terminal or browser.
|
|
@@ -133,7 +140,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
|
|
|
133
140
|
./
|
|
134
141
|
├── .github/ (8 files, 2 folders)
|
|
135
142
|
├── assets/ (2 files, 1 folder)
|
|
136
|
-
├── docs/ (
|
|
143
|
+
├── docs/ (6 files)
|
|
137
144
|
├── scripts/ (6 files) # Installed-package acceptance
|
|
138
145
|
├── src/
|
|
139
146
|
│ └── treestamp/ # Shared model and terminal editor
|
|
@@ -166,7 +173,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
|
|
|
166
173
|
|
|
167
174
|
## Development and support
|
|
168
175
|
|
|
169
|
-
[Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
|
|
176
|
+
[Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Verifying releases](https://github.com/reponomics/treestamp/blob/main/docs/verifying-releases.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
|
|
170
177
|
|
|
171
178
|
Report bugs through [GitHub issues](https://github.com/reponomics/treestamp/issues), vulnerabilities through [private security reporting](https://github.com/reponomics/treestamp/security/advisories/new), and conduct concerns to [conduct@reponomics.org](mailto:conduct@reponomics.org).
|
|
172
179
|
|
|
@@ -11,7 +11,6 @@ Counts and targets are regenerated facts, not saved curation.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import re
|
|
14
|
-
from contextlib import suppress
|
|
15
14
|
from dataclasses import replace
|
|
16
15
|
from typing import TYPE_CHECKING
|
|
17
16
|
|
|
@@ -31,11 +30,10 @@ from treestamp.model import (
|
|
|
31
30
|
)
|
|
32
31
|
|
|
33
32
|
if TYPE_CHECKING:
|
|
34
|
-
from collections.abc import
|
|
33
|
+
from collections.abc import Mapping
|
|
35
34
|
|
|
36
35
|
SYMLINK_ARROW = "->"
|
|
37
36
|
GITLINK_TAG = "[gitlink]"
|
|
38
|
-
MAX_IDENTITY_BRANCHES = 4096
|
|
39
37
|
|
|
40
38
|
# A row is one line, so control characters print as C escapes, and a byte that
|
|
41
39
|
# is not UTF-8 (a surrogate, from decoding with "surrogateescape") prints as its
|
|
@@ -137,13 +135,13 @@ def _row_head(node: Node, *, markdown: bool = False) -> str:
|
|
|
137
135
|
return text
|
|
138
136
|
|
|
139
137
|
|
|
140
|
-
def special_paths(tree: Node, root: str = ".") ->
|
|
141
|
-
"""
|
|
142
|
-
return
|
|
143
|
-
repo_path(node.path, root)
|
|
144
|
-
for node in tree.walk()
|
|
138
|
+
def special_paths(tree: Node, root: str = ".") -> dict[int, str]:
|
|
139
|
+
"""Map printed row indices to exact repository-relative special identities."""
|
|
140
|
+
return {
|
|
141
|
+
row: repo_path(node.path, root)
|
|
142
|
+
for row, node in enumerate(tree.walk(), 1)
|
|
145
143
|
if ANNOTATION_DELIMITER in node.name
|
|
146
|
-
|
|
144
|
+
}
|
|
147
145
|
|
|
148
146
|
|
|
149
147
|
def _row(node: Node, *, markdown: bool = False) -> str:
|
|
@@ -159,12 +157,22 @@ def render_tree(tree: Node, settings: Settings, *, fenced: bool = False) -> str:
|
|
|
159
157
|
|
|
160
158
|
Bare output is for display. Reading it requires its generated special paths.
|
|
161
159
|
"""
|
|
160
|
+
return render_tree_with_paths(tree, settings, fenced=fenced)[0]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_tree_with_paths(
|
|
164
|
+
tree: Node, settings: Settings, *, fenced: bool = False
|
|
165
|
+
) -> tuple[str, dict[int, str]]:
|
|
166
|
+
"""Emit rows and their special-path registrations in the same traversal."""
|
|
162
167
|
markdown = settings.style == "list"
|
|
163
168
|
validate_tree(tree)
|
|
164
169
|
rows = [_row(tree, markdown=markdown)]
|
|
170
|
+
registered: dict[int, str] = {}
|
|
165
171
|
|
|
166
172
|
def visit(node: Node, prefix: str, depth: int) -> None:
|
|
167
173
|
for index, child in enumerate(node.children):
|
|
174
|
+
if ANNOTATION_DELIMITER in child.name:
|
|
175
|
+
registered[len(rows)] = repo_path(child.path, settings.root)
|
|
168
176
|
last = index == len(node.children) - 1
|
|
169
177
|
if settings.style == "list":
|
|
170
178
|
rows.append(" " * (depth - 1) + "- " + _row(child, markdown=True))
|
|
@@ -178,12 +186,12 @@ def render_tree(tree: Node, settings: Settings, *, fenced: bool = False) -> str:
|
|
|
178
186
|
|
|
179
187
|
visit(tree, "", 1)
|
|
180
188
|
text = "\n".join(rows) + "\n"
|
|
181
|
-
_check_rendered_tree(tree, text, settings)
|
|
189
|
+
_check_rendered_tree(tree, text, settings, registered)
|
|
182
190
|
if fenced and not markdown:
|
|
183
191
|
# These styles prefix every physical row with './' or a connector, so
|
|
184
192
|
# embedded backticks cannot close the fence. New styles own their framing.
|
|
185
|
-
|
|
186
|
-
return text
|
|
193
|
+
text = f"```sh\n{text}```\n"
|
|
194
|
+
return text, registered
|
|
187
195
|
|
|
188
196
|
|
|
189
197
|
def _facts(node: Node) -> tuple[object, ...]:
|
|
@@ -197,13 +205,15 @@ def _facts(node: Node) -> tuple[object, ...]:
|
|
|
197
205
|
)
|
|
198
206
|
|
|
199
207
|
|
|
200
|
-
def _check_rendered_tree(
|
|
208
|
+
def _check_rendered_tree(
|
|
209
|
+
tree: Node, text: str, settings: Settings, registered: Mapping[int, str]
|
|
210
|
+
) -> None:
|
|
201
211
|
try:
|
|
202
212
|
recovered = parse_tree(
|
|
203
213
|
text,
|
|
204
214
|
settings.style,
|
|
205
215
|
root=settings.root,
|
|
206
|
-
special_paths=
|
|
216
|
+
special_paths=registered,
|
|
207
217
|
)
|
|
208
218
|
except MarkerError as exc:
|
|
209
219
|
raise MarkerError(
|
|
@@ -312,37 +322,42 @@ def detect_style(text: str, *, fenced: bool = False) -> str:
|
|
|
312
322
|
return "unicode" if fenced else "list"
|
|
313
323
|
|
|
314
324
|
|
|
315
|
-
def
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
325
|
+
def _registered_row(text: str, parent: str, path: str, *, markdown: bool) -> Node:
|
|
326
|
+
"""Consume one exact registered name; only its suffix remains to parse."""
|
|
327
|
+
directory, _, name = path.rpartition("/")
|
|
328
|
+
if directory != parent:
|
|
329
|
+
raise MarkerError("special-path parent does not match the row's indentation")
|
|
330
|
+
printed = _display(name, markdown)
|
|
331
|
+
if not text.startswith(printed):
|
|
332
|
+
raise MarkerError("special-path name does not match its registered row")
|
|
333
|
+
suffix = text[len(printed) :]
|
|
334
|
+
if suffix and not suffix.startswith(
|
|
335
|
+
("/", " [gitlink]", " -> ", ANNOTATION_DELIMITER)
|
|
336
|
+
):
|
|
337
|
+
raise MarkerError("invalid suffix after registered special-path name")
|
|
338
|
+
node = _parse_row("entry" + suffix, parent, markdown=markdown)
|
|
339
|
+
if node.name != "entry":
|
|
340
|
+
raise MarkerError("invalid suffix after registered special-path name")
|
|
341
|
+
return replace(node, name=name, path=path)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _registrations(
|
|
345
|
+
paths: Mapping[int, str], root: str, row_count: int
|
|
346
|
+
) -> dict[int, str]:
|
|
347
|
+
registered: dict[int, str] = {}
|
|
348
|
+
seen: set[str] = set()
|
|
349
|
+
for row, path in paths.items():
|
|
350
|
+
if type(row) is not int or not 0 < row < row_count:
|
|
351
|
+
raise MarkerError("special-path row must identify an existing non-root row")
|
|
352
|
+
relative = tree_path(path, root)
|
|
353
|
+
if ANNOTATION_DELIMITER not in relative.rsplit("/", 1)[-1]:
|
|
354
|
+
raise MarkerError(
|
|
355
|
+
"special_paths must register a name containing the annotation delimiter"
|
|
356
|
+
)
|
|
357
|
+
if relative in seen:
|
|
358
|
+
raise MarkerError("special_paths contains duplicate paths")
|
|
359
|
+
seen.add(relative)
|
|
360
|
+
registered[row] = relative
|
|
346
361
|
return registered
|
|
347
362
|
|
|
348
363
|
|
|
@@ -355,138 +370,16 @@ def _row_parent(stack: list[Node], depth: int) -> Node:
|
|
|
355
370
|
return parent
|
|
356
371
|
|
|
357
372
|
|
|
358
|
-
def _indexed_rows(lines: list[str], style: str) -> list[tuple[int, str]]:
|
|
359
|
-
"""Resolve indentation independently of the names assigned to each row."""
|
|
360
|
-
rows = []
|
|
361
|
-
stack = [0]
|
|
362
|
-
for index, line in enumerate(lines, 1):
|
|
363
|
-
depth, text = _depth_row(line, style)
|
|
364
|
-
if depth > len(stack):
|
|
365
|
-
raise MarkerError("tree indentation skips a parent")
|
|
366
|
-
stack = stack[:depth]
|
|
367
|
-
rows.append((stack[-1], text))
|
|
368
|
-
stack.append(index)
|
|
369
|
-
return rows
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def _assembled_tree(rows: list[tuple[int, str]], chosen: list[Node]) -> Node:
|
|
373
|
-
nodes = list(chosen)
|
|
374
|
-
children: dict[int, list[int]] = {}
|
|
375
|
-
for index, (parent, _) in enumerate(rows, 1):
|
|
376
|
-
children.setdefault(parent, []).append(index)
|
|
377
|
-
for index in reversed(range(len(nodes))):
|
|
378
|
-
nodes[index] = replace(
|
|
379
|
-
nodes[index],
|
|
380
|
-
children=tuple(nodes[child] for child in children.get(index, [])),
|
|
381
|
-
)
|
|
382
|
-
validate_tree(nodes[0])
|
|
383
|
-
return nodes[0]
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
class _IdentityAssignments:
|
|
387
|
-
"""Bounded backtracking over complete assignments, without recursive depth.
|
|
388
|
-
|
|
389
|
-
A registered prefix may also be an ordinary name plus an annotation. Only
|
|
390
|
-
full registration usage and the complete tree can resolve that ambiguity.
|
|
391
|
-
Ordinary rows add no branches and require no copies of the accumulated tree.
|
|
392
|
-
"""
|
|
393
|
-
|
|
394
|
-
def __init__(
|
|
395
|
-
self,
|
|
396
|
-
root: Node,
|
|
397
|
-
rows: list[tuple[int, str]],
|
|
398
|
-
registered: frozenset[str],
|
|
399
|
-
*,
|
|
400
|
-
markdown: bool,
|
|
401
|
-
) -> None:
|
|
402
|
-
self.rows, self.registered, self.markdown = rows, registered, markdown
|
|
403
|
-
self.chosen = [root]
|
|
404
|
-
self.paths = {""}
|
|
405
|
-
self.branches = 0
|
|
406
|
-
self.by_parent: dict[str, set[str]] = {}
|
|
407
|
-
for path in registered:
|
|
408
|
-
self.by_parent.setdefault(path.rpartition("/")[0], set()).add(path)
|
|
409
|
-
self.choices: list[Iterator[Node]] = [self._choices()] if rows else []
|
|
410
|
-
|
|
411
|
-
def _choices(self) -> Iterator[Node]:
|
|
412
|
-
parent_index, text = self.rows[len(self.chosen) - 1]
|
|
413
|
-
parent = self.chosen[parent_index]
|
|
414
|
-
if not parent.is_dir:
|
|
415
|
-
return iter(())
|
|
416
|
-
registered = frozenset(self.by_parent.get(parent.path, ()))
|
|
417
|
-
candidates = _registered_candidates(
|
|
418
|
-
text, parent.path, registered, markdown=self.markdown
|
|
419
|
-
)
|
|
420
|
-
remaining = len(self.rows) - len(self.chosen)
|
|
421
|
-
candidates = [
|
|
422
|
-
node
|
|
423
|
-
for node in candidates
|
|
424
|
-
if node.path not in self.paths
|
|
425
|
-
and len(self.registered - self.paths - {node.path}) <= remaining
|
|
426
|
-
]
|
|
427
|
-
if len(candidates) > 1:
|
|
428
|
-
self.branches += 1
|
|
429
|
-
if self.branches > MAX_IDENTITY_BRANCHES:
|
|
430
|
-
raise MarkerError(
|
|
431
|
-
"special-path identity resolution exceeds the search limit"
|
|
432
|
-
)
|
|
433
|
-
return iter(candidates)
|
|
434
|
-
|
|
435
|
-
def _pop(self) -> None:
|
|
436
|
-
self.paths.remove(self.chosen.pop().path)
|
|
437
|
-
|
|
438
|
-
def _advance(self) -> bool:
|
|
439
|
-
while self.choices:
|
|
440
|
-
node = next(self.choices[-1], None)
|
|
441
|
-
if node is not None:
|
|
442
|
-
self.chosen.append(node)
|
|
443
|
-
self.paths.add(node.path)
|
|
444
|
-
return True
|
|
445
|
-
self.choices.pop()
|
|
446
|
-
if len(self.chosen) > 1:
|
|
447
|
-
self._pop()
|
|
448
|
-
return False
|
|
449
|
-
|
|
450
|
-
def trees(self) -> Iterator[Node]:
|
|
451
|
-
while self._advance():
|
|
452
|
-
if len(self.chosen) <= len(self.rows):
|
|
453
|
-
self.choices.append(self._choices())
|
|
454
|
-
continue
|
|
455
|
-
try:
|
|
456
|
-
tree = _assembled_tree(self.rows, self.chosen)
|
|
457
|
-
except MarkerError:
|
|
458
|
-
tree = None
|
|
459
|
-
if tree is not None:
|
|
460
|
-
yield tree
|
|
461
|
-
self._pop()
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
def _registered_tree(
|
|
465
|
-
root: Node, lines: list[str], style: str, registered: frozenset[str]
|
|
466
|
-
) -> Node:
|
|
467
|
-
assignments = _IdentityAssignments(
|
|
468
|
-
root, _indexed_rows(lines, style), registered, markdown=style == "list"
|
|
469
|
-
).trees()
|
|
470
|
-
tree = next(assignments, None)
|
|
471
|
-
if tree is None:
|
|
472
|
-
raise MarkerError(
|
|
473
|
-
"special_paths contains unused, mismatched, or invalid identities"
|
|
474
|
-
)
|
|
475
|
-
if next(assignments, None) is not None:
|
|
476
|
-
raise MarkerError("ambiguous special-path identity in printed tree")
|
|
477
|
-
return tree
|
|
478
|
-
|
|
479
|
-
|
|
480
373
|
def parse_tree(
|
|
481
374
|
text: str,
|
|
482
375
|
style: str | None = None,
|
|
483
376
|
*,
|
|
484
377
|
root: str = ".",
|
|
485
|
-
special_paths:
|
|
378
|
+
special_paths: Mapping[int, str] | None = None,
|
|
486
379
|
) -> Node:
|
|
487
380
|
"""Read structured identities and facts, using registrations before rows."""
|
|
488
|
-
registered = _registrations(special_paths, root)
|
|
489
381
|
lines = text.splitlines()
|
|
382
|
+
registered = _registrations(special_paths or {}, root, len(lines))
|
|
490
383
|
if not lines:
|
|
491
384
|
raise MarkerError("tree is empty")
|
|
492
385
|
if style is None:
|
|
@@ -495,16 +388,18 @@ def parse_tree(
|
|
|
495
388
|
raise MarkerError(f"unknown tree style: {style!r}")
|
|
496
389
|
markdown = style == "list"
|
|
497
390
|
root_node = _parse_row(lines[0], "", root=True, markdown=markdown)
|
|
498
|
-
if registered:
|
|
499
|
-
return _registered_tree(root_node, lines[1:], style, registered)
|
|
500
391
|
nodes = {"": root_node}
|
|
501
392
|
children: dict[str, list[str]] = {"": []}
|
|
502
393
|
stack = [root_node]
|
|
503
|
-
for line in lines[1:]:
|
|
394
|
+
for index, line in enumerate(lines[1:], 1):
|
|
504
395
|
depth, row = _depth_row(line, style)
|
|
505
396
|
parent = _row_parent(stack, depth)
|
|
506
397
|
stack = stack[:depth]
|
|
507
|
-
node =
|
|
398
|
+
node = (
|
|
399
|
+
_registered_row(row, parent.path, registered[index], markdown=markdown)
|
|
400
|
+
if index in registered
|
|
401
|
+
else _parse_row(row, parent.path, markdown=markdown)
|
|
402
|
+
)
|
|
508
403
|
if node.path in nodes:
|
|
509
404
|
raise MarkerError(f"duplicate tree path: {node.path!r}")
|
|
510
405
|
nodes[node.path] = node
|
|
@@ -12,7 +12,7 @@ from dataclasses import dataclass, replace
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Literal, overload
|
|
14
14
|
|
|
15
|
-
from treestamp.codec import detect_style, parse_tree,
|
|
15
|
+
from treestamp.codec import detect_style, parse_tree, render_tree_with_paths
|
|
16
16
|
from treestamp.errors import MarkerError
|
|
17
17
|
from treestamp.gitio import checked_path, document_path
|
|
18
18
|
from treestamp.model import Node, Settings, read_path_value, tree_path
|
|
@@ -169,7 +169,30 @@ def _path_array(value: object, key: str, root: str) -> frozenset[str]:
|
|
|
169
169
|
return frozenset(paths)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
-
def
|
|
172
|
+
def _special_path_record(item: object) -> tuple[int, object]:
|
|
173
|
+
if isinstance(item, str) or isinstance(item, dict) and set(item) == {"git_bytes"}:
|
|
174
|
+
raise MarkerError("legacy special_paths format; regenerate this treestamp")
|
|
175
|
+
if not isinstance(item, dict) or set(item) != {"row", "path"}:
|
|
176
|
+
raise MarkerError("each special_paths record must contain row and path")
|
|
177
|
+
row = item["row"]
|
|
178
|
+
if type(row) is not int or row <= 0:
|
|
179
|
+
raise MarkerError("special-path row must be a positive integer")
|
|
180
|
+
return row, item["path"]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _special_path_array(value: object) -> dict[int, str]:
|
|
184
|
+
if not isinstance(value, list):
|
|
185
|
+
raise MarkerError("special_paths must be an array of row/path records")
|
|
186
|
+
result: dict[int, str] = {}
|
|
187
|
+
for item in value:
|
|
188
|
+
row, path = _special_path_record(item)
|
|
189
|
+
if row in result:
|
|
190
|
+
raise MarkerError("special_paths contains duplicate rows")
|
|
191
|
+
result[row] = read_path_value(path)
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _metadata(text: str) -> tuple[Settings, frozenset[str], dict[int, str]]:
|
|
173
196
|
try:
|
|
174
197
|
values = tomllib.loads(text)
|
|
175
198
|
except tomllib.TOMLDecodeError as exc:
|
|
@@ -179,9 +202,7 @@ def _metadata(text: str) -> tuple[Settings, frozenset[str], frozenset[str]]:
|
|
|
179
202
|
raise MarkerError(f"unknown document settings: {', '.join(sorted(unknown))}")
|
|
180
203
|
settings = Settings(root=read_path_value(values.get("root", ".")))
|
|
181
204
|
hidden = _path_array(values.get("hidden", []), "hidden", settings.root)
|
|
182
|
-
special =
|
|
183
|
-
values.get("special_paths", []), "special_paths", settings.root
|
|
184
|
-
)
|
|
205
|
+
special = _special_path_array(values.get("special_paths", []))
|
|
185
206
|
return settings, hidden, special
|
|
186
207
|
|
|
187
208
|
|
|
@@ -220,7 +241,7 @@ def _parse_interior(interior: bytes) -> Document:
|
|
|
220
241
|
)
|
|
221
242
|
settings = Settings()
|
|
222
243
|
hidden: frozenset[str] = frozenset()
|
|
223
|
-
special:
|
|
244
|
+
special: dict[int, str] = {}
|
|
224
245
|
if text.startswith(CONFIG_START):
|
|
225
246
|
payload, text = _split_metadata(text)
|
|
226
247
|
settings, hidden, special = _metadata(payload)
|
|
@@ -311,17 +332,20 @@ def normalized_hidden(document: Document) -> frozenset[str]:
|
|
|
311
332
|
return frozenset(result)
|
|
312
333
|
|
|
313
334
|
|
|
314
|
-
def _settings_text(document: Document) -> str:
|
|
335
|
+
def _settings_text(document: Document, registered: dict[int, str]) -> str:
|
|
315
336
|
rows = []
|
|
316
337
|
if document.settings.root != ".":
|
|
317
338
|
rows.append(f"root = {_path_literal(document.settings.root)}")
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
339
|
+
hidden = normalized_hidden(document)
|
|
340
|
+
if hidden:
|
|
341
|
+
values = ", ".join(_path_literal(path) for path in sorted(hidden))
|
|
342
|
+
rows.append(f"hidden = [{values}]")
|
|
343
|
+
if registered:
|
|
344
|
+
values = ", ".join(
|
|
345
|
+
f"{{row = {row}, path = {_path_literal(path)}}}"
|
|
346
|
+
for row, path in registered.items()
|
|
347
|
+
)
|
|
348
|
+
rows.append(f"special_paths = [{values}]")
|
|
325
349
|
return "".join(row + "\n" for row in rows)
|
|
326
350
|
|
|
327
351
|
|
|
@@ -329,8 +353,10 @@ def render_interior(document: Document, root: str | None = None) -> str:
|
|
|
329
353
|
"""Render the interior using repository-relative config identities."""
|
|
330
354
|
if root is not None:
|
|
331
355
|
document = replace(document, settings=replace(document.settings, root=root))
|
|
332
|
-
|
|
333
|
-
|
|
356
|
+
body, registered = render_tree_with_paths(
|
|
357
|
+
document.tree, document.settings, fenced=True
|
|
358
|
+
)
|
|
359
|
+
payload = _settings_text(document, registered)
|
|
334
360
|
output = f"{CONFIG_START}\n{payload}-->\n{body}" if payload else body
|
|
335
361
|
# Validate framing and metadata together before any bytes can be published.
|
|
336
362
|
_parse_interior(output.encode("utf-8", "surrogateescape"))
|
|
@@ -426,7 +426,7 @@ def test_ordinary_names_need_no_quotes_or_extra_marker(style: str) -> None:
|
|
|
426
426
|
tree = build_tree(Entry(name) for name in names)
|
|
427
427
|
settings = Settings(style=style)
|
|
428
428
|
printed = render_tree(tree, settings)
|
|
429
|
-
assert special_paths(tree) ==
|
|
429
|
+
assert special_paths(tree) == {}
|
|
430
430
|
for name in names:
|
|
431
431
|
assert name in printed
|
|
432
432
|
assert parse_tree(printed, style) == tree
|
|
@@ -507,16 +507,14 @@ def test_special_directory_establishes_identity_for_ordinary_children(
|
|
|
507
507
|
)
|
|
508
508
|
settings = Settings(root="src", style=style)
|
|
509
509
|
paths = special_paths(tree, settings.root)
|
|
510
|
-
assert paths ==
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
)
|
|
510
|
+
assert paths == {
|
|
511
|
+
1: "src/notes # drafts",
|
|
512
|
+
4: "src/notes # drafts/another # draft.md",
|
|
513
|
+
}
|
|
516
514
|
text = render_tree(tree, settings)
|
|
517
515
|
assert parse_tree(text, style, root="src", special_paths=paths) == tree
|
|
518
516
|
collapsed = replace(tree, children=(replace(tree.children[0], children=()),))
|
|
519
|
-
assert special_paths(collapsed, "src") ==
|
|
517
|
+
assert special_paths(collapsed, "src") == {1: "src/notes # drafts"}
|
|
520
518
|
|
|
521
519
|
|
|
522
520
|
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
@@ -543,14 +541,14 @@ def test_special_name_roundtrip_preserves_bytes_and_controls(
|
|
|
543
541
|
@pytest.mark.parametrize(
|
|
544
542
|
"paths",
|
|
545
543
|
[
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
544
|
+
{1: "unused # path"},
|
|
545
|
+
{1: "ordinary"},
|
|
546
|
+
{1: "other/a # b"},
|
|
547
|
+
{1: "../escape # path"},
|
|
550
548
|
],
|
|
551
549
|
)
|
|
552
550
|
def test_special_registrations_must_match_visible_scoped_names(
|
|
553
|
-
paths:
|
|
551
|
+
paths: dict[int, str],
|
|
554
552
|
) -> None:
|
|
555
553
|
with pytest.raises(MarkerError):
|
|
556
554
|
parse_tree("./\n└── ordinary\n", special_paths=paths)
|
|
@@ -558,7 +556,7 @@ def test_special_registrations_must_match_visible_scoped_names(
|
|
|
558
556
|
|
|
559
557
|
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
560
558
|
@pytest.mark.parametrize("reverse", [False, True])
|
|
561
|
-
def
|
|
559
|
+
def test_special_name_prefixes_resolve_using_row_identities(
|
|
562
560
|
style: str, reverse: bool
|
|
563
561
|
) -> None:
|
|
564
562
|
tree = build_tree([Entry("x # y"), Entry("x # y # z")])
|
|
@@ -569,7 +567,7 @@ def test_special_name_prefixes_resolve_using_all_registered_identities(
|
|
|
569
567
|
|
|
570
568
|
|
|
571
569
|
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
572
|
-
def
|
|
570
|
+
def test_special_parents_and_children_resolve_by_row(style: str) -> None:
|
|
573
571
|
tree = build_tree(
|
|
574
572
|
[Entry("x # y/a # b"), Entry("x # y # z/a # b"), Entry("ordinary")]
|
|
575
573
|
)
|
|
@@ -578,28 +576,22 @@ def test_special_parent_assignment_uses_descendant_registrations(style: str) ->
|
|
|
578
576
|
assert parse_tree(printed, style, special_paths=special_paths(tree)) == tree
|
|
579
577
|
|
|
580
578
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"./\n├── x # y\n└── x # y\n",
|
|
585
|
-
special_paths=frozenset({"x # y"}),
|
|
586
|
-
)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
def test_special_identity_search_limit_is_an_explicit_error(
|
|
590
|
-
monkeypatch: pytest.MonkeyPatch,
|
|
579
|
+
@pytest.mark.parametrize("registered_row", [1, 2])
|
|
580
|
+
def test_identical_printed_rows_have_exact_registered_identities(
|
|
581
|
+
registered_row: int,
|
|
591
582
|
) -> None:
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
)
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
583
|
+
tree = parse_tree(
|
|
584
|
+
"./\n├── x # y\n└── x # y\n",
|
|
585
|
+
special_paths={registered_row: "x # y"},
|
|
586
|
+
)
|
|
587
|
+
special = tree.children[registered_row - 1]
|
|
588
|
+
ordinary = tree.children[2 - registered_row]
|
|
589
|
+
assert (special.path, special.annotation) == ("x # y", "")
|
|
590
|
+
assert (ordinary.path, ordinary.annotation) == ("x", "y")
|
|
600
591
|
|
|
601
592
|
|
|
602
|
-
|
|
593
|
+
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
594
|
+
def test_filename_and_annotation_overlap_keeps_registered_identity(style: str) -> None:
|
|
603
595
|
tree = Node(
|
|
604
596
|
".",
|
|
605
597
|
"",
|
|
@@ -609,8 +601,102 @@ def test_filename_and_annotation_overlap_cannot_duplicate_registered_identity()
|
|
|
609
601
|
Node("x # y", "x # y", "file"),
|
|
610
602
|
),
|
|
611
603
|
)
|
|
612
|
-
|
|
613
|
-
|
|
604
|
+
printed = render_tree(tree, Settings(style=style))
|
|
605
|
+
assert parse_tree(printed, style, special_paths=special_paths(tree)) == tree
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
609
|
+
def test_registered_prefix_file_and_directory_remain_distinct(style: str) -> None:
|
|
610
|
+
tree = Node(
|
|
611
|
+
".",
|
|
612
|
+
"",
|
|
613
|
+
"dir",
|
|
614
|
+
children=(
|
|
615
|
+
Node("x # y", "x # y", "file", annotation="z"),
|
|
616
|
+
Node(
|
|
617
|
+
"x # y # z",
|
|
618
|
+
"x # y # z",
|
|
619
|
+
"dir",
|
|
620
|
+
children=(Node("ordinary", "x # y # z/ordinary", "file"),),
|
|
621
|
+
),
|
|
622
|
+
),
|
|
623
|
+
)
|
|
624
|
+
settings = Settings(root="src", style=style)
|
|
625
|
+
printed = render_tree(tree, settings)
|
|
626
|
+
assert (
|
|
627
|
+
parse_tree(printed, style, root="src", special_paths=special_paths(tree, "src"))
|
|
628
|
+
== tree
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
633
|
+
def test_registered_rows_preserve_types_targets_and_counts(style: str) -> None:
|
|
634
|
+
tree = Node(
|
|
635
|
+
".",
|
|
636
|
+
"",
|
|
637
|
+
"dir",
|
|
638
|
+
children=(
|
|
639
|
+
Node(
|
|
640
|
+
"dir # name", "dir # name", "dir", files=3, dirs=2, annotation="note"
|
|
641
|
+
),
|
|
642
|
+
Node("link # name", "link # name", "symlink", target="dir/file"),
|
|
643
|
+
Node(
|
|
644
|
+
"module # name",
|
|
645
|
+
"module # name",
|
|
646
|
+
"gitlink",
|
|
647
|
+
annotation="nested # note",
|
|
648
|
+
),
|
|
649
|
+
),
|
|
650
|
+
)
|
|
651
|
+
printed = render_tree(tree, Settings(style=style))
|
|
652
|
+
assert parse_tree(printed, style, special_paths=special_paths(tree)) == tree
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@pytest.mark.parametrize(
|
|
656
|
+
"paths",
|
|
657
|
+
[
|
|
658
|
+
{0: "x # y"},
|
|
659
|
+
{-1: "x # y"},
|
|
660
|
+
{3: "x # y"},
|
|
661
|
+
{True: "x # y"},
|
|
662
|
+
{1: "x # y", 2: "x # y"},
|
|
663
|
+
{1: "parent/x # y"},
|
|
664
|
+
],
|
|
665
|
+
)
|
|
666
|
+
def test_invalid_row_registrations_are_rejected(paths: dict[int, str]) -> None:
|
|
667
|
+
with pytest.raises(MarkerError):
|
|
668
|
+
parse_tree("./\n├── x # y\n└── ordinary\n", special_paths=paths)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@pytest.mark.parametrize("suffix", ["extra", " [gitlink]extra", "/junk"])
|
|
672
|
+
def test_registered_name_requires_a_valid_suffix(suffix: str) -> None:
|
|
673
|
+
with pytest.raises(MarkerError):
|
|
674
|
+
parse_tree(f"./\n└── x # y{suffix}\n", special_paths={1: "x # y"})
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@pytest.mark.parametrize("ordinary_count", [400, 6400])
|
|
678
|
+
def test_special_rows_do_not_revisit_following_ordinary_rows(
|
|
679
|
+
ordinary_count: int, monkeypatch: pytest.MonkeyPatch
|
|
680
|
+
) -> None:
|
|
681
|
+
from treestamp import codec
|
|
682
|
+
|
|
683
|
+
names = [f"special{i:02d} # suffix" for i in range(10)]
|
|
684
|
+
rows = [*names, *(f"ordinary{i:04d}" for i in range(ordinary_count))]
|
|
685
|
+
text = "./\n" + "".join(f"├── {name}\n" for name in rows)
|
|
686
|
+
original = codec._parse_row
|
|
687
|
+
calls = 0
|
|
688
|
+
|
|
689
|
+
def counted(
|
|
690
|
+
text: str, parent: str, *, root: bool = False, markdown: bool = False
|
|
691
|
+
) -> Node:
|
|
692
|
+
nonlocal calls
|
|
693
|
+
calls += 1
|
|
694
|
+
return original(text, parent, root=root, markdown=markdown)
|
|
695
|
+
|
|
696
|
+
monkeypatch.setattr(codec, "_parse_row", counted)
|
|
697
|
+
parsed = parse_tree(text, special_paths=dict(enumerate(names, 1)))
|
|
698
|
+
assert [node.path for node in parsed.children] == rows
|
|
699
|
+
assert calls == len(rows) + 1
|
|
614
700
|
|
|
615
701
|
|
|
616
702
|
@pytest.mark.parametrize("target", ["ordinary # target", "null"])
|
|
@@ -407,8 +407,8 @@ def test_config_is_only_curation_and_minimal_special_identities(style: str) -> N
|
|
|
407
407
|
assert 'root = "src"' in region
|
|
408
408
|
assert 'hidden = ["src/hidden"]' in region
|
|
409
409
|
assert (
|
|
410
|
-
'special_paths = [
|
|
411
|
-
in region
|
|
410
|
+
'special_paths = [{row = 1, path = "src/notes # drafts"}, '
|
|
411
|
+
'{row = 2, path = "src/notes # drafts/a # b"}]' in region
|
|
412
412
|
)
|
|
413
413
|
assert "style =" not in region and "show_counts =" not in region
|
|
414
414
|
assert parse_document(region.encode()) == source
|
|
@@ -424,6 +424,15 @@ def test_root_only_style_and_count_inference(style: str) -> None:
|
|
|
424
424
|
assert loaded.settings.show_counts is True
|
|
425
425
|
|
|
426
426
|
|
|
427
|
+
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
428
|
+
def test_more_than_100_special_entries_roundtrip(style: str) -> None:
|
|
429
|
+
names = [f"file{i:03d} # suffix" for i in range(120)]
|
|
430
|
+
document = Document(
|
|
431
|
+
build_tree([Entry(name) for name in names]), Settings(style=style)
|
|
432
|
+
)
|
|
433
|
+
assert parse_document(render_region(document).encode()) == document
|
|
434
|
+
|
|
435
|
+
|
|
427
436
|
def test_count_visibility_inferred_without_config() -> None:
|
|
428
437
|
from treestamp.model import Node
|
|
429
438
|
|
|
@@ -455,6 +464,75 @@ def test_invalid_metadata_never_reconstructs_another_identity(payload: str) -> N
|
|
|
455
464
|
parse_document(content.encode())
|
|
456
465
|
|
|
457
466
|
|
|
467
|
+
@pytest.mark.parametrize(
|
|
468
|
+
"records",
|
|
469
|
+
[
|
|
470
|
+
'[{row = 1, path = "x # y"}, {row = 1, path = "z # q"}]',
|
|
471
|
+
'[{row = 1, path = "x # y"}, {row = 2, path = "x # y"}]',
|
|
472
|
+
'[{row = true, path = "x # y"}]',
|
|
473
|
+
'[{row = 1.0, path = "x # y"}]',
|
|
474
|
+
'[{row = "1", path = "x # y"}]',
|
|
475
|
+
'[{row = 0, path = "x # y"}]',
|
|
476
|
+
'[{row = -1, path = "x # y"}]',
|
|
477
|
+
'[{row = 3, path = "x # y"}]',
|
|
478
|
+
"[{row = 1}]",
|
|
479
|
+
'[{path = "x # y"}]',
|
|
480
|
+
'[{row = 1, path = "x # y", extra = 0}]',
|
|
481
|
+
"[{row = 1, path = 4}]",
|
|
482
|
+
'[{row = 1, path = {git_bytes = "invalid"}}]',
|
|
483
|
+
'[{row = 1, path = "other # name"}]',
|
|
484
|
+
'[{row = 1, path = "parent/x # y"}]',
|
|
485
|
+
'[{row = 1, path = "../x # y"}]',
|
|
486
|
+
'[{row = 1, path = "ordinary"}]',
|
|
487
|
+
"[4]",
|
|
488
|
+
'{row = 1, path = "x # y"}',
|
|
489
|
+
],
|
|
490
|
+
)
|
|
491
|
+
def test_invalid_special_row_metadata_is_rejected(records: str) -> None:
|
|
492
|
+
content = (
|
|
493
|
+
f"<!-- treestamp:start -->\n<!-- treestamp:config\nspecial_paths = {records}\n"
|
|
494
|
+
"-->\n```\n./\n├── x # y\n└── z # q\n```\n<!-- treestamp:end -->\n"
|
|
495
|
+
).encode()
|
|
496
|
+
with pytest.raises(MarkerError):
|
|
497
|
+
parse_document(content)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@pytest.mark.parametrize(
|
|
501
|
+
"records",
|
|
502
|
+
['["x # y"]', '[{git_bytes = "782020232079"}]'],
|
|
503
|
+
)
|
|
504
|
+
def test_legacy_special_metadata_requires_regeneration(records: str) -> None:
|
|
505
|
+
content = (
|
|
506
|
+
f"<!-- treestamp:start -->\n<!-- treestamp:config\nspecial_paths = {records}\n"
|
|
507
|
+
"-->\n```\n./\n└── x # y\n```\n<!-- treestamp:end -->\n"
|
|
508
|
+
).encode()
|
|
509
|
+
with pytest.raises(MarkerError, match="legacy special_paths format; regenerate"):
|
|
510
|
+
parse_document(content)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
514
|
+
@pytest.mark.parametrize("eol", ["\n", "\r\n"])
|
|
515
|
+
def test_manual_annotations_preserve_registered_row_identities(
|
|
516
|
+
style: str, eol: str
|
|
517
|
+
) -> None:
|
|
518
|
+
source = Document(
|
|
519
|
+
build_tree([Entry("x"), Entry("x # y"), Entry("x # y # z")]),
|
|
520
|
+
Settings(style=style),
|
|
521
|
+
)
|
|
522
|
+
region = render_region(source).replace("\n", eol)
|
|
523
|
+
# Change only row annotations; leave the generated registrations untouched.
|
|
524
|
+
edited = region.replace("x" + eol, "x # y" + eol).replace(
|
|
525
|
+
"x # y # z" + eol, "x # y # z # another # note" + eol
|
|
526
|
+
)
|
|
527
|
+
content = ("# Outside the region" + eol * 3 + edited + eol + "After.").encode()
|
|
528
|
+
loaded = parse_document(content)
|
|
529
|
+
assert {node.path: node.annotation for node in loaded.tree.walk()} == {
|
|
530
|
+
"x": "y",
|
|
531
|
+
"x # y": "",
|
|
532
|
+
"x # y # z": "another # note",
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|
|
458
536
|
def test_hidden_cannot_overlap_printed_rows_or_live_under_a_collapsed_directory() -> (
|
|
459
537
|
None
|
|
460
538
|
):
|
|
@@ -474,11 +552,16 @@ def test_region_extraction_preserves_original_framing_and_endings() -> None:
|
|
|
474
552
|
extract_region(b"ordinary document")
|
|
475
553
|
|
|
476
554
|
|
|
477
|
-
|
|
555
|
+
@pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
|
|
556
|
+
@pytest.mark.parametrize("root", [".", "src # root"])
|
|
557
|
+
def test_byte_special_paths_roundtrip_without_redundant_descendants(
|
|
558
|
+
style: str, root: str
|
|
559
|
+
) -> None:
|
|
478
560
|
source = Document(
|
|
479
561
|
build_tree(
|
|
480
562
|
[Entry("bad\udcff # dir/plain"), Entry("bad\udcff # dir/child # name")]
|
|
481
|
-
)
|
|
563
|
+
),
|
|
564
|
+
Settings(root=root, style=style),
|
|
482
565
|
)
|
|
483
566
|
content = render_region(source).encode()
|
|
484
567
|
assert b"git_bytes" in content
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"""Fixed-snapshot editor journeys and transactional document writes."""
|
|
2
2
|
|
|
3
|
+
import tomllib
|
|
3
4
|
from dataclasses import replace
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
|
|
7
|
-
from treestamp.document import
|
|
8
|
+
from treestamp.document import (
|
|
9
|
+
Document,
|
|
10
|
+
load_document,
|
|
11
|
+
parse_document,
|
|
12
|
+
render_region,
|
|
13
|
+
write_document,
|
|
14
|
+
)
|
|
8
15
|
from treestamp.editor import OPEN_UPDATED_NOTICE, EditorController
|
|
9
16
|
from treestamp.errors import MarkerError
|
|
10
17
|
from treestamp.gitio import capture_snapshot
|
|
@@ -190,21 +197,113 @@ def test_noncanonical_open_is_dirty_even_when_semantics_unchanged(repo: Repo) ->
|
|
|
190
197
|
assert destination.read_bytes() == before
|
|
191
198
|
|
|
192
199
|
|
|
193
|
-
def
|
|
194
|
-
|
|
200
|
+
def region_metadata(region: str) -> dict[str, object]:
|
|
201
|
+
_, separator, remainder = region.partition("<!-- treestamp:config\n")
|
|
202
|
+
return tomllib.loads(remainder.partition("-->\n")[0]) if separator else {}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_special_rows_follow_expansion_hiding_and_selected_root(repo: Repo) -> None:
|
|
206
|
+
for path in (
|
|
207
|
+
"src/docs # drafts/a.txt",
|
|
208
|
+
"src/docs # drafts/notes # draft.md",
|
|
209
|
+
"src/z # last.txt",
|
|
210
|
+
):
|
|
211
|
+
repo.write(path)
|
|
195
212
|
repo.add(".")
|
|
196
|
-
editor = EditorController(
|
|
213
|
+
editor = EditorController(
|
|
214
|
+
capture_snapshot(repo.path / "src", repository=repo.path),
|
|
215
|
+
Settings(root="src"),
|
|
216
|
+
)
|
|
197
217
|
session = editor.state.session
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
collapsed_rows = [
|
|
219
|
+
{"row": 1, "path": "src/docs # drafts"},
|
|
220
|
+
{"row": 2, "path": "src/z # last.txt"},
|
|
221
|
+
]
|
|
222
|
+
assert region_metadata(editor.region_text())["special_paths"] == collapsed_rows
|
|
223
|
+
with editor.edit():
|
|
224
|
+
session.expand("docs # drafts")
|
|
225
|
+
session.set_annotation("docs # drafts/notes # draft.md", "Keep # literal")
|
|
200
226
|
expanded = editor.region_text()
|
|
201
|
-
assert
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
227
|
+
assert region_metadata(expanded)["special_paths"] == [
|
|
228
|
+
{"row": 1, "path": "src/docs # drafts"},
|
|
229
|
+
{"row": 3, "path": "src/docs # drafts/notes # draft.md"},
|
|
230
|
+
{"row": 4, "path": "src/z # last.txt"},
|
|
231
|
+
]
|
|
232
|
+
with editor.edit():
|
|
233
|
+
session.collapse("docs # drafts")
|
|
234
|
+
assert region_metadata(editor.region_text())["special_paths"] == collapsed_rows
|
|
235
|
+
with editor.edit():
|
|
236
|
+
session.expand("docs # drafts")
|
|
237
|
+
assert editor.region_text() == expanded
|
|
238
|
+
with editor.edit():
|
|
239
|
+
session.hide("docs # drafts/notes # draft.md")
|
|
240
|
+
hidden = region_metadata(editor.region_text())
|
|
241
|
+
assert hidden["hidden"] == ["src/docs # drafts/notes # draft.md"]
|
|
242
|
+
assert hidden["special_paths"] == [
|
|
243
|
+
{"row": 1, "path": "src/docs # drafts"},
|
|
244
|
+
{"row": 3, "path": "src/z # last.txt"},
|
|
245
|
+
]
|
|
246
|
+
session.show_hidden = True
|
|
247
|
+
assert region_metadata(editor.region_text()) == hidden
|
|
248
|
+
with editor.edit():
|
|
249
|
+
session.unhide("docs # drafts/notes # draft.md")
|
|
205
250
|
assert editor.region_text() == expanded
|
|
206
|
-
|
|
207
|
-
|
|
251
|
+
with editor.edit():
|
|
252
|
+
session.hide("docs # drafts")
|
|
253
|
+
hidden_parent = region_metadata(editor.region_text())
|
|
254
|
+
assert hidden_parent["hidden"] == ["src/docs # drafts"]
|
|
255
|
+
assert hidden_parent["special_paths"] == [{"row": 1, "path": "src/z # last.txt"}]
|
|
256
|
+
with editor.edit():
|
|
257
|
+
session.unhide("docs # drafts")
|
|
258
|
+
assert editor.region_text() == expanded
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_new_editor_preflights_and_expands_more_than_100_special_entries(
|
|
262
|
+
repo: Repo,
|
|
263
|
+
) -> None:
|
|
264
|
+
names = [f"docs/file{i:03d} # draft" for i in range(101)]
|
|
265
|
+
for name in names:
|
|
266
|
+
repo.write(name)
|
|
267
|
+
repo.add(".")
|
|
268
|
+
editor = EditorController(capture_snapshot(repo.path), Settings())
|
|
269
|
+
assert "special_paths" not in region_metadata(editor.region_text())
|
|
270
|
+
with editor.edit():
|
|
271
|
+
editor.state.session.expand("docs")
|
|
272
|
+
assert region_metadata(editor.region_text())["special_paths"] == [
|
|
273
|
+
{"row": row, "path": path} for row, path in enumerate(names, 2)
|
|
274
|
+
]
|
|
275
|
+
editor.create("tree.md")
|
|
276
|
+
loaded, _ = load_document(repo.path / "tree.md")
|
|
277
|
+
assert loaded == editor.document()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_load_reindexes_special_rows_after_new_source_entries(repo: Repo) -> None:
|
|
281
|
+
repo.write("z # draft.md")
|
|
282
|
+
repo.add(".")
|
|
283
|
+
editor = EditorController(capture_snapshot(repo.path), Settings())
|
|
284
|
+
with editor.edit():
|
|
285
|
+
editor.state.session.set_annotation("z # draft.md", "retain # this")
|
|
286
|
+
editor.create("tree.md")
|
|
287
|
+
original = repo.read("tree.md")
|
|
288
|
+
assert region_metadata(original)["special_paths"] == [
|
|
289
|
+
{"row": 1, "path": "z # draft.md"}
|
|
290
|
+
]
|
|
291
|
+
repo.write("a # new.md")
|
|
292
|
+
repo.add("a # new.md")
|
|
293
|
+
editor.adopt(editor.prepare_load("tree.md"))
|
|
294
|
+
assert repo.read("tree.md") == original
|
|
295
|
+
assert region_metadata(editor.region_text())["special_paths"] == [
|
|
296
|
+
{"row": 1, "path": "a # new.md"},
|
|
297
|
+
{"row": 2, "path": "z # draft.md"},
|
|
298
|
+
]
|
|
299
|
+
parsed = parse_document(editor.region_text().encode())
|
|
300
|
+
assert {node.path: node.annotation for node in parsed.tree.walk()} == {
|
|
301
|
+
"a # new.md": "",
|
|
302
|
+
"z # draft.md": "retain # this",
|
|
303
|
+
}
|
|
304
|
+
assert editor.dirty
|
|
305
|
+
editor.write("tree.md")
|
|
306
|
+
assert not editor.prepare_load("tree.md").dirty
|
|
208
307
|
|
|
209
308
|
|
|
210
309
|
@pytest.mark.parametrize("changed", ["count", "target"])
|
|
@@ -301,16 +400,29 @@ def test_failed_source_preflight_preserves_loaded_draft(repo: Repo) -> None:
|
|
|
301
400
|
assert repo.read("tree.md") == before
|
|
302
401
|
|
|
303
402
|
|
|
304
|
-
def
|
|
403
|
+
def test_annotation_can_duplicate_a_special_filename_without_changing_identity(
|
|
404
|
+
repo: Repo,
|
|
405
|
+
) -> None:
|
|
305
406
|
repo.write("x")
|
|
306
407
|
repo.write("x # y")
|
|
307
408
|
repo.add(".")
|
|
308
409
|
editor = EditorController(capture_snapshot(repo.path), Settings())
|
|
309
410
|
session = editor.state.session
|
|
310
|
-
|
|
311
|
-
with pytest.raises(MarkerError, match="ambiguous special-path"), editor.edit():
|
|
411
|
+
with editor.edit():
|
|
312
412
|
session.set_annotation("x", "y")
|
|
313
|
-
|
|
413
|
+
parsed = parse_document(editor.region_text().encode())
|
|
414
|
+
assert {node.path: node.annotation for node in parsed.tree.walk()} == {
|
|
415
|
+
"x": "y",
|
|
416
|
+
"x # y": "",
|
|
417
|
+
}
|
|
418
|
+
assert region_metadata(editor.region_text())["special_paths"] == [
|
|
419
|
+
{"row": 2, "path": "x # y"}
|
|
420
|
+
]
|
|
421
|
+
before = editor.region_text()
|
|
422
|
+
with pytest.raises(MarkerError, match="single line"), editor.edit():
|
|
423
|
+
session.hide("x")
|
|
424
|
+
session.set_annotation("x # y", "invalid\nannotation")
|
|
425
|
+
assert editor.region_text() == before
|
|
314
426
|
|
|
315
427
|
|
|
316
428
|
def test_preflight_only_checks_selected_subtree(repo: Repo) -> None:
|
|
@@ -110,7 +110,7 @@ def test_headless_hash_collision_update_and_check_preserve_authored_notes(
|
|
|
110
110
|
repo.write("notes # draft.md")
|
|
111
111
|
repo.add("notes # draft.md")
|
|
112
112
|
output = cli_bytes(repo, "--print")
|
|
113
|
-
assert b'special_paths = ["notes # draft.md"]' in output
|
|
113
|
+
assert b'special_paths = [{row = 3, path = "notes # draft.md"}]' in output
|
|
114
114
|
output = output.replace(
|
|
115
115
|
b"notes # draft.md\n", b"notes # draft.md # Keep # this note\n"
|
|
116
116
|
)
|
|
@@ -520,17 +520,22 @@ def test_invalid_annotation_keeps_draft_and_prompt_for_correction(repo: Repo) ->
|
|
|
520
520
|
async with app.run_test() as pilot:
|
|
521
521
|
await press(pilot, "down")
|
|
522
522
|
field = await prompt(pilot, "a")
|
|
523
|
-
field.value = "
|
|
523
|
+
field.value = "invalid\x00note"
|
|
524
524
|
await press(pilot, "enter")
|
|
525
525
|
assert isinstance(app.screen, PromptScreen)
|
|
526
|
-
assert app.screen.query_one(Input).value == "
|
|
526
|
+
assert app.screen.query_one(Input).value == "invalid\x00note"
|
|
527
527
|
assert "draft retained" in rendered_text(app.status_widget)
|
|
528
528
|
assert editor.region_text() == before
|
|
529
529
|
assert app.cursor.path == "x"
|
|
530
|
-
app.screen.query_one(Input).value = "
|
|
530
|
+
app.screen.query_one(Input).value = "y"
|
|
531
531
|
await press(pilot, "enter")
|
|
532
532
|
assert not isinstance(app.screen, PromptScreen)
|
|
533
|
-
assert editor.state.session.annotations["x"] == "
|
|
533
|
+
assert editor.state.session.annotations["x"] == "y"
|
|
534
|
+
loaded = parse_document(editor.region_text().encode())
|
|
535
|
+
assert {node.path: node.annotation for node in loaded.tree.walk()} == {
|
|
536
|
+
"x": "y",
|
|
537
|
+
"x # y": "",
|
|
538
|
+
}
|
|
534
539
|
|
|
535
540
|
asyncio.run(journey())
|
|
536
541
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|