certapi 1.1.3__tar.gz → 1.1.5__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 (70) hide show
  1. {certapi-1.1.3/src/certapi.egg-info → certapi-1.1.5}/PKG-INFO +1 -1
  2. {certapi-1.1.3 → certapi-1.1.5}/setup.py +1 -1
  3. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/client/renewal_manager.py +173 -21
  4. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/manager/acme_cert_manager.py +14 -1
  5. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/api.py +17 -35
  6. {certapi-1.1.3 → certapi-1.1.5/src/certapi.egg-info}/PKG-INFO +1 -1
  7. {certapi-1.1.3 → certapi-1.1.5}/tests/test_acme_cert_manager_batching.py +40 -0
  8. {certapi-1.1.3 → certapi-1.1.5}/tests/test_obtain_interface.py +15 -5
  9. {certapi-1.1.3 → certapi-1.1.5}/tests/test_renewal_manager.py +127 -39
  10. {certapi-1.1.3 → certapi-1.1.5}/MANIFEST.in +0 -0
  11. {certapi-1.1.3 → certapi-1.1.5}/README.md +0 -0
  12. {certapi-1.1.3 → certapi-1.1.5}/pyproject.toml +0 -0
  13. {certapi-1.1.3 → certapi-1.1.5}/setup.cfg +0 -0
  14. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/__init__.py +0 -0
  15. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/Acme.py +0 -0
  16. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/AcmeError.py +0 -0
  17. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/Challenge.py +0 -0
  18. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/Order.py +0 -0
  19. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/__init__.py +0 -0
  20. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/acme/http.py +0 -0
  21. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  22. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  23. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  24. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/__init__.py +0 -0
  25. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  26. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  27. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  28. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  29. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  30. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  31. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  32. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/cli.py +0 -0
  33. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/client/__init__.py +0 -0
  34. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/client/cert_manager_client.py +0 -0
  35. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/crypto/__init__.py +0 -0
  36. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/crypto/crypto.py +0 -0
  37. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/crypto/crypto_classes.py +0 -0
  38. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/domain_batching.py +0 -0
  39. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/errors.py +0 -0
  40. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/http/HttpClientBase.py +0 -0
  41. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/http/__init__.py +0 -0
  42. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/http/types.py +0 -0
  43. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  44. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  45. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/__init__.py +0 -0
  46. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/issuers/abstract_certissuer.py +0 -0
  47. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  48. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/KeyStore.py +0 -0
  49. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  50. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  51. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  52. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/keystore/__init__.py +0 -0
  53. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/manager/__init__.py +0 -0
  54. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/__init__.py +0 -0
  55. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/cert_api.py +0 -0
  56. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/server/key_api.py +0 -0
  57. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/util.py +0 -0
  58. {certapi-1.1.3 → certapi-1.1.5}/src/certapi/utils.py +0 -0
  59. {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/SOURCES.txt +0 -0
  60. {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/dependency_links.txt +0 -0
  61. {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/entry_points.txt +0 -0
  62. {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/requires.txt +0 -0
  63. {certapi-1.1.3 → certapi-1.1.5}/src/certapi.egg-info/top_level.txt +0 -0
  64. {certapi-1.1.3 → certapi-1.1.5}/tests/test_acme_error_handling.py +0 -0
  65. {certapi-1.1.3 → certapi-1.1.5}/tests/test_cert_issuer_generic.py +0 -0
  66. {certapi-1.1.3 → certapi-1.1.5}/tests/test_certs_with_key_types.py +0 -0
  67. {certapi-1.1.3 → certapi-1.1.5}/tests/test_cli.py +0 -0
  68. {certapi-1.1.3 → certapi-1.1.5}/tests/test_domain_batching_boulder_cases.py +0 -0
  69. {certapi-1.1.3 → certapi-1.1.5}/tests/test_http_error_handling.py +0 -0
  70. {certapi-1.1.3 → certapi-1.1.5}/tests/test_keystores.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.3
3
+ Version: 1.1.5
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.1.3",
5
+ version="1.1.5",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -2,7 +2,7 @@ import os
2
2
  import threading
3
3
  import time
4
4
  from datetime import datetime, timezone, timedelta
5
- from typing import Callable, Dict, Optional, Set, Any, List
5
+ from typing import Callable, Dict, Optional, Set, Any, List, Literal
6
6
 
7
7
  from certapi.crypto import Key, certs_from_pem
8
8
  from certapi.client.cert_manager_client import CertManagerClient
@@ -12,7 +12,15 @@ from certapi.manager.acme_cert_manager import DEFAULT_RENEW_THRESHOLD_DAYS
12
12
 
13
13
  class RenewalManager:
14
14
  """
15
- Background certificate refresh manager.
15
+ Keep certificates fresh for a changing set of watched domains.
16
+
17
+ RenewalManager can run as a background worker with :meth:`start`, while
18
+ callers publish the complete desired domain set through
19
+ :meth:`update_watch_domains`. Publishing a domain set is synchronous: the call
20
+ returns after certapi has processed the set and attempted any due
21
+ obtain/renew work. Each renewal pass first calls the optional
22
+ ``sync_watch_domains`` callback so integrations can republish the latest
23
+ domain list before certapi decides what to obtain or renew.
16
24
 
17
25
  Algorithm:
18
26
  - Maintain a watched domain set and cache each domain's certificate expiry.
@@ -27,6 +35,14 @@ class RenewalManager:
27
35
  - On failure for renewal, where a cached or local certificate exists, keep reusing
28
36
  that existing certificate even if it is expired, defer the next retry, and do not
29
37
  replace it with a self-signed certificate.
38
+
39
+ The manager keeps an in-memory expiry cache, bootstraps that cache from the
40
+ backend keystore when possible, renews missing or soon-expiring
41
+ certificates, and records failures in :meth:`get_state`. New domains that
42
+ fail issuance receive a short-lived retry blacklist and, when the backend
43
+ exposes a keystore, a local self-signed fallback certificate. Renewal
44
+ failures for domains with an existing certificate keep using that
45
+ certificate and schedule a later retry.
30
46
  """
31
47
 
32
48
  def __init__(
@@ -42,9 +58,71 @@ class RenewalManager:
42
58
  remote_poll_interval_seconds: int = 30,
43
59
  clock_fn: Optional[Callable[[], datetime]] = None,
44
60
  sleep_fn: Optional[Callable[[float], None]] = None,
61
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
62
+ expiry_days: int = 90,
63
+ batch_domains: bool = False,
64
+ self_verify: bool = True,
65
+ country: Optional[str] = None,
66
+ state: Optional[str] = None,
67
+ locality: Optional[str] = None,
68
+ organization: Optional[str] = None,
69
+ user_id: Optional[str] = None,
45
70
  ):
71
+ """
72
+ Create a renewal manager for a certificate backend.
73
+
74
+ Args:
75
+ cert_manager_client: Backend object with an ``obtain(domains,
76
+ **kwargs)`` method. Local managers and ``CertManagerClient`` are
77
+ both supported.
78
+ sync_watch_domains: Optional callback invoked at the start of each
79
+ renewal pass. The callback should call :meth:`update_watch_domains`
80
+ with the latest desired domain list. Calls made from this
81
+ callback only update the in-progress cycle and do not start a
82
+ nested renewal pass.
83
+ renew_threshold_days: Renew certificates when they expire within
84
+ this many days. Defaults to ``CERT_RENEW_THRESHOLD_DAYS`` or the
85
+ certapi default.
86
+ min_renew_threshold_days: Lower bound for renewal attempts, used to
87
+ avoid renewing too close to expiry even when a smaller threshold
88
+ is configured.
89
+ sleep_slack_seconds: Extra delay added after the calculated next
90
+ renewal time to avoid waking exactly on the threshold boundary.
91
+ max_sleep_seconds: Maximum time the background worker sleeps between
92
+ checks.
93
+ renew_retry_interval_seconds: Delay before retrying renewal for a
94
+ domain that already has a local certificate.
95
+ blacklist_duration_seconds: Delay before retrying issuance for a
96
+ domain that had no usable local certificate.
97
+ remote_poll_interval_seconds: Poll interval while waiting for remote
98
+ ``CertManagerClient`` requests.
99
+ clock_fn: Optional clock override for deterministic tests.
100
+ sleep_fn: Optional sleep override for deterministic tests.
101
+ key_type: Private key type requested from the backend.
102
+ expiry_days: Requested certificate lifetime when the backend
103
+ supports it.
104
+ batch_domains: Forwarded to the backend to enable domain batching.
105
+ self_verify: Forwarded to the backend to enable or disable
106
+ ownership self-verification.
107
+ country: Optional subject country for issued or fallback certs.
108
+ state: Optional subject state for issued or fallback certs.
109
+ locality: Optional subject locality for issued or fallback certs.
110
+ organization: Optional subject organization for issued or fallback
111
+ certs.
112
+ user_id: Optional subject/user identifier for issued or fallback
113
+ certs.
114
+ """
46
115
  self.cert_manager_client = cert_manager_client
47
116
  self.sync_watch_domains = sync_watch_domains
117
+ self.key_type = key_type
118
+ self.expiry_days = expiry_days
119
+ self.batch_domains = batch_domains
120
+ self.self_verify = self_verify
121
+ self.country = country
122
+ self.state = state
123
+ self.locality = locality
124
+ self.organization = organization
125
+ self.user_id = user_id
48
126
  self.sleep_slack_seconds = sleep_slack_seconds
49
127
  self.max_sleep_seconds = max_sleep_seconds
50
128
  self.renew_retry_interval_seconds = renew_retry_interval_seconds
@@ -71,34 +149,73 @@ class RenewalManager:
71
149
  self._lock = threading.Condition()
72
150
  self._thread: Optional[threading.Thread] = None
73
151
  self._running = False
152
+ self._cycle_requested = False
74
153
  self._force_trigger = False
75
154
  self._cycle_running = False
76
155
  self._cycle_thread_id: Optional[int] = None
156
+ self._cycle_generation = 0
77
157
  self._blacklist: Dict[str, datetime] = {}
78
158
  self._last_error_message: Optional[str] = None
79
159
  self._last_error_timestamp: Optional[datetime] = None
80
160
  self._self_signer: Optional[SelfCertIssuer] = None
81
161
 
82
- def set_watch_domains(self, domains: List[str]):
162
+ def update_watch_domains(self, domains: List[str]):
163
+ """
164
+ Replace the complete set of domains and process it before returning.
165
+
166
+ Empty values are ignored. Cache and blacklist entries for domains no
167
+ longer being watched are dropped.
168
+
169
+ When called outside an active renewal cycle, this method immediately
170
+ runs or wakes a renewal pass and blocks until certapi has attempted any
171
+ due obtain/renew work for the published set. When called from
172
+ ``sync_watch_domains`` during a renewal cycle, it only updates the set
173
+ being processed and returns immediately to avoid recursive cycles.
174
+ """
83
175
  new_watch_set = {x for x in domains if x}
84
176
  with self._lock:
85
177
  self._watch_domains = new_watch_set
86
178
  self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
87
179
  self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
88
- if self._running and self._cycle_thread_id != threading.get_ident():
89
- self._force_trigger = True
180
+ if self._cycle_thread_id == threading.get_ident():
181
+ self._lock.notify_all()
182
+ return
183
+ if self._running:
184
+ target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
185
+ self._cycle_requested = True
186
+ self._lock.notify_all()
187
+ while self._running and self._cycle_generation < target_generation:
188
+ self._lock.wait()
189
+ return
90
190
  self._lock.notify_all()
91
191
 
192
+ self._run_cycle(force=False)
193
+
92
194
  def start(self):
195
+ """
196
+ Start the background renewal worker.
197
+
198
+ The worker immediately performs a forced renewal pass, then sleeps until
199
+ the next watched certificate approaches the renewal threshold or until
200
+ another thread publishes a new set with :meth:`update_watch_domains`.
201
+ Calling ``start`` while already running is a no-op.
202
+ """
93
203
  with self._lock:
94
204
  if self._running:
95
205
  return
96
206
  self._running = True
97
- self._force_trigger = True
207
+ self._cycle_requested = True
98
208
  self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
99
209
  self._thread.start()
100
210
 
101
211
  def stop(self):
212
+ """
213
+ Stop the background renewal worker and wait briefly for it to exit.
214
+
215
+ In-flight remote certificate requests run in daemon helper threads and
216
+ are not waited on indefinitely; the manager stops scheduling new work
217
+ and joins the worker thread for up to two seconds.
218
+ """
102
219
  thread = None
103
220
  with self._lock:
104
221
  self._running = False
@@ -109,14 +226,43 @@ class RenewalManager:
109
226
  thread.join(timeout=2)
110
227
 
111
228
  def trigger_now(self):
229
+ """
230
+ Run a renewal pass immediately.
231
+
232
+ When the background worker is not running, this method performs the
233
+ renewal pass in the caller's thread and returns after it completes. When
234
+ the worker is running, this method wakes it and blocks until the
235
+ requested renewal pass has completed.
236
+
237
+ Most integrations should call :meth:`update_watch_domains` instead. This
238
+ method is for forcing a pass without changing the watched domain set.
239
+
240
+ If called from inside the renewal cycle itself, the method only requests
241
+ a follow-up pass and returns immediately to avoid deadlock.
242
+ """
112
243
  with self._lock:
113
244
  if self._running:
245
+ if self._cycle_thread_id == threading.get_ident():
246
+ self._force_trigger = True
247
+ self._lock.notify_all()
248
+ return
249
+ target_generation = self._cycle_generation + (2 if self._cycle_running else 1)
114
250
  self._force_trigger = True
115
251
  self._lock.notify_all()
252
+ while self._running and self._cycle_generation < target_generation:
253
+ self._lock.wait()
116
254
  return
117
255
  self._run_cycle(force=True)
118
256
 
119
257
  def get_state(self) -> Dict[str, Any]:
258
+ """
259
+ Return a snapshot of renewal-manager state for diagnostics.
260
+
261
+ The returned dictionary includes watched domains, cache size, active
262
+ blacklisted domains, next cached expiry time, whether the background
263
+ worker is running, and the most recent error message/timestamp recorded
264
+ by the manager.
265
+ """
120
266
  with self._lock:
121
267
  next_renewal_time = min(self._cache.values()).isoformat() if self._cache else None
122
268
  return {
@@ -139,6 +285,7 @@ class RenewalManager:
139
285
  if not self._running:
140
286
  return
141
287
  force = self._force_trigger
288
+ self._cycle_requested = False
142
289
  self._force_trigger = False
143
290
 
144
291
  attempt_count = self._run_cycle(force=force)
@@ -146,7 +293,7 @@ class RenewalManager:
146
293
  with self._lock:
147
294
  if not self._running:
148
295
  return
149
- if self._force_trigger:
296
+ if self._force_trigger or self._cycle_requested:
150
297
  continue
151
298
  wait_seconds = self._compute_wait_seconds(self.clock_fn())
152
299
 
@@ -215,6 +362,7 @@ class RenewalManager:
215
362
  with self._lock:
216
363
  self._cycle_running = False
217
364
  self._cycle_thread_id = None
365
+ self._cycle_generation += 1
218
366
  self._lock.notify_all()
219
367
 
220
368
  def _run_cycle_body(self, force: bool = False) -> int:
@@ -293,20 +441,24 @@ class RenewalManager:
293
441
 
294
442
  try:
295
443
  renew_threshold_days = self.cert_min_renew_threshold_secs // (24 * 3600)
296
- obtain_fn = getattr(self.cert_manager_client, "obtain", None)
297
- if callable(obtain_fn):
298
- res = self._call_certificate_backend(
299
- obtain_fn,
300
- [domain],
301
- {"renew_threshold_days": renew_threshold_days, "skip_failing": False},
302
- )
303
- else:
304
- # Backward compatibility for clients that only implement issue_certificate.
305
- res = self._call_certificate_backend(
306
- self.cert_manager_client.issue_certificate,
307
- [domain],
308
- {"renew_threshold_days": renew_threshold_days},
309
- )
444
+ obtain_options = {
445
+ "key_type": self.key_type,
446
+ "expiry_days": self.expiry_days,
447
+ "renew_threshold_days": renew_threshold_days,
448
+ "skip_failing": False,
449
+ "batch_domains": self.batch_domains,
450
+ "self_verify": self.self_verify,
451
+ "country": self.country,
452
+ "state": self.state,
453
+ "locality": self.locality,
454
+ "organization": self.organization,
455
+ "user_id": self.user_id,
456
+ }
457
+ res = self._call_certificate_backend(
458
+ self.cert_manager_client.obtain,
459
+ [domain],
460
+ obtain_options,
461
+ )
310
462
  if res is None:
311
463
  return
312
464
  self._update_expiry_cache(res.issued + res.existing)
@@ -75,6 +75,8 @@ class AcmeCertManager:
75
75
  organization: Optional[str] = None,
76
76
  user_id: Optional[str] = None,
77
77
  renew_threshold_days: Optional[int] = None,
78
+ skip_failing: bool = True,
79
+ batch_domains: bool = False,
78
80
  self_verify: bool = True,
79
81
  ) -> CertificateResponse:
80
82
  return self.obtain(
@@ -87,6 +89,8 @@ class AcmeCertManager:
87
89
  organization=organization,
88
90
  user_id=user_id,
89
91
  renew_threshold_days=renew_threshold_days,
92
+ skip_failing=skip_failing,
93
+ batch_domains=batch_domains,
90
94
  self_verify=self_verify,
91
95
  )
92
96
 
@@ -101,6 +105,8 @@ class AcmeCertManager:
101
105
  organization: Optional[str] = None,
102
106
  user_id: Optional[str] = None,
103
107
  renew_threshold_days: Optional[int] = None,
108
+ skip_failing: bool = True,
109
+ batch_domains: bool = False,
104
110
  self_verify: bool = True,
105
111
  ) -> CertificateResponse:
106
112
  return self._issue_certificate_internal(
@@ -113,7 +119,8 @@ class AcmeCertManager:
113
119
  organization=organization,
114
120
  user_id=user_id,
115
121
  renew_threshold_days=renew_threshold_days,
116
- batch_generator=None,
122
+ batch_generator=create_safe_domain_batches if batch_domains else None,
123
+ skip_failing=skip_failing,
117
124
  self_verify=self_verify,
118
125
  )
119
126
 
@@ -129,6 +136,7 @@ class AcmeCertManager:
129
136
  user_id: Optional[str] = None,
130
137
  renew_threshold_days: Optional[int] = None,
131
138
  batch_generator: Callable[[List[str]], List[List[str]]] = create_safe_domain_batches,
139
+ skip_failing: bool = True,
132
140
  self_verify: bool = True,
133
141
  ) -> CertificateResponse:
134
142
  return self._issue_certificate_internal(
@@ -142,6 +150,7 @@ class AcmeCertManager:
142
150
  user_id=user_id,
143
151
  renew_threshold_days=renew_threshold_days,
144
152
  batch_generator=batch_generator,
153
+ skip_failing=skip_failing,
145
154
  self_verify=self_verify,
146
155
  )
147
156
 
@@ -157,6 +166,7 @@ class AcmeCertManager:
157
166
  user_id: Optional[str] = None,
158
167
  renew_threshold_days: Optional[int] = None,
159
168
  batch_generator: Optional[Callable[[List[str]], List[List[str]]]] = None,
169
+ skip_failing: bool = True,
160
170
  self_verify: bool = True,
161
171
  ) -> CertificateResponse:
162
172
  if isinstance(hosts, str):
@@ -196,6 +206,9 @@ class AcmeCertManager:
196
206
  else:
197
207
  print(f"Warning: No challenge solver found that supports domain: {host}. Skipping.")
198
208
 
209
+ if len(domains_by_store) == 0 and not skip_failing:
210
+ raise ValueError("None of the domains are owned by this machine or could be verified")
211
+
199
212
  for store, domains_to_issue in domains_by_store.items():
200
213
  issuance_batches = (
201
214
  batch_generator(domains_to_issue) if batch_generator is not None else [domains_to_issue]
@@ -122,43 +122,25 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
122
122
  def get(self):
123
123
  args = obtain_parser.parse_args()
124
124
  hostnames = args["hostname"]
125
- skip_failing = args.get("skip_failing", False)
126
- self_verify = args.get("self_verify", True)
127
125
 
128
126
  with lock_manager:
129
- if self_verify:
130
- verified_hostnames = []
131
- for h in hostnames:
132
- # Find the first solver that supports and can verify this domain.
133
- for solver in reversed(cert_manager.challenge_solvers):
134
- if solver.supports_domain_strict(h):
135
- verified_hostnames.append(h)
136
-
137
- hostnames = verified_hostnames
138
-
139
- if not hostnames and not skip_failing:
140
- api_ns.abort(400, message="None of the domains are owned by this machine or could be verified")
141
-
142
- if not hostnames:
143
- return CertificateResponse().to_json()
144
-
145
- issue_fn = (
146
- cert_manager.issue_certificate_in_batches
147
- if args.get("batch_domains")
148
- else cert_manager.issue_certificate
149
- )
150
- data = issue_fn(
151
- hostnames,
152
- key_type=args["key_type"],
153
- expiry_days=args["expiry_days"],
154
- country=args["country"],
155
- state=args["state"],
156
- locality=args["locality"],
157
- organization=args["organization"],
158
- user_id=args["user_id"],
159
- renew_threshold_days=args.get("renew_threshold_days"),
160
- self_verify=False,
161
- )
127
+ try:
128
+ data = cert_manager.obtain(
129
+ hostnames,
130
+ key_type=args["key_type"],
131
+ expiry_days=args["expiry_days"],
132
+ country=args["country"],
133
+ state=args["state"],
134
+ locality=args["locality"],
135
+ organization=args["organization"],
136
+ user_id=args["user_id"],
137
+ renew_threshold_days=args.get("renew_threshold_days"),
138
+ skip_failing=args.get("skip_failing", False),
139
+ batch_domains=args.get("batch_domains", False),
140
+ self_verify=args.get("self_verify", True),
141
+ )
142
+ except ValueError as e:
143
+ api_ns.abort(400, message=str(e))
162
144
 
163
145
  print(data)
164
146
  if data:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.3
3
+ Version: 1.1.5
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -98,6 +98,26 @@ def test_issue_certificate_in_batches_uses_custom_batch_generator():
98
98
  assert [issued.domains for issued in response.issued] == issuer.calls
99
99
 
100
100
 
101
+ def test_obtain_batch_domains_uses_safe_batches():
102
+ key_store = DummyKeyStore()
103
+ issuer = DummyIssuer()
104
+ solver = DummySolver()
105
+ manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
106
+
107
+ domains = [
108
+ "x.abc.def.ghi.example.com",
109
+ "root.example.com",
110
+ ]
111
+ response = manager.obtain(hosts=domains, batch_domains=True)
112
+
113
+ assert issuer.calls == [
114
+ ["root.example.com"],
115
+ ["x.abc.def"],
116
+ ["ghi.example.com"],
117
+ ]
118
+ assert len(response.issued) == 3
119
+
120
+
101
121
  def test_issue_certificate_self_verify_false_skips_strict_solver_check():
102
122
  class StrictFailingSolver(DummySolver):
103
123
  def supports_domain_strict(self, _domain):
@@ -127,3 +147,23 @@ def test_issue_certificate_self_verify_true_uses_strict_solver_check():
127
147
 
128
148
  assert issuer.calls == []
129
149
  assert response.issued == []
150
+
151
+
152
+ def test_obtain_self_verify_true_raises_when_all_domains_fail_and_skip_failing_false():
153
+ class StrictFailingSolver(DummySolver):
154
+ def supports_domain_strict(self, _domain):
155
+ return False
156
+
157
+ key_store = DummyKeyStore()
158
+ issuer = DummyIssuer()
159
+ solver = StrictFailingSolver()
160
+ manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
161
+
162
+ try:
163
+ manager.obtain(hosts=["force.example.com"], skip_failing=False)
164
+ except ValueError as e:
165
+ assert "None of the domains are owned" in str(e)
166
+ else:
167
+ raise AssertionError("expected strict obtain failure")
168
+
169
+ assert issuer.calls == []
@@ -4,6 +4,7 @@ from flask import Flask
4
4
  from flask_restx import Api, Namespace
5
5
 
6
6
  from certapi.client.cert_manager_client import CertManagerClient
7
+ from certapi.domain_batching import create_safe_domain_batches
7
8
  from certapi.http.types import CertificateResponse
8
9
  from certapi.manager.acme_cert_manager import AcmeCertManager
9
10
  from certapi.server.api import create_api_resources
@@ -29,6 +30,8 @@ def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
29
30
  organization="Org",
30
31
  user_id="u1",
31
32
  renew_threshold_days=25,
33
+ skip_failing=False,
34
+ batch_domains=True,
32
35
  self_verify=False,
33
36
  )
34
37
 
@@ -42,6 +45,8 @@ def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
42
45
  assert captured["organization"] == "Org"
43
46
  assert captured["user_id"] == "u1"
44
47
  assert captured["renew_threshold_days"] == 25
48
+ assert captured["skip_failing"] is False
49
+ assert captured["batch_domains"] is True
45
50
  assert captured["self_verify"] is False
46
51
 
47
52
 
@@ -60,6 +65,8 @@ def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
60
65
  key_type="ecdsa",
61
66
  expiry_days=90,
62
67
  renew_threshold_days=30,
68
+ skip_failing=False,
69
+ batch_domains=True,
63
70
  self_verify=False,
64
71
  )
65
72
 
@@ -68,7 +75,8 @@ def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
68
75
  assert captured["key_type"] == "ecdsa"
69
76
  assert captured["expiry_days"] == 90
70
77
  assert captured["renew_threshold_days"] == 30
71
- assert captured["batch_generator"] is None
78
+ assert captured["batch_generator"] is create_safe_domain_batches
79
+ assert captured["skip_failing"] is False
72
80
  assert captured["self_verify"] is False
73
81
 
74
82
 
@@ -157,7 +165,7 @@ def test_remote_obtain_self_verify_false_skips_server_prefilter():
157
165
  def __init__(self):
158
166
  self.calls = []
159
167
 
160
- def issue_certificate(self, hosts, **kwargs):
168
+ def obtain(self, hosts, **kwargs):
161
169
  self.calls.append({"hosts": hosts, "kwargs": kwargs})
162
170
  return CertificateResponse()
163
171
 
@@ -171,7 +179,7 @@ def test_remote_obtain_self_verify_false_skips_server_prefilter():
171
179
  assert manager.calls[0]["kwargs"]["self_verify"] is False
172
180
 
173
181
 
174
- def test_remote_obtain_self_verify_true_keeps_server_prefilter():
182
+ def test_remote_obtain_self_verify_true_delegates_strict_failure_to_manager():
175
183
  app = Flask(__name__)
