Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion python/cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"repo": "{{ cookiecutter.project_slug }}",
"your_name": "",
"your_email": "",
"add_docs": [false, true]
"add_docs": [false, true],
"add_fastapi": [false, true]
}
9 changes: 9 additions & 0 deletions python/hooks/post_gen_project.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
"""Provide hooks to run after project is generated."""

from pathlib import Path
import shutil


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()
23 changes: 21 additions & 2 deletions python/{{cookiecutter.project_slug}}/pyproject.toml
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there's still some merge conflicts that need resolved

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -121,6 +137,9 @@ fixable = [
"PT",
"RSE",
"SIM",
{%- if cookiecutter.add_fastapi %}
"FAST",
{%- endif %}
"PLC",
"PLE",
"TRY",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""{{ cookiecutter.description }}"""

from importlib.metadata import PackageNotFoundError, version


Expand Down
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"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
)
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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["[email protected]"] = ( # noqa: N815
"[email protected]"
)
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__
3 changes: 2 additions & 1 deletion python/{{cookiecutter.project_slug}}/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 40 additions & 0 deletions python/{{cookiecutter.project_slug}}/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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"] == "[email protected]"
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"])
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading