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,79 @@
1
+ import csv
2
+
3
+ from domainhack.adapters._registration import format_utc
4
+ from domainhack.adapters._text_sink import TextSink, TextTarget
5
+ from domainhack.domain.entities import DomainCheckResult
6
+ from domainhack.ports.result_writer import ResultWriter
7
+
8
+ # New columns are appended at the end so existing column positions stay stable.
9
+ CSV_FIELDS = (
10
+ "fqdn",
11
+ "display",
12
+ "word",
13
+ "sld",
14
+ "tld",
15
+ "availability",
16
+ "error_message",
17
+ "statuses",
18
+ "expires_at",
19
+ "registered_at",
20
+ "registrar",
21
+ "nameservers",
22
+ "parked_hint",
23
+ "dns_nameservers",
24
+ "dns_conflict",
25
+ )
26
+ STATUS_SEPARATOR = ";"
27
+ LIST_SEPARATOR = STATUS_SEPARATOR
28
+
29
+
30
+ class CsvResultWriter(ResultWriter):
31
+ """Streams domain check results to a CSV file, one row per result.
32
+
33
+ ``fqdn`` is the ASCII name that was queried (A-label for IDNs); ``display``
34
+ is the name as written (U-label), and equals ``fqdn`` for ASCII names.
35
+ ``statuses`` is the registry status list joined with ``;`` and
36
+ ``expires_at`` an ISO 8601 UTC date-time; both are empty when unknown.
37
+ ``registered_at`` (ISO 8601 UTC), ``registrar``, ``nameservers`` (joined
38
+ with ``;``) and ``parked_hint`` (the parking/aftermarket service the
39
+ nameservers point to, e.g. ``domainrecover``) are registry details of a
40
+ taken name, empty when unknown. ``dns_nameservers`` (joined with ``;``)
41
+ are the NS hosts public DNS returned, empty when DNS was not consulted;
42
+ ``dns_conflict`` is ``true`` when the registry said available but DNS
43
+ shows a delegation, else ``false``.
44
+
45
+ The header is written immediately and each row is flushed as it is written,
46
+ so an interrupted run keeps every result checked so far.
47
+ """
48
+
49
+ def __init__(self, target: TextTarget) -> None:
50
+ self._sink = TextSink(target)
51
+ self._writer = csv.writer(self._sink.stream)
52
+ self._writer.writerow(CSV_FIELDS)
53
+ self._sink.stream.flush()
54
+
55
+ def write_result(self, result: DomainCheckResult) -> None:
56
+ domain = result.domain
57
+ self._writer.writerow(
58
+ (
59
+ domain.fqdn,
60
+ domain.display,
61
+ domain.word,
62
+ domain.sld,
63
+ domain.tld.suffix,
64
+ result.availability.value,
65
+ result.error_message,
66
+ STATUS_SEPARATOR.join(result.statuses),
67
+ format_utc(result.expires_at),
68
+ format_utc(result.registered_at),
69
+ result.registrar,
70
+ LIST_SEPARATOR.join(result.nameservers),
71
+ result.parked_hint,
72
+ LIST_SEPARATOR.join(result.dns.nameservers) if result.dns is not None else "",
73
+ "true" if result.dns_conflict else "false",
74
+ )
75
+ )
76
+ self._sink.stream.flush()
77
+
78
+ def flush(self) -> None:
79
+ self._sink.close()
@@ -0,0 +1,72 @@
1
+ """A ResultWriter decorator that adds DNS evidence before results are written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from concurrent.futures import Future, ThreadPoolExecutor
7
+
8
+ from domainhack.domain.entities import DomainCheckResult
9
+ from domainhack.ports.result_writer import ResultWriter
10
+ from domainhack.usecases.confirm_dns import ConfirmWithDns
11
+
12
+
13
+ class DnsConfirmingWriter(ResultWriter):
14
+ """Confirms each result with DNS (``ConfirmWithDns``), then passes it on.
15
+
16
+ Results stream: each lookup starts as soon as its result arrives, on a
17
+ pool of ``confirm.workers`` threads, and results are handed to ``inner``
18
+ in arrival order as soon as every earlier one is done. ``inner`` is only
19
+ called from the thread that calls ``write_result``/``flush`` (the
20
+ check's main thread), never from the pool.
21
+
22
+ ``flush`` waits for the outstanding lookups. If that wait is interrupted
23
+ (Ctrl-C), the results still pending are written without DNS evidence
24
+ and ``inner`` is flushed anyway, so partial output files are kept.
25
+ ``conflicts`` counts results written with ``dns_conflict``.
26
+ """
27
+
28
+ def __init__(self, inner: ResultWriter, confirm: ConfirmWithDns) -> None:
29
+ self._inner = inner
30
+ self._confirm = confirm
31
+ self._pool = ThreadPoolExecutor(
32
+ max_workers=confirm.workers, thread_name_prefix="dns-confirm"
33
+ )
34
+ self._pending: deque[tuple[DomainCheckResult, Future[DomainCheckResult] | None]] = deque()
35
+ self.conflicts = 0
36
+
37
+ def write_result(self, result: DomainCheckResult) -> None:
38
+ future = (
39
+ self._pool.submit(self._confirm.confirm, result)
40
+ if self._confirm.applies_to(result)
41
+ else None
42
+ )
43
+ self._pending.append((result, future))
44
+ self._emit_ready()
45
+
46
+ def flush(self) -> None:
47
+ try:
48
+ while self._pending:
49
+ original, future = self._pending[0]
50
+ confirmed = future.result() if future is not None else original
51
+ self._pending.popleft()
52
+ self._emit(confirmed)
53
+ finally:
54
+ # Interrupted: keep what was checked, without the missing evidence.
55
+ while self._pending:
56
+ original, future = self._pending.popleft()
57
+ done = future is not None and future.done() and future.exception() is None
58
+ self._emit(future.result() if done and future is not None else original)
59
+ self._pool.shutdown(wait=False, cancel_futures=True)
60
+ self._inner.flush()
61
+
62
+ def _emit_ready(self) -> None:
63
+ while self._pending:
64
+ original, future = self._pending[0]
65
+ if future is not None and not future.done():
66
+ return
67
+ self._pending.popleft()
68
+ self._emit(future.result() if future is not None else original)
69
+
70
+ def _emit(self, result: DomainCheckResult) -> None:
71
+ self.conflicts += result.dns_conflict
72
+ self._inner.write_result(result)
@@ -0,0 +1,120 @@
1
+ """Public DNS evidence for a name, via dnspython (a second opinion, never the verdict).
2
+
3
+ ``DnsPythonLookup.lookup("sumanda.com")`` asks the configured recursive
4
+ resolver (the system's by default) for the name's NS records and, when the
5
+ name exists, whether it has an A or AAAA record:
6
+
7
+ - NXDOMAIN: the name does not exist in DNS -> empty evidence, no error.
8
+ - NS present: the name is delegated, so it is certainly registered. NS hosts
9
+ are lowercase, without the trailing dot, sorted.
10
+ - Timeout, SERVFAIL (every server failed) or any other failure of the NS
11
+ query -> ``error`` is set and the other fields mean nothing.
12
+
13
+ A failed A/AAAA query after a successful NS query only leaves
14
+ ``has_address`` False: the delegation, the part that matters, is known.
15
+ ``lookup`` never raises.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import threading
21
+ from collections.abc import Callable, Iterable
22
+ from typing import Any, Protocol
23
+
24
+ import dns.exception
25
+ import dns.resolver
26
+
27
+ from domainhack.domain.entities import DnsEvidence
28
+ from domainhack.ports.dns_lookup import DnsLookup
29
+
30
+ DEFAULT_DNS_TIMEOUT = 3.0
31
+
32
+
33
+ class Resolver(Protocol):
34
+ """The part of ``dns.resolver.Resolver`` this adapter uses (tests pass a fake)."""
35
+
36
+ def resolve(
37
+ self,
38
+ qname: str,
39
+ rdtype: str,
40
+ *,
41
+ raise_on_no_answer: bool,
42
+ lifetime: float | None,
43
+ search: bool | None,
44
+ ) -> Iterable[Any]: ...
45
+
46
+
47
+ class DnsPythonLookup(DnsLookup):
48
+ """``DnsLookup`` backed by dnspython.
49
+
50
+ ``resolver`` is used as given (shared by every thread); by default each
51
+ thread gets its own ``dns.resolver.Resolver()`` built from the system
52
+ configuration, so parallel callers never share mutable resolver state.
53
+ ``timeout`` bounds each query (NS, A, AAAA) in seconds.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ resolver: Resolver | None = None,
59
+ timeout: float = DEFAULT_DNS_TIMEOUT,
60
+ resolver_factory: Callable[[], Resolver] = dns.resolver.Resolver,
61
+ ) -> None:
62
+ self._shared = resolver
63
+ self._factory = resolver_factory
64
+ self._timeout = timeout
65
+ self._local = threading.local()
66
+
67
+ def _resolver(self) -> Resolver:
68
+ if self._shared is not None:
69
+ return self._shared
70
+ resolver: Resolver | None = getattr(self._local, "resolver", None)
71
+ if resolver is None:
72
+ resolver = self._factory()
73
+ self._local.resolver = resolver
74
+ return resolver
75
+
76
+ def lookup(self, fqdn: str) -> DnsEvidence:
77
+ name = fqdn.strip().rstrip(".").lower() + "."
78
+ try:
79
+ resolver = self._resolver()
80
+ records = self._query(resolver, name, "NS")
81
+ except dns.resolver.NXDOMAIN:
82
+ return DnsEvidence()
83
+ except Exception as exc: # never raise: DNS is only a second opinion
84
+ return DnsEvidence(error=describe_dns_error(exc))
85
+ hosts = (_host(record) for record in records)
86
+ nameservers = tuple(sorted({host for host in hosts if host}))
87
+ return DnsEvidence(nameservers=nameservers, has_address=self._has_address(resolver, name))
88
+
89
+ def _has_address(self, resolver: Resolver, name: str) -> bool:
90
+ for rdtype in ("A", "AAAA"):
91
+ try:
92
+ if any(True for _ in self._query(resolver, name, rdtype)):
93
+ return True
94
+ except Exception: # NXDOMAIN, timeout...: no address known
95
+ return False
96
+ return False
97
+
98
+ def _query(self, resolver: Resolver, name: str, rdtype: str) -> list[Any]:
99
+ answer = resolver.resolve(
100
+ name, rdtype, raise_on_no_answer=False, lifetime=self._timeout, search=False
101
+ )
102
+ return list(answer)
103
+
104
+
105
+ def _host(record: Any) -> str:
106
+ """An NS record's target, lowercase, without the trailing dot."""
107
+ target = getattr(record, "target", record)
108
+ return str(target).strip().rstrip(".").lower()
109
+
110
+
111
+ def describe_dns_error(exc: BaseException) -> str:
112
+ """A short, stable description of a failed DNS query."""
113
+ if isinstance(exc, dns.exception.Timeout):
114
+ return "DNS timeout"
115
+ if isinstance(exc, dns.resolver.NoNameservers):
116
+ return "DNS SERVFAIL (no nameserver answered)"
117
+ if isinstance(exc, dns.resolver.NoResolverConfiguration):
118
+ return "DNS not configured (no resolver found)"
119
+ detail = str(exc) or type(exc).__name__
120
+ return f"DNS error: {detail}"
@@ -0,0 +1,51 @@
1
+ from collections.abc import Iterator
2
+ from pathlib import Path
3
+
4
+ from domainhack.ports.word_source import WordSource
5
+
6
+ DEFAULT_ENCODING = "utf-8"
7
+
8
+
9
+ class WordListError(Exception):
10
+ """The word list cannot be read; the message is meant for the end user."""
11
+
12
+
13
+ class FileWordSource(WordSource):
14
+ """Reads words line-by-line from a text file.
15
+
16
+ Decoding is strict: a byte that is not valid in ``encoding`` raises
17
+ ``WordListError`` instead of being replaced, because a mis-decoded word
18
+ (e.g. ``ma\\ufffdana``) would turn into a wrong domain to query.
19
+ """
20
+
21
+ def __init__(self, file_path: Path, encoding: str = DEFAULT_ENCODING) -> None:
22
+ self._file_path = file_path
23
+ self._encoding = encoding
24
+
25
+ def check_readable(self) -> None:
26
+ """Fail fast (``WordListError``) if the file cannot be opened at all."""
27
+ try:
28
+ with open(self._file_path, "rb"):
29
+ pass
30
+ except OSError as exc:
31
+ raise self._os_error(exc) from exc
32
+
33
+ def _os_error(self, exc: OSError) -> WordListError:
34
+ reason = exc.strerror or str(exc)
35
+ return WordListError(f"cannot read word list '{self._file_path}': {reason}")
36
+
37
+ def words(self) -> Iterator[str]:
38
+ try:
39
+ with open(self._file_path, encoding=self._encoding) as f:
40
+ for line in f:
41
+ word = line.strip()
42
+ if word:
43
+ yield word
44
+ except UnicodeDecodeError as exc:
45
+ bad = exc.object[exc.start]
46
+ raise WordListError(
47
+ f"cannot read word list '{self._file_path}': not valid {self._encoding} "
48
+ f"(byte {bad:#04x}); convert it to UTF-8 or pass --encoding (e.g. latin-1)"
49
+ ) from exc
50
+ except OSError as exc:
51
+ raise self._os_error(exc) from exc
@@ -0,0 +1,110 @@
1
+ """The public suffixes a name can be registered under, from bundled package data.
2
+
3
+ Two files ship with the package:
4
+
5
+ - ``data/iana_tlds.txt``: a verbatim snapshot of IANA's
6
+ https://data.iana.org/TLD/tlds-alpha-by-domain.txt (its first line, a
7
+ ``# Version ...`` comment, says when it was taken). IDN TLDs appear in
8
+ their ``xn--`` form and count as known.
9
+ - ``data/second_level.json``: a curated list of common registrable
10
+ second-level suffixes (``com.ar``, ``co.uk``...), each checked against the
11
+ ICANN section of the Public Suffix List (the file cites the version).
12
+
13
+ The list is snapshot-only on purpose: IANA adds TLDs a few times a year at
14
+ most, and a fixed list keeps hack suggestions deterministic and offline.
15
+ Refresh it by downloading the IANA file over ``data/iana_tlds.txt``
16
+ unchanged. Whether a suffix can actually be *checked* is a different
17
+ question, answered by ``registrar_catalog.supported_tlds``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from collections.abc import Iterable
24
+ from importlib import resources
25
+
26
+ from domainhack.ports.known_tlds import KnownTlds
27
+
28
+ IANA_SNAPSHOT = "data/iana_tlds.txt"
29
+ SECOND_LEVEL_FILE = "data/second_level.json"
30
+
31
+
32
+ def _read_data(name: str) -> str:
33
+ return resources.files("domainhack").joinpath(name).read_text(encoding="utf-8")
34
+
35
+
36
+ def parse_iana_tlds(text: str) -> tuple[str, list[str]]:
37
+ """Parse the IANA file into its ``# Version ...`` header (without ``#``) and lowercase TLDs."""
38
+ version = ""
39
+ tlds: list[str] = []
40
+ for raw in text.splitlines():
41
+ line = raw.strip()
42
+ if not line:
43
+ continue
44
+ if line.startswith("#"):
45
+ if not version:
46
+ version = line.lstrip("#").strip()
47
+ continue
48
+ tlds.append(line.lower())
49
+ return version, tlds
50
+
51
+
52
+ def load_iana_snapshot() -> tuple[str, list[str]]:
53
+ """The bundled IANA list: (version header, lowercase TLDs)."""
54
+ return parse_iana_tlds(_read_data(IANA_SNAPSHOT))
55
+
56
+
57
+ def load_second_level() -> list[str]:
58
+ """The curated second-level suffixes, lowercase, in file order (LATAM first)."""
59
+ data = json.loads(_read_data(SECOND_LEVEL_FILE))
60
+ return [s.lower() for s in data["suffixes"]]
61
+
62
+
63
+ class IanaTldList(KnownTlds):
64
+ """Known public suffixes: every IANA TLD plus curated second-level suffixes.
65
+
66
+ ``tlds`` and ``second_level`` replace the bundled files (tests pass small
67
+ lists). Matching is case-insensitive and ignores a leading dot.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ tlds: Iterable[str] | None = None,
73
+ second_level: Iterable[str] | None = None,
74
+ ) -> None:
75
+ if tlds is None:
76
+ self.version, tld_list = load_iana_snapshot()
77
+ else:
78
+ self.version, tld_list = "", list(tlds)
79
+ levels = load_second_level() if second_level is None else list(second_level)
80
+ self._tlds = frozenset(_normalize(t) for t in tld_list)
81
+ self._second_level = tuple(dict.fromkeys(_normalize(s) for s in levels))
82
+ self._all = self._tlds | frozenset(self._second_level)
83
+ # "comar" -> ["com.ar"]: suffixes as they appear at the end of a word.
84
+ self._by_joined: dict[str, list[str]] = {}
85
+ for suffix in sorted(self._all):
86
+ self._by_joined.setdefault(suffix.replace(".", ""), []).append(suffix)
87
+
88
+ @property
89
+ def top_level_domains(self) -> frozenset[str]:
90
+ return self._tlds
91
+
92
+ @property
93
+ def second_level_suffixes(self) -> tuple[str, ...]:
94
+ """The curated second-level suffixes, in file order."""
95
+ return self._second_level
96
+
97
+ def is_known(self, suffix: str) -> bool:
98
+ return _normalize(suffix) in self._all
99
+
100
+ def suffixes_of(self, name: str) -> list[str]:
101
+ word = name.strip().lower()
102
+ found: list[str] = []
103
+ # Longest ending first; position 0 would leave an empty SLD.
104
+ for start in range(1, len(word)):
105
+ found.extend(self._by_joined.get(word[start:], ()))
106
+ return found
107
+
108
+
109
+ def _normalize(suffix: str) -> str:
110
+ return suffix.strip().lower().lstrip(".")
@@ -0,0 +1,51 @@
1
+ import json
2
+
3
+ from domainhack.adapters._registration import format_utc
4
+ from domainhack.adapters._text_sink import TextSink, TextTarget
5
+ from domainhack.domain.entities import DomainCheckResult
6
+ from domainhack.ports.result_writer import ResultWriter
7
+
8
+
9
+ class JsonResultWriter(ResultWriter):
10
+ """Streams domain check results as JSON Lines (one JSON object per line).
11
+
12
+ Each line is flushed as it is written, so an interrupted run leaves a valid
13
+ file containing every result checked so far. ``fqdn`` is the queried ASCII
14
+ name (A-label for IDNs) and ``display`` the name as written. ``statuses``
15
+ is a list of registry statuses and ``expires_at`` an ISO 8601 UTC
16
+ date-time or null. ``registered_at`` (ISO 8601 UTC or null),
17
+ ``registrar``, ``nameservers`` (a list) and ``parked_hint`` (the
18
+ parking/aftermarket service, e.g. ``"domainrecover"``) are registry
19
+ details of a taken name, empty when unknown. ``dns_nameservers`` is the
20
+ list of NS hosts public DNS returned (empty when DNS was not consulted)
21
+ and ``dns_conflict`` is true when the registry said available but DNS
22
+ shows a delegation.
23
+ """
24
+
25
+ def __init__(self, target: TextTarget) -> None:
26
+ self._sink = TextSink(target)
27
+
28
+ def write_result(self, result: DomainCheckResult) -> None:
29
+ domain = result.domain
30
+ record = {
31
+ "fqdn": domain.fqdn,
32
+ "display": domain.display,
33
+ "word": domain.word,
34
+ "sld": domain.sld,
35
+ "tld": domain.tld.suffix,
36
+ "availability": result.availability.value,
37
+ "error_message": result.error_message,
38
+ "statuses": list(result.statuses),
39
+ "expires_at": format_utc(result.expires_at) or None,
40
+ "registered_at": format_utc(result.registered_at) or None,
41
+ "registrar": result.registrar,
42
+ "nameservers": list(result.nameservers),
43
+ "parked_hint": result.parked_hint,
44
+ "dns_nameservers": list(result.dns.nameservers) if result.dns is not None else [],
45
+ "dns_conflict": result.dns_conflict,
46
+ }
47
+ self._sink.stream.write(json.dumps(record, ensure_ascii=False) + "\n")
48
+ self._sink.stream.flush()
49
+
50
+ def flush(self) -> None:
51
+ self._sink.close()
@@ -0,0 +1,46 @@
1
+ """Where a registrar client sends a TLD's queries, and how fast (for run estimates)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Hashable
6
+
7
+ import httpx
8
+
9
+ from domainhack.adapters.rdap_registrar import RdapRegistrarClient
10
+ from domainhack.adapters.whois_registrar import WhoisRegistrarClient
11
+ from domainhack.domain.entities import TLD
12
+ from domainhack.ports.registrar import RegistrarClient
13
+ from domainhack.usecases.estimate_run import Pacing
14
+
15
+
16
+ def pacing_for(client: RegistrarClient | None, tld: TLD, delay: float) -> Pacing:
17
+ """The host serving ``tld`` through ``client`` and its base query interval.
18
+
19
+ RDAP clients are paced at ``delay``; WHOIS servers at max(``delay``, the
20
+ server's ``min_interval``). An unknown client is assumed to be on a host
21
+ of its own, paced at ``delay``.
22
+ """
23
+ if isinstance(client, RdapRegistrarClient):
24
+ return Pacing(httpx.URL(client.base_url).host, delay)
25
+ if isinstance(client, WhoisRegistrarClient):
26
+ server = client.server_for(tld.suffix)
27
+ if server is not None:
28
+ return Pacing(server.host, max(delay, server.min_interval))
29
+ return Pacing(f".{tld.suffix}", delay)
30
+
31
+
32
+ def lane_for(client: RegistrarClient | None, tld: TLD) -> Hashable:
33
+ """The parallel-check lane for ``tld``: domains in one lane never run concurrently.
34
+
35
+ RDAP and WHOIS clients get the registry host they query, so TLDs served
36
+ by the same host (.io/.sh/.ac/.me) share a lane and that host never has
37
+ more than one request in flight. Any other client gets a lane of its own
38
+ per instance: nothing is known about its host or its thread-safety, so
39
+ it is never called concurrently. Unsupported TLDs (no client) share a
40
+ lane; the router answers them without network access.
41
+ """
42
+ if isinstance(client, (RdapRegistrarClient, WhoisRegistrarClient)):
43
+ pacing = pacing_for(client, tld, 0.0)
44
+ if not pacing.host.startswith("."):
45
+ return pacing.host
46
+ return ("client", id(client)) if client is not None else ("unsupported",)