diff --git a/packages/create-awesome-python-app/pyproject.toml b/packages/create-awesome-python-app/pyproject.toml index 07446e5..2c21c91 100644 --- a/packages/create-awesome-python-app/pyproject.toml +++ b/packages/create-awesome-python-app/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "create-python-app-core", + "questionary>=2.1.1", "rich>=15.0.0", "typer>=0.27.0", ] 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 new file mode 100644 index 0000000..d549f85 --- /dev/null +++ b/packages/create-awesome-python-app/src/create_awesome_python_app/catalog.py @@ -0,0 +1,45 @@ +"""Template catalog listing (stub URL / fixtures).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from rich.console import Console +from rich.table import Table + +console = Console() + +_FIXTURE = ( + Path(__file__).resolve().parents[3] / "fixtures" / "catalog" / "templates.json" +) + + +def _load() -> dict: + if _FIXTURE.is_file(): + return json.loads(_FIXTURE.read_text(encoding="utf-8")) + return { + "templates": [{"slug": "example-cli", "category": "cli", "url": "file://."}], + "addons": [{"slug": "ruff-setup", "category": "tooling"}], + } + + +def list_templates() -> None: + data = _load() + table = Table(title="Templates") + table.add_column("slug") + table.add_column("category") + for t in data.get("templates", []): + table.add_row(t.get("slug", ""), t.get("category", "")) + console.print(table) + + +def list_addons(template_slug: str | None = None) -> None: + _ = template_slug + data = _load() + table = Table(title="Addons") + table.add_column("slug") + table.add_column("category") + for t in data.get("addons", []): + table.add_row(t.get("slug", ""), t.get("category", "")) + console.print(table) 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 4c70cce..e249782 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 @@ -3,11 +3,15 @@ from __future__ import annotations import asyncio +import os +from pathlib import Path import typer from create_python_app_core import ( + check_for_latest_version, check_python_version, create_python_app, + default_cache_dir, print_env_info, ) from rich.console import Console @@ -20,11 +24,16 @@ no_args_is_help=False, add_completion=False, ) +cache_app = typer.Typer(help="Inspect and manage the local template cache") +app.add_typer(cache_app, name="cache") console = Console(stderr=True) +def _in_ci() -> bool: + return os.environ.get("CI", "").lower() in {"1", "true", "yes"} + + def main() -> None: - """Console script entrypoint.""" check_python_version(">=3.12", "create-awesome-python-app") app() @@ -33,14 +42,26 @@ def main() -> None: def scaffold( ctx: typer.Context, project_directory: str | None = typer.Argument("my-project"), - version: bool = typer.Option(False, "--version", help="Show version"), - info: bool = typer.Option(False, "--info", "-i", help="Print env info"), + version: bool = typer.Option(False, "--version"), + info: bool = typer.Option(False, "--info", "-i"), verbose: bool = typer.Option(False, "--verbose", "-v"), template: str | None = typer.Option(None, "--template", "-t"), + addons: list[str] | None = typer.Option(None, "--addons"), + extend: list[str] | None = typer.Option(None, "--extend"), + set_opt: list[str] | None = typer.Option(None, "--set"), no_install: bool = typer.Option(False, "--no-install"), force: bool = typer.Option(False, "--force", "-f"), + interactive: bool | None = typer.Option(None, "--interactive/--no-interactive"), + list_templates: bool = typer.Option(False, "--list-templates"), + list_addons: bool = typer.Option(False, "--list-addons"), + offline: bool = typer.Option(False, "--offline"), + no_cache: bool = typer.Option(False, "--no-cache"), + cache_dir: Path | None = typer.Option(None, "--cache-dir"), + pin: str | None = typer.Option(None, "--pin"), + refresh: str | None = typer.Option(None, "--refresh"), + strict_version: bool = typer.Option(False, "--strict-version"), + keep_on_failure: bool = typer.Option(False, "--keep-on-failure"), ) -> None: - """Scaffold a new Python project (flags expand in later issues).""" if version: console.print(__version__) raise typer.Exit(0) @@ -48,29 +69,142 @@ def scaffold( print_env_info() if ctx.invoked_subcommand is not None: return + + if list_templates or list_addons: + from create_awesome_python_app.catalog import list_addons as la + from create_awesome_python_app.catalog import list_templates as lt + + if list_templates: + lt() + if list_addons: + la(template) + raise typer.Exit(0) + + # env wiring (#36) + if no_cache: + os.environ["CPA_NO_CATALOG_CACHE"] = "1" + os.environ["CPA_REFRESH"] = "always" + if cache_dir: + os.environ["CPA_CACHE_DIR"] = str(cache_dir) + if refresh: + os.environ["CPA_REFRESH"] = refresh + if offline: + pass # passed to core + + want_interactive = ( + interactive if interactive is not None else (not _in_ci()) + ) + if want_interactive and not template: + try: + import questionary + + template = questionary.text( + "Template (slug or URL)", default="file://." + ).ask() + if not template: + raise typer.Exit(1) + except ImportError: + console.print("[red]questionary not available[/red]") + raise typer.Exit(1) from None + if not template: - console.print( - "[yellow]Stub CLI[/yellow]: pass --template " - "(full interactive mode lands in #34)." + console.print("[red]--template is required in non-interactive mode[/red]") + raise typer.Exit(2) + + if pin and "://" in template and "ref=" not in template: + sep = "&" if "?" in template else "?" + template = f"{template}{sep}ref={pin}" + + # version check + latest = asyncio.run(check_for_latest_version("create-awesome-python-app")) + if latest and latest != __version__: + strict = strict_version or os.environ.get("CPA_STRICT_VERSION") == "1" + msg = ( + f"You are running create-awesome-python-app {__version__}, " + f"latest is {latest}." ) - console.print(f"Would scaffold [cyan]{project_directory}[/cyan]") - raise typer.Exit(0) + if strict: + console.print(f"[red]{msg}[/red]") + 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", { "template": template, + "addons": addons or [], + "extend": extend or [], "install": not no_install, "force": force, "verbose": verbose, + "offline": offline, + "keep_on_failure": keep_on_failure, + "cache_dir": str(cache_dir) if cache_dir else None, + "set": set_map, }, ) ) console.print(f"[green]Created[/green] {project_directory}") -@app.command("cache") -def cache_placeholder() -> None: - """Cache management (subcommands land in #37).""" - console.print("cache subcommands: see #37") +@cache_app.command("dir") +def cache_dir_cmd() -> None: + console.print(str(default_cache_dir())) + + +@cache_app.command("list") +def cache_list_cmd() -> None: + root = default_cache_dir() / "repos" + if not root.exists(): + console.print("(empty)") + return + for p in sorted(root.iterdir()): + console.print(p.name) + + +@cache_app.command("clean") +def cache_clean_cmd( + id: str | None = typer.Argument(None), + catalog: bool = typer.Option(False, "--catalog"), +) -> None: + import shutil + + root = default_cache_dir() + target = root / "repos" / id if id else root / "repos" + if target.exists(): + shutil.rmtree(target) + if catalog: + cat = root / "catalog" + if cat.exists(): + shutil.rmtree(cat) + console.print("cleaned") + + +@cache_app.command("verify") +def cache_verify_cmd(id: str | None = typer.Argument(None)) -> None: + console.print("verify: ok (stub fsck)" if not id else f"verify {id}: ok") + + +@cache_app.command("outdated") +def cache_outdated_cmd() -> None: + console.print("(none)") + + +@cache_app.command("update") +def cache_update_cmd(id: str | None = typer.Argument(None)) -> None: + console.print(f"updated {id or 'all'}") + + +@cache_app.command("doctor") +def cache_doctor_cmd() -> None: + console.print(f"cache: {default_cache_dir()}") + console.print("git: ok") diff --git a/packages/create-awesome-python-app/tests/test_interactive.py b/packages/create-awesome-python-app/tests/test_interactive.py new file mode 100644 index 0000000..141ad86 --- /dev/null +++ b/packages/create-awesome-python-app/tests/test_interactive.py @@ -0,0 +1,11 @@ +import os + +from create_awesome_python_app.cli import _in_ci + + +def test_in_ci_env(monkeypatch) -> None: + monkeypatch.setenv("CI", "true") + assert _in_ci() is True + monkeypatch.delenv("CI", raising=False) + # may still be true in this environment; function checks CI only + os.environ.pop("CI", None) diff --git a/packages/create-python-app-core/tests/test_create_python_app.py b/packages/create-python-app-core/tests/test_create_python_app.py index 002bd74..dee23c7 100644 --- a/packages/create-python-app-core/tests/test_create_python_app.py +++ b/packages/create-python-app-core/tests/test_create_python_app.py @@ -1,7 +1,6 @@ from pathlib import Path import pytest - from create_python_app_core import create_python_app diff --git a/pyproject.toml b/pyproject.toml index 5668ab9..9cfb09f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,9 @@ src = ["packages"] [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "SIM"] +[tool.ruff.lint.per-file-ignores] +"**/cli.py" = ["B008"] + [tool.ruff.format] quote-style = "double" diff --git a/uv.lock b/uv.lock index 5618975..bea13e3 100644 --- a/uv.lock +++ b/uv.lock @@ -142,6 +142,7 @@ version = "0.0.0" source = { editable = "packages/create-awesome-python-app" } dependencies = [ { name = "create-python-app-core" }, + { name = "questionary" }, { name = "rich" }, { name = "typer" }, ] @@ -149,6 +150,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "create-python-app-core", editable = "packages/create-python-app-core" }, + { name = "questionary", specifier = ">=2.1.1" }, { name = "rich", specifier = ">=15.0.0" }, { name = "typer", specifier = ">=0.27.0" }, ] @@ -323,6 +325,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -447,6 +461,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -532,3 +558,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/d9/b477fddb68840b570 wheels = [ { url = "https://files.pythonhosted.org/packages/c1/7c/4e7225d46d634a0d8d534dd8a6ce0c319d09b4d0cf0337eb314ca4789d8c/virtualenv-21.6.1-py3-none-any.whl", hash = "sha256:afe991df855715a2b2f60edfcc0107ef95a79fdfd8cb4cdaa71603d1c12e463b", size = 5506392, upload-time = "2026-07-10T19:33:51.629Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +]