whatamithinking-hostutil 5.0.2__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ from ipaddress import ip_address, ip_network
2
+ from typing import Optional, NamedTuple
3
+ import socket
4
+ import re
5
+
6
+ import psutil
7
+ from netifaces import AF_INET, AF_INET6, gateways
8
+ from python_hosts import Hosts, HostsEntry
9
+
10
+ __all__ = [
11
+ "HOSTNAME_REGEX",
12
+ "normalize",
13
+ "is_like_ipv4_address",
14
+ "is_like_ipv6_address",
15
+ "is_like_address",
16
+ "is_like_hostname",
17
+ "is_like_host",
18
+ "get_likely_type",
19
+ "is_valid_address",
20
+ "is_valid_hostname",
21
+ "is_valid_host",
22
+ "get_valid_type",
23
+ "normalize_mac_address",
24
+ "get_hostname",
25
+ "get_addresses",
26
+ "get_address",
27
+ "is_localhost",
28
+ ]
29
+
30
+
31
+ __version__ = "5.0.2"
32
+
33
+ # src: https://stackoverflow.com/questions/106179/regular-expression-to-fullmatch-dns-hostname-or-ip-address
34
+ # updated to inclue underscore, which is allowed on windows
35
+ HOSTNAME_REGEX = re.compile(
36
+ r"(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9_\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9_\-]*[A-Za-z0-9])"
37
+ )
38
+ # this does not guarantee a valid ipv4 address. it just indicates that the user probably entered an address
39
+ # but perhaps messed up the formatting
40
+ _IPV4_ADDRESS_LIKE_REGEX = re.compile(r"\d*\.\d*\.\d*\.\d*")
41
+ # hostnames cannot contain ":" (src: https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_host_names)
42
+ # and ipv4 does not use this char either, so if included it likely means the user was trying to provide ipv6
43
+ _IPV6_ADDRESS_LIKE_REGEX = re.compile(r".*:.*")
44
+ # cache of the hostname for the local machine for performance reasons
45
+ # saved the first time it is requested
46
+ _cached_local_hostname: str = None
47
+ _MAC_REPLACE_REGEX = re.compile(r"[^0123456789ABCDEF]")
48
+
49
+
50
+ def is_like_ipv4_address(host: str) -> bool:
51
+ """Return True if the given string looks like an ipv4 address, even
52
+ if it is not necessarily a valid one."""
53
+ return _IPV4_ADDRESS_LIKE_REGEX.match(host) is not None
54
+
55
+
56
+ def is_like_ipv6_address(host: str) -> bool:
57
+ """Return True if the given string looks like an ipv6 address, even
58
+ if it is not necessarily a valid one."""
59
+ return _IPV6_ADDRESS_LIKE_REGEX.match(host) is not None
60
+
61
+
62
+ def is_like_address(host: str) -> bool:
63
+ """Return True if the given string looks like an address, even
64
+ if it is not necessarily a valid one."""
65
+ if is_like_ipv4_address(host):
66
+ return True
67
+ if is_like_ipv6_address(host):
68
+ return True
69
+ return False
70
+
71
+
72
+ def is_like_hostname(host: str) -> bool:
73
+ """Return True if the given host looks like a hostname, even if
74
+ it is not necessarily a valid one."""
75
+ # ipv4 format can be used as a valid hostname on some systems so have
76
+ # to check that first to avoid calling address a hostname
77
+ if is_like_address(host):
78
+ return False
79
+ if not HOSTNAME_REGEX.match(host):
80
+ return False
81
+ return True
82
+
83
+
84
+ def is_like_host(host: str) -> bool:
85
+ """Return True if the given host looks like an address or hostname
86
+ and False otherwise.
87
+
88
+ True does not necessarily mean the host is a valid address or hostname,
89
+ only that it looks like one of those.
90
+ """
91
+ if is_like_address(host):
92
+ return True
93
+ if HOSTNAME_REGEX.match(host):
94
+ return True
95
+ return False
96
+
97
+
98
+ def get_likely_type(host: str) -> str:
99
+ """Returns 'address' if given host is likely an IP address, 'hostname'
100
+ if it is likely a hostname and raises an exception, ValueError, if neither.
101
+
102
+ This can be used for when the user input could be either an address
103
+ or a hostname, but there is a chance the user messed up the formatting.
104
+ """
105
+ if is_like_address(host):
106
+ return "address"
107
+ if HOSTNAME_REGEX.match(host):
108
+ return "hostname"
109
+ raise ValueError(f"Host is not likely a IPv4/IPv6 address or hostname: {host}")
110
+
111
+
112
+ def is_valid_address(host: str) -> bool:
113
+ """Return True if the given host is in a valid address format;
114
+ False otherwise.
115
+
116
+ This does not check whether the host exists, only if it appears to
117
+ be in the right format for an address.
118
+ """
119
+ try:
120
+ ip_address(host)
121
+ except ValueError:
122
+ return False
123
+ else:
124
+ return True
125
+
126
+
127
+ def is_valid_hostname(host: str) -> bool:
128
+ """Return True if the given host is in a valid hostname format
129
+ and is not in a valid address format.
130
+
131
+ This assumes that if an address was given, it is in the right format.
132
+ If an address was given but it is in the wrong format, it may be considered
133
+ a hostname.
134
+ """
135
+ if is_valid_address(host):
136
+ return False
137
+ if not HOSTNAME_REGEX.fullmatch(host):
138
+ return False
139
+ return True
140
+
141
+
142
+ def is_valid_host(host: str) -> bool:
143
+ """Return True if the given host is a valid address or hostname
144
+ and False otherwise.
145
+
146
+ This assumes valid formatting will be used for the address or
147
+ for the hostname. If this is a user-input and they may make a mistake
148
+ use `is_host_like` instead.
149
+ """
150
+ if is_valid_address(host):
151
+ return True
152
+ if HOSTNAME_REGEX.fullmatch(host):
153
+ return True
154
+ return False
155
+
156
+
157
+ def get_valid_type(host: str) -> str:
158
+ """Returns 'address' if given host is a valid IP address, 'hostname'
159
+ if it is a valid hostname and raises an exception, ValueError, if neither.
160
+
161
+ This assumes valid formatting will be used for the address or
162
+ for the hostname. If this is a user-input and they may make a mistake
163
+ use `get_likely_type` instead.
164
+ """
165
+ if is_valid_address(host):
166
+ return "address"
167
+ if HOSTNAME_REGEX.fullmatch(host):
168
+ return "hostname"
169
+ raise ValueError(f"Host is not a valid IPv4/IPv6 address or hostname: {host}")
170
+
171
+
172
+ def normalize(host: Optional[str]) -> Optional[str]:
173
+ """Normalize the given host (hostname or address) so it can be compared
174
+ against others to avoid duplicates.
175
+
176
+ Simple operation, but defined here to avoid replicating all over the place.
177
+ This works with likely types, so imperfect inputs are supported.
178
+ """
179
+ if not host:
180
+ return
181
+ htype = get_likely_type(host)
182
+ if htype == "hostname":
183
+ # the second strip removes the domain info which is sometimes
184
+ # included in the hostname but for a lan not relevant in most cases
185
+ return host.strip().split(".", 1)[0].casefold()
186
+ else:
187
+ return str(ip_address(host))
188
+
189
+
190
+ def get_hostname(host: Optional[str] = None) -> str:
191
+ """Blocking. Return the hostname from either an address or a
192
+ hostname.
193
+
194
+ The result is normalized so hostnames can be compared.
195
+
196
+ This takes 1-5ms for the local machine since it is statically set.
197
+ The address may take longer to lookup.
198
+
199
+ Args:
200
+ host: Optional. address or hostname to lookup.
201
+ Defaults to returning local hostname.
202
+
203
+ Returns:
204
+ The hostname for the given host
205
+
206
+ Raises:
207
+ socket.gaierror if lookup failed
208
+ """
209
+ if host is None:
210
+ global _cached_local_hostname
211
+ if _cached_local_hostname is None:
212
+ _cached_local_hostname = socket.gethostname()
213
+ hostname = _cached_local_hostname
214
+ else:
215
+ if get_likely_type(host) == "hostname":
216
+ hostname = host
217
+ else:
218
+ # strip off the dns suffix/domain info which should always be separated
219
+ # out from the hostname of the machine with dots
220
+ # this function supports both ipv4 and ipv6
221
+ hostname = socket.gethostbyaddr(host)[0]
222
+ return normalize(hostname)
223
+
224
+
225
+ def _fast_is_local_address(address: str) -> bool:
226
+ try:
227
+ htype = get_likely_type(address)
228
+ except ValueError:
229
+ return False
230
+ if htype != "address":
231
+ return False
232
+ # when you bind to this, the app binds to all addresses for the localhost
233
+ # assumed that when the user gives this, they mean the local machine
234
+ if address in ("0.0.0.0", "::1"):
235
+ return True
236
+ try:
237
+ ip_addr = ip_address(address)
238
+ except ValueError:
239
+ return False
240
+ else:
241
+ return ip_addr.is_loopback
242
+
243
+
244
+ def _get_hosts_file_local_hostnames() -> set[str]:
245
+ """Return a set of loopback hostnames defined in the local
246
+ hosts file.
247
+
248
+ Takes ~1ms
249
+
250
+ WARNING: Cached after the first call for performance reasons.
251
+ """
252
+ hosts = Hosts()
253
+ host_entries: HostsEntry = hosts.entries
254
+ loopback_hostnames = set(["localhost"]) # built in whether present in file or not
255
+ for _ in host_entries:
256
+ if _.entry_type in ("blank", "comment"):
257
+ continue
258
+ if not _fast_is_local_address(_.address):
259
+ continue
260
+ loopback_hostnames |= set(_.names)
261
+ return loopback_hostnames
262
+
263
+
264
+ def _fast_is_local_hostname(hostname: str) -> bool:
265
+ try:
266
+ htype = get_likely_type(hostname)
267
+ except ValueError:
268
+ return False
269
+ if htype != "hostname":
270
+ return False
271
+ hostname = normalize(hostname)
272
+ if hostname == "localhost":
273
+ return True
274
+ if hostname in _get_hosts_file_local_hostnames():
275
+ return True
276
+ # this requires io, but is still pretty fast, taking <5ms
277
+ if hostname == get_hostname():
278
+ return True
279
+ return False
280
+
281
+
282
+ def normalize_mac_address(mac: str, sep: str = ":") -> str:
283
+ """Normalize the given mac address using the the given separator.
284
+
285
+ Args:
286
+ mac: The mac address to normalize
287
+ sep: Optional. The separator to use between the blocks of the address.
288
+ Defaults to ":".
289
+ """
290
+ pure = re.sub(_MAC_REPLACE_REGEX, "", mac.upper())
291
+ if len(pure) != 12:
292
+ raise ValueError("Invalid MAC address length. Cannot normalize.")
293
+ return sep.join([pure[i : i + 2] for i in range(0, len(pure), 2)])
294
+
295
+
296
+ class AddressInfo(NamedTuple):
297
+ connection_name: str
298
+ family: socket.AddressFamily
299
+ address: str
300
+ netmask: str
301
+ gateway: str
302
+ broadcast: str
303
+ mac_address: str
304
+
305
+
306
+ def get_addresses() -> list[AddressInfo]:
307
+ """Return a connection name sorted list of AddressInfo objects for
308
+ all physical connections for this machine.
309
+
310
+ socket.gethostbyname(socket.gethostname()) is normally used but it
311
+ does not always work when other network adapters are installed on a
312
+ machine. For example, when WSL is installed on windows the socket lib
313
+ can sometimes return the address of the WSL adapter which is not accessible
314
+ from external machines.
315
+ This function filters out non-active network adapters, so if you are switching
316
+ from wifi to ethernet, the addresses returned will be different before and after.
317
+
318
+ WARNING: In some cases on windows it seems the address of an adapter such as wifi
319
+ may change when not in use to some internal address for the machine in which case
320
+ it will be filtered out instead of returned with an invalid address.
321
+
322
+ Takes ~30-60ms.
323
+
324
+ Note this is on the network and not on the internet.
325
+ """
326
+ # HACK: use the physical gateways we know about to filter out virtual adapters
327
+ # which should not have the right ip addr/netmask to use the physical gateways
328
+ gaddrs = frozenset(
329
+ addr[0]
330
+ for ift, addrs in gateways().items() # ~5ms
331
+ for addr in addrs
332
+ if ift in (AF_INET, AF_INET6)
333
+ )
334
+ addrinfos = []
335
+ for name, addrs in psutil.net_if_addrs().items(): # ~25ms
336
+ # print(name)
337
+ # skip connection if not for one of the known gateways to filter out virtual stuff
338
+ gfound = False
339
+ for addr in addrs:
340
+ if not addr.family in (socket.AF_INET, socket.AF_INET6):
341
+ continue
342
+ if addr.netmask is None:
343
+ continue
344
+ gwaddr = str(ip_network(f"{addr.address}/{addr.netmask}", strict=False)[1])
345
+ if not gwaddr in gaddrs:
346
+ continue
347
+ gfound = True
348
+ if not gfound:
349
+ continue
350
+
351
+ try:
352
+ mac_address = next(
353
+ addr.address for addr in addrs if addr.family == psutil.AF_LINK
354
+ )
355
+ except StopIteration:
356
+ raise RuntimeError(f"No mac address found for connection, {name}")
357
+
358
+ for addr in addrs:
359
+ if not addr.family in (socket.AF_INET, socket.AF_INET6):
360
+ continue
361
+ if ip_address(addr.address).is_loopback:
362
+ continue
363
+ if addr.netmask is None:
364
+ continue
365
+ ipnet = ip_network(f"{addr.address}/{addr.netmask}", strict=False)
366
+ addrinfos.append(
367
+ AddressInfo(
368
+ connection_name=name.casefold().strip(),
369
+ family=addr.family,
370
+ address=normalize(addr.address),
371
+ gateway=normalize(gwaddr),
372
+ netmask=normalize(addr.netmask),
373
+ broadcast=normalize(addr.broadcast or str(ipnet.broadcast_address)),
374
+ mac_address=normalize_mac_address(mac_address),
375
+ )
376
+ )
377
+
378
+ addrinfos.sort(key=lambda _: _.connection_name)
379
+ return addrinfos
380
+
381
+
382
+ def get_address(host: Optional[str] = None) -> str:
383
+ """Blocking. Get the default address of the machine.
384
+
385
+ Takes ~60-80ms
386
+
387
+ Args:
388
+ host: Optional. address or hostname to lookup the address
389
+ for. Defaults to returning the public address for the local machine.
390
+
391
+ Raises:
392
+ ConnectionError if computer has no network adapter in use and address
393
+ cannot be determined.
394
+ """
395
+ htype = None if host is None else get_likely_type(host)
396
+ address = None
397
+ if htype == "address":
398
+ address = host
399
+ else:
400
+ # HACK: if fast_is_local_hostname fails to pickup on a host value which is local
401
+ # this block may return the wrong address when mulitple network adapters are installed
402
+ # should be fine the ip address is given since we just return that as-is
403
+ if host is None or _fast_is_local_hostname(host):
404
+ addrs = get_addresses()
405
+ if not addrs:
406
+ raise ConnectionError("No network adapters found for this machine.")
407
+ try:
408
+ # return first address found which is in active use
409
+ # or else fallback to just whatever the first one is in the event
410
+ # no adapters are currently in use for some reason, such as network
411
+ # disconnect or switching from one to the other
412
+ stats = dict(
413
+ (k.casefold().strip(), v) for k, v in psutil.net_if_stats().items()
414
+ )
415
+ address = next(
416
+ _ for _ in addrs if stats[_.connection_name].isup
417
+ ).address
418
+ except StopIteration:
419
+ address = addrs[0].address
420
+ else:
421
+ address = socket.getaddrinfo(host, None)[0][4][0]
422
+ return normalize(address)
423
+
424
+
425
+ def is_localhost(host: str, dns: bool = True) -> bool:
426
+ """Blocking. Return True if the given address or hostname is for the
427
+ localhost; return False otherwise.
428
+
429
+ Always check info which is quickest to pull, such as known loopback
430
+ and local hostnames as well as the local hosts file. After that,
431
+ it checks with dns and finally it checks all interfaces available
432
+ on the local machine, which can take a few hundred ms.
433
+
434
+ Usually takes ~<50ms with default settings.
435
+
436
+ Args:
437
+ host: the address or hostname to check
438
+ dns: Optional. If True and a hostname is given, a final attempt
439
+ will be made to resolve it to an address and then check if that
440
+ address is for the local machine. This can take as long as typical
441
+ timeout for DNS queries of 4 seconds. Defaults to True.
442
+
443
+ Returns:
444
+ True if host is for the localhost/loopback; False otherwise
445
+ """
446
+ host = normalize(host)
447
+ try:
448
+ htype = get_likely_type(host)
449
+ except ValueError:
450
+ return False
451
+ if htype == "address":
452
+ if _fast_is_local_address(host):
453
+ return True
454
+ if dns:
455
+ # this takes 30-40ms. slightly faster than checking all interfaces
456
+ if host in frozenset(
457
+ map(normalize, socket.gethostbyname_ex(get_hostname())[2])
458
+ ):
459
+ return True
460
+ return False
461
+ else:
462
+ if _fast_is_local_hostname(host):
463
+ return True
464
+ if dns:
465
+ try:
466
+ addrinfos = socket.getaddrinfo(host, None)
467
+ except socket.gaierror:
468
+ return False
469
+ else:
470
+ for addrinfo in addrinfos:
471
+ if is_localhost(addrinfo[4][0], dns=dns):
472
+ return True
473
+ return False
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: whatamithinking-hostutil
3
+ Version: 5.0.2
4
+ Dynamic: Description
5
+ Dynamic: Description-Content-Type
6
+ Summary: hostname/address parsers/validators/utils for handling user inputs which could be either.
7
+ Author-email: connormaynes@gmail.com
8
+ Keywords: address,host,hostname,ip,ipv4,ipv6,networking
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Networking
12
+ Requires-Dist: netifaces
13
+ Requires-Dist: psutil
14
+ Requires-Dist: python-hosts
15
+ Provides-Extra: dev
16
+ Requires-Dist: black; extra == 'dev'
17
+ Provides-Extra: test
@@ -0,0 +1,4 @@
1
+ whatamithinking/hostutil/__init__.py,sha256=GXcssV1S1vRSDm3Mw-61o9o3mDdyhqwrBFmRzCGCRqo,16439
2
+ whatamithinking_hostutil-5.0.2.dist-info/METADATA,sha256=Xc31Qrr4vWx7STH7omp33_wXiqGGEyUPICiEg9egl4o,605
3
+ whatamithinking_hostutil-5.0.2.dist-info/WHEEL,sha256=aha0VrrYvgDJ3Xxl3db_g_MDIW-ZexDdrc_m-Hk8YY4,105
4
+ whatamithinking_hostutil-5.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any