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 7cc03c0..5979491 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 @@ -5,14 +5,20 @@ import asyncio import os from pathlib import Path +from typing import Any import typer from create_python_app_core import ( + ConfigParseError, + CpaCustomOption, check_for_latest_version, check_python_version, create_python_app, default_cache_dir, + download_repository, + load_cpa_config, print_env_info, + resolve_source, ) from rich.console import Console @@ -33,6 +39,97 @@ def _in_ci() -> bool: return os.environ.get("CI", "").lower() in {"1", "true", "yes"} +def _template_config_path(source_subdir: str | None, root: Path) -> Path: + cfg_path = root / "cpa.config.json" + if not cfg_path.is_file() and source_subdir: + cfg_path = root / source_subdir / "cpa.config.json" + return cfg_path + + +def _parse_set_options(set_opt: list[str] | None) -> dict[str, str]: + set_map: dict[str, str] = {} + for item in set_opt or []: + if "=" not in item: + console.print(f"[red]Invalid --set {item} (expected key=value)[/red]") + raise typer.Exit(2) + key, value = item.split("=", 1) + set_map[key] = value + return set_map + + +def _stringify_option_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _registry_custom_options(items: list[dict[str, Any]]) -> list[CpaCustomOption]: + options: list[CpaCustomOption] = [] + for item in items: + key = item.get("key") or item.get("name") + if not key: + continue + options.append( + CpaCustomOption( + key=str(key), + type=str(item.get("type", "string")), + message=str(item.get("message", "")), + default=item.get("default", item.get("initial")), + ) + ) + return options + + +def _prompt_custom_options( + template: str, + *, + set_map: dict[str, str], + cache_dir: Path | None, + offline: bool, + registry_options: list[dict[str, Any]] | None = None, +) -> dict[str, str]: + import questionary + + source = resolve_source(template, cache_dir=cache_dir) + root = download_repository(source, offline=offline, cache_root=cache_dir) + try: + config = load_cpa_config(_template_config_path(source.subdir, root)) + except ConfigParseError as err: + console.print(f"[yellow]Warning: {err}[/yellow]") + return {} + + custom_options = config.custom_options + if not custom_options and registry_options: + custom_options = _registry_custom_options(registry_options) + + answers: dict[str, str] = {} + blocked_types = {"password", "invisible"} + for option in custom_options: + if option.type in blocked_types: + console.print( + f"[yellow]Warning: skipped blocked custom option {option.key}[/yellow]" + ) + continue + if option.key in set_map: + answers[option.key] = set_map[option.key] + continue + initial = set_map.get(option.key, _stringify_option_value(option.default)) + message = option.message or option.key + if option.type in {"bool", "boolean", "confirm"}: + answer = questionary.confirm( + message, + default=initial.lower() in {"1", "true", "yes", "on"}, + ).ask() + else: + answer = questionary.text(message, default=initial).ask() + if answer is None: + raise typer.Exit(1) + answers[option.key] = _stringify_option_value(answer) + return answers + + def main() -> None: """Console script entrypoint. @@ -145,6 +242,8 @@ def scaffold( console.print("[red]--template is required in non-interactive mode[/red]") raise typer.Exit(2) + set_map = _parse_set_options(set_opt) + from create_awesome_python_app.catalog import ( CatalogResolutionError, resolve_catalog_spec, @@ -159,6 +258,10 @@ def scaffold( console.print(f"[red]{err}[/red]") raise typer.Exit(2) from err + if pin and "://" in template and "ref=" not in template: + sep = "&" if "?" in template else "?" + template = f"{template}{sep}ref={pin}" + if want_interactive and not addons: try: import questionary @@ -211,9 +314,38 @@ def scaffold( console.print("[red]questionary not available[/red]") raise typer.Exit(1) from None - if pin and "://" in template and "ref=" not in template: - sep = "&" if "?" in template else "?" - template = f"{template}{sep}ref={pin}" + if want_interactive: + try: + from create_awesome_python_app.catalog import ( + find_template_by_url, + get_catalog_data, + ) + + interactive_catalog = interactive_catalog or get_catalog_data() + registry_options: list[dict[str, Any]] = [] + template_entry = find_template_by_url(interactive_catalog, template) + if template_entry: + raw_registry_options = ( + template_entry.get("customOptions") + or template_entry.get("custom_options") + or [] + ) + if isinstance(raw_registry_options, list): + registry_options = [ + item for item in raw_registry_options if isinstance(item, dict) + ] + custom_answers = _prompt_custom_options( + template, + set_map=set_map, + cache_dir=cache_dir, + offline=offline, + registry_options=registry_options, + ) + except ImportError: + console.print("[red]questionary not available[/red]") + raise typer.Exit(1) from None + custom_answers.update(set_map) + set_map = custom_answers # version check latest = asyncio.run(check_for_latest_version("create-awesome-python-app")) @@ -228,14 +360,6 @@ def scaffold( raise typer.Exit(1) console.print(f"[yellow]{msg}[/yellow]") - set_map: dict[str, str] = {} - for item in set_opt or []: - if "=" not in item: - console.print(f"[red]Invalid --set {item} (expected key=value)[/red]") - raise typer.Exit(2) - k, v = item.split("=", 1) - set_map[k] = v - asyncio.run( create_python_app( project_directory or "my-project", diff --git a/packages/create-awesome-python-app/tests/test_interactive.py b/packages/create-awesome-python-app/tests/test_interactive.py index 77af7d1..d6628a7 100644 --- a/packages/create-awesome-python-app/tests/test_interactive.py +++ b/packages/create-awesome-python-app/tests/test_interactive.py @@ -8,6 +8,18 @@ runner = CliRunner() +class FakePrompt: + def __init__(self, answer): + self.answer = answer + + def ask(self): + return self.answer + + +async def fake_check_for_latest_version(_package_name): + return None + + def test_in_ci_env(monkeypatch) -> None: monkeypatch.setenv("CI", "true") assert _in_ci() is True @@ -19,13 +31,19 @@ def test_in_ci_env(monkeypatch) -> None: def test_interactive_extension_selection_passes_addon_urls( tmp_path: Path, monkeypatch ) -> None: + template_dir = tmp_path / "fastapi" + template_dir.mkdir() + (template_dir / "cpa.config.json").write_text( + json.dumps({"name": "fastapi-starter"}), + encoding="utf-8", + ) catalog = { "categories": [{"slug": "tooling", "name": "Tooling"}], "templates": [ { "slug": "fastapi-starter", "name": "FastAPI Starter", - "url": "file:///templates/fastapi", + "url": f"file://{template_dir}", "type": "fastapi-backend", "category": "backend-applications", } @@ -47,13 +65,6 @@ def test_interactive_extension_selection_passes_addon_urls( answers = [["tooling"], ["file:///extensions/github"]] - class FakePrompt: - def __init__(self, answer): - self.answer = answer - - def ask(self): - return self.answer - def fake_checkbox(*_args, **_kwargs): return FakePrompt(answers.pop(0)) @@ -63,9 +74,6 @@ async def fake_create_python_app(project_directory, options, *_args, **_kwargs): captured["project_directory"] = project_directory captured["options"] = options - async def fake_check_for_latest_version(_package_name): - return None - monkeypatch.setattr("questionary.checkbox", fake_checkbox) monkeypatch.setattr( "create_awesome_python_app.cli.create_python_app", @@ -92,3 +100,241 @@ async def fake_check_for_latest_version(_package_name): options = captured["options"] assert isinstance(options, dict) assert options["addons"] == ["file:///extensions/github"] + + +def test_interactive_custom_options_pass_set_values( + tmp_path: Path, monkeypatch +) -> None: + template_dir = tmp_path / "fastapi" + template_dir.mkdir() + (template_dir / "cpa.config.json").write_text( + json.dumps( + { + "name": "fastapi-starter", + "customOptions": [ + { + "key": "apiPrefix", + "type": "string", + "message": "API prefix", + "default": "/api/v1", + }, + { + "key": "enableCors", + "type": "boolean", + "message": "Enable CORS", + "default": True, + }, + ], + } + ), + encoding="utf-8", + ) + catalog = { + "categories": [{"slug": "backend-applications", "name": "Backend"}], + "templates": [ + { + "slug": "fastapi-starter", + "name": "FastAPI Starter", + "url": f"file://{template_dir}", + "type": "fastapi-backend", + "category": "backend-applications", + } + ], + "extensions": [], + } + catalog_file = tmp_path / "templates.json" + catalog_file.write_text(json.dumps(catalog), encoding="utf-8") + monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}") + monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1") + + text_answers = ["/api/v2"] + + def fake_text(*_args, **_kwargs): + return FakePrompt(text_answers.pop(0)) + + def fake_confirm(*_args, **_kwargs): + return FakePrompt(False) + + captured: dict[str, object] = {} + + async def fake_create_python_app(project_directory, options, *_args, **_kwargs): + captured["project_directory"] = project_directory + captured["options"] = options + + monkeypatch.setattr("questionary.text", fake_text) + monkeypatch.setattr("questionary.confirm", fake_confirm) + monkeypatch.setattr( + "create_awesome_python_app.cli.create_python_app", + fake_create_python_app, + ) + monkeypatch.setattr( + "create_awesome_python_app.cli.check_for_latest_version", + fake_check_for_latest_version, + ) + + result = runner.invoke( + app, + [ + "--template", + "fastapi-starter", + "--interactive", + "--no-install", + "api", + ], + ) + + assert result.exit_code == 0, result.stdout + result.stderr + options = captured["options"] + assert isinstance(options, dict) + assert options["set"] == { + "apiPrefix": "/api/v2", + "enableCors": "false", + } + + +def test_interactive_custom_options_honor_set_override( + tmp_path: Path, monkeypatch +) -> None: + template_dir = tmp_path / "fastapi" + template_dir.mkdir() + (template_dir / "cpa.config.json").write_text( + json.dumps( + { + "name": "fastapi-starter", + "customOptions": [ + { + "key": "apiPrefix", + "type": "string", + "message": "API prefix", + "default": "/api/v1", + } + ], + } + ), + encoding="utf-8", + ) + catalog = { + "categories": [{"slug": "backend-applications", "name": "Backend"}], + "templates": [ + { + "slug": "fastapi-starter", + "name": "FastAPI Starter", + "url": f"file://{template_dir}", + "type": "fastapi-backend", + "category": "backend-applications", + } + ], + "extensions": [], + } + catalog_file = tmp_path / "templates.json" + catalog_file.write_text(json.dumps(catalog), encoding="utf-8") + monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}") + monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1") + + def fail_text(*_args, **_kwargs): + raise AssertionError("--set options should not be prompted") + + captured: dict[str, object] = {} + + async def fake_create_python_app(project_directory, options, *_args, **_kwargs): + captured["project_directory"] = project_directory + captured["options"] = options + + monkeypatch.setattr("questionary.text", fail_text) + monkeypatch.setattr( + "create_awesome_python_app.cli.create_python_app", + fake_create_python_app, + ) + monkeypatch.setattr( + "create_awesome_python_app.cli.check_for_latest_version", + fake_check_for_latest_version, + ) + + result = runner.invoke( + app, + [ + "--template", + "fastapi-starter", + "--interactive", + "--set", + "apiPrefix=/forced", + "--no-install", + "api", + ], + ) + + assert result.exit_code == 0, result.stdout + result.stderr + options = captured["options"] + assert isinstance(options, dict) + assert options["set"] == {"apiPrefix": "/forced"} + + +def test_interactive_custom_options_fallback_to_registry( + tmp_path: Path, monkeypatch +) -> None: + template_dir = tmp_path / "fastapi" + template_dir.mkdir() + (template_dir / "cpa.config.json").write_text( + json.dumps({"name": "fastapi-starter"}), + encoding="utf-8", + ) + catalog = { + "categories": [{"slug": "backend-applications", "name": "Backend"}], + "templates": [ + { + "slug": "fastapi-starter", + "name": "FastAPI Starter", + "url": f"file://{template_dir}", + "type": "fastapi-backend", + "category": "backend-applications", + "customOptions": [ + { + "name": "apiPrefix", + "type": "string", + "message": "API prefix", + "initial": "/api/v1", + } + ], + } + ], + "extensions": [], + } + catalog_file = tmp_path / "templates.json" + catalog_file.write_text(json.dumps(catalog), encoding="utf-8") + monkeypatch.setenv("CPA_CATALOG_URL", f"file://{catalog_file}") + monkeypatch.setenv("CPA_NO_CATALOG_CACHE", "1") + + def fake_text(*_args, **_kwargs): + return FakePrompt("/api/v3") + + captured: dict[str, object] = {} + + async def fake_create_python_app(project_directory, options, *_args, **_kwargs): + captured["project_directory"] = project_directory + captured["options"] = options + + monkeypatch.setattr("questionary.text", fake_text) + monkeypatch.setattr( + "create_awesome_python_app.cli.create_python_app", + fake_create_python_app, + ) + monkeypatch.setattr( + "create_awesome_python_app.cli.check_for_latest_version", + fake_check_for_latest_version, + ) + + result = runner.invoke( + app, + [ + "--template", + "fastapi-starter", + "--interactive", + "--no-install", + "api", + ], + ) + + assert result.exit_code == 0, result.stdout + result.stderr + options = captured["options"] + assert isinstance(options, dict) + assert options["set"] == {"apiPrefix": "/api/v3"}