treestamp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
treestamp/__init__.py ADDED
File without changes
treestamp/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """python -m treestamp"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from treestamp.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
treestamp/cli.py ADDED
@@ -0,0 +1,197 @@
1
+ """Headless maintenance and lazy dispatch to the interactive editor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import difflib
7
+ import sys
8
+ from importlib.metadata import version
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from treestamp.document import (
13
+ Document,
14
+ load_document,
15
+ prepare_write,
16
+ publish_write,
17
+ validate_source_tree,
18
+ )
19
+ from treestamp.errors import ExecutionError, MarkerError, TreestampError
20
+ from treestamp.gitio import (
21
+ Snapshot,
22
+ capture_snapshot,
23
+ changes,
24
+ document_path,
25
+ repo_root,
26
+ )
27
+ from treestamp.maintenance import update
28
+ from treestamp.model import Settings, build_tree, tree_path
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Sequence
32
+
33
+ from treestamp.editor import EditorController
34
+
35
+ COMMANDS = ("tui", "update", "check")
36
+
37
+
38
+ def _parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="treestamp",
41
+ description=(
42
+ "Curate Git directory trees. With no command, open the editor. "
43
+ "Use --print to print the complete treestamp without opening the editor."
44
+ ),
45
+ )
46
+ parser.add_argument(
47
+ "--version", action="version", version=f"treestamp {version('treestamp')}"
48
+ )
49
+ commands = parser.add_subparsers(dest="command", required=True)
50
+ editor = commands.add_parser("tui", help="open the editor or print a treestamp")
51
+ editor.add_argument("path", nargs="?", default=".", help="repository location")
52
+ editor.add_argument(
53
+ "--document",
54
+ type=Path,
55
+ help="repository-root-relative Load document path (open it with --print)",
56
+ )
57
+ editor.add_argument(
58
+ "--print",
59
+ dest="print_output",
60
+ action="store_true",
61
+ help="print the complete treestamp and exit without writing a document",
62
+ )
63
+ for command in ("check", "update"):
64
+ child = commands.add_parser(
65
+ command,
66
+ help=(
67
+ "report whether Update would change a document"
68
+ if command == "check"
69
+ else "update a treestamped document"
70
+ ),
71
+ )
72
+ child.add_argument(
73
+ "document",
74
+ nargs="?",
75
+ type=Path,
76
+ default=Path("README.md"),
77
+ help="document path relative to the current repository root",
78
+ )
79
+ child.add_argument(
80
+ "--target", metavar="REF", help="target Git ref (default: captured index)"
81
+ )
82
+ child.add_argument(
83
+ "--compare", metavar="REF", help="reference for detected moves"
84
+ )
85
+ return parser
86
+
87
+
88
+ def _maintain(args: argparse.Namespace) -> int:
89
+ repository = repo_root(Path.cwd())
90
+ path = document_path(repository, args.document)
91
+ document, previous = load_document(path)
92
+ assert document is not None and previous is not None
93
+ root = repository / document.settings.root
94
+ target = capture_snapshot(root, args.target, repository=repository)
95
+ validate_source_tree(target.tree, document.settings)
96
+ if args.compare == "":
97
+ raise MarkerError("--compare requires a nonempty ref")
98
+ moves = changes(target, args.compare).moves if args.compare is not None else None
99
+ hidden = frozenset(
100
+ tree_path(path, document.settings.root) for path in document.hidden
101
+ )
102
+ tree = update(
103
+ document.tree, target.tree, document.settings, hidden=hidden, moves=moves
104
+ )
105
+ maintained = Document(tree, document.settings, document.hidden)
106
+ prepared = prepare_write(
107
+ maintained, previous.path, expected=previous, repository=repository
108
+ )
109
+ if prepared.content == previous.content:
110
+ _emit(f"Current: {previous.path}\n")
111
+ return 0
112
+ if args.command == "check":
113
+ before = previous.content.decode("utf-8", "replace").splitlines(keepends=True)
114
+ after = prepared.content.decode("utf-8", "replace").splitlines(keepends=True)
115
+ _emit(
116
+ "".join(
117
+ difflib.unified_diff(
118
+ before,
119
+ after,
120
+ fromfile=str(previous.path),
121
+ tofile=str(previous.path),
122
+ )
123
+ )
124
+ )
125
+ return 1
126
+ publish_write(prepared)
127
+ _emit(f"Updated: {previous.path}\n")
128
+ return 0
129
+
130
+
131
+ def _editor_inputs(args: argparse.Namespace) -> EditorController:
132
+ from treestamp.editor import EditorController
133
+
134
+ repository = repo_root(Path(args.path))
135
+ path = document_path(repository, args.document or "README.md")
136
+ document, previous = load_document(path, allow_absent=args.document is None)
137
+ settings = document.settings if document else Settings()
138
+ target = capture_snapshot(repository / settings.root, repository=repository)
139
+ return EditorController(
140
+ target,
141
+ settings,
142
+ tree=document.tree if document else None,
143
+ hidden=document.hidden if document else frozenset(),
144
+ document_path=path,
145
+ document_snapshot=previous,
146
+ loaded_document=document,
147
+ )
148
+
149
+
150
+ def _explore(args: argparse.Namespace) -> int:
151
+ if args.print_output:
152
+ _emit(_editor_inputs(args).region_text())
153
+ return 0
154
+ from treestamp.editor import EditorController
155
+ from treestamp.tui import explore
156
+
157
+ repository = repo_root(Path(args.path))
158
+ path = document_path(repository, args.document or "README.md").relative_to(
159
+ repository
160
+ )
161
+ # Startup has no accepted source yet; New/Load captures its selected subtree.
162
+ editor = EditorController(
163
+ Snapshot(repository, repository, build_tree([]), ""), Settings()
164
+ )
165
+ if explore(editor, startup_document=path):
166
+ _emit(editor.region_text())
167
+ return 0
168
+
169
+
170
+ def _emit(text: str) -> None:
171
+ """Preserve Git bytes and newlines even through a Windows output pipe."""
172
+ stream = getattr(sys.stdout, "buffer", None)
173
+ if stream is None:
174
+ sys.stdout.write(text)
175
+ return
176
+ sys.stdout.flush()
177
+ stream.write(text.encode("utf-8", "surrogateescape"))
178
+ stream.flush()
179
+
180
+
181
+ def main(argv: Sequence[str] | None = None) -> int:
182
+ arguments = list(sys.argv[1:] if argv is None else argv)
183
+ if not arguments or arguments[0] not in (*COMMANDS, "--help", "-h", "--version"):
184
+ arguments.insert(0, "tui")
185
+ try:
186
+ args = _parser().parse_args(arguments)
187
+ if args.command == "tui":
188
+ return _explore(args)
189
+ return _maintain(args)
190
+ except TreestampError as exc:
191
+ print(f"treestamp: {exc}", file=sys.stderr)
192
+ return exc.exit_code
193
+ except (OSError, UnicodeError) as exc:
194
+ print(f"treestamp: {exc}", file=sys.stderr)
195
+ return ExecutionError.exit_code
196
+ except KeyboardInterrupt:
197
+ return 130