From b85bdff0db9e7de57e1f71eb7fd161162e348334 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 17 Dec 2025 13:15:07 -0500 Subject: [PATCH 01/15] use 'BaseSettings' instead of 'BaseModel' as parent class for 'VlmResponse' classes that have statically-created fields --- src/anyvlm/schemas/vlm.py | 78 +++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index f6901a6..d389a93 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -1,36 +1,71 @@ """Schemas relating to VLM API.""" -from typing import ClassVar, Literal, Self +from collections.abc import Callable +from typing import Any, ClassVar, Literal, Self -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, 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, D107 (allow camelCase vars instead of snake_case to align with expected VLM protocol response + don't require init docstrings) RESULT_ENTITY_TYPE = "genomicVariant" -class HandoverType(BaseModel): +def forbid_env_override(field_name: str) -> Callable[..., Any]: + """Returns a Pydantic field validator that forbids explicitly + passing a value for `field_name`. The value must come from env. + """ + + @field_validator(field_name, mode="before") + @classmethod + def _forbid_override(cls, value: Any) -> Any: # noqa: ARG001, ANN401, ANN001 + if value is not None: + raise TypeError(f"{field_name} must be set via environment variable only") + return value + + return _forbid_override + + +class HandoverType(BaseSettings): """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. - label: str = Field( - default="GREGoR AnVIL browser", description="Node-specific label" - ) # TODO: enable configuration of this field. See Issue #27. + id: str = Field(..., description="Node-specific identifier") + label: str = Field(..., description="Node-specific label") + model_config = SettingsConfigDict(env_prefix="HANDOVER_TYPE_", extra="forbid") -class BeaconHandover(BaseModel): + # These validators prevent instantiation of this class with values that would override `id` or `label` + _forbid_id_override = forbid_env_override("id") + _forbid_label_override = forbid_env_override("label") + + # Allows `HandoverType` to be instantiated without providing values for the + # any required fields, since both are pulled from environment variables instead + def __init__(self) -> None: + super().__init__() + + +class BeaconHandover(BaseSettings): """Describes how users can get more information about the results provided in the parent `VlmResponse`""" - handoverType: HandoverType = HandoverType() + handoverType: HandoverType = Field(default=HandoverType()) 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)", ) + model_config = SettingsConfigDict(env_prefix="BEACON_HANDOVER_", extra="forbid") + + # These validators prevent instantiation of this class with values that would override `handoverType` or `url` + _forbid_handoverType_override = forbid_env_override("handoverType") + _forbid_url_override = forbid_env_override("url") + + # Allows `BeaconHandover` to be instantiated without providing values + # for any required fields, since both are generated statically + def __init__(self) -> None: + super().__init__() + class ReturnedSchema(BaseModel): """Fixed [Beacon Schema](https://github.com/ga4gh-beacon/beacon-v2/blob/c6558bf2e6494df3905f7b2df66e903dfe509500/framework/json/common/beaconCommonComponents.json#L241)""" @@ -49,7 +84,7 @@ class ReturnedSchema(BaseModel): model_config = ConfigDict(populate_by_name=True) -class Meta(BaseModel): +class Meta(BaseSettings): """Relevant metadata about the results provided in the parent `VlmResponse`""" apiVersion: str = Field( @@ -57,7 +92,8 @@ 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. + ..., + alias="BEACON_NODE_ID", 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 @@ -66,6 +102,18 @@ class Meta(BaseModel): ) returnedSchemas: list[ReturnedSchema] = [ReturnedSchema()] + model_config = SettingsConfigDict( + env_prefix="", populate_by_name=False, extra="forbid" + ) + + # This validator prevents instantiation of this class with values that would override `handoverType` or `url` + _forbid_beaconId_override = forbid_env_override("beaconId") + + # Allows `Meta` to be instantiated without providing values + # for any required fields, since all are generated statically + def __init__(self) -> None: + super().__init__() + class ResponseSummary(BaseModel): """A high-level summary of the results provided in the parent `VlmResponse""" From 2d8260cb0568c9faa34b7047de5d3c6ef22b805a Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 17 Dec 2025 13:20:35 -0500 Subject: [PATCH 02/15] updates comments + descriptions for vlm schema classes --- src/anyvlm/schemas/vlm.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index d389a93..e5d3e06 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -52,7 +52,10 @@ class BeaconHandover(BaseSettings): handoverType: HandoverType = Field(default=HandoverType()) url: str = Field( ..., - 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. + """, ) model_config = SettingsConfigDict(env_prefix="BEACON_HANDOVER_", extra="forbid") @@ -77,7 +80,7 @@ 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", ) From 45d20863aad71e081959a8e5f5d51b3b7da86e90 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Wed, 17 Dec 2025 13:25:40 -0500 Subject: [PATCH 03/15] remove unnecessary fields 'setType' and 'exists' from instantiation of 'ResultSet' --- tests/unit/test_schemas.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 1497b17..62b9311 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -31,30 +31,24 @@ def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseFiel ResponseField( resultSets=[ ResultSet( - exists=True, 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", resultsCount=0, - setType=RESULT_ENTITY_TYPE, ) ] ), ResponseField( resultSets=[ ResultSet( - exists=True, id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting resultsCount=0, - setType=RESULT_ENTITY_TYPE, ) ] ), From 453eafcac3399195a8deb7090089661948dc0d72 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 18 Dec 2025 09:16:23 -0500 Subject: [PATCH 04/15] simplify logic for using env vars for values + preventing overrides --- src/anyvlm/schemas/vlm.py | 73 ++++++++++++++------------------------- tests/conftest.py | 13 +++---- 2 files changed, 32 insertions(+), 54 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index e5d3e06..996a18f 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -1,10 +1,9 @@ """Schemas relating to VLM API.""" -from collections.abc import Callable -from typing import Any, ClassVar, Literal, Self +import os +from typing import ClassVar, Literal, Self -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import BaseModel, ConfigDict, Field, model_validator from anyvlm.utils.types import Zygosity @@ -13,59 +12,46 @@ RESULT_ENTITY_TYPE = "genomicVariant" -def forbid_env_override(field_name: str) -> Callable[..., Any]: - """Returns a Pydantic field validator that forbids explicitly - passing a value for `field_name`. The value must come from env. - """ +class MissingEnvironmentVariableError(Exception): + """Raised when a required environment variable is not set.""" - @field_validator(field_name, mode="before") - @classmethod - def _forbid_override(cls, value: Any) -> Any: # noqa: ARG001, ANN401, ANN001 - if value is not None: - raise TypeError(f"{field_name} must be set via environment variable only") - return value - return _forbid_override +def _get_environment_var(key: str) -> str: + value: str | None = os.environ.get(key) + if not value: + message = f"Missing required environment variable: {key}" + raise MissingEnvironmentVariableError(message) + return value -class HandoverType(BaseSettings): +class HandoverType(BaseModel): """The type of handover the parent `BeaconHandover` represents.""" - id: str = Field(..., description="Node-specific identifier") - label: str = Field(..., description="Node-specific label") - - model_config = SettingsConfigDict(env_prefix="HANDOVER_TYPE_", extra="forbid") - - # These validators prevent instantiation of this class with values that would override `id` or `label` - _forbid_id_override = forbid_env_override("id") - _forbid_label_override = forbid_env_override("label") + id: str = Field( + _get_environment_var("HANDOVER_TYPE_ID"), description="Node-specific identifier" + ) + label: str = Field( + _get_environment_var("HANDOVER_TYPE_LABEL"), description="Node-specific label" + ) - # Allows `HandoverType` to be instantiated without providing values for the - # any required fields, since both are pulled from environment variables instead + # override __init__ to prevent the ability to override attributes that are set via environment variables def __init__(self) -> None: super().__init__() -class BeaconHandover(BaseSettings): +class BeaconHandover(BaseModel): """Describes how users can get more information about the results provided in the parent `VlmResponse`""" handoverType: HandoverType = Field(default=HandoverType()) url: str = Field( - ..., + _get_environment_var("BEACON_HANDOVER_URL"), 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. """, ) - model_config = SettingsConfigDict(env_prefix="BEACON_HANDOVER_", extra="forbid") - - # These validators prevent instantiation of this class with values that would override `handoverType` or `url` - _forbid_handoverType_override = forbid_env_override("handoverType") - _forbid_url_override = forbid_env_override("url") - - # Allows `BeaconHandover` to be instantiated without providing values - # for any required fields, since both are generated statically + # override __init__ to prevent the ability to override attributes that are set via environment variables def __init__(self) -> None: super().__init__() @@ -87,7 +73,7 @@ class ReturnedSchema(BaseModel): model_config = ConfigDict(populate_by_name=True) -class Meta(BaseSettings): +class Meta(BaseModel): """Relevant metadata about the results provided in the parent `VlmResponse`""" apiVersion: str = Field( @@ -95,8 +81,7 @@ class Meta(BaseSettings): description="The version of the VLM API that this response conforms to", ) beaconId: str = Field( - ..., - alias="BEACON_NODE_ID", + _get_environment_var("BEACON_NODE_ID"), 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 @@ -105,15 +90,7 @@ class Meta(BaseSettings): ) returnedSchemas: list[ReturnedSchema] = [ReturnedSchema()] - model_config = SettingsConfigDict( - env_prefix="", populate_by_name=False, extra="forbid" - ) - - # This validator prevents instantiation of this class with values that would override `handoverType` or `url` - _forbid_beaconId_override = forbid_env_override("beaconId") - - # Allows `Meta` to be instantiated without providing values - # for any required fields, since all are generated statically + # override __init__ to prevent the ability to override attributes that are set via environment variables def __init__(self) -> None: super().__init__() diff --git a/tests/conftest.py b/tests/conftest.py index 616191a..3f122b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,14 +6,15 @@ from ga4gh.vrs import models from pydantic import BaseModel +load_dotenv() -@pytest.fixture(scope="session", autouse=True) -def load_env(): - """Load `.env` file. +# @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() +# Must set `autouse=True` to run before other fixtures or test cases. +# """ +# print("LOADING DOTENV") @pytest.fixture(scope="session") From bc3e3b30c48a2d8baf983f97a5d54ef0c8335dd7 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 18 Dec 2025 09:22:19 -0500 Subject: [PATCH 05/15] add '.env.example' file with GREGoR-specific valules required for 'VlmResponse' --- .env.example | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6bc1f98 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +########################### +## 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" From 8a805671522878494393f51fc6bc2090ea29522d Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 18 Dec 2025 09:41:30 -0500 Subject: [PATCH 06/15] use 'default_factory' instead of 'default' for attributes configured via env vars --- src/anyvlm/schemas/vlm.py | 10 ++++++---- tests/conftest.py | 8 -------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index 996a18f..b6ece23 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -28,10 +28,12 @@ class HandoverType(BaseModel): """The type of handover the parent `BeaconHandover` represents.""" id: str = Field( - _get_environment_var("HANDOVER_TYPE_ID"), description="Node-specific identifier" + default_factory=lambda: _get_environment_var("HANDOVER_TYPE_ID"), + description="Node-specific identifier", ) label: str = Field( - _get_environment_var("HANDOVER_TYPE_LABEL"), description="Node-specific label" + default_factory=lambda: _get_environment_var("HANDOVER_TYPE_LABEL"), + description="Node-specific label", ) # override __init__ to prevent the ability to override attributes that are set via environment variables @@ -44,7 +46,7 @@ class BeaconHandover(BaseModel): handoverType: HandoverType = Field(default=HandoverType()) url: str = Field( - _get_environment_var("BEACON_HANDOVER_URL"), + default_factory=lambda: _get_environment_var("BEACON_HANDOVER_URL"), 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. @@ -81,7 +83,7 @@ class Meta(BaseModel): description="The version of the VLM API that this response conforms to", ) beaconId: str = Field( - _get_environment_var("BEACON_NODE_ID"), + default_factory=lambda: _get_environment_var("BEACON_NODE_ID"), 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 diff --git a/tests/conftest.py b/tests/conftest.py index 3f122b9..8ec5781 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,14 +8,6 @@ load_dotenv() -# @pytest.fixture(scope="session", autouse=True) -# def load_env(): -# """Load `.env` file. - -# Must set `autouse=True` to run before other fixtures or test cases. -# """ -# print("LOADING DOTENV") - @pytest.fixture(scope="session") def test_data_dir() -> Path: From 8d32e346737f2da3bb67625054a71083f5542091 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 18 Dec 2025 09:43:33 -0500 Subject: [PATCH 07/15] add required env vars to GitHub workflow --- .github/workflows/python-package.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml index 0152cc0..3ebc5ab 100644 --- a/.github/workflows/python-package.yaml +++ b/.github/workflows/python-package.yaml @@ -47,6 +47,10 @@ jobs: run: uv run pytest env: 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 From cf62b44579b3a76a1beb0fef130fc1f4f921d496 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 18 Dec 2025 13:20:33 -0500 Subject: [PATCH 08/15] modify 'ResultSet' to prevent inadvertently overriding static fields --- src/anyvlm/schemas/vlm.py | 5 ++++- tests/unit/test_schemas.py | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index b6ece23..20cd008 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -7,7 +7,7 @@ from anyvlm.utils.types import Zygosity -# ruff: noqa: N815, D107 (allow camelCase vars instead of snake_case to align with expected VLM protocol response + don't require init docstrings) +# 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" @@ -134,6 +134,9 @@ class ResultSet(BaseModel): description=f"The type of entity relevant to these results. Must always be set to '{RESULT_ENTITY_TYPE}'", ) + def __init__(self, resultset_id: str, resultsCount: int) -> None: + super().__init__(id=resultset_id, resultsCount=resultsCount) + class ResponseField(BaseModel): """A list of ResultSets""" diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 62b9311..976117d 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -5,7 +5,6 @@ import pytest from anyvlm.schemas.vlm import ( - RESULT_ENTITY_TYPE, HandoverType, ResponseField, ResponseSummary, @@ -59,10 +58,8 @@ 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, ) ] ) From 4d6c1fc10b9e4448470437a21d2d1ab60c61edba Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Thu, 18 Dec 2025 13:22:54 -0500 Subject: [PATCH 09/15] update comments for clairity --- src/anyvlm/schemas/vlm.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index 20cd008..c249652 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -36,7 +36,7 @@ class HandoverType(BaseModel): description="Node-specific label", ) - # override __init__ to prevent the ability to override attributes that are set via environment variables + # custom __init__ to prevent overriding attributes that are set via environment variables def __init__(self) -> None: super().__init__() @@ -53,7 +53,7 @@ class BeaconHandover(BaseModel): """, ) - # override __init__ to prevent the ability to override attributes that are set via environment variables + # custom __init__ to prevent overriding attributes that are static/set via environment variables def __init__(self) -> None: super().__init__() @@ -92,7 +92,7 @@ class Meta(BaseModel): ) returnedSchemas: list[ReturnedSchema] = [ReturnedSchema()] - # override __init__ to prevent the ability to override attributes that are set via environment variables + # custom __init__ to prevent overriding attributes that are static or set via environment variables def __init__(self) -> None: super().__init__() @@ -134,6 +134,7 @@ 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) From d39ef0e02b06f212bd881cc1fa81bcf3910242d2 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Fri, 19 Dec 2025 08:14:11 -0500 Subject: [PATCH 10/15] update arg name for 'ResultSet.id' > 'ResultSet.resultset_id' --- tests/unit/test_schemas.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 976117d..6c81f22 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -30,7 +30,7 @@ def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseFiel ResponseField( resultSets=[ ResultSet( - id=f"invalid_handover_id {Zygosity.HOMOZYGOUS}", + resultset_id=f"invalid_handover_id {Zygosity.HOMOZYGOUS}", resultsCount=0, ) ] @@ -38,7 +38,7 @@ def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseFiel ResponseField( resultSets=[ ResultSet( - id=f"{valid_handover_id} invalid_zygosity", + resultset_id=f"{valid_handover_id} invalid_zygosity", resultsCount=0, ) ] @@ -46,7 +46,7 @@ def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseFiel ResponseField( resultSets=[ ResultSet( - id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting + resultset_id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting resultsCount=0, ) ] @@ -58,7 +58,7 @@ def test_valid_resultset_id(response_summary, valid_handover_id): response = ResponseField( resultSets=[ ResultSet( - id=f"{valid_handover_id} {Zygosity.HOMOZYGOUS}", + resultset_id=f"{valid_handover_id} {Zygosity.HOMOZYGOUS}", resultsCount=0, ) ] From a8ad18c2e7839b3f1295d08f315cc116656f2125 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Fri, 19 Dec 2025 12:37:12 -0500 Subject: [PATCH 11/15] use Pydantic 'SettingsConfigDict' instead of custom func to set attributes via env vars --- src/anyvlm/schemas/vlm.py | 74 ++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index c249652..ffb476f 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -1,9 +1,9 @@ """Schemas relating to VLM API.""" -import os 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 @@ -12,33 +12,59 @@ RESULT_ENTITY_TYPE = "genomicVariant" -class MissingEnvironmentVariableError(Exception): - """Raised when a required environment variable is not set.""" +class HandoverSettings(BaseSettings): + """Settings for 'HandoverType' class""" + id: str + label: str -def _get_environment_var(key: str) -> str: - value: str | None = os.environ.get(key) - if not value: - message = f"Missing required environment variable: {key}" - raise MissingEnvironmentVariableError(message) - return value + model_config = SettingsConfigDict( + env_prefix="HANDOVER_TYPE_", + extra="ignore", + ) + + +handover_type_settings = HandoverSettings() # type: ignore class HandoverType(BaseModel): """The type of handover the parent `BeaconHandover` represents.""" id: str = Field( - default_factory=lambda: _get_environment_var("HANDOVER_TYPE_ID"), + "", description="Node-specific identifier", + frozen=True, ) label: str = Field( - default_factory=lambda: _get_environment_var("HANDOVER_TYPE_LABEL"), + "", description="Node-specific label", + frozen=True, + ) + + model_config = ConfigDict( + extra="forbid", ) - # custom __init__ to prevent overriding attributes that are set via environment variables + # custom __init__ to prevent overriding attributes that are static/set via environment variables def __init__(self) -> None: - super().__init__() + super().__init__( + id=handover_type_settings.id, + label=handover_type_settings.label, + ) + + +class BeaconHandoverSettings(BaseSettings): + """Settings for 'BeaconHandover' class""" + + url: str + + model_config = SettingsConfigDict( + env_prefix="BEACON_HANDOVER_", + extra="ignore", + ) + + +beacon_handover_settings = BeaconHandoverSettings() # type: ignore class BeaconHandover(BaseModel): @@ -46,7 +72,7 @@ class BeaconHandover(BaseModel): handoverType: HandoverType = Field(default=HandoverType()) url: str = Field( - default_factory=lambda: _get_environment_var("BEACON_HANDOVER_URL"), + "", 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. @@ -55,7 +81,7 @@ class BeaconHandover(BaseModel): # custom __init__ to prevent overriding attributes that are static/set via environment variables def __init__(self) -> None: - super().__init__() + super().__init__(url=beacon_handover_settings.url) class ReturnedSchema(BaseModel): @@ -75,6 +101,20 @@ class ReturnedSchema(BaseModel): 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`""" @@ -83,7 +123,7 @@ class Meta(BaseModel): description="The version of the VLM API that this response conforms to", ) beaconId: str = Field( - default_factory=lambda: _get_environment_var("BEACON_NODE_ID"), + 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 @@ -94,7 +134,7 @@ class Meta(BaseModel): # custom __init__ to prevent overriding attributes that are static or set via environment variables def __init__(self) -> None: - super().__init__() + super().__init__(beaconId=meta_settings.beaconId) class ResponseSummary(BaseModel): From 37fb5e587999f784e8956fc844d7eb091a58838c Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 22 Dec 2025 12:43:52 -0500 Subject: [PATCH 12/15] moves logic to pull info from env vars out of 'VlmResponse' classes and into 'build_vlm_response_from_caf_data' --- src/anyvlm/functions/build_vlm_response.py | 53 ++++++++++++++++++- src/anyvlm/schemas/vlm.py | 60 +++------------------- 2 files changed, 58 insertions(+), 55 deletions(-) diff --git a/src/anyvlm/functions/build_vlm_response.py b/src/anyvlm/functions/build_vlm_response.py index 6ae31e5..07cd47b 100644 --- a/src/anyvlm/functions/build_vlm_response.py +++ b/src/anyvlm/functions/build_vlm_response.py @@ -1,12 +1,36 @@ """Craft a VlmResponse object from a list of CohortAlleleFrequencyStudyResults""" +import os + from ga4gh.va_spec.base.core import CohortAlleleFrequencyStudyResult from anyvlm.schemas.vlm import ( + BeaconHandover, + HandoverType, + ResponseField, + ResponseSummary, VlmResponse, ) +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[CohortAlleleFrequencyStudyResult], ) -> VlmResponse: @@ -15,4 +39,31 @@ def build_vlm_response_from_caf_data( :param caf_data: A list of `CohortAlleleFrequencyStudyResult` 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 ffb476f..e57c394 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -12,65 +12,21 @@ RESULT_ENTITY_TYPE = "genomicVariant" -class HandoverSettings(BaseSettings): - """Settings for 'HandoverType' class""" - - id: str - label: str - - model_config = SettingsConfigDict( - env_prefix="HANDOVER_TYPE_", - extra="ignore", - ) - - -handover_type_settings = HandoverSettings() # type: ignore - - class HandoverType(BaseModel): """The type of handover the parent `BeaconHandover` represents.""" - id: str = Field( - "", - description="Node-specific identifier", - frozen=True, - ) + id: str = Field(default="gregor", description="Node-specific identifier") label: str = Field( - "", - description="Node-specific label", - frozen=True, - ) - - model_config = ConfigDict( - extra="forbid", - ) - - # custom __init__ to prevent overriding attributes that are static/set via environment variables - def __init__(self) -> None: - super().__init__( - id=handover_type_settings.id, - label=handover_type_settings.label, - ) - - -class BeaconHandoverSettings(BaseSettings): - """Settings for 'BeaconHandover' class""" - - url: str - - model_config = SettingsConfigDict( - env_prefix="BEACON_HANDOVER_", - extra="ignore", + description="Node-specific identifier", ) -beacon_handover_settings = BeaconHandoverSettings() # type: ignore - - class BeaconHandover(BaseModel): """Describes how users can get more information about the results provided in the parent `VlmResponse`""" - handoverType: HandoverType = Field(default=HandoverType()) + handoverType: HandoverType = Field( + ..., description="The type of handover this represents" + ) url: str = Field( "", description=""" @@ -79,10 +35,6 @@ class BeaconHandover(BaseModel): """, ) - # custom __init__ to prevent overriding attributes that are static/set via environment variables - def __init__(self) -> None: - super().__init__(url=beacon_handover_settings.url) - class ReturnedSchema(BaseModel): """Fixed [Beacon Schema](https://github.com/ga4gh-beacon/beacon-v2/blob/c6558bf2e6494df3905f7b2df66e903dfe509500/framework/json/common/beaconCommonComponents.json#L241)""" @@ -190,7 +142,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 From 0fa05717cf96467c13faa6119301af0aa0a91949 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Mon, 22 Dec 2025 13:09:59 -0500 Subject: [PATCH 13/15] fix tests --- tests/unit/test_schemas.py | 45 +++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 6c81f22..5baa948 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -1,10 +1,12 @@ """Test schema validation functionality""" +import os import re import pytest from anyvlm.schemas.vlm import ( + BeaconHandover, HandoverType, ResponseField, ResponseSummary, @@ -16,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,7 +42,7 @@ def response_summary() -> ResponseSummary: @pytest.fixture(scope="module") -def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseField]: +def responses_with_invalid_resultset_ids(valid_handover_id: str) -> list[ResponseField]: return [ ResponseField( resultSets=[ @@ -54,7 +71,11 @@ def responses_with_invalid_resultset_ids(valid_handover_id) -> list[ResponseFiel ] -def test_valid_resultset_id(response_summary, valid_handover_id): +def test_valid_resultset_id( + valid_handover_id: str, + beacon_handovers: list[BeaconHandover], + response_summary: ResponseSummary, +): response = ResponseField( resultSets=[ ResultSet( @@ -65,7 +86,11 @@ def test_valid_resultset_id(response_summary, valid_handover_id): ) # Should NOT raise an error - vlm_response = VlmResponse(responseSummary=response_summary, response=response) + vlm_response = VlmResponse( + beaconHandovers=beacon_handovers, + responseSummary=response_summary, + response=response, + ) assert ( vlm_response.response.resultSets[0].id @@ -73,10 +98,18 @@ def test_valid_resultset_id(response_summary, valid_handover_id): ) -def test_invalid_resultset_ids(response_summary, responses_with_invalid_resultset_ids): +def test_invalid_resultset_ids( + response_summary: ResponseSummary, + responses_with_invalid_resultset_ids: list[ResponseField], + beacon_handovers: list[BeaconHandover], +): 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, + ) From 913bb7b6f4b4a58c24bafc08dc36caf7d30eef7e Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 23 Dec 2025 09:41:16 -0500 Subject: [PATCH 14/15] reformat description string to avoid wonky whitespace formatting --- src/anyvlm/schemas/vlm.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/anyvlm/schemas/vlm.py b/src/anyvlm/schemas/vlm.py index e57c394..b2ed8ff 100644 --- a/src/anyvlm/schemas/vlm.py +++ b/src/anyvlm/schemas/vlm.py @@ -76,11 +76,12 @@ class Meta(BaseModel): ) beaconId: str = Field( 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) - """, + 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()] From 4ffaf899d7507b33a5b7796ae0ae299c242bf3e9 Mon Sep 17 00:00:00 2001 From: Jennifer Bowser Date: Tue, 23 Dec 2025 09:44:47 -0500 Subject: [PATCH 15/15] refactor test to move fixture into the test where it's used --- tests/unit/test_schemas.py | 58 ++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 5baa948..5ddf056 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -41,36 +41,6 @@ def response_summary() -> ResponseSummary: return ResponseSummary(exists=False, numTotalResults=0) -@pytest.fixture(scope="module") -def responses_with_invalid_resultset_ids(valid_handover_id: str) -> list[ResponseField]: - return [ - ResponseField( - resultSets=[ - ResultSet( - resultset_id=f"invalid_handover_id {Zygosity.HOMOZYGOUS}", - resultsCount=0, - ) - ] - ), - ResponseField( - resultSets=[ - ResultSet( - resultset_id=f"{valid_handover_id} invalid_zygosity", - resultsCount=0, - ) - ] - ), - ResponseField( - resultSets=[ - ResultSet( - resultset_id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting - resultsCount=0, - ) - ] - ), - ] - - def test_valid_resultset_id( valid_handover_id: str, beacon_handovers: list[BeaconHandover], @@ -100,9 +70,35 @@ def test_valid_resultset_id( def test_invalid_resultset_ids( response_summary: ResponseSummary, - responses_with_invalid_resultset_ids: list[ResponseField], beacon_handovers: list[BeaconHandover], ): + responses_with_invalid_resultset_ids: list[ResponseField] = [ + ResponseField( + resultSets=[ + ResultSet( + resultset_id=f"invalid_handover_id {Zygosity.HOMOZYGOUS}", + resultsCount=0, + ) + ] + ), + ResponseField( + resultSets=[ + ResultSet( + resultset_id=f"{valid_handover_id} invalid_zygosity", + resultsCount=0, + ) + ] + ), + ResponseField( + resultSets=[ + ResultSet( + resultset_id=f"{Zygosity.HOMOZYGOUS}-{valid_handover_id}", # incorrect order/formatting + resultsCount=0, + ) + ] + ), + ] + for response in responses_with_invalid_resultset_ids: with pytest.raises( ValueError,