diff --git a/hooks/pre_gen_project.py b/hooks/pre_gen_project.py index 8c7a3ef..80a0ff1 100644 --- a/hooks/pre_gen_project.py +++ b/hooks/pre_gen_project.py @@ -6,7 +6,9 @@ namespace_import = "{{ cookiecutter.project_namespace_import }}" if namespace_import and not re.match(NAMESPACE_REGEX, namespace_import): print(f"ERROR: '{namespace_import}' is not a valid Python namespace import path!") - print(f" It must follow regex '{NAMESPACE_REGEX}', i.e. 'one_two' or 'one_two.three'") + print( + f" It must follow regex '{NAMESPACE_REGEX}', i.e. 'one_two' or 'one_two.three'" + ) sys.exit(1) diff --git a/{{cookiecutter.project_slug}}/.github/workflows/tests.yml b/{{cookiecutter.project_slug}}/.github/workflows/tests.yml index 5be6a3c..f812a53 100644 --- a/{{cookiecutter.project_slug}}/.github/workflows/tests.yml +++ b/{{cookiecutter.project_slug}}/.github/workflows/tests.yml @@ -13,8 +13,6 @@ jobs: strategy: matrix: python: - - "3.9" - - "3.10" - "3.11" - "3.12" runs-on: ubuntu-latest diff --git a/{{cookiecutter.project_slug}}/.pre-commit-config.yaml b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml new file mode 100644 index 0000000..5980969 --- /dev/null +++ b/{{cookiecutter.project_slug}}/.pre-commit-config.yaml @@ -0,0 +1,45 @@ +# Pre-commit hooks for code quality +# See https://pre-commit.com for more information +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.2 + hooks: + # Run the formatter + - id: ruff-format + # Run the linter + - id: ruff + args: [--fix] + + - repo: local + hooks: + - id: pyright + name: pyright type check + entry: uv run pyright + language: system + types: [python] + pass_filenames: false + require_serial: true + + {%- if cookiecutter.docstring_coverage %} + - id: interrogate + name: interrogate docstring coverage + entry: uv run interrogate -c pyproject.toml + language: system + types: [python] + pass_filenames: false + {%- endif %} + + - id: pytest + name: pytest (fast tests only) + entry: uv run pytest -x --tb=short -k "not slow" + language: system + types: [python] + pass_filenames: false + require_serial: true + # Only run on pre-commit, not on push + stages: [pre-commit] + +# Configuration +ci: + autofix_prs: true + autoupdate_schedule: weekly diff --git a/{{cookiecutter.project_slug}}/CLAUDE.md b/{{cookiecutter.project_slug}}/CLAUDE.md new file mode 100644 index 0000000..f52d165 --- /dev/null +++ b/{{cookiecutter.project_slug}}/CLAUDE.md @@ -0,0 +1,167 @@ +# {{ cookiecutter.project_name }} - Claude Instructions + +This document contains project-specific instructions for Claude when working on this codebase. + +## Project Overview + +{{ cookiecutter.project_description }} + +## Code Standards + +This project enforces strict code quality standards: + +### Code Complexity Limits +- **Max 50 lines per function** - Split larger functions +- **Cyclomatic complexity ≤ 8** - Simplify complex logic +- **Max 5 positional parameters** - Use keyword arguments or dataclasses +- **Max 12 branches per function** - Extract to helper functions +- **Max 6 return statements** - Consolidate exit points + +### Style Guidelines +- **Line length**: 100 characters max +- **Docstrings**: Google style on all public functions/classes +- **Type hints**: Required for all function signatures +- **Tests**: Must live beside code (`test_*.py` or `*_test.py`) + +## Quick Commands + +```bash +# Development setup +make dev + +# Run all checks +make check # Runs lint + tests + +# Code quality +make lint # ruff format --check + ruff check + pyright +make fix # Auto-fix formatting and lint issues +make typecheck # Run pyright type checker + +# Testing +make test # Run pytest with coverage + +# Development +{% if cookiecutter.entry_point -%} +make run ARGS="--help" # Run the CLI +{%- endif %} +make doc # Build documentation +``` + +## Project Structure + +``` +src/ +└── {{ cookiecutter.__project_import.replace('.', '/') }}/ + ├── __init__.py + {%- if cookiecutter.entry_point %} + ├── __main__.py # CLI entry point + ├── _cli.py # CLI implementation + {%- endif %} + └── py.typed # Type checking marker + +test/ +└── test_*.py # Traditional test location +``` + +Tests can also live beside source files as `test_*.py` or `*_test.py`. + +## General Python Guidelines + +These are general preferences for Python development: + +- **Web frameworks**: Prefer FastAPI over Flask for new projects +- **Data processing**: Consider Polars for performance-critical data operations +- **Async programming**: Use native async/await instead of threading +- **Type checking**: Always use type hints and run pyright + +## Common Patterns + +### Error Handling +```python +from typing import Result # If using result types + +def process_data(path: str) -> Result[Data, str]: + """Process data from file. + + Args: + path: Path to data file. + + Returns: + Result with Data on success, error message on failure. + """ + try: + # Implementation + return Ok(data) + except Exception as e: + return Err(f"Failed to process: {e}") +``` + +### Logging +```python +import logging + +logger = logging.getLogger(__name__) +``` + +{%- if cookiecutter.entry_point %} + +### CLI Arguments +Use the existing `_cli.py` structure: +```python +parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Enable verbose output" +) +``` +{%- endif %} + +## Testing Guidelines + +- Aim for 100% test coverage (enforced by CI) +- Use `pytest.mark.parametrize` for multiple test cases +- Mock external dependencies +- Test both success and error paths + +### Test Markers + +Mark slow tests to exclude them from pre-commit hooks: + +```python +import pytest + +@pytest.mark.slow +def test_integration_with_external_api(): + """This test won't run during pre-commit hooks.""" + ... + +def test_fast_unit_test(): + """This test will run during pre-commit hooks.""" + ... +``` + +The pre-commit hook runs `pytest -k "not slow"` to skip slow tests. + +## CI/CD + +GitHub Actions run on every push/PR: +1. **Linting**: ruff format/check + pyright type checking +2. **Tests**: pytest with coverage +3. **Security**: zizmor workflow scanning +{%- if cookiecutter.documentation == "pdoc" %} +4. **Docs**: Auto-deploy to GitHub Pages +{%- endif %} + +## Important Notes + +1. **Never commit code that violates the quality standards** - refactor instead +2. **All public APIs need Google-style docstrings** +3. **Type hints are mandatory** - use `pyright` +4. **Tests can live beside code** - prefer colocated tests for better maintainability + +## Project-Specific Instructions + + + +--- +*This file helps Claude understand project conventions. Update it as patterns emerge.* \ No newline at end of file diff --git a/{{cookiecutter.project_slug}}/Makefile b/{{cookiecutter.project_slug}}/Makefile index 4f99666..06b4586 100644 --- a/{{cookiecutter.project_slug}}/Makefile +++ b/{{cookiecutter.project_slug}}/Makefile @@ -44,6 +44,7 @@ all: .PHONY: dev dev: $(VENV)/pyvenv.cfg + uv run pre-commit install {%- if cookiecutter.entry_point %} .PHONY: run @@ -59,17 +60,28 @@ $(VENV)/pyvenv.cfg: pyproject.toml lint: $(VENV)/pyvenv.cfg uv run ruff format --check && \ uv run ruff check && \ - uv run mypy + uv run pyright {%- if cookiecutter.docstring_coverage %} uv run interrogate -c pyproject.toml . {%- endif %} -.PHONY: reformat -reformat: +.PHONY: check +check: lint test + +.PHONY: typecheck +typecheck: $(VENV)/pyvenv.cfg + uv run pyright + +.PHONY: fix +fix: uv run ruff format && \ uv run ruff check --fix +# Alias for backwards compatibility +.PHONY: reformat +reformat: fix + .PHONY: test tests test tests: $(VENV)/pyvenv.cfg uv run pytest --cov=$(PY_IMPORT) $(T) $(TEST_ARGS) diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index b364bd1..b75c3e8 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -3,14 +3,12 @@ name = "{{ cookiecutter.project_slug }}" dynamic = ["version"] description = "{{ cookiecutter.project_description }}" readme = "README.md" -license-files = ["LICENSE"] - {%- if cookiecutter.license == "Apache 2.0" %} -license = "Apache-2.0" +license = {text = "Apache-2.0"} {%- elif cookiecutter.license == "AGPL v3" %} -license = "AGPL-3.0-or-later" +license = {text = "AGPL-3.0-or-later"} {%- elif cookiecutter.license == "Proprietary" %} -license = "LicenseRef-Proprietary-License" +license = {file = "LICENSE"} {%- endif %} authors = [ @@ -20,7 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [] -requires-python = ">=3.9" +requires-python = ">=3.11" [tool.setuptools.dynamic] version = { attr = "{{ cookiecutter.__project_import }}.__version__" } @@ -36,7 +34,7 @@ lint = [ # NOTE: ruff is under active development, so we pin conservatively here # and let Dependabot periodically perform this update. "ruff ~= 0.6.2", - "mypy >= 1.0", + "pyright >= 1.1", "types-html5lib", "types-requests", "types-toml", @@ -44,7 +42,7 @@ lint = [ "interrogate", {%- endif %} ] -dev = ["{{ cookiecutter.project_slug }}[doc,test,lint]", "twine", "build"] +dev = ["{{ cookiecutter.project_slug }}[doc,test,lint]", "twine", "build", "pre-commit"] {% if cookiecutter.entry_point -%} [project.scripts] @@ -61,46 +59,59 @@ Source = "https://github.com/{{ cookiecutter.github_username }}/{{ cookiecutter. # don't attempt code coverage for the CLI entrypoints omit = ["{{ cookiecutter.__project_src_path }}/_cli.py"] -[tool.mypy] -mypy_path = "src" -packages = "{{ cookiecutter.__project_import }}" -allow_redefinition = true -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_untyped_defs = true -ignore_missing_imports = true -no_implicit_optional = true -show_error_codes = true -sqlite_cache = true -strict_equality = true -warn_no_return = true -warn_redundant_casts = true -warn_return_any = true -warn_unreachable = true -warn_unused_configs = true -warn_unused_ignores = true +[tool.pyright] +# Type checking configuration +include = ["src", "test"] +pythonVersion = "3.11" +typeCheckingMode = "strict" +useLibraryCodeForTypes = true +reportMissingTypeStubs = false [tool.ruff] line-length = 100 -include = ["src/**/*.py", "test/**/*.py"] +target-version = "py311" + +[tool.ruff.format] +line-ending = "lf" +quote-style = "double" [tool.ruff.lint] select = ["ALL"] -# D203 and D213 are incompatible with D211 and D212 respectively. -# COM812 and ISC001 can cause conflicts when using ruff as a formatter. -# See https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules. -ignore = ["D203", "D213", "COM812", "ISC001"] +ignore = [ + "D203", # Incompatible with D211 + "D213", # Incompatible with D212 + "COM812", # Can conflict with formatter + "ISC001", # Can conflict with formatter +] + +[tool.ruff.lint.mccabe] +# Maximum cyclomatic complexity +max-complexity = 8 + +[tool.ruff.lint.pydocstyle] +# Use Google-style docstrings +convention = "google" + +[tool.ruff.lint.pylint] +# Maximum number of branches for function or method +max-branches = 12 +# Maximum number of return statements in function or method +max-returns = 6 +# Maximum number of positional arguments for function or method +max-positional-args = 5 [tool.ruff.lint.per-file-ignores] {% if cookiecutter.entry_point -%} "{{ cookiecutter.__project_src_path }}/_cli.py" = [ - "T201", # allow `print` in cli module + "T201", # allow print in cli module ] {%- endif %} "test/**/*.py" = [ "D", # no docstrings in tests "S101", # asserts are expected in tests + "PLR2004", # Allow magic values in tests ] +"**/conftest.py" = ["D"] # No docstrings in pytest config {%- if cookiecutter.docstring_coverage %} [tool.interrogate] @@ -110,3 +121,10 @@ exclude = ["env", "test", "{{ cookiecutter.__project_src_path }}/_cli.py"] ignore-semiprivate = true fail-under = 100 {%- endif %} + +[tool.pytest.ini_options] +# Support tests living beside code +testpaths = ["src", "test"] +python_files = ["test_*.py", "*_test.py"] +# Show test durations +addopts = "--durations=10"