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,287 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Hashable, Iterable
4
+ from dataclasses import dataclass, field
5
+
6
+ from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
7
+ from domainhack.ports.progress import NullProgressReporter, ProgressReporter
8
+ from domainhack.ports.registrar import RegistrarClient
9
+ from domainhack.ports.result_cache import ResultCache
10
+ from domainhack.ports.result_writer import ResultWriter
11
+ from domainhack.usecases.lanes import LaneOutcome, LaneScheduler
12
+
13
+ LaneKey = Callable[[DomainHack], Hashable]
14
+ """Maps a domain to its lane: domains with equal keys are never checked concurrently.
15
+
16
+ In production the key is the registry host serving the domain's TLD.
17
+ """
18
+
19
+ DEFAULT_PARALLEL = 4
20
+ # Domains waiting per lane. With a lazy source (range mode) at most
21
+ # lanes x LANE_CAPACITY domains (plus their results) are held in memory.
22
+ LANE_CAPACITY = 16
23
+ # After Ctrl-C, how long in-flight checks may take to finish (and be written).
24
+ SHUTDOWN_GRACE_SECONDS = 1.0
25
+
26
+
27
+ def _one_lane(domain: DomainHack) -> Hashable:
28
+ return None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TldTally:
33
+ """How many domains under one TLD (suffix) were checked, and how many errored."""
34
+
35
+ suffix: str
36
+ checked: int = 0
37
+ errors: int = 0
38
+
39
+ @property
40
+ def unreachable(self) -> bool:
41
+ """Every check errored: the registry did not answer (timeouts, open circuit...)."""
42
+ return self.checked > 0 and self.errors == self.checked
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CheckSummary:
47
+ """Outcome of a run: how many domains were checked and how they came out.
48
+
49
+ ``tlds`` breaks the checks down per TLD, in first-seen order.
50
+ """
51
+
52
+ available: int = 0
53
+ taken: int = 0
54
+ errors: int = 0
55
+ interrupted: bool = False
56
+ dropping: int = 0 # TAKEN names in redemption or pending delete (included in ``taken``)
57
+ # A breakdown of the counts above, so not part of equality.
58
+ tlds: tuple[TldTally, ...] = field(default=(), compare=False)
59
+
60
+ @property
61
+ def checked(self) -> int:
62
+ return self.available + self.taken + self.errors
63
+
64
+ @property
65
+ def tlds_with_errors(self) -> tuple[TldTally, ...]:
66
+ """TLDs with at least one ERROR, unreachable ones first (then first-seen order)."""
67
+ failed = [t for t in self.tlds if t.errors]
68
+ return tuple(sorted(failed, key=lambda t: not t.unreachable))
69
+
70
+
71
+ @dataclass
72
+ class _Tally:
73
+ counts: dict[Availability, int] = field(default_factory=lambda: dict.fromkeys(Availability, 0))
74
+ dropping: int = 0
75
+ # suffix -> [checked, errors], in first-seen order
76
+ by_tld: dict[str, list[int]] = field(default_factory=dict)
77
+
78
+ def add(self, result: DomainCheckResult) -> None:
79
+ self.counts[result.availability] += 1
80
+ self.dropping += result.is_dropping
81
+ tld = self.by_tld.setdefault(result.domain.tld.suffix, [0, 0])
82
+ tld[0] += 1
83
+ tld[1] += result.availability is Availability.ERROR
84
+
85
+ def summary(self, interrupted: bool) -> CheckSummary:
86
+ return CheckSummary(
87
+ available=self.counts[Availability.AVAILABLE],
88
+ taken=self.counts[Availability.TAKEN],
89
+ errors=self.counts[Availability.ERROR],
90
+ interrupted=interrupted,
91
+ dropping=self.dropping,
92
+ tlds=tuple(TldTally(s, c, e) for s, (c, e) in self.by_tld.items()),
93
+ )
94
+
95
+
96
+ class CheckDomainsUseCase:
97
+ """Checks availability of domain hacks and writes results.
98
+
99
+ With ``parallel=1`` (the default) domains are checked one after another,
100
+ in input order. With ``parallel=N > 1`` they are checked by up to N
101
+ worker threads, one lane per ``lane_key`` (one registry host): domains
102
+ in the same lane are checked one at a time and in input order, different
103
+ lanes run concurrently. Results are then written in *completion* order,
104
+ unless ``keep_order`` buffers them to restore input order.
105
+
106
+ Only the thread calling ``execute`` touches the writer, the progress
107
+ reporter and the ``cache``. Cache hits are answered on that thread
108
+ before dispatch, so they never occupy a lane; live results are stored
109
+ there as they come back.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ registrar: RegistrarClient,
115
+ writer: ResultWriter,
116
+ progress: ProgressReporter | None = None,
117
+ *,
118
+ cache: ResultCache | None = None,
119
+ parallel: int = 1,
120
+ lane_key: LaneKey = _one_lane,
121
+ keep_order: bool = False,
122
+ lane_capacity: int = LANE_CAPACITY,
123
+ shutdown_grace: float | None = None,
124
+ ) -> None:
125
+ if parallel < 1:
126
+ raise ValueError("parallel must be at least 1")
127
+ self._registrar = registrar
128
+ self._writer = writer
129
+ self._progress = progress if progress is not None else NullProgressReporter()
130
+ self._cache = cache
131
+ self._parallel = parallel
132
+ self._lane_key = lane_key
133
+ self._keep_order = keep_order
134
+ self._lane_capacity = lane_capacity
135
+ self._shutdown_grace = (
136
+ shutdown_grace if shutdown_grace is not None else SHUTDOWN_GRACE_SECONDS
137
+ )
138
+
139
+ def execute(self, domains: Iterable[DomainHack], total: int | None = None) -> CheckSummary:
140
+ """Check every domain and return a summary.
141
+
142
+ A ``KeyboardInterrupt`` stops the run cleanly: the writers are flushed
143
+ (so partial output files are kept) and the summary has
144
+ ``interrupted=True``. In parallel mode checks already in flight get
145
+ ``shutdown_grace`` seconds to finish (and be written); the rest are
146
+ abandoned. Any other exception propagates after the flush. In
147
+ parallel mode an exception raised by a single check is reported as
148
+ an ERROR result for that domain instead.
149
+ """
150
+ tally = _Tally()
151
+ interrupted = False
152
+ try:
153
+ self._progress.start(total)
154
+ try:
155
+ if self._parallel == 1:
156
+ self._run_sequential(domains, tally)
157
+ else:
158
+ _ParallelRun(self, tally).run(domains)
159
+ except KeyboardInterrupt:
160
+ interrupted = True
161
+ finally:
162
+ # Close the bar first so anything printed afterwards appears below it.
163
+ try:
164
+ self._progress.close()
165
+ finally:
166
+ self._writer.flush()
167
+ return tally.summary(interrupted)
168
+
169
+ def _emit(self, result: DomainCheckResult, tally: _Tally) -> None:
170
+ self._writer.write_result(result)
171
+ tally.add(result)
172
+ self._progress.advance(result)
173
+
174
+ def _lookup(self, domain: DomainHack) -> DomainCheckResult | None:
175
+ return self._cache.lookup(domain) if self._cache is not None else None
176
+
177
+ def _store(self, result: DomainCheckResult) -> None:
178
+ if self._cache is not None:
179
+ self._cache.store(result)
180
+
181
+ def _run_sequential(self, domains: Iterable[DomainHack], tally: _Tally) -> None:
182
+ for domain in domains:
183
+ result = self._lookup(domain)
184
+ if result is None:
185
+ result = self._registrar.check_availability(domain)
186
+ self._store(result)
187
+ self._emit(result, tally)
188
+
189
+
190
+ class _ParallelRun:
191
+ """One parallel ``execute``: feeds the lanes and consumes their results.
192
+
193
+ Runs entirely on the calling thread; the workers live in ``LaneScheduler``.
194
+ """
195
+
196
+ def __init__(self, use_case: CheckDomainsUseCase, tally: _Tally) -> None:
197
+ self._uc = use_case
198
+ self._tally = tally
199
+ self._scheduler = LaneScheduler(
200
+ use_case._registrar.check_availability,
201
+ workers=use_case._parallel,
202
+ lane_capacity=use_case._lane_capacity,
203
+ )
204
+ self._outstanding = 0 # submitted, outcome not consumed yet
205
+ # keep_order: results waiting for an earlier one, by input index.
206
+ self._held: dict[int, DomainCheckResult] = {}
207
+ self._next_index = 0
208
+ # Max results held for keep_order before feeding pauses (memory bound).
209
+ self._max_held = max(use_case._lane_capacity * use_case._parallel, 1) * 4
210
+
211
+ def run(self, domains: Iterable[DomainHack]) -> None:
212
+ try:
213
+ for index, domain in enumerate(domains):
214
+ self._drain(block=False)
215
+ cached = self._uc._lookup(domain)
216
+ if cached is not None:
217
+ self._deliver(index, cached)
218
+ else:
219
+ self._submit(index, domain)
220
+ # keep_order: hold at most ~max_held results behind a slow check.
221
+ while len(self._held) > self._max_held:
222
+ self._drain(block=True)
223
+ self._scheduler.close()
224
+ while self._outstanding:
225
+ self._drain(block=True)
226
+ except KeyboardInterrupt:
227
+ # Stop dispatching; keep whatever finishes within the grace period.
228
+ self._scheduler.stop(self._uc._shutdown_grace)
229
+ self._drain_finished()
230
+ self._release_held()
231
+ raise
232
+ finally:
233
+ self._scheduler.stop()
234
+
235
+ def _submit(self, index: int, domain: DomainHack) -> None:
236
+ lane = self._uc._lane_key(domain)
237
+ while not self._scheduler.try_submit(lane, index, domain):
238
+ # That lane is full: handle a result (from any lane), then retry.
239
+ # The full lane is busy or next in line, so a result always comes.
240
+ self._drain(block=True)
241
+ self._outstanding += 1
242
+
243
+ def _drain(self, *, block: bool) -> None:
244
+ """Handle one outcome when ``block``; otherwise every outcome already available."""
245
+ if block:
246
+ outcome = self._scheduler.next_outcome()
247
+ assert outcome is not None
248
+ self._handle(outcome)
249
+ return
250
+ while self._outstanding:
251
+ outcome = self._scheduler.next_outcome(timeout=0)
252
+ if outcome is None:
253
+ return
254
+ self._handle(outcome)
255
+
256
+ def _drain_finished(self) -> None:
257
+ """After an interrupt: write results that are ready, ignore failures."""
258
+ while self._outstanding:
259
+ outcome = self._scheduler.next_outcome(timeout=0)
260
+ if outcome is None:
261
+ return
262
+ self._outstanding -= 1
263
+ if outcome.result is not None:
264
+ self._uc._store(outcome.result)
265
+ self._deliver(outcome.index, outcome.result)
266
+
267
+ def _handle(self, outcome: LaneOutcome) -> None:
268
+ self._outstanding -= 1
269
+ if outcome.fatal is not None:
270
+ raise outcome.fatal
271
+ assert outcome.result is not None
272
+ self._uc._store(outcome.result)
273
+ self._deliver(outcome.index, outcome.result)
274
+
275
+ def _deliver(self, index: int, result: DomainCheckResult) -> None:
276
+ if not self._uc._keep_order:
277
+ self._uc._emit(result, self._tally)
278
+ return
279
+ self._held[index] = result
280
+ while self._next_index in self._held:
281
+ self._uc._emit(self._held.pop(self._next_index), self._tally)
282
+ self._next_index += 1
283
+
284
+ def _release_held(self) -> None:
285
+ """Write results still held for ordering (after a gap left by an interrupt)."""
286
+ for index in sorted(self._held):
287
+ self._uc._emit(self._held.pop(index), self._tally)
@@ -0,0 +1,64 @@
1
+ """Second opinion from public DNS on registry answers.
2
+
3
+ The registry (RDAP/WHOIS) stays the source of truth; DNS only adds
4
+ evidence. An AVAILABLE name that is delegated in DNS is a conflict (see
5
+ ``DomainCheckResult.dns_conflict``): something is wrong (a stale or broken
6
+ registry answer, a reserved name...), so it must not be reported as free.
7
+ A TAKEN name without NS records is registered but not in use.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ from collections.abc import Iterable
14
+ from concurrent.futures import ThreadPoolExecutor
15
+
16
+ from domainhack.domain.entities import Availability, DnsEvidence, DomainCheckResult
17
+ from domainhack.domain.parking import parking_hint_for
18
+ from domainhack.ports.dns_lookup import DnsLookup
19
+
20
+ DEFAULT_DNS_WORKERS = 8
21
+
22
+ _CONFIRMED = frozenset({Availability.AVAILABLE, Availability.TAKEN})
23
+
24
+
25
+ class ConfirmWithDns:
26
+ """Fills ``DomainCheckResult.dns`` for AVAILABLE and TAKEN results.
27
+
28
+ ERROR results pass through unchanged (there is no registry answer to
29
+ confirm). Lookups run on a pool of at most ``workers`` threads, so the
30
+ ``lookup`` must be thread-safe.
31
+ """
32
+
33
+ def __init__(self, lookup: DnsLookup, workers: int = DEFAULT_DNS_WORKERS) -> None:
34
+ if workers < 1:
35
+ raise ValueError("workers must be at least 1")
36
+ self._lookup = lookup
37
+ self.workers = workers
38
+
39
+ @staticmethod
40
+ def applies_to(result: DomainCheckResult) -> bool:
41
+ """True for results whose registry answer DNS can confirm (AVAILABLE, TAKEN)."""
42
+ return result.availability in _CONFIRMED
43
+
44
+ def confirm(self, result: DomainCheckResult) -> DomainCheckResult:
45
+ """A copy of ``result`` with ``dns`` filled; ``result`` itself if it is an ERROR."""
46
+ if not self.applies_to(result):
47
+ return result
48
+ try:
49
+ evidence = self._lookup.lookup(result.domain.fqdn)
50
+ except Exception as exc: # the port says never; a broken adapter must not end the run
51
+ evidence = DnsEvidence(error=f"DNS lookup failed: {exc or type(exc).__name__}")
52
+ parked = result.parked_hint
53
+ if not parked and result.availability is Availability.TAKEN and evidence.is_delegated:
54
+ # The registry gave no nameservers (e.g. WHOIS TLDs): classify DNS's instead.
55
+ parked = parking_hint_for(evidence.nameservers)
56
+ return dataclasses.replace(result, dns=evidence, parked_hint=parked)
57
+
58
+ def apply(self, results: Iterable[DomainCheckResult]) -> list[DomainCheckResult]:
59
+ """Confirm every result, concurrently; the output keeps the input order."""
60
+ items = list(results)
61
+ if not any(self.applies_to(r) for r in items):
62
+ return items
63
+ with ThreadPoolExecutor(max_workers=self.workers, thread_name_prefix="dns-confirm") as pool:
64
+ return list(pool.map(self.confirm, items))
@@ -0,0 +1,80 @@
1
+ """Estimate how many queries a run sends and how long it takes, per host."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+
8
+ from domainhack.domain.entities import TLD
9
+
10
+ # Above this many queries a brute-force run needs an explicit --yes: registry
11
+ # terms of use ask clients not to bulk-query, and 10,000 queries is ~3 h at 1 query/s.
12
+ GUARDRAIL_MAX_QUERIES = 10_000
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Pacing:
17
+ """Where a TLD's queries go and the minimum spacing between them there."""
18
+
19
+ host: str
20
+ interval: float
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class HostLoad:
25
+ host: str
26
+ queries: int
27
+ interval: float
28
+
29
+ @property
30
+ def seconds(self) -> float:
31
+ return self.queries * self.interval
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class RunEstimate:
36
+ hosts: tuple[HostLoad, ...]
37
+
38
+ @property
39
+ def queries(self) -> int:
40
+ return sum(h.queries for h in self.hosts)
41
+
42
+ @property
43
+ def seconds(self) -> float:
44
+ """Lower bound on the run time: the busiest host's queries x its interval.
45
+
46
+ Hosts are paced independently, so requests to different hosts do not
47
+ wait for each other; the slowest host sets the pace. Network latency
48
+ and adaptive slow-downs only add to this.
49
+ """
50
+ return max((h.seconds for h in self.hosts), default=0.0)
51
+
52
+ def exceeds(self, threshold: int = GUARDRAIL_MAX_QUERIES) -> bool:
53
+ return self.queries > threshold
54
+
55
+
56
+ def estimate_run(queries_by_tld: Mapping[TLD, int], pacing: Callable[[TLD], Pacing]) -> RunEstimate:
57
+ """Group per-TLD query counts by host (TLDs can share one, e.g. .io/.sh)."""
58
+ loads: dict[str, tuple[int, float]] = {}
59
+ for tld, queries in queries_by_tld.items():
60
+ p = pacing(tld)
61
+ count, interval = loads.get(p.host, (0, 0.0))
62
+ loads[p.host] = (count + queries, max(interval, p.interval))
63
+ return RunEstimate(
64
+ tuple(HostLoad(host, count, interval) for host, (count, interval) in loads.items())
65
+ )
66
+
67
+
68
+ def format_duration(seconds: float) -> str:
69
+ """``"45 s"``, ``"12 min"``, ``"5 h 4 min"``, ``"5 d 12 h"``."""
70
+ total = round(seconds)
71
+ if total < 60:
72
+ return f"{total} s"
73
+ minutes, _ = divmod(total, 60)
74
+ if minutes < 60:
75
+ return f"{minutes} min"
76
+ hours, minutes = divmod(minutes, 60)
77
+ if hours < 24:
78
+ return f"{hours} h {minutes} min"
79
+ days, hours = divmod(hours, 24)
80
+ return f"{days} d {hours} h"
@@ -0,0 +1,37 @@
1
+ from collections.abc import Iterator, Sequence
2
+
3
+ from domainhack.domain.entities import TLD, DomainHack, InvalidLabelError
4
+ from domainhack.ports.word_source import WordSource
5
+
6
+
7
+ class FilterWordsUseCase:
8
+ """Filters words from a source that form valid domain hacks for one or more TLDs.
9
+
10
+ A word may match several TLDs (e.g. "testing" -> "testi.ng" and "test.ing");
11
+ one DomainHack is yielded per match, in word order and then in TLD order.
12
+
13
+ Matches whose SLD is not a valid label for that TLD (``can't`` -> ``can't.to``,
14
+ an IDN under a TLD without IDN support, too short for ``.it``...) are not
15
+ yielded; they are counted in ``skipped`` so callers can report them.
16
+ """
17
+
18
+ def __init__(
19
+ self, word_source: WordSource, tld: TLD | Sequence[TLD], min_length: int = 0
20
+ ) -> None:
21
+ self._word_source = word_source
22
+ self._tlds: tuple[TLD, ...] = (tld,) if isinstance(tld, TLD) else tuple(tld)
23
+ self._min_length = min_length
24
+ self.skipped = 0
25
+
26
+ def execute(self) -> Iterator[DomainHack]:
27
+ for word in self._word_source.words():
28
+ if self._min_length and len(word) < self._min_length:
29
+ continue
30
+ for tld in self._tlds:
31
+ try:
32
+ hack = DomainHack.from_word(word, tld)
33
+ except InvalidLabelError:
34
+ self.skipped += 1
35
+ continue
36
+ if hack is not None:
37
+ yield hack
@@ -0,0 +1,107 @@
1
+ import itertools
2
+ import string
3
+ from collections.abc import Iterator, Sequence
4
+
5
+ from domainhack.domain.entities import TLD, DomainHack, InvalidLabelError
6
+ from domainhack.domain.label_rules import label_rule_for
7
+ from domainhack.ports.word_source import WordSource
8
+
9
+ MAX_RANGE_LENGTH = 6
10
+ _ALPHABET = string.ascii_lowercase
11
+ _BASE = len(_ALPHABET)
12
+
13
+
14
+ class RangeWordSource(WordSource):
15
+ """Generates all lowercase letter combinations up to a given length."""
16
+
17
+ def __init__(self, max_length: int, end_at: str | None = None) -> None:
18
+ if max_length < 1:
19
+ raise ValueError("max_length must be >= 1")
20
+ if max_length > MAX_RANGE_LENGTH:
21
+ raise ValueError(f"max_length must be <= {MAX_RANGE_LENGTH}")
22
+ self._max_length = max_length
23
+ self._end_at = end_at
24
+
25
+ def words(self) -> Iterator[str]:
26
+ for length in range(1, self._max_length + 1):
27
+ for combo in itertools.product(_ALPHABET, repeat=length):
28
+ word = "".join(combo)
29
+ yield word
30
+ if self._end_at and word == self._end_at:
31
+ return
32
+
33
+ def total(self) -> int:
34
+ """Exact number of words ``words()`` will yield, without generating them.
35
+
36
+ If ``end_at`` can never be produced (wrong length, non-lowercase chars), the
37
+ generator runs to exhaustion, so the full count is returned.
38
+ """
39
+ return self.count()
40
+
41
+ def count(self, min_length: int = 1, prefix: str = "") -> int:
42
+ """How many words ``words()`` yields with ``len >= min_length`` and ``prefix``.
43
+
44
+ Computed arithmetically: words come out shortest first and, within one
45
+ length, in lexicographic order, so ``end_at`` cuts at a known point.
46
+ """
47
+ end = self._end_at
48
+ if not end or len(end) > self._max_length or any(c not in _ALPHABET for c in end):
49
+ end = None
50
+ count = 0
51
+ for length in range(max(min_length, len(prefix), 1), self._max_length + 1):
52
+ free = length - len(prefix)
53
+ if end is None or length < len(end):
54
+ count += _BASE**free
55
+ elif length == len(end):
56
+ head = end[: len(prefix)]
57
+ if head > prefix:
58
+ count += _BASE**free
59
+ elif head == prefix:
60
+ offset = 0
61
+ for char in end[len(prefix) :]:
62
+ offset = offset * _BASE + _ALPHABET.index(char)
63
+ count += offset + 1
64
+ else:
65
+ break
66
+ return count
67
+
68
+
69
+ class RangeCandidatesUseCase:
70
+ """Brute-force candidates: every generated SLD under every TLD, SLD-major.
71
+
72
+ Labels the TLD's registry would refuse (see ``label_rules``, e.g. names
73
+ shorter than 3 characters under ``.it``) are skipped and counted in
74
+ ``skipped`` instead of being yielded, so they never reach a registrar.
75
+ """
76
+
77
+ def __init__(self, source: RangeWordSource, tlds: TLD | Sequence[TLD]) -> None:
78
+ self._source = source
79
+ self._tlds: tuple[TLD, ...] = (tlds,) if isinstance(tlds, TLD) else tuple(tlds)
80
+ self.skipped = 0
81
+
82
+ def execute(self) -> Iterator[DomainHack]:
83
+ for sld in self._source.words():
84
+ for tld in self._tlds:
85
+ try:
86
+ yield DomainHack.from_sld(sld, tld)
87
+ except InvalidLabelError:
88
+ self.skipped += 1
89
+
90
+ def total(self) -> int:
91
+ """Exact number of candidates ``execute()`` yields."""
92
+ return sum(self.totals_by_tld().values())
93
+
94
+ def totals_by_tld(self) -> dict[TLD, int]:
95
+ """Exact number of candidates ``execute()`` yields for each TLD.
96
+
97
+ Generated SLDs are 1-6 lowercase ASCII letters, so only the rules'
98
+ ``min_length`` and ``forbidden_prefixes`` can reject them.
99
+ """
100
+ totals: dict[TLD, int] = {}
101
+ for tld in self._tlds:
102
+ rule = label_rule_for(tld.suffix)
103
+ count = self._source.count(min_length=rule.min_length)
104
+ for prefix in rule.forbidden_prefixes:
105
+ count -= self._source.count(min_length=rule.min_length, prefix=prefix)
106
+ totals[tld] = count
107
+ return totals