dc-up 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.
- dc_up/__init__.py +1 -0
- dc_up/__main__.py +6 -0
- dc_up/_version.py +24 -0
- dc_up/baseline.py +155 -0
- dc_up/cli.py +145 -0
- dc_up/commands/__init__.py +11 -0
- dc_up/commands/todo.py +23 -0
- dc_up/commands/update.py +50 -0
- dc_up/detect.py +124 -0
- dc_up/errors.py +38 -0
- dc_up/fetch.py +329 -0
- dc_up/plan.py +204 -0
- dc_up/py.typed +0 -0
- dc_up/render.py +35 -0
- dc_up/todo.py +97 -0
- dc_up/types.py +62 -0
- dc_up-0.1.0.dist-info/METADATA +150 -0
- dc_up-0.1.0.dist-info/RECORD +21 -0
- dc_up-0.1.0.dist-info/WHEEL +4 -0
- dc_up-0.1.0.dist-info/entry_points.txt +2 -0
- dc_up-0.1.0.dist-info/licenses/LICENSE +21 -0
dc_up/fetch.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Template source access."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import cast
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import quote, urlparse
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from dc_up.errors import TemplateFetchError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TemplateFile",
|
|
15
|
+
"TemplateSource",
|
|
16
|
+
"fetch_template_text",
|
|
17
|
+
"list_template_files",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TemplateFile:
|
|
23
|
+
"""One file discovered in a template layer."""
|
|
24
|
+
|
|
25
|
+
layer: str
|
|
26
|
+
template_path: str
|
|
27
|
+
target_path: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TemplateSource:
|
|
32
|
+
"""Canonical template source."""
|
|
33
|
+
|
|
34
|
+
repository: str = "denisecase/templates"
|
|
35
|
+
ref: str = "main"
|
|
36
|
+
local_path: Path | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def fetch_template_text(
|
|
40
|
+
*,
|
|
41
|
+
source: TemplateSource,
|
|
42
|
+
layer: str,
|
|
43
|
+
path: str,
|
|
44
|
+
) -> str | None:
|
|
45
|
+
"""Fetch one template file.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
source: Template source.
|
|
49
|
+
layer: Template layer, such as ALL-PY.
|
|
50
|
+
path: Repository-relative managed file path.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
File text, or None if the template file does not exist.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TemplateFetchError: If fetching fails for a reason other than not found.
|
|
57
|
+
"""
|
|
58
|
+
if source.local_path is not None:
|
|
59
|
+
return _fetch_local_template_text(
|
|
60
|
+
local_path=source.local_path,
|
|
61
|
+
layer=layer,
|
|
62
|
+
path=path,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return _fetch_github_template_text(
|
|
66
|
+
repository=source.repository,
|
|
67
|
+
ref=source.ref,
|
|
68
|
+
layer=layer,
|
|
69
|
+
path=path,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_template_files(
|
|
74
|
+
*,
|
|
75
|
+
source: TemplateSource,
|
|
76
|
+
layers: list[str],
|
|
77
|
+
) -> list[TemplateFile]:
|
|
78
|
+
"""List managed template files for selected layers.
|
|
79
|
+
|
|
80
|
+
Later layers override earlier layers by target path.
|
|
81
|
+
"""
|
|
82
|
+
discovered: list[TemplateFile]
|
|
83
|
+
|
|
84
|
+
if source.local_path is not None:
|
|
85
|
+
discovered = _list_local_template_files(
|
|
86
|
+
local_path=source.local_path,
|
|
87
|
+
layers=layers,
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
discovered = _list_github_template_files(
|
|
91
|
+
repository=source.repository,
|
|
92
|
+
ref=source.ref,
|
|
93
|
+
layers=layers,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
by_target: dict[str, TemplateFile] = {}
|
|
97
|
+
for item in discovered:
|
|
98
|
+
by_target[item.target_path] = item
|
|
99
|
+
|
|
100
|
+
return list(by_target.values())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _list_github_template_files(
|
|
104
|
+
*,
|
|
105
|
+
repository: str,
|
|
106
|
+
ref: str,
|
|
107
|
+
layers: list[str],
|
|
108
|
+
) -> list[TemplateFile]:
|
|
109
|
+
"""List template files from GitHub's recursive tree API."""
|
|
110
|
+
encoded_ref = quote(ref, safe="")
|
|
111
|
+
url = (
|
|
112
|
+
f"https://api.github.com/repos/{repository}/git/trees/{encoded_ref}?recursive=1"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
payload = _fetch_json_object(url)
|
|
116
|
+
tree = payload.get("tree")
|
|
117
|
+
|
|
118
|
+
if not isinstance(tree, list):
|
|
119
|
+
raise TemplateFetchError(f"Unexpected GitHub tree response: {url}")
|
|
120
|
+
|
|
121
|
+
selected_layers = set(layers)
|
|
122
|
+
layer_order = {layer: index for index, layer in enumerate(layers)}
|
|
123
|
+
items: list[TemplateFile] = []
|
|
124
|
+
|
|
125
|
+
tree_entries = cast(list[object], tree)
|
|
126
|
+
for entry in tree_entries:
|
|
127
|
+
if not isinstance(entry, dict):
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
entry_data = cast(dict[object, object], entry)
|
|
131
|
+
|
|
132
|
+
if entry_data.get("type") != "blob":
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
raw_path = entry_data.get("path")
|
|
136
|
+
if not isinstance(raw_path, str):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
layer, relative_path = _split_layer_path(raw_path)
|
|
140
|
+
|
|
141
|
+
if layer not in selected_layers:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if _should_skip_template_path(relative_path):
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
items.append(
|
|
148
|
+
TemplateFile(
|
|
149
|
+
layer=layer,
|
|
150
|
+
template_path=relative_path,
|
|
151
|
+
target_path=_target_path_for_template_path(relative_path),
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return sorted(
|
|
156
|
+
items,
|
|
157
|
+
key=lambda item: (layer_order[item.layer], item.target_path),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _list_local_template_files(
|
|
162
|
+
*,
|
|
163
|
+
local_path: Path,
|
|
164
|
+
layers: list[str],
|
|
165
|
+
) -> list[TemplateFile]:
|
|
166
|
+
"""List template files from a local templates repository."""
|
|
167
|
+
root = local_path.expanduser().resolve()
|
|
168
|
+
items: list[TemplateFile] = []
|
|
169
|
+
|
|
170
|
+
for layer in layers:
|
|
171
|
+
layer_root = root / layer
|
|
172
|
+
if not layer_root.exists():
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
for template_path in sorted(layer_root.rglob("*")):
|
|
176
|
+
if not template_path.is_file():
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
relative_path = template_path.relative_to(layer_root).as_posix()
|
|
180
|
+
if _should_skip_template_path(relative_path):
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
items.append(
|
|
184
|
+
TemplateFile(
|
|
185
|
+
layer=layer,
|
|
186
|
+
template_path=relative_path,
|
|
187
|
+
target_path=_target_path_for_template_path(relative_path),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return items
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _fetch_local_template_text(
|
|
195
|
+
*,
|
|
196
|
+
local_path: Path,
|
|
197
|
+
layer: str,
|
|
198
|
+
path: str,
|
|
199
|
+
) -> str | None:
|
|
200
|
+
"""Fetch template text from a local templates repository."""
|
|
201
|
+
template_path = local_path.expanduser().resolve() / layer / path
|
|
202
|
+
|
|
203
|
+
if not template_path.exists():
|
|
204
|
+
template_path_with_suffix = Path(f"{template_path}.template")
|
|
205
|
+
if not template_path_with_suffix.exists():
|
|
206
|
+
return None
|
|
207
|
+
template_path = template_path_with_suffix
|
|
208
|
+
|
|
209
|
+
if template_path.is_dir():
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
return template_path.read_text(encoding="utf-8")
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
raise TemplateFetchError(
|
|
216
|
+
f"Could not read template file: {template_path}"
|
|
217
|
+
) from exc
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _fetch_github_template_text(
|
|
221
|
+
*,
|
|
222
|
+
repository: str,
|
|
223
|
+
ref: str,
|
|
224
|
+
layer: str,
|
|
225
|
+
path: str,
|
|
226
|
+
) -> str | None:
|
|
227
|
+
"""Fetch template text from GitHub raw content."""
|
|
228
|
+
raw_path = f"{layer}/{path}"
|
|
229
|
+
url = f"https://raw.githubusercontent.com/{repository}/{ref}/{raw_path}"
|
|
230
|
+
|
|
231
|
+
text = _fetch_url_text(url)
|
|
232
|
+
if text is not None:
|
|
233
|
+
return text
|
|
234
|
+
|
|
235
|
+
template_url = f"{url}.template"
|
|
236
|
+
return _fetch_url_text(template_url)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _fetch_url_text(url: str) -> str | None:
|
|
240
|
+
"""Fetch text from a trusted HTTPS URL, returning None for HTTP 404."""
|
|
241
|
+
parsed = urlparse(url)
|
|
242
|
+
|
|
243
|
+
if parsed.scheme != "https":
|
|
244
|
+
raise TemplateFetchError(f"Invalid URL scheme: {url}")
|
|
245
|
+
|
|
246
|
+
if parsed.netloc != "raw.githubusercontent.com":
|
|
247
|
+
raise TemplateFetchError(f"Invalid template host: {url}")
|
|
248
|
+
|
|
249
|
+
request = Request( # noqa: S310
|
|
250
|
+
url,
|
|
251
|
+
headers={
|
|
252
|
+
"User-Agent": "dc-up",
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
with urlopen(request, timeout=20) as response: # noqa: S310
|
|
258
|
+
return response.read().decode("utf-8")
|
|
259
|
+
except HTTPError as exc:
|
|
260
|
+
if exc.code == 404:
|
|
261
|
+
return None
|
|
262
|
+
raise TemplateFetchError(f"Could not fetch template file: {url}") from exc
|
|
263
|
+
except URLError as exc:
|
|
264
|
+
raise TemplateFetchError(f"Could not fetch template file: {url}") from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _fetch_json_object(url: str) -> dict[str, object]:
|
|
268
|
+
"""Fetch a trusted GitHub API JSON object."""
|
|
269
|
+
parsed = urlparse(url)
|
|
270
|
+
|
|
271
|
+
if parsed.scheme != "https":
|
|
272
|
+
raise TemplateFetchError(f"Invalid URL scheme: {url}")
|
|
273
|
+
|
|
274
|
+
if parsed.netloc != "api.github.com":
|
|
275
|
+
raise TemplateFetchError(f"Invalid template API host: {url}")
|
|
276
|
+
|
|
277
|
+
request = Request( # noqa: S310
|
|
278
|
+
url,
|
|
279
|
+
headers={
|
|
280
|
+
"Accept": "application/vnd.github+json",
|
|
281
|
+
"User-Agent": "dc-up",
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
with urlopen(request, timeout=20) as response: # noqa: S310
|
|
287
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
288
|
+
except HTTPError as exc:
|
|
289
|
+
raise TemplateFetchError(f"Could not list template files: {url}") from exc
|
|
290
|
+
except URLError as exc:
|
|
291
|
+
raise TemplateFetchError(f"Could not list template files: {url}") from exc
|
|
292
|
+
except json.JSONDecodeError as exc:
|
|
293
|
+
raise TemplateFetchError(f"Could not parse template file list: {url}") from exc
|
|
294
|
+
|
|
295
|
+
if not isinstance(payload, dict):
|
|
296
|
+
raise TemplateFetchError(f"Unexpected GitHub API response: {url}")
|
|
297
|
+
|
|
298
|
+
return cast(dict[str, object], payload)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _split_layer_path(path: str) -> tuple[str, str]:
|
|
302
|
+
"""Split a template repository path into layer and relative path."""
|
|
303
|
+
parts = path.split("/", 1)
|
|
304
|
+
if len(parts) != 2:
|
|
305
|
+
return path, ""
|
|
306
|
+
|
|
307
|
+
return parts[0], parts[1]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _target_path_for_template_path(path: str) -> str:
|
|
311
|
+
"""Convert a template path to a target repository path."""
|
|
312
|
+
if path.endswith(".template"):
|
|
313
|
+
return path.removesuffix(".template")
|
|
314
|
+
|
|
315
|
+
return path
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _should_skip_template_path(path: str) -> bool:
|
|
319
|
+
"""Return whether a template path is internal or unsupported."""
|
|
320
|
+
if not path:
|
|
321
|
+
return True
|
|
322
|
+
|
|
323
|
+
if path.startswith((".dc-up/", "__pycache__/")):
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
if Path(path).name == ".DS_Store":
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
return path.endswith(".pyc")
|
dc_up/plan.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Build, print, and apply update plans."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dc_up.errors import UnsafePathError
|
|
6
|
+
from dc_up.fetch import (
|
|
7
|
+
TemplateFile,
|
|
8
|
+
TemplateSource,
|
|
9
|
+
fetch_template_text,
|
|
10
|
+
list_template_files,
|
|
11
|
+
)
|
|
12
|
+
from dc_up.render import render_template
|
|
13
|
+
from dc_up.types import FileStatus, PlannedFile, RepositoryContext, UpdatePlan
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"build_update_plan",
|
|
17
|
+
"print_update_plan",
|
|
18
|
+
"write_update_plan",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_update_plan(
|
|
23
|
+
*,
|
|
24
|
+
target: RepositoryContext,
|
|
25
|
+
source: TemplateSource,
|
|
26
|
+
) -> UpdatePlan:
|
|
27
|
+
"""Build an update plan from discovered template files."""
|
|
28
|
+
planned_files: list[PlannedFile] = []
|
|
29
|
+
|
|
30
|
+
template_files = list_template_files(
|
|
31
|
+
source=source,
|
|
32
|
+
layers=list(target.layers),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
for template_file in template_files:
|
|
36
|
+
planned_files.append(
|
|
37
|
+
_plan_one_template_file(
|
|
38
|
+
target=target,
|
|
39
|
+
source=source,
|
|
40
|
+
template_file=template_file,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return UpdatePlan(
|
|
45
|
+
target=target,
|
|
46
|
+
files=tuple(planned_files),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def print_update_plan(plan: UpdatePlan, *, write: bool) -> None:
|
|
51
|
+
"""Print a human-readable update plan."""
|
|
52
|
+
mode = "WRITE" if write else "DRY RUN"
|
|
53
|
+
|
|
54
|
+
print(f"[dc-up] {mode}") # noqa: T201
|
|
55
|
+
print(f"[dc-up] repo: {plan.target.repo_name}") # noqa: T201
|
|
56
|
+
print(f"[dc-up] root: {plan.target.root}") # noqa: T201
|
|
57
|
+
print(f"[dc-up] layers: {' -> '.join(plan.target.layers)}") # noqa: T201
|
|
58
|
+
print("") # noqa: T201
|
|
59
|
+
|
|
60
|
+
counts = _status_counts(plan)
|
|
61
|
+
|
|
62
|
+
print("[dc-up] managed files") # noqa: T201
|
|
63
|
+
for file in plan.files:
|
|
64
|
+
status = _status_label(file.status, write=write)
|
|
65
|
+
source_label = f" [{file.source_layer}]" if file.source_layer else ""
|
|
66
|
+
print(f"{status:13} {file.path.as_posix()}{source_label}") # noqa: T201
|
|
67
|
+
|
|
68
|
+
print("") # noqa: T201
|
|
69
|
+
print( # noqa: T201
|
|
70
|
+
"[dc-up] summary: "
|
|
71
|
+
f"{counts['current']} current, "
|
|
72
|
+
f"{counts['changed']} changed, "
|
|
73
|
+
f"{counts['missing']} missing, "
|
|
74
|
+
f"{counts['no-template']} no-template"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if not write:
|
|
78
|
+
print("") # noqa: T201
|
|
79
|
+
print("[dc-up] no files written; rerun with --write to apply managed changes") # noqa: T201
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def write_update_plan(plan: UpdatePlan) -> None:
|
|
83
|
+
"""Write changed or missing managed files."""
|
|
84
|
+
for file in plan.files:
|
|
85
|
+
if file.status not in {"changed", "missing"}:
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if file.desired_text is None:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
target_path = _safe_target_path(plan.target.root, file.path)
|
|
92
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
target_path.write_text(file.desired_text, encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _plan_one_template_file(
|
|
97
|
+
*,
|
|
98
|
+
target: RepositoryContext,
|
|
99
|
+
source: TemplateSource,
|
|
100
|
+
template_file: TemplateFile,
|
|
101
|
+
) -> PlannedFile:
|
|
102
|
+
"""Plan one discovered template file."""
|
|
103
|
+
template_text = fetch_template_text(
|
|
104
|
+
source=source,
|
|
105
|
+
layer=template_file.layer,
|
|
106
|
+
path=template_file.target_path,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if template_text is None:
|
|
110
|
+
return PlannedFile(
|
|
111
|
+
path=Path(template_file.target_path),
|
|
112
|
+
status="no-template",
|
|
113
|
+
source_layer=template_file.layer,
|
|
114
|
+
source_path=f"{template_file.layer}/{template_file.template_path}",
|
|
115
|
+
current_text=_read_current_text(
|
|
116
|
+
target.root, Path(template_file.target_path)
|
|
117
|
+
),
|
|
118
|
+
desired_text=None,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
desired_text = render_template(template_text, target)
|
|
122
|
+
relative_path = Path(template_file.target_path)
|
|
123
|
+
current_text = _read_current_text(target.root, relative_path)
|
|
124
|
+
|
|
125
|
+
status = _file_status(
|
|
126
|
+
current_text=current_text,
|
|
127
|
+
desired_text=desired_text,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return PlannedFile(
|
|
131
|
+
path=relative_path,
|
|
132
|
+
status=status,
|
|
133
|
+
source_layer=template_file.layer,
|
|
134
|
+
source_path=f"{template_file.layer}/{template_file.template_path}",
|
|
135
|
+
current_text=current_text,
|
|
136
|
+
desired_text=desired_text,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _file_status(
|
|
141
|
+
*,
|
|
142
|
+
current_text: str | None,
|
|
143
|
+
desired_text: str,
|
|
144
|
+
) -> FileStatus:
|
|
145
|
+
"""Determine planned file status."""
|
|
146
|
+
if current_text is None:
|
|
147
|
+
return "missing"
|
|
148
|
+
|
|
149
|
+
if current_text == desired_text:
|
|
150
|
+
return "current"
|
|
151
|
+
|
|
152
|
+
return "changed"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _read_current_text(root: Path, path: Path) -> str | None:
|
|
156
|
+
"""Read current file text if present."""
|
|
157
|
+
target_path = _safe_target_path(root, path)
|
|
158
|
+
|
|
159
|
+
if not target_path.exists() or target_path.is_dir():
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
return target_path.read_text(encoding="utf-8")
|
|
164
|
+
except UnicodeDecodeError:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _safe_target_path(root: Path, path: Path) -> Path:
|
|
169
|
+
"""Resolve a path under the repository root."""
|
|
170
|
+
target_path = (root / path).resolve()
|
|
171
|
+
root_resolved = root.resolve()
|
|
172
|
+
|
|
173
|
+
if target_path != root_resolved and root_resolved not in target_path.parents:
|
|
174
|
+
raise UnsafePathError(target_path)
|
|
175
|
+
|
|
176
|
+
return target_path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _status_counts(plan: UpdatePlan) -> dict[FileStatus, int]:
|
|
180
|
+
"""Count file statuses."""
|
|
181
|
+
counts: dict[FileStatus, int] = {
|
|
182
|
+
"current": 0,
|
|
183
|
+
"changed": 0,
|
|
184
|
+
"missing": 0,
|
|
185
|
+
"no-template": 0,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for file in plan.files:
|
|
189
|
+
counts[file.status] += 1
|
|
190
|
+
|
|
191
|
+
return counts
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _status_label(status: FileStatus, *, write: bool) -> str:
|
|
195
|
+
"""Return display label for a file status."""
|
|
196
|
+
match status:
|
|
197
|
+
case "current":
|
|
198
|
+
return "CURRENT"
|
|
199
|
+
case "changed":
|
|
200
|
+
return "CHANGED" if write else "WOULD CHANGE"
|
|
201
|
+
case "missing":
|
|
202
|
+
return "ADDED" if write else "WOULD ADD"
|
|
203
|
+
case "no-template":
|
|
204
|
+
return "NO TEMPLATE"
|
dc_up/py.typed
ADDED
|
File without changes
|
dc_up/render.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Minimal template rendering."""
|
|
2
|
+
|
|
3
|
+
from dc_up.types import RepositoryContext
|
|
4
|
+
|
|
5
|
+
__all__ = ["render_template"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_template(text: str, target: RepositoryContext) -> str:
|
|
9
|
+
"""Render simple repository identity tokens.
|
|
10
|
+
|
|
11
|
+
This is deliberately not a full template engine in v0.1.0.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
text: Template text.
|
|
15
|
+
target: Target repository context.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Rendered text.
|
|
19
|
+
"""
|
|
20
|
+
replacements = {
|
|
21
|
+
"{{ repo_slug }}": target.repo_slug,
|
|
22
|
+
"{{repo_slug}}": target.repo_slug,
|
|
23
|
+
"{{ repo_name }}": target.repo_name,
|
|
24
|
+
"{{repo_name}}": target.repo_name,
|
|
25
|
+
"{{ repo_url }}": target.repo_url,
|
|
26
|
+
"{{repo_url}}": target.repo_url,
|
|
27
|
+
"{{ site_url }}": target.site_url,
|
|
28
|
+
"{{site_url}}": target.site_url,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
rendered = text
|
|
32
|
+
for token, value in replacements.items():
|
|
33
|
+
rendered = rendered.replace(token, value)
|
|
34
|
+
|
|
35
|
+
return rendered
|
dc_up/todo.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Human action reporting for."""
|
|
2
|
+
|
|
3
|
+
from dc_up.types import RepositoryContext, TodoReport
|
|
4
|
+
|
|
5
|
+
__all__ = ["build_todo_report", "print_todo_report"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_REVIEW_PATHS: tuple[str, ...] = (
|
|
9
|
+
".accountability/surfaces.toml",
|
|
10
|
+
"README.md",
|
|
11
|
+
"docs/project-instructions.md",
|
|
12
|
+
"docs/working-files.md",
|
|
13
|
+
"docs/success.md",
|
|
14
|
+
"docs/example-output.md",
|
|
15
|
+
"docs/index.md",
|
|
16
|
+
"src/**",
|
|
17
|
+
"tests/**",
|
|
18
|
+
"notebooks/**",
|
|
19
|
+
"sql/**",
|
|
20
|
+
"data/raw/**",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_CONFIRMATIONS: tuple[str, ...] = (
|
|
25
|
+
"repo-specific project description is accurate",
|
|
26
|
+
"dataset paths and expected outputs are correct",
|
|
27
|
+
"module-specific instructions still match the assignment",
|
|
28
|
+
"tests still match intended student or package behavior",
|
|
29
|
+
"notebook outputs are intentional, if notebooks are present",
|
|
30
|
+
"protected surfaces still reflect human review boundaries",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_todo_report(target: RepositoryContext) -> TodoReport:
|
|
35
|
+
"""Build a repo-specific human review TODO report."""
|
|
36
|
+
review_paths = list(DEFAULT_REVIEW_PATHS)
|
|
37
|
+
confirmations = list(DEFAULT_CONFIRMATIONS)
|
|
38
|
+
|
|
39
|
+
if "pyproject.toml" in target.files:
|
|
40
|
+
review_paths.extend(
|
|
41
|
+
[
|
|
42
|
+
"pyproject.toml",
|
|
43
|
+
"uv.lock",
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
confirmations.extend(
|
|
47
|
+
[
|
|
48
|
+
"package metadata is correct",
|
|
49
|
+
"dependency groups match current tooling expectations",
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if "package.json" in target.files:
|
|
54
|
+
review_paths.append("package.json")
|
|
55
|
+
confirmations.append("npm scripts and TypeScript tooling are current")
|
|
56
|
+
|
|
57
|
+
if any(path.startswith("notebooks/") for path in target.files):
|
|
58
|
+
confirmations.append("notebook execution state is intentional")
|
|
59
|
+
|
|
60
|
+
if any(path.startswith("sql/") for path in target.files):
|
|
61
|
+
confirmations.append("SQL examples and database paths are current")
|
|
62
|
+
|
|
63
|
+
return TodoReport(
|
|
64
|
+
target=target,
|
|
65
|
+
review_paths=tuple(_dedupe(review_paths)),
|
|
66
|
+
confirmations=tuple(_dedupe(confirmations)),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_todo_report(report: TodoReport) -> None:
|
|
71
|
+
"""Print a human review TODO report."""
|
|
72
|
+
print(f"PROJECT TODO: {report.target.repo_slug}") # noqa: T201
|
|
73
|
+
print("") # noqa: T201
|
|
74
|
+
|
|
75
|
+
print("Review repo-specific surfaces:") # noqa: T201
|
|
76
|
+
for path in report.review_paths:
|
|
77
|
+
print(f" {path}") # noqa: T201
|
|
78
|
+
|
|
79
|
+
print("") # noqa: T201
|
|
80
|
+
print("Confirm:") # noqa: T201
|
|
81
|
+
for item in report.confirmations:
|
|
82
|
+
print(f" {item}") # noqa: T201
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dedupe(items: list[str]) -> list[str]:
|
|
86
|
+
"""Deduplicate while preserving order."""
|
|
87
|
+
seen: set[str] = set()
|
|
88
|
+
result: list[str] = []
|
|
89
|
+
|
|
90
|
+
for item in items:
|
|
91
|
+
if item in seen:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
seen.add(item)
|
|
95
|
+
result.append(item)
|
|
96
|
+
|
|
97
|
+
return result
|