176
184
  api = Api(app)
177
185
  api_ns = Namespace("api")
@@ -184,8 +192,10 @@ def test_remote_obtain_self_verify_true_keeps_server_prefilter():
184
192
  class Manager:
185
193
  challenge_solvers = [Solver()]
186
194
 
187
- def issue_certificate(self, hosts, **kwargs):
188
- raise AssertionError("unverified host should not reach manager")
195
+ def obtain(self, hosts, **kwargs):
196
+ assert hosts == ["force.example.com"]
197
+ assert kwargs["self_verify"] is True
198
+ raise ValueError("None of the domains are owned by this machine or could be verified")
189
199
 
190
200
  create_api_resources(api_ns, Manager())
191
201
 
@@ -23,7 +23,7 @@ class DummyClient:
23
23
  def set_handler(self, domain, handler):
24
24
  self._handlers[domain] = handler
25
25
 
26
- def issue_certificate(self, hosts, **kwargs):
26
+ def obtain(self, hosts, **kwargs):
27
27
  host = hosts[0] if isinstance(hosts, list) else hosts
28
28
  self.calls.append({"host": host, "kwargs": kwargs})
29
29
  handler = self._handlers.get(host)
@@ -35,6 +35,9 @@ class DummyClient:
35
35
  return handler(host, kwargs)
