Skip to content

Commit aa93867

Browse files
committed
feat: adds hostname validator
- hostname can be - a simple alpha-numeral, in the context of system names - an ip address, in the context of URLs - a domain name, in the context of URLs - in all the above cases, it can be with or without port number - adds tests, update docs **Related items** *PRs* - Closes #220
1 parent d2f1d3b commit aa93867

File tree

4 files changed

+179
-0
lines changed

4 files changed

+179
-0
lines changed

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
::: validators.hashes
1414

15+
::: validators.hostname
16+
1517
::: validators.iban
1618

1719
::: validators.ip_address

tests/test_hostname.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Test Hostname."""
2+
# -*- coding: utf-8 -*-
3+
4+
# external
5+
import pytest
6+
7+
# local
8+
from validators import hostname, ValidationFailure
9+
10+
11+
@pytest.mark.parametrize(
12+
("value", "rfc_1034", "rfc_2782"),
13+
[
14+
# simple hostname w/ optional ports
15+
("ubuntu-pc:443", False, False),
16+
("this-pc", False, False),
17+
("lab-01a-notebook:404", False, False),
18+
("4-oh-4", False, False),
19+
# hostname w/ optional ports
20+
("example.com:4444", False, False),
21+
("kräuter.com.", True, False),
22+
("xn----gtbspbbmkef.xn--p1ai:65535", False, False),
23+
("_example.com", False, True),
24+
# ipv4 addr w/ optional ports
25+
("123.123.123.123:9090", False, False),
26+
("127.0.0.1:43512", False, False),
27+
("123.5.77.88:31000", False, False),
28+
("12.12.12.12:5353", False, False),
29+
# ipv6 addr w/ optional ports
30+
("[::1]:22", False, False),
31+
("[dead:beef:0:0:0:0000:42:1]:5731", False, False),
32+
("[0:0:0:0:0:ffff:1.2.3.4]:80", False, False),
33+
("[0:a:b:c:d:e:f::]:53", False, False),
34+
],
35+
)
36+
def test_returns_true_on_valid_hostname(value: str, rfc_1034: bool, rfc_2782: bool):
37+
"""Test returns true on valid hostname."""
38+
assert hostname(value, rfc_1034=rfc_1034, rfc_2782=rfc_2782)
39+
40+
41+
@pytest.mark.parametrize(
42+
("value", "rfc_1034", "rfc_2782"),
43+
[
44+
# bad (simple hostname w/ optional ports)
45+
("ubuntu-pc:443080", False, False),
46+
("this-pc-is-sh*t", False, False),
47+
("lab-01a-note._com_.com:404", False, False),
48+
("4-oh-4:@.com", False, False),
49+
# bad (hostname w/ optional ports)
50+
("example.com:-4444", False, False),
51+
("xn----gtbspbbmkef.xn--p1ai:65538", False, False),
52+
("_example.com:0", False, True),
53+
("kräuter.com.:81_00", True, False),
54+
# bad (ipv4 addr w/ optional ports)
55+
("123.123.123.123:99999", False, False),
56+
("127.0.0.1:", False, False),
57+
("123.5.-12.88:8080", False, False),
58+
("12.12.12.12:$#", False, False),
59+
# bad (ipv6 addr w/ optional ports)
60+
("[::1]:[22]", False, False),
61+
("[dead:beef:0:-:0:-:42:1]:5731", False, False),
62+
("[0:0:0:0:0:ffff:1.2.3.4]:-65538", False, False),
63+
("[0:&:b:c:@:e:f:::9999", False, False),
64+
],
65+
)
66+
def test_returns_failed_validation_on_invalid_hostname(value: str, rfc_1034: bool, rfc_2782: bool):
67+
"""Test returns failed validation on invalid hostname."""
68+
assert isinstance(hostname(value, rfc_1034=rfc_1034, rfc_2782=rfc_2782), ValidationFailure)

validators/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .domain import domain
88
from .email import email
99
from .hashes import md5, sha1, sha224, sha256, sha512
10+
from .hostname import hostname
1011
from .i18n import fi_business_id, fi_ssn
1112
from .iban import iban
1213
from .ip_address import ipv4, ipv6
@@ -28,6 +29,7 @@
2829
"email",
2930
"fi_business_id",
3031
"fi_ssn",
32+
"hostname",
3133
"iban",
3234
"ipv4",
3335
"ipv6",

validators/hostname.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Hostname."""
2+
# -*- coding: utf-8 -*-
3+
4+
# standard
5+
from functools import lru_cache
6+
import re
7+
8+
# local
9+
from .ip_address import ipv4, ipv6
10+
from .utils import validator
11+
from .domain import domain
12+
13+
14+
@lru_cache
15+
def _port_regex():
16+
"""Port validation regex."""
17+
return re.compile(
18+
r"^\:(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|"
19+
+ r"6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3})$",
20+
)
21+
22+
23+
@lru_cache
24+
def _simple_hostname_regex():
25+
"""Simple hostname validation regex."""
26+
return re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$")
27+
28+
29+
@validator
30+
def hostname(
31+
value: str,
32+
/,
33+
*,
34+
may_have_port: bool = True,
35+
skip_ip_addr: bool = False,
36+
maybe_simple: bool = True,
37+
rfc_1034: bool = False,
38+
rfc_2782: bool = False,
39+
):
40+
"""Return whether or not given value is a valid hostname.
41+
42+
Examples:
43+
>>> hostname("ubuntu-pc:443")
44+
# Output: True
45+
>>> hostname("this-pc")
46+
# Output: True
47+
>>> hostname("xn----gtbspbbmkef.xn--p1ai:65535")
48+
# Output: True
49+
>>> hostname("_example.com")
50+
# Output: True
51+
>>> hostname("123.5.77.88:31000")
52+
# Output: True
53+
>>> hostname("12.12.12.12")
54+
# Output: True
55+
>>> hostname("[::1]:22")
56+
# Output: True
57+
>>> hostname("dead:beef:0:0:0:0000:42:1")
58+
# Output: True
59+
>>> hostname("[0:0:0:0:0:ffff:1.2.3.4]:-65538")
60+
# Output: ValidationFailure(func=hostname, ...)
61+
>>> hostname("[0:&:b:c:@:e:f::]:9999")
62+
# Output: ValidationFailure(func=hostname, ...)
63+
64+
Args:
65+
value:
66+
Hostname string to validate.
67+
may_have_port:
68+
Hostname string may contain port number.
69+
skip_ip_addr:
70+
When hostname string cannot be an IP address.
71+
maybe_simple:
72+
Hostname string maybe only hyphens and alpha-numerals.
73+
rfc_1034:
74+
Allow trailing dot in domain/host name.
75+
Ref: [RFC 1034](https://www.rfc-editor.org/rfc/rfc1034).
76+
rfc_2782:
77+
Domain/Host name is of type service record.
78+
Ref: [RFC 2782](https://www.rfc-editor.org/rfc/rfc2782).
79+
80+
Returns:
81+
(Literal[True]):
82+
If `value` is a valid hostname.
83+
(ValidationFailure):
84+
If `value` is an invalid hostname.
85+
86+
> *New in version 0.21.0*.
87+
"""
88+
if may_have_port:
89+
if value.count("]:") == 1 and not skip_ip_addr:
90+
host_seg, port_seg = value.rsplit(":", 1)
91+
return _port_regex().match(f":{port_seg}") and ipv6(
92+
host_seg.lstrip("[").rstrip("]"), cidr=False
93+
)
94+
if value.count(":") == 1:
95+
host_seg, port_seg = value.rsplit(":", 1)
96+
return _port_regex().match(f":{port_seg}") and (
97+
(_simple_hostname_regex().match(host_seg) if maybe_simple else False)
98+
or domain(host_seg, rfc_1034=rfc_1034, rfc_2782=rfc_2782)
99+
or (False if skip_ip_addr else ipv4(host_seg, cidr=False))
100+
)
101+
102+
return (
103+
(_simple_hostname_regex().match(value) if maybe_simple else False)
104+
or domain(value, rfc_1034=rfc_1034, rfc_2782=rfc_2782)
105+
or (False if skip_ip_addr else ipv4(value, cidr=False))
106+
or (False if skip_ip_addr else ipv6(value, cidr=False))
107+
)

0 commit comments

Comments
 (0)