domainhack 0.2.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.
Files changed (67) hide show
  1. domainhack/__init__.py +5 -0
  2. domainhack/__main__.py +5 -0
  3. domainhack/adapters/__init__.py +0 -0
  4. domainhack/adapters/_circuit.py +105 -0
  5. domainhack/adapters/_http.py +22 -0
  6. domainhack/adapters/_registration.py +128 -0
  7. domainhack/adapters/_stderr.py +16 -0
  8. domainhack/adapters/_text_sink.py +33 -0
  9. domainhack/adapters/_throttle.py +150 -0
  10. domainhack/adapters/brand_data.py +49 -0
  11. domainhack/adapters/brand_report_render.py +222 -0
  12. domainhack/adapters/cached_registrar.py +340 -0
  13. domainhack/adapters/composite_writer.py +27 -0
  14. domainhack/adapters/console_writer.py +77 -0
  15. domainhack/adapters/csv_writer.py +79 -0
  16. domainhack/adapters/dns_confirming_writer.py +72 -0
  17. domainhack/adapters/dns_resolver.py +120 -0
  18. domainhack/adapters/file_word_source.py +51 -0
  19. domainhack/adapters/iana_tlds.py +110 -0
  20. domainhack/adapters/json_writer.py +51 -0
  21. domainhack/adapters/pacing.py +46 -0
  22. domainhack/adapters/rdap_bootstrap.py +223 -0
  23. domainhack/adapters/rdap_registrar.py +328 -0
  24. domainhack/adapters/registrar_catalog.py +120 -0
  25. domainhack/adapters/registrar_router.py +72 -0
  26. domainhack/adapters/registry_sources.py +192 -0
  27. domainhack/adapters/tqdm_progress.py +101 -0
  28. domainhack/adapters/whois_registrar.py +386 -0
  29. domainhack/cli/__init__.py +0 -0
  30. domainhack/cli/app.py +800 -0
  31. domainhack/cli/name_cmd.py +222 -0
  32. domainhack/cli/tlds_cmd.py +96 -0
  33. domainhack/data/hack_words.json +20 -0
  34. domainhack/data/iana_tlds.txt +1438 -0
  35. domainhack/data/rdap_dns.json +1 -0
  36. domainhack/data/registry_sources.json +2367 -0
  37. domainhack/data/second_level.json +28 -0
  38. domainhack/data/tld_presets.json +7 -0
  39. domainhack/domain/__init__.py +0 -0
  40. domainhack/domain/entities.py +287 -0
  41. domainhack/domain/label_rules.py +96 -0
  42. domainhack/domain/parking.py +81 -0
  43. domainhack/ports/__init__.py +0 -0
  44. domainhack/ports/dns_lookup.py +11 -0
  45. domainhack/ports/known_tlds.py +21 -0
  46. domainhack/ports/progress.py +32 -0
  47. domainhack/ports/registrar.py +27 -0
  48. domainhack/ports/result_cache.py +19 -0
  49. domainhack/ports/result_writer.py +13 -0
  50. domainhack/ports/word_source.py +9 -0
  51. domainhack/py.typed +0 -0
  52. domainhack/usecases/__init__.py +0 -0
  53. domainhack/usecases/brand_candidates.py +195 -0
  54. domainhack/usecases/brand_report.py +271 -0
  55. domainhack/usecases/check_domains.py +287 -0
  56. domainhack/usecases/confirm_dns.py +64 -0
  57. domainhack/usecases/estimate_run.py +80 -0
  58. domainhack/usecases/filter_words.py +37 -0
  59. domainhack/usecases/generate_range.py +107 -0
  60. domainhack/usecases/lanes.py +193 -0
  61. domainhack/usecases/rank_candidates.py +106 -0
  62. domainhack-0.2.0.dist-info/METADATA +501 -0
  63. domainhack-0.2.0.dist-info/RECORD +67 -0
  64. domainhack-0.2.0.dist-info/WHEEL +5 -0
  65. domainhack-0.2.0.dist-info/entry_points.txt +2 -0
  66. domainhack-0.2.0.dist-info/licenses/LICENSE +21 -0
  67. domainhack-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,72 @@
