create-awesome-python-app 0.1.0__py3-none-any.whl → 0.2.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.
@@ -1,3 +1,3 @@
1
1
  """Create Awesome Python App CLI."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,405 @@
1
+ """Local template/extension cache management (CNA cache.ts parity)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from create_python_app_core.git_cache import (
16
+ CacheMeta,
17
+ read_cache_meta,
18
+ write_cache_meta,
19
+ )
20
+ from create_python_app_core.paths import default_cache_dir
21
+
22
+ from create_awesome_python_app.catalog import catalog_url
23
+
24
+
25
+ @dataclass
26
+ class CacheEntry:
27
+ id: str
28
+ path: Path
29
+ url: str | None = None
30
+ ref: str | None = None
31
+ fetched_at: float | None = None
32
+ commit: str | None = None
33
+ size_bytes: int = 0
34
+ fsck_ok: bool | None = None
35
+
36
+
37
+ @dataclass
38
+ class RemoteTipResult:
39
+ id: str
40
+ behind: bool
41
+ url: str | None = None
42
+ ref: str | None = None
43
+ local_sha: str | None = None
44
+ remote_sha: str | None = None
45
+ error: str | None = None
46
+
47
+
48
+ @dataclass
49
+ class DoctorResult:
50
+ check: str
51
+ ok: bool
52
+ detail: str = ""
53
+
54
+
55
+ @dataclass
56
+ class CleanResult:
57
+ removed: list[str]
58
+ not_found: list[str]
59
+
60
+
61
+ def repos_root(cache_root: Path | None = None) -> Path:
62
+ return (cache_root or default_cache_dir()) / "repos"
63
+
64
+
65
+ def _dir_size(path: Path) -> int:
66
+ total = 0
67
+ stack = [path]
68
+ while stack:
69
+ current = stack.pop()
70
+ try:
71
+ for child in current.iterdir():
72
+ if child.is_dir():
73
+ stack.append(child)
74
+ elif child.is_file():
75
+ with contextlib.suppress(OSError):
76
+ total += child.stat().st_size
77
+ except OSError:
78
+ continue
79
+ return total
80
+
81
+
82
+ def _run_git(
83
+ args: list[str],
84
+ *,
85
+ cwd: Path | None = None,
86
+ check: bool = True,
87
+ timeout: float | None = None,
88
+ ) -> subprocess.CompletedProcess[str]:
89
+ return subprocess.run(
90
+ ["git", *args],
91
+ cwd=str(cwd) if cwd else None,
92
+ check=check,
93
+ capture_output=True,
94
+ text=True,
95
+ timeout=timeout,
96
+ )
97
+
98
+
99
+ def run_git_fsck(entry_path: Path) -> bool:
100
+ try:
101
+ _run_git(["fsck", "--no-progress"], cwd=entry_path, check=True)
102
+ return True
103
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
104
+ return False
105
+
106
+
107
+ def list_cache_entries(cache_root: Path | None = None) -> list[CacheEntry]:
108
+ root = repos_root(cache_root)
109
+ if not root.is_dir():
110
+ return []
111
+ out: list[CacheEntry] = []
112
+ for child in sorted(root.iterdir()):
113
+ if not child.is_dir():
114
+ continue
115
+ meta = read_cache_meta(child)
116
+ out.append(
117
+ CacheEntry(
118
+ id=child.name,
119
+ path=child,
120
+ url=meta.url if meta else None,
121
+ ref=meta.ref if meta else None,
122
+ fetched_at=meta.fetched_at if meta else None,
123
+ commit=meta.commit if meta else None,
124
+ size_bytes=_dir_size(child),
125
+ )
126
+ )
127
+ return out
128
+
129
+
130
+ def clean_cache(
131
+ entry_id: str | None = None,
132
+ *,
133
+ cache_root: Path | None = None,
134
+ catalog: bool = False,
135
+ ) -> CleanResult:
136
+ root = cache_root or default_cache_dir()
137
+ removed: list[str] = []
138
+ not_found: list[str] = []
139
+
140
+ if catalog:
141
+ target = root / "catalog" / "templates.json"
142
+ if target.is_file():
143
+ target.unlink()
144
+ removed.append(str(target))
145
+ catalog_dir = root / "catalog"
146
+ if catalog_dir.is_dir() and not any(catalog_dir.iterdir()):
147
+ catalog_dir.rmdir()
148
+ return CleanResult(removed=removed, not_found=not_found)
149
+
150
+ repos = root / "repos"
151
+ if entry_id:
152
+ target = repos / entry_id
153
+ if not target.exists():
154
+ return CleanResult(removed=[], not_found=[entry_id])
155
+ shutil.rmtree(target)
156
+ return CleanResult(removed=[str(target)], not_found=[])
157
+
158
+ if not repos.is_dir():
159
+ return CleanResult(removed=[], not_found=[])
160
+
161
+ for child in list(repos.iterdir()):
162
+ if child.is_dir():
163
+ shutil.rmtree(child)
164
+ removed.append(str(child))
165
+ return CleanResult(removed=removed, not_found=[])
166
+
167
+
168
+ def verify_cache(
169
+ entry_id: str | None = None,
170
+ *,
171
+ cache_root: Path | None = None,
172
+ ) -> list[CacheEntry]:
173
+ entries = list_cache_entries(cache_root)
174
+ if entry_id:
175
+ entries = [e for e in entries if e.id == entry_id]
176
+ for entry in entries:
177
+ entry.fsck_ok = run_git_fsck(entry.path)
178
+ return entries
179
+
180
+
181
+ def _remote_tip_sha(git_url: str, ref: str) -> str | None:
182
+ try:
183
+ result = _run_git(
184
+ ["ls-remote", git_url, ref],
185
+ check=True,
186
+ timeout=15,
187
+ )
188
+ except (
189
+ subprocess.CalledProcessError,
190
+ FileNotFoundError,
191
+ OSError,
192
+ subprocess.TimeoutExpired,
193
+ ):
194
+ return None
195
+ first = result.stdout.strip().splitlines()
196
+ if not first:
197
+ return None
198
+ return first[0].split()[0]
199
+
200
+
201
+ def check_outdated(cache_root: Path | None = None) -> list[RemoteTipResult]:
202
+ results: list[RemoteTipResult] = []
203
+ for entry in list_cache_entries(cache_root):
204
+ if not entry.url or not entry.ref:
205
+ results.append(
206
+ RemoteTipResult(
207
+ id=entry.id,
208
+ behind=False,
209
+ url=entry.url,
210
+ ref=entry.ref,
211
+ error="no remote URL in meta"
212
+ if not entry.url
213
+ else "no branch/ref in meta",
214
+ )
215
+ )
216
+ continue
217
+ remote_sha = _remote_tip_sha(entry.url, entry.ref)
218
+ if not remote_sha:
219
+ results.append(
220
+ RemoteTipResult(
221
+ id=entry.id,
222
+ behind=False,
223
+ url=entry.url,
224
+ ref=entry.ref,
225
+ local_sha=entry.commit,
226
+ error="unable to fetch remote tip",
227
+ )
228
+ )
229
+ continue
230
+ behind = bool(entry.commit and remote_sha != entry.commit)
231
+ results.append(
232
+ RemoteTipResult(
233
+ id=entry.id,
234
+ behind=behind,
235
+ url=entry.url,
236
+ ref=entry.ref,
237
+ local_sha=entry.commit,
238
+ remote_sha=remote_sha,
239
+ )
240
+ )
241
+ return results
242
+
243
+
244
+ def update_cache(
245
+ entry_id: str | None = None,
246
+ *,
247
+ cache_root: Path | None = None,
248
+ ) -> tuple[list[str], list[str]]:
249
+ """Fetch/merge cached repos. Returns (updated_ids, failed_ids)."""
250
+ entries = list_cache_entries(cache_root)
251
+ targets = [e for e in entries if e.id == entry_id] if entry_id else entries
252
+ updated: list[str] = []
253
+ failed: list[str] = []
254
+ for entry in targets:
255
+ if not entry.url:
256
+ failed.append(entry.id)
257
+ continue
258
+ try:
259
+ _run_git(["fetch", "--all", "--tags"], cwd=entry.path, check=True)
260
+ ref = entry.ref
261
+ if ref:
262
+ branch_ref = ref if ref.startswith("refs/") else f"origin/{ref}"
263
+ try:
264
+ _run_git(
265
+ ["merge", "--ff-only", branch_ref],
266
+ cwd=entry.path,
267
+ check=True,
268
+ )
269
+ except subprocess.CalledProcessError:
270
+ _run_git(["checkout", ref], cwd=entry.path, check=True)
271
+ commit = _run_git(
272
+ ["rev-parse", "HEAD"], cwd=entry.path, check=True
273
+ ).stdout.strip()
274
+ write_cache_meta(
275
+ entry.path,
276
+ CacheMeta(
277
+ url=entry.url,
278
+ ref=entry.ref,
279
+ fetched_at=time.time(),
280
+ commit=commit,
281
+ ),
282
+ )
283
+ updated.append(entry.id)
284
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
285
+ failed.append(entry.id)
286
+ return updated, failed
287
+
288
+
289
+ def _probe_network() -> DoctorResult:
290
+ url = catalog_url()
291
+ try:
292
+ req = urllib.request.Request(url, method="HEAD")
293
+ with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
294
+ status = getattr(resp, "status", 200)
295
+ if 200 <= int(status) < 400:
296
+ return DoctorResult(
297
+ check="network",
298
+ ok=True,
299
+ detail=f"catalog reachable ({url})",
300
+ )
301
+ return DoctorResult(
302
+ check="network",
303
+ ok=False,
304
+ detail=f"HTTP {status} for {url}",
305
+ )
306
+ except urllib.error.HTTPError as exc:
307
+ # Some hosts reject HEAD; treat 4xx/405 as reachable enough for doctor.
308
+ if exc.code in {403, 404, 405}:
309
+ return DoctorResult(
310
+ check="network",
311
+ ok=True,
312
+ detail=f"catalog host reachable ({url})",
313
+ )
314
+ return DoctorResult(check="network", ok=False, detail=str(exc))
315
+ except Exception as exc: # noqa: BLE001 — doctor surfaces any probe failure
316
+ return DoctorResult(check="network", ok=False, detail=str(exc))
317
+
318
+
319
+ def run_doctor(cache_root: Path | None = None) -> list[DoctorResult]:
320
+ results: list[DoctorResult] = []
321
+ root = cache_root or default_cache_dir()
322
+
323
+ try:
324
+ ver = _run_git(["--version"], check=True).stdout.strip()
325
+ results.append(DoctorResult(check="git", ok=True, detail=ver))
326
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc:
327
+ results.append(
328
+ DoctorResult(check="git", ok=False, detail=f"git not found on PATH ({exc})")
329
+ )
330
+
331
+ results.append(_probe_network())
332
+
333
+ try:
334
+ root.mkdir(parents=True, exist_ok=True)
335
+ probe = root / ".doctor-probe"
336
+ probe.write_text("ok", encoding="utf-8")
337
+ probe.unlink(missing_ok=True)
338
+ results.append(DoctorResult(check="cache-dir", ok=True, detail=str(root)))
339
+ except OSError as exc:
340
+ results.append(DoctorResult(check="cache-dir", ok=False, detail=str(exc)))
341
+
342
+ entries = list_cache_entries(root)
343
+ corrupt = [e for e in entries if not run_git_fsck(e.path)]
344
+ if not corrupt:
345
+ results.append(
346
+ DoctorResult(
347
+ check="cache-integrity",
348
+ ok=True,
349
+ detail=f"{len(entries)} entries, all clean",
350
+ )
351
+ )
352
+ else:
353
+ ids = ", ".join(e.id for e in corrupt)
354
+ results.append(
355
+ DoctorResult(
356
+ check="cache-integrity",
357
+ ok=False,
358
+ detail=f"{len(corrupt)}/{len(entries)} entries failed git fsck: {ids}",
359
+ )
360
+ )
361
+ return results
362
+
363
+
364
+ def format_bytes(num: int) -> str:
365
+ if num < 1024:
366
+ return f"{num} B"
367
+ if num < 1024 * 1024:
368
+ return f"{num / 1024:.1f} KB"
369
+ if num < 1024 * 1024 * 1024:
370
+ return f"{num / (1024 * 1024):.1f} MB"
371
+ return f"{num / (1024 * 1024 * 1024):.2f} GB"
372
+
373
+
374
+ def format_age(fetched_at: float | None) -> str:
375
+ if fetched_at is None:
376
+ return "—"
377
+ minutes = int((time.time() - fetched_at) / 60)
378
+ if minutes < 1:
379
+ return "just now"
380
+ if minutes < 60:
381
+ return f"{minutes}m ago"
382
+ hours = minutes // 60
383
+ if hours < 24:
384
+ return f"{hours}h ago"
385
+ days = hours // 24
386
+ return f"{days}d ago"
387
+
388
+
389
+ def short_sha(sha: str | None) -> str:
390
+ if not sha:
391
+ return "—"
392
+ return sha[:7]
393
+
394
+
395
+ def entry_as_dict(entry: CacheEntry) -> dict[str, Any]:
396
+ return {
397
+ "id": entry.id,
398
+ "path": str(entry.path),
399
+ "url": entry.url,
400
+ "ref": entry.ref,
401
+ "fetched_at": entry.fetched_at,
402
+ "commit": entry.commit,
403
+ "size_bytes": entry.size_bytes,
404
+ "fsck_ok": entry.fsck_ok,
405
+ }