epptools 1.0.0__tar.gz

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 (60) hide show
  1. epptools-1.0.0/CHANGELOG.md +46 -0
  2. epptools-1.0.0/LICENSE +21 -0
  3. epptools-1.0.0/MANIFEST.in +16 -0
  4. epptools-1.0.0/PKG-INFO +489 -0
  5. epptools-1.0.0/README.md +461 -0
  6. epptools-1.0.0/docs/en/README.md +60 -0
  7. epptools-1.0.0/docs/en/balance.md +469 -0
  8. epptools-1.0.0/docs/en/builders.md +481 -0
  9. epptools-1.0.0/docs/en/commands.md +261 -0
  10. epptools-1.0.0/docs/en/contacts.md +475 -0
  11. epptools-1.0.0/docs/en/domains.md +715 -0
  12. epptools-1.0.0/docs/en/errors.md +402 -0
  13. epptools-1.0.0/docs/en/hosts.md +273 -0
  14. epptools-1.0.0/docs/en/poll.md +366 -0
  15. epptools-1.0.0/docs/en/quickstart.md +195 -0
  16. epptools-1.0.0/docs/en/responses.md +314 -0
  17. epptools-1.0.0/docs/en/session.md +310 -0
  18. epptools-1.0.0/docs/ru/README.md +61 -0
  19. epptools-1.0.0/docs/ru/balance.md +474 -0
  20. epptools-1.0.0/docs/ru/builders.md +485 -0
  21. epptools-1.0.0/docs/ru/commands.md +263 -0
  22. epptools-1.0.0/docs/ru/contacts.md +480 -0
  23. epptools-1.0.0/docs/ru/domains.md +719 -0
  24. epptools-1.0.0/docs/ru/errors.md +408 -0
  25. epptools-1.0.0/docs/ru/hosts.md +273 -0
  26. epptools-1.0.0/docs/ru/poll.md +370 -0
  27. epptools-1.0.0/docs/ru/quickstart.md +199 -0
  28. epptools-1.0.0/docs/ru/responses.md +317 -0
  29. epptools-1.0.0/docs/ru/session.md +314 -0
  30. epptools-1.0.0/docs/uk/README.md +61 -0
  31. epptools-1.0.0/docs/uk/balance.md +474 -0
  32. epptools-1.0.0/docs/uk/builders.md +483 -0
  33. epptools-1.0.0/docs/uk/commands.md +263 -0
  34. epptools-1.0.0/docs/uk/contacts.md +478 -0
  35. epptools-1.0.0/docs/uk/domains.md +721 -0
  36. epptools-1.0.0/docs/uk/errors.md +407 -0
  37. epptools-1.0.0/docs/uk/hosts.md +271 -0
  38. epptools-1.0.0/docs/uk/poll.md +368 -0
  39. epptools-1.0.0/docs/uk/quickstart.md +201 -0
  40. epptools-1.0.0/docs/uk/responses.md +317 -0
  41. epptools-1.0.0/docs/uk/session.md +315 -0
  42. epptools-1.0.0/epptools/__init__.py +72 -0
  43. epptools-1.0.0/epptools/_version.py +7 -0
  44. epptools-1.0.0/epptools/builders.py +639 -0
  45. epptools-1.0.0/epptools/client.py +435 -0
  46. epptools-1.0.0/epptools/commands.py +907 -0
  47. epptools-1.0.0/epptools/config.py +80 -0
  48. epptools-1.0.0/epptools/exceptions.py +176 -0
  49. epptools-1.0.0/epptools/frame.py +148 -0
  50. epptools-1.0.0/epptools/namespaces.py +95 -0
  51. epptools-1.0.0/epptools/py.typed +1 -0
  52. epptools-1.0.0/epptools/response.py +894 -0
  53. epptools-1.0.0/epptools/result_code.py +53 -0
  54. epptools-1.0.0/epptools/transport.py +151 -0
  55. epptools-1.0.0/epptools.egg-info/PKG-INFO +489 -0
  56. epptools-1.0.0/epptools.egg-info/SOURCES.txt +58 -0
  57. epptools-1.0.0/epptools.egg-info/dependency_links.txt +1 -0
  58. epptools-1.0.0/epptools.egg-info/top_level.txt +1 -0
  59. epptools-1.0.0/pyproject.toml +43 -0
  60. epptools-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this library are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.0.0]