1
+ """Routes each domain to the RegistrarClient responsible for its TLD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+
8
+ from domainhack.adapters.registry_sources import unsupported_message
9
+ from domainhack.domain.entities import TLD, Availability, DomainCheckResult, DomainHack
10
+ from domainhack.ports.registrar import RegistrarClient
11
+
12
+ RegistrarFactory = Callable[[TLD], RegistrarClient | None]
13
+
14
+
15
+ class RegistrarRouter(RegistrarClient):
16
+ """A RegistrarClient that dispatches by TLD to per-TLD clients.
17
+
18
+ Clients are created lazily through ``factory`` the first time a TLD is seen
19
+ and memoized (including "no client" answers, so the factory runs at most
20
+ once per TLD). Domains whose TLD has no client get an ERROR result without
21
+ any network access.
22
+
23
+ Thread-safe: parallel checks call it from several worker threads. Client
24
+ creation is serialized by a lock, so the factory still runs at most once
25
+ per TLD; the clients themselves are called concurrently only for TLDs in
26
+ different lanes (see ``pacing.lane_for``).
27
+ """
28
+
29
+ def __init__(self, factory: RegistrarFactory) -> None:
30
+ self._factory = factory
31
+ self._clients: dict[TLD, RegistrarClient | None] = {}
32
+ self._lock = threading.Lock()
33
+
34
+ def client_for(self, tld: TLD) -> RegistrarClient | None:
35
+ """The client for ``tld`` (created on first use), or None if unsupported."""
36
+ with self._lock:
37
+ if tld not in self._clients:
38
+ self._clients[tld] = self._factory(tld)
39
+ return self._clients[tld]
40
+
41
+ def supports(self, tld: TLD) -> bool:
42
+ """True if some registrar client can check domains under ``tld``."""
43
+ return self.client_for(tld) is not None
44
+
45
+ def check_availability(self, domain: DomainHack) -> DomainCheckResult:
46
+ client = self.client_for(domain.tld)
47
+ if client is None:
48
+ return DomainCheckResult(
49
+ domain=domain,
50
+ availability=Availability.ERROR,
51
+ error_message=_capitalized(unsupported_message(domain.tld.suffix)),
52
+ )
53
+ return client.check_availability(domain)
54
+
55
+ def close(self) -> None:
56
+ """Close every client created so far; re-raise the first failure, if any."""
57
+ with self._lock:
58
+ clients = [c for c in self._clients.values() if c is not None]
59
+ self._clients.clear()
60
+ first_error: Exception | None = None
61
+ for client in clients:
62
+ try:
63
+ client.close()
64
+ except Exception as exc:
65
+ if first_error is None:
66
+ first_error = exc
67
+ if first_error is not None:
68
+ raise first_error
69
+
70
+
71
+ def _capitalized(message: str) -> str:
72
+ return message[:1].upper() + message[1:]
@@ -0,0 +1,192 @@
1
+ """Registry backends per TLD, loaded from ``data/registry_sources.json``.
2
+
3
+ The JSON is the single source of truth for TLDs the IANA RDAP bootstrap does
4
+ not cover (or covers badly): RDAP overrides, the RDAP denylist, WHOIS servers
5
+ with their verified "not found" patterns, and TLDs that cannot be checked at
6
+ all, with the reason why. Keeping it as data means adding a registry is a
7
+ reviewed data change, not a code change.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from collections.abc import Iterable, Mapping
15
+ from dataclasses import dataclass
16
+ from functools import cache
17
+ from importlib import resources
18
+ from typing import Any
19
+
20
+ REGISTRY_SOURCES = "data/registry_sources.json"
21
+ STATUSES = frozenset({"rdap", "whois", "restricted", "unavailable"})
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class WhoisSpec:
26
+ """A port-43 server and how to read its replies (patterns as strings)."""
27
+
28
+ host: str
29
+ not_found: str
30
+ taken: str | None
31
+ query_format: str
32
+ min_interval: float
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RegistrySource:
37
+ """How one TLD is checked, or why it cannot be."""
38
+
39
+ tld: str
40
+ status: str
41
+ rdap: str | None
42
+ whois: WhoisSpec | None
43
+ reason: str
44
+ verified_on: str | None
45
+ source: str
46
+ verified_taken: str = ""
47
+ verified_free: str = ""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class RegistrySources:
52
+ tlds: Mapping[str, RegistrySource]
53
+ rdap_denylist: frozenset[str]
54
+ rdap_denylist_reason: str
55
+
56
+ def get(self, tld: str) -> RegistrySource | None:
57
+ return self.tlds.get(tld.lower())
58
+
59
+ def rdap_overrides(self) -> dict[str, str]:
60
+ """TLD -> RDAP base URL for registries missing from the IANA bootstrap."""
61
+ return {t: s.rdap for t, s in self.tlds.items() if s.status == "rdap" and s.rdap}
62
+
63
+ def whois_primary(self) -> dict[str, WhoisSpec]:
64
+ """TLDs whose only backend is WHOIS."""
65
+ return {t: s.whois for t, s in self.tlds.items() if s.status == "whois" and s.whois}
66
+
67
+ def whois_fallback(self) -> dict[str, WhoisSpec]:
68
+ """RDAP TLDs with a WHOIS server kept as a fallback."""
69
+ return {t: s.whois for t, s in self.tlds.items() if s.status == "rdap" and s.whois}
70
+
71
+ def unsupported_reason(self, tld: str) -> str:
72
+ """The user-facing reason a TLD cannot be checked, or ``""`` if none is recorded."""
73
+ source = self.get(tld)
74
+ if source is None or source.status not in ("restricted", "unavailable"):
75
+ return ""
76
+ return f"{source.status}: {source.reason}"
77
+
78
+
79
+ class RegistrySourcesError(ValueError):
80
+ """The bundled registry data is malformed (a packaging bug, caught by tests)."""
81
+
82
+
83
+ def parse_registry_sources(doc: Any) -> RegistrySources:
84
+ """Validate and convert the JSON document; raises RegistrySourcesError."""
85
+ if not isinstance(doc, dict) or not isinstance(doc.get("tlds"), dict):
86
+ raise RegistrySourcesError("registry sources: missing 'tlds' object")
87
+ tlds: dict[str, RegistrySource] = {}
88
+ for tld, raw in doc["tlds"].items():
89
+ tlds[tld] = _parse_entry(tld, raw)
90
+ deny = doc.get("rdap_denylist") or {}
91
+ return RegistrySources(
92
+ tlds=tlds,
93
+ rdap_denylist=frozenset(t.lower() for t in deny.get("tlds", ())),
94
+ rdap_denylist_reason=str(deny.get("reason", "")),
95
+ )
96
+
97
+
98
+ def _parse_entry(tld: str, raw: Any) -> RegistrySource:
99
+ def fail(message: str) -> RegistrySourcesError:
100
+ return RegistrySourcesError(f"registry sources: .{tld}: {message}")
101
+
102
+ if not isinstance(raw, dict):
103
+ raise fail("entry must be an object")
104
+ status = raw.get("status")
105
+ if status not in STATUSES:
106
+ raise fail(f"unknown status {status!r}")
107
+ rdap = raw.get("rdap")
108
+ if rdap is not None and not (isinstance(rdap, str) and rdap.startswith("https://")):
109
+ raise fail("rdap must be an https:// base URL")
110
+ if isinstance(rdap, str) and not rdap.endswith("/"):
111
+ raise fail("rdap base URL must end with '/'")
112
+ whois = _parse_whois(raw.get("whois"), fail)
113
+ reason = str(raw.get("reason") or "")
114
+ if status == "whois" and whois is None:
115
+ raise fail("status whois needs a whois server")
116
+ if status in ("restricted", "unavailable") and not reason:
117
+ raise fail(f"status {status} needs a reason")
118
+ verified = raw.get("verified")
119
+ if status in ("rdap", "whois") and not verified:
120
+ raise fail("rdap/whois entries must record when they were verified")
121
+ return RegistrySource(
122
+ tld=tld.lower(),
123
+ status=status,
124
+ rdap=rdap,
125
+ whois=whois,
126
+ reason=reason,
127
+ verified_on=verified.get("date") if isinstance(verified, dict) else None,
128
+ source=str(raw.get("source") or ""),
129
+ verified_taken=str(verified.get("taken_probe") or "") if isinstance(verified, dict) else "",
130
+ verified_free=str(verified.get("free_probe") or "") if isinstance(verified, dict) else "",
131
+ )
132
+
133
+
134
+ def _parse_whois(raw: Any, fail: Any) -> WhoisSpec | None:
135
+ if raw is None:
136
+ return None
137
+ if not isinstance(raw, dict) or not raw.get("host") or not raw.get("not_found"):
138
+ raise fail("whois needs host and not_found")
139
+ for key in ("not_found", "taken"):
140
+ pattern = raw.get(key)
141
+ if pattern is not None:
142
+ try:
143
+ re.compile(pattern)
144
+ except re.error as exc:
145
+ raise fail(f"invalid {key} regex: {exc}") from exc
146
+ query_format = raw.get("query_format") or "{fqdn}\r\n"
147
+ if "{fqdn}" not in query_format or not query_format.endswith("\r\n"):
148
+ raise fail("query_format must contain {fqdn} and end with CRLF")
149
+ return WhoisSpec(
150
+ host=str(raw["host"]),
151
+ not_found=str(raw["not_found"]),
152
+ taken=raw.get("taken"),
153
+ query_format=query_format,
154
+ min_interval=float(raw.get("min_interval") or 1.0),
155
+ )
156
+
157
+
158
+ @cache
159
+ def load_registry_sources() -> RegistrySources:
160
+ """The bundled registry data (parsed once per process)."""
161
+ text = resources.files("domainhack").joinpath(REGISTRY_SOURCES).read_text(encoding="utf-8")
162
+ return parse_registry_sources(json.loads(text))
163
+
164
+
165
+ def unsupported_message(suffix: str) -> str:
166
+ """``"no registrar supports .es"``, plus the recorded reason when there is one.
167
+
168
+ For a multi-label suffix (``com.xx``) the reason recorded for its top-level
169
+ domain applies.
170
+ """
171
+ sources = load_registry_sources()
172
+ reason = sources.unsupported_reason(suffix) or sources.unsupported_reason(
173
+ suffix.rsplit(".", 1)[-1]
174
+ )
175
+ message = f"no registrar supports .{suffix}"
176
+ return f"{message} ({reason})" if reason else message
177
+
178
+
179
+ def describe_unsupported(suffixes: Iterable[str]) -> list[str]:
180
+ """Messages for TLDs that cannot be checked: the ones without a recorded
181
+ reason grouped (``"no registrar supports .in, .io"``), then one message per
182
+ TLD whose reason is known (``"no registrar supports .es (restricted: ...)"``)."""
183
+ plain: list[str] = []
184
+ explained: list[str] = []
185
+ for suffix in suffixes:
186
+ message = unsupported_message(suffix)
187
+ if message == f"no registrar supports .{suffix}":
188
+ plain.append(f".{suffix}")
189
+ else:
190
+ explained.append(message)
191
+ grouped = [f"no registrar supports {', '.join(plain)}"] if plain else []
192
+ return grouped + explained
@@ -0,0 +1,101 @@
1
+ import sys
2
+ from typing import Any, TextIO
3
+
4
+ from tqdm import tqdm
5
+
6
+ from domainhack.domain.entities import Availability, DomainCheckResult
7
+ from domainhack.ports.progress import ProgressReporter
8
+
9
+
10
+ class _TqdmLineWriter:
11
+ """File-like proxy installed as ``sys.stdout`` while a bar is visible.
12
+
13
+ Complete lines are written inside ``tqdm.external_write_mode()``, which clears the
14
+ bar, lets the line through to the real stream and redraws the bar, so ``print()``
15
+ output on stdout doesn't get mangled by a bar on stderr.
16
+ """
17
+
18
+ def __init__(self, target: TextIO) -> None:
19
+ self._target = target
20
+ self._buffer = ""
21
+
22
+ def write(self, text: str) -> int:
23
+ self._buffer += text
24
+ if "\n" in self._buffer:
25
+ complete, _, self._buffer = self._buffer.rpartition("\n")
26
+ self._emit(complete + "\n")
27
+ return len(text)
28
+
29
+ def flush(self) -> None:
30
+ if self._buffer:
31
+ self._emit(self._buffer)
32
+ self._buffer = ""
33
+ self._target.flush()
34
+
35
+ def _emit(self, text: str) -> None:
36
+ # external_write_mode() defaults to sys.stdout (this proxy), which tqdm treats
37
+ # as sharing the terminal with a stderr bar, so the bar gets cleared.
38
+ with tqdm.external_write_mode():
39
+ self._target.write(text)
40
+ self._target.flush()
41
+
42
+ def __getattr__(self, name: str) -> Any:
43
+ return getattr(self._target, name)
44
+
45
+
46
+ class TqdmProgressReporter(ProgressReporter):
47
+ """Shows a tqdm progress bar (on stderr by default) with available/error counts.
48
+
49
+ ``disable=None`` lets tqdm turn the bar off automatically when ``file`` is not a TTY.
50
+ While the bar is visible, ``sys.stdout`` is temporarily wrapped so regular prints
51
+ are interleaved cleanly with the bar; it is restored on ``close()``.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ file: TextIO | None = None,
57
+ disable: bool | None = None,
58
+ desc: str = "Checking",
59
+ ) -> None:
60
+ self._file = file
61
+ self._disable = disable
62
+ self._desc = desc
63
+ self._bar: tqdm[Any] | None = None
64
+ self._saved_stdout: TextIO | None = None
65
+ self.available = 0
66
+ self.errors = 0
67
+
68
+ def start(self, total: int | None) -> None:
69
+ self.available = 0
70
+ self.errors = 0
71
+ self._bar = tqdm(
72
+ total=total,
73
+ file=self._file if self._file is not None else sys.stderr,
74
+ disable=self._disable,
75
+ desc=self._desc,
76
+ unit="dom",
77
+ dynamic_ncols=True,
78
+ )
79
+ if not self._bar.disable:
80
+ self._bar.set_postfix(available=0, errors=0, refresh=False)
81
+ self._saved_stdout = sys.stdout
82
+ sys.stdout = _TqdmLineWriter(sys.stdout)
83
+
84
+ def advance(self, result: DomainCheckResult) -> None:
85
+ if result.availability is Availability.AVAILABLE:
86
+ self.available += 1
87
+ elif result.availability is Availability.ERROR:
88
+ self.errors += 1
89
+ if self._bar is None:
90
+ return
91
+ self._bar.set_postfix(available=self.available, errors=self.errors, refresh=False)
92
+ self._bar.update(1)
93
+
94
+ def close(self) -> None:
95
+ if self._saved_stdout is not None:
96
+ sys.stdout.flush()
97
+ sys.stdout = self._saved_stdout
98
+ self._saved_stdout = None
99
+ if self._bar is not None:
100
+ self._bar.close()
101
+ self._bar = None