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,193 @@
1
+ """Worker lanes: check domains in parallel, one at a time per registry host.
2
+
3
+ Every domain is assigned to a *lane* (in production, the registry host that
4
+ answers for its TLD; TLDs that share a host share a lane). A small pool of
5
+ worker threads serves the lanes:
6
+
7
+ * a lane is served by at most one worker at a time, so a host never has more
8
+ than one request in flight from this process;
9
+ * at most ``workers`` lanes are served at once (``--parallel``);
10
+ * a worker takes one domain from a lane, checks it, and hands the lane back,
11
+ so with more lanes than workers every lane gets its turn;
12
+ * within a lane domains are checked in the order they were submitted.
13
+
14
+ Each lane holds at most ``lane_capacity`` waiting domains. ``try_submit``
15
+ returns False instead of blocking when a lane is full, so the caller (the
16
+ main thread) can consume results meanwhile and try again: with a lazy source
17
+ the memory in use is bounded by ``lanes x lane_capacity`` waiting domains
18
+ plus their results, whatever the size of the source.
19
+
20
+ Workers never touch writers, progress bars or caches: every outcome goes to
21
+ a queue that the thread running the use case consumes (``next_outcome``).
22
+ Worker threads are daemons, so an abandoned in-flight request never keeps
23
+ the process alive.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import queue
29
+ import threading
30
+ import time
31
+ from collections import deque
32
+ from collections.abc import Callable, Hashable
33
+ from dataclasses import dataclass
34
+
35
+ from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
36
+
37
+ # How long ``next_outcome`` blocks between checks for Ctrl-C. Waiting on a
38
+ # lock with no timeout cannot be interrupted on every platform (Windows).
39
+ _POLL_SECONDS = 0.25
40
+
41
+ Check = Callable[[DomainHack], DomainCheckResult]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class LaneOutcome:
46
+ """What a worker produced for the domain submitted as number ``index``.
47
+
48
+ ``fatal`` is set (and ``result`` is None) when the check raised a
49
+ ``BaseException`` that is not an ``Exception``, e.g. ``KeyboardInterrupt``:
50
+ the caller re-raises it, and the scheduler stops serving lanes.
51
+ """
52
+
53
+ index: int
54
+ result: DomainCheckResult | None = None
55
+ fatal: BaseException | None = None
56
+
57
+
58
+ def unexpected_error(domain: DomainHack, exc: Exception) -> DomainCheckResult:
59
+ """The ERROR result reported for a check that raised ``exc``."""
60
+ detail = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
61
+ return DomainCheckResult(
62
+ domain=domain,
63
+ availability=Availability.ERROR,
64
+ error_message=f"unexpected error: {detail}",
65
+ )
66
+
67
+
68
+ class LaneScheduler:
69
+ """Runs ``check`` on submitted domains, one lane at a time per worker."""
70
+
71
+ def __init__(self, check: Check, *, workers: int, lane_capacity: int) -> None:
72
+ if workers < 1:
73
+ raise ValueError("workers must be at least 1")
74
+ if lane_capacity < 1:
75
+ raise ValueError("lane_capacity must be at least 1")
76
+ self._check = check
77
+ self._max_workers = workers
78
+ self._capacity = lane_capacity
79
+ self._cond = threading.Condition()
80
+ self._waiting: dict[Hashable, deque[tuple[int, DomainHack]]] = {}
81
+ # Invariant: a lane is in ``_ready`` iff it has waiting domains and is not busy.
82
+ self._ready: deque[Hashable] = deque()
83
+ self._busy: set[Hashable] = set()
84
+ self._closed = False # no more submissions
85
+ self._stopped = False # stop serving lanes (Ctrl-C, fatal error)
86
+ self._threads: list[threading.Thread] = []
87
+ self._outcomes: queue.SimpleQueue[LaneOutcome] = queue.SimpleQueue()
88
+
89
+ @property
90
+ def worker_count(self) -> int:
91
+ """Worker threads started so far (one per new lane, up to ``workers``)."""
92
+ return len(self._threads)
93
+
94
+ def try_submit(self, lane: Hashable, index: int, domain: DomainHack) -> bool:
95
+ """Queue ``domain`` on ``lane``; False (nothing queued) if that lane is full."""
96
+ with self._cond:
97
+ if self._closed or self._stopped:
98
+ raise RuntimeError("scheduler no longer accepts domains")
99
+ waiting = self._waiting.setdefault(lane, deque())
100
+ if len(waiting) >= self._capacity:
101
+ return False
102
+ waiting.append((index, domain))
103
+ if len(waiting) == 1 and lane not in self._busy:
104
+ self._ready.append(lane)
105
+ self._cond.notify()
106
+ start_worker = len(self._threads) < min(self._max_workers, len(self._waiting))
107
+ if start_worker:
108
+ self._start_worker()
109
+ return True
110
+
111
+ def next_outcome(self, timeout: float | None = None) -> LaneOutcome | None:
112
+ """The next finished check, waiting up to ``timeout`` seconds (None: forever).
113
+
114
+ Returns None on timeout. Waits in short slices so Ctrl-C is noticed.
115
+ """
116
+ if timeout is not None:
117
+ try:
118
+ return self._outcomes.get(timeout=timeout)
119
+ except queue.Empty:
120
+ return None
121
+ while True:
122
+ try:
123
+ return self._outcomes.get(timeout=_POLL_SECONDS)
124
+ except queue.Empty:
125
+ continue
126
+
127
+ def close(self) -> None:
128
+ """No more submissions: workers exit once every lane is drained."""
129
+ with self._cond:
130
+ self._closed = True
131
+ self._cond.notify_all()
132
+
133
+ def stop(self, grace: float = 0.0) -> None:
134
+ """Stop serving lanes and wait up to ``grace`` seconds for in-flight checks.
135
+
136
+ Waiting domains are dropped. Checks still running after ``grace`` are
137
+ abandoned (their daemon threads finish or die with the process).
138
+ """
139
+ with self._cond:
140
+ self._stopped = True
141
+ self._cond.notify_all()
142
+ if grace <= 0:
143
+ return
144
+ deadline = time.monotonic() + grace
145
+ for thread in self._threads:
146
+ thread.join(max(deadline - time.monotonic(), 0.0))
147
+
148
+ def _start_worker(self) -> None:
149
+ thread = threading.Thread(
150
+ target=self._work, name=f"domainhack-lane-{len(self._threads) + 1}", daemon=True
151
+ )
152
+ self._threads.append(thread)
153
+ thread.start()
154
+
155
+ def _take(self) -> tuple[Hashable, int, DomainHack] | None:
156
+ with self._cond:
157
+ while not self._ready and not self._stopped and not self._closed:
158
+ self._cond.wait()
159
+ if self._stopped or not self._ready:
160
+ return None
161
+ lane = self._ready.popleft()
162
+ index, domain = self._waiting[lane].popleft()
163
+ self._busy.add(lane)
164
+ return lane, index, domain
165
+
166
+ def _release(self, lane: Hashable, *, stop: bool) -> None:
167
+ with self._cond:
168
+ self._busy.discard(lane)
169
+ if stop:
170
+ self._stopped = True
171
+ self._cond.notify_all()
172
+ elif self._waiting[lane]:
173
+ self._ready.append(lane)
174
+ self._cond.notify()
175
+
176
+ def _work(self) -> None:
177
+ while True:
178
+ taken = self._take()
179
+ if taken is None:
180
+ return
181
+ lane, index, domain = taken
182
+ fatal: BaseException | None = None
183
+ try:
184
+ outcome = LaneOutcome(index, self._check(domain))
185
+ except Exception as exc:
186
+ outcome = LaneOutcome(index, unexpected_error(domain, exc))
187
+ except BaseException as exc: # KeyboardInterrupt, SystemExit: the caller decides
188
+ fatal = exc
189
+ outcome = LaneOutcome(index, fatal=exc)
190
+ self._outcomes.put(outcome)
191
+ self._release(lane, stop=fatal is not None)
192
+ if fatal is not None:
193
+ return
@@ -0,0 +1,106 @@
1
+ """Decide which candidates are checked first, and how many per TLD.
2
+
3
+ Registries throttle (whois.nic.it allowed about 50 queries in a live run), so
4
+ the queries a run gets should go to the best candidates, not to whatever comes
5
+ first alphabetically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Iterable, Iterator, Sequence
11
+ from enum import Enum
12
+
13
+ from domainhack.domain.entities import TLD, DomainHack
14
+
15
+ ScoreKey = tuple[float, ...]
16
+ Scorer = Callable[[DomainHack], ScoreKey]
17
+ """Maps a candidate to a sort key; lower sorts first (checked earlier)."""
18
+
19
+
20
+ class CandidateOrder(str, Enum):
21
+ SCORE = "score" # best candidates first, by ``Scorer``
22
+ ALPHA = "alpha" # alphabetical by domain name
23
+ INPUT = "input" # as produced (word-list order, or range generation order)
24
+
25
+
26
+ def length_score(domain: DomainHack) -> ScoreKey:
27
+ """The default score: shorter SLD first, then shorter full word.
28
+
29
+ This is the hook for smarter ranking: a word-frequency or
30
+ pronounceability scorer only has to return a different key (for example
31
+ ``(-frequency, len(sld))``) and be passed as ``scorer``.
32
+ """
33
+ return (len(domain.sld), len(domain.word))
34
+
35
+
36
+ def _tiebreak(domain: DomainHack) -> tuple[str, str, str]:
37
+ # References to strings the DomainHack already holds: sorting a large
38
+ # list does not build a new name string per candidate.
39
+ return (domain.ascii_sld, domain.tld.suffix, domain.word)
40
+
41
+
42
+ class RankCandidatesUseCase:
43
+ """Orders candidates and caps how many are checked per TLD.
44
+
45
+ * ``SCORE``: by ``scorer`` (default ``length_score``), ties broken
46
+ alphabetically by name, then TLD, then word, so the order is total and
47
+ deterministic whatever the input order.
48
+ * ``ALPHA``: alphabetically by name, then TLD, then word.
49
+ * ``INPUT``: unchanged and lazy, so it can wrap the huge range generator.
50
+
51
+ ``SCORE`` and ``ALPHA`` materialize the candidates (DomainHack objects
52
+ only), which suits bounded word lists, not range mode.
53
+
54
+ ``limit`` keeps at most that many candidates per TLD, counted after
55
+ ordering. When ``tlds`` is given, a lazy input stops being read as soon as
56
+ every one of them has reached the limit.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ order: CandidateOrder = CandidateOrder.SCORE,
62
+ *,
63
+ limit: int | None = None,
64
+ scorer: Scorer = length_score,
65
+ tlds: Sequence[TLD] | None = None,
66
+ ) -> None:
67
+ if limit is not None and limit < 1:
68
+ raise ValueError("limit must be at least 1")
69
+ self._order = order
70
+ self._limit = limit
71
+ self._scorer = scorer
72
+ self._tlds = frozenset(tlds) if tlds is not None else None
73
+
74
+ @property
75
+ def materializes(self) -> bool:
76
+ """True when ``execute`` has to read every candidate before yielding one."""
77
+ return self._order is not CandidateOrder.INPUT
78
+
79
+ def execute(self, candidates: Iterable[DomainHack]) -> Iterator[DomainHack]:
80
+ return self._limited(self._ordered(candidates))
81
+
82
+ def _ordered(self, candidates: Iterable[DomainHack]) -> Iterable[DomainHack]:
83
+ if self._order is CandidateOrder.INPUT:
84
+ return candidates
85
+ if self._order is CandidateOrder.ALPHA:
86
+ return sorted(candidates, key=_tiebreak)
87
+ scorer = self._scorer
88
+ return sorted(candidates, key=lambda d: (scorer(d), _tiebreak(d)))
89
+
90
+ def _limited(self, candidates: Iterable[DomainHack]) -> Iterator[DomainHack]:
91
+ limit = self._limit
92
+ if limit is None:
93
+ yield from candidates
94
+ return
95
+ counts: dict[TLD, int] = {}
96
+ pending = set(self._tlds) if self._tlds is not None else None
97
+ for domain in candidates:
98
+ seen = counts.get(domain.tld, 0)
99
+ if seen >= limit:
100
+ continue
101
+ counts[domain.tld] = seen + 1
102
+ yield domain
103
+ if pending is not None and seen + 1 == limit:
104
+ pending.discard(domain.tld)
105
+ if not pending:
106
+ return