Skip to content

Commit c7ef909

Browse files
feat(cli): wire --refresh/--pin through git cache
Closes #165.
1 parent abaeba4 commit c7ef909

6 files changed

Lines changed: 260 additions & 7 deletions

File tree

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

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
print_env_info,
2121
resolve_source,
2222
)
23+
from create_python_app_core.git_cache import RefreshMode
2324
from rich.console import Console
2425

2526
from create_awesome_python_app import __version__
@@ -57,6 +58,16 @@ def _parse_set_options(set_opt: list[str] | None) -> dict[str, str]:
5758
return set_map
5859

5960

61+
def _normalize_refresh(refresh: str | None) -> RefreshMode | None:
62+
if refresh == "always":
63+
return "always"
64+
if refresh == "stale":
65+
return "stale"
66+
if refresh == "manual":
67+
return "manual"
68+
return None
69+
70+
6071
def _stringify_option_value(value: Any) -> str:
6172
if value is None:
6273
return ""
@@ -88,12 +99,18 @@ def _prompt_custom_options(
8899
set_map: dict[str, str],
89100
cache_dir: Path | None,
90101
offline: bool,
102+
refresh: str | None,
91103
registry_options: list[dict[str, Any]] | None = None,
92104
) -> dict[str, str]:
93105
import questionary
94106

