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,19 +1,18 @@
# Python Library Template # Python App Template
A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for creating modern Python libraries with uv, ruff, mypy, and pytest. A Cookiecutter template for building CLI-first Python applications with uv, Typer, Pydantic, and strict typing.
## Features ## Features
- **Modern Python setup** with Python 3.12+ support - **CLI-first** architecture with Typer
- **uv** for fast dependency management - **Local-first config** (XDG paths) with env override
- **Ruff** for linting and formatting - **uv** for fast dependency management and reproducible installs
- **MyPy** for type checking - **Ruff** for linting/formatting
- **MyPy** with strict typing
- **Pytest** with coverage support - **Pytest** with coverage support
- **bump-my-version** for automated version management
- **Invoke** tasks for common operations - **Invoke** tasks for common operations
- **Pre-configured** development workflow - **py.typed** marker for typed packages
- **Type hints** support with py.typed marker - **Example command** wired from core `commands/` into CLI
- **CLAUDE.md** guidance for Claude Code integration
## Quick Start ## Quick Start
@@ -24,12 +23,12 @@ A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for crea
2. Generate a new project: 2. Generate a new project:
```bash ```bash
cruft create https://gitlab.com/roxautomation/templates/python-lib-template cruft create https://gitlab.com/roxautomation/templates/python-app-template
``` ```
Or locally: Or locally:
```bash ```bash
cookiecutter /path/to/python-lib-template cookiecutter /path/to/python-app-template
``` ```
3. The template will prompt for project details and automatically: 3. The template will prompt for project details and automatically:
@@ -42,10 +41,10 @@ A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for crea
| Variable | Description | Example | | Variable | Description | Example |
|----------|-------------|---------| |----------|-------------|---------|
| `project_name` | Human-readable project name | "My Python Library" | | `project_name` | Human-readable project name | "My CLI App" |
| `project_slug` | Repository/directory name | "my-python-library" | | `project_slug` | Repository/directory name | "my-cli-app" |
| `package_name` | Python package name | "my_python_library" | | `package_name` | Python package name | "my_cli_app" |
| `description` | Short project description | "A modern Python library" | | `description` | Short project description | "A modern CLI tool" |
| `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" |
@@ -55,49 +54,58 @@ A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for crea
``` ```
your-project/ your-project/
├── CLAUDE.md # Claude Code guidance ├── README.md
├── README.md # Project documentation ├── pyproject.toml
├── pyproject.toml # Project configuration ├── tasks.py
├── tasks.py # Invoke tasks
├── src/ ├── src/
│ └── your_package/ │ └── your_package/
│ ├── __init__.py │ ├── __init__.py
│ ├── core.py │ ├── py.typed
── py.typed ── cli/
│ │ ├── __init__.py
│ │ ├── app.py
│ │ ├── common.py
│ │ ├── config.py
│ │ └── hello.py
│ ├── config/
│ │ ├── __init__.py
│ │ ├── paths.py
│ │ └── settings.py
│ ├── commands/
│ │ ├── __init__.py
│ │ └── hello.py
│ ├── models/
│ │ └── __init__.py
│ └── utils/
│ ├── __init__.py
│ └── logging.py
├── tests/ ├── tests/
── test_your_package.py ── conftest.py
│ ├── test_public.py
│ └── test_internals.py
└── examples/ └── examples/
└── basic_usage.py └── config_init.sh
``` ```
## Development Workflow ## Development Workflow
The generated project includes these common tasks:
### Setup ### Setup
```bash ```bash
uv sync uv sync --group dev
``` ```
### Code Quality ### Code Quality
```bash ```bash
uv run ruff check --fix # Linting uv run ruff check src tests
uv run ruff format # Formatting uv run ruff format src tests
uv run mypy . # Type checking uv run mypy src
uv run invoke lint # All quality checks uv run invoke lint
``` ```
### Testing ### Testing
```bash ```bash
uv run pytest # Run tests uv run pytest
uv run invoke test # Run tests with coverage uv run invoke test
```
### Version Management
```bash
uv run bump-my-version bump patch # 0.1.0 → 0.1.1
uv run bump-my-version bump minor # 0.1.0 → 0.2.0
uv run bump-my-version bump major # 0.1.0 → 1.0.0
``` ```
## Template Development ## Template Development

View File

@@ -1,11 +1,11 @@
{ {
"project_name": "My Python Library", "project_name": "My CLI App",
"project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-').replace('_', '-') }}", "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-').replace('_', '-') }}",
"package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}", "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}",
"description": "A modern Python library", "description": "A modern CLI tool",
"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", "python_version": "3.12",
"year": "{% now 'utc', '%Y' %}" "year": "{% now 'utc', '%Y' %}"
} }

View File

@@ -29,11 +29,14 @@ def main():
run_command(["git", "init"], "git init") run_command(["git", "init"], "git init")
# Sync dependencies with uv # Sync dependencies with uv
run_command(["uv", "sync"], "uv sync") run_command(["uv", "sync", "--group", "dev"], "uv sync --group dev")
# Run initial formatting and linting # Run initial formatting and linting
run_command(["uv", "run", "ruff", "format", "src"], "ruff format") run_command(["uv", "run", "ruff", "format", "src", "tests"], "ruff format")
run_command(["uv", "run", "ruff", "check", "--fix", "src"], "ruff check --fix") run_command(
["uv", "run", "ruff", "check", "--fix", "src", "tests"],
"ruff check --fix",
)
# Run type checking # Run type checking
try: try:
@@ -52,10 +55,10 @@ def main():
print("Next steps:") print("Next steps:")
print("1. Review and customize the generated files") print("1. Review and customize the generated files")
print("2. Add your actual dependencies to pyproject.toml") print("2. Add your actual dependencies to pyproject.toml")
print("3. Implement your library functionality in src/") print("3. Implement your core logic in src/")
print("4. Update tests as needed") print("4. Update tests as needed")
print("5. Update README.md with proper documentation") print("5. Update README.md with proper documentation")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

96
test.sh
View File

@@ -44,30 +44,56 @@ check_tool() {
# Main test function # Main test function
main() { main() {
print_status "Starting template test..." print_status "Starting template test..."
# Get the template directory
TEMPLATE_DIR="$(cd "$(dirname "$0")" && pwd)"
# Check which template tool is available # Check which template tool is available
CRUFT_AVAILABLE=0
COOKIECUTTER_AVAILABLE=0
if command -v cruft &> /dev/null; then if command -v cruft &> /dev/null; then
TEMPLATE_TOOL="cruft" CRUFT_AVAILABLE=1
print_status "Using cruft for template generation" fi
elif command -v cookiecutter &> /dev/null; then if command -v cookiecutter &> /dev/null; then
TEMPLATE_TOOL="cookiecutter" COOKIECUTTER_AVAILABLE=1
print_status "Using cookiecutter for template generation" fi
else
if [ "$CRUFT_AVAILABLE" -eq 0 ] && [ "$COOKIECUTTER_AVAILABLE" -eq 0 ]; then
print_error "Neither cruft nor cookiecutter is installed. Please install one of them." print_error "Neither cruft nor cookiecutter is installed. Please install one of them."
print_status "Install with: pip install cruft or pip install cookiecutter" print_status "Install with: pip install cruft or pip install cookiecutter"
exit 1 exit 1
fi fi
if git -C "$TEMPLATE_DIR" rev-parse --is-inside-work-tree &> /dev/null; then
if [ -n "$(git -C "$TEMPLATE_DIR" status --porcelain)" ]; then
if [ "$COOKIECUTTER_AVAILABLE" -eq 1 ]; then
TEMPLATE_TOOL="cookiecutter"
print_warning "Working tree is dirty; using cookiecutter to include local changes"
else
TEMPLATE_TOOL="cruft"
print_warning "Working tree is dirty but cookiecutter is unavailable; using cruft"
fi
fi
fi
if [ -z "${TEMPLATE_TOOL:-}" ]; then
if [ "$CRUFT_AVAILABLE" -eq 1 ]; then
TEMPLATE_TOOL="cruft"
print_status "Using cruft for template generation"
else
TEMPLATE_TOOL="cookiecutter"
print_status "Using cookiecutter for template generation"
fi
fi
# Check for uv # Check for uv
check_tool "uv" check_tool "uv"
# Get the template directory
TEMPLATE_DIR="$(cd "$(dirname "$0")" && pwd)"
print_status "Template directory: $TEMPLATE_DIR" print_status "Template directory: $TEMPLATE_DIR"
# Set up build directory # Set up build directory
TEMP_DIR="$TEMPLATE_DIR/build" TEMP_DIR="$TEMPLATE_DIR/build"
# Check if build directory exists and ask for confirmation # Check if build directory exists and ask for confirmation
if [ -d "$TEMP_DIR" ]; then if [ -d "$TEMP_DIR" ]; then
print_warning "Build directory already exists: $TEMP_DIR" print_warning "Build directory already exists: $TEMP_DIR"
@@ -80,17 +106,17 @@ main() {
print_status "Removing existing build directory..." print_status "Removing existing build directory..."
rm -rf "$TEMP_DIR" rm -rf "$TEMP_DIR"
fi fi
# Create build directory # Create build directory
mkdir -p "$TEMP_DIR" mkdir -p "$TEMP_DIR"
print_status "Created build directory: $TEMP_DIR" print_status "Created build directory: $TEMP_DIR"
# Generate project from template # Generate project from template
print_status "Generating example project from template..." print_status "Generating example project from template..."
# Default values for the template # Default values for the template
PROJECT_NAME="test-project" PROJECT_NAME="test-project"
if [ "$TEMPLATE_TOOL" = "cruft" ]; then if [ "$TEMPLATE_TOOL" = "cruft" ]; then
cd "$TEMP_DIR" cd "$TEMP_DIR"
cruft create "$TEMPLATE_DIR" \ cruft create "$TEMPLATE_DIR" \
@@ -111,55 +137,55 @@ main() {
python_version="3.12" \ 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
# Navigate to generated project # Navigate to generated project
cd "$TEMP_DIR/$PROJECT_NAME" cd "$TEMP_DIR/$PROJECT_NAME"
print_status "Generated project at: $(pwd)" print_status "Generated project at: $(pwd)"
# List generated files # List generated files
print_status "Generated files:" print_status "Generated files:"
ls -la ls -la
# Install dependencies # Install dependencies
print_status "Installing dependencies with uv..." print_status "Installing dependencies with uv..."
uv sync || { print_error "Failed to install dependencies"; exit 1; } uv sync --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..."
uv run ruff check src || { print_error "Ruff check failed"; exit 1; } uv run ruff check src tests || { print_error "Ruff check failed"; exit 1; }
# Run ruff format check # Run ruff format check
print_status "Running ruff format check..." print_status "Running ruff format check..."
uv run ruff format --check src || { print_error "Ruff format check failed"; exit 1; } uv run ruff format --check src tests || { print_error "Ruff format check failed"; exit 1; }
# Run mypy # Run mypy
print_status "Running mypy type checking..." print_status "Running mypy type checking..."
uv run mypy src || { print_error "Mypy type checking failed"; exit 1; } uv run mypy src || { print_error "Mypy type checking failed"; exit 1; }
# Run tests # Run tests
print_status "Running tests..." print_status "Running tests..."
uv run pytest || { print_error "Tests failed"; exit 1; } uv run pytest || { print_error "Tests failed"; exit 1; }
# Run tests with coverage # Run tests with coverage
print_status "Running tests with coverage..." print_status "Running tests with coverage..."
uv run pytest --cov=src --cov-report=term-missing || { print_error "Tests with coverage failed"; exit 1; } uv run pytest --cov=src --cov-report=term-missing || { print_error "Tests with coverage failed"; exit 1; }
# Try running invoke tasks # Try running invoke tasks
print_status "Testing invoke lint task..." print_status "Testing invoke lint task..."
uv run invoke lint || { print_error "Invoke lint task failed"; exit 1; } uv run invoke lint || { print_error "Invoke lint task failed"; exit 1; }
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 # Check if example script runs
if [ -f "examples/basic_usage.py" ]; then if [ -f "examples/config_init.sh" ]; then
print_status "Running example script..." print_status "Running example script..."
uv run python examples/basic_usage.py || print_warning "Example script failed (this may be expected if it's a placeholder)" bash examples/config_init.sh || print_warning "Example script failed (this may be expected if it's a placeholder)"
fi 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."
} }
# Run main function # Run main function
main "$@" main "$@"

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 ## Installation
```bash ```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 ## Development
This project uses `uv` for dependency management and the following tools:
### Setup ### Setup
```bash ```bash
uv sync uv sync --group dev
``` ```
### Code Quality ### Code Quality
- **Ruff**: Linting and formatting ```bash
```bash uv run ruff check src tests
uv run ruff check --fix uv run ruff format src tests
uv run ruff format uv run mypy src
``` uv run invoke lint
```
- **MyPy**: Type checking
```bash
uv run mypy .
```
### Testing ### Testing
```bash ```bash
uv run pytest uv run pytest
``` uv run invoke test
### 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!
``` ```
## License ## 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 }}" requires-python = ">={{ cookiecutter.python_version }}"
dependencies = [ 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] [build-system]
requires = ["uv_build>=0.8.8,<0.9.0"] requires = ["uv_build>=0.8.8,<0.9.0"]
build-backend = "uv_build" build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"bump-my-version>=1.2.1",
"invoke>=2.2.0", "invoke>=2.2.0",
"mypy>=1.17.1", "mypy>=1.10",
"pytest>=8.4.1", "pytest>=8.0",
"pytest-cov>=6.1.0", "pytest-cov>=4.0",
"ruff>=0.12.8", "ruff>=0.4",
] ]
[tool.bumpversion] [tool.mypy]
current_version = "{{ cookiecutter.version }}" python_version = "{{ cookiecutter.python_version }}"
commit = true strict = true
tag = true files = ["src"]
tag_name = "v{new_version}"
[[tool.bumpversion.files]] [tool.pytest.ini_options]
filename = "pyproject.toml" testpaths = ["tests"]
search = "version = \"{current_version}\""
replace = "version = \"{new_version}\"" [tool.ruff]
target-version = "py312"
line-length = 100
#------------------ruff configuration----------------
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["B", "I", "C4", "TID", "SIM", "PLE", "RUF"] extend-select = ["B", "I", "C4", "TID", "SIM", "PLE", "RUF"]
ignore = [ ignore = [
@@ -48,4 +52,4 @@ ignore = [
] ]
[tool.ruff.lint.per-file-ignores] [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 }}""" """{{ 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 @task
def clean(ctx): def venv(c):
""" """Sync dependencies."""
Remove all files and directories that are not under version control to ensure a pristine working environment. c.run("uv sync --group dev")
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")
@task @task
def lint(ctx): def format(c):
""" """Format code."""
Perform static analysis on the source code to check for syntax errors and enforce style consistency. c.run("uv run ruff format src tests")
"""
ctx.run("ruff check src", pty=True)
ctx.run("ruff format --check src", pty=True)
ctx.run("mypy src", pty=True)
@task @task
def test(ctx): def lint(c):
""" """Run linters."""
Run tests with coverage information. c.run("uv run ruff check src tests")
""" c.run("uv run ruff format --check src tests")
ctx.run("pytest --cov=src --cov-report=term-missing", pty=True) 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"