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
+ """Render a ``BrandReport`` as text (aligned, for the terminal), Markdown or JSON.
2
+
3
+ Every renderer is a pure function of the report, so output is deterministic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Callable, Sequence
10
+ from typing import Any
11
+
12
+ from domainhack.usecases.brand_report import BrandReport, ReportEntry
13
+
14
+ Renderer = Callable[[BrandReport], str]
15
+
16
+ _NONE = "(none)"
17
+ _NO_VARIANTS = "not requested (add --variants)"
18
+
19
+
20
+ def _variants_empty(report: BrandReport) -> str:
21
+ return "none available" if report.variants_requested else _NO_VARIANTS
22
+
23
+
24
+ def _hack_reading(entry: ReportEntry) -> str:
25
+ meaning = entry.candidate.meaning
26
+ return f"{entry.label}: {meaning}" if meaning else entry.label
27
+
28
+
29
+ def _with_detail(*parts: str) -> str:
30
+ return " -- ".join(p for p in parts if p)
31
+
32
+
33
+ # ── text ─────────────────────────────────────────────────
34
+
35
+
36
+ def _align(rows: Sequence[Sequence[str]], indent: str = " ") -> list[str]:
37
+ """Left-align every column but the last; trailing spaces are stripped. ``rows`` is non-empty."""
38
+ widths = [max(len(row[i]) for row in rows) for i in range(len(rows[0]) - 1)]
39
+ lines = []
40
+ for row in rows:
41
+ cells = [cell.ljust(width) for cell, width in zip(row, widths, strict=False)]
42
+ lines.append((indent + " ".join([*cells, row[-1]])).rstrip())
43
+ return lines
44
+
45
+
46
+ def _text_section(title: str, rows: Sequence[Sequence[str]], empty: str = _NONE) -> list[str]:
47
+ body = _align(rows) if rows else [f" {empty}"]
48
+ return ["", title, *body]
49
+
50
+
51
+ def render_text(report: BrandReport) -> str:
52
+ lines = [
53
+ f"Domains for {report.name!r}",
54
+ f"TLDs: {', '.join(t.suffix for t in report.tlds) or _NONE}",
55
+ ]
56
+ lines += _text_section("Exact name", [(e.name, e.status.value, e.detail) for e in report.exact])
57
+ hack_rows = [
58
+ (e.name, e.status.value, _with_detail(_hack_reading(e), e.detail)) for e in report.hacks
59
+ ]
60
+ hack_lines = _text_section("Domain hacks", hack_rows)
61
+ if not report.suffix_hack_found:
62
+ hack_lines.insert(2, f" {report.no_suffix_hack_note}")
63
+ if not hack_rows:
64
+ hack_lines.pop() # the note replaces "(none)"
65
+ lines += hack_lines
66
+ lines += _text_section(
67
+ "Brand variants (available)",
68
+ [(e.name, _with_detail(e.label, e.detail)) for e in report.variants],
69
+ _variants_empty(report),
70
+ )
71
+ lines += _text_section("Taken", [(e.name, e.status.value, e.detail) for e in report.taken])
72
+ lines += _text_section(
73
+ "Could not verify", [(e.name, e.status.value, e.detail) for e in report.unverifiable]
74
+ )
75
+ lines += _text_section("DNS conflicts", [(e.name, e.detail) for e in report.dns_conflicts])
76
+ rec_rows = [(f"{r.rank}.", r.entry.name, r.reason) for r in report.recommendations]
77
+ lines += _text_section("Recommendation", rec_rows, "nothing available to recommend")
78
+ if report.more_available:
79
+ lines.append(f" (+{report.more_available} more available above)")
80
+ lines += [f" - {line}" for line in report.advice]
81
+ if report.notes:
82
+ lines += ["", "Notes", *(f" - {note}" for note in report.notes)]
83
+ return "\n".join(lines) + "\n"
84
+
85
+
86
+ # ── markdown ─────────────────────────────────────────────
87
+
88
+
89
+ def _cell(text: str) -> str:
90
+ return text.replace("|", "\\|") if text else ""
91
+
92
+
93
+ def _md_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> list[str]:
94
+ out = [
95
+ "| " + " | ".join(headers) + " |",
96
+ "|" + "|".join("---" for _ in headers) + "|",
97
+ ]
98
+ out += ["| " + " | ".join(_cell(c) for c in row) + " |" for row in rows]
99
+ return out
100
+
101
+
102
+ def _md_section(
103
+ title: str, headers: Sequence[str], rows: Sequence[Sequence[str]], empty: str = "None."
104
+ ) -> list[str]:
105
+ body = _md_table(headers, rows) if rows else [f"_{empty}_"]
106
+ return ["", f"## {title}", "", *body]
107
+
108
+
109
+ def render_markdown(report: BrandReport) -> str:
110
+ tlds = ", ".join(f"`.{t.suffix}`" for t in report.tlds) or "none"
111
+ lines = [f"# Domains for `{report.name}`", "", f"TLDs: {tlds}"]
112
+ status_headers = ("Domain", "Status", "Details")
113
+
114
+ def code(entry: ReportEntry) -> str:
115
+ return f"`{entry.name}`"
116
+
117
+ lines += _md_section(
118
+ "Exact name", status_headers, [(code(e), e.status.value, e.detail) for e in report.exact]
119
+ )
120
+ hack_section = _md_section(
121
+ "Domain hacks",
122
+ ("Domain", "Status", "Reads as", "Details"),
123
+ [(code(e), e.status.value, _hack_reading(e), e.detail) for e in report.hacks],
124
+ )
125
+ if not report.suffix_hack_found:
126
+ note = f"_{report.no_suffix_hack_note[0].upper()}{report.no_suffix_hack_note[1:]}._"
127
+ if report.hacks:
128
+ hack_section[3:3] = [note, ""]
129
+ else:
130
+ hack_section[-1] = note
131
+ lines += hack_section
132
+ lines += _md_section(
133
+ "Brand variants (available)",
134
+ ("Domain", "Reads as", "Details"),
135
+ [(code(e), e.label, e.detail) for e in report.variants],
136
+ _variants_empty(report).capitalize() + ".",
137
+ )
138
+ lines += _md_section(
139
+ "Taken", status_headers, [(code(e), e.status.value, e.detail) for e in report.taken]
140
+ )
141
+ lines += _md_section(
142
+ "Could not verify",
143
+ status_headers,
144
+ [(code(e), e.status.value, e.detail) for e in report.unverifiable],
145
+ )
146
+ lines += _md_section(
147
+ "DNS conflicts", ("Domain", "Details"), [(code(e), e.detail) for e in report.dns_conflicts]
148
+ )
149
+ lines += ["", "## Recommendation", ""]
150
+ if report.recommendations:
151
+ lines += [f"{r.rank}. **`{r.entry.name}`**: {r.reason}" for r in report.recommendations]
152
+ else:
153
+ lines.append("_Nothing available to recommend._")
154
+ if report.more_available:
155
+ lines += ["", f"_+{report.more_available} more available above._"]
156
+ if report.advice:
157
+ lines += ["", *(f"- {line}" for line in report.advice)]
158
+ if report.notes:
159
+ lines += ["", "## Notes", "", *(f"- {note}" for note in report.notes)]
160
+ return "\n".join(lines) + "\n"
161
+
162
+
163
+ # ── json ─────────────────────────────────────────────────
164
+
165
+
166
+ def _entry_json(entry: ReportEntry) -> dict[str, Any]:
167
+ data: dict[str, Any] = {
168
+ "domain": entry.name,
169
+ "fqdn": entry.fqdn,
170
+ "kind": entry.kind.value,
171
+ "label": entry.label,
172
+ "status": entry.status.value,
173
+ "detail": entry.detail,
174
+ }
175
+ if entry.candidate.meaning:
176
+ data["meaning"] = entry.candidate.meaning
177
+ result = entry.result
178
+ if result is not None and (result.registered_at or result.expires_at or result.statuses):
179
+ data["registered_at"] = result.registered_at.isoformat() if result.registered_at else None
180
+ data["expires_at"] = result.expires_at.isoformat() if result.expires_at else None
181
+ data["statuses"] = list(result.statuses)
182
+ if result is not None and result.registrar:
183
+ data["registrar"] = result.registrar
184
+ if result is not None and result.parked_hint:
185
+ data["parked_hint"] = result.parked_hint
186
+ if result is not None and result.dns is not None:
187
+ data["dns"] = {
188
+ "nameservers": list(result.dns.nameservers),
189
+ "has_address": result.dns.has_address,
190
+ "error": result.dns.error,
191
+ }
192
+ return data
193
+
194
+
195
+ def render_json(report: BrandReport) -> str:
196
+ data = {
197
+ "name": report.name,
198
+ "tlds": [t.suffix for t in report.tlds],
199
+ "exact": [_entry_json(e) for e in report.exact],
200
+ "hacks": [_entry_json(e) for e in report.hacks],
201
+ "suffix_hack_found": report.suffix_hack_found,
202
+ "variants_requested": report.variants_requested,
203
+ "variants": [_entry_json(e) for e in report.variants],
204
+ "taken": [_entry_json(e) for e in report.taken],
205
+ "unverifiable": [_entry_json(e) for e in report.unverifiable],
206
+ "dns_conflicts": [_entry_json(e) for e in report.dns_conflicts],
207
+ "recommendations": [
208
+ {"rank": r.rank, "domain": r.entry.name, "score": r.score, "reason": r.reason}
209
+ for r in report.recommendations
210
+ ],
211
+ "more_available": report.more_available,
212
+ "advice": list(report.advice),
213
+ "notes": list(report.notes),
214
+ }
215
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
216
+
217
+
218
+ RENDERERS: dict[str, Renderer] = {
219
+ "text": render_text,
220
+ "markdown": render_markdown,
221
+ "json": render_json,
222
+ }
@@ -0,0 +1,340 @@
1
+ """SQLite-backed caching decorator for any RegistrarClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ from domainhack.adapters._registration import (
16
+ normalize_name,
17
+ normalize_nameservers,
18
+ normalize_statuses,
19
+ )
20
+ from domainhack.adapters._stderr import write_stderr
21
+ from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
22
+ from domainhack.ports.registrar import RegistrarClient
23
+ from domainhack.ports.result_cache import ResultCache
24
+
25
+ HOUR: float = 60 * 60
26
+ DAY: float = 24 * HOUR
27
+ AVAILABLE_TTL_SECONDS: float = 24 * HOUR
28
+ DROPPING_TTL_SECONDS: float = 24 * HOUR
29
+ TAKEN_TTL_SECONDS: float = 30 * DAY
30
+ TAKEN_MAX_TTL_SECONDS: float = 90 * DAY
31
+ CACHE_RAW_TITLE = "cache"
32
+ # Rows are committed in batches: every COMMIT_EVERY writes, when COMMIT_INTERVAL
33
+ # seconds have passed since the last commit, and on close(). A killed run loses
34
+ # at most one batch; Ctrl-C goes through close() and loses nothing.
35
+ COMMIT_EVERY = 25
36
+ COMMIT_INTERVAL_SECONDS = 10.0
37
+
38
+ SCHEMA_VERSION = 2
39
+ _COLUMNS = {
40
+ # Version 0 (before statuses/expiration were stored): fqdn, availability, checked_at.
41
+ # Version 1:
42
+ "statuses": "TEXT NOT NULL DEFAULT ''",
43
+ "expires_at": "REAL",
44
+ # Version 2:
45
+ "registered_at": "REAL",
46
+ "registrar": "TEXT NOT NULL DEFAULT ''",
47
+ "nameservers": "TEXT NOT NULL DEFAULT ''",
48
+ "parked_hint": "TEXT NOT NULL DEFAULT ''",
49
+ }
50
+ _DETAIL_COLUMNS = tuple(_COLUMNS)
51
+
52
+ _CACHEABLE = (Availability.AVAILABLE, Availability.TAKEN)
53
+
54
+
55
+ def default_cache_path() -> Path:
56
+ """Return ``$XDG_CACHE_HOME/domainhack/results.sqlite3`` (or ``~/.cache/...``)."""
57
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
58
+ return Path(base) / "domainhack" / "results.sqlite3"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CacheTtlPolicy:
63
+ """How long a cached result stays fresh, by what it says.
64
+
65
+ * AVAILABLE: ``available`` (24 h). Free names are the ones people act on,
66
+ and someone else can register them at any moment.
67
+ * TAKEN and dropping (redemption / pending delete): ``dropping`` (24 h).
68
+ * TAKEN with an expiration date: until that date, at least ``dropping``
69
+ (a name past its expiry is in its grace period and worth a daily look)
70
+ and at most ``taken_max`` (90 days).
71
+ * TAKEN otherwise: ``taken`` (30 days).
72
+
73
+ ``cap`` (``--cache-ttl``), when set, caps every one of these.
74
+ """
75
+
76
+ available: float = AVAILABLE_TTL_SECONDS
77
+ dropping: float = DROPPING_TTL_SECONDS
78
+ taken: float = TAKEN_TTL_SECONDS
79
+ taken_max: float = TAKEN_MAX_TTL_SECONDS
80
+ cap: float | None = None
81
+
82
+ def ttl_for(self, result: DomainCheckResult, checked_at: float) -> float:
83
+ """Seconds a ``result`` checked at ``checked_at`` (epoch seconds) stays fresh."""
84
+ if result.availability is Availability.AVAILABLE:
85
+ ttl = self.available
86
+ elif result.is_dropping:
87
+ ttl = self.dropping
88
+ elif result.expires_at is not None:
89
+ until_expiry = result.expires_at.timestamp() - checked_at
90
+ ttl = min(max(until_expiry, self.dropping), self.taken_max)
91
+ else:
92
+ ttl = self.taken
93
+ return ttl if self.cap is None else min(ttl, self.cap)
94
+
95
+
96
+ class CachedRegistrarClient(RegistrarClient, ResultCache):
97
+ """Decorator that caches AVAILABLE/TAKEN results of an inner RegistrarClient.
98
+
99
+ Cache hits never touch the inner client, so any rate-limit delay it applies
100
+ is skipped. ERROR results are never cached. Results served from the cache
101
+ carry ``raw_title == "cache"`` plus the stored registration details
102
+ (``statuses``, ``expires_at``, ``registered_at``, ``registrar``,
103
+ ``nameservers``, ``parked_hint``). DNS evidence (``dns``) is never stored:
104
+ it is a fresh second opinion, gathered on every run. How long each
105
+ result stays fresh is set by ``ttl`` (a ``CacheTtlPolicy``); it is
106
+ decided when the row is read, so a smaller ``--cache-ttl`` also applies
107
+ to rows written by earlier runs.
108
+
109
+ The cache is an optimisation, never a reason to fail a run: if the database
110
+ cannot be opened, read or written (corrupt file, a directory, no
111
+ permission...), ``warn`` is called once and every later check goes straight
112
+ to the inner client. Cache files written by older versions are migrated in
113
+ place (missing columns are added; old rows read as "no details").
114
+
115
+ It is also a ``ResultCache``: ``lookup`` and ``store`` let a caller that
116
+ queries the inner client itself (the parallel check) use the cache from
117
+ one thread. The sqlite connection is not shared across threads, so all
118
+ calls must come from the same thread.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ inner: RegistrarClient,
124
+ path: Path | None = None,
125
+ ttl: CacheTtlPolicy | None = None,
126
+ clock: Callable[[], float] = time.time,
127
+ warn: Callable[[str], None] = write_stderr,
128
+ *,
129
+ commit_every: int = COMMIT_EVERY,
130
+ commit_interval: float = COMMIT_INTERVAL_SECONDS,
131
+ ) -> None:
132
+ self._inner = inner
133
+ self._path = path if path is not None else default_cache_path()
134
+ self._ttl = ttl if ttl is not None else CacheTtlPolicy()
135
+ self._clock = clock
136
+ self._conn: sqlite3.Connection | None = None
137
+ self._warn = warn
138
+ self._disabled = False
139
+ self._commit_every = max(commit_every, 1)
140
+ self._commit_interval = commit_interval
141
+ self._pending = 0
142
+ self._last_commit = 0.0
143
+
144
+ @property
145
+ def disabled(self) -> bool:
146
+ """True once the cache failed and the client became a pass-through."""
147
+ return self._disabled
148
+
149
+ @property
150
+ def ttl(self) -> CacheTtlPolicy:
151
+ return self._ttl
152
+
153
+ def _disable(self, exc: Exception) -> None:
154
+ self._disabled = True
155
+ self._close_db(commit=False)
156
+ self._warn(f"warning: result cache disabled ({self._path}: {exc}); continuing without it")
157
+
158
+ def _close_db(self, *, commit: bool) -> None:
159
+ conn, self._conn = self._conn, None
160
+ if conn is None:
161
+ return
162
+ try:
163
+ if commit and self._pending:
164
+ conn.commit()
165
+ except sqlite3.Error as exc:
166
+ self._warn(f"warning: could not save the result cache ({self._path}: {exc})")
167
+ finally:
168
+ self._pending = 0
169
+ with contextlib.suppress(sqlite3.Error):
170
+ conn.close()
171
+
172
+ def _db(self) -> sqlite3.Connection:
173
+ if self._conn is None:
174
+ self._path.parent.mkdir(parents=True, exist_ok=True)
175
+ conn = sqlite3.connect(self._path)
176
+ try:
177
+ _migrate(conn)
178
+ except BaseException:
179
+ conn.close()
180
+ raise
181
+ self._conn = conn
182
+ self._last_commit = self._clock()
183
+ return self._conn
184
+
185
+ def _lookup(self, domain: DomainHack) -> DomainCheckResult | None:
186
+ row = (
187
+ self._db()
188
+ .execute(
189
+ f"SELECT availability, checked_at, {', '.join(_DETAIL_COLUMNS)}"
190
+ " FROM results WHERE fqdn = ?",
191
+ (domain.fqdn,),
192
+ )
193
+ .fetchone()
194
+ )
195
+ if row is None:
196
+ return None
197
+ (
198
+ availability_value,
199
+ checked_at,
200
+ statuses_value,
201
+ expires_value,
202
+ registered_value,
203
+ registrar_value,
204
+ nameservers_value,
205
+ parked_value,
206
+ ) = row
207
+ try:
208
+ availability = Availability(availability_value)
209
+ checked = float(checked_at)
210
+ except (TypeError, ValueError):
211
+ return None
212
+ if availability not in _CACHEABLE:
213
+ return None
214
+ result = DomainCheckResult(
215
+ domain=domain,
216
+ availability=availability,
217
+ raw_title=CACHE_RAW_TITLE,
218
+ statuses=_load_statuses(statuses_value),
219
+ expires_at=_load_timestamp(expires_value),
220
+ registered_at=_load_timestamp(registered_value),
221
+ registrar=normalize_name(registrar_value) or "",
222
+ nameservers=normalize_nameservers(_load_json_list(nameservers_value)),
223
+ parked_hint=normalize_name(parked_value) or "",
224
+ )
225
+ if self._clock() - checked > self._ttl.ttl_for(result, checked):
226
+ return None
227
+ return result
228
+
229
+ def _store(self, result: DomainCheckResult) -> None:
230
+ db = self._db()
231
+ columns = ("fqdn", "availability", "checked_at", *_DETAIL_COLUMNS)
232
+ db.execute(
233
+ f"INSERT OR REPLACE INTO results ({', '.join(columns)})"
234
+ f" VALUES ({', '.join('?' * len(columns))})",
235
+ (
236
+ result.domain.fqdn,
237
+ result.availability.value,
238
+ self._clock(),
239
+ _dump_list(result.statuses),
240
+ _dump_timestamp(result.expires_at),
241
+ _dump_timestamp(result.registered_at),
242
+ result.registrar,
243
+ _dump_list(result.nameservers),
244
+ result.parked_hint,
245
+ ),
246
+ )
247
+ self._pending += 1
248
+ now = self._clock()
249
+ if self._pending >= self._commit_every or now - self._last_commit >= self._commit_interval:
250
+ db.commit()
251
+ self._pending = 0
252
+ self._last_commit = now
253
+
254
+ def lookup(self, domain: DomainHack) -> DomainCheckResult | None:
255
+ """A fresh cached result for ``domain``, or None (also when the cache failed)."""
256
+ if self._disabled:
257
+ return None
258
+ try:
259
+ return self._lookup(domain)
260
+ except (sqlite3.Error, OSError) as exc:
261
+ self._disable(exc)
262
+ return None
263
+
264
+ def store(self, result: DomainCheckResult) -> None:
265
+ """Save an AVAILABLE/TAKEN ``result``; anything else is ignored."""
266
+ if result.availability not in _CACHEABLE or self._disabled:
267
+ return
268
+ try:
269
+ self._store(result)
270
+ except (sqlite3.Error, OSError) as exc:
271
+ self._disable(exc)
272
+
273
+ def check_availability(self, domain: DomainHack) -> DomainCheckResult:
274
+ cached = self.lookup(domain)
275
+ if cached is not None:
276
+ return cached
277
+ result = self._inner.check_availability(domain)
278
+ self.store(result)
279
+ return result
280
+
281
+ def close(self) -> None:
282
+ try:
283
+ self._close_db(commit=True)
284
+ finally:
285
+ self._inner.close()
286
+
287
+
288
+ def _migrate(conn: sqlite3.Connection) -> None:
289
+ """Create the table, or bring an older one up to ``SCHEMA_VERSION`` in place."""
290
+ conn.execute(
291
+ "CREATE TABLE IF NOT EXISTS results ("
292
+ " fqdn TEXT PRIMARY KEY,"
293
+ " availability TEXT NOT NULL,"
294
+ " checked_at REAL NOT NULL)"
295
+ )
296
+ present = {row[1] for row in conn.execute("PRAGMA table_info(results)")}
297
+ for name, declaration in _COLUMNS.items():
298
+ if name not in present:
299
+ conn.execute(f"ALTER TABLE results ADD COLUMN {name} {declaration}")
300
+ (version,) = conn.execute("PRAGMA user_version").fetchone()
301
+ if version < SCHEMA_VERSION:
302
+ # TAKEN rows written by an older schema lack the registration details
303
+ # added since, and their TTL (up to the expiry date) would hide that
304
+ # for months: expire them so the next run fetches the details.
305
+ conn.execute("UPDATE results SET checked_at = 0 WHERE availability = 'taken'")
306
+ conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
307
+ conn.commit()
308
+
309
+
310
+ def _dump_list(values: tuple[str, ...]) -> str:
311
+ return json.dumps(list(values)) if values else ""
312
+
313
+
314
+ def _dump_timestamp(value: datetime | None) -> float | None:
315
+ return value.timestamp() if value is not None else None
316
+
317
+
318
+ def _load_json_list(value: object) -> list[object]:
319
+ """A stored JSON list; ``[]`` for old rows or anything malformed."""
320
+ if not isinstance(value, str) or not value:
321
+ return []
322
+ try:
323
+ parsed = json.loads(value)
324
+ except ValueError:
325
+ return []
326
+ return parsed if isinstance(parsed, list) else []
327
+
328
+
329
+ def _load_statuses(value: object) -> tuple[str, ...]:
330
+ """Stored statuses (a JSON list); ``()`` for old rows or anything malformed."""
331
+ return normalize_statuses(_load_json_list(value))
332
+
333
+
334
+ def _load_timestamp(value: object) -> datetime | None:
335
+ if not isinstance(value, (int, float)):
336
+ return None
337
+ try:
338
+ return datetime.fromtimestamp(value, tz=timezone.utc)
339
+ except (OverflowError, OSError, ValueError):
340
+ return None
@@ -0,0 +1,27 @@
1
+ from collections.abc import Sequence
2
+
3
+ from domainhack.domain.entities import DomainCheckResult
4
+ from domainhack.ports.result_writer import ResultWriter
5
+
6
+
7
+ class CompositeResultWriter(ResultWriter):
8
+ """Fans out every result to several writers (e.g. console + file)."""
9
+
10
+ def __init__(self, writers: Sequence[ResultWriter]) -> None:
11
+ self._writers = tuple(writers)
12
+
13
+ def write_result(self, result: DomainCheckResult) -> None:
14
+ for writer in self._writers:
15
+ writer.write_result(result)
16
+
17
+ def flush(self) -> None:
18
+ """Flush every writer, even if one fails; re-raise the first error."""
19
+ first_error: Exception | None = None
20
+ for writer in self._writers:
21
+ try:
22
+ writer.flush()
23
+ except Exception as exc:
24
+ if first_error is None:
25
+ first_error = exc
26
+ if first_error is not None:
27
+ raise first_error
@@ -0,0 +1,77 @@
1
+ import sys
2
+
3
+ from domainhack.adapters._stderr import write_stderr
4
+ from domainhack.domain.entities import Availability, DomainCheckResult, DomainHack
5
+ from domainhack.ports.result_writer import ResultWriter
6
+
7
+
8
+ class ConsoleResultWriter(ResultWriter):
9
+ """Writes domain check results to the console.
10
+
11
+ Results (AVAILABLE, and TAKEN with ``show_taken``) go to stdout, so the
12
+ output can be piped; ERROR lines are diagnostics and go to stderr. TAKEN
13
+ names in redemption or pending delete ("dropping") are printed with their
14
+ status and expiry date, and ``show_dropping`` prints them even without
15
+ ``show_taken``. Other TAKEN names print with whatever registration
16
+ details are known: ``TAKEN: sumanda.com (since 2015-11-12, expires
17
+ 2026-11-12, parked: domainrecover)``. An AVAILABLE name that DNS shows as
18
+ delegated (``dns_conflict``, see ``--confirm-dns``) prints as
19
+ ``AVAILABLE?`` with a warning instead. The run summary is printed by the
20
+ CLI, from the use case's
21
+ ``CheckSummary``.
22
+ """
23
+
24
+ def __init__(
25
+ self, show_taken: bool = False, show_errors: bool = True, show_dropping: bool = False
26
+ ) -> None:
27
+ self._show_taken = show_taken
28
+ self._show_errors = show_errors
29
+ self._show_dropping = show_dropping
30
+
31
+ def write_result(self, result: DomainCheckResult) -> None:
32
+ match result.availability:
33
+ case Availability.AVAILABLE:
34
+ if result.dns_conflict:
35
+ print(
36
+ f" AVAILABLE? {_name(result.domain)} -- registry says free "
37
+ "but DNS has NS records"
38
+ )
39
+ else:
40
+ print(f" AVAILABLE: {_name(result.domain)} (word: {result.domain.word!r})")
41
+ case Availability.TAKEN:
42
+ if result.is_dropping:
43
+ if self._show_taken or self._show_dropping:
44
+ print(f" TAKEN ({_dropping_detail(result)}): {_name(result.domain)}")
45
+ elif self._show_taken:
46
+ print(f" TAKEN: {_name(result.domain)}{_taken_detail(result)}")
47
+ case Availability.ERROR:
48
+ if self._show_errors:
49
+ write_stderr(f" ERROR: {_name(result.domain)} -- {result.error_message}")
50
+
51
+ def flush(self) -> None:
52
+ sys.stdout.flush()
53
+
54
+
55
+ def _dropping_detail(result: DomainCheckResult) -> str:
56
+ """``dropping: pending delete, expires 2026-11-02`` (the date only when known)."""
57
+ detail = f"dropping: {', '.join(result.dropping_statuses)}"
58
+ if result.expires_at is not None:
59
+ detail += f", expires {result.expires_at.date().isoformat()}"
60
+ return detail
61
+
62
+
63
+ def _taken_detail(result: DomainCheckResult) -> str:
64
+ """`` (since 2015-11-12, expires 2026-11-12, parked: domainrecover)``, known parts only."""
65
+ parts = []
66
+ if result.registered_at is not None:
67
+ parts.append(f"since {result.registered_at.date().isoformat()}")
68
+ if result.expires_at is not None:
69
+ parts.append(f"expires {result.expires_at.date().isoformat()}")
70
+ if result.parked_hint:
71
+ parts.append(f"parked: {result.parked_hint}")
72
+ return f" ({', '.join(parts)})" if parts else ""
73
+
74
+
75
+ def _name(domain: DomainHack) -> str:
76
+ """The name as written, plus the queried A-label for IDNs: ``ñandú.de (xn--and-6ma2c.de)``."""
77
+ return f"{domain.display} ({domain.fqdn})" if domain.is_idn else domain.fqdn