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/hooks/post_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@

if not {{ cookiecutter.add_fastapi }}:
Path("tests/test_api.py").unlink()
Path("tests/data/service_info_openapi.yaml").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()

if (not {{ cookiecutter.add_fastapi }}) and (not {{ cookiecutter.add_cli }}):
Path("./src/{{ cookiecutter.project_slug }}/logging.py").unlink()
Path("src/{{ cookiecutter.project_slug }}/config.py").unlink()
5 changes: 5 additions & 0 deletions python/{{cookiecutter.project_slug}}/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dependencies = [
"fastapi",
"pydantic~=2.1",
{%- endif %}
{%- if cookiecutter.add_fastapi or cookiecutter.add_cli %}
"pydantic-settings",
{%- endif %}
]
dynamic = ["version"]

Expand All @@ -35,6 +38,8 @@ tests = [
"pytest-cov",
{%- if cookiecutter.add_fastapi %}
"httpx",
"jsonschema~=4.24", # pin to avoid deprecation of ref resolver
"pyyaml",
{%- endif %}
]
dev = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from importlib.metadata import PackageNotFoundError, version


try:
__version__ = version("{{ cookiecutter.project_slug }}")
except PackageNotFoundError:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"""Define API endpoints."""

import logging
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 }}.config import get_config
from {{ cookiecutter.project_slug }}.logging import initialize_logs
from {{ cookiecutter.project_slug }}.models import ServiceInfo, ServiceOrganization, ServiceType

Expand All @@ -20,7 +21,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: # noqa: ARG001

:param app: FastAPI instance
"""
initialize_logs()
log_level = logging.DEBUG if get_config().debug else logging.INFO

initialize_logs(log_level=log_level)
yield


Expand All @@ -46,20 +49,18 @@ class _Tag(str, Enum):
docs_url="/docs",
openapi_url="/openapi.json",
swagger_ui_parameters={"tryItOutEnabled": True},
lifespan=lifespan
)


@app.get(
"/service_info",
"/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
"""
"""Provide service info per GA4GH Service Info spec"""
return ServiceInfo(
organization=ServiceOrganization(), type=ServiceType(), environment=config.env
organization=ServiceOrganization(), type=ServiceType(), environment=get_config().env
)
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Provide CLI for application."""

import logging

import click

from {{ cookiecutter.project_slug }} import __version__
from {{ cookiecutter.project_slug }}.config import get_config
from {{ cookiecutter.project_slug }}.logging import initialize_logs


Expand All @@ -18,4 +21,5 @@ def cli() -> None:

Conclude by summarizing additional commands
""" # noqa: D301
initialize_logs()
log_level = logging.DEBUG if get_config().debug else logging.INFO
initialize_logs(log_level)
Original file line number Diff line number Diff line change
@@ -1,93 +1,37 @@
"""Read and provide runtime configuration."""

import logging
import os
from functools import cache

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

from {{ cookiecutter.project_slug }}.models import ServiceEnvironment


_logger = logging.getLogger(__name__)
class Settings(BaseSettings):
"""Create app settings


_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
This is not a singleton, so every new call to this class will re-compute
configuration settings, defaults, etc.
"""
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()
model_config = SettingsConfigDict(
env_prefix="{{ cookiecutter.project_slug }}_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore"
)

env: ServiceEnvironment = ServiceEnvironment.DEV
debug: bool = False
test: bool = False

_CONFIG_MAP = {
ServiceEnvironment.DEV: _dev_config,
ServiceEnvironment.TEST: _test_config,
ServiceEnvironment.STAGING: _staging_config,
ServiceEnvironment.PROD: _prod_config,
}

@cache
def get_config() -> Settings:
"""Get runtime configuration.

def _set_config() -> Config:
"""Set configs based on environment variable `{{ cookiecutter.project_slug | upper }}_ENV`.
This function is cached, so the config object only gets created/calculated once.

:return: complete config object with environment-specific parameters
:return: Settings instance
"""
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()
return Settings()
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging


def initialize_logs(log_level: int = logging.DEBUG) -> None:
def initialize_logs(log_level: int = logging.INFO) -> None:
"""Configure logging.

:param log_level: app log level to set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@ class ServiceInfo(BaseModel):
"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
updatedAt: Literal["{% now 'utc', '%Y-%m-%dT%H:%M:%S+00:00' %}"] = "{% now 'utc', '%Y-%m-%dT%H:%M:%S+00:00' %}" # noqa: N815
environment: ServiceEnvironment
version: Literal[__version__] = __version__
12 changes: 12 additions & 0 deletions python/{{cookiecutter.project_slug}}/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@

