Skip to content

Commit 16f293c

Browse files
Merge pull request #236 from Create-Python-App/feat/issue-228-229-cache-json-confirm
feat(cli): cache --json output and clean confirmation
2 parents 2bd780e + 57d6ad2 commit 16f293c

2 files changed

Lines changed: 145 additions & 4 deletions

File tree

packages/create-awesome-python-app/src/create_awesome_python_app/cli.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -497,8 +497,29 @@ def cache_dir_cmd() -> None:
497497
console.print(str(default_cache_dir()))
498498

499499

500+
def _cache_json_print(payload: Any) -> None:
501+
"""Print machine-readable JSON to stdout (not the stderr console)."""
502+
import json
503+
from dataclasses import asdict, is_dataclass
504+
505+
def _to_jsonable(value: Any) -> Any:
506+
if isinstance(value, Path):
507+
return str(value)
508+
if is_dataclass(value) and not isinstance(value, type):
509+
return {k: _to_jsonable(v) for k, v in asdict(value).items()}
510+
if isinstance(value, list):
511+
return [_to_jsonable(item) for item in value]
512+
if isinstance(value, dict):
513+
return {k: _to_jsonable(v) for k, v in value.items()}
514+
return value
515+
516+
typer.echo(json.dumps(_to_jsonable(payload), indent=2))
517+
518+
500519
@cache_app.command("list")
501-
def cache_list_cmd() -> None:
520+
def cache_list_cmd(
521+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
522+
) -> None:
502523
from create_awesome_python_app.cache import (
503524
format_age,
504525
format_bytes,
@@ -507,6 +528,9 @@ def cache_list_cmd() -> None:
507528
)
508529

509530
entries = list_cache_entries()
531+
if json_out:
532+
_cache_json_print(entries)
533+
return
510534
if not entries:
511535
console.print("[dim]No cached templates or extensions.[/dim]")
512536
console.print(
@@ -541,10 +565,38 @@ def cache_list_cmd() -> None:
541565
def cache_clean_cmd(
542566
id: str | None = typer.Argument(None),
543567
catalog: bool = typer.Option(False, "--catalog"),
568+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
569+
force: bool = typer.Option(
570+
False, "--force", "-f", help="Skip interactive confirmation"
571+
),
544572
) -> None:
545573
from create_awesome_python_app.cache import clean_cache
546574

575+
# Targeted cleans (id / --catalog) never prompt.
576+
if not catalog and id is None and not json_out and not force:
577+
if not sys.stdin.isatty():
578+
console.print(
579+
"[yellow]Non-interactive shell — use --json or --force to skip "
580+
"the prompt, or specify an id to target a specific entry.[/yellow]"
581+
)
582+
return
583+
import questionary
584+
585+
from create_awesome_python_app.prompt_style import CPA_PROMPT_STYLE
586+
587+
confirmed = questionary.confirm(
588+
"Remove ALL cached templates and extensions?",
589+
default=False,
590+
style=CPA_PROMPT_STYLE,
591+
).ask()
592+
if not confirmed:
593+
console.print("[dim]Clean cancelled.[/dim]")
594+
return
595+
547596
result = clean_cache(id, catalog=catalog)
597+
if json_out:
598+
_cache_json_print(result)
599+
return
548600
if result.not_found:
549601
console.print(f"[yellow]No cache entry found for id: {id}[/yellow]")
550602
return
@@ -556,10 +608,18 @@ def cache_clean_cmd(
556608

557609

558610
@cache_app.command("verify")
559-
def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
611+
def cache_verify_cmd(
612+
id: str | None = typer.Argument(None),
613+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
614+
) -> None:
560615
from create_awesome_python_app.cache import verify_cache
561616

562617
results = verify_cache(id)
618+
if json_out:
619+
_cache_json_print(results)
620+
if any(not bool(entry.fsck_ok) for entry in results):
621+
raise typer.Exit(1)
622+
raise typer.Exit(0)
563623
if not results:
564624
console.print("[dim]No cached entries.[/dim]")
565625
raise typer.Exit(0)
@@ -580,10 +640,15 @@ def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None:
580640

581641

582642
@cache_app.command("outdated")
583-
def cache_outdated_cmd() -> None:
643+
def cache_outdated_cmd(
644+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
645+
) -> None:
584646
from create_awesome_python_app.cache import check_outdated
585647

586648
results = check_outdated()
649+
if json_out:
650+
_cache_json_print(results)
651+
return
587652
if not results:
588653
console.print("[dim]No cached entries to check.[/dim]")
589654
return
@@ -643,10 +708,17 @@ def cache_update_cmd(id: str | None = typer.Argument(None)) -> None:
643708

644709

645710
@cache_app.command("doctor")
646-
def cache_doctor_cmd() -> None:
711+
def cache_doctor_cmd(
712+
json_out: bool = typer.Option(False, "--json", help="Output as JSON"),
713+
) -> None:
647714
from create_awesome_python_app.cache import run_doctor
648715

649716
results = run_doctor()
717+
if json_out:
718+
_cache_json_print(results)
719+
if any(not row.ok for row in results):
720+
raise typer.Exit(1)
721+
raise typer.Exit(0)
650722
all_ok = True
651723
for row in results:
652724
mark = "[green]✓[/green]" if row.ok else "[red]✗[/red]"

packages/create-awesome-python-app/tests/test_cache_cmds.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,72 @@ def test_list_cli_shows_table(cache_root: Path) -> None:
198198
text = _out(result)
199199
assert "demo" in text
200200
assert "SHA" in text
201+
202+
203+
def test_cache_list_verify_doctor_json(
204+
cache_root: Path, monkeypatch: pytest.MonkeyPatch
205+
) -> None:
206+
import json
207+
208+
entry = cache_root / "repos" / "demo"
209+
sha = _init_git_repo(entry)
210+
write_cache_meta(
211+
entry,
212+
CacheMeta(
213+
url="https://example.com/repo.git",
214+
ref="main",
215+
fetched_at=time.time(),
216+
commit=sha,
217+
),
218+
)
219+
monkeypatch.setattr(
220+
"create_awesome_python_app.cache._probe_network",
221+
lambda: __import__(
222+
"create_awesome_python_app.cache", fromlist=["DoctorResult"]
223+
).DoctorResult(check="network", ok=True, detail="mocked"),
224+
)
225+
226+
listed = runner.invoke(cache_app, ["list", "--json"])
227+
assert listed.exit_code == 0, _out(listed)
228+
payload = json.loads(listed.stdout)
229+
assert payload[0]["id"] == "demo"
230+
assert payload[0]["commit"] == sha
231+
232+
verified = runner.invoke(cache_app, ["verify", "--json"])
233+
assert verified.exit_code == 0, _out(verified)
234+
assert json.loads(verified.stdout)[0]["fsck_ok"] is True
235+
236+
doctor = runner.invoke(cache_app, ["doctor", "--json"])
237+
assert doctor.exit_code == 0, _out(doctor)
238+
assert any(row["check"] == "git" for row in json.loads(doctor.stdout))
239+
240+
outdated = runner.invoke(cache_app, ["outdated", "--json"])
241+
assert outdated.exit_code == 0, _out(outdated)
242+
assert isinstance(json.loads(outdated.stdout), list)
243+
244+
245+
def test_cache_clean_json_and_force(cache_root: Path) -> None:
246+
import json
247+
248+
entry = cache_root / "repos" / "demo"
249+
_init_git_repo(entry)
250+
251+
blocked = runner.invoke(cache_app, ["clean"])
252+
assert blocked.exit_code == 0
253+
assert "Non-interactive" in _out(blocked)
254+
assert entry.exists()
255+
256+
forced = runner.invoke(cache_app, ["clean", "--json"])
257+
assert forced.exit_code == 0, _out(forced)
258+
payload = json.loads(forced.stdout)
259+
assert str(entry) in payload["removed"]
260+
assert not entry.exists()
261+
262+
263+
def test_cache_clean_force_without_json(cache_root: Path) -> None:
264+
entry = cache_root / "repos" / "demo"
265+
_init_git_repo(entry)
266+
result = runner.invoke(cache_app, ["clean", "--force"])
267+
assert result.exit_code == 0, _out(result)
268+
assert "Removed" in _out(result)
269+
assert not entry.exists()

0 commit comments

Comments
 (0)