create-awesome-python-app 0.1.0__tar.gz → 0.2.1__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.
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/PKG-INFO +17 -2
- create_awesome_python_app-0.2.1/README.md +26 -0
- create_awesome_python_app-0.2.1/assets/hero.svg +19 -0
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/pyproject.toml +2 -2
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/src/create_awesome_python_app/__init__.py +1 -1
- create_awesome_python_app-0.2.1/src/create_awesome_python_app/cache.py +405 -0
- create_awesome_python_app-0.2.1/src/create_awesome_python_app/catalog.py +515 -0
- create_awesome_python_app-0.2.1/src/create_awesome_python_app/cli.py +588 -0
- create_awesome_python_app-0.2.1/tests/test_cache_cmds.py +200 -0
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/tests/test_cache_env.py +1 -2
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/tests/test_catalog.py +1 -2
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/tests/test_catalog_fetch.py +1 -2
- create_awesome_python_app-0.2.1/tests/test_catalog_resolve.py +257 -0
- create_awesome_python_app-0.2.1/tests/test_cli.py +225 -0
- create_awesome_python_app-0.2.1/tests/test_cpa_templates_integration.py +210 -0
- create_awesome_python_app-0.2.1/tests/test_flags.py +29 -0
- create_awesome_python_app-0.2.1/tests/test_interactive.py +405 -0
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/tests/test_smoke.py +2 -5
- create_awesome_python_app-0.1.0/README.md +0 -11
- create_awesome_python_app-0.1.0/assets/hero.svg +0 -4
- create_awesome_python_app-0.1.0/src/create_awesome_python_app/catalog.py +0 -182
- create_awesome_python_app-0.1.0/src/create_awesome_python_app/cli.py +0 -222
- create_awesome_python_app-0.1.0/tests/test_cache_cmds.py +0 -19
- create_awesome_python_app-0.1.0/tests/test_cli.py +0 -17
- create_awesome_python_app-0.1.0/tests/test_cpa_templates_integration.py +0 -96
- create_awesome_python_app-0.1.0/tests/test_flags.py +0 -19
- create_awesome_python_app-0.1.0/tests/test_interactive.py +0 -11
- {create_awesome_python_app-0.1.0 → create_awesome_python_app-0.2.1}/.gitignore +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: create-awesome-python-app
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Composable scaffolding CLI for production-ready Python apps
|
|
5
5
|
Project-URL: Homepage, https://github.com/Create-Python-App/create-python-app
|
|
6
6
|
Project-URL: Repository, https://github.com/Create-Python-App/create-python-app
|
|
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/Create-Python-App/create-python-app/issu
|
|
|
8
8
|
Project-URL: Changelog, https://github.com/Create-Python-App/create-python-app/blob/main/CHANGELOG.md
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
Requires-Python: >=3.12
|
|
11
|
-
Requires-Dist: create-python-app-core>=0.1
|
|
11
|
+
Requires-Dist: create-python-app-core>=0.2.1
|
|
12
12
|
Requires-Dist: questionary>=2.1.1
|
|
13
13
|
Requires-Dist: rich>=15.0.0
|
|
14
14
|
Requires-Dist: typer>=0.27.0
|
|
@@ -25,3 +25,18 @@ uv run create-awesome-python-app --help
|
|
|
25
25
|
uvx create-awesome-python-app@latest my-app # after PyPI publish
|
|
26
26
|
pipx run create-awesome-python-app my-app
|
|
27
27
|
```
|
|
28
|
+
|
|
29
|
+
## Shell completion
|
|
30
|
+
|
|
31
|
+
Typer installs completion scripts for bash, zsh, and fish:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Interactive install into your shell profile
|
|
35
|
+
create-awesome-python-app --install-completion
|
|
36
|
+
|
|
37
|
+
# Or print the script and source it manually
|
|
38
|
+
create-awesome-python-app --show-completion
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
After installing, restart the shell (or `source` your profile) and tab-complete
|
|
42
|
+
flags such as `--template`, `--addons`, and `cache` subcommands.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# create-awesome-python-app
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
CLI package. Framework: **Typer** (chosen over Click for richer typing/help).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv run create-awesome-python-app --help
|
|
9
|
+
uvx create-awesome-python-app@latest my-app # after PyPI publish
|
|
10
|
+
pipx run create-awesome-python-app my-app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Shell completion
|
|
14
|
+
|
|
15
|
+
Typer installs completion scripts for bash, zsh, and fish:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Interactive install into your shell profile
|
|
19
|
+
create-awesome-python-app --install-completion
|
|
20
|
+
|
|
21
|
+
# Or print the script and source it manually
|
|
22
|
+
create-awesome-python-app --show-completion
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
After installing, restart the shell (or `source` your profile) and tab-complete
|
|
26
|
+
flags such as `--template`, `--addons`, and `cache` subcommands.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" role="img" aria-label="Create Awesome Python App">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#3b82f6"/>
|
|
5
|
+
<stop offset="55%" stop-color="#2563eb"/>
|
|
6
|
+
<stop offset="100%" stop-color="#16a34a"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect width="100%" height="100%" fill="#0f172a"/>
|
|
10
|
+
<circle cx="60" cy="70" r="10" fill="#3b82f6" fill-opacity="0.9"/>
|
|
11
|
+
<circle cx="120" cy="70" r="10" fill="#16a34a" fill-opacity="0.9"/>
|
|
12
|
+
<circle cx="90" cy="110" r="14" fill="url(#g)"/>
|
|
13
|
+
<circle cx="90" cy="155" r="8" fill="#16a34a" fill-opacity="0.75"/>
|
|
14
|
+
<line x1="68" y1="78" x2="82" y2="100" stroke="#3b82f6" stroke-opacity="0.5" stroke-width="3" stroke-linecap="round"/>
|
|
15
|
+
<line x1="112" y1="78" x2="98" y2="100" stroke="#16a34a" stroke-opacity="0.5" stroke-width="3" stroke-linecap="round"/>
|
|
16
|
+
<line x1="90" y1="124" x2="90" y2="147" stroke="#16a34a" stroke-opacity="0.45" stroke-width="3" stroke-linecap="round"/>
|
|
17
|
+
<text x="160" y="105" fill="url(#g)" font-size="36" font-weight="700" font-family="ui-sans-serif, system-ui, sans-serif">Create Awesome Python App</text>
|
|
18
|
+
<text x="160" y="145" fill="#94a3b8" font-size="18" font-family="ui-sans-serif, system-ui, sans-serif">One command. Any Python stack. · Beta</text>
|
|
19
|
+
</svg>
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "create-awesome-python-app"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Composable scaffolding CLI for production-ready Python apps"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
license = "MIT"
|
|
8
8
|
dependencies = [
|
|
9
|
-
"create-python-app-core>=0.1
|
|
9
|
+
"create-python-app-core>=0.2.1",
|
|
10
10
|
"questionary>=2.1.1",
|
|
11
11
|
"rich>=15.0.0",
|
|
12
12
|
"typer>=0.27.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
|
+
}
|