diff --git a/setup.cfg b/setup.cfg index af25bbf..c5aa9dd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -135,6 +135,9 @@ per-file-ignores = src/urban_meal_delivery/db/__init__.py: # Top-level of a sub-packages is intended to import a lot. F401,WPS201, + src/urban_meal_delivery/db/utils/__init__.py: + # Top-level of a sub-packages is intended to import a lot. + F401, tests/*.py: # Type annotations are not strictly enforced. ANN0, ANN2, diff --git a/src/urban_meal_delivery/db/addresses.py b/src/urban_meal_delivery/db/addresses.py index f392207..f4b853c 100644 --- a/src/urban_meal_delivery/db/addresses.py +++ b/src/urban_meal_delivery/db/addresses.py @@ -1,6 +1,5 @@ """Provide the ORM's `Address` model.""" -from typing import Any import sqlalchemy as sa from sqlalchemy import orm @@ -69,14 +68,9 @@ class Address(meta.Base): ) pixels = orm.relationship('AddressPixelAssociation', back_populates='address') - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Create a new address.""" - # Call SQLAlchemy's default `.__init__()` method. - super().__init__(*args, **kwargs) - - self._utm_coordinates = utils.UTMCoordinate( - self.latitude, self.longitude, relative_to=self.city.as_origin, - ) + # We do not implement a `.__init__()` method and leave that to SQLAlchemy. + # Instead, we use `hasattr()` to check for uninitialized attributes. + # grep:b1f68d24 pylint:disable=attribute-defined-outside-init def __repr__(self) -> str: """Non-literal text representation.""" @@ -95,12 +89,39 @@ class Address(meta.Base): """ return self.id == self._primary_id + @property + def location(self) -> utils.Location: + """The location of the address. + + The returned `Location` object relates to `.city.viewport.southwest`. + + See also the `.x` and `.y` properties that are shortcuts for + `.location.x` and `.location.y`. + + Implementation detail: This property is cached as none of the + underlying attributes to calculate the value are to be changed. + """ + if not hasattr(self, '_location'): # noqa:WPS421 note:b1f68d24 + self._location = utils.Location(self.latitude, self.longitude) + self._location.relate_to(self.city.as_xy_origin) + return self._location + @property def x(self) -> int: # noqa=WPS111 - """The `.easting` of the address in meters, relative to the `.city`.""" - return self._utm_coordinates.x + """The relative x-coordinate within the `.city` in meters. + + On the implied x-y plane, the `.city`'s southwest corner is the origin. + + Shortcut for `.location.x`. + """ + return self.location.x @property def y(self) -> int: # noqa=WPS111 - """The `.northing` of the address in meters, relative to the `.city`.""" - return self._utm_coordinates.y + """The relative y-coordinate within the `.city` in meters. + + On the implied x-y plane, the `.city`'s southwest corner is the origin. + + Shortcut for `.location.y`. + """ + return self.location.y diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index d0d7422..11175ad 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -1,6 +1,6 @@ """Provide the ORM's `City` model.""" -from typing import Any, Dict +from typing import Dict import sqlalchemy as sa from sqlalchemy import orm @@ -47,60 +47,52 @@ class City(meta.Base): addresses = orm.relationship('Address', back_populates='city') grids = orm.relationship('Grid', back_populates='city') - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Create a new city.""" - # Call SQLAlchemy's default `.__init__()` method. - super().__init__(*args, **kwargs) - - # Take the "lower left" of the viewport as the origin - # of a Cartesian coordinate system. - lower_left = self.viewport['southwest'] - self._origin = utils.UTMCoordinate( - lower_left['latitude'], lower_left['longitude'], - ) + # We do not implement a `.__init__()` method and leave that to SQLAlchemy. + # Instead, we use `hasattr()` to check for uninitialized attributes. + # grep:d334120e pylint:disable=attribute-defined-outside-init def __repr__(self) -> str: """Non-literal text representation.""" return '<{cls}({name})>'.format(cls=self.__class__.__name__, name=self.name) @property - def location(self) -> Dict[str, float]: - """GPS location of the city's center. + def center(self) -> utils.Location: + """Location of the city's center. - Example: - {"latitude": 48.856614, "longitude": 2.3522219} + Implementation detail: This property is cached as none of the + underlying attributes to calculate the value are to be changed. """ - return { - 'latitude': self._center_latitude, - 'longitude': self._center_longitude, - } + if not hasattr(self, '_center'): # noqa:WPS421 note:d334120e + self._center = utils.Location( + self._center_latitude, self._center_longitude, + ) + return self._center @property - def viewport(self) -> Dict[str, Dict[str, float]]: + def viewport(self) -> Dict[str, utils.Location]: """Google Maps viewport of the city. - Example: - { - 'northeast': {'latitude': 48.9021449, 'longitude': 2.4699208}, - 'southwest': {'latitude': 48.815573, 'longitude': 2.225193}, + Implementation detail: This property is cached as none of the + underlying attributes to calculate the value are to be changed. + """ + if not hasattr(self, '_viewport'): # noqa:WPS421 note:d334120e + self._viewport = { + 'northeast': utils.Location( + self._northeast_latitude, self._northeast_longitude, + ), + 'southwest': utils.Location( + self._southwest_latitude, self._southwest_longitude, + ), } - """ # noqa:RST203 - return { - 'northeast': { - 'latitude': self._northeast_latitude, - 'longitude': self._northeast_longitude, - }, - 'southwest': { - 'latitude': self._southwest_latitude, - 'longitude': self._southwest_longitude, - }, - } + + return self._viewport @property - def as_origin(self) -> utils.UTMCoordinate: - """The lower left corner of the `.viewport` in UTM coordinates. + def as_xy_origin(self) -> utils.Location: + """The southwest corner of the `.viewport`. - This property serves as the `relative_to` argument to the - `UTMConstructor` when representing an `Address` in the x-y plane. + This property serves, for example, as the `other` argument to the + `Location.relate_to()` method when representing an `Address` + in the x-y plane. """ - return self._origin + return self.viewport['southwest'] diff --git a/src/urban_meal_delivery/db/utils/__init__.py b/src/urban_meal_delivery/db/utils/__init__.py index 3fe2e82..59ade94 100644 --- a/src/urban_meal_delivery/db/utils/__init__.py +++ b/src/urban_meal_delivery/db/utils/__init__.py @@ -1,3 +1,3 @@ """Utilities used by the ORM models.""" -from urban_meal_delivery.db.utils.coordinates import UTMCoordinate # noqa:F401 +from urban_meal_delivery.db.utils.locations import Location diff --git a/src/urban_meal_delivery/db/utils/coordinates.py b/src/urban_meal_delivery/db/utils/locations.py similarity index 63% rename from src/urban_meal_delivery/db/utils/coordinates.py rename to src/urban_meal_delivery/db/utils/locations.py index 17a60e6..f789bc3 100644 --- a/src/urban_meal_delivery/db/utils/coordinates.py +++ b/src/urban_meal_delivery/db/utils/locations.py @@ -1,4 +1,4 @@ -"""A `UTMCoordinate` class to unify working with coordinates.""" +"""A `Location` class to unify working with coordinates.""" from __future__ import annotations @@ -7,19 +7,27 @@ from typing import Optional import utm -class UTMCoordinate: - """A GPS location represented in UTM coordinates. +class Location: + """A location represented in WGS84 and UTM coordinates. - For further info, we refer to this comprehensive article on the UTM system: - https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system + WGS84: + - "conventional" system with latitude-longitude pairs + - assumes earth is a sphere and models the location in 3D + + UTM: + - the Universal Transverse Mercator sytem + - projects WGS84 coordinates onto a 2D map + - can be used for visualizations and calculations directly + - distances are in meters + + Further info how WGS84 and UTM are related: + https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system """ # pylint:disable=too-many-instance-attributes - def __init__( - self, latitude: float, longitude: float, relative_to: UTMCoordinate = None, - ) -> None: - """Cast a WGS84-conforming `latitude`-`longitude` pair as UTM coordinates.""" + def __init__(self, latitude: float, longitude: float) -> None: + """Create a location from a WGS84-conforming `latitude`-`longitude` pair.""" # The SQLAlchemy columns come as `Decimal`s due to the `DOUBLE_PRECISION`. self._latitude = float(latitude) self._longitude = float(longitude) @@ -35,36 +43,40 @@ class UTMCoordinate: self._normalized_easting: Optional[int] = None self._normalized_northing: Optional[int] = None - if relative_to: - try: - self.relate_to(relative_to) - except TypeError: - raise TypeError( - '`relative_to` must be a `UTMCoordinate` object', - ) from None - except ValueError: - raise ValueError( - '`relative_to` must be in the same UTM zone as the `latitude`-`longitude` pair', # noqa:E501 - ) from None - def __repr__(self) -> str: - """A non-literal text representation. + """A non-literal text representation in the UTM system. Convention is {ZONE} {EASTING} {NORTHING}. Example: - `'` + `'` """ - return f'' # noqa:WPS221 + return f'' # noqa:WPS221 + + @property + def latitude(self) -> float: + """The latitude of the location in degrees (WGS84). + + Between -90 and +90 degrees. + """ + return self._latitude + + @property + def longitude(self) -> float: + """The longitude of the location in degrees (WGS84). + + Between -180 and +180 degrees. + """ + return self._longitude @property def easting(self) -> int: - """The easting of the location in meters.""" + """The easting of the location in meters (UTM).""" return self._easting @property def northing(self) -> int: - """The northing of the location in meters.""" + """The northing of the location in meters (UTM).""" return self._northing @property @@ -73,8 +85,8 @@ class UTMCoordinate: return f'{self._zone}{self._band}' def __eq__(self, other: object) -> bool: - """Check if two `UTMCoordinate` objects are the same location.""" - if not isinstance(other, UTMCoordinate): + """Check if two `Location` objects are the same location.""" + if not isinstance(other, Location): return NotImplemented if self.zone != other.zone: @@ -104,24 +116,21 @@ class UTMCoordinate: return self._normalized_northing - def relate_to(self, other: UTMCoordinate) -> None: + def relate_to(self, other: Location) -> None: """Make the origin in the lower-left corner relative to `other`. The `.x` and `.y` properties are the `.easting` and `.northing` values of `self` minus the ones from `other`. So, `.x` and `.y` make up a Cartesian coordinate system where the `other` origin is `(0, 0)`. - This method is implicitly called by `.__init__()` if that is called - with a `relative_to` argument. - To prevent semantic errors in calculations based on the `.x` and `.y` properties, the `other` origin may only be set once! """ if self._normalized_easting is not None: raise RuntimeError('the `other` origin may only be set once') - if not isinstance(other, UTMCoordinate): - raise TypeError('`other` is not a `UTMCoordinate` object') + if not isinstance(other, Location): + raise TypeError('`other` is not a `Location` object') if self.zone != other.zone: raise ValueError('`other` must be in the same zone, including the band') diff --git a/tests/db/test_addresses.py b/tests/db/test_addresses.py index 2f51bb8..8f44352 100644 --- a/tests/db/test_addresses.py +++ b/tests/db/test_addresses.py @@ -6,6 +6,7 @@ import sqlalchemy as sqla from sqlalchemy import exc as sa_exc from urban_meal_delivery import db +from urban_meal_delivery.db import utils class TestSpecialMethods: @@ -123,6 +124,24 @@ class TestProperties: assert result is False + def test_location(self, address): + """Test `Address.location` property.""" + latitude = float(address.latitude) + longitude = float(address.longitude) + + result = address.location + + assert isinstance(result, utils.Location) + assert result.latitude == pytest.approx(latitude) + assert result.longitude == pytest.approx(longitude) + + def test_location_is_cached(self, address): + """Test `Address.location` property.""" + result1 = address.location + result2 = address.location + + assert result1 is result2 + def test_x_is_positive(self, address): """Test `Address.x` property.""" result = address.x diff --git a/tests/db/test_cities.py b/tests/db/test_cities.py index 94d69fe..a0110e5 100644 --- a/tests/db/test_cities.py +++ b/tests/db/test_cities.py @@ -3,8 +3,9 @@ import pytest -from tests.db.utils import test_coordinates as consts +from tests.db.utils import test_locations as consts from urban_meal_delivery import db +from urban_meal_delivery.db import utils class TestSpecialMethods: @@ -39,16 +40,22 @@ class TestConstraints: class TestProperties: """Test properties in `City`.""" - def test_location_data(self, city, city_data): - """Test `City.location` property.""" - result = city.location + def test_center(self, city, city_data): + """Test `City.center` property.""" + result = city.center - assert isinstance(result, dict) - assert len(result) == 2 - assert result['latitude'] == pytest.approx(city_data['_center_latitude']) - assert result['longitude'] == pytest.approx(city_data['_center_longitude']) + assert isinstance(result, utils.Location) + assert result.latitude == pytest.approx(city_data['_center_latitude']) + assert result.longitude == pytest.approx(city_data['_center_longitude']) - def test_viewport_data_overall(self, city): + def test_center_is_cached(self, city): + """Test `City.center` property.""" + result1 = city.center + result2 = city.center + + assert result1 is result2 + + def test_viewport_overall(self, city): """Test `City.viewport` property.""" result = city.viewport @@ -56,18 +63,24 @@ class TestProperties: assert len(result) == 2 @pytest.mark.parametrize('corner', ['northeast', 'southwest']) - def test_viewport_data_corners(self, city, city_data, corner): + def test_viewport_corners(self, city, city_data, corner): """Test `City.viewport` property.""" result = city.viewport[corner] - assert isinstance(result, dict) - assert len(result) == 2 - assert result['latitude'] == pytest.approx(city_data[f'_{corner}_latitude']) - assert result['longitude'] == pytest.approx(city_data[f'_{corner}_longitude']) + assert isinstance(result, utils.Location) + assert result.latitude == pytest.approx(city_data[f'_{corner}_latitude']) + assert result.longitude == pytest.approx(city_data[f'_{corner}_longitude']) - def test_city_in_utm_coordinates(self, city): - """Test `City.as_origin` property.""" - result = city.as_origin + def test_viewport_is_cached(self, city): + """Test `City.viewport` property.""" + result1 = city.viewport + result2 = city.viewport + + assert result1 is result2 + + def test_city_as_xy_origin(self, city): + """Test `City.as_xy_origin` property.""" + result = city.as_xy_origin assert result.zone == consts.ZONE assert consts.MIN_EASTING < result.easting < consts.MAX_EASTING diff --git a/tests/db/utils/test_coordinates.py b/tests/db/utils/test_locations.py similarity index 63% rename from tests/db/utils/test_coordinates.py rename to tests/db/utils/test_locations.py index 6909240..1ee4ddf 100644 --- a/tests/db/utils/test_coordinates.py +++ b/tests/db/utils/test_locations.py @@ -1,4 +1,4 @@ -"""Test the `UTMCoordinate` class.""" +"""Test the `Location` class.""" # pylint:disable=no-self-use import pytest @@ -14,8 +14,8 @@ ZONE = '31U' @pytest.fixture def location(address): - """A `UTMCoordinate` object based off the `address` fixture.""" - obj = utils.UTMCoordinate(address.latitude, address.longitude) + """A `Location` object based off the `address` fixture.""" + obj = utils.Location(address.latitude, address.longitude) assert obj.zone == ZONE # sanity check @@ -24,8 +24,8 @@ def location(address): @pytest.fixture def faraway_location(): - """A `UTMCoordinate` object far away from the `location`.""" - obj = utils.UTMCoordinate(latitude=0, longitude=0) + """A `Location` object far away from the `location`.""" + obj = utils.Location(latitude=0, longitude=0) assert obj.zone != ZONE # sanity check @@ -34,10 +34,8 @@ def faraway_location(): @pytest.fixture def origin(city): - """A `UTMCoordinate` object based off the one and only `city`.""" - # Use the `city`'s lower left viewport corner as the `(0, 0)` origin. - lower_left = city.viewport['southwest'] - obj = utils.UTMCoordinate(lower_left['latitude'], lower_left['longitude']) + """A `Location` object based off the one and only `city`.""" + obj = city.as_xy_origin assert obj.zone == ZONE # sanity check @@ -45,43 +43,17 @@ def origin(city): class TestSpecialMethods: - """Test special methods in `UTMCoordinate`.""" + """Test special methods in `Location`.""" def test_create_utm_coordinates(self, location): - """Test instantiation of a new `UTMCoordinate` object.""" + """Test instantiation of a new `Location` object.""" assert location is not None - def test_create_utm_coordinates_with_origin(self, address, origin): - """Test instantiation with a `relate_to` argument.""" - result = utils.UTMCoordinate( - latitude=address.latitude, longitude=address.longitude, relative_to=origin, - ) - - assert result is not None - - def test_create_utm_coordinates_with_non_utm_origin(self): - """Test instantiation with a `relate_to` argument of the wrong type.""" - with pytest.raises(TypeError, match='UTMCoordinate'): - utils.UTMCoordinate( - latitude=0, longitude=0, relative_to=object(), - ) - - def test_create_utm_coordinates_with_invalid_origin( - self, address, faraway_location, - ): - """Test instantiation with a `relate_to` argument at an invalid location.""" - with pytest.raises(ValueError, match='must be in the same UTM zone'): - utils.UTMCoordinate( - latitude=address.latitude, - longitude=address.longitude, - relative_to=faraway_location, - ) - def test_text_representation(self, location): """The text representation is a non-literal.""" result = repr(location) - assert result.startswith('') @pytest.mark.e2e @@ -103,7 +75,7 @@ class TestSpecialMethods: assert MIN_NORTHING < northing < MAX_NORTHING def test_compare_utm_coordinates_to_different_data_type(self, location): - """Test `UTMCoordinate.__eq__()`.""" + """Test `Location.__eq__()`.""" result = location == object() assert result is False @@ -111,56 +83,68 @@ class TestSpecialMethods: def test_compare_utm_coordinates_to_far_away_coordinates( self, location, faraway_location, ): - """Test `UTMCoordinate.__eq__()`.""" + """Test `Location.__eq__()`.""" with pytest.raises(ValueError, match='must be in the same zone'): bool(location == faraway_location) def test_compare_utm_coordinates_to_equal_coordinates(self, location, address): - """Test `UTMCoordinate.__eq__()`.""" - same_location = utils.UTMCoordinate(address.latitude, address.longitude) + """Test `Location.__eq__()`.""" + same_location = utils.Location(address.latitude, address.longitude) result = location == same_location assert result is True def test_compare_utm_coordinates_to_themselves(self, location): - """Test `UTMCoordinate.__eq__()`.""" + """Test `Location.__eq__()`.""" # pylint:disable=comparison-with-itself result = location == location # noqa:WPS312 assert result is True def test_compare_utm_coordinates_to_different_coordinates(self, location, origin): - """Test `UTMCoordinate.__eq__()`.""" + """Test `Location.__eq__()`.""" result = location == origin assert result is False class TestProperties: - """Test properties in `UTMCoordinate`.""" + """Test properties in `Location`.""" + + def test_latitude(self, location, address): + """Test `Location.latitude` property.""" + result = location.latitude + + assert result == pytest.approx(float(address.latitude)) + + def test_longitude(self, location, address): + """Test `Location.longitude` property.""" + result = location.longitude + + assert result == pytest.approx(float(address.longitude)) def test_easting(self, location): - """Test `UTMCoordinate.easting` property.""" + """Test `Location.easting` property.""" result = location.easting assert MIN_EASTING < result < MAX_EASTING def test_northing(self, location): - """Test `UTMCoordinate.northing` property.""" + """Test `Location.northing` property.""" result = location.northing assert MIN_NORTHING < result < MAX_NORTHING def test_zone(self, location): - """Test `UTMCoordinate.zone` property.""" + """Test `Location.zone` property.""" result = location.zone assert result == ZONE class TestRelateTo: - """Test the `UTMCoordinate.relate_to()` method and the `.x` and `.y` properties.""" + """Test the `Location.relate_to()` method and the `.x` and `.y` properties.""" def test_run_relate_to_twice(self, location, origin): """The `.relate_to()` method must only be run once.""" @@ -170,8 +154,8 @@ class TestRelateTo: location.relate_to(origin) def test_call_relate_to_with_wrong_other_type(self, location): - """`other` must be another `UTMCoordinate`.""" - with pytest.raises(TypeError, match='UTMCoordinate'): + """`other` must be another `Location`.""" + with pytest.raises(TypeError, match='Location'): location.relate_to(object()) def test_call_relate_to_with_far_away_other(