From ddba4f11ed33f9d8dfe483102068db5ce893d3e4 Mon Sep 17 00:00:00 2001 From: Russ Allbery Date: Wed, 31 Jan 2024 17:25:40 -0800 Subject: [PATCH] Update the FastAPI template for current practices Update for current FastAPI recommendations and warnings: - Use a lifespan function instead of hooks - Update to Pydantic v2 - Use Annotated for injected dependencies in route functions Use Pydantic's support for environment variable prefixes to use the same prefix for all environment variables used for configuration, instead of using SAFIR_ for the default options. Update the Makefile for current best practices: - Add a make help default target - Upgrade pip before updating dependencies - Run pre-commit autoupdate during make update-deps - Add --allow-unsafe to pip-compile because setuptools is normally needed (and we've not had any problems with it in the past) Remove the dependency update CI job. We've found this is more annoying than helpful; it's easier to update using make update at the start of a development cycle. Switch to Ruff for both linting and reformatting; remove the Black, isort, and flake8 configurations; and update the Ruff configuration with more exclusions. Update the default and minimum version of Python to 3.12. --- .../example/.pre-commit-config.yaml | 24 ++---- .../fastapi_safir_app/example/Dockerfile | 2 +- .../fastapi_safir_app/example/Makefile | 56 ++++++++++---- .../fastapi_safir_app/example/pyproject.toml | 73 ++++++++----------- .../example/requirements/dev.in | 1 + .../example/requirements/main.in | 4 +- .../example/src/example/config.py | 17 +++-- .../example/src/example/handlers/external.py | 4 +- .../example/src/example/main.py | 19 +++-- .../{{cookiecutter.name}}/.flake8 | 7 -- .../.github/workflows/dependencies.yaml | 35 --------- .../.pre-commit-config.yaml | 24 ++---- .../{{cookiecutter.name}}/Dockerfile | 2 +- .../{{cookiecutter.name}}/Makefile | 56 ++++++++++---- .../{{cookiecutter.name}}/pyproject.toml | 73 ++++++++----------- .../{{cookiecutter.name}}/requirements/dev.in | 1 + .../requirements/main.in | 4 +- .../{{cookiecutter.module_name}}/config.py | 25 ++++--- .../handlers/external.py | 4 +- .../src/{{cookiecutter.module_name}}/main.py | 19 +++-- .../technote_md/testn-000/technote.toml | 2 +- .../technote_rst/testn-000/technote.toml | 2 +- 22 files changed, 226 insertions(+), 228 deletions(-) delete mode 100644 project_templates/fastapi_safir_app/{{cookiecutter.name}}/.flake8 delete mode 100644 project_templates/fastapi_safir_app/{{cookiecutter.name}}/.github/workflows/dependencies.yaml diff --git a/project_templates/fastapi_safir_app/example/.pre-commit-config.yaml b/project_templates/fastapi_safir_app/example/.pre-commit-config.yaml index 05338a7f..05252747 100644 --- a/project_templates/fastapi_safir_app/example/.pre-commit-config.yaml +++ b/project_templates/fastapi_safir_app/example/.pre-commit-config.yaml @@ -1,22 +1,14 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - - id: check-yaml - id: check-toml + - id: check-yaml + - id: trailing-whitespace - - repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - additional_dependencies: [toml] - - - repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - - - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.14 hooks: - - id: flake8 + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format diff --git a/project_templates/fastapi_safir_app/example/Dockerfile b/project_templates/fastapi_safir_app/example/Dockerfile index 5712dc66..b041b8bf 100644 --- a/project_templates/fastapi_safir_app/example/Dockerfile +++ b/project_templates/fastapi_safir_app/example/Dockerfile @@ -14,7 +14,7 @@ # - Runs a non-root user. # - Sets up the entrypoint and port. -FROM python:3.11.1-slim-bullseye as base-image +FROM python:3.12.1-slim-bullseye as base-image # Update system packages COPY scripts/install-base-packages.sh . diff --git a/project_templates/fastapi_safir_app/example/Makefile b/project_templates/fastapi_safir_app/example/Makefile index 7f4eb352..338583cc 100644 --- a/project_templates/fastapi_safir_app/example/Makefile +++ b/project_templates/fastapi_safir_app/example/Makefile @@ -1,27 +1,51 @@ -.PHONY: update-deps -update-deps: - pip install --upgrade pip-tools pip setuptools - pip-compile --upgrade --resolver=backtracking --build-isolation --generate-hashes --output-file requirements/main.txt requirements/main.in - pip-compile --upgrade --resolver=backtracking --build-isolation --generate-hashes --output-file requirements/dev.txt requirements/dev.in - -# Useful for testing against a Git version of Safir. -.PHONY: update-deps-no-hashes -update-deps-no-hashes: - pip install --upgrade pip-tools pip setuptools - pip-compile --upgrade --resolver=backtracking --build-isolation --allow-unsafe --output-file requirements/main.txt requirements/main.in - pip-compile --upgrade --resolver=backtracking --build-isolation --allow-unsafe --output-file requirements/dev.txt requirements/dev.in +.PHONY: help +help: + @echo "Make targets for Gafaelfawr" + @echo "make init - Set up dev environment" + @echo "make run - Start a local development instance" + @echo "make update - Update pinned dependencies and run make init" + @echo "make update-deps - Update pinned dependencies" + @echo "make update-deps-no-hashes - Pin dependencies without hashes" .PHONY: init init: + pip install --upgrade pip + pip install --upgrade pre-commit tox pip install --editable . pip install --upgrade -r requirements/main.txt -r requirements/dev.txt rm -rf .tox - pip install --upgrade pre-commit tox pre-commit install -.PHONY: update -update: update-deps init - .PHONY: run run: tox run -e run + +.PHONY: update +update: update-deps init + +# The dependencies need --allow-unsafe because kubernetes-asyncio and +# (transitively) pre-commit depends on setuptools, which is normally not +# allowed to appear in a hashed dependency file. +.PHONY: update-deps +update-deps: + pip install --upgrade pip + pip install --upgrade pre-commit + pre-commit autoupdate + pip install --upgrade pip-tools pip setuptools + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe --generate-hashes \ + --output-file requirements/main.txt requirements/main.in + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe --generate-hashes \ + --output-file requirements/dev.txt requirements/dev.in + +# Useful for testing against a Git version of Safir. +.PHONY: update-deps-no-hashes +update-deps-no-hashes: + pip install --upgrade pip-tools pip setuptools + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe \ + --output-file requirements/main.txt requirements/main.in + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe \ + --output-file requirements/dev.txt requirements/dev.in diff --git a/project_templates/fastapi_safir_app/example/pyproject.toml b/project_templates/fastapi_safir_app/example/pyproject.toml index dcb327b8..8505db87 100644 --- a/project_templates/fastapi_safir_app/example/pyproject.toml +++ b/project_templates/fastapi_safir_app/example/pyproject.toml @@ -11,13 +11,13 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: POSIX", "Typing :: Typed", ] -requires-python = ">=3.11" +requires-python = ">=3.12" # Use requirements/main.in for runtime dependencies instead. dependencies = [] dynamic = ["version"] @@ -35,24 +35,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] -[tool.black] -line-length = 79 -target-version = ["py311"] -exclude = ''' -/( - \.eggs - | \.git - | \.mypy_cache - | \.tox - | \.venv - | _build - | build - | dist -)/ -''' -# Use single-quoted strings so TOML treats the string like a Python r-string -# Multi-line strings are implicitly treated by black as regular expressions - [tool.coverage.run] parallel = true branch = true @@ -75,12 +57,6 @@ exclude_lines = [ "if TYPE_CHECKING:", ] -[tool.isort] -profile = "black" -line_length = 79 -known_first_party = ["example", "tests"] -skip = ["docs/conf.py"] - [tool.mypy] disallow_untyped_defs = true disallow_incomplete_defs = true @@ -138,15 +114,22 @@ ignore = [ "D104", # don't see the point of documenting every package "D105", # our style doesn't require docstrings for magic methods "D106", # Pydantic uses a nested Config class that doesn't warrant docs + "D205", # our documentation style allows a folded first line "EM101", # justification (duplicate string in traceback) is silly "EM102", # justification (duplicate string in traceback) is silly "FBT003", # positional booleans are normal for Pydantic field defaults + "FIX002", # point of a TODO comment is that we're not ready to fix it "G004", # forbidding logging f-strings is appealing, but not our style "RET505", # disagree that omitting else always makes code more readable + "PLR0911", # often many returns is clearer and simpler style "PLR0913", # factory pattern uses constructors with many arguments "PLR2004", # too aggressive about magic values + "PLW0603", # yes global is discouraged but if needed, it's needed "S105", # good idea but too many false positives on non-passwords "S106", # good idea but too many false positives on non-passwords + "S107", # good idea but too many false positives on non-passwords + "S603", # not going to manually mark every subprocess call as reviewed + "S607", # using PATH is not a security vulnerability "SIM102", # sometimes the formatting of nested if statements is clearer "SIM117", # sometimes nested with contexts are clearer "TCH001", # we decided to not maintain separate TYPE_CHECKING blocks @@ -154,19 +137,40 @@ ignore = [ "TCH003", # we decided to not maintain separate TYPE_CHECKING blocks "TID252", # if we're going to use relative imports, use them always "TRY003", # good general advice but lint is way too aggressive + "TRY301", # sometimes raising exceptions inside try is the best flow + + # The following settings should be disabled when using ruff format + # per https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", ] select = ["ALL"] -target-version = "py311" +target-version = "py312" [tool.ruff.per-file-ignores] "src/example/handlers/**" = [ "D103", # FastAPI handlers should not have docstrings ] "tests/**" = [ + "C901", # tests are allowed to be complex, sometimes that's convenient + "D101", # tests don't need docstrings "D103", # tests don't need docstrings "PLR0915", # tests are allowed to be long, sometimes that's convenient "PT012", # way too aggressive about limiting pytest.raises blocks "S101", # tests should use assert + "S106", # tests are allowed to hard-code dummy passwords "SLF001", # tests are allowed to access private members ] @@ -174,15 +178,6 @@ target-version = "py311" known-first-party = ["example", "tests"] split-on-trailing-comma = false -[tool.ruff.flake8-bugbear] -extend-immutable-calls = [ - "fastapi.Form", - "fastapi.Header", - "fastapi.Depends", - "fastapi.Path", - "fastapi.Query", -] - # These are too useful as attributes or methods to allow the conflict with the # built-in to rule out their use. [tool.ruff.flake8-builtins] @@ -199,12 +194,6 @@ builtins-ignorelist = [ fixture-parentheses = false mark-parentheses = false -[tool.ruff.pep8-naming] -classmethod-decorators = [ - "pydantic.root_validator", - "pydantic.validator", -] - [tool.ruff.pydocstyle] convention = "numpy" diff --git a/project_templates/fastapi_safir_app/example/requirements/dev.in b/project_templates/fastapi_safir_app/example/requirements/dev.in index a0cc567d..d8ffa50b 100644 --- a/project_templates/fastapi_safir_app/example/requirements/dev.in +++ b/project_templates/fastapi_safir_app/example/requirements/dev.in @@ -19,6 +19,7 @@ pydantic pytest pytest-asyncio pytest-cov +ruff # Documentation scriv diff --git a/project_templates/fastapi_safir_app/example/requirements/main.in b/project_templates/fastapi_safir_app/example/requirements/main.in index 465b8fb0..985d2fe1 100644 --- a/project_templates/fastapi_safir_app/example/requirements/main.in +++ b/project_templates/fastapi_safir_app/example/requirements/main.in @@ -13,4 +13,6 @@ starlette uvicorn[standard] # Other dependencies. -safir>=3.4.0 +pydantic +pydantic-settings +safir>=5 diff --git a/project_templates/fastapi_safir_app/example/src/example/config.py b/project_templates/fastapi_safir_app/example/src/example/config.py index 995024a6..50474f54 100644 --- a/project_templates/fastapi_safir_app/example/src/example/config.py +++ b/project_templates/fastapi_safir_app/example/src/example/config.py @@ -2,39 +2,40 @@ from __future__ import annotations -from pydantic import BaseSettings, Field +from pydantic import Field +from pydantic_settings import BaseSettings from safir.logging import LogLevel, Profile -__all__ = ["Configuration", "config"] +__all__ = ["Config", "config"] -class Configuration(BaseSettings): +class Config(BaseSettings): """Configuration for example.""" name: str = Field( "example", title="Name of application", - env="SAFIR_NAME", + validation_alias="EXAMPLE_NAME", ) path_prefix: str = Field( "/example", title="URL prefix for application", - env="SAFIR_PATH_PREFIX", + validation_alias="EXAMPLE_PATH_PREFIX", ) profile: Profile = Field( Profile.development, title="Application logging profile", - env="SAFIR_PROFILE", + validation_alias="EXAMPLE_PROFILE", ) log_level: LogLevel = Field( LogLevel.INFO, title="Log level of the application's logger", - env="SAFIR_LOG_LEVEL", + validation_alias="EXAMPLE_LOG_LEVEL", ) -config = Configuration() +config = Config() """Configuration for example.""" diff --git a/project_templates/fastapi_safir_app/example/src/example/handlers/external.py b/project_templates/fastapi_safir_app/example/src/example/handlers/external.py index 037b6e0e..fa0c1740 100644 --- a/project_templates/fastapi_safir_app/example/src/example/handlers/external.py +++ b/project_templates/fastapi_safir_app/example/src/example/handlers/external.py @@ -1,5 +1,7 @@ """Handlers for the app's external root, ``/example/``.""" +from typing import Annotated + from fastapi import APIRouter, Depends from safir.dependencies.logger import logger_dependency from safir.metadata import get_metadata @@ -25,7 +27,7 @@ summary="Application metadata", ) async def get_index( - logger: BoundLogger = Depends(logger_dependency), + logger: Annotated[BoundLogger, Depends(logger_dependency)], ) -> Index: """GET ``/example/`` (the app's external root). diff --git a/project_templates/fastapi_safir_app/example/src/example/main.py b/project_templates/fastapi_safir_app/example/src/example/main.py index 9c218c63..581c5843 100644 --- a/project_templates/fastapi_safir_app/example/src/example/main.py +++ b/project_templates/fastapi_safir_app/example/src/example/main.py @@ -7,6 +7,8 @@ called. """ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from importlib.metadata import metadata, version from fastapi import FastAPI @@ -21,6 +23,17 @@ __all__ = ["app", "config"] +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Set up and tear down the application.""" + # Any code here will be run when the application starts up. + + yield + + # Any code here will be run when the application shuts down. + await http_client_dependency.aclose() + + configure_logging( profile=config.profile, log_level=config.log_level, @@ -35,6 +48,7 @@ openapi_url=f"/{config.path_prefix}/openapi.json", docs_url=f"/{config.path_prefix}/docs", redoc_url=f"/{config.path_prefix}/redoc", + lifespan=lifespan, ) """The main FastAPI application for example.""" @@ -44,8 +58,3 @@ # Add middleware. app.add_middleware(XForwardedMiddleware) - - -@app.on_event("shutdown") -async def shutdown_event() -> None: - await http_client_dependency.aclose() diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.flake8 b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.flake8 deleted file mode 100644 index 01ee5229..00000000 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.flake8 +++ /dev/null @@ -1,7 +0,0 @@ -[flake8] -max-line-length = 79 -# E203: whitespace before :, flake8 disagrees with PEP-8 -# W503: line break after binary operator, flake8 disagrees with PEP-8 -ignore = E203, W503 -exclude = - docs/conf.py diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.github/workflows/dependencies.yaml b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.github/workflows/dependencies.yaml deleted file mode 100644 index 319e6e95..00000000 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.github/workflows/dependencies.yaml +++ /dev/null @@ -1,35 +0,0 @@ -{% if cookiecutter.github_org == "lsst-sqre" -%} -name: Dependency Update - -"on": - schedule: - - cron: "0 12 * * 1" - workflow_dispatch: {} - -jobs: - update: - runs-on: ubuntu-latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v3 - - - name: Run neophile - uses: lsst-sqre/run-neophile@v1 - with: - python-version: "3.11" - mode: pr - types: pre-commit - app-id: {{ "${{ secrets.NEOPHILE_APP_ID }}" }} - app-secret: {{ "${{ secrets.NEOPHILE_PRIVATE_KEY }}" }} - - - name: Report status - if: always() - uses: ravsamhq/notify-slack-action@v2 - with: - status: {{ "${{ job.status }}" }} - notify_when: "failure" - notification_title: "Periodic dependency update for {repo} failed" - env: - SLACK_WEBHOOK_URL: {{ "${{ secrets.SLACK_ALERT_WEBHOOK }}" }} -{%- endif %} diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.pre-commit-config.yaml b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.pre-commit-config.yaml index 05338a7f..05252747 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.pre-commit-config.yaml +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/.pre-commit-config.yaml @@ -1,22 +1,14 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - - id: check-yaml - id: check-toml + - id: check-yaml + - id: trailing-whitespace - - repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - additional_dependencies: [toml] - - - repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - - - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.14 hooks: - - id: flake8 + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Dockerfile b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Dockerfile index 5a1f210e..d32cbec4 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Dockerfile +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Dockerfile @@ -14,7 +14,7 @@ # - Runs a non-root user. # - Sets up the entrypoint and port. -FROM python:3.11.1-slim-bullseye as base-image +FROM python:3.12.1-slim-bullseye as base-image # Update system packages COPY scripts/install-base-packages.sh . diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Makefile b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Makefile index 7f4eb352..338583cc 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Makefile +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/Makefile @@ -1,27 +1,51 @@ -.PHONY: update-deps -update-deps: - pip install --upgrade pip-tools pip setuptools - pip-compile --upgrade --resolver=backtracking --build-isolation --generate-hashes --output-file requirements/main.txt requirements/main.in - pip-compile --upgrade --resolver=backtracking --build-isolation --generate-hashes --output-file requirements/dev.txt requirements/dev.in - -# Useful for testing against a Git version of Safir. -.PHONY: update-deps-no-hashes -update-deps-no-hashes: - pip install --upgrade pip-tools pip setuptools - pip-compile --upgrade --resolver=backtracking --build-isolation --allow-unsafe --output-file requirements/main.txt requirements/main.in - pip-compile --upgrade --resolver=backtracking --build-isolation --allow-unsafe --output-file requirements/dev.txt requirements/dev.in +.PHONY: help +help: + @echo "Make targets for Gafaelfawr" + @echo "make init - Set up dev environment" + @echo "make run - Start a local development instance" + @echo "make update - Update pinned dependencies and run make init" + @echo "make update-deps - Update pinned dependencies" + @echo "make update-deps-no-hashes - Pin dependencies without hashes" .PHONY: init init: + pip install --upgrade pip + pip install --upgrade pre-commit tox pip install --editable . pip install --upgrade -r requirements/main.txt -r requirements/dev.txt rm -rf .tox - pip install --upgrade pre-commit tox pre-commit install -.PHONY: update -update: update-deps init - .PHONY: run run: tox run -e run + +.PHONY: update +update: update-deps init + +# The dependencies need --allow-unsafe because kubernetes-asyncio and +# (transitively) pre-commit depends on setuptools, which is normally not +# allowed to appear in a hashed dependency file. +.PHONY: update-deps +update-deps: + pip install --upgrade pip + pip install --upgrade pre-commit + pre-commit autoupdate + pip install --upgrade pip-tools pip setuptools + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe --generate-hashes \ + --output-file requirements/main.txt requirements/main.in + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe --generate-hashes \ + --output-file requirements/dev.txt requirements/dev.in + +# Useful for testing against a Git version of Safir. +.PHONY: update-deps-no-hashes +update-deps-no-hashes: + pip install --upgrade pip-tools pip setuptools + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe \ + --output-file requirements/main.txt requirements/main.in + pip-compile --upgrade --resolver=backtracking --build-isolation \ + --allow-unsafe \ + --output-file requirements/dev.txt requirements/dev.in diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/pyproject.toml b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/pyproject.toml index 3f46d2f9..e8359106 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/pyproject.toml +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/pyproject.toml @@ -11,13 +11,13 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Intended Audience :: Developers", "Natural Language :: English", "Operating System :: POSIX", "Typing :: Typed", ] -requires-python = ">=3.11" +requires-python = ">=3.12" # Use requirements/main.in for runtime dependencies instead. dependencies = [] dynamic = ["version"] @@ -35,24 +35,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] -[tool.black] -line-length = 79 -target-version = ["py311"] -exclude = ''' -/( - \.eggs - | \.git - | \.mypy_cache - | \.tox - | \.venv - | _build - | build - | dist -)/ -''' -# Use single-quoted strings so TOML treats the string like a Python r-string -# Multi-line strings are implicitly treated by black as regular expressions - [tool.coverage.run] parallel = true branch = true @@ -75,12 +57,6 @@ exclude_lines = [ "if TYPE_CHECKING:", ] -[tool.isort] -profile = "black" -line_length = 79 -known_first_party = ["{{cookiecutter.module_name}}", "tests"] -skip = ["docs/conf.py"] - [tool.mypy] disallow_untyped_defs = true disallow_incomplete_defs = true @@ -138,15 +114,22 @@ ignore = [ "D104", # don't see the point of documenting every package "D105", # our style doesn't require docstrings for magic methods "D106", # Pydantic uses a nested Config class that doesn't warrant docs + "D205", # our documentation style allows a folded first line "EM101", # justification (duplicate string in traceback) is silly "EM102", # justification (duplicate string in traceback) is silly "FBT003", # positional booleans are normal for Pydantic field defaults + "FIX002", # point of a TODO comment is that we're not ready to fix it "G004", # forbidding logging f-strings is appealing, but not our style "RET505", # disagree that omitting else always makes code more readable + "PLR0911", # often many returns is clearer and simpler style "PLR0913", # factory pattern uses constructors with many arguments "PLR2004", # too aggressive about magic values + "PLW0603", # yes global is discouraged but if needed, it's needed "S105", # good idea but too many false positives on non-passwords "S106", # good idea but too many false positives on non-passwords + "S107", # good idea but too many false positives on non-passwords + "S603", # not going to manually mark every subprocess call as reviewed + "S607", # using PATH is not a security vulnerability "SIM102", # sometimes the formatting of nested if statements is clearer "SIM117", # sometimes nested with contexts are clearer "TCH001", # we decided to not maintain separate TYPE_CHECKING blocks @@ -154,19 +137,40 @@ ignore = [ "TCH003", # we decided to not maintain separate TYPE_CHECKING blocks "TID252", # if we're going to use relative imports, use them always "TRY003", # good general advice but lint is way too aggressive + "TRY301", # sometimes raising exceptions inside try is the best flow + + # The following settings should be disabled when using ruff format + # per https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", ] select = ["ALL"] -target-version = "py311" +target-version = "py312" [tool.ruff.per-file-ignores] "src/{{cookiecutter.module_name}}/handlers/**" = [ "D103", # FastAPI handlers should not have docstrings ] "tests/**" = [ + "C901", # tests are allowed to be complex, sometimes that's convenient + "D101", # tests don't need docstrings "D103", # tests don't need docstrings "PLR0915", # tests are allowed to be long, sometimes that's convenient "PT012", # way too aggressive about limiting pytest.raises blocks "S101", # tests should use assert + "S106", # tests are allowed to hard-code dummy passwords "SLF001", # tests are allowed to access private members ] @@ -174,15 +178,6 @@ target-version = "py311" known-first-party = ["{{cookiecutter.module_name}}", "tests"] split-on-trailing-comma = false -[tool.ruff.flake8-bugbear] -extend-immutable-calls = [ - "fastapi.Form", - "fastapi.Header", - "fastapi.Depends", - "fastapi.Path", - "fastapi.Query", -] - # These are too useful as attributes or methods to allow the conflict with the # built-in to rule out their use. [tool.ruff.flake8-builtins] @@ -199,12 +194,6 @@ builtins-ignorelist = [ fixture-parentheses = false mark-parentheses = false -[tool.ruff.pep8-naming] -classmethod-decorators = [ - "pydantic.root_validator", - "pydantic.validator", -] - [tool.ruff.pydocstyle] convention = "numpy" diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/dev.in b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/dev.in index a0cc567d..d8ffa50b 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/dev.in +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/dev.in @@ -19,6 +19,7 @@ pydantic pytest pytest-asyncio pytest-cov +ruff # Documentation scriv diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/main.in b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/main.in index 465b8fb0..985d2fe1 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/main.in +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/requirements/main.in @@ -13,4 +13,6 @@ starlette uvicorn[standard] # Other dependencies. -safir>=3.4.0 +pydantic +pydantic-settings +safir>=5 diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py index 153a3147..27d1f152 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/config.py @@ -2,39 +2,40 @@ from __future__ import annotations -from pydantic import BaseSettings, Field +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict from safir.logging import LogLevel, Profile -__all__ = ["Configuration", "config"] +__all__ = ["Config", "config"] -class Configuration(BaseSettings): +class Config(BaseSettings): """Configuration for {{ cookiecutter.name }}.""" name: str = Field( "{{ cookiecutter.name }}", - title="Name of application", - env="SAFIR_NAME", + title="Name of application" ) path_prefix: str = Field( "/{{ cookiecutter.name | lower }}", - title="URL prefix for application", - env="SAFIR_PATH_PREFIX", + title="URL prefix for application" ) profile: Profile = Field( Profile.development, - title="Application logging profile", - env="SAFIR_PROFILE", + title="Application logging profile" ) log_level: LogLevel = Field( LogLevel.INFO, - title="Log level of the application's logger", - env="SAFIR_LOG_LEVEL", + title="Log level of the application's logger" + ) + + model_config = SettingsConfigDict( + env_prefix="{{ cookiecutter.name | upper }}_", case_sensitive=False ) -config = Configuration() +config = Config() """Configuration for {{ cookiecutter.name }}.""" diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py index 417d2df2..b88d39c4 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/handlers/external.py @@ -1,5 +1,7 @@ """Handlers for the app's external root, ``/{{ cookiecutter.name | lower }}/``.""" +from typing import Annotated + from fastapi import APIRouter, Depends from safir.dependencies.logger import logger_dependency from safir.metadata import get_metadata @@ -25,7 +27,7 @@ summary="Application metadata", ) async def get_index( - logger: BoundLogger = Depends(logger_dependency), + logger: Annotated[BoundLogger, Depends(logger_dependency)], ) -> Index: """GET ``/{{ cookiecutter.name | lower }}/`` (the app's external root). diff --git a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py index 863c5e6e..7d61dcb8 100644 --- a/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py +++ b/project_templates/fastapi_safir_app/{{cookiecutter.name}}/src/{{cookiecutter.module_name}}/main.py @@ -7,6 +7,8 @@ called. """ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from importlib.metadata import metadata, version from fastapi import FastAPI @@ -21,6 +23,17 @@ __all__ = ["app", "config"] +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Set up and tear down the application.""" + # Any code here will be run when the application starts up. + + yield + + # Any code here will be run when the application shuts down. + await http_client_dependency.aclose() + + configure_logging( profile=config.profile, log_level=config.log_level, @@ -35,6 +48,7 @@ openapi_url=f"/{config.path_prefix}/openapi.json", docs_url=f"/{config.path_prefix}/docs", redoc_url=f"/{config.path_prefix}/redoc", + lifespan=lifespan, ) """The main FastAPI application for {{ cookiecutter.name }}.""" @@ -44,8 +58,3 @@ # Add middleware. app.add_middleware(XForwardedMiddleware) - - -@app.on_event("shutdown") -async def shutdown_event() -> None: - await http_client_dependency.aclose() diff --git a/project_templates/technote_md/testn-000/technote.toml b/project_templates/technote_md/testn-000/technote.toml index 83b18db1..6c6a2c3c 100644 --- a/project_templates/technote_md/testn-000/technote.toml +++ b/project_templates/technote_md/testn-000/technote.toml @@ -4,7 +4,7 @@ series_id = "TESTN" canonical_url = "https://testn-000.lsst.io" github_url = "https://github.com/lsst/testn-000" github_default_branch = "main" -date_created = 2024-01-25T20:38:53Z +date_created = 2024-02-01T01:33:58Z organization.name = "Vera C. Rubin Observatory" organization.ror = "https://ror.org/048g3cy84" license.id = "CC-BY-4.0" diff --git a/project_templates/technote_rst/testn-000/technote.toml b/project_templates/technote_rst/testn-000/technote.toml index 0d9860b9..e89417bc 100644 --- a/project_templates/technote_rst/testn-000/technote.toml +++ b/project_templates/technote_rst/testn-000/technote.toml @@ -4,7 +4,7 @@ series_id = "TESTN" canonical_url = "https://testn-000.lsst.io" github_url = "https://github.com/lsst/testn-000" github_default_branch = "main" -date_created = 2024-01-25T20:38:53Z +date_created = 2024-02-01T01:33:58Z organization.name = "Vera C. Rubin Observatory" organization.ror = "https://ror.org/048g3cy84" license.id = "CC-BY-4.0"