Skip to content
This repository was archived by the owner on Aug 19, 2025. It is now read-only.

Commit 7b7ebec

Browse files
authored
add IPAddress field (#114)
1 parent 202bb14 commit 7b7ebec

6 files changed

Lines changed: 132 additions & 12 deletions

File tree

docs/fields.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ Represented in HTML forms as a `<textarea>`.
8383
Similar to `String` and takes the same arguments.
8484
Represented in HTML forms as a `<input type="password">`.
8585

86+
### Email
87+
88+
Similar to `String` and takes the same arguments.
89+
Represented in HTML forms as a `<input type="email">`.
90+
91+
### IPAddress
92+
93+
Validates IPv4 and IPv6 addresses.
94+
95+
Returns `ipaddress.IPv4Address` or `ipaddress.IPv6Address` based on input.
96+
8697
## Boolean data types
8798

8899
### Boolean

tests/test_fields.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import decimal
3+
import ipaddress
34
import re
45
import uuid
56

@@ -16,6 +17,7 @@
1617
Email,
1718
Float,
1819
Integer,
20+
IPAddress,
1921
Number,
2022
Object,
2123
Password,
@@ -850,3 +852,29 @@ def test_password():
850852
validator = Password()
851853
value, _ = validator.validate_or_error("secret")
852854
assert value == "secret"
855+
856+
857+
def test_ipaddress():
858+
validator = IPAddress()
859+
value, error = validator.validate_or_error("192.168.1.1")
860+
assert value == ipaddress.ip_address("192.168.1.1")
861+
862+
validator = IPAddress()
863+
value, error = validator.validate_or_error("192.168.1.af")
864+
assert error == ValidationError(text="Must be a valid IP format.", code="format")
865+
866+
validator = IPAddress()
867+
value, error = validator.validate_or_error("192.168.1.256")
868+
assert error == ValidationError(text="Must be a real IP.", code="invalid")
869+
870+
validator = IPAddress()
871+
value, error = validator.validate_or_error(
872+
"2001:0db8:85a3:0000:0000:8a2e:0370:7334"
873+
)
874+
assert value == ipaddress.ip_address("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
875+
876+
validator = IPAddress()
877+
value, error = validator.validate_or_error(
878+
"2001:0dz8:85a3:0000:0000:8a2e:0370:7334"
879+
)
880+
assert error == ValidationError(text="Must be a valid IP format.", code="format")

tests/test_schemas.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import datetime
2+
import ipaddress
3+
import uuid
24

35
import typesystem
46
import typesystem.formats
@@ -265,16 +267,22 @@ def test_schema_decimal_serialization():
265267
def test_schema_uuid_serialization():
266268
user = typesystem.Schema(
267269
fields={
268-
"id": typesystem.String(format="uuid"),
270+
"id": typesystem.UUID(),
271+
"parent_id": typesystem.UUID(),
269272
"username": typesystem.String(),
270273
}
271274
)
272275

273-
item = {"id": "b769df4a-18ec-480f-89ef-8ea961a82269", "username": "tom"}
276+
item = {
277+
"id": uuid.UUID("b769df4a-18ec-480f-89ef-8ea961a82269"),
278+
"username": "tom",
279+
"parent_id": None,
280+
}
274281
data = user.serialize(item)
275282

276283
assert data["id"] == "b769df4a-18ec-480f-89ef-8ea961a82269"
277284
assert data["username"] == "tom"
285+
assert data["parent_id"] is None
278286

279287

280288
def test_schema_reference_serialization():
@@ -503,8 +511,22 @@ def test_definitions_to_json_schema():
503511

504512

505513
def test_schema_email_serialization():
506-
user = typesystem.Schema(fields={"email": typesystem.Email()})
514+
user = typesystem.Schema(
515+
fields={"from": typesystem.Email(), "to": typesystem.Email()}
516+
)
507517

508-
item = {"email": "team@encode.io"}
518+
item = {"from": "team@encode.io", "to": None}
509519
data = user.serialize(item)
510-
assert data == {"email": "team@encode.io"}
520+
assert data["from"] == "team@encode.io"
521+
assert data["to"] is None
522+
523+
524+
def test_schema_ipaddress_serialization():
525+
schema = typesystem.Schema(
526+
fields={"src": typesystem.IPAddress(), "dst": typesystem.IPAddress()}
527+
)
528+
529+
item = {"src": ipaddress.ip_address("127.0.0.1"), "dst": None}
530+
data = schema.serialize(item)
531+
assert data["src"] == "127.0.0.1"
532+
assert data["dst"] is None

typesystem/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Field,
1313
Float,
1414
Integer,
15+
IPAddress,
1516
Number,
1617
Object,
1718
Password,
@@ -38,6 +39,7 @@
3839
"Decimal",
3940
"Email",
4041
"Integer",
42+
"IPAddress",
4143
"Jinja2Forms",
4244
"Field",
4345
"Float",

typesystem/fields.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"datetime": formats.DateTimeFormat(),
1616
"uuid": formats.UUIDFormat(),
1717
"email": formats.EmailFormat(),
18+
"ipaddress": formats.IPAddressFormat(),
1819
}
1920

2021

@@ -778,3 +779,8 @@ def __init__(self, **kwargs: typing.Any) -> None:
778779
class Password(String):
779780
def __init__(self, **kwargs: typing.Any) -> None:
780781
super().__init__(format="password", **kwargs)
782+
783+
784+
class IPAddress(String):
785+
def __init__(self, **kwargs: typing.Any) -> None:
786+
super().__init__(format="ipaddress", **kwargs)

typesystem/formats.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import ipaddress
23
import re
34
import typing
45
import uuid
@@ -30,6 +31,13 @@
3031
re.IGNORECASE,
3132
)
3233

