diff --git a/.env.example b/.env.example index 2bad5e3..7bb46b1 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,11 @@ ANYVLM_STORAGE_URI=postgresql://anyvlm:anyvlm-pw@localhost:5435/anyvlm ## Testing - see "Contributing" -> "Testing" in the docs ANYVLM_TEST_STORAGE_URI=postgresql://anyvlm_test:anyvlm-test-pw@localhost:5436/anyvlm_test + +########################### +## VLM RESPONSE SETTINGS ## +########################### +HANDOVER_TYPE_ID="GREGoR-NCH" +HANDOVER_TYPE_LABEL="GREGoR AnyVLM Reference" +BEACON_HANDOVER_URL="https://variants.gregorconsortium.org/" +BEACON_NODE_ID="org.anyvlm.gregor" diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml index 397c3c2..65ae06e 100644 --- a/.github/workflows/python-package.yaml +++ b/.github/workflows/python-package.yaml @@ -46,8 +46,12 @@ jobs: - name: Run tests run: uv run pytest env: - ANYVLM_ANYVAR_TEST_STORAGE_URI: postgresql://postgres:postgres@localhost:5432/postgres ANYVLM_TEST_STORAGE_URI: postgresql://postgres:postgres@localhost:5432/postgres + ANYVLM_ANYVAR_TEST_STORAGE_URI: postgresql://postgres:postgres@localhost:5432/postgres + HANDOVER_TYPE_ID: GREGoR-NCH + HANDOVER_TYPE_LABEL: "GREGoR AnyVLM Reference" + BEACON_HANDOVER_URL: https://variants.gregorconsortium.org/ + BEACON_NODE_ID: org.anyvlm.gregor lint: name: lint runs-on: ubuntu-latest diff --git a/src/anyvlm/functions/build_vlm_response.py b/src/anyvlm/functions/build_vlm_response.py index e6ff359..195b221 100644 --- a/src/anyvlm/functions/build_vlm_response.py +++ b/src/anyvlm/functions/build_vlm_response.py @@ -1,11 +1,35 @@ """Craft a VlmResponse object from a list of CohortAlleleFrequencyStudyResults""" +import os + from anyvlm.schemas.vlm import ( + BeaconHandover, + HandoverType, + ResponseField, + ResponseSummary, VlmResponse, ) from anyvlm.utils.types import AnyVlmCohortAlleleFrequencyResult +class MissingEnvironmentVariableError(Exception): + """Raised when a required environment variable is not set.""" + + +def _get_environment_var(key: str) -> str: + """Retrieves an environment variable, raising an error if it is not set. + + :param key: The key for the environment variable + :returns: The value for the environment variable of the provided `key` + :raises: MissingEnvironmentVariableError if environment variable is not found. + """ + value: str | None = os.environ.get(key) + if not value: + message = f"Missing required environment variable: {key}" + raise MissingEnvironmentVariableError(message) + return value + + def build_vlm_response_from_caf_data( caf_data: list[AnyVlmCohortAlleleFrequencyResult], ) -> VlmResponse: @@ -14,4 +38,31 @@ def build_vlm_response_from_caf_data( :param caf_data: A list of `AnyVlmCohortAlleleFrequencyResult` objects that will be used to build the VlmResponse :return: A `VlmResponse` object. """ - raise NotImplementedError # TODO: Implement this during/after Issue #16 + raise NotImplementedError # TODO: Remove this and finish implementing this function in Issue #35 + + # TODO - create `handover_type` and `beacon_handovers` dynamically, + # instead of pulling from environment variables. See Issue #37. + handover_type = HandoverType( + id=_get_environment_var("HANDOVER_TYPE_ID"), + label=_get_environment_var("HANDOVER_TYPE_LABEL"), + ) + + beacon_handovers: list[BeaconHandover] = [ + BeaconHandover( + handoverType=handover_type, url=_get_environment_var("BEACON_HANDOVER_URL") + ) + ] + + num_results = len(caf_data) + response_summary = ResponseSummary( + exists=num_results > 0, numTotalResults=num_results + ) + + # TODO - create this field in Issue #35 + response_field = ResponseField() + + return VlmResponse( + beaconHandovers=beacon_handovers, + responseSummary=response_summary, + response=response_field, + ) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index f6901a6..b2ed8ff 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -3,10 +3,11 @@ from typing import ClassVar, Literal, Self from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict from anyvlm.utils.types import Zygosity -# ruff: noqa: N815 (allows camelCase vars instead of snake_case to align with expected VLM protocol response) +# ruff: noqa: N815, N803, D107 (allow camelCase instead of snake_case to align with expected VLM protocol response + don't require init docstrings) RESULT_ENTITY_TYPE = "genomicVariant" @@ -14,21 +15,24 @@ class HandoverType(BaseModel): """The type of handover the parent `BeaconHandover` represents.""" - id: str = Field( - default="gregor", description="Node-specific identifier" - ) # TODO: enable configuration of this field. See Issue #27. + id: str = Field(default="gregor", description="Node-specific identifier") label: str = Field( - default="GREGoR AnVIL browser", description="Node-specific label" - ) # TODO: enable configuration of this field. See Issue #27. + description="Node-specific identifier", + ) class BeaconHandover(BaseModel): """Describes how users can get more information about the results provided in the parent `VlmResponse`""" - handoverType: HandoverType = HandoverType() + handoverType: HandoverType = Field( + ..., description="The type of handover this represents" + ) url: str = Field( - default="https://anvil.terra.bio/#workspaces?filter=GREGoR", # TODO: enable configuration of this field. See Issue #27. - description="A url which directs users to more detailed information about the results tabulated by the API (ideally human-readable)", + "", + description=""" + A url which directs users to more detailed information about the results tabulated by the API. Must be human-readable. + Ideally links directly to the variant specified in the query, but can be a generic search page if necessary. + """, ) @@ -42,13 +46,27 @@ class ReturnedSchema(BaseModel): schema_: str = Field( default="ga4gh-beacon-variant-v2.0.0", # Alias is required because 'schema' is reserved by Pydantic's BaseModel class, - # But VLM expects a field named 'schema' + # But VLM protocol expects a field named 'schema' alias="schema", ) model_config = ConfigDict(populate_by_name=True) +class MetaSettings(BaseSettings): + """Settings for 'Meta' class""" + + beaconId: str = Field(..., alias="BEACON_NODE_ID") + + model_config = SettingsConfigDict( + env_prefix="", + extra="ignore", + ) + + +meta_settings = MetaSettings() # type: ignore + + class Meta(BaseModel): """Relevant metadata about the results provided in the parent `VlmResponse`""" @@ -57,15 +75,20 @@ class Meta(BaseModel): description="The version of the VLM API that this response conforms to", ) beaconId: str = Field( - default="org.gregor.beacon", # TODO: enable configuration of this field. See Issue #27. - description=""" - The Id of a Beacon. Usually a reversed domain string, but any URI is acceptable. The purpose of this attribute is, - in the context of a Beacon network, to disambiguate responses coming from different Beacons. See the beacon documentation - [here](https://github.com/ga4gh-beacon/beacon-v2/blob/c6558bf2e6494df3905f7b2df66e903dfe509500/framework/src/common/beaconCommonComponents.yaml#L26) - """, + default="", + description=( + "The Id of a Beacon. Usually a reversed domain string, but any URI is acceptable. " + "The purpose of this attribute is,in the context of a Beacon network, to disambiguate " + "responses coming from different Beacons. See the beacon documentation " + "[here](https://github.com/ga4gh-beacon/beacon-v2/blob/c6558bf2e6494df3905f7b2df66e903dfe509500/framework/src/common/beaconCommonComponents.yaml#L26)" + ), ) returnedSchemas: list[ReturnedSchema] = [ReturnedSchema()] + # custom __init__ to prevent overriding attributes that are static or set via environment variables + def __init__(self) -> None: + super().__init__(beaconId=meta_settings.beaconId) + class ResponseSummary(BaseModel): """A high-level summary of the results provided in the parent `VlmResponse""" @@ -104,6 +127,10 @@ class ResultSet(BaseModel): description=f"The type of entity relevant to these results. Must always be set to '{RESULT_ENTITY_TYPE}'", ) + # custom __init__ to prevent inadvertently overriding static fields + def __init__(self, resultset_id: str, resultsCount: int) -> None: + super().__init__(id=resultset_id, resultsCount=resultsCount) + class ResponseField(BaseModel): """A list of ResultSets""" @@ -116,7 +143,7 @@ class ResponseField(BaseModel): class VlmResponse(BaseModel): """Define response structure for the variant_counts endpoint.""" - beaconHandovers: list[BeaconHandover] = [BeaconHandover()] + beaconHandovers: list[BeaconHandover] meta: Meta = Meta() responseSummary: ResponseSummary response: ResponseField diff --git a/tests/conftest.py b/tests/conftest.py index b1b8dd8..3c165cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,14 +18,7 @@ QualityMeasures, ) - -@pytest.fixture(scope="session", autouse=True) -def load_env(): - """Load `.env` file. - - Must set `autouse=True` to run before other fixtures or test cases. - """ - load_dotenv() +load_dotenv() @pytest.fixture(scope="session") diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 1497b17..5ddf056 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -1,11 +1,12 @@ """Test schema validation functionality""" +import os import re import pytest from anyvlm.schemas.vlm import ( - RESULT_ENTITY_TYPE, + BeaconHandover, HandoverType, ResponseField, ResponseSummary, @@ -17,7 +18,22 @@ @pytest.fixture(scope="module") def valid_handover_id() -> str: - return HandoverType().id + return os.environ.get("HANDOVER_TYPE_ID") # type: ignore + + +@pytest.fixture(scope="module") +def beacon_handovers(valid_handover_id: str) -> list[BeaconHandover]: + handover_type = HandoverType( + id=valid_handover_id, + label=os.environ.get("HANDOVER_TYPE_LABEL"), # type: ignore + ) + + return [ + BeaconHandover( + handoverType=handover_type, + url=os.environ.get("BEACON_HANDOVER_URL"), # type: ignore + ) + ] @pytest.fixture(scope="module") @@ -25,67 +41,71 @@ def response_summary() -> ResponseSummary: return ResponseSummary(exists=False, numTotalResults=0) -@pytest.fixture(scope="module") -def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseField]: - return [ +def test_valid_resultset_id( + valid_handover_id: str, + beacon_handovers: list[BeaconHandover], + response_summary: ResponseSummary, +): + response = ResponseField( + resultSets=[ + ResultSet( + resultset_id=f"{valid_handover_id} {Zygosity.HOMOZYGOUS}", + resultsCount=0, + ) + ] + ) + + # Should NOT raise an error + vlm_response = VlmResponse( + beaconHandovers=beacon_handovers, + responseSummary=response_summary, + response=response, + ) + + assert ( + vlm_response.response.resultSets[0].id + == f"{valid_handover_id} {Zygosity.HOMOZYGOUS}" + ) + + +def test_invalid_resultset_ids( + response_summary: ResponseSummary, + beacon_handovers: list[BeaconHandover], +): + responses_with_invalid_resultset_ids: list[ResponseField] = [ ResponseField( resultSets=[ ResultSet( - exists=True, - id=f"invalid_handover_id {Zygosity.HOMOZYGOUS}", + resultset_id=f"invalid_handover_id {Zygosity.HOMOZYGOUS}", resultsCount=0, - setType=RESULT_ENTITY_TYPE, ) ] ), ResponseField( resultSets=[ ResultSet( - exists=True, - id=f"{valid_handover_id} invalid_zygosity", + resultset_id=f"{valid_handover_id} invalid_zygosity", resultsCount=0, - setType=RESULT_ENTITY_TYPE, ) ] ), ResponseField( resultSets=[ ResultSet( - exists=True, - id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting + resultset_id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting resultsCount=0, - setType=RESULT_ENTITY_TYPE, ) ] ), ] - -def test_valid_resultset_id(response_summary, valid_handover_id): - response = ResponseField( - resultSets=[ - ResultSet( - exists=True, - id=f"{valid_handover_id} {Zygosity.HOMOZYGOUS}", - resultsCount=0, - setType=RESULT_ENTITY_TYPE, - ) - ] - ) - - # Should NOT raise an error - vlm_response = VlmResponse(responseSummary=response_summary, response=response) - - assert ( - vlm_response.response.resultSets[0].id - == f"{valid_handover_id} {Zygosity.HOMOZYGOUS}" - ) - - -def test_invalid_resultset_ids(response_summary, responses_with_invalid_resultset_ids): for response in responses_with_invalid_resultset_ids: with pytest.raises( ValueError, match=re.escape(VlmResponse.resultset_id_error_message_base), ): - VlmResponse(responseSummary=response_summary, response=response) + VlmResponse( + beaconHandovers=beacon_handovers, + responseSummary=response_summary, + response=response, + )