diff --git a/python/hooks/post_gen_project.py b/python/hooks/post_gen_project.py index 60e5fa0..3b8fa97 100644 --- a/python/hooks/post_gen_project.py +++ b/python/hooks/post_gen_project.py @@ -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() diff --git a/python/{{cookiecutter.project_slug}}/pyproject.toml b/python/{{cookiecutter.project_slug}}/pyproject.toml index cbd77ce..1937371 100644 --- a/python/{{cookiecutter.project_slug}}/pyproject.toml +++ b/python/{{cookiecutter.project_slug}}/pyproject.toml @@ -26,6 +26,9 @@ dependencies = [ "fastapi", "pydantic~=2.1", {%- endif %} +{%- if cookiecutter.add_fastapi or cookiecutter.add_cli %} + "pydantic-settings", +{%- endif %} ] dynamic = ["version"] @@ -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 = [ 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 dca501f..a96eac4 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/__init__.py @@ -2,7 +2,6 @@ from importlib.metadata import PackageNotFoundError, version - try: __version__ = version("{{ cookiecutter.project_slug }}") except PackageNotFoundError: diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py index 201bab6..9af2414 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/api.py @@ -1,5 +1,6 @@ """Define API endpoints.""" +import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from enum import Enum @@ -7,7 +8,7 @@ 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 @@ -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 @@ -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 ) diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py index 238c181..6ba624f 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/cli.py @@ -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 @@ -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) diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py index 3db97db..5b7e692 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/config.py @@ -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() diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py index 5fe9733..511a035 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/logging.py @@ -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 diff --git a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py index eb85d82..58d3051 100644 --- a/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py +++ b/python/{{cookiecutter.project_slug}}/src/{{cookiecutter.project_slug}}/models.py @@ -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__ diff --git a/python/{{cookiecutter.project_slug}}/tests/conftest.py b/python/{{cookiecutter.project_slug}}/tests/conftest.py index 3e64a1d..fb06d9b 100644 --- a/python/{{cookiecutter.project_slug}}/tests/conftest.py +++ b/python/{{cookiecutter.project_slug}}/tests/conftest.py @@ -1,3 +1,9 @@ + +from pathlib import Path + +import pytest + + def pytest_addoption(parser): """Add custom commands to pytest invocation. @@ -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" diff --git a/python/{{cookiecutter.project_slug}}/tests/data/service_info_openapi.yaml b/python/{{cookiecutter.project_slug}}/tests/data/service_info_openapi.yaml new file mode 100644 index 0000000..9f9ddfd --- /dev/null +++ b/python/{{cookiecutter.project_slug}}/tests/data/service_info_openapi.yaml @@ -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: 'ga4gh-discovery-networks@ga4gh.org' +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:support@example.com' + 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' diff --git a/python/{{cookiecutter.project_slug}}/tests/test_api.py b/python/{{cookiecutter.project_slug}}/tests/test_api.py index 8dfa29c..3a1dccb 100644 --- a/python/{{cookiecutter.project_slug}}/tests/test_api.py +++ b/python/{{cookiecutter.project_slug}}/tests/test_api.py @@ -1,13 +1,13 @@ """Test FastAPI endpoint function.""" -from datetime import datetime -import re +from pathlib import Path +import jsonschema import pytest +import yaml from fastapi.testclient import TestClient from {{ cookiecutter.project_slug }}.api import app -from {{ cookiecutter.project_slug }}.models import ServiceEnvironment @pytest.fixture(scope="module") @@ -15,26 +15,17 @@ 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"]) +def test_service_info(api_client: TestClient, test_data_dir: Path): + response = api_client.get("/service-info") + response.raise_for_status() + + with (test_data_dir / "service_info_openapi.yaml").open() as f: + spec = yaml.safe_load(f) + + resp_schema = spec["paths"]["/service-info"]["get"]["responses"]["200"]["content"][ + "application/json" + ]["schema"] + + resolver = jsonschema.RefResolver.from_schema(spec) + data = response.json() + jsonschema.validate(instance=data, schema=resp_schema, resolver=resolver)