8
+
9
+ First public release.
10
+
11
+ ### The library
12
+
13
+ - **EPP over TLS with no dependencies at all** — the standard library and nothing else, on Python
14
+ 3.8 and up. Domains, contacts and hosts (RFC 5730–5733), DNSSEC (RFC 5910), redemption and restore
15
+ (RFC 3915), prices and fee agreements (RFC 8748) and login security (RFC 8807).
16
+ - **A registry's own extensions are discovered from its `<greeting>`**, not compiled in. This library
17
+ ships no registry's URIs, so it works against a registry it has never seen — and keeps working when
18
+ one changes its namespaces. `client.registry_ext_uri()` / `registry_balance_uri()` report what was
19
+ found; `Config` can override both for a registry whose naming discovery cannot guess.
20
+ - **Responses are read by local element name**, never by namespace prefix, so extension data stays
21
+ readable whatever namespace it arrived under and whatever prefix the server chose.
22
+ - **Commands that need an extension the server does not offer fail loudly.** `domain.create` with a
23
+ licence, `host.delete` with `force` and `balance()` raise `ConfigException` naming what was wanted
24
+ and listing what the server advertised — because an extension sent under a namespace the server
25
+ does not recognise is ignored rather than rejected, so the alternative is a `1000 OK` with the
26
+ value silently unset.
27
+ - **Misspelt keyword and option keys are refused, not dropped.** A key this library does not
28
+ understand raises `ValidationException` with the nearest accepted spelling, instead of building a
29
+ frame that omits what you asked for and comes back successful. DNSSEC blocks accept both the
30
+ RFC's camelCase spelling and this library's snake_case, and nothing else.
31
+ - **Passwords never reach a log or a `repr()`.** Frame logging redacts `<pw>` and `<newPW>` in any
32
+ namespace, `Config` keeps the password out of its own `repr()`, and a password too long for the
33
+ RFC 5730 `<pw>` element is either carried by RFC 8807 or refused before a socket is opened.
34
+ - **Builders** for the commands with the most options — `domain.update`, `contact.create`,
35
+ `contact.update` — so a long call site reads as a sequence of decisions.
36
+ - **Typed throughout, and it ships `py.typed`** (PEP 561), so mypy and pyright use the annotations
37
+ instead of treating every import as untyped.
38
+
39
+ ### The documentation
40
+
41
+ - A full reference manual in English, Ukrainian and Russian: `docs/en/`, `docs/uk/`, `docs/ru/`,
42
+ twelve pages each — quickstart, session, domains, contacts, hosts, DNSSEC, transfers, poll, fees,
43
+ balance, responses, errors, plus a commands reference and a builders guide.
44
+ - Every example runs against `epp.registry.example` with the login `EXAMPLE`. The examples name no
45
+ real registry, and every hostname is under a TLD RFC 2606 reserves so a copied example cannot
46
+ reach somebody's server.
epptools-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EppTools
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ # What goes into the source distribution beyond the package itself.
2
+ #
3
+ # setuptools builds the sdist from a default list that stops at the package directory, so the manual
4
+ # would be on GitHub and absent from `pip download`/`pip install --no-binary`. A reader who installs
5
+ # the package and looks for the documentation beside it must find it there.
6
+ include README.md
7
+ include CHANGELOG.md
8
+ include LICENSE
9
+ recursive-include docs *.md
10
+
11
+ # Nothing below is of use to someone installing the library.
12
+ prune tests
13
+ prune examples
14
+ prune .github
15
+ global-exclude __pycache__
16
+ global-exclude *.py[cod]
@@ -0,0 +1,489 @@
1
+ Metadata-Version: 2.4
2
+ Name: epptools
3
+ Version: 1.0.0
4
+ Summary: Dependency-free Python client for EPP domain registries (RFC 5730-5734 over TLS), with a registry's own extensions discovered from its greeting.
5
+ Author: EppTools
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/epptools/python-sdk
8
+ Project-URL: Repository, https://github.com/epptools/python-sdk
9
+ Project-URL: Issues, https://github.com/epptools/python-sdk/issues
10
+ Project-URL: Documentation, https://github.com/epptools/python-sdk/blob/main/docs/en/README.md
11
+ Project-URL: Changelog, https://github.com/epptools/python-sdk/blob/main/CHANGELOG.md
12
+ Keywords: epp,registry,registrar,domains,rfc5730,rfc5731,dnssec,sdk
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.8
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: Name Service (DNS)
24
+ Requires-Python: >=3.8
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # EppTools — EPP SDK for Python
30
+
31
+ A small, **dependency-free** Python client for **any** EPP domain registry — standard
32
+ **RFC 5730–5734** EPP over TLS, conventionally on port 700. It speaks the wire protocol directly
33
+ (no framework, no server-side code), so you can drop it into any Python 3.8+ project.
34
+ Every command frame is standard, schema-valid EPP.
35
+
36
+ **Manual:** [English](docs/en/README.md) · [Українською](docs/uk/README.md) · [Русский](docs/ru/README.md) — every command, every builder step and every response accessor, with examples.
37
+
38
+ - TLS transport with correct RFC 5734 framing (4-byte length prefix, UTF-8 byte-safe).
39
+ - Session: `connect` / `login` / `logout`, with the login services taken from the server
40
+ greeting automatically (never rejected for an unsupported service).
41
+ - Full object commands: **domain**, **contact**, **host** (check / info / create / update /
42
+ delete / transfer / renew), plus **poll**.
43
+ - Extensions: **secDNS** (RFC 5910), **RGP restore** (RFC 3915), **fees** (RFC 8748:
44
+ prices in ``check``, fee agreement on transforms) and **login security** (RFC 8807).
45
+ - **Your registry's own extensions, without configuring anything.** No registry's namespaces are
46
+ compiled in: they are read from the `<greeting>` the server sends before you say a word, so this
47
+ works against a registry it has never seen — and keeps working when one changes its URIs. Override
48
+ them in `Config` for a registry whose naming discovery cannot guess.
49
+ - Clean `Response` objects (result code, message, availability map, value getters) and typed
50
+ exceptions, and the package ships `py.typed`.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install epptools
56
+ ```
57
+
58
+ Or straight from GitHub, pinned to a release tag, if you would rather not depend on PyPI:
59
+
60
+ ```bash
61
+ pip install "epptools @ git+https://github.com/epptools/python-sdk@v1.0.0"
62
+ ```
63
+
64
+ No packaging at all? Copy the `epptools/` package folder next to your code and
65
+ `import epptools`. The SDK requires only the Python standard library
66
+ (`ssl`, `socket`, `xml.etree`).
67
+
68
+ ## Quick start
69
+
70
+ ```python
71
+ from epptools import Client, Config
72
+ from epptools.exceptions import EppException
73
+
74
+ client = Client(Config(
75
+ host="epp.registry.example",
76
+ clid="your-clid",
77
+ password="your-secret",
78
+ port=700, # the EPP convention; some registries differ
79
+ lang="uk", # result-message language, from the greeting's <lang> list
80
+ # ca_file="/path/to/ca.pem", # only for a private-CA or self-signed certificate
81
+ ))
82
+
83
+ try:
84
+ client.connect() # TLS + read <greeting>
85
+ client.login()
86
+
87
+ avail = client.domain.check(["example.com.ua"]).availability()
88
+ # => {"example.com.ua": True}
89
+
90
+ info = client.domain.info("example.com.ua")
91
+ print(info.value("exDate"))
92
+
93
+ client.logout()
94
+ except EppException as exc:
95
+ print("EPP error:", exc)
96
+ finally:
97
+ client.disconnect()
98
+ ```
99
+
100
+ `Client` is also a context manager (`with Client(cfg) as client: ...`) that disconnects on exit.
101
+
102
+ ## TLS notes
103
+
104
+ | Scenario | Config |
105
+ |---|---|
106
+ | Public, browser-trusted certificate | nothing — the defaults (`verify_peer=True`, `verify_peer_name=True`) are correct |
107
+ | Private-CA or self-signed certificate | `ca_file` → the PEM bundle of the CA that signed the **server** certificate |
108
+ | Mutual TLS (the registry requires a client certificate) | `client_cert` + `client_key` (+ `client_key_passphrase` if the key is encrypted) |
109
+ | Hostname mismatch (development only) | `verify_peer_name=False` |
110
+
111
+ **Which of these applies is your registry's choice, so ask them.** Many present an ordinary
112
+ browser-trusted certificate, and then there is nothing to configure. Others run their own CA, whose
113
+ certificate is in no system trust store: `ca_file` must point at that bundle or the handshake fails
114
+ with verification errors.
115
+
116
+ Authentication is clID + password inside TLS. A client certificate is needed only where the registry
117
+ requires mutual TLS, and many additionally restrict access to registered source addresses — that
118
+ part is policy, not protocol.
119
+
120
+ ### When the handshake fails
121
+
122
+ The commonest first-run failure is certificate verification, and it looks like this:
123
+
124
+ ```
125
+ ConnectionException: TLS connect failed: certificate verify failed
126
+ ```
127
+
128
+ That almost always means `ca_file` is unset or points at the wrong bundle. Check it before anything
129
+ else:
130
+
131
+ ```bash
132
+ openssl s_client -connect epp.registry.example:700 -CAfile /path/to/registry-ca.pem </dev/null
133
+ # "Verify return code: 0 (ok)" means the bundle is right; anything else means it is not.
134
+ ```
135
+
136
+ **Do not reach for `verify_peer=False`.** It makes the message go away and leaves you sending your
137
+ clID, your password and every transfer secret to whatever answers on that address, with no way to
138
+ tell. If the handshake will not verify, the bundle is wrong — ask the registry for the current one.
139
+ `verify_peer_name=False` is a narrower loosening (right certificate, wrong hostname) and is
140
+ occasionally reasonable in development; `verify_peer=False` is not reasonable anywhere.
141
+
142
+ ## Commands
143
+
144
+ ```python
145
+ # Session
146
+ client.connect(); client.login(); client.logout(); client.disconnect()
147
+ client.login("new-password") # rotate the EPP password during login
148
+ client.hello() # re-read the greeting / keep-alive
149
+
150
+ # Domain
151
+ client.domain.check(["example1.com.ua", "example2.com.ua"])
152
+ client.domain.info("example1.com.ua", "pw")
153
+ client.domain.create("example1.com.ua",
154
+ years=1, registrant="C1", contacts={"admin": "C1", "tech": "C2"},
155
+ nameservers=["ns1.example.net", "ns2.example.net"], auth_info="pw",
156
+ # Or with the glue inlined, where the registry wants the addresses with the name rather
157
+ # than a reference to a host object you created first. A command uses one model or the
158
+ # other — a mixture is a ValidationException here rather than a 2001 from the registry:
159
+ # nameservers=[{"name": "ns1.example.net", "addresses": ["203.0.113.1", "2001:db8::1"]}],
160
+ license="TM-123", # where your registry requires one
161
+ sec_dns={"ds_data": [{"key_tag": 12345, "alg": 8, "digest_type": 2, "digest": "ABCD..."}]})
162
+ client.domain.update("example1.com.ua",
163
+ add={"ns": ["ns3.example.net"], "statuses": ["clientHold"]},
164
+ rem={"statuses": ["clientHold"]},
165
+ chg={"registrant": "C9", "auth_info": "newpw"},
166
+ # DNSSEC (RFC 5910): sec_dns={"add": {"ds_data": [...]}, "rem_all": True, "max_sig_life": 1209600}
167
+ # (the RFC camelCase spelling — dsData / remAll / maxSigLife / keyTag — is accepted too;
168
+ # anything else raises ValidationException instead of silently dropping the DNSSEC block)
169
+ )
170
+ client.domain.renew("example1.com.ua", "2027-01-15", 1)
171
+ client.domain.restore("example1.com.ua") # RGP restore (op="request")
172
+ client.domain.delete("example1.com.ua")
173
+ client.domain.transfer("request", "example1.com.ua", "pw", 1)
174
+
175
+ # Prices (RFC 8748 fee extension) — every fee= below is OPTIONAL. Without it the
176
+ # registry's own price is charged. Two independent uses: ASK the price in check();
177
+ # CAP what you agree to pay on a transform — if the actual price is HIGHER (tariff
178
+ # change, premium name, stale cache) the command is refused (2004) and nothing is
179
+ # charged, instead of silently billing you more.
180
+ r = client.domain.check(["example1.com.ua"], fee={"create": 1, "renew": 1})
181
+ fees = r.fees() # {"_currency": "UAH", "example1.com.ua": {"commands": {"create": {"fee": "100.00", ...}}}}
182
+ # A whole price table in ONE round trip: a LIST of years asks the same operation at each period.
183
+ # Up to 20 entries per frame; transfer and restore are one-year operations however many you ask.
184
+ table = client.domain.check(["example1.com.ua"], fee={"create": [1, 2, 3, 5, 10]}, currency="UAH")
185
+ table.fee_for("example1.com.ua", "create", 5) # "480.00" — or None with a reason in fees()
186
+ client.domain.create("example1.com.ua", years=1, registrant="C1",
187
+ fee="100.00") # "I agree to pay up to 100.00" — not a price you set
188
+ client.domain.renew("example1.com.ua", "2027-01-15", 1, fee={"amount": "90.00", "currency": "UAH"})
189
+ client.domain.restore("example1.com.ua", fee="500.00")
190
+
191
+ # Contact
192
+ client.contact.check(["c1"])
193
+ client.contact.info("c1", "pw")
194
+ client.contact.create("c1", name="ACME", city="Kyiv", cc="UA", email="contact@example.com", auth_info="pw",
195
+ # postal_infos=[{"type": "int", ...}, {"type": "loc", ...}], # int + localized
196
+ # disclose={"flag": False, "addr": ["int"], "voice": True}, # RFC 5733 privacy
197
+ )
198
+ # No naming scheme of your own? Let the registry choose the handle and read it back. Every call
199
+ # mints a fresh one, so a repeat is a second contact rather than a 2302 collision.
200
+ handle = client.contact.create_auto(
201
+ name="ACME", city="Kyiv", cc="UA", email="contact@example.com").object_name() # appears HERE and nowhere else
202
+ client.contact.update("c1",
203
+ # Inside an address, PRESENCE decides: a field you leave out keeps its value, and a field given
204
+ # as "" is CLEARED — the only way to remove org, sp or pc. The block needs its city and country
205
+ # whenever you touch it, because the schema makes them required.
206
+ chg={"email": "new-contact@example.com",
207
+ "postal_info": {"name": "New Name", "city": "Lviv", "cc": "UA", "org": ""}},
208
+ add_statuses=["clientUpdateProhibited"])
209
+ client.contact.delete("c1")
210
+ client.contact.transfer("request", "c1", "pw")
211
+
212
+ # Host
213
+ client.host.check(["ns1.example.net"])
214
+ client.host.info("ns1.example.net")
215
+ client.host.create("ns1.example.net", ["203.0.113.10", "2001:db8::1"]) # v4/v6 auto-detected
216
+ client.host.update("ns1.example.net", add_addresses=["203.0.113.11"])
217
+ client.host.delete("ns1.example.net")
218
+
219
+ # Poll & balance
220
+ msg = client.poll.request() # 1301 with a message, 1300 when empty
221
+ if msg.message_id() is not None: # message_count() = how many remain
222
+ msg.queue_message() # the NOTICE text (<msgQ><msg>) — read this
223
+ msg.queue_message_lang() # its language: "uk" | "ru" | "en"
224
+ msg.queue_date() # when it was queued
225
+ client.poll.ack(msg.message_id()) # ack DESTROYS it at the registry
226
+ b = client.balance().balance() # {"creditLimit": ..., "balance": ..., "availableCredit": ...}
227
+ ```
228
+
229
+ ## Responses
230
+
231
+ Every command returns a `Response`:
232
+
233
+ ```python
234
+ r.code() # int EPP result code (1000, 1001, 2303, ...)
235
+ r.is_success() # True for 1xxx
236
+ r.is_pending() # True for 1001 (registry resolves via a poll message)
237
+ r.message() # human-readable <msg>
238
+ r.message_lang() # "en" | "uk" | "ua" | "ru"
239
+ r.availability() # {name: bool} for *:check
240
+ r.statuses() # ["ok"] or ["clientHold", ...]
241
+ r.value("exDate") # first element with that local name
242
+ r.values("hostObj") # all elements with that local name (nameservers are <domain:hostObj>)
243
+ r.balance() # {"creditLimit": ..., "balance": ..., "availableCredit": ...} or None
244
+ r.prices() # {"renewal": {"value": ..., "currency": "UAH"}, ...}
245
+ r.fees() # check+fee: per-name RFC 8748 prices (see above), {} when absent
246
+ r.charged_fee() # transform echo: {"currency": "UAH", "fee": "100.00"} or None
247
+ r.price_channel() # domain:info: which price channel those prices belong to, or None
248
+ r.license() # a trademark or licence number, or None
249
+ r.rgp_status() # ["redemptionPeriod"], ...
250
+ r.transfer_status() # "pending" | "serverApproved" | ... or None
251
+ r.ds_records() # [{"keyTag":..,"alg":..,"digestType":..,"digest":..}, ...]
252
+ r.key_records() # [{"flags":..,"protocol":..,"alg":..,"pubKey":..}, ...]
253
+ r.is_signed() # bool: any DNSSEC data present
254
+ r.message_id() # poll: id to pass to poll.ack(); message_count() = queue size
255
+ r.queue_message() # poll: the NOTICE text (<msgQ><msg>), NOT the result banner
256
+ r.queue_message_lang() # poll: the notice's language ("uk" | "ru" | "en")
257
+ r.queue_date() # poll: when the notice was queued
258
+ r.error_reasons() # extra <extValue><reason> text on a failed command
259
+ r.sv_trid() # server transaction id
260
+ r.raw # the raw XML
261
+ r.root # the parsed ElementTree root, for anything bespoke
262
+ ```
263
+
264
+ ### Reading an object without touching XML
265
+
266
+ The getters above return the frame's shape; these return the answer. Everything an ``info``,
267
+ ``check`` or ``transfer`` response carries has a named accessor, so you never index into a dict by
268
+ a string you had to guess.
269
+
270
+ ```python
271
+ # Any object
272
+ r.object_name() # the domain name, the host name or the contact HANDLE
273
+ r.roid() # the registry's own identifier
274
+ r.sponsor() # clID — the registrar it belongs to now
275
+ r.created_by() # crID r.created_date() # crDate
276
+ r.updated_by() # upID, or None when never changed r.updated_date()
277
+ r.auth_info() # <authInfo><pw> — the transfer secret; never log it
278
+
279
+ # Domain
280
+ r.expiry_date() # exDate, exactly as the registry wrote it (see the note below)
281
+ r.registrant() # the registrant handle
282
+ r.contacts() # {"admin": ["c-1"], "tech": ["c-1", "c-2"]}
283
+ r.tech_contacts() # just that role — also admin_contacts() / billing_contacts()
284
+ r.contacts_for("tech") # any role, matched case-insensitively; [] when nobody holds it
285
+ r.all_contacts() # every handle including the registrant, de-duplicated
286
+ r.nameservers() # names, whether the registry sent hostObj or hostAttr
287
+ r.nameserver_addresses() # hostAttr glue, keyed by nameserver name
288
+ r.subordinate_hosts() # hosts living UNDER this domain — they block a delete
289
+ r.transfer() # {"status","requested_by","requested_at","acting_client","act_by","expiry_date"}
290
+ r.transfer_date() # when it last changed hands, or None
291
+ r.registrar_of_record() # the handle the registry's own WHOIS/RDAP publishes as the registrar
292
+ # — which for a reseller is not the same party as sponsor()
293
+
294
+ # Host
295
+ r.host_addresses() # [{"ip": "192.0.2.1", "version": "v4"}, ...]
296
+
297
+ # Contact
298
+ r.postal_info() # {"int": {...}, "loc": {...}} — name, org, street[], city, sp, pc, cc
299
+ r.email() r.voice() r.fax()
300
+ r.disclose() # {"flag": False, "elements": ["email", "voice"]} or None
301
+
302
+ # Check + money
303
+ r.is_available("example.com.ua") # True | False | None ("the answer said nothing")
304
+ r.unavailable_reason("taken.com.ua") # "In use", or None when it is available
305
+ r.is_premium("rare.com.ua") # priced outside the standard list
306
+ r.fee_class("rare.com.ua") # "premium" | "standard" | None
307
+ r.credit_limit() r.current_balance() r.available_credit()
308
+ r.fee_amount() r.fee_currency() # what this transform actually charged
309
+ r.ext_values() # per-<extValue>: which ELEMENT the registry rejected, plus the reason
310
+ ```
311
+
312
+ Two things worth knowing before you build on these:
313
+
314
+ - **Dates come back as the registry's own string** (``2027-04-01T09:15:00Z``), never a
315
+ ``datetime``. The registry decides which calendar day a renewal lands on; re-formatting through a
316
+ local timezone is how a client ends up displaying — and renewing against — the day before.
317
+ - **Money comes back as an exact decimal string**, never a ``float``. ``0.1 + 0.2`` is not ``0.3``
318
+ in binary floating point, and a balance summed that way drifts. Use ``decimal.Decimal``.
319
+
320
+
321
+ ## Building a command step by step
322
+
323
+ Keyword arguments already give you named parameters and a loud `TypeError` on a misspelling, so
324
+ write the direct call when the whole command is in one place. Reach for a builder when it is
325
+ assembled in pieces — across branches, in a loop, or from a form:
326
+
327
+ ```python
328
+ response = (client.domain.create_builder("your-brand.com.ua")
329
+ .years(1)
330
+ .registrant("acme-01")
331
+ .admin_contact("acme-01")
332
+ .tech_contact("acme-ns1").tech_contact("acme-ns2") # accumulates
333
+ .nameserver("ns1.acme.example").nameserver("ns2.acme.example")
334
+ # or, where the registry wants the glue inlined instead of a host object:
335
+ # .nameserver_with_glue("ns1.acme.example", "203.0.113.1", "2001:db8::1")
336
+ .auth_info("D0main-Pw")
337
+ .max_fee("180.00", "UAH") # a cap you consent to, not a price you set
338
+ .send())
339
+ ```
340
+
341
+ Available on `domain.create_builder()` / `update_builder()`, `contact.create_builder(id, email)` /
342
+ `update_builder()`, and `host.update_builder()`. Same command, same frame, same result — the
343
+ builder calls the ordinary method. Three things worth knowing:
344
+
345
+ - **Every list step accumulates.** `.tech_contact("a").tech_contact("b")` and
346
+ `.tech_contact("a", "b")` are the same thing.
347
+ - **Nothing is sent until `send()`.** Until then the builder is an ordinary value you can keep,
348
+ pass around, or inspect with `to_options()` — which returns exactly the keyword arguments the
349
+ direct call takes.
350
+ - **A builder sends once.** Sending twice would be two registrations and two charges, so the second
351
+ `send()` is refused.
352
+
353
+ An update builder names the block each change lands in — `add_nameserver`, `rem_status`,
354
+ `change_registrant` — because an EPP update is a delta, and which block a change belongs to is the
355
+ whole semantics of the command.
356
+
357
+ ## Reading the message queue
358
+
359
+ ```python
360
+ client.poll.drain(lambda notice: store(notice.queue_message(), notice.pending_action_data()))
361
+ ```
362
+
363
+ The order matters and is the reason this helper exists: each notice is acknowledged only **after**
364
+ your callback returns. An ack deletes the notice at the registry permanently, so a loop that acks
365
+ first and processes second loses every notice whose processing fails — a transfer request, the
366
+ outcome of a pending create — with nothing left to retry from. If your callback raises, the notice
367
+ stays in the queue and the exception reaches you.
368
+
369
+ ## Session security warnings (RFC 8807)
370
+
371
+ Where the server offers the Login Security extension, the login carries a small block identifying
372
+ this client, and the server answers with anything it wants you to fix about the session:
373
+
374
+ ```python
375
+ for event in client.login().security_events():
376
+ # type: certificate | cipher | tlsProtocol | password | newPW | stat | custom
377
+ # level: "warning" or "error"; text: a sentence to show an operator
378
+ alert(event["level"], event["type"], event["text"], event.get("exDate"))
379
+ ```
380
+
381
+ The list is empty on a healthy session, so treat any entry as something to act on. The commonest
382
+ one is a client certificate approaching its expiry date — the alternative to hearing about it here
383
+ is finding out on the morning it stops working.
384
+
385
+ A server sends these only to a client that took part in the extension, because announcing a URI is
386
+ not evidence of supporting it. That is why the block goes out even when nothing needs to travel in
387
+ it. If you would rather stay off the extension, set `login_security=False` in the config; it is
388
+ still used for a password longer than the 16 characters the base `<pw>` element can carry, since
389
+ there is nowhere else for that to go.
390
+
391
+ ## Error handling
392
+
393
+ Every failure extends `EppException`, so one `except` handles everything. Beyond that, a class
394
+ exists where the right next step differs — and nowhere else:
395
+
396
+ | Catch | When | What to do |
397
+ |---|---|---|
398
+ | `ValidationException` | a value in THIS call is unusable; nothing was sent | fix the arguments |
399
+ | `ConfigException` | the client is set up wrong: no host, no credentials | fix the deployment; every call fails until then |
400
+ | `ConnectionException` | TLS, timeout, framing | see the TLS notes above; the connection is closed |
401
+ | `InsufficientFundsError` | 2104 | **stop the batch**, top up, resume — every later billable command fails the same way |
402
+ | `AuthorizationError` | 2201 / 2202 | not yours, or the wrong authInfo |
403
+ | `ObjectExistsError` | 2302 | already registered |
404
+ | `ObjectDoesNotExistError` | 2303 | stale handle or typo |
405
+ | `ObjectStatusError` | 2304 / 2305 | clear the status or association, then repeat |
406
+ | `PolicyError` | 2306 / 2308 | the registry's rules refuse this value |
407
+ | `SessionError` | 2500–2502 | reconnect and log in again |
408
+ | `AuthenticationException` | 2200 | the login itself failed |
409
+ | `CommandException` | any other >= 2000 | branch on `.epp_code` |
410
+
411
+ `ValidationException` and `ConfigException` are also `ValueError`s, so an `except ValueError` you
412
+ already have keeps catching them.
413
+
414
+ ```python
415
+ from epptools.exceptions import CommandException, InsufficientFundsError, ObjectExistsError
416
+
417
+ for name in names_to_register:
418
+ try:
419
+ client.domain.create_builder(name).years(1).registrant("acme-01").send()
420
+ except InsufficientFundsError as exc:
421
+ # Not this name's problem — the account's. Carrying on would produce the same failure
422
+ # for every remaining name.
423
+ alert_billing(str(exc))
424
+ break
425
+ except ObjectExistsError as exc:
426
+ taken.append(exc.subject() or name) # which one the registry objected to
427
+ except CommandException as exc:
428
+ if not exc.is_retryable(): # retrying cannot change the answer
429
+ raise
430
+ retry_later.append(name)
431
+ ```
432
+
433
+ `is_retryable()` is true only for failures about the moment rather than the request (2400, and the
434
+ 2500-family after you reconnect). It is deliberately false for everything else: retrying a 2302
435
+ cannot make the name free, and a loop that treats every failure as transient turns one refusal into
436
+ a rate-limit ban.
437
+
438
+ `ResultCode` has named constants for every code, and `throw_on_failure(False)` turns raising off
439
+ entirely if you would rather read `response.code()` yourself.
440
+
441
+ ### When a transform fails and you do not know whether it happened
442
+
443
+ A read timeout, a dropped connection or a `ConnectionException` in the middle of a `create`,
444
+ `renew` or `transfer` leaves a genuinely unknown outcome: the registry may have carried the command
445
+ out and billed you before the reply was lost. This library cannot tell the difference, and neither
446
+ can you from the exception.
447
+
448
+ **Do not simply retry.** A blind retry is how a domain gets registered — and paid for — twice.
449
+ Instead, ask the registry what is true: `domain.info()` for a create, and compare `expiry_date()`
450
+ against what you expected for a renew. Reconcile from that, then retry only if the object really is
451
+ in the state you started from. A failure whose outcome you cannot determine deserves an operator's
452
+ attention, not an automatic second attempt.
453
+
454
+ ## Custom frames
455
+
456
+ Anything the high-level API doesn't cover can be built with `Frame` and sent raw:
457
+
458
+ ```python
459
+ from epptools import Frame, Namespaces
460
+
461
+ frame = Frame.command("my-trid-1")
462
+ check = frame.ns(frame.verb("check"), Namespaces.DOMAIN, "domain:check")
463
+ frame.ns(check, Namespaces.DOMAIN, "domain:name", "example3.com.ua")
464
+ resp = client.request(frame) # or client.request(raw_xml_string)
465
+ ```
466
+
467
+ ## Testing
468
+
469
+ A no-dependency offline self-test (frame building + response parsing, no server, no network):
470
+
471
+ ```bash
472
+ python tests/offline_test.py
473
+ ```
474
+
475
+ ## Support
476
+
477
+ Questions about the library, a frame the registry rejected, or a bug: **https://github.com/epptools/python-sdk/issues**.
478
+
479
+ When reporting a problem, include the **svTRID** from the response (`sv_trid()`) and the clTRID your
480
+ client sent — together they identify the exact transaction in the registry's logs, which is what
481
+ makes a report answerable without a round trip. Send the frames too if you can, but **redact
482
+ `<pw>`, `<newPW>` and `<authInfo>` first**: those are live credentials, and the library masks them
483
+ in its own logs for the same reason.
484
+
485
+ Account, billing and registration questions go to your registry account manager, not here — this
486
+ address is for the client libraries.
487
+ ## License
488
+
489
+ MIT — see [LICENSE](LICENSE).