simplify repo
This commit is contained in:
6
test.sh
6
test.sh
@@ -179,12 +179,6 @@ main() {
|
|||||||
print_status "Testing invoke test task..."
|
print_status "Testing invoke test task..."
|
||||||
uv run invoke test || { print_error "Invoke test task failed"; exit 1; }
|
uv run invoke test || { print_error "Invoke test task failed"; exit 1; }
|
||||||
|
|
||||||
# Check if example script runs
|
|
||||||
if [ -f "examples/config_init.sh" ]; then
|
|
||||||
print_status "Running example script..."
|
|
||||||
bash examples/config_init.sh || print_warning "Example script failed (this may be expected if it's a placeholder)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_status "✅ All tests passed successfully!"
|
print_status "✅ All tests passed successfully!"
|
||||||
print_status "Template is working correctly."
|
print_status "Template is working correctly."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,59 +12,13 @@ uv sync --frozen --group dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
{{ cookiecutter.project_slug }} --help
|
{{ cookiecutter.project_slug }} --help
|
||||||
{{ cookiecutter.project_slug }} config show
|
{{ cookiecutter.project_slug }} hello
|
||||||
{{ cookiecutter.project_slug }} config init
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Default config path (XDG):
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.config/{{ cookiecutter.project_slug }}/config.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
Override with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
```bash
|
```bash
|
||||||
uv sync --frozen --group dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
```bash
|
|
||||||
uv run ruff check src tests
|
|
||||||
uv run ruff format --check src tests
|
|
||||||
uv run mypy src
|
|
||||||
uv run invoke lint
|
uv run invoke lint
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
```bash
|
|
||||||
uv run pytest
|
|
||||||
uv run invoke test
|
uv run invoke test
|
||||||
|
uv run invoke format
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License - see LICENSE file for details.
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -8,13 +8,11 @@ authors = [
|
|||||||
]
|
]
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"coloredlogs>=15.0",
|
|
||||||
"pydantic>=2.0",
|
|
||||||
"typer>=0.12",
|
"typer>=0.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
{{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.cli.app:app"
|
{{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.cli:app"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.8.8,<0.9.0"]
|
requires = ["uv_build>=0.8.8,<0.9.0"]
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ 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
|
||||||
__version__ = "0.0.0"
|
__version__ = "0.0.0"
|
||||||
|
|
||||||
__all__ = ["Settings", "__version__", "get_config"]
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
app = typer.Typer(no_args_is_help=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_callback(value: bool) -> None:
|
||||||
|
if value:
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
print(version("{{ cookiecutter.project_slug }}"))
|
||||||
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback()
|
||||||
|
def main(
|
||||||
|
_version: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option("--version", "-v", help="Show version and exit.", callback=_version_callback),
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""{{ cookiecutter.description }}"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def hello(name: str = "world") -> None:
|
||||||
|
"""Say hello."""
|
||||||
|
print(f"Hello, {name}!")
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""CLI package for {{ cookiecutter.project_name }}."""
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.cli.config import app as config_app
|
|
||||||
from {{ cookiecutter.package_name }}.cli.hello import app as hello_app
|
|
||||||
from {{ cookiecutter.package_name }}.utils.logging import setup_logging
|
|
||||||
|
|
||||||
app = typer.Typer(no_args_is_help=True)
|
|
||||||
app.add_typer(config_app, name="config")
|
|
||||||
app.add_typer(hello_app, name="hello")
|
|
||||||
|
|
||||||
|
|
||||||
@app.callback(invoke_without_command=True)
|
|
||||||
def main(
|
|
||||||
version: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option("--version", "-v", help="Show version and exit."),
|
|
||||||
] = False,
|
|
||||||
) -> None:
|
|
||||||
"""Run the CLI application."""
|
|
||||||
setup_logging()
|
|
||||||
if version:
|
|
||||||
from importlib.metadata import version as get_version
|
|
||||||
|
|
||||||
print(get_version("{{ cookiecutter.project_slug }}"))
|
|
||||||
raise typer.Exit()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app()
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> Settings:
|
|
||||||
"""Load configuration once at the CLI boundary."""
|
|
||||||
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) -> 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)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
app = typer.Typer(no_args_is_help=True)
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def show() -> None:
|
|
||||||
"""Print the effective configuration and source path."""
|
|
||||||
config = load_config()
|
|
||||||
source = get_config_source()
|
|
||||||
payload = {
|
|
||||||
"source": str(source) if source else "<defaults>",
|
|
||||||
"config": config.model_dump(),
|
|
||||||
}
|
|
||||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def init(
|
|
||||||
path: Annotated[
|
|
||||||
Path | None,
|
|
||||||
typer.Option(
|
|
||||||
"--path",
|
|
||||||
help="Write the default config to a specific path.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
overwrite: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option("--overwrite", help="Overwrite existing file."),
|
|
||||||
] = False,
|
|
||||||
) -> None:
|
|
||||||
"""Write a default config to the XDG location."""
|
|
||||||
target = path or config_file_path()
|
|
||||||
if target.exists() and not overwrite:
|
|
||||||
exit_with_error(f"Config already exists at {target}. Use --overwrite to replace it.")
|
|
||||||
write_default_config(target)
|
|
||||||
typer.secho(f"Wrote default config to {target}", fg=typer.colors.GREEN)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
|
||||||
config = load_config()
|
|
||||||
message = format_greeting(name=name, greeting=greeting or config.app.greeting)
|
|
||||||
print(message)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Command implementations for {{ cookiecutter.project_name }}."""
|
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.commands.hello import format_greeting
|
|
||||||
|
|
||||||
__all__ = ["format_greeting"]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def format_greeting(name: str, greeting: str = "Hello") -> str:
|
|
||||||
"""Return a greeting message."""
|
|
||||||
return f"{greeting}, {name}!"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .settings import Settings, load_settings
|
|
||||||
|
|
||||||
CONFIG_SOURCE: Path | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_config() -> Settings:
|
|
||||||
"""Return the cached Settings instance."""
|
|
||||||
global CONFIG_SOURCE
|
|
||||||
settings, source = load_settings()
|
|
||||||
CONFIG_SOURCE = source
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
def get_config_source() -> Path | None:
|
|
||||||
"""Return the path used to load config, if any."""
|
|
||||||
return CONFIG_SOURCE
|
|
||||||
|
|
||||||
|
|
||||||
def clear_config_cache() -> None:
|
|
||||||
"""Clear cached settings for tests or reload scenarios."""
|
|
||||||
global CONFIG_SOURCE
|
|
||||||
CONFIG_SOURCE = None
|
|
||||||
get_config.cache_clear()
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
APP_NAME = "{{ cookiecutter.project_slug }}"
|
|
||||||
ENV_CONFIG_VAR = "{{ cookiecutter.package_name | upper }}_CONFIG"
|
|
||||||
|
|
||||||
|
|
||||||
def xdg_config_dir() -> Path:
|
|
||||||
"""Return the XDG config directory."""
|
|
||||||
root = os.environ.get("XDG_CONFIG_HOME")
|
|
||||||
return Path(root) if root else Path.home() / ".config"
|
|
||||||
|
|
||||||
|
|
||||||
def xdg_data_dir() -> Path:
|
|
||||||
"""Return the XDG data directory."""
|
|
||||||
root = os.environ.get("XDG_DATA_HOME")
|
|
||||||
return Path(root) if root else Path.home() / ".local" / "share"
|
|
||||||
|
|
||||||
|
|
||||||
def config_file_path() -> Path:
|
|
||||||
"""Return the default config file path."""
|
|
||||||
return xdg_config_dir() / APP_NAME / "config.toml"
|
|
||||||
|
|
||||||
|
|
||||||
def data_dir_path() -> Path:
|
|
||||||
"""Return the default data directory path."""
|
|
||||||
return xdg_data_dir() / APP_NAME
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
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"}
|
|
||||||
|
|
||||||
app: AppConfig = AppConfig()
|
|
||||||
database: DatabaseConfig = DatabaseConfig()
|
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> tuple[Settings, Path | None]:
|
|
||||||
"""Load settings and return the source path, if any."""
|
|
||||||
env_path = os.environ.get(ENV_CONFIG_VAR)
|
|
||||||
if env_path:
|
|
||||||
path = Path(env_path).expanduser()
|
|
||||||
if not path.exists():
|
|
||||||
raise FileNotFoundError(f"Config file not found: {path}")
|
|
||||||
data = tomllib.loads(path.read_text())
|
|
||||||
return Settings.model_validate(data), path
|
|
||||||
|
|
||||||
default_path = config_file_path()
|
|
||||||
if default_path.exists():
|
|
||||||
data = tomllib.loads(default_path.read_text())
|
|
||||||
return Settings.model_validate(data), default_path
|
|
||||||
|
|
||||||
return Settings(), None
|
|
||||||
|
|
||||||
|
|
||||||
def write_default_config(path: Path) -> None:
|
|
||||||
"""Write default config to a file."""
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
payload = Settings().model_dump()
|
|
||||||
content = _to_toml(payload)
|
|
||||||
path.write_text(content)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_toml(payload: dict[str, object]) -> str:
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
for key, value in payload.items():
|
|
||||||
if isinstance(value, dict):
|
|
||||||
_write_table(lines, key, value)
|
|
||||||
else:
|
|
||||||
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('"', '\\"')
|
|
||||||
return f'"{escaped}"'
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
if isinstance(value, int | float):
|
|
||||||
return str(value)
|
|
||||||
raise TypeError(f"Unsupported TOML value: {value!r}")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Data models for {{ cookiecutter.project_name }}."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Utility helpers for {{ cookiecutter.project_name }}."""
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import coloredlogs # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(level: str | None = None) -> None:
|
|
||||||
"""Configure application logging."""
|
|
||||||
level = level or os.environ.get("LOGLEVEL", "INFO")
|
|
||||||
coloredlogs.install(
|
|
||||||
level=level,
|
|
||||||
fmt="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
||||||
datefmt="%H:%M:%S",
|
|
||||||
)
|
|
||||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.config import clear_config_cache
|
|
||||||
from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate_xdg_paths(monkeypatch, tmp_path_factory) -> None:
|
|
||||||
temp_root = tmp_path_factory.mktemp("xdg")
|
|
||||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(temp_root / "config"))
|
|
||||||
monkeypatch.setenv("XDG_DATA_HOME", str(temp_root / "data"))
|
|
||||||
monkeypatch.delenv(ENV_CONFIG_VAR, raising=False)
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_config_cache() -> None:
|
|
||||||
clear_config_cache()
|
|
||||||
yield
|
|
||||||
clear_config_cache()
|
|
||||||
@@ -1,75 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from {{ cookiecutter.package_name }}.cli.app import app
|
from {{ cookiecutter.package_name }}.cli import app
|
||||||
from {{ cookiecutter.package_name }}.config.paths import ENV_CONFIG_VAR
|
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
def test_config_show_success_defaults() -> None:
|
def test_help() -> None:
|
||||||
result = runner.invoke(app, ["config", "show"])
|
result = runner.invoke(app, ["--help"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
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:
|
def test_version() -> None:
|
||||||
path = tmp_path / "config.toml"
|
result = runner.invoke(app, ["--version"])
|
||||||
|
assert result.exit_code == 0
|
||||||
create_result = runner.invoke(app, ["config", "init", "--path", str(path)])
|
assert result.output.strip()
|
||||||
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:
|
def test_hello() -> None:
|
||||||
missing = tmp_path / "missing.toml"
|
result = runner.invoke(app, ["hello", "--name", "Ada"])
|
||||||
monkeypatch.setenv(ENV_CONFIG_VAR, str(missing))
|
assert result.exit_code == 0
|
||||||
|
assert "Hello, Ada!" in result.output
|
||||||
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,62 +0,0 @@
|
|||||||
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, config_file_path
|
|
||||||
|
|
||||||
|
|
||||||
def test_env_config_override(tmp_path, monkeypatch) -> None:
|
|
||||||
config_path = tmp_path / "config.toml"
|
|
||||||
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.app.greeting == "Hi"
|
|
||||||
assert settings.database.path == "/tmp/app.db"
|
|
||||||
assert get_config_source() == config_path
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_env_config_raises(tmp_path, monkeypatch) -> None:
|
|
||||||
missing = tmp_path / "missing.toml"
|
|
||||||
monkeypatch.setenv(ENV_CONFIG_VAR, str(missing))
|
|
||||||
|
|
||||||
with pytest.raises(FileNotFoundError, match="Config file not found"):
|
|
||||||
get_config()
|
|
||||||
|
|
||||||
|
|
||||||
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,19 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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_public_exports_include_stable_symbols() -> None:
|
|
||||||
assert set(__all__) >= {"__version__", "get_config", "Settings"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_config_defaults_shape() -> None:
|
|
||||||
settings = get_config()
|
|
||||||
assert isinstance(settings, Settings)
|
|
||||||
assert settings.app.greeting == "Hello"
|
|
||||||
assert settings.database.path.endswith("/data.db")
|
|
||||||
Reference in New Issue
Block a user