diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py index 279c70c..519267b 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py @@ -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"} diff --git a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py index 75a1d22..da6728f 100644 --- a/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/cli.py @@ -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 ( diff --git a/packages/create-awesome-python-app/tests/test_catalog_resolve.py b/packages/create-awesome-python-app/tests/test_catalog_resolve.py index 6e0dd54..bfb1f4a 100644 --- a/packages/create-awesome-python-app/tests/test_catalog_resolve.py +++ b/packages/create-awesome-python-app/tests/test_catalog_resolve.py @@ -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 = { @@ -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")] diff --git a/packages/create-awesome-python-app/tests/test_cli.py b/packages/create-awesome-python-app/tests/test_cli.py index f6447ea..cff37a8 100644 --- a/packages/create-awesome-python-app/tests/test_cli.py +++ b/packages/create-awesome-python-app/tests/test_cli.py @@ -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 diff --git a/packages/create-python-app-core/src/create_python_app_core/__init__.py b/packages/create-python-app-core/src/create_python_app_core/__init__.py index bc86322..b238b4d 100644 --- a/packages/create-python-app-core/src/create_python_app_core/__init__.py +++ b/packages/create-python-app-core/src/create_python_app_core/__init__.py @@ -20,6 +20,7 @@ NON_EMPTY_DIR_ERROR_CODE, ConfigParseError, CpaError, + IncompatibleExtensionsError, ManifestLoadError, NonEmptyTargetDirectoryError, PackageManagerFallbackError, @@ -48,6 +49,7 @@ "PackageManagerFallbackError", "ScaffoldAbortedError", "NonEmptyTargetDirectoryError", + "IncompatibleExtensionsError", "NON_EMPTY_DIR_ERROR_CODE", "default_cache_dir", "resolve_source", diff --git a/packages/create-python-app-core/src/create_python_app_core/errors.py b/packages/create-python-app-core/src/create_python_app_core/errors.py index 9afa3ed..d31b593 100644 --- a/packages/create-python-app-core/src/create_python_app_core/errors.py +++ b/packages/create-python-app-core/src/create_python_app_core/errors.py @@ -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 diff --git a/packages/create-python-app-core/src/create_python_app_core/installer.py b/packages/create-python-app-core/src/create_python_app_core/installer.py index 2ab2fcc..9cbf72b 100644 --- a/packages/create-python-app-core/src/create_python_app_core/installer.py +++ b/packages/create-python-app-core/src/create_python_app_core/installer.py @@ -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 @@ -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, *, @@ -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) diff --git a/packages/create-python-app-core/tests/test_installer.py b/packages/create-python-app-core/tests/test_installer.py index 34030c2..78d1ff9 100644 --- a/packages/create-python-app-core/tests/test_installer.py +++ b/packages/create-python-app-core/tests/test_installer.py @@ -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 @@ -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: @@ -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()