All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Initial release: a Python port of monovm/whois-php.
The classes the PHP package exposes — Checker, WhoisHandler, Whois and
AvailabilityDetector — keep their names and methods, every method is available under
both snake_case and the original camelCase name, and the lookup() dictionary
shape is unchanged.
Checker,WhoisHandler,WhoisandAvailabilityDetector, mirroring the PHP classes.- 870+ bundled TLD definitions (
dist.whois.json), served over WHOIS port 43 or RDAP. - Module-level shortcuts
whois(),lookup()andis_available(). monovm-whoiscommand line interface (--json,--record,--tlds, timeout flags).- Typed exception hierarchy:
WhoisError,UnsupportedTldError,WhoisServerError,WhoisConnectionError,DefinitionsError,InvalidDomainError,EmptyResponseError. - Input normalisation: case, whitespace, trailing root dot, URLs, ports and subdomains are all
accepted (
https://www.example.co.uk/xresolves toexample.co.uk), with the TLD matched longest-suffix first so multi-label TLDs work. - Internationalised domain support in either form, punycode on the wire, with
unicode_query_tldsfor registries such as DENIC that require the Unicode form. available_when_emptydefinition flag for registries that answer an unregistered name with nothing but a banner.- Configurable
socket_timeout,http_timeout,verify_ssland definition files (override_path,definitions_path,MONOVM_WHOIS_DEFINITIONS). Verdict.PREMIUM,AvailabilityDetector.classify()andDetectionEngine.explain()for callers that need more than a boolean.- Inline type hints and a
py.typedmarker. - No third-party runtime dependencies.
LookupService orchestrates a lookup and owns none of the steps. Every step is a
collaborator passed to it, so a custom setup is assembly rather than subclassing:
from monovm_whois import LookupService, Whois
from monovm_whois.definitions import InMemoryDefinitionRepository
from monovm_whois.transport import TransportFactory
service = LookupService(
repository=InMemoryDefinitionRepository(
{".test": {"uri": "socket://whois.example.test", "available": "No match"}}
),
transports=TransportFactory.default(socket_timeout=2),
)
Whois(service=service).lookup({"sld": "a", "tld": ".test"})monovm_whois.definitions—ServerDefinitionplus aDefinitionRepositoryabstraction with three implementations:JsonFileDefinitionRepository,InMemoryDefinitionRepository, andChainedDefinitionRepository, which merges sources so an override file extends the bundled data without either knowing about the other. Parsed definitions are held in a boundedDefinitionCache, so a bulk check does not re-read and re-parse the definitions file once per domain.monovm_whois.transport— oneTransportper protocol (Whois43Transport,HttpTransport) selected at run time byTransportFactory, keyed on URI scheme. Supporting a new protocol is a registration, not an edit to the lookup path.Endpointparses a definition URI;RawResponsecarries what came back.monovm_whois.detection— classification is an ordered chain ofDetectionRuleobjects walked byDetectionEngine, which takes the first conclusive answer.patterns.pyholds the tables as data,matching.pycompiles them once at import, andWhoisResponsedecides which slice of a reply each rule may look at. Adding a signal is a new rule;engine.explain()reports which rule decided.monovm_whois.formatting—RecordFormatter, with an HTML and a plain-text implementation. Escaping the record is a choice, not something the lookup does and the handler undoes.- Value objects —
DomainName,LookupResult,Status,Verdict,ServerDefinition,Endpoint,WhoisResponse.Statuscompares equal to its wire string, soresult.status == "available"holds. monovm_whois.namesandmonovm_whois.textsplit domain-name semantics from presentation helpers.
- 1350+ offline tests at 100% line and branch coverage, with no network access.
tests/fixtures/live_registry.json: 26 replies captured verbatim from 15 registries, one registered and one unregistered name per TLD, replayed through the whole lookup path. Every over-broad pattern below was caught or confirmed by this corpus.- Offline PHP parity suite: 60 recorded responses, each pinned with both the PHP
verdict and the reviewed verdict expected here. The corpus records the direction
of each divergence (
divergence_kind) and a reason for every individual detection flag that differs, so drift in either direction fails the build. - Live registry tests are paced and retry only a non-answer, never a wrong verdict.
Availability is only reported on positive evidence. The PHP original infers it from the absence of registration fields, which makes it report a registered domain as free whenever a server replies with anything other than a record:
- Rate-limit replies (
request limit exceeded,Maximum query rate reached,Excessive querying), blocked clients (Requests of this client are not permitted) and registries that retired port 43 in favour of RDAP raiseWhoisServerError. Refusal detection skips conditional sentences, so Identity Digital's.ioterms of use ("If too many queries are received…") are not read as a refusal. - HTTP 401/403/405/406/429 and 5xx raise
WhoisServerError; an RDAP 404 is still read as "no such domain". - IP-number registry banners (RIPE, APNIC, ARIN, LACNIC, AFRINIC) raise
UnsupportedTldErrorinstead of being read as availability. - Empty and whitespace-only replies are reported as errors.
- Records whose keys are padded with dots (
status.............: Registered, used by Traficom and NIC Monaco) are recognised as records. - Premium/reserved domains report
is_available() is Falseandis_premium() is True, instead of PHP's re-analysis of the"No WHOIS information available."placeholder returningtrue. - A junk, empty or TLD-less domain string is invalid rather than available.
.denames that DENIC rejects (Status: invalid) are never available.
The PHP pattern tables match too broadly in ways that can report a registered domain as available — the worst thing a domain checker can do. Each of these was found by matching the tables against realistic and then real registry replies:
- The word
available, matched anywhere, in 81 per-TLD lists. It occurs in registry legal banners ("Notice, available at https://…"), in field names (Traficom printsavailable.........: <date>on registered domains) and in ordinary prose, so a registered.dk,.se,.pl,.it,.fi,.caor.nodomain could be reported as free. This package uses anchored patterns that require the word to be an assertion about the domain, and matches per-TLD patterns against the reply's data lines rather than its banner. - A bare
404matched any occurrence of those digits — a registrant's street number, a phone number, a registry object id — so a registered domain read as an RDAP "not found". Anchored here to the RDAP and HTTP shapes that mean it. The.ecand.shopdefinitions carried the same bare404as their availability marker and match"errorCode"instead. - A bare
not existmatched prose such as "a cached copy may not exist". - A bare
freematched a registry's "free FAQ" footer link. - A definition's availability marker could outrank a real record. Some markers
are a single word (
.itusesAVAILABLE), and PHP consults its marker first and returns immediately. Here the marker is a hint asked only after a refusal, an explicit denial and a real record have been ruled out. Measured against the live corpus, no free reply carries more than one registration field and every registered reply carries at least three, so the ordering cannot mask a genuine availability. - A reserved name could be reported as free. IRNIC announces one as "This domain is only available for registration under certain conditions" — which contains "available for registration". Premium is a verdict in the rule chain, asked before every availability rule.
.ukand.co.uk: the unavailability table matched the bare wordregistered, and Nominet's reply for a free name reads "This domain name has not been registered" — so every unregistered.ukdomain was reported as taken. Matched here on the affirmative forms (Registered on:and the explicit sentence)..co.ukalso gets availability patterns of its own.---not found/---domain not foundappeared in both the availability and unavailability tables, with unavailability checked first — so a reply of "Not found: free.sx" came back registered. Those are the exact strings the.ioand.sgdefinitions use as their availability marker; they appear only in the availability table here. (This is the one divergence where PHP is the over-cautious one; it is recorded as such in the parity corpus.)- A registry that answers in a single line no longer has that line discarded by the
keyword filter, because the
---sentinel is applied after the reply is split.
Each verified against the live registry in both directions — every one of these answered "not found" for every domain, so every domain under them looked free:
.es .com.es .nom.es .gob.es .edu.es:whois.crsnic.net→whois.nic.es..online,.site:whois.centralnic.com→whois.nic.online/whois.nic.site..li:whois.nic.li→whois.nic.ch:4343..shop:whois.nic.shop→https://rdap.gmoregistry.net/rdap/domain/..ad,.asso.mc,.tm.mc:whois.ripe.net→whois.nic.ad/whois.nic.mc..com.tw .net.tw .org.tw:whois.twnic.net→whois.twnic.net.tw..ru.com:whois.verisign-grs.com→whois.centralnic.com..com.ru .net.ru .org.ru .pp.ru:whois.ripn.net→whois.nic.ru..gtfamily: removed; the configured HTML gateway is now a JavaScript app and IANA publishes no WHOIS server, so these reportinvalid.
- A domain given without a TLD is reported as invalid rather than raising an undefined-index warning.
- Latin-1 registry replies are decoded instead of turning accented names into replacement characters.
- An RDAP JSON body does not have
strip_tagsapplied to it; the transport reports whether a body is markup, from its content type. - The CLI does not crash when a legacy console codepage cannot encode a registry reply.
repr(WhoisHandler)andis_available()never re-run detection as a side effect; the verdict is decided once, during the lookup.{"popularTLDs": None}reports the mistake instead of silently falling back to the defaults.
Checkerdoes not extendWhois. It could never substitute for one — its constructor requires a domain — so the inheritance only obscured that. It holds a client instead, reachable aschecker.client.Checker.whoisis also available asChecker.whois_lookup, sincewhoisis both the PHP class-method name and the obvious name for the client attribute.- Customising requires assembling, not subclassing.
WhoisandCheckeraccept a ready-madeservice=;WhoisHandleracceptsclient=andformatter=. - A premium name with a full record is reported
unavailablerather thanpremium: the registry showed a registration, so the name is taken, and calling it premium implies it could still be bought.premiumis reported when the registry withholds the record, which is the case that needed distinguishing.
.shopresolves tohttps://rdap.gmoregistry.net/, which answers HTTP 403 to every query from at least some networks, registered or not. That is reported aserror— the honest outcome — rather than a guess. IANA publishes no port 43 server for.shop.