Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/validators/url.py: 96%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

56 statements  

1"""URL.""" 

2 

3# standard 

4from functools import lru_cache 

5import re 

6from typing import Callable, Optional 

7from urllib.parse import parse_qs, unquote, urlsplit 

8 

9# local 

10from .hostname import hostname 

11from .utils import validator 

12 

13 

14@lru_cache 

15def _username_regex(): 

16 return re.compile( 

17 # extended latin 

18 r"(^[\u0100-\u017F\u0180-\u024F]" 

19 # dot-atom 

20 + r"|[-!#$%&'*+/=?^_`{}|~0-9a-z]+(\.[-!#$%&'*+/=?^_`{}|~0-9a-z]+)*$" 

21 # non-quoted-string 

22 + r"|^([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\011.])*$)", 

23 re.IGNORECASE, 

24 ) 

25 

26 

27@lru_cache 

28def _path_regex(): 

29 return re.compile( 

30 # allowed symbols 

31 r"^[\/a-z0-9\-\.\_\~\!\$\&\'\(\)\*\+\,\;\=\:\@\%" 

32 # symbols / pictographs 

33 + r"\U0001F300-\U0001F5FF" 

34 # emoticons / emoji 

35 + r"\U0001F600-\U0001F64F" 

36 # multilingual unicode ranges 

37 + r"\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+$", 

38 re.IGNORECASE, 

39 ) 

40 

41 

42def _validate_scheme(value: str): 

43 """Validate scheme.""" 

44 # More schemes will be considered later. 

45 return ( 

46 value 

47 # fmt: off 

48 in { 

49 "file", 

50 "ftp", 

51 "ftps", 

52 "git", 

53 "http", 

54 "https", 

55 "irc", 

56 "rtmp", 

57 "rtmps", 

58 "rtsp", 

59 "sftp", 

60 "ssh", 

61 "telnet", 

62 } 

63 # fmt: on 

64 if value 

65 else False 

66 ) 

67 

68 

69def _confirm_ipv6_skip(value: str, skip_ipv6_addr: bool): 

70 """Confirm skip IPv6 check.""" 

71 return skip_ipv6_addr or value.count(":") < 2 or not value.startswith("[") 

72 

73 

74def _validate_auth_segment(value: str): 

75 """Validate authentication segment.""" 

76 if not value: 

77 return True 

78 if (colon_count := value.count(":")) > 1: 

79 # everything before @ is then considered as a username 

80 # this is a bad practice, but syntactically valid URL 

81 return _username_regex().match(unquote(value)) 

82 if colon_count < 1: 

83 return _username_regex().match(value) 

84 username, password = value.rsplit(":", 1) 

85 return _username_regex().match(username) and all( 

86 char_to_avoid not in password for char_to_avoid in ("/", "?", "#", "@") 

87 ) 

88 

89 

90def _validate_netloc( 

91 value: str, 

92 skip_ipv6_addr: bool, 

93 skip_ipv4_addr: bool, 

94 may_have_port: bool, 

95 simple_host: bool, 

96 consider_tld: bool, 

97 private: Optional[bool], 

98 rfc_1034: bool, 

99 rfc_2782: bool, 

100): 

101 """Validate netloc.""" 

102 if not value or value.count("@") > 1: 

103 return False 

104 if value.count("@") < 1: 

105 return hostname( 

106 ( 

107 value 

108 if _confirm_ipv6_skip(value, skip_ipv6_addr) or "]:" in value 

109 else value.lstrip("[").replace("]", "", 1) 

110 ), 

111 skip_ipv6_addr=_confirm_ipv6_skip(value, skip_ipv6_addr), 

112 skip_ipv4_addr=skip_ipv4_addr, 

113 may_have_port=may_have_port, 

114 maybe_simple=simple_host, 

115 consider_tld=consider_tld, 

116 private=private, 

117 rfc_1034=rfc_1034, 

118 rfc_2782=rfc_2782, 

119 ) 

120 basic_auth, host = value.rsplit("@", 1) 

121 return hostname( 

122 ( 

123 host 

124 if _confirm_ipv6_skip(host, skip_ipv6_addr) or "]:" in value 

125 else host.lstrip("[").replace("]", "", 1) 

126 ), 

127 skip_ipv6_addr=_confirm_ipv6_skip(host, skip_ipv6_addr), 

128 skip_ipv4_addr=skip_ipv4_addr, 

129 may_have_port=may_have_port, 

130 maybe_simple=simple_host, 

131 consider_tld=consider_tld, 

132 private=private, 

133 rfc_1034=rfc_1034, 

134 rfc_2782=rfc_2782, 

135 ) and _validate_auth_segment(basic_auth) 

136 

137 

138def _validate_optionals(path: str, query: str, fragment: str, strict_query: bool): 

