diff --git a/python/cookiecutter.json b/python/cookiecutter.json index 93594c4..1733049 100644 --- a/python/cookiecutter.json +++ b/python/cookiecutter.json @@ -6,5 +6,6 @@ "repo": "{{ cookiecutter.project_slug }}", "your_name": "", "your_email": "", - "add_docs": [false, true] + "add_docs": [false, true], + "add_fastapi": [false, true] } diff --git a/python/hooks/post_gen_project.py b/python/hooks/post_gen_project.py index 6e456c0..54e8ec6 100644 --- a/python/hooks/post_gen_project.py +++ b/python/hooks/post_gen_project.py @@ -1,4 +1,5 @@ """Provide hooks to run after project is generated.""" + from pathlib import Path import shutil @@ -6,3 +7,11 @@ if not {{ cookiecutter.add_docs }}: shutil.rmtree("docs") Path(".readthedocs.yaml").unlink() + + +if not {{ cookiecutter.add_fastapi }}: + Path("tests/test_api.py").unlink() + Path("src/{{ cookiecutter.project_slug }}/api.py").unlink() + Path("src/{{ cookiecutter.project_slug }}/models.py").unlink() + Path("src/{{ cookiecutter.project_slug }}/config.py").unlink() + Path("src/{{ cookiecutter.project_slug }}/logging.py").unlink() diff --git a/python/{{cookiecutter.project_slug}}/pyproject.toml b/python/{{cookiecutter.project_slug}}/pyproject.toml index 88eefe5..3120c68 100644 --- a/python/{{cookiecutter.project_slug}}/pyproject.toml +++ b/python/{{cookiecutter.project_slug}}/pyproject.toml @@ -18,16 +18,29 @@ classifiers = [ requires-python = ">=3.11" description = "{{ cookiecutter.description }}" license = {file = "LICENSE"} +{%- if cookiecutter.add_fastapi %} +dependencies = [ + "fastapi", + "pydantic~=2.1", +] +{% else %} dependencies = [] +{% endif -%} dynamic = ["version"] [project.optional-dependencies] -tests = ["pytest", "pytest-cov"] +tests = [ + "pytest", + "pytest-cov", +{%- if cookiecutter.add_fastapi %} + "httpx", +{%- endif %} +] dev = [ "pre-commit>=4.0.1", "ruff==0.8.6", ] -{% if cookiecutter.add_docs %} +{%- if cookiecutter.add_docs %} docs = [ "sphinx==6.1.3", "sphinx-autodoc-typehints==1.22.0", @@ -100,6 +113,9 @@ select = [ "ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth "PGH", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh +{%- if cookiecutter.add_fastapi %} + "FAST", # https://docs.astral.sh/ruff/rules/#fastapi-fast +{%- endif %} "PLC", # https://docs.astral.sh/ruff/rules/#convention-c "PLE", # https://docs.astral.sh/ruff/rules/#error-e_1 "TRY", # https://docs.astral.sh/ruff/rules/#tryceratops-try @@ -121,6 +137,9 @@ fixable = [ "PT", "RSE", "SIM", +{%- if cookiecutter.add_fastapi %} + "FAST", +{%- endif %} "PLC", "PLE", "TRY", diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py index 68871eb..dca501f 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py @@ -1,4 +1,5 @@ """{{ cookiecutter.description }}""" + from importlib.metadata import PackageNotFoundError, version diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py new file mode 100644 index 0000000..201bab6 --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py @@ -0,0 +1,65 @@ +"""Define API endpoints.""" + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from enum import Enum + +from fastapi import FastAPI + +from {{ cookiecutter.project_slug }} import __version__ +from {{ cookiecutter.project_slug }}.config import config +from {{ cookiecutter.project_slug }}.logging import initialize_logs +from {{ cookiecutter.project_slug }}.models import ServiceInfo, ServiceOrganization, ServiceType + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator: # noqa: ARG001 + """Perform operations that interact with the lifespan of the FastAPI instance. + + See https://fastapi.tiangolo.com/advanced/events/#lifespan. + + :param app: FastAPI instance + """ + initialize_logs() + yield + + +class _Tag(str, Enum): + """Define tag names for endpoints.""" + + META = "Meta" + + +app = FastAPI( + title="{{ cookiecutter.project_slug }}", + description="{{ cookiecutter.description }}", + version=__version__, + contact={ + "name": "Alex H. Wagner", + "email": "Alex.Wagner@nationwidechildrens.org", + "url": "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab", + }, + license={ + "name": "MIT", + "url": "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}/blob/main/LICENSE", + }, + docs_url="/docs", + openapi_url="/openapi.json", + swagger_ui_parameters={"tryItOutEnabled": True}, +) + + +@app.get( + "/service_info", + summary="Get basic service information", + description="Retrieve service metadata, such as versioning and contact info. Structured in conformance with the [GA4GH service info API specification](https://www.ga4gh.org/product/service-info/)", + tags=[_Tag.META], +) +def service_info() -> ServiceInfo: + """Provide service info per GA4GH Service Info spec + + :return: conformant service info description + """ + return ServiceInfo( + organization=ServiceOrganization(), type=ServiceType(), environment=config.env + ) diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py new file mode 100644 index 0000000..3db97db --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py @@ -0,0 +1,93 @@ +"""Read and provide runtime configuration.""" + +import logging +import os + +from pydantic import BaseModel + +from {{ cookiecutter.project_slug }}.models import ServiceEnvironment + + +_logger = logging.getLogger(__name__) + + +_ENV_VARNAME = "{{ cookiecutter.project_slug | upper }}_ENV" + + +class Config(BaseModel): + """Define app configuration data object.""" + + env: ServiceEnvironment + debug: bool + test: bool + + +def _dev_config() -> Config: + """Provide development environment configs + + :return: dev env configs + """ + return Config(env=ServiceEnvironment.DEV, debug=True, test=False) + + +def _test_config() -> Config: + """Provide test env configs + + :return: test configs + """ + return Config(env=ServiceEnvironment.TEST, debug=False, test=True) + + +def _staging_config() -> Config: + """Provide staging env configs + + :return: staging configs + """ + return Config(env=ServiceEnvironment.STAGING, debug=False, test=False) + + +def _prod_config() -> Config: + """Provide production configs + + :return: prod configs + """ + return Config(env=ServiceEnvironment.PROD, debug=False, test=False) + + +def _default_config() -> Config: + """Provide default configs. This function sets what they are. + + :return: default configs + """ + return _dev_config() + + +_CONFIG_MAP = { + ServiceEnvironment.DEV: _dev_config, + ServiceEnvironment.TEST: _test_config, + ServiceEnvironment.STAGING: _staging_config, + ServiceEnvironment.PROD: _prod_config, +} + + +def _set_config() -> Config: + """Set configs based on environment variable `{{ cookiecutter.project_slug | upper }}_ENV`. + + :return: complete config object with environment-specific parameters + """ + raw_env_value = os.environ.get(_ENV_VARNAME) + if not raw_env_value: + return _default_config() + try: + env_value = ServiceEnvironment(raw_env_value.lower()) + except ValueError: + _logger.error( + "Unrecognized value for %s: '%s'. Using default configs", + _ENV_VARNAME, + raw_env_value + ) + return _default_config() + return _CONFIG_MAP[env_value]() + + +config = _set_config() diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py new file mode 100644 index 0000000..5fe9733 --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py @@ -0,0 +1,16 @@ +"""Configure application logging.""" + +import logging + + +def initialize_logs(log_level: int = logging.DEBUG) -> None: + """Configure logging. + + :param log_level: app log level to set + """ + logging.basicConfig( + filename=f"{__package__}.log", + format="[%(asctime)s] - %(name)s - %(levelname)s : %(message)s", + ) + logger = logging.getLogger(__package__) + logger.setLevel(log_level) diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py new file mode 100644 index 0000000..eb85d82 --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py @@ -0,0 +1,58 @@ +"""Define models for internal data and API responses.""" + +from enum import Enum +from typing import Literal + +from pydantic import BaseModel + +from . import __version__ + + +class ServiceEnvironment(str, Enum): + """Define current runtime environment.""" + + DEV = "dev" + PROD = "prod" + TEST = "test" + STAGING = "staging" + + +class ServiceOrganization(BaseModel): + """Define service_info response for organization field""" + + name: Literal["Genomic Medicine Lab at Nationwide Children's Hospital"] = ( + "Genomic Medicine Lab at Nationwide Children's Hospital" + ) + url: Literal[ + "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab" + ] = "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab" + + +class ServiceType(BaseModel): + """Define service_info response for type field""" + + group: Literal["org.genomicmedlab"] = "org.genomicmedlab" + artifact: Literal["{{ cookiecutter.project_slug }} API"] = "{{ cookiecutter.project_slug }} API" + version: Literal[__version__] = __version__ + + +class ServiceInfo(BaseModel): + """Define response structure for GA4GH /service_info endpoint.""" + + id: Literal["org.genomicmedlab.{{ cookiecutter.project_slug }}"] = ( + "org.genomicmedlab.{{ cookiecutter.project_slug }}" + ) + name: Literal["{{ cookiecutter.project_slug }}"] = "{{ cookiecutter.project_slug }}" + type: ServiceType + description: Literal["{{ cookiecutter.description }}"] = "{{ cookiecutter.description }}" + organization: ServiceOrganization + contactUrl: Literal["Alex.Wagner@nationwidechildrens.org"] = ( # noqa: N815 + "Alex.Wagner@nationwidechildrens.org" + ) + documentationUrl: Literal["https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}"] = ( # noqa: N815 + "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}" + ) + createdAt: Literal["{% now 'utc', '%Y-%m-%dT%H:%M:%S+00:00' %}"] = "{% now 'utc', '%Y-%m-%dT%H:%M:%S+00:00' %}" # noqa: N815 + updatedAt: str | None = None # noqa: N815 + environment: ServiceEnvironment + version: Literal[__version__] = __version__ diff --git a/python/{{cookiecutter.project_slug}}/tests/conftest.py b/python/{{cookiecutter.project_slug}}/tests/conftest.py index 8cec28e..3e64a1d 100644 --- a/python/{{cookiecutter.project_slug}}/tests/conftest.py +++ b/python/{{cookiecutter.project_slug}}/tests/conftest.py @@ -1,7 +1,8 @@ def pytest_addoption(parser): """Add custom commands to pytest invocation. - See https://docs.pytest.org/en/8.1.x/reference/reference.html#parser""" + See https://docs.pytest.org/en/8.1.x/reference/reference.html#parser + """ parser.addoption( "--verbose-logs", action="store_true", diff --git a/python/{{cookiecutter.project_slug}}/tests/test_api.py b/python/{{cookiecutter.project_slug}}/tests/test_api.py new file mode 100644 index 0000000..8dfa29c --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/tests/test_api.py @@ -0,0 +1,40 @@ +"""Test FastAPI endpoint function.""" + +from datetime import datetime +import re + +import pytest +from fastapi.testclient import TestClient + +from {{ cookiecutter.project_slug }}.api import app +from {{ cookiecutter.project_slug }}.models import ServiceEnvironment + + +@pytest.fixture(scope="module") +def api_client(): + return TestClient(app) + + +def test_service_info(api_client: TestClient): + response = api_client.get("/service_info") + assert response.status_code == 200 + expected_version_pattern = r"\d\.\d\." # at minimum, should be something like "0.1" + response_json = response.json() + assert response_json["id"] == "org.genomicmedlab.{{ cookiecutter.project_slug }}" + assert response_json["name"] == "{{ cookiecutter.project_slug }}" + assert response_json["type"]["group"] == "org.genomicmedlab" + assert response_json["type"]["artifact"] == "{{ cookiecutter.project_slug }} API" + assert re.match(expected_version_pattern, response_json["type"]["version"]) + assert response_json["description"] == "{{ cookiecutter.description }}" + assert response_json["organization"] == { + "name": "Genomic Medicine Lab at Nationwide Children's Hospital", + "url": "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab", + } + assert response_json["contactUrl"] == "Alex.Wagner@nationwidechildrens.org" + assert ( + response_json["documentationUrl"] + == "https://github.com/{{ cookiecutter.org }}/{{ cookiecutter.repo }}" + ) + assert datetime.fromisoformat(response_json["createdAt"]) + assert ServiceEnvironment(response_json["environment"]) + assert re.match(expected_version_pattern, response_json["version"]) diff --git a/requirements.txt b/requirements.txt index b6ccfd0..c62ca9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ click==8.1.7 cookiecutter==2.5.0 idna==3.6 Jinja2==3.1.3 +jinja2-time==0.2.0 markdown-it-py==3.0.0 MarkupSafe==2.1.4 mdurl==0.1.2