Skip to content

Commit

Permalink
Add Latitude, Longitude and Coordinate (#76)
Browse files Browse the repository at this point in the history
* feat: add latitude, longitude and coordinate

* refactor: apply feedbacks

* refactor: apply feedbacks

* refactor: delete __init__ functions

* fix: coordinate parsing

* docs: update coordinate documentation

* refactor: use latitude, longitude in schema

* 🚧 Some improvements for `Coordinate` type PR (#2)

* refactor: delete __init__ functions

* 🚧 Some improvements for `Coordinate` type PR

* Get tests passing

* ✨ Test serialization json schema

* ⬆ Upgrade deps in `pyproject.toml` and `requirements/pyproject.txt

---------

Co-authored-by: JeanArhancet <jean.arhancetebehere@gmail.com>
Co-authored-by: David Montague <35119617+dmontagu@users.noreply.github.com>

* fix: test and requirements

* docs: fix supported format

---------

Co-authored-by: Serge Matveenko <lig@countzero.co>
Co-authored-by: David Montague <35119617+dmontagu@users.noreply.github.com>
  • Loading branch information
3 people committed Jul 27, 2023
1 parent b7cdcc1 commit e779d19
Show file tree
Hide file tree
Showing 7 changed files with 414 additions and 4 deletions.
49 changes: 49 additions & 0 deletions docs/coordinate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@

Coordinate parses Latitude and Longitude.

You can use the `Coordinate` data type for storing coordinates. Coordinates can be defined using one of the following formats:

1. Tuple format: `(Latitude, Longitude)`. For example: `(41.40338, 2.17403)`.
2. `Coordinate` instance format: `Coordinate(latitude=Latitude, longitude=Longitude)`. For example: `Coordinate(latitude=41.40338, longitude=2.17403)`.

The `Latitude` class and `Longitude` class, which are used to represent latitude and longitude, respectively, enforce the following valid ranges for their values:

- `Latitude`: The latitude value should be between -90 and 90, inclusive.
- `Longitude`: The longitude value should be between -180 and 180, inclusive.

```py
from pydantic import BaseModel

from pydantic_extra_types.coordinate import Longitude, Latitude, Coordinate


class Lat(BaseModel):
lat: Latitude


class Lng(BaseModel):
lng: Longitude


class Coord(BaseModel):
coord: Coordinate


lat = Lat(
lat='90.0',
)

lng = Lng(
long='180.0'
)

coord = Coord(
coord=('90.0', '180.0')
)
print(lat.lat)
# > 90.0
print(lng.lng)
# > 180.0
print(coord.coord)
# > 90.0,180.0
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ nav:
- Phone Number: 'phone_numbers.md'
- ABA Routing Number: 'routing_number.md'
- MAC address: 'mac_address.md'
- Coordinate: 'coordinate.md'

markdown_extensions:
- tables
Expand Down
92 changes: 92 additions & 0 deletions pydantic_extra_types/coordinate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any, ClassVar, Mapping, Tuple, Union

from pydantic import GetCoreSchemaHandler
from pydantic._internal import _repr
from pydantic_core import ArgsKwargs, PydanticCustomError, core_schema

CoordinateValueType = Union[str, int, float]


class Latitude(float):
min: ClassVar[float] = -90.00
max: ClassVar[float] = 90.00

@classmethod
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.float_schema(ge=cls.min, le=cls.max)


class Longitude(float):
min: ClassVar[float] = -180.00
max: ClassVar[float] = 180.00

@classmethod
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
return core_schema.float_schema(ge=cls.min, le=cls.max)


@dataclass
class Coordinate(_repr.Representation):
_NULL_ISLAND: ClassVar[tuple[float, float]] = (0.0, 0.0)

latitude: Latitude
longitude: Longitude

@classmethod
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
schema_chain = [
core_schema.no_info_wrap_validator_function(cls._parse_str, core_schema.str_schema()),
core_schema.no_info_wrap_validator_function(
cls._parse_tuple,
handler.generate_schema(Tuple[float, float]),
),
handler(source),
]

chain_length = len(schema_chain)
chain_schemas: list[Mapping[str, Any]] = [
core_schema.chain_schema(schema_chain[x:]) for x in range(chain_length - 1, -1, -1)
]

return core_schema.no_info_wrap_validator_function(cls._parse_args, core_schema.union_schema(chain_schemas))

@classmethod
def _parse_args(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
if isinstance(value, ArgsKwargs) and not value.kwargs:
n_args = len(value.args)
if n_args == 0:
value = cls._NULL_ISLAND
elif n_args == 1:
value = value.args[0]
return handler(value)

@classmethod
def _parse_str(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
if not isinstance(value, str):
return value
try:
value = tuple(float(x) for x in value.split(','))
except ValueError:
raise PydanticCustomError(
'coordinate_error',
'value is not a valid coordinate: string is not recognized as a valid coordinate',
)
return ArgsKwargs(args=value)

@classmethod
def _parse_tuple(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
if not isinstance(value, tuple):
return value
return ArgsKwargs(args=handler(value))

def __str__(self) -> str:
return f'{self.latitude},{self.longitude}'

def __eq__(self, other: Any) -> bool:
return isinstance(other, Coordinate) and self.latitude == other.latitude and self.longitude == other.longitude

def __hash__(self) -> int:
return hash((self.latitude, self.longitude))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ classifiers = [
]
requires-python = '>=3.7'
dependencies = [
'pydantic>=2.0b3',
'pydantic>=2.0.3',
]
dynamic = ['version']

Expand Down
8 changes: 5 additions & 3 deletions requirements/pyproject.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ phonenumbers==8.13.13
# via pydantic-extra-types (pyproject.toml)
pycountry==22.3.5
# via pydantic-extra-types (pyproject.toml)
pydantic==2.0b2
pydantic==2.0.3
# via pydantic-extra-types (pyproject.toml)
pydantic-core==0.38.0
pydantic-core==2.3.0
# via pydantic
typing-extensions==4.6.3
# via pydantic
# via
# pydantic
# pydantic-core

# The following packages are considered to be unsafe in a requirements file:
# setuptools
196 changes: 196 additions & 0 deletions tests/test_coordinate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
from re import Pattern
from typing import Any, Optional

import pytest
from pydantic import BaseModel, ValidationError
from pydantic_core._pydantic_core import ArgsKwargs

from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude


class Coord(BaseModel):
coord: Coordinate


class Lat(BaseModel):
lat: Latitude


class Lng(BaseModel):
lng: Longitude


@pytest.mark.parametrize(
'coord, result, error',
[
# Valid coordinates
((20.0, 10.0), (20.0, 10.0), None),
((-90.0, 0.0), (-90.0, 0.0), None),
(('20.0', 10.0), (20.0, 10.0), None),
((20.0, '10.0'), (20.0, 10.0), None),
((45.678, -123.456), (45.678, -123.456), None),
(('45.678, -123.456'), (45.678, -123.456), None),
(Coordinate(20.0, 10.0), (20.0, 10.0), None),
(Coordinate(latitude=0, longitude=0), (0, 0), None),
(ArgsKwargs(args=()), (0, 0), None),
(ArgsKwargs(args=(1, 0.0)), (1.0, 0), None),
# # Invalid coordinates
((), None, 'Field required'), # Empty tuple
((10.0,), None, 'Field required'), # Tuple with only one value
(('ten, '), None, 'string is not recognized as a valid coordinate'),
((20.0, 10.0, 30.0), None, 'Tuple should have at most 2 items'), # Tuple with more than 2 values
(ArgsKwargs(args=(1.0,)), None, 'Input should be a dictionary or an instance of Coordinate'),
(
'20.0, 10.0, 30.0',
None,
'Input should be a dictionary or an instance of Coordinate ',
), # Str with more than 2 values
('20.0, 10.0, 30.0', None, 'Unexpected positional argument'), # Str with more than 2 values
(2, None, 'Input should be a dictionary or an instance of Coordinate'), # Wrong type
],
)
def test_format_for_coordinate(coord: (Any, Any), result: (float, float), error: Optional[Pattern]):
if error is None:
_coord: Coordinate = Coord(coord=coord).coord
print('vars(_coord)', vars(_coord))
assert _coord.latitude == result[0]
assert _coord.longitude == result[1]
else:
with pytest.raises(ValidationError, match=error):
Coord(coord=coord).coord


@pytest.mark.parametrize(
'coord, error',
[
# Valid coordinates
((-90.0, 0.0), None),
((50.0, 180.0), None),
# Invalid coordinates
((-91.0, 0.0), 'Input should be greater than or equal to -90'),
((50.0, 181.0), 'Input should be less than or equal to 180'),
],
)
def test_limit_for_coordinate(coord: (Any, Any), error: Optional[Pattern]):
if error is None:
_coord: Coordinate = Coord(coord=coord).coord
assert _coord.latitude == coord[0]
assert _coord.longitude == coord[1]
else:
with pytest.raises(ValidationError, match=error):
Coord(coord=coord).coord


@pytest.mark.parametrize(
'latitude, valid',
[
# Valid latitude
(20.0, True),
(3.0000000000000000000000, True),
(90.0, True),
('90.0', True),
(-90.0, True),
('-90.0', True),
# Unvalid latitude
(91.0, False),
(-91.0, False),
],
)
def test_format_latitude(latitude: float, valid: bool):
if valid:
_lat = Lat(lat=latitude).lat
assert _lat == float(latitude)
else:
with pytest.raises(ValidationError, match='1 validation error for Lat'):
Lat(lat=latitude)


@pytest.mark.parametrize(
'longitude, valid',
[
# Valid latitude
(20.0, True),
(3.0000000000000000000000, True),
(90.0, True),
('90.0', True),
(-90.0, True),
('-90.0', True),
(91.0, True),
(-91.0, True),
(180.0, True),
(-180.0, True),
# Unvalid latitude
(181.0, False),
(-181.0, False),
],
)
def test_format_longitude(longitude: float, valid: bool):
if valid:
_lng = Lng(lng=longitude).lng
assert _lng == float(longitude)
else:
with pytest.raises(ValidationError, match='1 validation error for Lng'):
Lng(lng=longitude)


def test_str_repr():
assert str(Coord(coord=(20.0, 10.0)).coord) == '20.0,10.0'
assert str(Coord(coord=('20.0, 10.0')).coord) == '20.0,10.0'
assert repr(Coord(coord=(20.0, 10.0)).coord) == 'Coordinate(latitude=20.0, longitude=10.0)'


def test_eq():
assert Coord(coord=(20.0, 10.0)).coord != Coord(coord='20.0,11.0').coord
assert Coord(coord=('20.0, 10.0')).coord != Coord(coord='20.0,11.0').coord
assert Coord(coord=('20.0, 10.0')).coord != Coord(coord='20.0,11.0').coord
assert Coord(coord=(20.0, 10.0)).coord == Coord(coord='20.0,10.0').coord


def test_hashable():
assert hash(Coord(coord=(20.0, 10.0)).coord) == hash(Coord(coord=(20.0, 10.0)).coord)
assert hash(Coord(coord=(20.0, 11.0)).coord) != hash(Coord(coord=(20.0, 10.0)).coord)


def test_json_schema():
class Model(BaseModel):
value: Coordinate

assert Model.model_json_schema(mode='validation')['$defs']['Coordinate'] == {
'properties': {
'latitude': {'maximum': 90.0, 'minimum': -90.0, 'title': 'Latitude', 'type': 'number'},
'longitude': {'maximum': 180.0, 'minimum': -180.0, 'title': 'Longitude', 'type': 'number'},
},
'required': ['latitude', 'longitude'],
'title': 'Coordinate',
'type': 'object',
}
assert Model.model_json_schema(mode='validation')['properties']['value'] == {
'anyOf': [
{'$ref': '#/$defs/Coordinate'},
{
'maxItems': 2,
'minItems': 2,
'prefixItems': [{'type': 'number'}, {'type': 'number'}],
'type': 'array',
},
{'type': 'string'},
],
'title': 'Value',
}
assert Model.model_json_schema(mode='serialization') == {
'$defs': {
'Coordinate': {
'properties': {
'latitude': {'maximum': 90.0, 'minimum': -90.0, 'title': 'Latitude', 'type': 'number'},
'longitude': {'maximum': 180.0, 'minimum': -180.0, 'title': 'Longitude', 'type': 'number'},
},
'required': ['latitude', 'longitude'],
'title': 'Coordinate',
'type': 'object',
}
},
'properties': {'value': {'allOf': [{'$ref': '#/$defs/Coordinate'}], 'title': 'Value'}},
'required': ['value'],
'title': 'Model',
'type': 'object',
}
Loading

0 comments on commit e779d19

Please sign in to comment.