epptools 1.0.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.
epptools/builders.py ADDED
@@ -0,0 +1,639 @@
1
+ """Fluent command builders.
2
+
3
+ A builder is a typed façade over the keyword arguments the command takes. It builds no XML of its
4
+ own: ``send()`` hands the options straight to the ordinary method, so a builder and the equivalent
5
+ call produce the identical frame, and every check that applies to one applies to the other.
6
+
7
+ Python's keyword arguments already give you named parameters and a loud ``TypeError`` on a
8
+ misspelling, so reach for a builder when the command is assembled in pieces — across branches, in
9
+ a loop, or from a form — rather than written out in one place. Both forms are supported::
10
+
11
+ client.domain.create("example3.com.ua", years=1, registrant="C1") # direct
12
+
13
+ (client.domain.create_builder("example3.com.ua") # step by step
14
+ .years(1)
15
+ .registrant("C1")
16
+ .nameserver("ns1.example")
17
+ .send())
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import copy
23
+ import re
24
+ from typing import Any, Dict, List, Optional, Sequence
25
+
26
+ from .exceptions import ValidationException
27
+ from .response import Response
28
+
29
+ _FEE_AMOUNT_RE = re.compile(r"^\d{1,10}(\.\d{1,2})?$")
30
+
31
+
32
+ def _fee_agreement(amount: str, currency: Optional[str]) -> Any:
33
+ """The RFC 8748 fee AGREEMENT: the most you consent to pay.
34
+
35
+ Not a price you set — the registry charges its own. If the real price is HIGHER the command is
36
+ refused (2004) and nothing is charged, which is the point: a tariff change or a premium name
37
+ cannot bill you more than you agreed to.
38
+ """
39
+ value = str(amount).strip()
40
+ if _FEE_AMOUNT_RE.match(value) is None:
41
+ # Checked here rather than on the wire: a malformed agreement draws a bare 2001 that names
42
+ # no field, and it arrives after the command has been attempted.
43
+ raise ValidationException(
44
+ "a fee amount must be a plain decimal like '100.00' (got %r)" % (value,)
45
+ )
46
+ return value if currency is None else {"amount": value, "currency": currency}
47
+
48
+
49
+ def _ds_record(key_tag: int, alg: int, digest_type: int, digest: str) -> Dict[str, Any]:
50
+ value = str(digest or "").strip()
51
+ if not value:
52
+ raise ValidationException("a DS record needs a digest")
53
+ return {"key_tag": key_tag, "alg": alg, "digest_type": digest_type, "digest": value}
54
+
55
+
56
+ def _key_record(flags: int, protocol: int, alg: int, pub_key: str) -> Dict[str, Any]:
57
+ value = str(pub_key or "").strip()
58
+ if not value:
59
+ raise ValidationException("a key record needs a public key")
60
+ return {"flags": flags, "protocol": protocol, "alg": alg, "pub_key": value}
61
+
62
+
63
+ def _postal_info(kind: str, name: str, city: str, country_code: str,
64
+ street: Sequence[str] = (), org: Optional[str] = None,
65
+ state_province: Optional[str] = None,
66
+ postal_code: Optional[str] = None) -> Dict[str, Any]:
67
+ block: Dict[str, Any] = {"type": kind, "name": name, "city": city, "cc": country_code}
68
+ if street:
69
+ block["street"] = [str(line) for line in street]
70
+ if org is not None:
71
+ block["org"] = org
72
+ if state_province is not None:
73
+ block["sp"] = state_province
74
+ if postal_code is not None:
75
+ block["pc"] = postal_code
76
+ return block
77
+
78
+
79
+ def _disclosure(publish: bool, fields: Sequence[str]) -> Dict[str, Any]:
80
+ spec: Dict[str, Any] = {"flag": publish}
81
+ for field in fields:
82
+ name = str(field).strip()
83
+ # name/org/addr exist once per postal form, so the choice is per form. Both are named:
84
+ # withholding only the ASCII form while the local one stays public is a privacy setting
85
+ # that reads as applied and is not.
86
+ if name in ("name", "org", "addr"):
87
+ spec[name] = ["int", "loc"]
88
+ elif name in ("voice", "fax", "email"):
89
+ spec[name] = True
90
+ else:
91
+ raise ValidationException(
92
+ "%r is not a disclosable field. Use: name, org, addr, voice, fax, email." % (name,)
93
+ )
94
+ return spec
95
+
96
+
97
+ def _append(target: Dict[str, Any], key: str, values: Sequence[str]) -> Dict[str, Any]:
98
+ existing: List[str] = list(target.get(key) or [])
99
+ for value in values:
100
+ text = str(value).strip()
101
+ if text:
102
+ existing.append(text)
103
+ target[key] = existing
104
+ return target
105
+
106
+
107
+ def _append_contacts(target: Dict[str, Any], role: str, handles: Sequence[str]) -> Dict[str, Any]:
108
+ name = str(role).strip()
109
+ if not name:
110
+ raise ValidationException("a contact role must not be empty (admin, tech, billing, …)")
111
+ return _append(target, name, handles)
112
+
113
+
114
+ class Builder:
115
+ """Shared behaviour of the fluent builders."""
116
+
117
+ def __init__(self, handler: Any, object_id: str) -> None:
118
+ self._handler = handler
119
+ self._id = object_id
120
+ self._options: Dict[str, Any] = {}
121
+ self._sent = False
122
+
123
+ def to_options(self) -> Dict[str, Any]:
124
+ """The options as the equivalent direct call would take them.
125
+
126
+ Useful for a dry run, for logging what you are about to do, or for handing the command to a
127
+ queue. Sends nothing and does not spend the builder.
128
+ """
129
+ # A COPY, and a deep one: the result is a value you can keep, log or queue. Handing back
130
+ # the live dict would make it change under the caller every time another step is added, so
131
+ # what was logged and what was sent could differ.
132
+ return copy.deepcopy(self._options)
133
+
134
+ def _mark_sent(self) -> None:
135
+ # A builder is a command that has not happened yet, so sending it twice would be two
136
+ # registrations and two charges — and the second is never what the caller meant.
137
+ if self._sent:
138
+ raise ValidationException(
139
+ "%s has already been sent. A builder carries one command; build another rather "
140
+ "than re-sending this one." % type(self).__name__
141
+ )
142
+ self._sent = True
143
+
144
+
145
+ class DomainCreateBuilder(Builder):
146
+ """Registers a domain, one named step at a time."""
147
+
148
+ def years(self, years: int) -> "DomainCreateBuilder":
149
+ """Registration period in years. Omit it and the registry applies its own default."""
150
+ self._options["years"] = years
151
+ return self
152
+
153
+ def registrant(self, handle: str) -> "DomainCreateBuilder":
154
+ """The registrant — the holder of the domain. Required by the registry."""
155
+ self._options["registrant"] = handle
156
+ return self
157
+
158
+ def contact(self, role: str, *handles: str) -> "DomainCreateBuilder":
159
+ """Attach contacts in a role.
160
+
161
+ Every list method ACCUMULATES: pass several at once, or call it again, or both — the effect
162
+ is the same. RFC 5731 puts no limit on handles per role.
163
+ """
164
+ self._options["contacts"] = _append_contacts(self._options.get("contacts") or {}, role, handles)
165
+ return self
166
+
167
+ def admin_contact(self, *handles: str) -> "DomainCreateBuilder":
168
+ """The people authorised to make decisions about the domain. Accumulates."""
169
+ return self.contact("admin", *handles)
170
+
171
+ def tech_contact(self, *handles: str) -> "DomainCreateBuilder":
172
+ """The people to reach about DNS and delegation. Accumulates."""
173
+ return self.contact("tech", *handles)
174
+
175
+ def billing_contact(self, *handles: str) -> "DomainCreateBuilder":
176
+ """The people to reach about invoices for this domain. Accumulates."""
177
+ return self.contact("billing", *handles)
178
+
179
+ def nameserver(self, host: str) -> "DomainCreateBuilder":
180
+ """One nameserver to delegate to. Accumulates; suits a loop or a conditional."""
181
+ return self.nameservers(host)
182
+
183
+ def nameservers(self, *hosts: str) -> "DomainCreateBuilder":
184
+ """The nameservers to delegate to. Accumulates."""
185
+ _append(self._options, "nameservers", hosts)
186
+ return self
187
+
188
+ def nameserver_with_glue(self, host: str, *addresses: str) -> "DomainCreateBuilder":
189
+ """A nameserver given with its glue addresses, inlined in the command (RFC 5731 hostAttr).
190
+
191
+ Use this where the registry expects the addresses with the name rather than a reference to
192
+ a host object created beforehand; ask yours which model it takes. A command cannot mix the
193
+ two, so use either this or :meth:`nameserver`, not both.
194
+ """
195
+ name = str(host).strip()
196
+ if not name:
197
+ raise ValidationException("a nameserver name must not be empty")
198
+ # Appended directly rather than through _append(), which is for plain names and would
199
+ # stringify this one into a hostObj reading "{'name': ...}".
200
+ existing = list(self._options.get("nameservers") or [])
201
+ existing.append({"name": name, "addresses": [str(a) for a in addresses]})
202
+ self._options["nameservers"] = existing
203
+ return self
204
+
205
+ def auth_info(self, password: str) -> "DomainCreateBuilder":
206
+ """The transfer secret.
207
+
208
+ Anyone holding it can move the domain to another registrar, so treat it as a credential:
209
+ never log it, and roll it after handing it to a customer.
210
+ """
211
+ self._options["auth_info"] = password
212
+ return self
213
+
214
+ def license(self, number: str) -> "DomainCreateBuilder":
215
+ """A trademark or licence number, for registries that require one to register a name."""
216
+ self._options["license"] = number
217
+ return self
218
+
219
+ def max_fee(self, amount: str, currency: Optional[str] = None) -> "DomainCreateBuilder":
220
+ """The most you agree to pay for this registration (RFC 8748).
221
+
222
+ Optional: without it the registry's own price is charged; with it, a higher real price
223
+ refuses the command instead of billing you the difference.
224
+ """
225
+ self._options["fee"] = _fee_agreement(amount, currency)
226
+ return self
227
+
228
+ def ds_record(self, key_tag: int, alg: int, digest_type: int, digest: str) -> "DomainCreateBuilder":
229
+ """Sign the domain with a DS record (RFC 5910). Call it again for a second key."""
230
+ sec = self._options.get("sec_dns") or {}
231
+ sec["ds_data"] = list(sec.get("ds_data") or []) + [_ds_record(key_tag, alg, digest_type, digest)]
232
+ self._options["sec_dns"] = sec
233
+ return self
234
+
235
+ def ds_record_with_key(self, key_tag: int, alg: int, digest_type: int, digest: str,
236
+ flags: int, protocol: int, key_alg: int,
237
+ pub_key: str) -> "DomainCreateBuilder":
238
+ """A DS record that carries the DNSKEY it was computed from (RFC 5910).
239
+
240
+ A registry that accepts this can verify the digest against the key for you, catching a
241
+ mistyped digest before it reaches the zone. One that does not accept DNSKEY data refuses the
242
+ command rather than ignoring the extra element, so trying costs you nothing but a 2306.
243
+ """
244
+ sec = self._options.get("sec_dns") or {}
245
+ record = _ds_record(key_tag, alg, digest_type, digest)
246
+ record["key_data"] = _key_record(flags, protocol, key_alg, pub_key)
247
+ sec["ds_data"] = list(sec.get("ds_data") or []) + [record]
248
+ self._options["sec_dns"] = sec
249
+ return self
250
+
251
+ def key_record(self, flags: int, protocol: int, alg: int, pub_key: str) -> "DomainCreateBuilder":
252
+ """Sign with a public key instead, where the registry accepts one."""
253
+ sec = self._options.get("sec_dns") or {}
254
+ sec["key_data"] = list(sec.get("key_data") or []) + [_key_record(flags, protocol, alg, pub_key)]
255
+ self._options["sec_dns"] = sec
256
+ return self
257
+
258
+ def max_sig_life(self, seconds: int) -> "DomainCreateBuilder":
259
+ """Maximum signature lifetime in seconds. Only meaningful alongside a DS or key record."""
260
+ sec = self._options.get("sec_dns") or {}
261
+ sec["max_sig_life"] = seconds
262
+ self._options["sec_dns"] = sec
263
+ return self
264
+
265
+ def send(self) -> Response:
266
+ """Send the command and return the registry's answer."""
267
+ self._mark_sent()
268
+ return self._handler.create(self._id, **self._options)
269
+
270
+
271
+ class DomainUpdateBuilder(Builder):
272
+ """Changes a domain, one named step at a time.
273
+
274
+ An EPP update is a DELTA, not a replacement: what you do not mention is left alone. The method
275
+ names say which of the three blocks each change lands in — add, rem or chg — because that
276
+ distinction is the whole semantics of the command.
277
+ """
278
+
279
+ def _block(self, name: str) -> Dict[str, Any]:
280
+ block = self._options.get(name) or {}
281
+ self._options[name] = block
282
+ return block
283
+
284
+ def add_nameserver(self, host: str) -> "DomainUpdateBuilder":
285
+ """Delegate to one more nameserver. Accumulates."""
286
+ return self.add_nameservers(host)
287
+
288
+ def add_nameservers(self, *hosts: str) -> "DomainUpdateBuilder":
289
+ """Delegate to these nameservers as well as the ones already there. Accumulates."""
290
+ _append(self._block("add"), "ns", hosts)
291
+ return self
292
+
293
+ def rem_nameserver(self, host: str) -> "DomainUpdateBuilder":
294
+ """Stop delegating to one nameserver. Accumulates."""
295
+ return self.rem_nameservers(host)
296
+
297
+ def rem_nameservers(self, *hosts: str) -> "DomainUpdateBuilder":
298
+ """Stop delegating to these nameservers. Accumulates."""
299
+ _append(self._block("rem"), "ns", hosts)
300
+ return self
301
+
302
+ def add_contact(self, role: str, *handles: str) -> "DomainUpdateBuilder":
303
+ """Attach contacts in a role, keeping the ones already there."""
304
+ block = self._block("add")
305
+ block["contacts"] = _append_contacts(block.get("contacts") or {}, role, handles)
306
+ return self
307
+
308
+ def rem_contact(self, role: str, *handles: str) -> "DomainUpdateBuilder":
309
+ """Detach contacts from a role."""
310
+ block = self._block("rem")
311
+ block["contacts"] = _append_contacts(block.get("contacts") or {}, role, handles)
312
+ return self
313
+
314
+ def add_status(self, *statuses: str) -> "DomainUpdateBuilder":
315
+ """Set a client-side status, e.g. ``clientHold`` (takes the domain out of DNS)."""
316
+ _append(self._block("add"), "statuses", statuses)
317
+ return self
318
+
319
+ def rem_status(self, *statuses: str) -> "DomainUpdateBuilder":
320
+ """Clear a client-side status."""
321
+ _append(self._block("rem"), "statuses", statuses)
322
+ return self
323
+
324
+ def change_registrant(self, handle: str) -> "DomainUpdateBuilder":
325
+ """Hand the domain to a different holder.
326
+
327
+ Many registries treat this as a change of ownership with its own rules, so a refusal is
328
+ usually policy rather than a malformed command.
329
+ """
330
+ self._block("chg")["registrant"] = handle
331
+ return self
332
+
333
+ def change_auth_info(self, password: str) -> "DomainUpdateBuilder":
334
+ """Replace the transfer secret."""
335
+ self._block("chg")["auth_info"] = password
336
+ return self
337
+
338
+ def clear_auth_info(self) -> "DomainUpdateBuilder":
339
+ """REMOVE the transfer secret entirely, so no code will move this domain.
340
+
341
+ This is the answer to a leak, and it is not the same as setting an empty one: an empty
342
+ password is a value the holder can still present, so the domain stays exactly as movable as
343
+ it was. Only this clears it. Set a fresh one with :meth:`change_auth_info` when the customer
344
+ needs it again.
345
+ """
346
+ self._block("chg")["clear_auth_info"] = True
347
+ return self
348
+
349
+ def restore(self) -> "DomainUpdateBuilder":
350
+ """Ask to bring a deleted domain back from the redemption period (RFC 3915)."""
351
+ self._options["restore"] = True
352
+ return self
353
+
354
+ def license(self, number: str) -> "DomainUpdateBuilder":
355
+ """A trademark or licence number, for registries that require one."""
356
+ self._options["license"] = number
357
+ return self
358
+
359
+ def max_fee(self, amount: str, currency: Optional[str] = None) -> "DomainUpdateBuilder":
360
+ """The most you agree to pay, when the change is a billable one (RFC 8748)."""
361
+ self._options["fee"] = _fee_agreement(amount, currency)
362
+ return self
363
+
364
+ def add_ds_record(self, key_tag: int, alg: int, digest_type: int, digest: str) -> "DomainUpdateBuilder":
365
+ """Add a DS record to an existing domain."""
366
+ return self._sec_dns("add", "ds_data", _ds_record(key_tag, alg, digest_type, digest))
367
+
368
+ def rem_ds_record(self, key_tag: int, alg: int, digest_type: int, digest: str) -> "DomainUpdateBuilder":
369
+ """Remove one specific DS record. Everything must match what the registry holds."""
370
+ return self._sec_dns("rem", "ds_data", _ds_record(key_tag, alg, digest_type, digest))
371
+
372
+ def add_key_record(self, flags: int, protocol: int, alg: int, pub_key: str) -> "DomainUpdateBuilder":
373
+ """Add a public key to an existing domain."""
374
+ return self._sec_dns("add", "key_data", _key_record(flags, protocol, alg, pub_key))
375
+
376
+ def rem_key_record(self, flags: int, protocol: int, alg: int, pub_key: str) -> "DomainUpdateBuilder":
377
+ """Remove one specific public key."""
378
+ return self._sec_dns("rem", "key_data", _key_record(flags, protocol, alg, pub_key))
379
+
380
+ def remove_all_dnssec(self) -> "DomainUpdateBuilder":
381
+ """Unsign the domain entirely.
382
+
383
+ Mutually exclusive with removing specific records — the protocol has no way to express
384
+ both, and a frame carrying both is refused. Refused here instead, where the message can say
385
+ so.
386
+ """
387
+ sec = self._options.get("sec_dns") or {}
388
+ if "rem" in sec:
389
+ raise ValidationException(
390
+ "remove_all_dnssec() cannot be combined with rem_ds_record()/rem_key_record() — "
391
+ "remove everything, or name what to remove, not both"
392
+ )
393
+ sec["rem_all"] = True
394
+ self._options["sec_dns"] = sec
395
+ return self
396
+
397
+ def max_sig_life(self, seconds: int) -> "DomainUpdateBuilder":
398
+ """Maximum signature lifetime in seconds."""
399
+ sec = self._options.get("sec_dns") or {}
400
+ sec["max_sig_life"] = seconds
401
+ self._options["sec_dns"] = sec
402
+ return self
403
+
404
+ def send(self) -> Response:
405
+ """Send the command and return the registry's answer."""
406
+ self._mark_sent()
407
+ return self._handler.update(self._id, **self._options)
408
+
409
+ def _sec_dns(self, block: str, kind: str, record: Dict[str, Any]) -> "DomainUpdateBuilder":
410
+ sec = self._options.get("sec_dns") or {}
411
+ if block == "rem" and sec.get("rem_all"):
412
+ raise ValidationException(
413
+ "rem_ds_record()/rem_key_record() cannot be combined with remove_all_dnssec() — "
414
+ "remove everything, or name what to remove, not both"
415
+ )
416
+ spec = sec.get(block) or {}
417
+ spec[kind] = list(spec.get(kind) or []) + [record]
418
+ sec[block] = spec
419
+ self._options["sec_dns"] = sec
420
+ return self
421
+
422
+
423
+ class ContactCreateBuilder(Builder):
424
+ """Creates a contact, one named step at a time.
425
+
426
+ The id and the e-mail are constructor arguments because the registry requires both — a builder
427
+ that lets you forget a mandatory field has moved the error from your editor to the wire.
428
+ """
429
+
430
+ def __init__(self, handler: Any, contact_id: str, email: str) -> None:
431
+ super().__init__(handler, contact_id)
432
+ self._options["email"] = email
433
+
434
+ def international_address(self, name: str, city: str, country_code: str,
435
+ street: Sequence[str] = (), org: Optional[str] = None,
436
+ state_province: Optional[str] = None,
437
+ postal_code: Optional[str] = None) -> "ContactCreateBuilder":
438
+ """The address in ASCII, which every registry accepts.
439
+
440
+ At least one form is required. Give this one unless you have a reason not to: it is the
441
+ form that survives being printed, e-mailed and read by a system that knows no Cyrillic.
442
+ """
443
+ return self._address("int", name, city, country_code, street, org, state_province, postal_code)
444
+
445
+ def localized_address(self, name: str, city: str, country_code: str,
446
+ street: Sequence[str] = (), org: Optional[str] = None,
447
+ state_province: Optional[str] = None,
448
+ postal_code: Optional[str] = None) -> "ContactCreateBuilder":
449
+ """The address in the local script — Cyrillic for a Ukrainian registrant.
450
+
451
+ Optional, and additional: a contact may carry this form as well as the international one.
452
+ """
453
+ return self._address("loc", name, city, country_code, street, org, state_province, postal_code)
454
+
455
+ def voice(self, number: str) -> "ContactCreateBuilder":
456
+ """Voice number in the EPP form: ``+CC.NNNNNNNNN``."""
457
+ self._options["voice"] = number
458
+ return self
459
+
460
+ def fax(self, number: str) -> "ContactCreateBuilder":
461
+ """Fax number, same form as :meth:`voice`."""
462
+ self._options["fax"] = number
463
+ return self
464
+
465
+ def auth_info(self, password: str) -> "ContactCreateBuilder":
466
+ """The transfer secret for this contact."""
467
+ self._options["auth_info"] = password
468
+ return self
469
+
470
+ def publish(self, *fields: str) -> "ContactCreateBuilder":
471
+ """Consent to publish these fields: name, org, addr, voice, fax, email.
472
+
473
+ Anything not listed takes the opposite treatment, so publish() and withhold() say the same
474
+ thing two ways — pick the one that matches how you think about it, and do not call both.
475
+ """
476
+ self._options["disclose"] = _disclosure(True, fields)
477
+ return self
478
+
479
+ def withhold(self, *fields: str) -> "ContactCreateBuilder":
480
+ """Withhold these fields from publication. See :meth:`publish` for the field names."""
481
+ self._options["disclose"] = _disclosure(False, fields)
482
+ return self
483
+
484
+ def send(self) -> Response:
485
+ """Send the command and return the registry's answer."""
486
+ self._mark_sent()
487
+ return self._handler.create(self._id, **self._options)
488
+
489
+ def _address(self, kind, name, city, country_code, street, org, state_province, postal_code):
490
+ blocks = list(self._options.get("postal_infos") or [])
491
+ blocks.append(_postal_info(kind, name, city, country_code, street, org, state_province, postal_code))
492
+ self._options["postal_infos"] = blocks
493
+ return self
494
+
495
+
496
+ class ContactUpdateBuilder(Builder):
497
+ """Changes a contact, one named step at a time.
498
+
499
+ What you do not mention is left alone. That holds inside an address too: a field you leave out
500
+ keeps its value, and a field passed as an empty string is cleared. The one thing to know is
501
+ that the address block is a sequence with a required city and country, so touching any part of
502
+ it sends the whole block — give city and country_code whenever you change street, sp or pc.
503
+ """
504
+
505
+ def change_international_address(self, name: Optional[str] = None, city: Optional[str] = None,
506
+ country_code: Optional[str] = None,
507
+ street: Optional[Sequence[str]] = None,
508
+ org: Optional[str] = None,
509
+ state_province: Optional[str] = None,
510
+ postal_code: Optional[str] = None) -> "ContactUpdateBuilder":
511
+ """Change the ASCII address. Only the fields you pass are sent, and PRESENCE decides:
512
+
513
+ ================== ===========================================================
514
+ leave it out/None the field is not sent, and the registry keeps its value
515
+ give a value the field is set to it
516
+ give ``""`` the field is CLEARED — the only way to remove org, state
517
+ province or postal code
518
+ ================== ===========================================================
519
+
520
+ The other form (local or international) is untouched. The address block is a sequence with
521
+ a required city and country, so passing any part of it sends the whole block: give
522
+ ``city`` and ``country_code`` whenever you touch street, state province or postal code.
523
+ """
524
+ return self._address("int", name, city, country_code, street, org, state_province, postal_code)
525
+
526
+ def change_localized_address(self, name: Optional[str] = None, city: Optional[str] = None,
527
+ country_code: Optional[str] = None,
528
+ street: Optional[Sequence[str]] = None,
529
+ org: Optional[str] = None,
530
+ state_province: Optional[str] = None,
531
+ postal_code: Optional[str] = None) -> "ContactUpdateBuilder":
532
+ """Change the local-script address. Same rules as :meth:`change_international_address`."""
533
+ return self._address("loc", name, city, country_code, street, org, state_province, postal_code)
534
+
535
+ def change_voice(self, number: str) -> "ContactUpdateBuilder":
536
+ return self._chg("voice", number)
537
+
538
+ def change_fax(self, number: str) -> "ContactUpdateBuilder":
539
+ return self._chg("fax", number)
540
+
541
+ def change_email(self, email: str) -> "ContactUpdateBuilder":
542
+ return self._chg("email", email)
543
+
544
+ def change_auth_info(self, password: str) -> "ContactUpdateBuilder":
545
+ """Replace the transfer secret."""
546
+ return self._chg("auth_info", password)
547
+
548
+ # There is deliberately no clear_auth_info() here. RFC 5731 gives a domain a nullable form,
549
+ # <domain:authInfo><domain:null/>, and RFC 5733 defines no equivalent for a contact — so a
550
+ # contact's transfer secret can be REPLACED but not removed. Do not reach for an empty password
551
+ # as a substitute: an empty value is still a value the holder can present.
552
+ def publish(self, *fields: str) -> "ContactUpdateBuilder":
553
+ """Consent to publish these fields: name, org, addr, voice, fax, email."""
554
+ return self._chg("disclose", _disclosure(True, fields))
555
+
556
+ def withhold(self, *fields: str) -> "ContactUpdateBuilder":
557
+ """Withhold these fields from publication."""
558
+ return self._chg("disclose", _disclosure(False, fields))
559
+
560
+ def add_status(self, *statuses: str) -> "ContactUpdateBuilder":
561
+ """Set a client-side status, e.g. ``clientUpdateProhibited``."""
562
+ _append(self._options, "add_statuses", statuses)
563
+ return self
564
+
565
+ def rem_status(self, *statuses: str) -> "ContactUpdateBuilder":
566
+ """Clear a client-side status."""
567
+ _append(self._options, "rem_statuses", statuses)
568
+ return self
569
+
570
+ def send(self) -> Response:
571
+ """Send the command and return the registry's answer."""
572
+ self._mark_sent()
573
+ return self._handler.update(self._id, **self._options)
574
+
575
+ def _chg(self, key: str, value: Any) -> "ContactUpdateBuilder":
576
+ chg = self._options.get("chg") or {}
577
+ chg[key] = value
578
+ self._options["chg"] = chg
579
+ return self
580
+
581
+ def _address(self, kind, name, city, country_code, street, org, state_province, postal_code):
582
+ # Only the arguments actually given become keys. The emitter reads presence, so an absent
583
+ # key is not sent and a key holding "" is sent empty, which is what clears a field.
584
+ block: Dict[str, Any] = {"type": kind}
585
+ for key, value in (("name", name), ("org", org), ("city", city),
586
+ ("sp", state_province), ("pc", postal_code), ("cc", country_code)):
587
+ if value is not None:
588
+ block[key] = value
589
+ if street is not None:
590
+ block["street"] = [str(line) for line in street]
591
+
592
+ chg = self._options.get("chg") or {}
593
+ blocks = list(chg.get("postal_infos") or [])
594
+ blocks.append(block)
595
+ chg["postal_infos"] = blocks
596
+ self._options["chg"] = chg
597
+ return self
598
+
599
+
600
+ class HostUpdateBuilder(Builder):
601
+ """Changes a nameserver's glue addresses or statuses.
602
+
603
+ There is no rename: this registry reads only the add and remove blocks, so a rename request is
604
+ discarded and the command still answers 1000. Create the new host, repoint the domains that use
605
+ it, then delete the old one.
606
+ """
607
+
608
+ def add_address(self, ip: str) -> "HostUpdateBuilder":
609
+ """Add one glue address. Accumulates."""
610
+ return self.add_addresses(ip)
611
+
612
+ def add_addresses(self, *ips: str) -> "HostUpdateBuilder":
613
+ """Add glue addresses. IPv4 and IPv6 are told apart automatically. Accumulates."""
614
+ _append(self._options, "add_addresses", ips)
615
+ return self
616
+
617
+ def rem_address(self, ip: str) -> "HostUpdateBuilder":
618
+ """Remove one glue address. Accumulates."""
619
+ return self.rem_addresses(ip)
620
+
621
+ def rem_addresses(self, *ips: str) -> "HostUpdateBuilder":
622
+ """Remove glue addresses. Accumulates."""
623
+ _append(self._options, "rem_addresses", ips)
624
+ return self
625
+
626
+ def add_status(self, *statuses: str) -> "HostUpdateBuilder":
627
+ """Set a client-side status."""
628
+ _append(self._options, "add_statuses", statuses)
629
+ return self
630
+
631
+ def rem_status(self, *statuses: str) -> "HostUpdateBuilder":
632
+ """Clear a client-side status."""
633
+ _append(self._options, "rem_statuses", statuses)
634
+ return self
635
+
636
+ def send(self) -> Response:
637
+ """Send the command and return the registry's answer."""
638
+ self._mark_sent()
639
+ return self._handler.update(self._id, **self._options)