Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -87,6 +87,82 @@ def resolve_catalog_specs(
return [resolve_catalog_spec(spec, catalog=catalog) for spec in specs]


class IncompatibleExtensionsError(ValueError):
"""Raised when selected extensions declare mutual incompatibility."""

def __init__(self, pairs: list[tuple[str, str]]) -> None:
self.pairs = pairs
rendered = ", ".join(f"'{a}' ↔ '{b}'" for a, b in pairs)
super().__init__(
"Incompatible extension combination: "
f"{rendered}. Remove one of each conflicting pair and retry."
)


def _extension_entries(catalog: dict[str, Any]) -> list[dict[str, Any]]:
raw = catalog.get("extensions", catalog.get("addons", []))
if not isinstance(raw, list):
return []
return [entry for entry in raw if isinstance(entry, dict)]


def find_extension_entry(catalog: dict[str, Any], spec: str) -> dict[str, Any] | None:
"""Find an extension by slug or URL."""
for entry in _extension_entries(catalog):
slug = str(entry.get("slug", ""))
url = str(entry.get("url", ""))
if spec in (slug, url):
return entry
return None


def find_incompatible_pairs(
specs: list[str], *, catalog: dict[str, Any] | None = None
) -> list[tuple[str, str]]:
"""Return ordered (slug, conflicting_slug) pairs among *specs*."""
data = catalog if catalog is not None else get_catalog_data()
selected: list[dict[str, Any]] = []
seen_slugs: set[str] = set()
for spec in specs:
entry = find_extension_entry(data, spec)
if entry is None:
continue
slug = str(entry.get("slug", ""))
if not slug or slug in seen_slugs:
continue
seen_slugs.add(slug)
selected.append(entry)

selected_slugs = {str(entry.get("slug", "")) for entry in selected}
pairs: list[tuple[str, str]] = []
reported: set[tuple[str, str]] = set()
for entry in selected:
slug = str(entry.get("slug", ""))
raw = entry.get("incompatibleWith") or entry.get("incompatible_with") or []
if not isinstance(raw, list):
continue
for other in raw:
other_slug = str(other)
if other_slug not in selected_slugs or other_slug == slug:
continue
first, second = sorted((slug, other_slug))
key = (first, second)
if key in reported:
continue
reported.add(key)
pairs.append((slug, other_slug))
return pairs


def validate_extension_compatibility(
specs: list[str], *, catalog: dict[str, Any] | None = None
) -> None:
"""Fail fast when selected catalog extensions are mutually incompatible."""
pairs = find_incompatible_pairs(specs, catalog=catalog)
if pairs:
raise IncompatibleExtensionsError(pairs)


def short_category_label(category_name: str) -> str:
"""Derive a compact badge label from a catalog category name."""
stop_words = {"Applications", "Application", "Boilerplate"}
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -339,6 +339,23 @@ def scaffold(
console.print("[red]questionary not available[/red]")
raise typer.Exit(1) from None

from create_awesome_python_app.catalog import (
IncompatibleExtensionsError as CatalogIncompatibleExtensionsError,
)
from create_awesome_python_app.catalog import (
get_catalog_data,
validate_extension_compatibility,
)

try:
validate_extension_compatibility(
[*(addons or []), *(extend or [])],
catalog=interactive_catalog or get_catalog_data(),
)
except CatalogIncompatibleExtensionsError as err:
console.print(f"[red]{err}[/red]")
raise typer.Exit(2) from err

if want_interactive:
try:
from create_awesome_python_app.catalog import (
Expand Down
59 changes: 59 additions & 0 deletions packages/create-awesome-python-app/tests/test_catalog_resolve.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,13 +6,16 @@
from create_awesome_python_app.catalog import (
CUSTOM_TEMPLATE_SENTINEL,
CatalogResolutionError,
IncompatibleExtensionsError,
build_extension_choices,
build_template_choices,
find_incompatible_pairs,
group_extension_choices,
is_url_like,
resolve_catalog_spec,
resolve_catalog_specs,
short_category_label,
validate_extension_compatibility,
)

SAMPLE_CATALOG = {
Expand DownExpand Up@@ -196,3 +199,59 @@ def test_group_extension_choices_preserves_category_order() -> None:

assert list(grouped) == ["ci", "data"]
assert grouped["ci"][0].value == "file:///extensions/github"


def test_validate_extension_compatibility_ok() -> None:
catalog = {
"extensions": [
{
"slug": "github-setup",
"url": "file:///ext/github",
"incompatibleWith": ["other"],
},
{"slug": "python-docker", "url": "file:///ext/docker"},
]
}
validate_extension_compatibility(["github-setup", "python-docker"], catalog=catalog)


def test_validate_extension_compatibility_fails_on_pair() -> None:
catalog = {
"extensions": [
{
"slug": "react-redux-saga",
"url": "file:///ext/saga",
"incompatibleWith": ["react-redux-thunk"],
},
{
"slug": "react-redux-thunk",
"url": "file:///ext/thunk",
"incompatibleWith": ["react-redux-saga"],
},
]
}
with pytest.raises(IncompatibleExtensionsError, match="react-redux-saga") as ei:
validate_extension_compatibility(
["react-redux-saga", "file:///ext/thunk"],
catalog=catalog,
)
assert ei.value.pairs == [("react-redux-saga", "react-redux-thunk")]


def test_find_incompatible_pairs_dedupes_symmetric_edges() -> None:
catalog = {
"extensions": [
{
"slug": "a",
"url": "file:///a",
"incompatibleWith": ["b"],
},
{
"slug": "b",
"url": "file:///b",
"incompatibleWith": ["a"],
},
]
}
pairs = find_incompatible_pairs(["a", "b"], catalog=catalog)
assert pairs == [("a", "b")]
54 changes: 54 additions & 0 deletions packages/create-awesome-python-app/tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,3 +168,57 @@ async def fake_create_python_app(project_directory, options, *_args, **_kwargs):
options = captured["options"]
assert isinstance(options, dict)
assert options["template"] == f"file://{tpl}?ref=abc123"


def test_incompatible_addons_fail_fast(tmp_path: Path, monkeypatch) -> None:
tpl = tmp_path / "tpl"
tpl.mkdir()
catalog = {
"templates": [
{"slug": "fastapi-starter", "url": f"file://{tpl}"},
],
"extensions": [
{
"slug": "saga",
"url": "file:///ext/saga",
"incompatibleWith": ["thunk"],
},
{
"slug": "thunk",
"url": "file:///ext/thunk",
"incompatibleWith": ["saga"],
},
],
}

async def fake_check_for_latest_version(_package_name):
return None

monkeypatch.setattr(
"create_awesome_python_app.cli.check_for_latest_version",
fake_check_for_latest_version,
)
monkeypatch.setattr(
"create_awesome_python_app.catalog.get_catalog_data",
lambda: catalog,
)

result = runner.invoke(
app,
[
"--template",
"fastapi-starter",
"--addons",
"saga",
"--addons",
"thunk",
"--no-install",
"--no-interactive",
"api",
],
)

text = (result.stdout or "") + (result.stderr or "")
assert result.exit_code == 2
assert "Incompatible extension combination" in text
assert "saga" in text
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,7 @@
NON_EMPTY_DIR_ERROR_CODE,
ConfigParseError,
CpaError,
IncompatibleExtensionsError,
ManifestLoadError,
NonEmptyTargetDirectoryError,
PackageManagerFallbackError,
Expand DownExpand Up@@ -48,6 +49,7 @@
"PackageManagerFallbackError",
"ScaffoldAbortedError",
"NonEmptyTargetDirectoryError",
"IncompatibleExtensionsError",
"NON_EMPTY_DIR_ERROR_CODE",
"default_cache_dir",
"resolve_source",
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,4 +34,8 @@ class NonEmptyTargetDirectoryError(CpaError):
code = "CPA_NON_EMPTY_TARGET_DIR"


class IncompatibleExtensionsError(CpaError):
code = "CPA_INCOMPATIBLE_EXTENSIONS"


NON_EMPTY_DIR_ERROR_CODE = NonEmptyTargetDirectoryError.code
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,11 @@
assert_directory_is_empty,
load_cpa_config,
)
from create_python_app_core.errors import CpaError, ScaffoldAbortedError
from create_python_app_core.errors import (
CpaError,
IncompatibleExtensionsError,
ScaffoldAbortedError,
)
from create_python_app_core.git_cache import RefreshMode, download_repository
from create_python_app_core.loaders import merge_layers
from create_python_app_core.paths import ResolvedSource, resolve_source
Expand DownExpand Up@@ -58,6 +62,42 @@ def build_scaffold_context(
return context


def _config_incompatible_list(cfg: CpaConfig) -> list[str]:
raw = cfg.raw.get("incompatibleWith") or cfg.raw.get("incompatible_with") or []
if not isinstance(raw, list):
return []
return [str(item) for item in raw]


def validate_config_incompatible_extensions(configs: list[CpaConfig]) -> None:
"""Fail when loaded cpa.config.json layers declare mutual incompatibility.

The template config (first entry) is ignored; only addon/extend layers are
checked. Matches are against each layer's ``name`` field (slug-like id).
"""
addon_configs = [cfg for cfg in configs[1:] if cfg.name]
names = {str(cfg.name) for cfg in addon_configs}
pairs: list[tuple[str, str]] = []
reported: set[tuple[str, str]] = set()
for cfg in addon_configs:
name = str(cfg.name)
for other in _config_incompatible_list(cfg):
if other not in names or other == name:
continue
first, second = sorted((name, other))
key = (first, second)
if key in reported:
continue
reported.add(key)
pairs.append((name, other))
if pairs:
rendered = ", ".join(f"'{a}' ↔ '{b}'" for a, b in pairs)
raise IncompatibleExtensionsError(
"Incompatible extension combination from cpa.config.json: "
f"{rendered}. Remove one of each conflicting pair and retry."
)


def scaffold_project(
project_directory: str,
*,
Expand DownExpand Up@@ -92,6 +132,7 @@ def scaffold_project(
layers.append((source, root))
configs.append(load_cpa_config(_config_path(source, root)))

validate_config_incompatible_extensions(configs)
context = build_scaffold_context(dest.name, configs, options)
merge_layers(layers, dest, context=context)

Expand Down
48 changes: 47 additions & 1 deletion packages/create-python-app-core/tests/test_installer.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
from pathlib import Path

import pytest
from create_python_app_core.installer import scaffold_project
from create_python_app_core.config import CpaConfig
from create_python_app_core.errors import IncompatibleExtensionsError
from create_python_app_core.installer import (
scaffold_project,
validate_config_incompatible_extensions,
)
from create_python_app_core.paths import ResolvedSource


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


def _ext(tmp: Path, name: str, *, incompatible: list[str] | None = None) -> str:
root = tmp / name
(root / "template").mkdir(parents=True)
(root / "template" / f"{name}.txt").write_text(name)
payload: dict[str, object] = {"name": name}
if incompatible:
payload["incompatibleWith"] = incompatible
import json

(root / "cpa.config.json").write_text(json.dumps(payload))
return f"file://{root}"


def test_scaffold_file_template(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand DownExpand Up@@ -52,3 +70,31 @@ def fake_download_repository(
scaffold_project(str(dest), template=url, install=False, refresh="always")

assert refresh_values == ["always"]


def test_validate_config_incompatible_extensions_raises() -> None:
configs = [
CpaConfig(name="template", raw={}),
CpaConfig(name="saga", raw={"incompatibleWith": ["thunk"]}),
CpaConfig(name="thunk", raw={"incompatibleWith": ["saga"]}),
]
with pytest.raises(IncompatibleExtensionsError, match="saga"):
validate_config_incompatible_extensions(configs)


def test_scaffold_rejects_config_incompatible_extensions(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("CPA_SKIP_GIT", "1")
dest = tmp_path / "app"
template = _tpl(tmp_path, "tpl")
a = _ext(tmp_path, "saga", incompatible=["thunk"])
b = _ext(tmp_path, "thunk", incompatible=["saga"])
with pytest.raises(IncompatibleExtensionsError, match="saga"):
scaffold_project(
str(dest),
template=template,
addons=[a, b],
install=False,
)
assert not dest.exists()
Loading