36
36
  return handler
37
37
 
38
+ def issue_certificate(self, hosts, **kwargs):
39
+ return self.obtain(hosts, **kwargs)
40
+
38
41
 
39
42
  class DummyClientWithObtain(DummyClient):
40
43
  def __init__(self):
@@ -45,12 +48,12 @@ class DummyClientWithObtain(DummyClient):
45
48
  def obtain(self, hosts, **kwargs):
46
49
  host = hosts[0] if isinstance(hosts, list) else hosts
47
50
  self.obtain_calls.append({"host": host, "kwargs": kwargs})
48
- return super().issue_certificate(hosts, **kwargs)
51
+ return super().obtain(hosts, **kwargs)
49
52
 
50
53
  def issue_certificate(self, hosts, **kwargs):
51
54
  host = hosts[0] if isinstance(hosts, list) else hosts
52
55
  self.issue_calls.append({"host": host, "kwargs": kwargs})
53
- return super().issue_certificate(hosts, **kwargs)
56
+ return super().obtain(hosts, **kwargs)
54
57
 
55
58
 
56
59
  class DummyRemoteClient(CertManagerClient):
@@ -155,7 +158,7 @@ def test_default_threshold_and_min_floor(monkeypatch):
155
158
  def test_sleep_computation_slack_and_cap():
