diff --git a/.gitignore b/.gitignore index 5e352f8b..aeeb1ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ site/ !/docs/pages/index.md /docs/pages/index.html +# dotenv settings file +abis_mapping.env diff --git a/README.md b/README.md index 8bbc0285..59495f49 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,9 @@ customize and extend how Frictionless works. In particular, we have the [`abis_mapping/plugins/string_customized.py`](/abis_mapping/plugins/string_customized.py) plugin which overrides the default Frictionless string field to use our custom class. + +## Settings + +There are some settings that can be changed via environment variables or in a `abis_mapping.env` file. +In either case all settings names are prefixed with `ABIS_MAPPING_`. +See [`/abis_mapping/settings.py`](/abis_mapping/settings.py) for details of the settings. diff --git a/abis_mapping/models/metadata.py b/abis_mapping/models/metadata.py index 4b9dd344..d37840f6 100644 --- a/abis_mapping/models/metadata.py +++ b/abis_mapping/models/metadata.py @@ -54,8 +54,8 @@ def instructions_url(self) -> str: """ # Create string representation str_url = urllib.parse.urljoin( - base=str(settings.Settings().INSTRUCTIONS_BASE_URL), - url="/".join([settings.Settings().INSTRUCTIONS_VERSION, self.id]), + base=settings.SETTINGS.INSTRUCTIONS_BASE_URL, + url="/".join([settings.SETTINGS.INSTRUCTIONS_VERSION, self.id]), ) # Perform validation pydantic.AnyUrl(str_url) diff --git a/abis_mapping/models/spatial.py b/abis_mapping/models/spatial.py index 9f224a6b..264991e3 100644 --- a/abis_mapping/models/spatial.py +++ b/abis_mapping/models/spatial.py @@ -140,7 +140,7 @@ def transformer_datum_uri(self) -> rdflib.URIRef: """ # Retrieve vocab class vocab = utils.vocabs.get_vocab("GEODETIC_DATUM") - default_crs = settings.Settings().DEFAULT_TARGET_CRS + default_crs = settings.SETTINGS.DEFAULT_TARGET_CRS try: # Init with dummy graph and return corresponding uri @@ -210,7 +210,7 @@ def to_rdf_literal(self) -> rdflib.Literal: # Construct and return rdf literal wkt_string = shapely.to_wkt( geometry=geometry, - rounding_precision=settings.Settings().DEFAULT_WKT_ROUNDING_PRECISION, + rounding_precision=settings.SETTINGS.DEFAULT_WKT_ROUNDING_PRECISION, ) return rdflib.Literal( @@ -235,7 +235,7 @@ def to_transformed_crs_rdf_literal(self) -> rdflib.Literal: # Construct and return rdf literal wkt_string = shapely.to_wkt( geometry=geometry, - rounding_precision=settings.Settings().DEFAULT_WKT_ROUNDING_PRECISION, + rounding_precision=settings.SETTINGS.DEFAULT_WKT_ROUNDING_PRECISION, ) return rdflib.Literal( lexical_or_value=datum_string + wkt_string, diff --git a/abis_mapping/plugins/wkt.py b/abis_mapping/plugins/wkt.py index cd6a4c1c..b3973707 100644 --- a/abis_mapping/plugins/wkt.py +++ b/abis_mapping/plugins/wkt.py @@ -93,7 +93,7 @@ def value_writer(cell: shapely.Geometry) -> str: # Serialize to wkt format wkt_str = shapely.to_wkt( geometry=cell, - rounding_precision=settings.Settings().DEFAULT_WKT_ROUNDING_PRECISION, + rounding_precision=settings.SETTINGS.DEFAULT_WKT_ROUNDING_PRECISION, ) # Type checking due to no types provided by the shapely package diff --git a/abis_mapping/settings.py b/abis_mapping/settings.py index e65722a7..dde387d0 100644 --- a/abis_mapping/settings.py +++ b/abis_mapping/settings.py @@ -1,15 +1,17 @@ """All non-sensitive project-wide configuration parameters""" -# Standard -import importlib.metadata - # Third-party import pydantic_settings -class Settings(pydantic_settings.BaseSettings): +class _Settings(pydantic_settings.BaseSettings): """Model for defining default project-wide settings.""" + model_config = pydantic_settings.SettingsConfigDict( + # Don't let settings object be mutated, since it is stored globally on the module + frozen=True, + ) + # Default precision for rounding WKT coordinates when serializing. DEFAULT_WKT_ROUNDING_PRECISION: int = 8 @@ -22,11 +24,10 @@ class Settings(pydantic_settings.BaseSettings): # The version of the documents to be selected INSTRUCTIONS_VERSION: str = "dev" - # Version parts - MAJOR_VERSION: int = int(importlib.metadata.version("abis-mapping").split(".", 1)[0]) - - # If changing via environment variable prefix name with 'ABIS_MAPPING_' - model_config = pydantic_settings.SettingsConfigDict(env_prefix="ABIS_MAPPING_") - -SETTINGS = Settings() +# If changing via environment variable or .env file prefix name with 'ABIS_MAPPING_' +SETTINGS = _Settings( + _env_prefix="ABIS_MAPPING_", + _env_file="abis_mapping.env", +) +# NOTE environment variables and .env files are ignored when running the test suite. diff --git a/poetry.lock b/poetry.lock index e8b070a2..c0cb3b4d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2343,4 +2343,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "66fa1da7077ac329fc68b888a612e7c168d32c9d62b2aab04766874caab65702" +content-hash = "d4bae0ce0ba7ae3ae2d86496e29cc0ca71215c86b95fbb05ecca9bd83f4b26d7" diff --git a/pyproject.toml b/pyproject.toml index 12fa70b6..bea835c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ python-dateutil = "^2.9.0.post0" shapely = "^2.0.6" pyproj = "^3.7.0" pydantic = "^2.9.2" -pydantic-settings = "^2.6.0" +pydantic-settings = "^2.6.1" numpy = "^2.1.2" attrs = "^24.2.0" diff --git a/tests/conftest.py b/tests/conftest.py index 9ba615d6..e4f04c21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,12 +11,20 @@ import rdflib.compare # Local +from abis_mapping import settings from abis_mapping import utils +from tests import helpers # Typing from typing import Union, Callable +@pytest.fixture(scope="session", autouse=True) +def setup_test_settings() -> None: + """Autouse fixture to replace the settings with the test suite version.""" + settings.SETTINGS = helpers.TestSettings() + + @pytest.fixture def mocked_vocab(mocker: pytest_mock.MockerFixture) -> unittest.mock.MagicMock: """Provides a mocked term fixture. diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..3af4bf21 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,67 @@ +"""Helper utilities for testing.""" + +# Standard Library +import collections.abc +import contextlib + +# Third-party +import pydantic_settings + +# Local +from abis_mapping import settings + + +@contextlib.contextmanager +def override_settings(**overrides: object) -> collections.abc.Iterator[None]: + """Context manager to override any number of settings, + and restore the original settings at the end. + + This is non-trivial since the settings object is frozen. + + Args: + **overrides: + Pass settings to override as keyword arguments. + + Returns: + Context manager to override the settings. + """ + # Get current settings. + initial_settings = settings.SETTINGS + # Make new settings object and override settings with it + settings.SETTINGS = TestSettings(**(initial_settings.model_dump() | overrides)) + + yield + + # Restore the original settings on context exit + settings.SETTINGS = initial_settings + + +class TestSettings(settings._Settings): + """Version of the settings to use in the test suite.""" + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[pydantic_settings.BaseSettings], + init_settings: pydantic_settings.PydanticBaseSettingsSource, + env_settings: pydantic_settings.PydanticBaseSettingsSource, + dotenv_settings: pydantic_settings.PydanticBaseSettingsSource, + file_secret_settings: pydantic_settings.PydanticBaseSettingsSource, + ) -> tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: + """ + Define the sources and their order for loading the settings values. + + Args: + settings_cls: The Settings class. + init_settings: The `InitSettingsSource` instance. + env_settings: The `EnvSettingsSource` instance. + dotenv_settings: The `DotEnvSettingsSource` instance. + file_secret_settings: The `SecretsSettingsSource` instance. + + Returns: + A tuple containing the sources and their order for loading the settings values. + """ + # In the tests, ignore all env, dotenv and secrets settings. + # This is so the test suite is deterministic and isolated, + # and won't be effected by any settings a particular developer has set locally. + return (init_settings,) diff --git a/tests/models/test_metadata.py b/tests/models/test_metadata.py index c58d1236..e500d694 100644 --- a/tests/models/test_metadata.py +++ b/tests/models/test_metadata.py @@ -60,7 +60,9 @@ def test_instructions_url( instance fixture. """ # Expected instructions url - expected = f"{settings.Settings().INSTRUCTIONS_BASE_URL}{settings.Settings().INSTRUCTIONS_VERSION}/{template_metadata.id}" + expected = ( + f"{settings.SETTINGS.INSTRUCTIONS_BASE_URL}{settings.SETTINGS.INSTRUCTIONS_VERSION}/{template_metadata.id}" + ) # Assert as expected assert template_metadata.instructions_url == expected diff --git a/tests/models/test_spatial.py b/tests/models/test_spatial.py index a63713f7..109d1d8f 100644 --- a/tests/models/test_spatial.py +++ b/tests/models/test_spatial.py @@ -6,7 +6,6 @@ # Third-party import shapely import pytest -import pytest_mock import rdflib # Local @@ -14,9 +13,7 @@ from abis_mapping import models from abis_mapping import utils from abis_mapping import vocabs - -# Typing -from typing import Type, Callable, Iterator +from tests import helpers def test_geometry_init_wkt_string_valid() -> None: @@ -130,58 +127,11 @@ def test_geometry_transformer_datum_uri() -> None: # Assert default datum assert vocab is not None - assert geometry.transformer_datum_uri == vocab(graph=rdflib.Graph()).get(settings.Settings().DEFAULT_TARGET_CRS) - - -@pytest.fixture -def temp_default_crs(mocker: pytest_mock.MockerFixture) -> Iterator[Callable[[str], None]]: - """Provides a temporary value for Default CRS when called. - - Args: - mocker: The mocker fixture - - Yields: - Function to perform the change with new value as arg. - """ - # Retain the original setting - original = settings.SETTINGS.DEFAULT_TARGET_CRS - - # Define callable - def change_crs(value: str) -> None: - """Performs the change. - - Args: - value: New default CRS name to use for the test. - """ - - # Create a stubbed settings model - class TempSettings(settings.Settings): - # Modified fields below - DEFAULT_TARGET_CRS: str = value - - # Patch Settings - mocker.patch( - "abis_mapping.settings.Settings", - new=TempSettings, - ) - - # Change assigned variable - settings.SETTINGS.DEFAULT_TARGET_CRS = value + assert geometry.transformer_datum_uri == vocab(graph=rdflib.Graph()).get(settings.SETTINGS.DEFAULT_TARGET_CRS) - # Yield - yield change_crs - # Change setting back to original - settings.SETTINGS.DEFAULT_TARGET_CRS = original - - -def test_geometry_transformer_datum_uri_invalid(temp_default_crs: Callable[[str], None]) -> None: - """Tests the transformer_datum_uri with unrecognised default crs. - - Args: - temp_default_crs: Callable fixture allowing setting of the project's - default crs temporarily - """ +def test_geometry_transformer_datum_uri_invalid() -> None: + """Tests the transformer_datum_uri with unrecognised default crs.""" # Create geometry geometry = models.spatial.Geometry( raw="POINT(0 0)", @@ -189,11 +139,10 @@ def test_geometry_transformer_datum_uri_invalid(temp_default_crs: Callable[[str] ) # Set temp default crs - temp_default_crs("NOTADATUM") - - # Should raise exception on invalid CRS not in fixed datum vocabulary - with pytest.raises(models.spatial.GeometryError, match=r"NOTADATUM .+ GEODETIC_DATUM") as exc: - _ = geometry.transformer_datum_uri + with helpers.override_settings(DEFAULT_TARGET_CRS="NOTADATUM"): + # Should raise exception on invalid CRS not in fixed datum vocabulary + with pytest.raises(models.spatial.GeometryError, match=r"NOTADATUM .+ GEODETIC_DATUM") as exc: + _ = geometry.transformer_datum_uri # Should have been raised from VocabularyError assert exc.value.__cause__.__class__ is utils.vocabs.VocabularyError @@ -250,7 +199,7 @@ def test_geometry_from_geosparql_wkt_literal_valid( ) def test_geometry_from_geosparql_wkt_literal_invalid( literal_in: str | rdflib.Literal, - expected_error: Type[Exception], + expected_error: type[Exception], ) -> None: """Tests the geometry from_geosparql_wkt_literal method.""" with pytest.raises(expected_error): @@ -278,12 +227,12 @@ def test_geometry_to_rdf_literal() -> None: ( "POINT (571666.4475041276 5539109.815175673)", "EPSG:26917", - f"<{vocabs.geodetic_datum.GeodeticDatum(graph=rdflib.Graph()).get(settings.Settings().DEFAULT_TARGET_CRS)}> POINT (50 -80)", + f"<{vocabs.geodetic_datum.GDA2020.iri}> POINT (50 -80)", ), ( "LINESTRING (1 2, 3 4)", "WGS84", - f"<{vocabs.geodetic_datum.GeodeticDatum(graph=rdflib.Graph()).get(settings.Settings().DEFAULT_TARGET_CRS)}> LINESTRING (2 1, 4 3)", + f"<{vocabs.geodetic_datum.GDA2020.iri}> LINESTRING (2 1, 4 3)", ), ], ) diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py new file mode 100644 index 00000000..4b3b11c3 --- /dev/null +++ b/tests/utils/test_helpers.py @@ -0,0 +1,67 @@ +"""Tests for the test helpers themselves""" + +# Third-party +import pydantic +import pytest + +# Local +from abis_mapping import settings +from tests import helpers + + +def test_override_settings() -> None: + """Test override_settings helper.""" + # initial value + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "GDA2020" + # start first override + with helpers.override_settings(DEFAULT_TARGET_CRS="A1"): + # first override value + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "A1" + # initial value should be restored + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "GDA2020" + + # override multiple settings + with helpers.override_settings( + DEFAULT_WKT_ROUNDING_PRECISION=99, + DEFAULT_TARGET_CRS="B2", + ): + assert settings.SETTINGS.DEFAULT_WKT_ROUNDING_PRECISION == 99 + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "B2" + # initial settings are restored + assert settings.SETTINGS.DEFAULT_WKT_ROUNDING_PRECISION == 8 + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "GDA2020" + + +def test_override_settings_nested() -> None: + """Test override_settings helper with nested usage.""" + # initial value + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "GDA2020" + # start first override + with helpers.override_settings(DEFAULT_TARGET_CRS="A1"): + # first override value + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "A1" + # nested override + with helpers.override_settings(DEFAULT_TARGET_CRS="B2"): + # nested override value + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "B2" + # first override value should be restored + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "A1" + # initial value should be restored + assert settings.SETTINGS.DEFAULT_TARGET_CRS == "GDA2020" + + +def test_override_settings_invalid() -> None: + """Test override_settings helper with bad inputs.""" + # unknown setting + with pytest.raises(pydantic.ValidationError) as error: + with helpers.override_settings(NOT_A_SETTING="foo"): + pass + assert error.value.error_count() == 1 + assert error.value.errors()[0]["type"] == "extra_forbidden" + + # bad value for setting + with pytest.raises(pydantic.ValidationError) as error: + with helpers.override_settings(DEFAULT_WKT_ROUNDING_PRECISION="not_an_int"): + pass + assert error.value.error_count() == 1 + assert error.value.errors()[0]["type"] == "int_parsing"