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,222 @@
1
+ """``domainhack name NAME``: find a domain for a brand name, in one command.
2
+
3
+ Checks the exact name under each requested TLD, domain hacks (the name split
4
+ at a TLD it ends with, or the name plus a word that ends in one) and, with
5
+ ``--variants``, brand variants; then prints a report with a recommendation.
6
+ Checking reuses ``CheckDomainsUseCase`` and the ``check`` wiring in
7
+ ``cli.app`` (registrar catalog and router, parallel lanes, cache, circuit
8
+ breaker, progress bar).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from collections.abc import Callable, Iterable, Sequence
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from domainhack.adapters.brand_data import DEFAULT_PRESET, load_hack_words, load_tld_presets
20
+ from domainhack.adapters.brand_report_render import RENDERERS
21
+ from domainhack.adapters.cached_registrar import CachedRegistrarClient
22
+ from domainhack.adapters.dns_resolver import DnsPythonLookup
23
+ from domainhack.adapters.iana_tlds import IanaTldList
24
+ from domainhack.adapters.registrar_catalog import supported_tlds
25
+ from domainhack.adapters.registry_sources import describe_unsupported, unsupported_message
26
+ from domainhack.cli import app
27
+ from domainhack.domain.entities import DomainCheckResult
28
+ from domainhack.ports.dns_lookup import DnsLookup
29
+ from domainhack.ports.known_tlds import KnownTlds
30
+ from domainhack.ports.result_writer import ResultWriter
31
+ from domainhack.usecases.brand_candidates import BrandCandidates, resolve_tld_spec
32
+ from domainhack.usecases.brand_report import build_brand_report
33
+ from domainhack.usecases.check_domains import CheckDomainsUseCase
34
+ from domainhack.usecases.confirm_dns import ConfirmWithDns
35
+
36
+ REPORT_FORMATS = tuple(RENDERERS)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class NameServices:
41
+ """What ``name`` needs besides the registrar catalog (tests pass fakes).
42
+
43
+ ``dns`` is optional: without it, results are not confirmed against DNS.
44
+ ``supported_tlds`` lists the catalog's TLDs for the ``all-supported`` preset.
45
+ """
46
+
47
+ known_tlds: KnownTlds
48
+ dns: DnsLookup | None = None
49
+ supported_tlds: Callable[[], Iterable[str]] = supported_tlds
50
+
51
+
52
+ def _default_services() -> NameServices:
53
+ """Production wiring for ``name``: the known-TLD list and the DNS lookup."""
54
+ return NameServices(known_tlds=IanaTldList(), dns=DnsPythonLookup())
55
+
56
+
57
+ def _confirm_with_dns(
58
+ lookup: DnsLookup, results: Sequence[DomainCheckResult]
59
+ ) -> list[DomainCheckResult]:
60
+ return ConfirmWithDns(lookup).apply(results)
61
+
62
+
63
+ class _Collector(ResultWriter):
64
+ def __init__(self) -> None:
65
+ self.results: list[DomainCheckResult] = []
66
+
67
+ def write_result(self, result: DomainCheckResult) -> None:
68
+ self.results.append(result)
69
+
70
+
71
+ def _brand_name(value: str) -> str:
72
+ name = value.strip().lower()
73
+ if not name or any(c.isspace() for c in name):
74
+ raise argparse.ArgumentTypeError(f"not a single name: {value!r}")
75
+ if "." in name:
76
+ raise argparse.ArgumentTypeError(
77
+ f"give the name without a TLD (e.g. {name.split('.')[0]!r}), got {value!r}"
78
+ )
79
+ return name
80
+
81
+
82
+ def register(sub: argparse._SubParsersAction[argparse.ArgumentParser], *, epilog: str) -> None:
83
+ """Add the ``name`` subcommand to the CLI's subparsers."""
84
+ presets = ", ".join(load_tld_presets())
85
+ parser = sub.add_parser(
86
+ "name",
87
+ help="Find a domain for a brand name (exact, hacks, variants) and recommend one",
88
+ description="Check NAME under a set of TLDs, look for domain hacks that spell it "
89
+ "(sumanda -> sumandastud.io) and, with --variants, brand variants "
90
+ "(getsumanda.com); then print a report with a recommendation.",
91
+ epilog=epilog,
92
+ formatter_class=argparse.RawDescriptionHelpFormatter,
93
+ )
94
+ parser.add_argument("name", type=_brand_name, help="The name, without a TLD (e.g. sumanda)")
95
+ parser.add_argument(
96
+ "--tlds",
97
+ default=DEFAULT_PRESET,
98
+ metavar="PRESET|TLD[,...]",
99
+ help=f"Presets ({presets}) and TLDs, mixed: startup,com.ar,la (default: {DEFAULT_PRESET})",
100
+ )
101
+ parser.add_argument(
102
+ "--variants",
103
+ action="store_true",
104
+ help="Also check get-/use-/try-/hola-/my- NAME and NAME -hq/-app/-labs/-studio, "
105
+ "under .com and the first 3 other TLDs",
106
+ )
107
+ parser.add_argument(
108
+ "--format",
109
+ choices=REPORT_FORMATS,
110
+ default="text",
111
+ help="Report format on stdout (default: text)",
112
+ )
113
+ parser.add_argument(
114
+ "--output",
115
+ type=Path,
116
+ default=None,
117
+ help="Also save the raw results to FILE (.csv, .json or .jsonl)",
118
+ )
119
+ parser.add_argument(
120
+ "--confirm-dns",
121
+ action=argparse.BooleanOptionalAction,
122
+ default=None,
123
+ help="Confirm results against public DNS (default: on when a DNS resolver is available)",
124
+ )
125
+ app.add_run_options(parser)
126
+
127
+
128
+ def cmd_name(
129
+ args: argparse.Namespace,
130
+ *,
131
+ catalog: app.RegistrarCatalog | None = None,
132
+ services: NameServices | None = None,
133
+ ) -> int:
134
+ services = services if services is not None else _default_services()
135
+ notes: list[str] = []
136
+ selection = resolve_tld_spec(args.tlds, load_tld_presets(), services.supported_tlds)
137
+ for suffix in selection.rejected:
138
+ notes.append(f".{suffix} skipped: not a TLD this version can check")
139
+ app._stderr(f"warning: .{suffix} is not a TLD this version can check; skipping")
140
+ if not selection.tlds:
141
+ app._stderr(f"error: no valid TLD in --tlds {args.tlds!r}")
142
+ return app.EXIT_USAGE
143
+ output: Path | None = args.output
144
+ fmt = app._resolve_output_format(output, None) if output is not None else None
145
+
146
+ confirm = args.confirm_dns if args.confirm_dns is not None else services.dns is not None
147
+ if confirm and services.dns is None:
148
+ app._stderr("warning: no DNS resolver available; results are not confirmed in DNS")
149
+ confirm = False
150
+
151
+ generator = BrandCandidates(
152
+ args.name,
153
+ selection.tlds,
154
+ services.known_tlds,
155
+ variants=args.variants,
156
+ hack_words=load_hack_words(),
157
+ )
158
+ candidates = generator.execute()
159
+ if not candidates:
160
+ app._stderr(f"error: {args.name!r} is not a valid domain name label")
161
+ return app.EXIT_USAGE
162
+ if generator.skipped:
163
+ notes.append(f"skipped {generator.skipped} invalid candidates")
164
+ for suffix in generator.unusable_suffixes:
165
+ notes.append(f"hack under .{suffix} skipped: not a TLD this version can check")
166
+
167
+ router = app._build_router(args, catalog)
168
+ try:
169
+ unsupported = {c.domain.tld for c in candidates if not router.supports(c.domain.tld)}
170
+ domains = [c.domain for c in candidates if c.domain.tld not in unsupported]
171
+ requested = [t.suffix for t in selection.tlds if t in unsupported]
172
+ for message in describe_unsupported(requested):
173
+ app._stderr(f"warning: {message}; skipping")
174
+ if not domains:
175
+ raise app.NoSupportedTLDError(
176
+ f"no registrar supports any candidate TLD for {args.name!r}"
177
+ )
178
+ file_writer = app.build_file_writer(output, fmt) if output and fmt else None
179
+ except BaseException:
180
+ router.close()
181
+ raise
182
+
183
+ collector = _Collector()
184
+ with app._build_registrar(args, router) as registrar:
185
+ cache = registrar if isinstance(registrar, CachedRegistrarClient) else None
186
+ use_case = CheckDomainsUseCase(
187
+ router,
188
+ collector,
189
+ app._build_progress(args),
190
+ cache=cache,
191
+ parallel=args.parallel,
192
+ lane_key=app._lane_key(args, router),
193
+ )
194
+ summary = use_case.execute(domains, total=len(domains))
195
+
196
+ order = {c.domain.fqdn: i for i, c in enumerate(candidates)}
197
+ results = sorted(collector.results, key=lambda r: order[r.domain.fqdn])
198
+ try:
199
+ if confirm and services.dns is not None and not summary.interrupted:
200
+ results = _confirm_with_dns(services.dns, results)
201
+ finally:
202
+ if file_writer is not None:
203
+ for result in results:
204
+ file_writer.write_result(result)
205
+ file_writer.flush()
206
+
207
+ if summary.interrupted:
208
+ notes.append("run interrupted: some names were not checked")
209
+ report = build_brand_report(
210
+ generator.name,
211
+ candidates,
212
+ results,
213
+ tlds=selection.tlds,
214
+ suffix_hack_found=generator.suffix_hack_found,
215
+ variants_requested=args.variants,
216
+ unsupported=unsupported,
217
+ notes=notes,
218
+ unsupported_reasons={t: unsupported_message(t.suffix) for t in unsupported},
219
+ )
220
+ sys.stdout.write(RENDERERS[args.format](report))
221
+ app._warn_skipped(generator.skipped)
222
+ return app._report(summary)
@@ -0,0 +1,96 @@
1
+ """``domainhack tlds``: which TLDs this version can check, how, and why not.
2
+
3
+ Offline: it reads the bundled IANA TLD list, RDAP bootstrap snapshot and
4
+ registry data, so the answer is the same on every machine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import dataclasses
11
+ import json
12
+ import sys
13
+ from collections import Counter
14
+ from collections.abc import Sequence
15
+
16
+ from domainhack.adapters.iana_tlds import IanaTldList
17
+ from domainhack.adapters.rdap_bootstrap import RdapBootstrap
18
+ from domainhack.adapters.registrar_catalog import Backend, describe_backend
19
+
20
+ EXIT_OK = 0
21
+ EXIT_USAGE = 2
22
+
23
+
24
+ def register(sub: argparse._SubParsersAction[argparse.ArgumentParser], *, epilog: str) -> None:
25
+ """Add the ``tlds`` subcommand to the CLI's subparsers."""
26
+ parser = sub.add_parser(
27
+ "tlds",
28
+ help="Show which TLDs can be checked, and how (RDAP or WHOIS) or why not",
29
+ description="List TLDs with the backend used to check them. Without arguments: "
30
+ "a coverage summary plus every country-code TLD. Offline.",
31
+ epilog=epilog,
32
+ formatter_class=argparse.RawDescriptionHelpFormatter,
33
+ )
34
+ parser.add_argument(
35
+ "only", nargs="*", metavar="TLD", help="Only these TLDs (e.g. cl es com.ar)"
36
+ )
37
+ parser.add_argument(
38
+ "--unsupported", action="store_true", help="Only TLDs that cannot be checked, with why"
39
+ )
40
+ parser.add_argument(
41
+ "--all", action="store_true", help="Include generic TLDs (com, app...), not only ccTLDs"
42
+ )
43
+ parser.add_argument("--json", action="store_true", help="Machine-readable output")
44
+
45
+
46
+ def _offline_bootstrap() -> RdapBootstrap:
47
+ def no_fetch() -> bytes:
48
+ raise OSError("offline")
49
+
50
+ return RdapBootstrap(fetcher=no_fetch, warn=lambda _message: None)
51
+
52
+
53
+ def cmd_tlds(args: argparse.Namespace, bootstrap: RdapBootstrap | None = None) -> int:
54
+ rdap = bootstrap if bootstrap is not None else _offline_bootstrap()
55
+ known = IanaTldList()
56
+ if args.only:
57
+ suffixes = [s.lower().lstrip(".") for s in args.only]
58
+ unknown = [s for s in suffixes if not known.is_known(s)]
59
+ if unknown:
60
+ print(f"domainhack tlds: error: not a known TLD: {', '.join(unknown)}", file=sys.stderr)
61
+ return EXIT_USAGE
62
+ else:
63
+ suffixes = sorted(
64
+ t
65
+ for t in known.top_level_domains
66
+ if not t.startswith("xn--") and (args.all or len(t) == 2)
67
+ )
68
+ every = [describe_backend(s, rdap) for s in suffixes]
69
+ shown = [b for b in every if b.kind == "unsupported"] if args.unsupported else every
70
+ if args.json:
71
+ print(json.dumps([dataclasses.asdict(b) for b in shown], indent=2))
72
+ sys.stdout.flush() # surface a closed pipe (| head) to main's handler
73
+ return EXIT_OK
74
+ if not args.only:
75
+ print(_summary(every))
76
+ print()
77
+ _print_table(shown)
78
+ sys.stdout.flush() # surface a closed pipe (| head) to main's handler
79
+ return EXIT_OK
80
+
81
+
82
+ def _summary(backends: Sequence[Backend]) -> str:
83
+ counts = Counter(b.kind for b in backends)
84
+ supported = len(backends) - counts["unsupported"]
85
+ parts = [
86
+ f"{counts[k]} {k}" for k in ("rdap", "whois", "whois-fallback", "unsupported") if counts[k]
87
+ ]
88
+ return f"{supported} of {len(backends)} TLDs can be checked ({', '.join(parts)})"
89
+
90
+
91
+ def _print_table(backends: Sequence[Backend]) -> None:
92
+ width_tld = max((len(b.tld) for b in backends), default=3) + 1
93
+ width_kind = max((len(b.kind) for b in backends), default=4)
94
+ width_host = max((len(b.host) for b in backends), default=4)
95
+ for b in backends:
96
+ print(f" .{b.tld:<{width_tld}} {b.kind:<{width_kind}} {b.host:<{width_host}} {b.detail}")
@@ -0,0 +1,20 @@
1
+ {
2
+ "_comment": "Words appended to a name to form a domain hack: name + word must end in a known TLD (sumanda + studio -> sumandastud.io). A word whose ending is not a known TLD is simply never used. Each word maps to the short meaning shown in the report.",
3
+ "studio": "a creative or production studio",
4
+ "radio": "a radio show or podcast",
5
+ "audio": "audio, music or sound",
6
+ "folio": "a portfolio",
7
+ "portfolio": "a portfolio of work",
8
+ "demo": "a demo or showcase",
9
+ "labs": "a lab or experiments",
10
+ "online": "an online presence",
11
+ "games": "games",
12
+ "apps": "apps",
13
+ "tech": "technology",
14
+ "design": "design",
15
+ "team": "the team",
16
+ "store": "a store or shop",
17
+ "house": "a house, home or venue",
18
+ "news": "news or a newsletter",
19
+ "zone": "a zone or community space"
20
+ }