156
159
  now = datetime(2026, 1, 1, tzinfo=UTC)
157
160
  mgr = RenewalManager(DummyClient(), renew_threshold_days=30)
158
- mgr.set_watch_domains(["example.com"])
161
+ mgr.update_watch_domains(["example.com"])
159
162
 
160
163
  with mgr._lock:
161
164
  mgr._cache["example.com"] = now + timedelta(days=60)
@@ -184,8 +187,8 @@ def test_existing_cert_failure_sets_threshold_plus_24h_retry():
184
187
  renew_threshold_days=30,
185
188
  clock_fn=lambda: clock["now"],
186
189
  )
187
- mgr.set_watch_domains(["example.com"])
188
190
  with mgr._lock:
191
+ mgr._watch_domains = {"example.com"}
189
192
  mgr._cache["example.com"] = now - timedelta(hours=1)
190
193
 
191
194
  mgr.trigger_now()
@@ -219,7 +222,7 @@ def test_existing_cert_failure_does_not_selfsign():
219
222
  client.set_handler("existing.example.com", RuntimeError("renew failed"))
220
223
 
221
224
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
222
- mgr.set_watch_domains(["existing.example.com"])
225
+ mgr.update_watch_domains(["existing.example.com"])
223
226
  with mgr._lock:
224
227
  mgr._cache["existing.example.com"] = now - timedelta(hours=1)