95107
source = resolve_source(template, cache_dir=cache_dir)
96-
root = download_repository(source, offline=offline, cache_root=cache_dir)
108+
root = download_repository(
109+
source,
110+
offline=offline,
111+
refresh=_normalize_refresh(refresh),
112+
cache_root=cache_dir,
113+
)
97114
try:
98115
config = load_cpa_config(_template_config_path(source.subdir, root))
99116
except ConfigParseError as err:
@@ -189,14 +206,22 @@ def scaffold(
189206
la(template)
190207
raise typer.Exit(0)
191208

209+
effective_refresh = _normalize_refresh(refresh)
210+
if refresh and effective_refresh is None:
211+
console.print(
212+
"[red]Invalid --refresh mode: "
213+
f"'{refresh}'. Use one of: always, stale, manual.[/red]"
214+
)
215+
raise typer.Exit(2)
216+
192217
# env wiring (#36)
193218
if no_cache:
194219
os.environ["CPA_NO_CATALOG_CACHE"] = "1"
195-
os.environ["CPA_REFRESH"] = "always"
220+
effective_refresh = effective_refresh or "always"
196221
if cache_dir:
197222
os.environ["CPA_CACHE_DIR"] = str(cache_dir)
198-
if refresh:
199-
os.environ["CPA_REFRESH"] = refresh
223+
if effective_refresh:
224+
os.environ["CPA_REFRESH"] = effective_refresh
200225
if offline:
201226
pass # passed to core
202227

@@ -339,6 +364,7 @@ def scaffold(
339364
set_map=set_map,
340365
cache_dir=cache_dir,
341366
offline=offline,
367+
refresh=effective_refresh,
342368
registry_options=registry_options,
343369
)
344370
except ImportError:
@@ -371,6 +397,7 @@ def scaffold(
371397
"force": force,
372398
"verbose": verbose,
373399
"offline": offline,
400+
"refresh": effective_refresh,
374401
"keep_on_failure": keep_on_failure,
375402
"cache_dir": str(cache_dir) if cache_dir else None,
376403
"set": set_map,

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
from pathlib import Path
2+
3+
import pytest
14
from create_awesome_python_app.cli import app
25
from typer.testing import CliRunner
36

47
runner = CliRunner()
8+
_CPA_ENV_VARS = ("CPA_REFRESH", "CPA_NO_CATALOG_CACHE", "CPA_CACHE_DIR")
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def _clean_cpa_env(monkeypatch):
13+
for name in _CPA_ENV_VARS:
14+
monkeypatch.delenv(name, raising=False)
15+
yield
16+
for name in _CPA_ENV_VARS:
17+
monkeypatch.delenv(name, raising=False)
518

619

720
def test_version() -> None:
@@ -15,3 +28,143 @@ def test_help() -> None:
1528
result = runner.invoke(app, ["--help"])
1629
assert result.exit_code == 0
1730
assert "Scaffold" in result.stdout or "create" in result.stdout.lower()
31+
32+
33+
def test_refresh_flag_is_forwarded(tmp_path: Path, monkeypatch) -> None:
34+
tpl = tmp_path / "tpl"
35+
tpl.mkdir()
36+
captured: dict[str, object] = {}
37+
38+
async def fake_check_for_latest_version(_package_name):
39+
return None
40+
41+
async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
42+
captured["project_directory"] = project_directory
43+
captured["options"] = options
44+
45+
monkeypatch.setattr(
46+
"create_awesome_python_app.cli.check_for_latest_version",
47+
fake_check_for_latest_version,
48+
)
49+
monkeypatch.setattr(
50+
"create_awesome_python_app.cli.create_python_app",
51+
fake_create_python_app,
52+
)
53+
54+
result = runner.invoke(
55+
app,
56+
[
57+
"--template",
58+
f"file://{tpl}",
59+
"--refresh",
60+
"always",
61+
"--no-install",
62+
"--no-interactive",
63+
"api",
64+
],
65+
)
66+
67+
assert result.exit_code == 0, result.stdout + result.stderr
68+
options = captured["options"]
69+
assert isinstance(options, dict)
70+
assert options["refresh"] == "always"
71+
72+
73+
def test_invalid_refresh_mode_fails(tmp_path: Path) -> None:
74+
tpl = tmp_path / "tpl"
75+
tpl.mkdir()
76+
77+
result = runner.invoke(
78+
app,
79+
[
80+
"--template",
81+
f"file://{tpl}",
82+
"--refresh",
83+
"bogus",
84+
"--no-install",
85+
"--no-interactive",
86+
"api",
87+
],
88+
)
89+
90+
assert result.exit_code == 2
91+
assert "Invalid --refresh mode" in result.stdout + result.stderr
92+
93+
94+
def test_no_cache_sets_explicit_refresh(tmp_path: Path, monkeypatch) -> None:
95+
tpl = tmp_path / "tpl"
96+
tpl.mkdir()
97+
captured: dict[str, object] = {}
98+
99+
async def fake_check_for_latest_version(_package_name):
100+
return None
101+
102+
async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
103+
captured["project_directory"] = project_directory
104+
captured["options"] = options
105+
106+
monkeypatch.setattr(
107+
"create_awesome_python_app.cli.check_for_latest_version",
108+
fake_check_for_latest_version,
109+
)
110+
monkeypatch.setattr(
111+
"create_awesome_python_app.cli.create_python_app",
112+
fake_create_python_app,
113+
)
114+
115+
result = runner.invoke(
116+
app,
117+
[
118+
"--template",
119+
f"file://{tpl}",
120+
"--no-cache",
121+
"--no-install",
122+
"--no-interactive",
123+
"api",
124+
],
125+
)
126+
127+
assert result.exit_code == 0, result.stdout + result.stderr
128+
options = captured["options"]
129+
assert isinstance(options, dict)
130+
assert options["refresh"] == "always"
131+
132+
133+
def test_pin_appends_ref_to_template_url(tmp_path: Path, monkeypatch) -> None:
134+
tpl = tmp_path / "tpl"
135+
tpl.mkdir()
136+
captured: dict[str, object] = {}
137+
138+
async def fake_check_for_latest_version(_package_name):
139+
return None
140+
141+
async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
142+
captured["project_directory"] = project_directory
143+
captured["options"] = options
144+
145+
monkeypatch.setattr(
146+
"create_awesome_python_app.cli.check_for_latest_version",
147+
fake_check_for_latest_version,
148+
)
149+
monkeypatch.setattr(
150+
"create_awesome_python_app.cli.create_python_app",
151+
fake_create_python_app,
152+
)
153+
154+
result = runner.invoke(
155+
app,
156+
[
157+
"--template",
158+
f"file://{tpl}",
159+
"--pin",
160+
"abc123",
161+
"--no-install",
162+
"--no-interactive",
163+
"api",
164+
],
165+
)
166+
167+
assert result.exit_code == 0, result.stdout + result.stderr
168+
options = captured["options"]
169+
assert isinstance(options, dict)
170+
assert options["template"] == f"file://{tpl}?ref=abc123"

packages/create-python-app-core/src/create_python_app_core/api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any
1111

1212
from create_python_app_core._version import __version__
13+
from create_python_app_core.git_cache import RefreshMode
1314

1415
CPA_USER_AGENT = (
1516
f"create-python-app-core/{__version__} "
@@ -84,6 +85,10 @@ async def create_python_app(
8485
options = await transform_options(options)
8586

8687
cache = options.get("cache_dir")
88+
refresh = options.get("refresh")
89+
refresh_mode: RefreshMode | None = (
90+
refresh if refresh in ("always", "stale", "manual") else None
91+
)
8792
scaffold_project(
8893
project_directory,
8994
template=str(options.get("template") or ""),
@@ -92,6 +97,7 @@ async def create_python_app(
9297
force=bool(options.get("force", False)),
9398
install=bool(options.get("install", True)),
9499
offline=bool(options.get("offline", False)),
100+
refresh=refresh_mode,
95101
keep_on_failure=bool(options.get("keep_on_failure", False)),
96102
cache_dir=Path(cache) if cache else None,
97103
options=options,

packages/create-python-app-core/src/create_python_app_core/installer.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
load_cpa_config,
1515
)
1616
from create_python_app_core.errors import CpaError, ScaffoldAbortedError
17-
from create_python_app_core.git_cache import download_repository
17+
from create_python_app_core.git_cache import RefreshMode, download_repository
1818
from create_python_app_core.loaders import merge_layers
1919
from create_python_app_core.paths import ResolvedSource, resolve_source
2020

@@ -67,6 +67,7 @@ def scaffold_project(
6767
force: bool = False,
6868
install: bool = True,
6969
offline: bool = False,
70+
refresh: RefreshMode | None = None,
7071
keep_on_failure: bool = False,
7172
cache_dir: Path | None = None,
7273
options: dict[str, Any] | None = None,
@@ -82,7 +83,12 @@ def scaffold_project(
8283
try:
8384
for spec in specs:
8485
source = resolve_source(spec, cache_dir=cache_dir)
85-
root = download_repository(source, offline=offline, cache_root=cache_dir)
86+
root = download_repository(
87+
source,
88+
offline=offline,
89+
refresh=refresh,
90+
cache_root=cache_dir,
91+
)
8692
layers.append((source, root))
8793
configs.append(load_cpa_config(_config_path(source, root)))
8894

packages/create-python-app-core/tests/test_api.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import asyncio
2+
13
import pytest
2-
from create_python_app_core import check_python_version
4+
from create_python_app_core import check_python_version, create_python_app
35

46

57
def test_check_python_version_accepts_current() -> None:
@@ -9,3 +11,30 @@ def test_check_python_version_accepts_current() -> None:
911
def test_check_python_version_rejects_impossible() -> None:
1012
with pytest.raises(SystemExit):
1113
check_python_version(">=99.0", "create-python-app-core")
14+
15+
16+
def test_create_python_app_forwards_refresh(monkeypatch) -> None:
17+
captured: dict[str, object] = {}
18+
19+
def fake_scaffold_project(project_directory: str, **kwargs) -> None:
20+
captured["project_directory"] = project_directory
21+
captured.update(kwargs)
22+
23+
monkeypatch.setattr(
24+
"create_python_app_core.installer.scaffold_project",
25+
fake_scaffold_project,
26+
)
27+
28+
asyncio.run(
29+
create_python_app(
30+
"api",
31+
{
32+
"template": "file:///template",
33+
"install": False,
34+
"refresh": "always",
35+
},
36+
)
37+
)
38+
39+
assert captured["project_directory"] == "api"
40+
assert captured["refresh"] == "always"

packages/create-python-app-core/tests/test_installer.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from create_python_app_core.installer import scaffold_project
5+
from create_python_app_core.paths import ResolvedSource
56

67

78
def _tpl(tmp: Path, name: str) -> str:
@@ -20,3 +21,34 @@ def test_scaffold_file_template(
2021
url = _tpl(tmp_path, "tpl")
2122
scaffold_project(str(dest), template=url, install=False)
2223
assert (dest / "hello.txt").read_text() == "hi"
24+
25+
26+
def test_scaffold_forwards_refresh_to_download_repository(
27+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
28+
) -> None:
29+
monkeypatch.setenv("CPA_SKIP_GIT", "1")
30+
dest = tmp_path / "app"
31+
url = _tpl(tmp_path, "tpl")
32+
refresh_values: list[str | None] = []
33+
34+
def fake_download_repository(
35+
source: ResolvedSource,
36+
*,
37+
offline: bool = False,
38+
refresh: str | None = None,
39+
cache_root: Path | None = None,
40+
) -> Path:
41+
refresh_values.append(refresh)
42+
assert offline is False
43+
assert cache_root is None
44+
assert source.local_path is not None
45+
return source.local_path
46+
47+
monkeypatch.setattr(
48+
"create_python_app_core.installer.download_repository",
49+
fake_download_repository,
50+
)
51+
52+
scaffold_project(str(dest), template=url, install=False, refresh="always")
53+
54+
assert refresh_values == ["always"]

0 commit comments

Comments
 (0)