use Codex 5.3 to review template to spec
This commit is contained in:
53
README.md
53
README.md
@@ -1,18 +1,18 @@
|
|||||||
# Python App Template
|
# 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
|
## Features
|
||||||
|
|
||||||
- **CLI-first** architecture with Typer
|
- CLI-first architecture with Typer
|
||||||
- **Local-first config** (XDG paths) with env override
|
- Local-first config (XDG paths) with env override
|
||||||
- **uv** for fast dependency management and reproducible installs
|
- `uv_build` backend and lockfile-first reproducibility
|
||||||
- **Ruff** for linting/formatting
|
- Ruff for linting/formatting
|
||||||
- **MyPy** with strict typing
|
- MyPy strict mode
|
||||||
- **Pytest** with coverage support
|
- Pytest with coverage support
|
||||||
- **Invoke** tasks for common operations
|
- Invoke tasks for common operations
|
||||||
- **py.typed** marker for typed packages
|
- `py.typed` marker for typed packages
|
||||||
- **Example command** wired from core `commands/` into CLI
|
- Example command wired from core `commands/` into CLI
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -31,11 +31,14 @@ A Cookiecutter template for building CLI-first Python applications with uv, Type
|
|||||||
cookiecutter /path/to/python-app-template
|
cookiecutter /path/to/python-app-template
|
||||||
```
|
```
|
||||||
|
|
||||||
3. The template will prompt for project details and automatically:
|
3. Post-generation hook runs `uv lock` to create `uv.lock`.
|
||||||
- Initialize git repository
|
|
||||||
- Set up uv environment
|
4. Bootstrap the generated project:
|
||||||
- Run initial linting and formatting
|
```bash
|
||||||
- Execute tests to verify setup
|
uv sync --frozen --group dev
|
||||||
|
uv run invoke lint
|
||||||
|
uv run invoke test
|
||||||
|
```
|
||||||
|
|
||||||
## Template Variables
|
## 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_name` | Author's full name | "Your Name" |
|
||||||
| `author_email` | Author's email | "your.email@example.com" |
|
| `author_email` | Author's email | "your.email@example.com" |
|
||||||
| `version` | Initial version | "0.1.0" |
|
| `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
|
## Generated Project Structure
|
||||||
|
|
||||||
@@ -57,6 +61,7 @@ your-project/
|
|||||||
├── README.md
|
├── README.md
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── tasks.py
|
├── tasks.py
|
||||||
|
├── uv.lock
|
||||||
├── src/
|
├── src/
|
||||||
│ └── your_package/
|
│ └── your_package/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
@@ -81,8 +86,9 @@ your-project/
|
|||||||
│ └── logging.py
|
│ └── logging.py
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── conftest.py
|
│ ├── conftest.py
|
||||||
│ ├── test_public.py
|
│ ├── test_cli.py
|
||||||
│ └── test_internals.py
|
│ ├── test_internals.py
|
||||||
|
│ └── test_public.py
|
||||||
└── examples/
|
└── examples/
|
||||||
└── config_init.sh
|
└── config_init.sh
|
||||||
```
|
```
|
||||||
@@ -91,13 +97,13 @@ your-project/
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev
|
uv sync --frozen --group dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
```bash
|
```bash
|
||||||
uv run ruff check src tests
|
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 mypy src
|
||||||
uv run invoke lint
|
uv run invoke lint
|
||||||
```
|
```
|
||||||
@@ -117,14 +123,15 @@ To modify this template:
|
|||||||
3. Modify the post-generation hook in `hooks/post_gen_project.py`
|
3. Modify the post-generation hook in `hooks/post_gen_project.py`
|
||||||
4. Test changes:
|
4. Test changes:
|
||||||
```bash
|
```bash
|
||||||
cookiecutter . --output-dir /tmp
|
./test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.12+
|
- Python 3.12+
|
||||||
- uv (automatically installed during generation)
|
- `uv`
|
||||||
- git (for version control)
|
- `cruft` or `cookiecutter`
|
||||||
|
- `git` (optional, for your project workflow)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,5 @@
|
|||||||
"author_name": "Your Name",
|
"author_name": "Your Name",
|
||||||
"author_email": "your.email@example.com",
|
"author_email": "your.email@example.com",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"python_version": "3.12",
|
|
||||||
"year": "{% now 'utc', '%Y' %}"
|
"year": "{% now 'utc', '%Y' %}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,31 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd: list[str], description: str) -> None:
|
def main() -> None:
|
||||||
"""Run a command and handle errors."""
|
"""Create a lockfile so generated projects are reproducible by default."""
|
||||||
print(f"Running: {description}")
|
if shutil.which("uv") is None:
|
||||||
try:
|
print("Error: 'uv' is required but was not found on PATH.", file=sys.stderr)
|
||||||
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}")
|
|
||||||
sys.exit(1)
|
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():
|
print("\nProject lockfile generated.")
|
||||||
"""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("Next steps:")
|
print("Next steps:")
|
||||||
print("1. Review and customize the generated files")
|
print("1. uv sync --frozen --group dev")
|
||||||
print("2. Add your actual dependencies to pyproject.toml")
|
print("2. uv run invoke lint")
|
||||||
print("3. Implement your core logic in src/")
|
print("3. uv run invoke test")
|
||||||
print("4. Update tests as needed")
|
|
||||||
print("5. Update README.md with proper documentation")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
12
test.sh
12
test.sh
@@ -121,7 +121,7 @@ main() {
|
|||||||
cd "$TEMP_DIR"
|
cd "$TEMP_DIR"
|
||||||
cruft create "$TEMPLATE_DIR" \
|
cruft create "$TEMPLATE_DIR" \
|
||||||
--no-input \
|
--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; }
|
|| { print_error "Failed to generate project with cruft"; exit 1; }
|
||||||
else
|
else
|
||||||
cd "$TEMP_DIR"
|
cd "$TEMP_DIR"
|
||||||
@@ -134,7 +134,6 @@ main() {
|
|||||||
author_name="Test Author" \
|
author_name="Test Author" \
|
||||||
author_email="test@example.com" \
|
author_email="test@example.com" \
|
||||||
version="0.1.0" \
|
version="0.1.0" \
|
||||||
python_version="3.12" \
|
|
||||||
|| { print_error "Failed to generate project with cookiecutter"; exit 1; }
|
|| { print_error "Failed to generate project with cookiecutter"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -146,9 +145,12 @@ main() {
|
|||||||
print_status "Generated files:"
|
print_status "Generated files:"
|
||||||
ls -la
|
ls -la
|
||||||
|
|
||||||
# Install dependencies
|
# Ensure lockfile exists and install dependencies
|
||||||
print_status "Installing dependencies with uv..."
|
print_status "Checking for generated uv.lock..."
|
||||||
uv sync --group dev || { print_error "Failed to install dependencies"; exit 1; }
|
[ -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
|
# Run ruff check
|
||||||
print_status "Running ruff check..."
|
print_status "Running ruff check..."
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync --frozen --group dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -30,17 +30,31 @@ Override with:
|
|||||||
export {{ cookiecutter.package_name | upper }}_CONFIG=/path/to/config.toml
|
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
|
## Development
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
```bash
|
```bash
|
||||||
uv sync --group dev
|
uv sync --frozen --group dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code Quality
|
### Code Quality
|
||||||
```bash
|
```bash
|
||||||
uv run ruff check src tests
|
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 mypy src
|
||||||
uv run invoke lint
|
uv run invoke lint
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,5 +2,13 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
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
|
uv run {{ cookiecutter.project_slug }} config show
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ readme = "README.md"
|
|||||||
authors = [
|
authors = [
|
||||||
{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }
|
{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }
|
||||||
]
|
]
|
||||||
requires-python = ">={{ cookiecutter.python_version }}"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"coloredlogs>=15.0",
|
"coloredlogs>=15.0",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
@@ -30,7 +30,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
python_version = "{{ cookiecutter.python_version }}"
|
python_version = "3.12"
|
||||||
strict = true
|
strict = true
|
||||||
files = ["src"]
|
files = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
from {{ cookiecutter.package_name }}.config import get_config
|
||||||
|
from {{ cookiecutter.package_name }}.config.settings import Settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = version("{{ cookiecutter.project_slug }}")
|
__version__ = version("{{ cookiecutter.project_slug }}")
|
||||||
except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode
|
except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode
|
||||||
__version__ = "0.0.0"
|
__version__ = "0.0.0"
|
||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["Settings", "__version__", "get_config"]
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.config import get_config
|
from {{ cookiecutter.package_name }}.config import get_config
|
||||||
from {{ cookiecutter.package_name }}.config.settings import Settings
|
from {{ cookiecutter.package_name }}.config.settings import Settings
|
||||||
@@ -8,10 +12,13 @@ from {{ cookiecutter.package_name }}.config.settings import Settings
|
|||||||
|
|
||||||
def load_config() -> Settings:
|
def load_config() -> Settings:
|
||||||
"""Load configuration once at the CLI boundary."""
|
"""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."""
|
"""Print a concise error and exit with a non-zero status."""
|
||||||
typer.secho(message, fg=typer.colors.RED, err=True)
|
typer.secho(message, fg=typer.colors.RED, err=True)
|
||||||
raise typer.Exit(code)
|
raise typer.Exit(code)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from typing import Annotated
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.cli.common import exit_with_error
|
from {{ cookiecutter.package_name }}.cli.common import exit_with_error, load_config
|
||||||
from {{ cookiecutter.package_name }}.config import get_config, get_config_source
|
from {{ cookiecutter.package_name }}.config import get_config_source
|
||||||
from {{ cookiecutter.package_name }}.config.paths import config_file_path
|
from {{ cookiecutter.package_name }}.config.paths import config_file_path
|
||||||
from {{ cookiecutter.package_name }}.config.settings import write_default_config
|
from {{ cookiecutter.package_name }}.config.settings import write_default_config
|
||||||
|
|
||||||
@@ -17,12 +17,7 @@ app = typer.Typer(no_args_is_help=True)
|
|||||||
@app.command()
|
@app.command()
|
||||||
def show() -> None:
|
def show() -> None:
|
||||||
"""Print the effective configuration and source path."""
|
"""Print the effective configuration and source path."""
|
||||||
try:
|
config = load_config()
|
||||||
config = get_config()
|
|
||||||
except (FileNotFoundError, ValueError) as exc:
|
|
||||||
exit_with_error(str(exc))
|
|
||||||
return
|
|
||||||
|
|
||||||
source = get_config_source()
|
source = get_config_source()
|
||||||
payload = {
|
payload = {
|
||||||
"source": str(source) if source else "<defaults>",
|
"source": str(source) if source else "<defaults>",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from {{ cookiecutter.package_name }}.cli.common import load_config
|
||||||
from {{ cookiecutter.package_name }}.commands import format_greeting
|
from {{ cookiecutter.package_name }}.commands import format_greeting
|
||||||
|
|
||||||
app = typer.Typer(no_args_is_help=True)
|
app = typer.Typer(no_args_is_help=True)
|
||||||
@@ -12,6 +13,12 @@ app = typer.Typer(no_args_is_help=True)
|
|||||||
@app.command()
|
@app.command()
|
||||||
def say(
|
def say(
|
||||||
name: Annotated[str, typer.Option("--name", "-n", help="Name to greet.")] = "World",
|
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:
|
) -> None:
|
||||||
"""Print a greeting."""
|
"""Print a greeting."""
|
||||||
print(format_greeting(name))
|
config = load_config()
|
||||||
|
message = format_greeting(name=name, greeting=greeting or config.app.greeting)
|
||||||
|
print(message)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
def format_greeting(name: str) -> str:
|
def format_greeting(name: str, greeting: str = "Hello") -> str:
|
||||||
"""Return a greeting message."""
|
"""Return a greeting message."""
|
||||||
return f"Hello, {name}!"
|
return f"{greeting}, {name}!"
|
||||||
|
|||||||
@@ -4,15 +4,28 @@ import os
|
|||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
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):
|
class Settings(BaseModel):
|
||||||
model_config = {"frozen": True, "extra": "forbid"}
|
model_config = {"frozen": True, "extra": "forbid"}
|
||||||
|
|
||||||
greeting: str = "Hello"
|
app: AppConfig = AppConfig()
|
||||||
|
database: DatabaseConfig = DatabaseConfig()
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> tuple[Settings, Path | None]:
|
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:
|
def _to_toml(payload: dict[str, object]) -> str:
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for section, values in payload.items():
|
|
||||||
if isinstance(values, dict):
|
for key, value in payload.items():
|
||||||
lines.append(f"[{section}]")
|
if isinstance(value, dict):
|
||||||
for key, value in values.items():
|
_write_table(lines, key, value)
|
||||||
lines.append(f"{key} = {_toml_value(value)}")
|
|
||||||
lines.append("")
|
|
||||||
else:
|
else:
|
||||||
lines.append(f"{section} = {_toml_value(values)}")
|
lines.append(f"{key} = {_toml_value(value)}")
|
||||||
|
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
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:
|
def _toml_value(value: object) -> str:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
escaped = value.replace("\\", "\\\\").replace('"', "\\\"")
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
return f'"{escaped}"'
|
return f'"{escaped}"'
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return "true" if value else "false"
|
return "true" if value else "false"
|
||||||
if isinstance(value, (int, float)):
|
if isinstance(value, int | float):
|
||||||
return str(value)
|
return str(value)
|
||||||
raise TypeError(f"Unsupported TOML value: {value!r}")
|
raise TypeError(f"Unsupported TOML value: {value!r}")
|
||||||
|
|||||||
75
{{cookiecutter.project_slug}}/tests/test_cli.py
Normal file
75
{{cookiecutter.project_slug}}/tests/test_cli.py
Normal file
@@ -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"] == "<defaults>"
|
||||||
|
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
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.commands import format_greeting
|
from {{ cookiecutter.package_name }}.commands import format_greeting
|
||||||
from {{ cookiecutter.package_name }}.config import get_config, get_config_source
|
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:
|
def test_env_config_override(tmp_path, monkeypatch) -> None:
|
||||||
config_path = tmp_path / "config.toml"
|
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))
|
monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path))
|
||||||
|
|
||||||
settings = get_config()
|
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
|
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"
|
missing = tmp_path / "missing.toml"
|
||||||
monkeypatch.setenv(ENV_CONFIG_VAR, str(missing))
|
monkeypatch.setenv(ENV_CONFIG_VAR, str(missing))
|
||||||
|
|
||||||
try:
|
with pytest.raises(FileNotFoundError, match="Config file not found"):
|
||||||
get_config()
|
get_config()
|
||||||
except FileNotFoundError as exc:
|
|
||||||
assert str(missing) in str(exc)
|
|
||||||
else:
|
def test_xdg_default_config_is_loaded(monkeypatch) -> None:
|
||||||
raise AssertionError("Expected FileNotFoundError")
|
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:
|
def test_format_greeting() -> None:
|
||||||
assert format_greeting("Ada") == "Hello, Ada!"
|
assert format_greeting("Ada") == "Hello, Ada!"
|
||||||
|
assert format_greeting("Ada", greeting="Hi") == "Hi, Ada!"
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }} import __version__
|
from {{ cookiecutter.package_name }} import __all__, __version__, get_config
|
||||||
from {{ cookiecutter.package_name }}.config import get_config
|
from {{ cookiecutter.package_name }}.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
def test_public_version_is_string() -> None:
|
def test_public_version_is_string() -> None:
|
||||||
assert isinstance(__version__, str)
|
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()
|
settings = get_config()
|
||||||
assert settings.greeting == "Hello"
|
assert isinstance(settings, Settings)
|
||||||
|
assert settings.app.greeting == "Hello"
|
||||||
|
assert settings.database.path.endswith("/data.db")
|
||||||
|
|||||||
Reference in New Issue
Block a user