225
228
 
@@ -252,9 +255,7 @@ def test_expired_local_cert_failure_keeps_existing_cert_and_defers_retry():
252
255
  client.set_handler("expired.example.com", RuntimeError("renew failed"))
253
256
 
254
257
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
255
- mgr.set_watch_domains(["expired.example.com"])
256
-
257
- mgr.trigger_now()
258
+ mgr.update_watch_domains(["expired.example.com"])
258
259
 
259
260
  expected = now + timedelta(seconds=mgr.update_threshold_secs + mgr.renew_retry_interval_seconds)
260
261
  with mgr._lock:
@@ -290,8 +291,7 @@ def test_expired_sqlite_cert_failure_does_not_seed_selfsigned_as_fresh():
290
291
  client.set_handler("sqlite-expired.example.com", RuntimeError("renew failed"))
291
292
 
292
293
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
293
- mgr.set_watch_domains(["sqlite-expired.example.com"])
294
- mgr.trigger_now()
294
+ mgr.update_watch_domains(["sqlite-expired.example.com"])
295
295
 
296
296
  with mgr._lock:
297
297
  assert "sqlite-expired.example.com" in mgr._cache
@@ -309,7 +309,7 @@ def test_retry_deferral_suppresses_immediate_retry_and_force_when_blacklisted():
309
309
  client.set_handler("retry.example.com", RuntimeError("renew failed"))
310
310
 
311
311
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: clock["now"])
312
- mgr.set_watch_domains(["retry.example.com"])
312
+ mgr.update_watch_domains(["retry.example.com"])
313
313
  with mgr._lock:
314
314
  mgr._cache["retry.example.com"] = now - timedelta(minutes=1)
315
315
 
@@ -337,15 +337,15 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
337
337
  mgr = None
338
338
 
339
339
  def sync_watch_domains():
340
- mgr.set_watch_domains(["c.example.com"])
340
+ mgr.update_watch_domains(["c.example.com"])
341
341
 