34+
IPV4_REGEX = re.compile(
35+
r"(?:0|25[0-5]|2[0-4]\d|1\d?\d?|[1-9]\d?)"
36+
r"(?:\.(?:0|25[0-5]|2[0-4]\d|1\d?\d?|[1-9]\d?)){3}"
37+
)
38+
39+
IPV6_REGEX = re.compile(r"(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}")
40+
3341

3442
class BaseFormat:
3543
errors: typing.Dict[str, str] = {}
@@ -44,7 +52,7 @@ def is_native_type(self, value: typing.Any) -> bool:
4452
def validate(self, value: typing.Any) -> typing.Union[typing.Any, ValidationError]:
4553
raise NotImplementedError() # pragma: no cover
4654

47-
def serialize(self, obj: typing.Any) -> typing.Union[str, None]:
55+
def serialize(self, obj: typing.Any) -> typing.Optional[str]:
4856
raise NotImplementedError() # pragma: no cover
4957

5058

@@ -68,7 +76,7 @@ def validate(self, value: typing.Any) -> datetime.date:
6876
except ValueError:
6977
raise self.validation_error("invalid")
7078

71-
def serialize(self, obj: typing.Any) -> typing.Union[str, None]:
79+
def serialize(self, obj: typing.Optional[datetime.date]) -> typing.Optional[str]:
7280
if obj is None:
7381
return None
7482

@@ -101,7 +109,7 @@ def validate(self, value: typing.Any) -> datetime.time:
101109
except ValueError:
102110
raise self.validation_error("invalid")
103111

104-
def serialize(self, obj: typing.Any) -> typing.Union[str, None]:
112+
def serialize(self, obj: typing.Optional[datetime.time]) -> typing.Optional[str]:
105113
if obj is None:
106114
return None
107115

@@ -147,7 +155,9 @@ def validate(self, value: typing.Any) -> datetime.datetime:
147155
except ValueError:
148156
raise self.validation_error("invalid")
149157

150-
def serialize(self, obj: typing.Any) -> typing.Union[str, None]:
158+
def serialize(
159+
self, obj: typing.Optional[datetime.datetime]
160+
) -> typing.Optional[str]:
151161
if obj is None:
152162
return None
153163

@@ -174,7 +184,12 @@ def validate(self, value: typing.Any) -> uuid.UUID:
174184

175185
return uuid.UUID(value)
176186

177-
def serialize(self, obj: typing.Any) -> str:
187+
def serialize(self, obj: typing.Optional[uuid.UUID]) -> typing.Optional[str]:
188+
if obj is None:
189+
return None
190+
191+
assert isinstance(obj, uuid.UUID)
192+
178193
return str(obj)
179194

180195

@@ -184,12 +199,48 @@ class EmailFormat(BaseFormat):
184199
def is_native_type(self, value: typing.Any) -> bool:
185200
return False
186201

187-
def validate(self, value: typing.Any) -> uuid.UUID:
202+
def validate(self, value: str) -> str:
188203
match = EMAIL_REGEX.match(value)
189204
if not match:
190205
raise self.validation_error("format")
191206

192207
return value
193208

194-
def serialize(self, obj: typing.Any) -> str:
209+
def serialize(self, obj: typing.Optional[str]) -> typing.Optional[str]:
210+
if obj is None:
211+
return None
212+
213+
return obj
214+
215+
216+
class IPAddressFormat(BaseFormat):
217+
errors = {
218+
"format": "Must be a valid IP format.",
219+
"invalid": "Must be a real IP.",
220+
}
221+
222+
def is_native_type(self, value: typing.Any) -> bool:
223+
return isinstance(value, (ipaddress.IPv4Address, ipaddress.IPv6Address))
224+
225+
def validate(
226+
self, value: typing.Any
227+
) -> typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]:
228+
match_ipv4 = IPV4_REGEX.match(value)
229+
match_ipv6 = IPV6_REGEX.match(value)
230+
if not match_ipv4 and not match_ipv6:
231+
raise self.validation_error("format")
232+
233+
try:
234+
return ipaddress.ip_address(value)
235+
except ValueError:
236+
raise self.validation_error("invalid")
237+
238+
def serialize(
239+
self, obj: typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address, None]
240+
) -> typing.Optional[str]:
241+
if obj is None:
242+
return None
243+
244+
assert isinstance(obj, (ipaddress.IPv4Address, ipaddress.IPv6Address))
245+
195246
return str(obj)

0 commit comments

Comments
 (0)