139 """Validate path query and fragments.""" 

140 optional_segments = True 

141 if path: 

142 optional_segments &= bool(_path_regex().match(path)) 

143 try: 

144 if ( 

145 query 

146 # ref: https://github.com/python/cpython/issues/117109 

147 and parse_qs(query, strict_parsing=strict_query, separator="&") 

148 and parse_qs(query, strict_parsing=strict_query, separator=";") 

149 ): 

150 optional_segments &= True 

151 except TypeError: 

152 # for Python < v3.9.2 (official v3.10) 

153 if query and parse_qs(query, strict_parsing=strict_query): 

154 optional_segments &= True 

155 if fragment: 

156 # See RFC3986 Section 3.5 Fragment for allowed characters 

157 # Adding "#", see https://github.com/python-validators/validators/issues/403 

158 optional_segments &= bool( 

159 re.fullmatch(r"[0-9a-z?/:@\-._~%!$&'()*+,;=#]*", fragment, re.IGNORECASE) 

160 ) 

161 return optional_segments 

162 

163 

164@validator 

165def url( 

166 value: str, 

167 /, 

168 *, 

169 skip_ipv6_addr: bool = False, 

170 skip_ipv4_addr: bool = False, 

171 may_have_port: bool = True, 

172 simple_host: bool = False, 

173 strict_query: bool = True, 

174 consider_tld: bool = False, 

175 private: Optional[bool] = None, # only for ip-addresses 

176 rfc_1034: bool = False, 

177 rfc_2782: bool = False, 

178 validate_scheme: Callable[[str], bool] = _validate_scheme, 

179): 

180 r"""Return whether or not given value is a valid URL. 

181 

182 This validator was originally inspired from [URL validator of dperini][1]. 

183 The following diagram is from [urlly][2]:: 

184 

185 

186 foo://admin:hunter1@example.com:8042/over/there?name=ferret#nose 

187 \_/ \___/ \_____/ \_________/ \__/\_________/ \_________/ \__/ 

188 | | | | | | | | 

189 scheme username password hostname port path query fragment 

190 

191 [1]: https://gist.github.com/dperini/729294 

192 [2]: https://github.com/treeform/urlly 

193 

194 Examples: 

195 >>> url('http://duck.com') 

196 True 

197 >>> url('ftp://foobar.dk') 

198 True 

199 >>> url('http://10.0.0.1') 

200 True 

201 >>> url('http://example.com/">user@example.com') 

202 ValidationError(func=url, args={'value': 'http://example.com/">user@example.com'}) 

203 

204 Args: 

205 value: 

206 URL string to validate. 

207 skip_ipv6_addr: 

208 When URL string cannot contain an IPv6 address. 

209 skip_ipv4_addr: 

210 When URL string cannot contain an IPv4 address. 

211 may_have_port: 

212 URL string may contain port number. 

213 simple_host: 

214 URL string maybe only hyphens and alpha-numerals. 

215 strict_query: 

216 Fail validation on query string parsing error. 

217 consider_tld: 

218 Restrict domain to TLDs allowed by IANA. 

219 private: 

220 Embedded IP address is public if `False`, private/local if `True`. 

221 rfc_1034: 

222 Allow trailing dot in domain/host name. 

223 Ref: [RFC 1034](https://www.rfc-editor.org/rfc/rfc1034). 

224 rfc_2782: 

225 Domain/Host name is of type service record. 

226 Ref: [RFC 2782](https://www.rfc-editor.org/rfc/rfc2782). 

227 validate_scheme: 

228 Function that validates URL scheme. 

229 

230 Returns: 

231 (Literal[True]): If `value` is a valid url. 

232 (ValidationError): If `value` is an invalid url. 

233 """ 

234 if not value or re.search(r"[\s\x00-\x1f\x7f]", value): 

235 # url must not contain any white 

236 # spaces, they must be encoded 

237 return False 

238 

239 try: 

240 scheme, netloc, path, query, fragment = urlsplit(value) 

241 except ValueError: 

242 return False 

243 

244 # RFC 8089: a "file" URL's authority is commonly empty or "localhost", 

245 # neither of which is a hostname `_validate_netloc` would accept. 

246 is_local_file = scheme == "file" and netloc in ("", "localhost") 

247 

248 return ( 

249 validate_scheme(scheme) 

250 and ( 

251 is_local_file 

252 or _validate_netloc( 

253 netloc, 

254 skip_ipv6_addr, 

255 skip_ipv4_addr, 

256 may_have_port, 

257 simple_host, 

258 consider_tld, 

259 private, 

260 rfc_1034, 

261 rfc_2782, 

262 ) 

263 ) 

264 and _validate_optionals(path, query, fragment, strict_query) 

265 )