342
342
  mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
343
- mgr.set_watch_domains(["a.example.com", "b.example.com"])
343
+ mgr.update_watch_domains(["a.example.com", "b.example.com"])
344
344
  with mgr._lock:
345
345
  mgr._cache["a.example.com"] = now + timedelta(days=20)
346
346
  mgr._cache["b.example.com"] = now + timedelta(days=20)
347
347
 
348
- mgr.set_watch_domains(["a.example.com"])
348
+ mgr.update_watch_domains(["a.example.com"])
349
349
  with mgr._lock:
350
350
  assert "b.example.com" not in mgr._cache
351
351
 
@@ -375,7 +375,7 @@ def test_bootstrap_and_cache_update_from_issued_and_existing():
375
375
  )
376
376
 
377
377
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
378
- mgr.set_watch_domains(["issued.example.com", "existing.example.com"])
378
+ mgr.update_watch_domains(["issued.example.com", "existing.example.com"])
379
379
 
380
380
  # No cache initially; both should be obtained immediately to bootstrap.
381
381
  mgr._run_cycle(force=False)
@@ -401,13 +401,39 @@ def test_renewal_manager_prefers_obtain_when_available():
401
401
  )
402
402
 
403
403
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
404
- mgr.set_watch_domains(["prefer-obtain.example.com"])
405
- mgr._run_cycle(force=False)
404
+ mgr.update_watch_domains(["prefer-obtain.example.com"])
406
405
 
407
406
  assert len(client.obtain_calls) == 1
408
407
  assert len(client.issue_calls) == 0
409
408
 
410
409
 
410
+ def test_renewal_manager_passes_typed_obtain_options_into_renewal_calls():
411
+ now = datetime(2026, 1, 1, tzinfo=UTC)
412
+ client = DummyClient()
413
+ cert = _make_cert_pem("batch.example.com", now, valid_for_days=60)
414
+ client.set_handler(
415
+ "batch.example.com",
416
+ CertificateResponse(issued=[IssuedCert(cert=cert, domains=["batch.example.com"])], existing=[]),
417
+ )
418
+
419
+ mgr = RenewalManager(
420
+ client,
421
+ renew_threshold_days=30,
422
+ clock_fn=lambda: now,
423
+ batch_domains=True,
424
+ self_verify=False,
425
+ organization="certapi-tests",
426
+ )
427
+ mgr.update_watch_domains(["batch.example.com"])
428
+
429
+ assert len(client.calls) == 1
430
+ assert client.calls[0]["kwargs"]["batch_domains"] is True
431
+ assert client.calls[0]["kwargs"]["self_verify"] is False
432
+ assert client.calls[0]["kwargs"]["skip_failing"] is False
433
+ assert client.calls[0]["kwargs"]["renew_threshold_days"] == 30
434
+ assert client.calls[0]["kwargs"]["organization"] == "certapi-tests"
435
+
436
+
411
437
  def test_local_keystore_seed_prevents_remote_call_when_fresh():
412
438
  now = datetime(2026, 1, 1, tzinfo=UTC)
413
439
  key = Key.generate("ecdsa")
@@ -430,8 +456,7 @@ def test_local_keystore_seed_prevents_remote_call_when_fresh():
430
456
  client.key_store.set_domain_cert("fresh.example.com", [cert])
431
457
 
432
458
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
433
- mgr.set_watch_domains(["fresh.example.com"])
434
- mgr._run_cycle(force=False)
459
+ mgr.update_watch_domains(["fresh.example.com"])
435
460
 
436
461
  with mgr._lock:
437
462
  assert "fresh.example.com" in mgr._cache
@@ -465,8 +490,7 @@ def test_local_keystore_seed_stale_cert_still_renews():
465
490
  )
466
491
 
467
492
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
468
- mgr.set_watch_domains(["stale.example.com"])
469
- mgr._run_cycle(force=False)
493
+ mgr.update_watch_domains(["stale.example.com"])
470
494
 
471
495
  assert len(client.calls) == 1
472
496
  with mgr._lock:
@@ -486,9 +510,8 @@ def test_new_domain_failure_selfsigns_and_blacklists():
486
510
  blacklist_duration_seconds=180,
487
511
  clock_fn=lambda: clock["now"],
488
512
  )
489
- mgr.set_watch_domains(["new.example.com"])
513
+ mgr.update_watch_domains(["new.example.com"])
490
514
 
491
- mgr._run_cycle(force=False)
492
515
  assert len(client.calls) == 1
493
516
  assert len(client.key_store.saved_certs) == 1
494
517
  assert client.key_store.saved_certs[0][2] == "new.example.com.selfsigned"
@@ -519,19 +542,37 @@ def test_sync_callback_exception_sets_state_error():
519
542
  assert state["last_error_timestamp"] == now.isoformat()
520
543
 
521
544
 
522
- def test_set_watch_domains_external_update_requests_followup_cycle_when_running():
545
+ def test_update_watch_domains_blocks_until_running_worker_processes_domains():
546
+ now = datetime(2026, 1, 1, tzinfo=UTC)
547
+ started = threading.Event()
548
+ release = threading.Event()
523
549
  client = DummyClient()
524
- mgr = RenewalManager(client)
525
- with mgr._lock:
526
- mgr._running = True
550
+ cert = _make_cert_pem("requested.example.com", now, valid_for_days=90)
551
+
552
+ def slow_handler(host, kwargs):
553
+ started.set()
554
+ release.wait(timeout=2)
555
+ return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
527
556
 
