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.
- epptools-1.0.0/CHANGELOG.md +46 -0
- epptools-1.0.0/LICENSE +21 -0
- epptools-1.0.0/MANIFEST.in +16 -0
- epptools-1.0.0/PKG-INFO +489 -0
- epptools-1.0.0/README.md +461 -0
- epptools-1.0.0/docs/en/README.md +60 -0
- epptools-1.0.0/docs/en/balance.md +469 -0
- epptools-1.0.0/docs/en/builders.md +481 -0
- epptools-1.0.0/docs/en/commands.md +261 -0
- epptools-1.0.0/docs/en/contacts.md +475 -0
- epptools-1.0.0/docs/en/domains.md +715 -0
- epptools-1.0.0/docs/en/errors.md +402 -0
- epptools-1.0.0/docs/en/hosts.md +273 -0
- epptools-1.0.0/docs/en/poll.md +366 -0
- epptools-1.0.0/docs/en/quickstart.md +195 -0
- epptools-1.0.0/docs/en/responses.md +314 -0
- epptools-1.0.0/docs/en/session.md +310 -0
- epptools-1.0.0/docs/ru/README.md +61 -0
- epptools-1.0.0/docs/ru/balance.md +474 -0
- epptools-1.0.0/docs/ru/builders.md +485 -0
- epptools-1.0.0/docs/ru/commands.md +263 -0
- epptools-1.0.0/docs/ru/contacts.md +480 -0
- epptools-1.0.0/docs/ru/domains.md +719 -0
- epptools-1.0.0/docs/ru/errors.md +408 -0
- epptools-1.0.0/docs/ru/hosts.md +273 -0
- epptools-1.0.0/docs/ru/poll.md +370 -0
- epptools-1.0.0/docs/ru/quickstart.md +199 -0
- epptools-1.0.0/docs/ru/responses.md +317 -0
- epptools-1.0.0/docs/ru/session.md +314 -0
- epptools-1.0.0/docs/uk/README.md +61 -0
- epptools-1.0.0/docs/uk/balance.md +474 -0
- epptools-1.0.0/docs/uk/builders.md +483 -0
- epptools-1.0.0/docs/uk/commands.md +263 -0
- epptools-1.0.0/docs/uk/contacts.md +478 -0
- epptools-1.0.0/docs/uk/domains.md +721 -0
- epptools-1.0.0/docs/uk/errors.md +407 -0
- epptools-1.0.0/docs/uk/hosts.md +271 -0
- epptools-1.0.0/docs/uk/poll.md +368 -0
- epptools-1.0.0/docs/uk/quickstart.md +201 -0
- epptools-1.0.0/docs/uk/responses.md +317 -0
- epptools-1.0.0/docs/uk/session.md +315 -0
- epptools-1.0.0/epptools/__init__.py +72 -0
- epptools-1.0.0/epptools/_version.py +7 -0
- epptools-1.0.0/epptools/builders.py +639 -0
- epptools-1.0.0/epptools/client.py +435 -0
- epptools-1.0.0/epptools/commands.py +907 -0
- epptools-1.0.0/epptools/config.py +80 -0
- epptools-1.0.0/epptools/exceptions.py +176 -0
- epptools-1.0.0/epptools/frame.py +148 -0
- epptools-1.0.0/epptools/namespaces.py +95 -0
- epptools-1.0.0/epptools/py.typed +1 -0
- epptools-1.0.0/epptools/response.py +894 -0
- epptools-1.0.0/epptools/result_code.py +53 -0
- epptools-1.0.0/epptools/transport.py +151 -0
- epptools-1.0.0/epptools.egg-info/PKG-INFO +489 -0
- epptools-1.0.0/epptools.egg-info/SOURCES.txt +58 -0
- epptools-1.0.0/epptools.egg-info/dependency_links.txt +1 -0
- epptools-1.0.0/epptools.egg-info/top_level.txt +1 -0
- epptools-1.0.0/pyproject.toml +43 -0
- 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]
|
epptools-1.0.0/PKG-INFO
ADDED
|
@@ -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).
|