refactor template
This commit is contained in:
88
README.md
88
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
|
||||
|
||||
@@ -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' %}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
main()
|
||||
|
||||
96
test.sh
96
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 "$@"
|
||||
main "$@"
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
6
{{cookiecutter.project_slug}}/examples/config_init.sh
Normal file
6
{{cookiecutter.project_slug}}/examples/config_init.sh
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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__"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI package for {{ cookiecutter.project_name }}."""
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Command implementations for {{ cookiecutter.project_name }}."""
|
||||
|
||||
from {{ cookiecutter.package_name }}.commands.hello import format_greeting
|
||||
|
||||
__all__ = ["format_greeting"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def format_greeting(name: str) -> str:
|
||||
"""Return a greeting message."""
|
||||
return f"Hello, {name}!"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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}!")
|
||||
@@ -0,0 +1 @@
|
||||
"""Data models for {{ cookiecutter.project_name }}."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility helpers for {{ cookiecutter.project_name }}."""
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
22
{{cookiecutter.project_slug}}/tests/conftest.py
Normal file
22
{{cookiecutter.project_slug}}/tests/conftest.py
Normal 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()
|
||||
32
{{cookiecutter.project_slug}}/tests/test_internals.py
Normal file
32
{{cookiecutter.project_slug}}/tests/test_internals.py
Normal 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!"
|
||||
13
{{cookiecutter.project_slug}}/tests/test_public.py
Normal file
13
{{cookiecutter.project_slug}}/tests/test_public.py
Normal 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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user