Skip to content

Commit 00b314c

Browse files
feat(core): validate extension incompatibleWith combinations
Fail fast when selected catalog extensions (or cpa.config.json layers) declare mutual incompatibility, matching CNA templates.json semantics. Closes #168 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cf0d014 commit 00b314c

8 files changed

Lines changed: 301 additions & 2 deletions

File tree

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,82 @@ def resolve_catalog_specs(
8787
return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs]
8888

8989

90+
class IncompatibleExtensionsError(ValueError):
91+
"""Raised when selected extensions declare mutual incompatibility."""
92+
93+
def __init__(self, pairs: list[tuple[str, str]]) -> None:
94+
self.pairs = pairs
95+
rendered = ", ".join(f"'{a}' ↔ '{b}'" for a, b in pairs)
96+
super().__init__(
97+
"Incompatible extension combination: "
98+
f"{rendered}. Remove one of each conflicting pair and retry."
99+
)
100+
101+
102+
def _extension_entries(catalog: dict[str, Any]) -> list[dict[str, Any]]:
103+
raw = catalog.get("extensions", catalog.get("addons", []))
104+
if not isinstance(raw, list):
105+
return []
106+
return [entry for entry in raw if isinstance(entry, dict)]
107+
108+
109+
def find_extension_entry(catalog: dict[str, Any], spec: str) -> dict[str, Any] | None:
110+
"""Find an extension by slug or URL."""
111+
for entry in _extension_entries(catalog):
112+
slug = str(entry.get("slug", ""))
113+
url = str(entry.get("url", ""))
114+
if spec in (slug, url):
115+
return entry
116+
return None
117+
118+
119+
def find_incompatible_pairs(
120+
specs: list[str], *, catalog: dict[str, Any] | None = None
121+
) -> list[tuple[str, str]]:
122+
"""Return ordered (slug, conflicting_slug) pairs among *specs*."""
123+
data = catalog if catalog is not None else get_catalog_data()
124+
selected: list[dict[str, Any]] = []
125+
seen_slugs: set[str] = set()
126+
for spec in specs:
127+
entry = find_extension_entry(data, spec)
128+
if entry is None:
129+
continue
130+
slug = str(entry.get("slug", ""))
131+
if not slug or slug in seen_slugs:
132+
continue
133+
seen_slugs.add(slug)
134+
selected.append(entry)
135+
136+
selected_slugs = {str(entry.get("slug", "")) for entry in selected}
137+
pairs: list[tuple[str, str]] = []
138+
reported: set[tuple[str, str]] = set()
139+
for entry in selected:
140+
slug = str(entry.get("slug", ""))
141+
raw = entry.get("incompatibleWith") or entry.get("incompatible_with") or []
142+
if not isinstance(raw, list):
143+
continue
144+
for other in raw:
145+
other_slug = str(other)
146+
if other_slug not in selected_slugs or other_slug == slug:
147+
continue
148+
first, second = sorted((slug, other_slug))
149+
key = (first, second)
150+
if key in reported:
151+
continue
152+
reported.add(key)
153+
pairs.append((slug, other_slug))
154+
return pairs
155+
156+
157+
def validate_extension_compatibility(
158+
specs: list[str], *, catalog: dict[str, Any] | None = None
159+
) -> None:
160+
"""Fail fast when selected catalog extensions are mutually incompatible."""
161+
pairs = find_incompatible_pairs(specs, catalog=catalog)
162+
if pairs:
163+
raise IncompatibleExtensionsError(pairs)
164+
165+
90166
def short_category_label(category_name: str) -> str:
91167
"""Derive a compact badge label from a catalog category name."""
92168
stop_words = {"Applications", "Application", "Boilerplate"}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,23 @@ def scaffold(
339339
console.print("[red]questionary not available[/red]")
340340
raise typer.Exit(1) from None
341341

342+
from create_awesome_python_app.catalog import (
343+
IncompatibleExtensionsError as CatalogIncompatibleExtensionsError,
344+
)
345+
from create_awesome_python_app.catalog import (
346+
get_catalog_data,
347+
validate_extension_compatibility,
348+
)
349+
350+
try:
351+
validate_extension_compatibility(
352+
[*(addons or []), *(extend or [])],
353+
catalog=interactive_catalog or get_catalog_data(),
354+
)
355+
except CatalogIncompatibleExtensionsError as err:
356+
console.print(f"[red]{err}[/red]")
357+
raise typer.Exit(2) from err
358+
342359
if want_interactive:
343360
try:
344361
from create_awesome_python_app.catalog import (

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@
66
from create_awesome_python_app.catalog import (
77
CUSTOM_TEMPLATE_SENTINEL,
88
CatalogResolutionError,
9+
IncompatibleExtensionsError,
910
build_extension_choices,
1011
build_template_choices,
12+
find_incompatible_pairs,
1113
group_extension_choices,
1214
is_url_like,
1315
resolve_catalog_spec,
1416
resolve_catalog_specs,
1517
short_category_label,
18+
validate_extension_compatibility,
1619
)
1720

1821
SAMPLE_CATALOG = {
@@ -196,3 +199,59 @@ def test_group_extension_choices_preserves_category_order() -> None:
196199

197200
assert list(grouped) == ["ci", "data"]
198201
assert grouped["ci"][0].value == "file:///extensions/github"
202+
203+
204+
def test_validate_extension_compatibility_ok() -> None:
205+
catalog = {
206+
"extensions": [
207+
{
208+
"slug": "github-setup",
209+
"url": "file:///ext/github",
210+
"incompatibleWith": ["other"],
211+
},
212+
{"slug": "python-docker", "url": "file:///ext/docker"},
213+
]
214+
}
215+
validate_extension_compatibility(["github-setup", "python-docker"], catalog=catalog)
216+
217+
218+
def test_validate_extension_compatibility_fails_on_pair() -> None:
219+
catalog = {
220+
"extensions": [
221+
{
222+
"slug": "react-redux-saga",
223+
"url": "file:///ext/saga",
224+
"incompatibleWith": ["react-redux-thunk"],
225+
},
226+
{
227+
"slug": "react-redux-thunk",
228+
"url": "file:///ext/thunk",
229+
"incompatibleWith": ["react-redux-saga"],
230+
},
231+
]
232+
}
233+
with pytest.raises(IncompatibleExtensionsError, match="react-redux-saga") as ei:
234+
validate_extension_compatibility(
235+
["react-redux-saga", "file:///ext/thunk"],
236+
catalog=catalog,
237+
)
238+
assert ei.value.pairs == [("react-redux-saga", "react-redux-thunk")]
239+
240+
241+
def test_find_incompatible_pairs_dedupes_symmetric_edges() -> None:
242+
catalog = {
243+
"extensions": [
244+
{
245+
"slug": "a",
246+
"url": "file:///a",
247+
"incompatibleWith": ["b"],
248+
},
249+
{
250+
"slug": "b",
251+
"url": "file:///b",
252+
"incompatibleWith": ["a"],
253+
},
254+
]
255+
}
256+
pairs = find_incompatible_pairs(["a", "b"], catalog=catalog)
257+
assert pairs == [("a", "b")]

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,57 @@ async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
168168
options = captured["options"]
169169
assert isinstance(options, dict)
170170
assert options["template"] == f"file://{tpl}?ref=abc123"
171+
172+
173+
def test_incompatible_addons_fail_fast(tmp_path: Path, monkeypatch) -> None:
174+
tpl = tmp_path / "tpl"
175+
tpl.mkdir()
176+
catalog = {
177+
"templates": [
178+
{"slug": "fastapi-starter", "url": f"file://{tpl}"},
179+
],
180+
"extensions": [
181+
{
182+
"slug": "saga",
183+
"url": "file:///ext/saga",
184+
"incompatibleWith": ["thunk"],
185+
},
186+
{
187+
"slug": "thunk",
188+
"url": "file:///ext/thunk",
189+
"incompatibleWith": ["saga"],
190+
},
191+
],
192+
}
193+
194+
async def fake_check_for_latest_version(_package_name):
195+
return None
196+
197+
monkeypatch.setattr(
198+
"create_awesome_python_app.cli.check_for_latest_version",
199+
fake_check_for_latest_version,
200+
)
201+
monkeypatch.setattr(
202+
"create_awesome_python_app.catalog.get_catalog_data",
203+
lambda: catalog,
204+
)
205+
206+
result = runner.invoke(
207+
app,
208+
[
209+
"--template",
210+
"fastapi-starter",
211+
"--addons",
212+
"saga",
213+
"--addons",
214+
"thunk",
215+
"--no-install",
216+
"--no-interactive",
217+
"api",
218+
],
219+
)
220+
221+
text = (result.stdout or "") + (result.stderr or "")
222+
assert result.exit_code == 2
223+
assert "Incompatible extension combination" in text
224+
assert "saga" in text

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
NON_EMPTY_DIR_ERROR_CODE,
2121
ConfigParseError,
2222
CpaError,
23+
IncompatibleExtensionsError,
2324
ManifestLoadError,
2425
NonEmptyTargetDirectoryError,
2526
PackageManagerFallbackError,
@@ -48,6 +49,7 @@
4849
"PackageManagerFallbackError",
4950
"ScaffoldAbortedError",
5051
"NonEmptyTargetDirectoryError",
52+
"IncompatibleExtensionsError",
5153
"NON_EMPTY_DIR_ERROR_CODE",
5254
"default_cache_dir",
5355
"resolve_source",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ class NonEmptyTargetDirectoryError(CpaError):
3434
code = "CPA_NON_EMPTY_TARGET_DIR"
3535

3636

37+
class IncompatibleExtensionsError(CpaError):
38+
code = "CPA_INCOMPATIBLE_EXTENSIONS"
39+
40+
3741
NON_EMPTY_DIR_ERROR_CODE = NonEmptyTargetDirectoryError.code

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
assert_directory_is_empty,
1414
load_cpa_config,
1515
)
16-
from create_python_app_core.errors import CpaError, ScaffoldAbortedError
16+
from create_python_app_core.errors import (
17+
CpaError,
18+
IncompatibleExtensionsError,
19+
ScaffoldAbortedError,
20+
)
1721
from create_python_app_core.git_cache import RefreshMode, download_repository
1822
from create_python_app_core.loaders import merge_layers
1923
from create_python_app_core.paths import ResolvedSource, resolve_source
@@ -58,6 +62,42 @@ def build_scaffold_context(
5862
return context
5963

6064

65+
def _config_incompatible_list(cfg: CpaConfig) -> list[str]:
66+
raw = cfg.raw.get("incompatibleWith") or cfg.raw.get("incompatible_with") or []
67+
if not isinstance(raw, list):
68+
return []
69+
return [str(item) for item in raw]
70+
71+
72+
def validate_config_incompatible_extensions(configs: list[CpaConfig]) -> None:
73+
"""Fail when loaded cpa.config.json layers declare mutual incompatibility.
74+
75+
The template config (first entry) is ignored; only addon/extend layers are
76+
checked. Matches are against each layer's ``name`` field (slug-like id).
77+
"""
78+
addon_configs = [cfg for cfg in configs[1:] if cfg.name]
79+
names = {str(cfg.name) for cfg in addon_configs}
80+
pairs: list[tuple[str, str]] = []
81+
reported: set[tuple[str, str]] = set()
82+
for cfg in addon_configs:
83+
name = str(cfg.name)
84+
for other in _config_incompatible_list(cfg):
85+
if other not in names or other == name:
86+
continue
87+
first, second = sorted((name, other))
88+
key = (first, second)
89+
if key in reported:
90+
continue
91+
reported.add(key)
92+
pairs.append((name, other))
93+
if pairs:
94+
rendered = ", ".join(f"'{a}' ↔ '{b}'" for a, b in pairs)
95+
raise IncompatibleExtensionsError(
96+
"Incompatible extension combination from cpa.config.json: "
97+
f"{rendered}. Remove one of each conflicting pair and retry."
98+
)
99+
100+
61101
def scaffold_project(
62102
project_directory: str,
63103
*,
@@ -92,6 +132,7 @@ def scaffold_project(
92132
layers.append((source, root))
93133
configs.append(load_cpa_config(_config_path(source, root)))
94134

135+
validate_config_incompatible_extensions(configs)
95136
context = build_scaffold_context(dest.name, configs, options)
96137
merge_layers(layers, dest, context=context)
97138

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from pathlib import Path
22

33
import pytest
4-
from create_python_app_core.installer import scaffold_project
4+
from create_python_app_core.config import CpaConfig
5+
from create_python_app_core.errors import IncompatibleExtensionsError
6+
from create_python_app_core.installer import (
7+
scaffold_project,
8+
validate_config_incompatible_extensions,
9+
)
510
from create_python_app_core.paths import ResolvedSource
611

712

@@ -13,6 +18,19 @@ def _tpl(tmp: Path, name: str) -> str:
1318
return f"file://{root}"
1419

1520

21+
def _ext(tmp: Path, name: str, *, incompatible: list[str] | None = None) -> str:
22+
root = tmp / name
23+
(root / "template").mkdir(parents=True)
24+
(root / "template" / f"{name}.txt").write_text(name)
25+
payload = {"name": name}
26+
if incompatible:
27+
payload["incompatibleWith"] = incompatible
28+
import json
29+
30+
(root / "cpa.config.json").write_text(json.dumps(payload))
31+
return f"file://{root}"
32+
33+
1634
def test_scaffold_file_template(
1735
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1836
) -> None:
@@ -52,3 +70,31 @@ def fake_download_repository(
5270
scaffold_project(str(dest), template=url, install=False, refresh="always")
5371

5472
assert refresh_values == ["always"]
73+
74+
75+
def test_validate_config_incompatible_extensions_raises() -> None:
76+
configs = [
77+
CpaConfig(name="template", raw={}),
78+
CpaConfig(name="saga", raw={"incompatibleWith": ["thunk"]}),
79+
CpaConfig(name="thunk", raw={"incompatibleWith": ["saga"]}),
80+
]
81+
with pytest.raises(IncompatibleExtensionsError, match="saga"):
82+
validate_config_incompatible_extensions(configs)
83+
84+
85+
def test_scaffold_rejects_config_incompatible_extensions(
86+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
87+
) -> None:
88+
monkeypatch.setenv("CPA_SKIP_GIT", "1")
89+
dest = tmp_path / "app"
90+
template = _tpl(tmp_path, "tpl")
91+
a = _ext(tmp_path, "saga", incompatible=["thunk"])
92+
b = _ext(tmp_path, "thunk", incompatible=["saga"])
93+
with pytest.raises(IncompatibleExtensionsError, match="saga"):
94+
scaffold_project(
95+
str(dest),
96+
template=template,
97+
addons=[a, b],
98+
install=False,
99+
)
100+
assert not dest.exists()

0 commit comments

Comments
 (0)