refactor template

This commit is contained in:
Jev
2026-01-19 21:16:56 +01:00
parent 27bb46e039
commit 1b66fecb9a
29 changed files with 555 additions and 256 deletions

View File

@@ -1,49 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
`{{ cookiecutter.project_slug }}` is a Python library for {{ cookiecutter.description.lower() }}.
## Development Commands
### Setup
```bash
uv sync
```
### Code Quality
- **Linting and formatting**: `uv run ruff check --fix` and `uv run ruff format`
- **Type checking**: `uv run mypy .`
- **Combined linting**: `uv run invoke lint`
### Testing
- **Run tests**: `uv run pytest`
- **Run tests with coverage**: `uv run invoke test`
### Maintenance
- **Clean untracked files**: `uv run invoke clean` (interactive)
- **Version bumping**: `uv run bump-my-version bump [patch|minor|major]`
## Project Structure
```
src/{{ cookiecutter.package_name }}/
├── __init__.py # Package initialization
├── core.py # Main functionality
└── py.typed # Type hints marker
examples/
└── basic_usage.py # Usage examples
tests/
└── test_{{ cookiecutter.package_name }}.py # Test suite
```
## Development Notes
- Uses Python {{ cookiecutter.python_version }}+ with modern type hints
- Configured with ruff for linting/formatting and mypy for type checking
- Built with uv for dependency management
- Includes invoke tasks for common operations

View File

@@ -5,52 +5,52 @@
## Installation
```bash
pip install {{ cookiecutter.project_slug }}
uv sync
```
## Usage
```bash
{{ cookiecutter.project_slug }} --help
{{ cookiecutter.project_slug }} config show
{{ 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
```
## Development
This project uses `uv` for dependency management and the following tools:
### Setup
```bash
uv sync
uv sync --group dev
```
### Code Quality
- **Ruff**: Linting and formatting
```bash
uv run ruff check --fix
uv run ruff format
```
- **MyPy**: Type checking
```bash
uv run mypy .
```
```bash
uv run ruff check src tests
uv run ruff format src tests
uv run mypy src
uv run invoke lint
```
### Testing
```bash
uv run pytest
```
### Version Management
- **bump-my-version**: Automated version bumping with git tags
```bash
uv run bump-my-version bump patch # {{ cookiecutter.version }} → 0.1.1
uv run bump-my-version bump minor # {{ cookiecutter.version }} → 0.2.0
uv run bump-my-version bump major # {{ cookiecutter.version }} → 1.0.0
```
## Usage
```python
from {{ cookiecutter.package_name }}.core import say_hello
say_hello() # prints: Hello, World!
say_hello("Alice") # prints: Hello, Alice!
uv run invoke test
```
## License
MIT License - see LICENSE file for details.
MIT License - see LICENSE file for details.

View File

@@ -1,16 +0,0 @@
"""Basic usage example for {{ cookiecutter.project_name }}."""
from {{ cookiecutter.package_name }}.core import say_hello
def main():
"""Demonstrate basic usage."""
# Basic hello
say_hello()
# Hello with a name
say_hello("{{ cookiecutter.project_name }}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
uv run {{ cookiecutter.project_slug }} config init
uv run {{ cookiecutter.project_slug }} config show

View File

@@ -8,35 +8,39 @@ authors = [
]
requires-python = ">={{ cookiecutter.python_version }}"
dependencies = [
# Add your project dependencies here
"coloredlogs>=15.0",
"pydantic>=2.0",
"typer>=0.12",
]
[project.scripts]
{{ cookiecutter.project_slug }} = "{{ cookiecutter.package_name }}.cli.app:app"
[build-system]
requires = ["uv_build>=0.8.8,<0.9.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"bump-my-version>=1.2.1",
"invoke>=2.2.0",
"mypy>=1.17.1",
"pytest>=8.4.1",
"pytest-cov>=6.1.0",
"ruff>=0.12.8",
"mypy>=1.10",
"pytest>=8.0",
"pytest-cov>=4.0",
"ruff>=0.4",
]
[tool.bumpversion]
current_version = "{{ cookiecutter.version }}"
commit = true
tag = true
tag_name = "v{new_version}"
[tool.mypy]
python_version = "{{ cookiecutter.python_version }}"
strict = true
files = ["src"]
[[tool.bumpversion.files]]
filename = "pyproject.toml"
search = "version = \"{current_version}\""
replace = "version = \"{new_version}\""
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
#------------------ruff configuration----------------
[tool.ruff.lint]
extend-select = ["B", "I", "C4", "TID", "SIM", "PLE", "RUF"]
ignore = [
@@ -48,4 +52,4 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["ARG001"] # Allow unused arguments in tests
"tests/*" = ["ARG001"] # Allow unused arguments in tests

View File

@@ -1,3 +1,12 @@
"""{{ cookiecutter.project_name }} - {{ cookiecutter.description }}"""
__version__ = "{{ cookiecutter.version }}"
from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("{{ cookiecutter.project_slug }}")
except PackageNotFoundError: # pragma: no cover - only in editable/uninstalled mode
__version__ = "0.0.0"
__all__ = ["__version__"]

View File

@@ -0,0 +1 @@
"""CLI package for {{ cookiecutter.project_name }}."""

View File

@@ -0,0 +1,33 @@
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()

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import typer
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."""
return get_config()
def exit_with_error(message: str, code: int = 1) -> None:
"""Print a concise error and exit with a non-zero status."""
typer.secho(message, fg=typer.colors.RED, err=True)
raise typer.Exit(code)

View File

@@ -0,0 +1,53 @@
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
from {{ cookiecutter.package_name }}.config import get_config, 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."""
try:
config = get_config()
except (FileNotFoundError, ValueError) as exc:
exit_with_error(str(exc))
return
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)

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from typing import Annotated
import typer
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",
) -> None:
"""Print a greeting."""
print(format_greeting(name))