528
- mgr.set_watch_domains(["requested.example.com"])
557
+ client.set_handler("requested.example.com", slow_handler)
558
+ mgr = RenewalManager(client, clock_fn=lambda: now)
559
+ mgr.start()
529
560
 
530
- with mgr._lock:
531
- assert mgr._force_trigger is True
561
+ update_thread = threading.Thread(target=lambda: mgr.update_watch_domains(["requested.example.com"]))
562
+ update_thread.start()
563
+
564
+ assert started.wait(timeout=2)
565
+ assert update_thread.is_alive()
566
+ release.set()
567
+ update_thread.join(timeout=2)
568
+ mgr.stop()
569
+
570
+ assert not update_thread.is_alive()
571
+ assert len(client.calls) == 1
572
+ assert client.calls[0]["host"] == "requested.example.com"
532
573
 
533
574
 
534
- def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
575
+ def test_update_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
535
576
  now = datetime(2026, 1, 1, tzinfo=UTC)
536
577
  client = DummyClient()
537
578
  cert = _make_cert_pem("from-callback.example.com", now, valid_for_days=60)
@@ -542,7 +583,7 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
542
583
  mgr = None
543
584
 
544
585
  def sync_watch_domains():
545
- mgr.set_watch_domains(["from-callback.example.com"])
586
+ mgr.update_watch_domains(["from-callback.example.com"])
546
587
 
547
588
  mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
548
589
  with mgr._lock:
@@ -552,9 +593,55 @@ def test_set_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
552
593
 
553
594
  with mgr._lock:
554
595
  assert mgr._force_trigger is False
596
+ assert mgr._cycle_requested is False
555
597
  assert len(client.calls) == 1
556
598
 
557
599
 
600
+ def test_running_trigger_now_blocks_until_synced_cycle_finishes():
601
+ now = datetime(2026, 1, 1, tzinfo=UTC)
602
+ sync_count = 0
603
+ sync_ready = threading.Event()
604
+ sync_enabled = threading.Event()
605
+ obtain_started = threading.Event()
606
+ release_obtain = threading.Event()
607
+ client = DummyClient()
608
+ cert = _make_cert_pem("blocking.example.com", now, valid_for_days=90)
609
+ mgr = None
610
+
611
+ def slow_handler(host, kwargs):
612
+ obtain_started.set()
613
+ release_obtain.wait(timeout=2)
614
+ return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
615
+
616
+ def sync_watch_domains():
617
+ nonlocal sync_count
618
+ sync_count += 1
619
+ sync_ready.set()
620
+ if sync_enabled.is_set():
621
+ mgr.update_watch_domains(["blocking.example.com"])
622
+
623
+ client.set_handler("blocking.example.com", slow_handler)
624
+ mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
625
+
626
+ mgr.start()
627
+ assert sync_ready.wait(timeout=2)
628
+
629
+ sync_enabled.set()
630
+ trigger_thread = threading.Thread(target=mgr.trigger_now)
631
+ trigger_thread.start()
632
+
633
+ assert obtain_started.wait(timeout=2)
634
+ assert trigger_thread.is_alive()
635
+ release_obtain.set()
636
+ trigger_thread.join(timeout=2)
637
+
638
+ mgr.stop()
639
+ assert not trigger_thread.is_alive()
640
+ assert sync_count >= 2
641
+ assert len(client.calls) == 1
642
+ assert client.calls[0]["host"] == "blocking.example.com"
643
+
644
+
558
645
  def test_singleflight_suppresses_concurrent_manual_cycles():
559
646
  now = datetime(2026, 1, 1, tzinfo=UTC)
560
647
  started = threading.Event()
@@ -569,7 +656,8 @@ def test_singleflight_suppresses_concurrent_manual_cycles():
569
656
 
570
657
  client.set_handler("singleflight.example.com", slow_handler)
571
658
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
572
- mgr.set_watch_domains(["singleflight.example.com"])
659
+ with mgr._lock:
660
+ mgr._watch_domains = {"singleflight.example.com"}
573
661
 
574
662
  first = threading.Thread(target=lambda: mgr.trigger_now())
575
663
  first.start()
@@ -599,7 +687,8 @@ def test_remote_certapi_polling_prints_waiting_message(capsys):
599
687
  remote_poll_interval_seconds=0.01,
600
688
  clock_fn=lambda: now,
601
689
  )
602
- mgr.set_watch_domains(["remote.example.com"])
690
+ with mgr._lock:
691
+ mgr._watch_domains = {"remote.example.com"}
603
692
 
604
693
  thread = threading.Thread(target=lambda: mgr.trigger_now())
605
694
  thread.start()
@@ -620,9 +709,7 @@ def test_remote_renewal_disables_skip_failing_so_unverified_domain_fails():
620
709
  client.key_store = DummyKeyStore()
621
710
 
622
711
  mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now)
623
- mgr.set_watch_domains(["missing.example.com"])
624
-
625
- mgr.trigger_now()
712
+ mgr.update_watch_domains(["missing.example.com"])
626
713
 
627
714
  assert len(client.obtain_calls) == 1
628
715
  assert client.obtain_calls[0]["kwargs"]["skip_failing"] is False
@@ -637,7 +724,8 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
637
724
  release = threading.Event()
638
725
  client = DummyRemoteClient(started=started, release=release)
639
726
  mgr = RenewalManager(client, remote_poll_interval_seconds=0.01)
640
- mgr.set_watch_domains(["hung.example.com"])
727
+ with mgr._lock:
728
+ mgr._watch_domains = {"hung.example.com"}
641
729
 
642
730
  mgr.start()
643
731
  assert started.wait(timeout=2)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes