Skip to content

Commit e779d19

Browse files
JeanArhancetligdmontagu
authored
Add Latitude, Longitude and Coordinate (#76)
* 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 <[email protected]> Co-authored-by: David Montague <[email protected]> * fix: test and requirements * docs: fix supported format --------- Co-authored-by: Serge Matveenko <[email protected]> Co-authored-by: David Montague <[email protected]>
1 parent b7cdcc1 commit e779d19

File tree

7 files changed

+414
-4
lines changed

7 files changed

+414
-4
lines changed

docs/coordinate.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
2+
Coordinate parses Latitude and Longitude.
3+
4+
You can use the `Coordinate` data type for storing coordinates. Coordinates can be defined using one of the following formats:
5+
6+
1. Tuple format: `(Latitude, Longitude)`. For example: `(41.40338, 2.17403)`.
7+
2. `Coordinate` instance format: `Coordinate(latitude=Latitude, longitude=Longitude)`. For example: `Coordinate(latitude=41.40338, longitude=2.17403)`.
8+
9+
The `Latitude` class and `Longitude` class, which are used to represent latitude and longitude, respectively, enforce the following valid ranges for their values:
10+
11+
- `Latitude`: The latitude value should be between -90 and 90, inclusive.
12+
- `Longitude`: The longitude value should be between -180 and 180, inclusive.
13+
14+
```py
15+
from pydantic import BaseModel
16+
17+
from pydantic_extra_types.coordinate import Longitude, Latitude, Coordinate
18+
19+
20+
class Lat(BaseModel):
21+
lat: Latitude
22+
23+
24+
class Lng(BaseModel):
25+
lng: Longitude
26+
27+
28+
class Coord(BaseModel):
29+
coord: Coordinate
30+
31+
32+
lat = Lat(
33+
lat='90.0',
34+
)
35+
36+
lng = Lng(
37+
long='180.0'
38+
)
39+
40+
coord = Coord(
41+
coord=('90.0', '180.0')
42+
)
43+
print(lat.lat)
44+
# > 90.0
45+
print(lng.lng)
46+
# > 180.0
47+
print(coord.coord)
48+
# > 90.0,180.0
49+
```

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ nav:
4444
- Phone Number: 'phone_numbers.md'
4545
- ABA Routing Number: 'routing_number.md'
4646
- MAC address: 'mac_address.md'
47+
- Coordinate: 'coordinate.md'
4748

4849
markdown_extensions:
4950
- tables

pydantic_extra_types/coordinate.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any, ClassVar, Mapping, Tuple, Union
5+
6+
from pydantic import GetCoreSchemaHandler
7+
from pydantic._internal import _repr
8+
from pydantic_core import ArgsKwargs, PydanticCustomError, core_schema
9+
10+
CoordinateValueType = Union[str, int, float]
11+
12+
13+
class Latitude(float):
14+
min: ClassVar[float] = -90.00
15+
max: ClassVar[float] = 90.00
16+
17+
@classmethod
18+
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
19+
return core_schema.float_schema(ge=cls.min, le=cls.max)
20+
21+
22+
class Longitude(float):
23+
min: ClassVar[float] = -180.00
24+
max: ClassVar[float] = 180.00
25+
26+
@classmethod
27+
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
28+
return core_schema.float_schema(ge=cls.min, le=cls.max)
29+
30+
31+
@dataclass
32+
class Coordinate(_repr.Representation):
33+
_NULL_ISLAND: ClassVar[tuple[float, float]] = (0.0, 0.0)
34+
35+
latitude: Latitude
36+
longitude: Longitude
37+
38+
@classmethod
39+
def __get_pydantic_core_schema__(cls, source: type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
40+
schema_chain = [
41+
core_schema.no_info_wrap_validator_function(cls._parse_str, core_schema.str_schema()),
42+
core_schema.no_info_wrap_validator_function(
43+
cls._parse_tuple,
44+
handler.generate_schema(Tuple[float, float]),
45+
),
46+
handler(source),
47+
]
48+
49+
chain_length = len(schema_chain)
50+
chain_schemas: list[Mapping[str, Any]] = [
51+
core_schema.chain_schema(schema_chain[x:]) for x in range(chain_length - 1, -1, -1)
52+
]
53+
54+
return core_schema.no_info_wrap_validator_function(cls._parse_args, core_schema.union_schema(chain_schemas))
55+
56+
@classmethod
57+
def _parse_args(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
58+
if isinstance(value, ArgsKwargs) and not value.kwargs:
59+
n_args = len(value.args)
60+
if n_args == 0:
61+
value = cls._NULL_ISLAND
62+
elif n_args == 1:
63+
value = value.args[0]
64+
return handler(value)
65+
66+
@classmethod
67+
def _parse_str(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
68+
if not isinstance(value, str):
69+
return value
70+
try:
71+
value = tuple(float(x) for x in value.split(','))
72+
except ValueError:
73+
raise PydanticCustomError(
74+
'coordinate_error',
75+
'value is not a valid coordinate: string is not recognized as a valid coordinate',
76+
)
77+
return ArgsKwargs(args=value)
78+
79+
@classmethod
80+
def _parse_tuple(cls, value: Any, handler: core_schema.ValidatorFunctionWrapHandler) -> Any:
81+
if not isinstance(value, tuple):
82+
return value
83+
return ArgsKwargs(args=handler(value))
84+
85+
def __str__(self) -> str:
86+
return f'{self.latitude},{self.longitude}'
87+
88+
def __eq__(self, other: Any) -> bool:
89+
return isinstance(other, Coordinate) and self.latitude == other.latitude and self.longitude == other.longitude
90+
91+
def __hash__(self) -> int:
92+
return hash((self.latitude, self.longitude))

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ classifiers = [
3939
]
4040
requires-python = '>=3.7'
4141
dependencies = [
42-
'pydantic>=2.0b3',
42+
'pydantic>=2.0.3',
4343
]
4444
dynamic = ['version']
4545

requirements/pyproject.txt

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ phonenumbers==8.13.13
1010
# via pydantic-extra-types (pyproject.toml)
1111
pycountry==22.3.5
1212
# via pydantic-extra-types (pyproject.toml)
13-
pydantic==2.0b2
13+
pydantic==2.0.3
1414
# via pydantic-extra-types (pyproject.toml)
15-
pydantic-core==0.38.0
15+
pydantic-core==2.3.0
1616
# via pydantic
1717
typing-extensions==4.6.3
18-
# via pydantic
18+
# via
19+
# pydantic
20+
# pydantic-core
1921

2022
# The following packages are considered to be unsafe in a requirements file:
2123
# setuptools

tests/test_coordinate.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
from re import Pattern
2+
from typing import Any, Optional
3+
4+
import pytest
5+
from pydantic import BaseModel, ValidationError
6+
from pydantic_core._pydantic_core import ArgsKwargs
7+
8+
from pydantic_extra_types.coordinate import Coordinate, Latitude, Longitude
9+
10+
11+
class Coord(BaseModel):
12+
coord: Coordinate
13+
14+
15+
class Lat(BaseModel):
16+
lat: Latitude
17+
18+
19+
class Lng(BaseModel):
20+
lng: Longitude
21+
22+
23+
@pytest.mark.parametrize(
24+
'coord, result, error',
25+
[
26+
# Valid coordinates
27+
((20.0, 10.0), (20.0, 10.0), None),
28+
((-90.0, 0.0), (-90.0, 0.0), None),
29+
(('20.0', 10.0), (20.0, 10.0), None),
30+
((20.0, '10.0'), (20.0, 10.0), None),
31+
((45.678, -123.456), (45.678, -123.456), None),
32+
(('45.678, -123.456'), (45.678, -123.456), None),
33+
(Coordinate(20.0, 10.0), (20.0, 10.0), None),
34+
(Coordinate(latitude=0, longitude=0), (0, 0), None),
35+
(ArgsKwargs(args=()), (0, 0), None),
36+
(ArgsKwargs(args=(1, 0.0)), (1.0, 0), None),
37+
# # Invalid coordinates
38+
((), None, 'Field required'), # Empty tuple
39+
((10.0,), None, 'Field required'), # Tuple with only one value
40+
(('ten, '), None, 'string is not recognized as a valid coordinate'),
41+
((20.0, 10.0, 30.0), None, 'Tuple should have at most 2 items'), # Tuple with more than 2 values
42+
(ArgsKwargs(args=(1.0,)), None, 'Input should be a dictionary or an instance of Coordinate'),
43+
(
44+
'20.0, 10.0, 30.0',
45+
None,
46+
'Input should be a dictionary or an instance of Coordinate ',
47+
), # Str with more than 2 values
48+
('20.0, 10.0, 30.0', None, 'Unexpected positional argument'), # Str with more than 2 values
49+
(2, None, 'Input should be a dictionary or an instance of Coordinate'), # Wrong type
50+
],
51+
)
52+
def test_format_for_coordinate(coord: (Any, Any), result: (float, float), error: Optional[Pattern]):
53+
if error is None:
54+
_coord: Coordinate = Coord(coord=coord).coord
55+
print('vars(_coord)', vars(_coord))
56+
assert _coord.latitude == result[0]
57+
assert _coord.longitude == result[1]
58+
else:
59+
with pytest.raises(ValidationError, match=error):
60+
Coord(coord=coord).coord
61+
62+
63+
@pytest.mark.parametrize(
64+
'coord, error',
65+
[
66+
# Valid coordinates
67+
((-90.0, 0.0), None),
68+
((50.0, 180.0), None),
69+
# Invalid coordinates
70+
((-91.0, 0.0), 'Input should be greater than or equal to -90'),
71+
((50.0, 181.0), 'Input should be less than or equal to 180'),
72+
],
73+
)
74+
def test_limit_for_coordinate(coord: (Any, Any), error: Optional[Pattern]):
75+
if error is None:
76+
_coord: Coordinate = Coord(coord=coord).coord
77+
assert _coord.latitude == coord[0]
78+
assert _coord.longitude == coord[1]
79+
else:
80+
with pytest.raises(ValidationError, match=error):
81+
Coord(coord=coord).coord
82+
83+
84+
@pytest.mark.parametrize(
85+
'latitude, valid',
86+
[
87+
# Valid latitude
88+
(20.0, True),
89+
(3.0000000000000000000000, True),
90+
(90.0, True),
91+
('90.0', True),
92+
(-90.0, True),
93+
('-90.0', True),
94+
# Unvalid latitude
95+
(91.0, False),
96+
(-91.0, False),
97+
],
98+
)
99+
def test_format_latitude(latitude: float, valid: bool):
100+
if valid:
101+
_lat = Lat(lat=latitude).lat
102+
assert _lat == float(latitude)
103+
else:
104+
with pytest.raises(ValidationError, match='1 validation error for Lat'):
105+
Lat(lat=latitude)
106+
107+
108+
@pytest.mark.parametrize(
109+
'longitude, valid',
110+
[
111+
# Valid latitude
112+
(20.0, True),
113+
(3.0000000000000000000000, True),
114+
(90.0, True),
115+
('90.0', True),
116+
(-90.0, True),
117+
('-90.0', True),
118+
(91.0, True),
119+
(-91.0, True),
120+
(180.0, True),
121+
(-180.0, True),
122+
# Unvalid latitude
123+
(181.0, False),
124+
(-181.0, False),
125+
],
126+
)
127+
def test_format_longitude(longitude: float, valid: bool):
128+
if valid:
129+
_lng = Lng(lng=longitude).lng
130+
assert _lng == float(longitude)
131+
else:
132+
with pytest.raises(ValidationError, match='1 validation error for Lng'):
133+
Lng(lng=longitude)
134+
135+
136+
def test_str_repr():
137+
assert str(Coord(coord=(20.0, 10.0)).coord) == '20.0,10.0'
138+
assert str(Coord(coord=('20.0, 10.0')).coord) == '20.0,10.0'
139+
assert repr(Coord(coord=(20.0, 10.0)).coord) == 'Coordinate(latitude=20.0, longitude=10.0)'
140+
141+
142+
def test_eq():
143+
assert Coord(coord=(20.0, 10.0)).coord != Coord(coord='20.0,11.0').coord
144+
assert Coord(coord=('20.0, 10.0')).coord != Coord(coord='20.0,11.0').coord
145+
assert Coord(coord=('20.0, 10.0')).coord != Coord(coord='20.0,11.0').coord
146+
assert Coord(coord=(20.0, 10.0)).coord == Coord(coord='20.0,10.0').coord
147+
148+
149+
def test_hashable():
150+
assert hash(Coord(coord=(20.0, 10.0)).coord) == hash(Coord(coord=(20.0, 10.0)).coord)
151+
assert hash(Coord(coord=(20.0, 11.0)).coord) != hash(Coord(coord=(20.0, 10.0)).coord)
152+
153+
154+
def test_json_schema():
155+
class Model(BaseModel):
156+
value: Coordinate
157+
158+
assert Model.model_json_schema(mode='validation')['$defs']['Coordinate'] == {
159+
'properties': {
160+
'latitude': {'maximum': 90.0, 'minimum': -90.0, 'title': 'Latitude', 'type': 'number'},
161+
'longitude': {'maximum': 180.0, 'minimum': -180.0, 'title': 'Longitude', 'type': 'number'},
162+
},
163+
'required': ['latitude', 'longitude'],
164+
'title': 'Coordinate',
165+
'type': 'object',
166+
}
167+
assert Model.model_json_schema(mode='validation')['properties']['value'] == {
168+
'anyOf': [
169+
{'$ref': '#/$defs/Coordinate'},
170+
{
171+
'maxItems': 2,
172+
'minItems': 2,
173+
'prefixItems': [{'type': 'number'}, {'type': 'number'}],
174+
'type': 'array',
175+
},
176+
{'type': 'string'},
177+
],
178+
'title': 'Value',
179+
}
180+
assert Model.model_json_schema(mode='serialization') == {
181+
'$defs': {
182+
'Coordinate': {
183+
'properties': {
184+
'latitude': {'maximum': 90.0, 'minimum': -90.0, 'title': 'Latitude', 'type': 'number'},
185+
'longitude': {'maximum': 180.0, 'minimum': -180.0, 'title': 'Longitude', 'type': 'number'},
186+
},
187+
'required': ['latitude', 'longitude'],
188+
'title': 'Coordinate',
189+
'type': 'object',
190+
}
191+
},
192+
'properties': {'value': {'allOf': [{'$ref': '#/$defs/Coordinate'}], 'title': 'Value'}},
193+
'required': ['value'],
194+
'title': 'Model',
195+
'type': 'object',
196+
}

0 commit comments

Comments
 (0)