-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
b7cdcc1
commit e779d19
Showing
7 changed files
with
414 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
} |
Oops, something went wrong.