from pathlib import Path

import pytest


def pytest_addoption(parser):
"""Add custom commands to pytest invocation.

Expand All @@ -20,3 +26,9 @@ def pytest_configure(config):
# logging.getLogger("botocore").setLevel(logging.ERROR)
# logging.getLogger("boto3").setLevel(logging.ERROR)
# logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)


@pytest.fixture(scope="session")
def test_data_dir() -> Path:
"""Provide Path instance pointing to test data directory"""
return Path(__file__).parent / "data"
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
openapi: 3.0.2
info:
title: 'GA4GH service-info API specification'
description: 'A way for a service to describe basic metadata concerning a service alongside a set of capabilities and/or limitations of the service. More information on [GitHub](https://github.com/ga4gh-discovery/ga4gh-service-info/).'
version: 1.0.0
license:
name: 'Apache 2.0'
url: 'https://raw.githubusercontent.com/ga4gh-discovery/ga4gh-service-info/develop/LICENSE'
contact:
name: 'GA4GH Discovery Networks Team'
email: '[email protected]'
security:
- bearerAuth: []
paths:
/service-info:
get:
summary: 'Show information about this service. It is assumed that removing this endpoint from a URL will result in a valid URL to query against'
operationId: getServiceInfo
tags:
- service-info
responses:
'200':
description: 'A successful operation to request the service information about this running service.'
content:
application/json:
schema:
$ref: '#/components/schemas/Service'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Service:
description: 'GA4GH service'
type: object
required:
- id
- name
- type
- organization
- version
properties:
id:
type: string
description: 'Unique ID of this service. Reverse domain name notation is recommended, though not required. The identifier should attempt to be globally unique so it can be used in downstream aggregator services e.g. Service Registry.'
example: 'org.ga4gh.myservice'
name:
type: string
description: 'Name of this service. Should be human readable.'
example: 'My project'
type:
$ref: '#/components/schemas/ServiceType'
description:
type: string
description: 'Description of the service. Should be human readable and provide information about the service.'
example: 'This service provides...'
organization:
type: object
description: 'Organization providing the service'
required:
- name
- url
properties:
name:
type: string
description: 'Name of the organization responsible for the service'
example: 'My organization'
url:
type: string
format: uri
description: 'URL of the website of the organization (RFC 3986 format)'
example: 'https://example.com'
contactUrl:
type: string
format: uri
description: 'URL of the contact for the provider of this service, e.g. a link to a contact form (RFC 3986 format), or an email (RFC 2368 format).'
example: 'mailto:[email protected]'
documentationUrl:
type: string
format: uri
description: 'URL of the documentation of this service (RFC 3986 format). This should help someone learn how to use your service, including any specifics required to access data, e.g. authentication.'
example: 'https://docs.myservice.example.com'
createdAt:
type: string
format: date-time
description: 'Timestamp describing when the service was first deployed and available (RFC 3339 format)'
example: '2019-06-04T12:58:19Z'
updatedAt:
type: string
format: date-time
description: 'Timestamp describing when the service was last updated (RFC 3339 format)'
example: '2019-06-04T12:58:19Z'
environment:
type: string
description: 'Environment the service is running in. Use this to distinguish between production, development and testing/staging deployments. Suggested values are prod, test, dev, staging. However this is advised and not enforced.'
example: 'test'
version:
type: string
description: 'Version of the service being described. Semantic versioning is recommended, but other identifiers, such as dates or commit hashes, are also allowed. The version should be changed whenever the service is updated.'
example: '1.0.0'
ServiceType:
description: 'Type of a GA4GH service'
type: object
required:
- group
- artifact
- version
properties:
group:
type: string
description: 'Namespace in reverse domain name format. Use `org.ga4gh` for implementations compliant with official GA4GH specifications. For services with custom APIs not standardized by GA4GH, or implementations diverging from official GA4GH specifications, use a different namespace (e.g. your organization''s reverse domain name).'
example: 'org.ga4gh'
artifact:
type: string
description: 'Name of the API or GA4GH specification implemented. Official GA4GH types should be assigned as part of standards approval process. Custom artifacts are supported.'
example: 'beacon'
version:
type: string
description: 'Version of the API or specification. GA4GH specifications use semantic versioning.'
example: '1.0.0'
Loading
Loading