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
1 change: 1 addition & 0 deletions packages/create-awesome-python-app/pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
]
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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)
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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()

Expand All@@ -33,44 +42,169 @@ 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)
if info:
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")
11 changes: 11 additions & 0 deletions packages/create-awesome-python-app/tests/test_interactive.py
Original file line numberDiff line numberDiff line change
@@ -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)
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
from pathlib import Path

import pytest

from create_python_app_core import create_python_app


Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"

Expand Down
35 changes: 35 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.