use Codex 5.3 to review template to spec
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "<defaults>",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}!"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
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
|
||||
|
||||
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!"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user