plevin 0.1.0__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.
plevin/__init__.py ADDED
@@ -0,0 +1,399 @@
1
+ """Location, network and abuse information for any IP address in one offline file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+ from importlib import import_module
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from os import PathLike
11
+ from pathlib import Path
12
+ from time import time as now
13
+ from typing import Any
14
+
15
+ from . import address, derive
16
+ from .address import (
17
+ BENCHMARK,
18
+ DOCUMENTATION,
19
+ LINK_LOCAL,
20
+ LOOPBACK,
21
+ MAPPED,
22
+ MULTICAST,
23
+ PRIVATE,
24
+ RESERVED,
25
+ SHARED,
26
+ SIXTOFOUR,
27
+ TEREDO,
28
+ UNIQUE_LOCAL,
29
+ Value,
30
+ parse,
31
+ purpose,
32
+ span,
33
+ spelled,
34
+ tunnel,
35
+ )
36
+ from .extra import clock, country
37
+ from .models import (
38
+ Abuse,
39
+ Carrier,
40
+ City,
41
+ Country,
42
+ District,
43
+ Metro,
44
+ Network,
45
+ Operator,
46
+ Place,
47
+ Region,
48
+ Result,
49
+ Time,
50
+ )
51
+ from .reader import CACHED, Cache, File, Found
52
+
53
+ __all__ = [
54
+ "Abuse",
55
+ "Carrier",
56
+ "City",
57
+ "Country",
58
+ "District",
59
+ "Metro",
60
+ "Network",
61
+ "Operator",
62
+ "Place",
63
+ "Plevin",
64
+ "Region",
65
+ "Result",
66
+ "Time",
67
+ "address",
68
+ "database",
69
+ "lookup",
70
+ "use",
71
+ ]
72
+
73
+ try:
74
+ __version__ = version("plevin")
75
+ except PackageNotFoundError: # pragma: no cover
76
+ __version__ = "0.0.0"
77
+
78
+ ENVIRONMENT = "PLEVIN_DB"
79
+ PACKAGES = ("plevin_db", "plevin_db_place", "plevin_db_network", "plevin_db_country")
80
+ MISSING = (
81
+ "no database found: install one of "
82
+ f"{', '.join(name.replace('_', '-') for name in PACKAGES)}, set {ENVIRONMENT},"
83
+ " or pass a path"
84
+ )
85
+
86
+ WIDE = 1 << 128
87
+ ANSWERED = 1 << 10
88
+
89
+ Rows = dict[str, Any]
90
+ Ground = tuple[Any, Any, Any, Any, str | None, City | None, Country | None]
91
+ Wires = tuple[int | None, str | None, str | None, Any, Operator | None, Carrier | None]
92
+ Stored = tuple[tuple[Ground, str] | None, tuple[Wires, int | None] | None,
93
+ Abuse | None]
94
+
95
+
96
+ def _text(value: Any) -> str | None:
97
+ return str(value) if value else None
98
+
99
+
100
+ def _count(value: Any) -> int | None:
101
+ return int(value) if value else None
102
+
103
+
104
+ def _metro(rows: Rows | None) -> Metro | None:
105
+ if rows is None:
106
+ return None
107
+ return Metro(code=_count(rows.get("code")), label=_text(rows.get("label")))
108
+
109
+
110
+ def _district(rows: Rows | None) -> District | None:
111
+ if rows is None:
112
+ return None
113
+ return District(id=_count(rows.get("id")), code=_text(rows.get("code")),
114
+ name=_text(rows.get("name")))
115
+
116
+
117
+ def _region(rows: Rows | None) -> Region | None:
118
+ if rows is None:
119
+ return None
120
+ return Region(id=_count(rows.get("id")), code=_text(rows.get("code")),
121
+ iso=_text(rows.get("iso")), name=_text(rows.get("name")),
122
+ type=_text(rows.get("type")))
123
+
124
+
125
+ class Shaped:
126
+ """One model per row object the file hands back, and the file hands back one."""
127
+
128
+ def __init__(self, build: Callable[..., Any]) -> None:
129
+ self.build = build
130
+ self.held: dict[Any, tuple[Rows, Any]] = {}
131
+
132
+ def __call__(self, rows: Rows | None, *rest: Any) -> Any:
133
+ if rows is None:
134
+ return None
135
+ key = (id(rows), rest)
136
+ found = self.held.get(key)
137
+ if found is not None and found[0] is rows:
138
+ return found[1]
139
+ if len(self.held) >= CACHED:
140
+ self.held.clear()
141
+ model = self.held[key] = (rows, self.build(rows, *rest))
142
+ return model[1]
143
+
144
+
145
+ def _built_city(rows: Rows) -> City:
146
+ kind = str(rows.get("type", ""))
147
+ return City(
148
+ id=_count(rows.get("id")),
149
+ name=_text(rows.get("name")),
150
+ ascii=_text(rows.get("ascii")),
151
+ country=_text(rows.get("country")),
152
+ population=_count(rows.get("population")),
153
+ elevation=_count(rows.get("elevation")),
154
+ postal=_text(rows.get("postal")),
155
+ postal_partial=_text(rows.get("postal_partial")),
156
+ timezone=_text(rows.get("timezone")),
157
+ type=_text(kind),
158
+ capital=_text(derive.capital(kind)),
159
+ region=_region(rows.get("region")),
160
+ district=_district(rows.get("district")),
161
+ metro=_metro(rows.get("metro")),
162
+ )
163
+
164
+
165
+ _city = Shaped(_built_city)
166
+
167
+
168
+ def _place(rows: Rows | None) -> tuple[Ground, str] | None:
169
+ """The place without its clock, which is the one part an address does not fix."""
170
+ if rows is None:
171
+ return None
172
+ city = _city(rows.get("city"))
173
+ code = "" if city is None or city.country is None else city.country
174
+ zone = "" if city is None or city.timezone is None else city.timezone
175
+ ground = (rows.get("lat"), rows.get("lon"), rows.get("accuracy"),
176
+ rows.get("confidence"), _text(rows.get("granularity")), city,
177
+ country(code))
178
+ return ground, zone
179
+
180
+
181
+ def _built_operator(rows: Rows, handle: str) -> Operator:
182
+ company = str(rows.get("company", ""))
183
+ website = str(rows.get("website", ""))
184
+ mailbox = str(rows.get("abuse_email", ""))
185
+ return Operator(
186
+ company=_text(company),
187
+ brand=_text(derive.brand(handle, company)),
188
+ domain=_text(derive.domain(website, mailbox)),
189
+ website=_text(website),
190
+ category=_text(rows.get("category")),
191
+ tier=_count(rows.get("tier")),
192
+ peering=_count(rows.get("peering")),
193
+ scope=_text(rows.get("scope")),
194
+ rir=_text(rows.get("rir")),
195
+ since=_count(rows.get("since")),
196
+ street=_text(rows.get("street")),
197
+ state=_text(rows.get("state")),
198
+ postal=_text(rows.get("postal")),
199
+ country=_text(rows.get("country")),
200
+ abuse_email=_text(mailbox),
201
+ city=_city(rows.get("city")),
202
+ )
203
+
204
+
205
+ _operator = Shaped(_built_operator)
206
+
207
+
208
+ def _carrier(rows: Rows | None, user_type: str) -> Carrier | None:
209
+ if rows is None and not user_type:
210
+ return None
211
+ held = rows or {}
212
+ return Carrier(
213
+ user_type=_text(user_type),
214
+ user_count=_count(held.get("user_count")),
215
+ mcc=_count(held.get("mcc")),
216
+ mnc=_count(held.get("mnc")),
217
+ is_mobile=user_type == "cellular",
218
+ )
219
+
220
+
221
+ def _abuse(record: Rows | None, system: Rows | None, user_type: str) -> Abuse | None:
222
+ if record is None and system is None:
223
+ return None
224
+ held = record or {}
225
+ named, inferred = derive.service(str(held.get("service", "")), user_type)
226
+ evidence = str(held.get("evidence", "")) or inferred
227
+ return Abuse(
228
+ name=_text(held.get("name")),
229
+ service=_text(named),
230
+ evidence=_text(evidence),
231
+ risk=held.get("risk"),
232
+ network_risk=None if system is None else system.get("risk"),
233
+ last_seen_days=_count(held.get("last_seen_days")),
234
+ is_anycast=bool(held.get("is_anycast")),
235
+ is_satellite=bool(held.get("is_satellite")),
236
+ is_hosting_provider=user_type in derive.SERVERS,
237
+ is_proxy=named in derive.PROXIES,
238
+ is_public_proxy=named == "public_proxy",
239
+ is_residential_proxy=named == "residential_proxy",
240
+ is_anonymous_vpn=named == "anonymous_vpn",
241
+ is_tor_exit_node=named == "tor_exit_node",
242
+ is_private_relay=named == "private_relay",
243
+ is_anonymous=bool(named),
244
+ )
245
+
246
+
247
+ def _network(rows: Rows | None, user_type: str) -> tuple[Wires, int | None] | None:
248
+ """The network without its span, which the address the lookup asked about fixes."""
249
+ if rows is None:
250
+ return None
251
+ handle = str(rows.get("handle", ""))
252
+ wires = (_count(rows.get("asn")), _text(handle), _text(rows.get("rpki")),
253
+ rows.get("roas"), _operator(rows.get("operator"), handle),
254
+ _carrier(rows.get("carrier"), user_type))
255
+ return wires, _count(rows.get("prefix"))
256
+
257
+
258
+ def _user_type(record: Rows | None, system: Rows | None) -> str:
259
+ """The ASN's type sits on the system row; the record carries only an override."""
260
+ held, below = record or {}, system or {}
261
+ return str(held.get("user_type") or below.get("user_type") or "")
262
+
263
+
264
+ def _stored(rows: Rows) -> Stored:
265
+ """Everything a boundary answers that no address of it changes, built once."""
266
+ network = rows.get("network")
267
+ system = None if network is None else network.get("abuse")
268
+ user_type = _user_type(rows.get("abuse"), system)
269
+ return (_place(rows.get("place")), _network(network, user_type),
270
+ _abuse(rows.get("abuse"), system, user_type))
271
+
272
+
273
+ def _result(value: int, wide: bool, stored: Stored | None,
274
+ moment: datetime | None) -> Result:
275
+ text, expanded, arpa = spelled(value, wide)
276
+ marks = purpose(value, wide)
277
+ through, embedded = tunnel(value, wide)
278
+ ground, wires, abuse = stored or (None, None, None)
279
+ return Result(
280
+ ip=text,
281
+ version=6 if wide else 4,
282
+ number=value,
283
+ compressed=text,
284
+ expanded=expanded,
285
+ arpa=arpa,
286
+ is_global=marks == 0,
287
+ is_bogon=marks != 0,
288
+ is_private=marks & PRIVATE != 0,
289
+ is_loopback=marks & LOOPBACK != 0,
290
+ is_multicast=marks & MULTICAST != 0,
291
+ is_reserved=marks & RESERVED != 0,
292
+ is_link_local=marks & LINK_LOCAL != 0,
293
+ is_unique_local=marks & UNIQUE_LOCAL != 0,
294
+ is_documentation=marks & DOCUMENTATION != 0,
295
+ is_shared=marks & SHARED != 0,
296
+ is_benchmark=marks & BENCHMARK != 0,
297
+ is_ipv4_mapped=through == MAPPED,
298
+ is_6to4=through == SIXTOFOUR,
299
+ is_teredo=through == TEREDO,
300
+ tunnel=through,
301
+ embedded_ipv4=embedded,
302
+ found=stored is not None,
303
+ place=None if ground is None else Place(*ground[0], clock(ground[1], moment)),
304
+ network=None if wires is None else _spanned(*wires, value, wide),
305
+ abuse=abuse,
306
+ )
307
+
308
+
309
+ def _spanned(wires: Wires, prefix: int | None, value: int, wide: bool) -> Network:
310
+ asn, handle, rpki, roas, operator, carrier = wires
311
+ cidr, start, end = (None, None, None) if prefix is None else span(
312
+ value, wide, prefix)
313
+ return Network(asn, handle, prefix, cidr, start, end, rpki, roas, operator, carrier)
314
+
315
+
316
+ def _found() -> Path:
317
+ """The database a reader that was given no path should open."""
318
+ named = os.environ.get(ENVIRONMENT)
319
+ if named:
320
+ return Path(named)
321
+ for package in PACKAGES:
322
+ try:
323
+ module = import_module(package)
324
+ except ModuleNotFoundError:
325
+ continue
326
+ return Path(module.PATH)
327
+ raise LookupError(MISSING)
328
+
329
+
330
+ class Plevin:
331
+ """One database, opened once and asked as often as a log has addresses."""
332
+
333
+ def __init__(self, path: str | PathLike[str] | None = None) -> None:
334
+ self.file = File(_found() if path is None else path)
335
+ self.stored = Cache(self._stored)
336
+ self.results = Cache(self._result, ANSWERED)
337
+ self.second = 0
338
+
339
+ @property
340
+ def path(self) -> str:
341
+ return self.file.path
342
+
343
+ @property
344
+ def built(self) -> str:
345
+ return self.file.built
346
+
347
+ @property
348
+ def selection(self) -> str:
349
+ return self.file.selection
350
+
351
+ @property
352
+ def fields(self) -> list[str]:
353
+ return self.file.fields
354
+
355
+ def _stored(self, found: Found) -> Stored:
356
+ return _stored(self.file.answers[found])
357
+
358
+ def _result(self, key: int) -> Result:
359
+ wide = key >= WIDE
360
+ value = key - WIDE if wide else key
361
+ found = self.file.locate(value, wide)
362
+ return _result(value, wide, None if found is None else self.stored[found], None)
363
+
364
+ def lookup(self, value: Value, moment: datetime | None = None) -> Result:
365
+ """One address, however it is written, as everything the file answers."""
366
+ number, wide = parse(value)
367
+ if moment is not None:
368
+ found = self.file.locate(number, wide)
369
+ held = None if found is None else self.stored[found]
370
+ return _result(number, wide, held, moment)
371
+ second = int(now())
372
+ if second != self.second:
373
+ self.second = second
374
+ self.results.clear()
375
+ answer: Result = self.results[number + WIDE if wide else number]
376
+ return answer
377
+
378
+
379
+ _opened: Plevin | None = None
380
+
381
+
382
+ def database() -> Plevin:
383
+ """The database the module reads, opened on the first address asked of it."""
384
+ global _opened
385
+ if _opened is None:
386
+ _opened = Plevin()
387
+ return _opened
388
+
389
+
390
+ def use(path: str | PathLike[str] | None) -> Plevin:
391
+ """Read a database of your own from here on, or forget the one in use."""
392
+ global _opened
393
+ _opened = None if path is None else Plevin(path)
394
+ return database()
395
+
396
+
397
+ def lookup(value: Value, moment: datetime | None = None) -> Result:
398
+ """One address, however it is written, as everything the file answers."""
399
+ return database().lookup(value, moment)
plevin/address.py ADDED
@@ -0,0 +1,133 @@
1
+ """What an address is before any database is opened."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bisect import bisect_right
6
+ from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
7
+ from socket import AF_INET, AF_INET6, inet_pton
8
+
9
+ Value = str | int | bytes | bytearray | memoryview | IPv4Address | IPv6Address
10
+
11
+ PRIVATE, LOOPBACK, MULTICAST, RESERVED = 1, 2, 4, 8
12
+ LINK_LOCAL, UNIQUE_LOCAL, DOCUMENTATION, SHARED, BENCHMARK = 16, 32, 64, 128, 256
13
+
14
+ SPECIAL_V4 = (
15
+ ("0.0.0.0/8", RESERVED),
16
+ ("10.0.0.0/8", PRIVATE),
17
+ ("100.64.0.0/10", PRIVATE | SHARED),
18
+ ("127.0.0.0/8", PRIVATE | LOOPBACK),
19
+ ("169.254.0.0/16", PRIVATE | LINK_LOCAL),
20
+ ("172.16.0.0/12", PRIVATE),
21
+ ("192.0.0.0/24", RESERVED),
22
+ ("192.0.2.0/24", RESERVED | DOCUMENTATION),
23
+ ("192.88.99.0/24", RESERVED),
24
+ ("192.168.0.0/16", PRIVATE),
25
+ ("198.18.0.0/15", RESERVED | BENCHMARK),
26
+ ("198.51.100.0/24", RESERVED | DOCUMENTATION),
27
+ ("203.0.113.0/24", RESERVED | DOCUMENTATION),
28
+ ("224.0.0.0/4", MULTICAST),
29
+ ("240.0.0.0/4", RESERVED),
30
+ )
31
+ SPECIAL_V6 = (
32
+ ("::/128", RESERVED),
33
+ ("::1/128", PRIVATE | LOOPBACK),
34
+ ("::ffff:0:0/96", RESERVED),
35
+ ("64:ff9b::/96", RESERVED),
36
+ ("64:ff9b:1::/48", RESERVED),
37
+ ("100::/64", RESERVED),
38
+ ("2001::/23", RESERVED),
39
+ ("2001:db8::/32", RESERVED | DOCUMENTATION),
40
+ ("2002::/16", RESERVED),
41
+ ("3fff::/20", RESERVED | DOCUMENTATION),
42
+ ("fc00::/7", PRIVATE | UNIQUE_LOCAL),
43
+ ("fe80::/10", PRIVATE | LINK_LOCAL),
44
+ ("ff00::/8", MULTICAST),
45
+ )
46
+
47
+ MAPPED = "ipv4-mapped"
48
+ SIXTOFOUR = "6to4"
49
+ TEREDO = "teredo"
50
+ NAT64 = "nat64"
51
+ NAT64_PREFIX = 0x0064FF9B << 96
52
+
53
+
54
+ def _table(rows: tuple[tuple[str, int], ...]) -> tuple[list[int], list[tuple[int, int]]]:
55
+ """The rows as first addresses to bisect, and what each range ends at and marks."""
56
+ spans = []
57
+ for text, marks in rows:
58
+ span = ip_network(text)
59
+ spans.append((int(span.network_address), int(span.broadcast_address), marks))
60
+ spans.sort()
61
+ return [first for first, _, _ in spans], [(last, marks) for _, last, marks in spans]
62
+
63
+
64
+ SPECIAL = (_table(SPECIAL_V4), _table(SPECIAL_V6))
65
+
66
+
67
+ def parse(value: Value) -> tuple[int, bool]:
68
+ """An address however it is written, as its number and whether it is a v6 one."""
69
+ if isinstance(value, str):
70
+ wide = ":" in value
71
+ try:
72
+ packed = inet_pton(AF_INET6 if wide else AF_INET, value)
73
+ except OSError:
74
+ return parse(ip_address(value))
75
+ return int.from_bytes(packed, "big"), wide
76
+ if isinstance(value, bytes | bytearray | memoryview):
77
+ return parse(ip_address(bytes(value)))
78
+ if isinstance(value, IPv4Address | IPv6Address):
79
+ return int(value), value.version == 6
80
+ if value < 0 or value > 0xFFFFFFFF_FFFFFFFF_FFFFFFFF_FFFFFFFF:
81
+ raise ValueError(f"{value} is not an address")
82
+ return value, value > 0xFFFFFFFF
83
+
84
+
85
+ def written(value: int, wide: bool) -> str:
86
+ """An address as text: v4 from its octets, v6 through the shortening rules."""
87
+ if wide:
88
+ return str(IPv6Address(value))
89
+ return f"{value >> 24}.{value >> 16 & 255}.{value >> 8 & 255}.{value & 255}"
90
+
91
+
92
+ def spelled(value: int, wide: bool) -> tuple[str, str, str]:
93
+ """How the address reads short and in full, and the name a resolver asks by."""
94
+ if wide:
95
+ address = IPv6Address(value)
96
+ return str(address), address.exploded, address.reverse_pointer
97
+ octets = [str(value >> shift & 255) for shift in (24, 16, 8, 0)]
98
+ text = ".".join(octets)
99
+ return text, text, ".".join(reversed(octets)) + ".in-addr.arpa"
100
+
101
+
102
+ def tunnel(value: int, wide: bool) -> tuple[str | None, str | None]:
103
+ """The v4 address a v6 one carries, and the tunnel that puts it there."""
104
+ if not wide:
105
+ return None, None
106
+ address = IPv6Address(value)
107
+ if address.ipv4_mapped is not None:
108
+ return MAPPED, str(address.ipv4_mapped)
109
+ if address.sixtofour is not None:
110
+ return SIXTOFOUR, str(address.sixtofour)
111
+ if address.teredo is not None:
112
+ return TEREDO, str(address.teredo[1])
113
+ if value >> 32 == NAT64_PREFIX >> 32:
114
+ return NAT64, written(value & 0xFFFFFFFF, False)
115
+ return None, None
116
+
117
+
118
+ def purpose(value: int, wide: bool) -> int:
119
+ """What an address is where it is not the internet, bisected out of the table."""
120
+ starts, ranges = SPECIAL[wide]
121
+ at = bisect_right(starts, value) - 1
122
+ if at >= 0:
123
+ last, marks = ranges[at]
124
+ if value <= last:
125
+ return marks
126
+ return RESERVED if wide and value >> 125 != 1 else 0
127
+
128
+
129
+ def span(value: int, wide: bool, prefix: int) -> tuple[str, str, str]:
130
+ """The announcement the address falls in, masked out of the address itself."""
131
+ spare = (128 if wide else 32) - prefix
132
+ start = written(value >> spare << spare, wide)
133
+ return f"{start}/{prefix}", start, written(value | (1 << spare) - 1, wide)
plevin/derive.py ADDED
@@ -0,0 +1,111 @@
1
+ """The answers the file does not store, read off the ones it does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from functools import lru_cache
7
+
8
+ FORMS = frozenset(
9
+ "inc incorporated llc ltd ltda limited gmbh mbh ag kgaa ohg ev eg sa sab saa sau"
10
+ " sal saog sac sas sarl srl spa nv bv cv asa aps oyj kft zrt nyrt doo sro ooo zao"
11
+ " pao pjsc jsc ojsc llp plc pte pteltd pty corp corporation company holding"
12
+ " holdings group uab sia tov oao pt sdn bhd coltd coltda eireli ead ood eood sti"
13
+ " ltdsti spzoo".split()
14
+ )
15
+ TAILS = frozenset(
16
+ "de me epp co as ab ad dd bt lc lp se sl slu sp z oo zoo oy ao esp kg network"
17
+ " networks net telecom telecoms telecommunication telecommunications"
18
+ " telecomunicaciones comunicaciones communication communications hosting solutions"
19
+ " services service technologies technology tech systems system data datacenter"
20
+ " datacentre cloud internet online isp international global enterprises enterprise"
21
+ " backbone provider providers of and".split()
22
+ )
23
+ LEAD = frozenset(
24
+ "the llc ltd gmbh sarl ooo zao pao ao oao jsc ojsc pjsc uab sia tov pt pp ps ip"
25
+ " spolka".split()
26
+ )
27
+ TAIL = FORMS | TAILS | frozenset({""})
28
+ TLDS = (".com", ".net", ".org", ".io")
29
+
30
+ TRADING = re.compile(r"(?i).*\b(?:trading as|d/b/a|dba)\b\s*")
31
+ ALIAS = re.compile(r"\(.*?\)|,.*|\s+-\s+.*")
32
+ BARE = re.compile(r"[^0-9a-z]")
33
+ NUMBERED = re.compile(r"AS\d+", re.I)
34
+ HANDLE_TAIL = re.compile(r"-(AS|AP|US|UK|DE|FR|IN|CN|JP|EU|NET|COM|ORG)$")
35
+ NETWORK_TAIL = re.compile(r"(NET|COM|TEL|WEB|LINE)$")
36
+ AUTHORITY = re.compile(r"[/?#]")
37
+
38
+ SERVERS = frozenset({"hosting", "cdn", "content"})
39
+ ACCESS = frozenset({"residential", "cellular"})
40
+ PROXIES = frozenset({"public_proxy", "residential_proxy"})
41
+ NAMES = 1 << 13
42
+ CAPITALS = {"national capital": "country", "regional capital": "region",
43
+ "district capital": "district"}
44
+
45
+
46
+ def _cased(text: str) -> str:
47
+ """Shouted words stop shouting, `GOOGLE` reading as `Google` and `IBM` as `IBM`."""
48
+ return " ".join(
49
+ word.title() if word.isalpha() and word.isupper() and len(word) > 4 else word
50
+ for word in text.split(" ")
51
+ )
52
+
53
+
54
+ def _from_company(company: str) -> str:
55
+ """Aliases, legal forms and the words every network shares, all stripped."""
56
+ words = ALIAS.sub("", TRADING.sub("", company)).split()
57
+ tokens = [token for word in words if (token := word.strip("\"'"))]
58
+ while len(tokens) > 1 and BARE.sub("", tokens[-1].lower()) in TAIL:
59
+ tokens.pop()
60
+ while tokens and BARE.sub("", tokens[0].lower()) in LEAD:
61
+ tokens.pop(0)
62
+ name = " ".join(tokens)
63
+ cut = next((len(tld) for tld in TLDS if name.lower().endswith(tld)), 0)
64
+ return _cased(name[: len(name) - cut])
65
+
66
+
67
+ def _from_handle(handle: str) -> str:
68
+ """The first word of a registry handle, its country and network tails gone."""
69
+ words = handle.split()
70
+ head = HANDLE_TAIL.sub("", words[0]) if words else ""
71
+ if NUMBERED.fullmatch(head):
72
+ return ""
73
+ if len(head) > 4 and head.isupper():
74
+ head = NETWORK_TAIL.sub("", head)
75
+ return _cased(head)
76
+
77
+
78
+ @lru_cache(maxsize=NAMES)
79
+ def brand(handle: str, company: str) -> str:
80
+ """The name a network goes by: its handle where the company only spells it out."""
81
+ legal, short = _from_company(company), _from_handle(handle)
82
+ if not legal or not short:
83
+ return legal or short
84
+ if legal.lower() == short.lower():
85
+ return legal if short.isupper() else short
86
+ return short if legal.lower().startswith(f"{short.lower()} ") else legal
87
+
88
+
89
+ @lru_cache(maxsize=NAMES)
90
+ def domain(website: str, mailbox: str) -> str:
91
+ """The bare host the website names, else the one the abuse mailbox does."""
92
+ authority = AUTHORITY.split(website.rpartition("//")[2], maxsplit=1)[0]
93
+ host = authority.rpartition("@")[2].partition(":")[0]
94
+ site = (host or mailbox.partition("@")[2]).lower().removeprefix("www.")
95
+ box = mailbox.partition("@")[2].lower()
96
+ top = site.rpartition(".")[2]
97
+ if len(top) > 2 and box.partition(".")[0] == top and site != box:
98
+ return box
99
+ return site
100
+
101
+
102
+ def service(named: str, user_type: str) -> tuple[str, str]:
103
+ """A public proxy on an access network is someone's home line, resold."""
104
+ if named == "public_proxy" and user_type in ACCESS:
105
+ return "residential_proxy", "inferred"
106
+ return named, ""
107
+
108
+
109
+ def capital(city_type: str) -> str:
110
+ """The city type already says which capital it is, so nothing stores it twice."""
111
+ return CAPITALS.get(city_type, "")