Skip to content

Commit

Permalink
Merge pull request #357 from gaiaresources/BDRSPS-1089
Browse files Browse the repository at this point in the history
BDRSPS-1089 Various improvements to settings
  • Loading branch information
Lincoln-GR authored Dec 3, 2024
2 parents dce6117 + dd545f5 commit d28192d
Show file tree
Hide file tree
Showing 13 changed files with 184 additions and 82 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,5 @@ site/
!/docs/pages/index.md
/docs/pages/index.html

# dotenv settings file
abis_mapping.env
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions abis_mapping/models/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions abis_mapping/models/spatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion abis_mapping/plugins/wkt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 12 additions & 11 deletions abis_mapping/settings.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
8 changes: 8 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -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,)
4 changes: 3 additions & 1 deletion tests/models/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 11 additions & 62 deletions tests/models/test_spatial.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,14 @@
# Third-party
import shapely
import pytest
import pytest_mock
import rdflib

# Local
from abis_mapping import settings
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:
Expand Down Expand Up @@ -130,70 +127,22 @@ 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)",
datum="OSGB36",
)

# 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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)",
),
],
)
Expand Down
Loading

0 comments on commit d28192d

Please sign in to comment.