diff --git a/README.md b/README.md index 4d587b5..25aa8cc 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # Python App Template -A Cookiecutter template for building CLI-first Python applications with uv, Typer, Pydantic, and strict typing. +A Cookiecutter template for building CLI-first Python applications with `uv`, Typer, Pydantic, and strict typing. ## Features -- **CLI-first** architecture with Typer -- **Local-first config** (XDG paths) with env override -- **uv** for fast dependency management and reproducible installs -- **Ruff** for linting/formatting -- **MyPy** with strict typing -- **Pytest** with coverage support -- **Invoke** tasks for common operations -- **py.typed** marker for typed packages -- **Example command** wired from core `commands/` into CLI +- CLI-first architecture with Typer +- Local-first config (XDG paths) with env override +- `uv_build` backend and lockfile-first reproducibility +- Ruff for linting/formatting +- MyPy strict mode +- Pytest with coverage support +- Invoke tasks for common operations +- `py.typed` marker for typed packages +- Example command wired from core `commands/` into CLI ## Quick Start @@ -31,11 +31,14 @@ A Cookiecutter template for building CLI-first Python applications with uv, Type cookiecutter /path/to/python-app-template ``` -3. The template will prompt for project details and automatically: - - Initialize git repository - - Set up uv environment - - Run initial linting and formatting - - Execute tests to verify setup +3. Post-generation hook runs `uv lock` to create `uv.lock`. + +4. Bootstrap the generated project: + ```bash + uv sync --frozen --group dev + uv run invoke lint + uv run invoke test + ``` ## Template Variables @@ -48,7 +51,8 @@ A Cookiecutter template for building CLI-first Python applications with uv, Type | `author_name` | Author's full name | "Your Name" | | `author_email` | Author's email | "your.email@example.com" | | `version` | Initial version | "0.1.0" | -| `python_version` | Minimum Python version | "3.12" | + +Python is fixed to 3.12+ by template policy. ## Generated Project Structure @@ -57,6 +61,7 @@ your-project/ ├── README.md ├── pyproject.toml ├── tasks.py +├── uv.lock ├── src/ │ └── your_package/ │ ├── __init__.py @@ -81,8 +86,9 @@ your-project/ │ └── logging.py ├── tests/ │ ├── conftest.py -│ ├── test_public.py -│ └── test_internals.py +│ ├── test_cli.py +│ ├── test_internals.py +│ └── test_public.py └── examples/ └── config_init.sh ``` @@ -91,13 +97,13 @@ your-project/ ### Setup ```bash -uv sync --group dev +uv sync --frozen --group dev ``` ### Code Quality ```bash uv run ruff check src tests -uv run ruff format src tests +uv run ruff format --check src tests uv run mypy src uv run invoke lint ``` @@ -117,14 +123,15 @@ To modify this template: 3. Modify the post-generation hook in `hooks/post_gen_project.py` 4. Test changes: ```bash - cookiecutter . --output-dir /tmp + ./test.sh ``` ## Requirements - Python 3.12+ -- uv (automatically installed during generation) -- git (for version control) +- `uv` +- `cruft` or `cookiecutter` +- `git` (optional, for your project workflow) ## License diff --git a/cookiecutter.json b/cookiecutter.json index 83eae8c..9718594 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -6,6 +6,5 @@ "author_name": "Your Name", "author_email": "your.email@example.com", "version": "0.1.0", - "python_version": "3.12", "year": "{% now 'utc', '%Y' %}" } diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 12141b8..cdbf1f9 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -1,63 +1,31 @@ #!/usr/bin/env python3 -"""Post-generation hook for the Python library template.""" +"""Post-generation hook for deterministic project bootstrapping.""" +from __future__ import annotations + +import shutil import subprocess import sys -from pathlib import Path -def run_command(cmd: list[str], description: str) -> None: - """Run a command and handle errors.""" - print(f"Running: {description}") - try: - result = subprocess.run(cmd, check=True, capture_output=True, text=True) - if result.stdout: - print(result.stdout) - except subprocess.CalledProcessError as e: - print(f"Error running {description}: {e}") - if e.stderr: - print(f"Error output: {e.stderr}") +def main() -> None: + """Create a lockfile so generated projects are reproducible by default.""" + if shutil.which("uv") is None: + print("Error: 'uv' is required but was not found on PATH.", file=sys.stderr) sys.exit(1) + print("Running: uv lock") + try: + subprocess.run(["uv", "lock"], check=True) + except subprocess.CalledProcessError as exc: + print(f"Error: uv lock failed with exit code {exc.returncode}.", file=sys.stderr) + sys.exit(exc.returncode) -def main(): - """Initialize the generated project.""" - project_dir = Path.cwd() - print(f"Setting up project in: {project_dir}") - - # Initialize git repository - run_command(["git", "init"], "git init") - - # Sync dependencies with uv - run_command(["uv", "sync", "--group", "dev"], "uv sync --group dev") - - # Run initial formatting and linting - run_command(["uv", "run", "ruff", "format", "src", "tests"], "ruff format") - run_command( - ["uv", "run", "ruff", "check", "--fix", "src", "tests"], - "ruff check --fix", - ) - - # Run type checking - try: - run_command(["uv", "run", "mypy", "src"], "mypy type checking") - except SystemExit: - # mypy might fail on initial template, continue anyway - print("MyPy check failed - this is normal for initial template") - - # Run tests to ensure everything works - try: - run_command(["uv", "run", "pytest"], "pytest") - except SystemExit: - print("Tests failed - you may need to adjust the generated code") - - print("\n✅ Project setup complete!") + print("\nProject lockfile generated.") print("Next steps:") - print("1. Review and customize the generated files") - print("2. Add your actual dependencies to pyproject.toml") - print("3. Implement your core logic in src/") - print("4. Update tests as needed") - print("5. Update README.md with proper documentation") + print("1. uv sync --frozen --group dev") + print("2. uv run invoke lint") + print("3. uv run invoke test") if __name__ == "__main__": diff --git a/test.sh b/test.sh index fd27998..0c8501b 100755 --- a/test.sh +++ b/test.sh @@ -121,7 +121,7 @@ main() { cd "$TEMP_DIR" cruft create "$TEMPLATE_DIR" \ --no-input \ - --extra-context '{"project_name": "Test Project", "project_slug": "test-project", "package_name": "test_project", "description": "A test project", "author_name": "Test Author", "author_email": "test@example.com", "version": "0.1.0", "python_version": "3.12"}' \ + --extra-context '{"project_name": "Test Project", "project_slug": "test-project", "package_name": "test_project", "description": "A test project", "author_name": "Test Author", "author_email": "test@example.com", "version": "0.1.0"}' \ || { print_error "Failed to generate project with cruft"; exit 1; } else cd "$TEMP_DIR" @@ -134,7 +134,6 @@ main() { author_name="Test Author" \ author_email="test@example.com" \ version="0.1.0" \ - python_version="3.12" \ || { print_error "Failed to generate project with cookiecutter"; exit 1; } fi @@ -146,9 +145,12 @@ main() { print_status "Generated files:" ls -la - # Install dependencies - print_status "Installing dependencies with uv..." - uv sync --group dev || { print_error "Failed to install dependencies"; exit 1; } + # Ensure lockfile exists and install dependencies + print_status "Checking for generated uv.lock..." + [ -f "uv.lock" ] || { print_error "uv.lock was not generated"; exit 1; } + + print_status "Installing dependencies with uv (frozen)..." + uv sync --frozen --group dev || { print_error "Failed to install dependencies"; exit 1; } # Run ruff check print_status "Running ruff check..." diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index dd15613..6cd3a9d 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -5,7 +5,7 @@ ## Installation ```bash -uv sync +uv sync --frozen --group dev ``` ## Usage @@ -30,17 +30,31 @@ Override with: export {{ cookiecutter.package_name | upper }}_CONFIG=/path/to/config.toml ``` +Resolution order: +1. `{{ cookiecutter.package_name | upper }}_CONFIG` explicit path (errors if missing) +2. `~/.config/{{ cookiecutter.project_slug }}/config.toml` when present +3. In-code defaults + +## Reproducible Workflow + +```bash +uv lock +uv sync --frozen --group dev +uv run invoke lint +uv run invoke test +``` + ## Development ### Setup ```bash -uv sync --group dev +uv sync --frozen --group dev ``` ### Code Quality ```bash uv run ruff check src tests -uv run ruff format src tests +uv run ruff format --check src tests uv run mypy src uv run invoke lint ``` diff --git a/{{cookiecutter.project_slug}}/examples/config_init.sh b/{{cookiecutter.project_slug}}/examples/config_init.sh index 03e7df0..934f455 100644 --- a/{{cookiecutter.project_slug}}/examples/config_init.sh +++ b/{{cookiecutter.project_slug}}/examples/config_init.sh @@ -2,5 +2,13 @@ set -euo pipefail -uv run {{ cookiecutter.project_slug }} config init +EXAMPLE_ROOT="${TMPDIR:-/tmp}/{{ cookiecutter.project_slug }}-example" +mkdir -p "$EXAMPLE_ROOT/config" "$EXAMPLE_ROOT/data" + +XDG_CONFIG_HOME="$EXAMPLE_ROOT/config" \ +XDG_DATA_HOME="$EXAMPLE_ROOT/data" \ +uv run {{ cookiecutter.project_slug }} config init --overwrite + +XDG_CONFIG_HOME="$EXAMPLE_ROOT/config" \ +XDG_DATA_HOME="$EXAMPLE_ROOT/data" \ uv run {{ cookiecutter.project_slug }} config show diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index 9c77b3b..973f709 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" authors = [ { name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" } ] -requires-python = ">={{ cookiecutter.python_version }}" +requires-python = ">=3.12" dependencies = [ "coloredlogs>=15.0", "pydantic>=2.0", @@ -30,7 +30,7 @@ dev = [ ] [tool.mypy] -python_version = "{{ cookiecutter.python_version }}" +python_version = "3.12" strict = true files = ["src"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py index 7f516b5..c6e2ee8 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py @@ -4,9 +4,12 @@ from __future__ import annotations from importlib.metadata import PackageNotFoundError, version +from {{ cookiecutter.package_name }}.config import get_config +from {{ cookiecutter.package_name }}.config.settings import Settings + try: __version__ = version("{{ cookiecutter.project_slug }}") except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode __version__ = "0.0.0" -__all__ = ["__version__"] +__all__ = ["Settings", "__version__", "get_config"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py index 630398b..8063503 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py @@ -1,6 +1,10 @@ from __future__ import annotations +import tomllib +from typing import NoReturn + import typer +from pydantic import ValidationError from {{ cookiecutter.package_name }}.config import get_config from {{ cookiecutter.package_name }}.config.settings import Settings @@ -8,10 +12,13 @@ from {{ cookiecutter.package_name }}.config.settings import Settings def load_config() -> Settings: """Load configuration once at the CLI boundary.""" - return get_config() + try: + return get_config() + except (FileNotFoundError, tomllib.TOMLDecodeError, ValidationError) as exc: + exit_with_error(str(exc)) -def exit_with_error(message: str, code: int = 1) -> None: +def exit_with_error(message: str, code: int = 1) -> NoReturn: """Print a concise error and exit with a non-zero status.""" typer.secho(message, fg=typer.colors.RED, err=True) raise typer.Exit(code) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py index 07c23fc..8c614c7 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py @@ -6,8 +6,8 @@ from typing import Annotated import typer -from {{ cookiecutter.package_name }}.cli.common import exit_with_error -from {{ cookiecutter.package_name }}.config import get_config, get_config_source +from {{ cookiecutter.package_name }}.cli.common import exit_with_error, load_config +from {{ cookiecutter.package_name }}.config import get_config_source from {{ cookiecutter.package_name }}.config.paths import config_file_path from {{ cookiecutter.package_name }}.config.settings import write_default_config @@ -17,12 +17,7 @@ app = typer.Typer(no_args_is_help=True) @app.command() def show() -> None: """Print the effective configuration and source path.""" - try: - config = get_config() - except (FileNotFoundError, ValueError) as exc: - exit_with_error(str(exc)) - return - + config = load_config() source = get_config_source() payload = { "source": str(source) if source else "", diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py index 49fe434..38af0d9 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py @@ -4,6 +4,7 @@ from typing import Annotated import typer +from {{ cookiecutter.package_name }}.cli.common import load_config from {{ cookiecutter.package_name }}.commands import format_greeting app = typer.Typer(no_args_is_help=True) @@ -12,6 +13,12 @@ app = typer.Typer(no_args_is_help=True) @app.command() def say( name: Annotated[str, typer.Option("--name", "-n", help="Name to greet.")] = "World", + greeting: Annotated[ + str | None, + typer.Option("--greeting", "-g", help="Greeting prefix; overrides config value."), + ] = None, ) -> None: """Print a greeting.""" - print(format_greeting(name)) + config = load_config() + message = format_greeting(name=name, greeting=greeting or config.app.greeting) + print(message) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py index 3f3104a..556c4a6 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py @@ -1,6 +1,6 @@ from __future__ import annotations -def format_greeting(name: str) -> str: +def format_greeting(name: str, greeting: str = "Hello") -> str: """Return a greeting message.""" - return f"Hello, {name}!" + return f"{greeting}, {name}!" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py index 5ffb9ad..96a5acd 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py @@ -4,15 +4,28 @@ import os import tomllib from pathlib import Path -from pydantic import BaseModel +from pydantic import BaseModel, Field -from .paths import ENV_CONFIG_VAR, config_file_path +from .paths import ENV_CONFIG_VAR, config_file_path, data_dir_path + + +class AppConfig(BaseModel): + model_config = {"frozen": True, "extra": "forbid"} + + greeting: str = "Hello" + + +class DatabaseConfig(BaseModel): + model_config = {"frozen": True, "extra": "forbid"} + + path: str = Field(default_factory=lambda: str(data_dir_path() / "data.db")) class Settings(BaseModel): model_config = {"frozen": True, "extra": "forbid"} - greeting: str = "Hello" + app: AppConfig = AppConfig() + database: DatabaseConfig = DatabaseConfig() def load_settings() -> tuple[Settings, Path | None]: @@ -43,23 +56,43 @@ def write_default_config(path: Path) -> None: def _to_toml(payload: dict[str, object]) -> str: lines: list[str] = [] - for section, values in payload.items(): - if isinstance(values, dict): - lines.append(f"[{section}]") - for key, value in values.items(): - lines.append(f"{key} = {_toml_value(value)}") - lines.append("") + + for key, value in payload.items(): + if isinstance(value, dict): + _write_table(lines, key, value) else: - lines.append(f"{section} = {_toml_value(values)}") + lines.append(f"{key} = {_toml_value(value)}") + return "\n".join(lines).rstrip() + "\n" +def _write_table(lines: list[str], table_name: str, values: dict[str, object]) -> None: + lines.append(f"[{table_name}]") + scalar_items: list[tuple[str, object]] = [] + nested_items: list[tuple[str, dict[str, object]]] = [] + + for key, value in values.items(): + if isinstance(value, dict): + nested_items.append((key, value)) + else: + scalar_items.append((key, value)) + + for key, value in scalar_items: + lines.append(f"{key} = {_toml_value(value)}") + + for key, nested in nested_items: + lines.append("") + _write_table(lines, f"{table_name}.{key}", nested) + + lines.append("") + + def _toml_value(value: object) -> str: if isinstance(value, str): - escaped = value.replace("\\", "\\\\").replace('"', "\\\"") + escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' if isinstance(value, bool): return "true" if value else "false" - if isinstance(value, (int, float)): + if isinstance(value, int | float): return str(value) raise TypeError(f"Unsupported TOML value: {value!r}") diff --git a/{{cookiecutter.project_slug}}/tests/test_cli.py b/{{cookiecutter.project_slug}}/tests/test_cli.py new file mode 100644 index 0000000..12bb746 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/test_cli.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from {{ cookiecutter.package_name }}.cli.app import app +from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR + +runner = CliRunner() + + +def test_config_show_success_defaults() -> None: + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["source"] == "" + assert payload["config"]["app"]["greeting"] == "Hello" + + +def test_config_init_create_and_overwrite(tmp_path) -> None: + path = tmp_path / "config.toml" + + create_result = runner.invoke(app, ["config", "init", "--path", str(path)]) + assert create_result.exit_code == 0 + assert path.exists() + + conflict_result = runner.invoke(app, ["config", "init", "--path", str(path)]) + assert conflict_result.exit_code == 1 + assert "Config already exists" in conflict_result.output + + overwrite_result = runner.invoke( + app, + ["config", "init", "--path", str(path), "--overwrite"], + ) + assert overwrite_result.exit_code == 0 + + +def test_config_show_missing_env_file_exits_with_code_1(tmp_path, monkeypatch) -> None: + missing = tmp_path / "missing.toml" + monkeypatch.setenv(ENV_CONFIG_VAR, str(missing)) + + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 1 + assert "Config file not found" in result.output + + +def test_config_show_invalid_config_exits_with_code_1(tmp_path, monkeypatch) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('unknown = "value"\n') + monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path)) + + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 1 + assert "Extra inputs are not permitted" in result.output + + +def test_hello_cli_override_precedence(tmp_path, monkeypatch) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('[app]\ngreeting = "Hi"\n\n[database]\npath = "/tmp/example.db"\n') + monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path)) + + from_config = runner.invoke(app, ["hello", "say", "--name", "Ada"]) + assert from_config.exit_code == 0 + assert "Hi, Ada!" in from_config.output + + from_cli = runner.invoke( + app, + ["hello", "say", "--name", "Ada", "--greeting", "Yo"], + ) + assert from_cli.exit_code == 0 + assert "Yo, Ada!" in from_cli.output diff --git a/{{cookiecutter.project_slug}}/tests/test_internals.py b/{{cookiecutter.project_slug}}/tests/test_internals.py index ca0950b..b3b7ee3 100644 --- a/{{cookiecutter.project_slug}}/tests/test_internals.py +++ b/{{cookiecutter.project_slug}}/tests/test_internals.py @@ -1,18 +1,22 @@ from __future__ import annotations +import pytest +from pydantic import ValidationError + from {{ cookiecutter.package_name }}.commands import format_greeting from {{ cookiecutter.package_name }}.config import get_config, get_config_source -from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR +from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR, config_file_path def test_env_config_override(tmp_path, monkeypatch) -> None: config_path = tmp_path / "config.toml" - config_path.write_text("greeting = \"Hi\"\n") + config_path.write_text('[app]\ngreeting = "Hi"\n\n[database]\npath = "/tmp/app.db"\n') monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path)) settings = get_config() - assert settings.greeting == "Hi" + assert settings.app.greeting == "Hi" + assert settings.database.path == "/tmp/app.db" assert get_config_source() == config_path @@ -20,13 +24,39 @@ def test_missing_env_config_raises(tmp_path, monkeypatch) -> None: missing = tmp_path / "missing.toml" monkeypatch.setenv(ENV_CONFIG_VAR, str(missing)) - try: + with pytest.raises(FileNotFoundError, match="Config file not found"): get_config() - except FileNotFoundError as exc: - assert str(missing) in str(exc) - else: - raise AssertionError("Expected FileNotFoundError") + + +def test_xdg_default_config_is_loaded(monkeypatch) -> None: + path = config_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('[app]\ngreeting = "Hola"\n\n[database]\npath = "/tmp/default.db"\n') + monkeypatch.delenv(ENV_CONFIG_VAR, raising=False) + + settings = get_config() + + assert settings.app.greeting == "Hola" + assert settings.database.path == "/tmp/default.db" + assert get_config_source() == path + + +def test_unknown_key_raises_validation_error(tmp_path, monkeypatch) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('unknown = "value"\n') + monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path)) + + with pytest.raises(ValidationError): + get_config() + + +def test_settings_are_frozen() -> None: + settings = get_config() + + with pytest.raises(ValidationError, match="frozen"): + settings.app.greeting = "Mutated" def test_format_greeting() -> None: assert format_greeting("Ada") == "Hello, Ada!" + assert format_greeting("Ada", greeting="Hi") == "Hi, Ada!" diff --git a/{{cookiecutter.project_slug}}/tests/test_public.py b/{{cookiecutter.project_slug}}/tests/test_public.py index c0a1acf..7039e01 100644 --- a/{{cookiecutter.project_slug}}/tests/test_public.py +++ b/{{cookiecutter.project_slug}}/tests/test_public.py @@ -1,13 +1,19 @@ from __future__ import annotations -from {{ cookiecutter.package_name }} import __version__ -from {{ cookiecutter.package_name }}.config import get_config +from {{ cookiecutter.package_name }} import __all__, __version__, get_config +from {{ cookiecutter.package_name }}.config.settings import Settings def test_public_version_is_string() -> None: assert isinstance(__version__, str) -def test_get_config_defaults() -> None: +def test_public_exports_include_stable_symbols() -> None: + assert set(__all__) >= {"__version__", "get_config", "Settings"} + + +def test_get_config_defaults_shape() -> None: settings = get_config() - assert settings.greeting == "Hello" + assert isinstance(settings, Settings) + assert settings.app.greeting == "Hello" + assert settings.database.path.endswith("/data.db")