diff --git a/README.md b/README.md index 41cda24..4d587b5 100644 --- a/README.md +++ b/README.md @@ -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 -- **Modern Python setup** with Python 3.12+ support -- **uv** for fast dependency management -- **Ruff** for linting and formatting -- **MyPy** for type checking +- **CLI-first** architecture with Typer +- **Local-first config** (XDG paths) with env override +- **uv** for fast dependency management and reproducible installs +- **Ruff** for linting/formatting +- **MyPy** with strict typing - **Pytest** with coverage support -- **bump-my-version** for automated version management - **Invoke** tasks for common operations -- **Pre-configured** development workflow -- **Type hints** support with py.typed marker -- **CLAUDE.md** guidance for Claude Code integration +- **py.typed** marker for typed packages +- **Example command** wired from core `commands/` into CLI ## Quick Start @@ -24,12 +23,12 @@ A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for crea 2. Generate a new project: ```bash - cruft create https://gitlab.com/roxautomation/templates/python-lib-template + cruft create https://gitlab.com/roxautomation/templates/python-app-template ``` Or locally: ```bash - cookiecutter /path/to/python-lib-template + cookiecutter /path/to/python-app-template ``` 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 | |----------|-------------|---------| -| `project_name` | Human-readable project name | "My Python Library" | -| `project_slug` | Repository/directory name | "my-python-library" | -| `package_name` | Python package name | "my_python_library" | -| `description` | Short project description | "A modern Python library" | +| `project_name` | Human-readable project name | "My CLI App" | +| `project_slug` | Repository/directory name | "my-cli-app" | +| `package_name` | Python package name | "my_cli_app" | +| `description` | Short project description | "A modern CLI tool" | | `author_name` | Author's full name | "Your Name" | | `author_email` | Author's email | "your.email@example.com" | | `version` | Initial version | "0.1.0" | @@ -55,49 +54,58 @@ A [Cookiecutter](https://github.com/cookiecutter/cookiecutter) template for crea ``` your-project/ -├── CLAUDE.md # Claude Code guidance -├── README.md # Project documentation -├── pyproject.toml # Project configuration -├── tasks.py # Invoke tasks +├── README.md +├── pyproject.toml +├── tasks.py ├── src/ │ └── your_package/ │ ├── __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/ -│ └── test_your_package.py +│ ├── conftest.py +│ ├── test_public.py +│ └── test_internals.py └── examples/ - └── basic_usage.py + └── config_init.sh ``` ## Development Workflow -The generated project includes these common tasks: - ### Setup ```bash -uv sync +uv sync --group dev ``` ### Code Quality ```bash -uv run ruff check --fix # Linting -uv run ruff format # Formatting -uv run mypy . # Type checking -uv run invoke lint # All quality checks +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 # Run tests -uv run invoke test # Run tests with coverage -``` - -### 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 +uv run pytest +uv run invoke test ``` ## Template Development diff --git a/cookiecutter.json b/cookiecutter.json index dccf19f..83eae8c 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -1,11 +1,11 @@ { - "project_name": "My Python Library", + "project_name": "My CLI App", "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-').replace('_', '-') }}", "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}", - "description": "A modern Python library", + "description": "A modern CLI tool", "author_name": "Your Name", "author_email": "your.email@example.com", "version": "0.1.0", "python_version": "3.12", "year": "{% now 'utc', '%Y' %}" -} \ No newline at end of file +} diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 2fd0fd8..12141b8 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -29,11 +29,14 @@ def main(): run_command(["git", "init"], "git init") # 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_command(["uv", "run", "ruff", "format", "src"], "ruff format") - run_command(["uv", "run", "ruff", "check", "--fix", "src"], "ruff check --fix") + 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: @@ -52,10 +55,10 @@ def main(): print("Next steps:") print("1. Review and customize the generated files") 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("5. Update README.md with proper documentation") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/test.sh b/test.sh index beb4467..fd27998 100755 --- a/test.sh +++ b/test.sh @@ -44,30 +44,56 @@ check_tool() { # Main test function main() { print_status "Starting template test..." - + + # Get the template directory + TEMPLATE_DIR="$(cd "$(dirname "$0")" && pwd)" + # Check which template tool is available + CRUFT_AVAILABLE=0 + COOKIECUTTER_AVAILABLE=0 if command -v cruft &> /dev/null; then - TEMPLATE_TOOL="cruft" - print_status "Using cruft for template generation" - elif command -v cookiecutter &> /dev/null; then - TEMPLATE_TOOL="cookiecutter" - print_status "Using cookiecutter for template generation" - else + CRUFT_AVAILABLE=1 + fi + if command -v cookiecutter &> /dev/null; then + COOKIECUTTER_AVAILABLE=1 + fi + + if [ "$CRUFT_AVAILABLE" -eq 0 ] && [ "$COOKIECUTTER_AVAILABLE" -eq 0 ]; then print_error "Neither cruft nor cookiecutter is installed. Please install one of them." print_status "Install with: pip install cruft or pip install cookiecutter" exit 1 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_tool "uv" - - # Get the template directory - TEMPLATE_DIR="$(cd "$(dirname "$0")" && pwd)" + print_status "Template directory: $TEMPLATE_DIR" - + # Set up build directory TEMP_DIR="$TEMPLATE_DIR/build" - + # Check if build directory exists and ask for confirmation if [ -d "$TEMP_DIR" ]; then print_warning "Build directory already exists: $TEMP_DIR" @@ -80,17 +106,17 @@ main() { print_status "Removing existing build directory..." rm -rf "$TEMP_DIR" fi - + # Create build directory mkdir -p "$TEMP_DIR" print_status "Created build directory: $TEMP_DIR" - + # Generate project from template print_status "Generating example project from template..." - + # Default values for the template PROJECT_NAME="test-project" - + if [ "$TEMPLATE_TOOL" = "cruft" ]; then cd "$TEMP_DIR" cruft create "$TEMPLATE_DIR" \ @@ -111,55 +137,55 @@ main() { python_version="3.12" \ || { print_error "Failed to generate project with cookiecutter"; exit 1; } fi - + # Navigate to generated project cd "$TEMP_DIR/$PROJECT_NAME" print_status "Generated project at: $(pwd)" - + # List generated files print_status "Generated files:" ls -la - + # Install dependencies 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 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 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 print_status "Running mypy type checking..." uv run mypy src || { print_error "Mypy type checking failed"; exit 1; } - + # Run tests print_status "Running tests..." uv run pytest || { print_error "Tests failed"; exit 1; } - + # Run 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; } - + # Try running invoke tasks print_status "Testing invoke lint task..." uv run invoke lint || { print_error "Invoke lint task failed"; exit 1; } - + print_status "Testing invoke test task..." uv run invoke test || { print_error "Invoke test task failed"; exit 1; } - + # Check if example script runs - if [ -f "examples/basic_usage.py" ]; then + if [ -f "examples/config_init.sh" ]; then 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 - + print_status "✅ All tests passed successfully!" print_status "Template is working correctly." } # Run main function -main "$@" \ No newline at end of file +main "$@" diff --git a/{{cookiecutter.project_slug}}/CLAUDE.md b/{{cookiecutter.project_slug}}/CLAUDE.md deleted file mode 100644 index e5e58bf..0000000 --- a/{{cookiecutter.project_slug}}/CLAUDE.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/README.md b/{{cookiecutter.project_slug}}/README.md index 19c034b..dd15613 100644 --- a/{{cookiecutter.project_slug}}/README.md +++ b/{{cookiecutter.project_slug}}/README.md @@ -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. \ No newline at end of file +MIT License - see LICENSE file for details. diff --git a/{{cookiecutter.project_slug}}/examples/basic_usage.py b/{{cookiecutter.project_slug}}/examples/basic_usage.py deleted file mode 100644 index 260e91a..0000000 --- a/{{cookiecutter.project_slug}}/examples/basic_usage.py +++ /dev/null @@ -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() \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/examples/config_init.sh b/{{cookiecutter.project_slug}}/examples/config_init.sh new file mode 100644 index 0000000..03e7df0 --- /dev/null +++ b/{{cookiecutter.project_slug}}/examples/config_init.sh @@ -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 diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index 1d4f7ef..9c77b3b 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -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 \ No newline at end of file +"tests/*" = ["ARG001"] # Allow unused arguments in tests diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py index a37e042..7f516b5 100644 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/__init__.py @@ -1,3 +1,12 @@ """{{ cookiecutter.project_name }} - {{ cookiecutter.description }}""" -__version__ = "{{ cookiecutter.version }}" \ No newline at end of file +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__"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/__init__.py new file mode 100644 index 0000000..c2a0794 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/__init__.py @@ -0,0 +1 @@ +"""CLI package for {{ cookiecutter.project_name }}.""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/app.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/app.py new file mode 100644 index 0000000..bbd727d --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/app.py @@ -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() diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py new file mode 100644 index 0000000..630398b --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/common.py @@ -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) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py new file mode 100644 index 0000000..07c23fc --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/config.py @@ -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 "", + "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) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py new file mode 100644 index 0000000..49fe434 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/cli/hello.py @@ -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)) diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/__init__.py new file mode 100644 index 0000000..ccaee63 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/__init__.py @@ -0,0 +1,5 @@ +"""Command implementations for {{ cookiecutter.project_name }}.""" + +from {{ cookiecutter.package_name }}.commands.hello import format_greeting + +__all__ = ["format_greeting"] diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py new file mode 100644 index 0000000..3f3104a --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/commands/hello.py @@ -0,0 +1,6 @@ +from __future__ import annotations + + +def format_greeting(name: str) -> str: + """Return a greeting message.""" + return f"Hello, {name}!" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/__init__.py new file mode 100644 index 0000000..1cbc882 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/__init__.py @@ -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() diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/paths.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/paths.py new file mode 100644 index 0000000..f088bf6 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/paths.py @@ -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 diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py new file mode 100644 index 0000000..5ffb9ad --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/config/settings.py @@ -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}") diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/core.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/core.py deleted file mode 100644 index 12bf740..0000000 --- a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/core.py +++ /dev/null @@ -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}!") \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/models/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/models/__init__.py new file mode 100644 index 0000000..4501fca --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/models/__init__.py @@ -0,0 +1 @@ +"""Data models for {{ cookiecutter.project_name }}.""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/utils/__init__.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/utils/__init__.py new file mode 100644 index 0000000..296f638 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers for {{ cookiecutter.project_name }}.""" diff --git a/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/utils/logging.py b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/utils/logging.py new file mode 100644 index 0000000..a807748 --- /dev/null +++ b/{{cookiecutter.project_slug}}/src/{{cookiecutter.package_name}}/utils/logging.py @@ -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) diff --git a/{{cookiecutter.project_slug}}/tasks.py b/{{cookiecutter.project_slug}}/tasks.py index 999f586..dee4891 100644 --- a/{{cookiecutter.project_slug}}/tasks.py +++ b/{{cookiecutter.project_slug}}/tasks.py @@ -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) \ No newline at end of file +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") diff --git a/{{cookiecutter.project_slug}}/tests/conftest.py b/{{cookiecutter.project_slug}}/tests/conftest.py new file mode 100644 index 0000000..67fef55 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/conftest.py @@ -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() diff --git a/{{cookiecutter.project_slug}}/tests/test_internals.py b/{{cookiecutter.project_slug}}/tests/test_internals.py new file mode 100644 index 0000000..ca0950b --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/test_internals.py @@ -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!" diff --git a/{{cookiecutter.project_slug}}/tests/test_public.py b/{{cookiecutter.project_slug}}/tests/test_public.py new file mode 100644 index 0000000..c0a1acf --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/test_public.py @@ -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" diff --git a/{{cookiecutter.project_slug}}/tests/test_{{cookiecutter.package_name}}.py b/{{cookiecutter.project_slug}}/tests/test_{{cookiecutter.package_name}}.py deleted file mode 100644 index 3499afb..0000000 --- a/{{cookiecutter.project_slug}}/tests/test_{{cookiecutter.package_name}}.py +++ /dev/null @@ -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" \ No newline at end of file