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