View File

@@ -0,0 +1,5 @@
"""Command implementations for {{ cookiecutter.project_name }}."""
from {{ cookiecutter.package_name }}.commands.hello import format_greeting
__all__ = ["format_greeting"]

View File

@@ -0,0 +1,6 @@
from __future__ import annotations
def format_greeting(name: str) -> str:
"""Return a greeting message."""
return f"Hello, {name}!"

View File

@@ -0,0 +1,29 @@
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()

View File

@@ -0,0 +1,29 @@
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

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import os
import tomllib
from pathlib import Path
from pydantic import BaseModel
from .paths import ENV_CONFIG_VAR, config_file_path
class Settings(BaseModel):
model_config = {"frozen": True, "extra": "forbid"}
greeting: str = "Hello"
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 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("")
else:
lines.append(f"{section} = {_toml_value(values)}")
return "\n".join(lines).rstrip() + "\n"
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}")

View File

@@ -1,6 +0,0 @@
"""Core functionality for {{ cookiecutter.project_name }}."""
def say_hello(name: str = "World") -> None:
"""Print a hello message."""
print(f"Hello, {name}!")

View File

@@ -0,0 +1 @@
"""Data models for {{ cookiecutter.project_name }}."""

View File

@@ -0,0 +1 @@
"""Utility helpers for {{ cookiecutter.project_name }}."""

View File

@@ -0,0 +1,17 @@
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)

View File

@@ -3,37 +3,34 @@ from invoke import task
@task
def clean(ctx):
"""
Remove all files and directories that are not under version control to ensure a pristine working environment.
Use caution as this operation cannot be undone and might remove untracked files.
"""
ctx.run("git clean -nfdx")
response = (
input("Are you sure you want to remove all untracked files? (y/n) [n]: ")
.strip()
.lower()
)
if response == "y":
ctx.run("git clean -fdx")
def venv(c):
"""Sync dependencies."""
c.run("uv sync --group dev")
@task
def lint(ctx):
"""
Perform static analysis on the source code to check for syntax errors and enforce style consistency.
"""
ctx.run("ruff check src", pty=True)
ctx.run("ruff format --check src", pty=True)
ctx.run("mypy src", pty=True)
def format(c):
"""Format code."""
c.run("uv run ruff format src tests")
@task
def test(ctx):
"""
Run tests with coverage information.
"""
ctx.run("pytest --cov=src --cov-report=term-missing", pty=True)
def lint(c):
"""Run linters."""
c.run("uv run ruff check src tests")
c.run("uv run ruff format --check src tests")
c.run("uv run mypy src")
@task
def test(c):
"""Run tests with coverage."""
c.run("uv run pytest --cov=src --cov-report=term-missing")
@task
def clean(c):
"""Preview files to delete (safe mode)."""
c.run("git clean -nfdx")
if input("Delete? [y/N] ").lower() == "y":
c.run("git clean -fdx")

View File

@@ -0,0 +1,22 @@
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()

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
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
def test_env_config_override(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("greeting = \"Hi\"\n")
monkeypatch.setenv(ENV_CONFIG_VAR, str(config_path))
settings = get_config()
assert settings.greeting == "Hi"
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))
try:
get_config()
except FileNotFoundError as exc:
assert str(missing) in str(exc)
else:
raise AssertionError("Expected FileNotFoundError")
def test_format_greeting() -> None:
assert format_greeting("Ada") == "Hello, Ada!"

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from {{ cookiecutter.package_name }} import __version__
from {{ cookiecutter.package_name }}.config import get_config
def test_public_version_is_string() -> None:
assert isinstance(__version__, str)
def test_get_config_defaults() -> None:
settings = get_config()
assert settings.greeting == "Hello"

View File

@@ -1,24 +0,0 @@
"""Tests for {{ cookiecutter.package_name }}."""
from io import StringIO
import sys
from {{ cookiecutter.package_name }}.core import say_hello
def test_say_hello():
"""Test say_hello function."""
captured_output = StringIO()
sys.stdout = captured_output
say_hello()
sys.stdout = sys.__stdout__
assert captured_output.getvalue() == "Hello, World!\n"
def test_say_hello_with_name():
"""Test say_hello function with custom name."""
captured_output = StringIO()
sys.stdout = captured_output
say_hello("Alice")
sys.stdout = sys.__stdout__
assert captured_output.getvalue() == "Hello, Alice!\n"