certapi 1.1.5__tar.gz → 1.1.7__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 (71) hide show
  1. {certapi-1.1.5/src/certapi.egg-info → certapi-1.1.7}/PKG-INFO +2 -2
  2. {certapi-1.1.5 → certapi-1.1.7}/setup.py +2 -2
  3. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/client/renewal_manager.py +87 -55
  4. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/issuers/AcmeCertIssuer.py +1 -1
  5. {certapi-1.1.5 → certapi-1.1.7/src/certapi.egg-info}/PKG-INFO +2 -2
  6. certapi-1.1.7/src/certapi.egg-info/requires.txt +2 -0
  7. {certapi-1.1.5 → certapi-1.1.7}/tests/test_renewal_manager.py +31 -49
  8. certapi-1.1.5/src/certapi.egg-info/requires.txt +0 -2
  9. {certapi-1.1.5 → certapi-1.1.7}/MANIFEST.in +0 -0
  10. {certapi-1.1.5 → certapi-1.1.7}/README.md +0 -0
  11. {certapi-1.1.5 → certapi-1.1.7}/pyproject.toml +0 -0
  12. {certapi-1.1.5 → certapi-1.1.7}/setup.cfg +0 -0
  13. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/__init__.py +0 -0
  14. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/acme/Acme.py +0 -0
  15. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/acme/AcmeError.py +0 -0
  16. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/acme/Challenge.py +0 -0
  17. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/acme/Order.py +0 -0
  18. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/acme/__init__.py +0 -0
  19. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/acme/http.py +0 -0
  20. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  21. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  22. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  23. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/__init__.py +0 -0
  24. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  25. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  26. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  27. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  28. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  29. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  30. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  31. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/cli.py +0 -0
  32. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/client/__init__.py +0 -0
  33. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/client/cert_manager_client.py +0 -0
  34. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/crypto/__init__.py +0 -0
  35. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/crypto/crypto.py +0 -0
  36. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/crypto/crypto_classes.py +0 -0
  37. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/domain_batching.py +0 -0
  38. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/errors.py +0 -0
  39. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/http/HttpClientBase.py +0 -0
  40. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/http/__init__.py +0 -0
  41. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/http/types.py +0 -0
  42. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  43. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/issuers/__init__.py +0 -0
  44. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/issuers/abstract_certissuer.py +0 -0
  45. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  46. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/keystore/KeyStore.py +0 -0
  47. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  48. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  49. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  50. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/keystore/__init__.py +0 -0
  51. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/manager/__init__.py +0 -0
  52. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/manager/acme_cert_manager.py +0 -0
  53. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/server/__init__.py +0 -0
  54. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/server/api.py +0 -0
  55. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/server/cert_api.py +0 -0
  56. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/server/key_api.py +0 -0
  57. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/util.py +0 -0
  58. {certapi-1.1.5 → certapi-1.1.7}/src/certapi/utils.py +0 -0
  59. {certapi-1.1.5 → certapi-1.1.7}/src/certapi.egg-info/SOURCES.txt +0 -0
  60. {certapi-1.1.5 → certapi-1.1.7}/src/certapi.egg-info/dependency_links.txt +0 -0
  61. {certapi-1.1.5 → certapi-1.1.7}/src/certapi.egg-info/entry_points.txt +0 -0
  62. {certapi-1.1.5 → certapi-1.1.7}/src/certapi.egg-info/top_level.txt +0 -0
  63. {certapi-1.1.5 → certapi-1.1.7}/tests/test_acme_cert_manager_batching.py +0 -0
  64. {certapi-1.1.5 → certapi-1.1.7}/tests/test_acme_error_handling.py +0 -0
  65. {certapi-1.1.5 → certapi-1.1.7}/tests/test_cert_issuer_generic.py +0 -0
  66. {certapi-1.1.5 → certapi-1.1.7}/tests/test_certs_with_key_types.py +0 -0
  67. {certapi-1.1.5 → certapi-1.1.7}/tests/test_cli.py +0 -0
  68. {certapi-1.1.5 → certapi-1.1.7}/tests/test_domain_batching_boulder_cases.py +0 -0
  69. {certapi-1.1.5 → certapi-1.1.7}/tests/test_http_error_handling.py +0 -0
  70. {certapi-1.1.5 → certapi-1.1.7}/tests/test_keystores.py +0 -0
  71. {certapi-1.1.5 → certapi-1.1.7}/tests/test_obtain_interface.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.5
3
+ Version: 1.1.7
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
@@ -10,7 +10,7 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.6
12
12
  Description-Content-Type: text/markdown
13
- Requires-Dist: cryptography
13
+ Requires-Dist: cryptography>=39.0.0
14
14
  Requires-Dist: requests
15
15
  Dynamic: author
16
16
  Dynamic: author-email
@@ -2,11 +2,11 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.1.5",
5
+ version="1.1.7",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
9
- "cryptography",
9
+ "cryptography>=39.0.0",
10
10
  "requests",
11
11
  ],
12
12
  include_package_data=True,
@@ -18,9 +18,13 @@ class RenewalManager:
18
18
  callers publish the complete desired domain set through
19
19
  :meth:`update_watch_domains`. Publishing a domain set is synchronous: the call
20
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.
21
+ obtain/renew work.
22
+
23
+ The integration owns domain discovery. Certapi's background renewal cycle
24
+ only invokes ``renewal_callback`` when it is time to refresh external state;
25
+ that callback is responsible for calling :meth:`update_watch_domains` with
26
+ the current complete domain set. ``update_watch_domains`` is the only method
27
+ that mutates watched domains and performs due renewal work.
24
28
 
25
29
  Algorithm:
26
30
  - Maintain a watched domain set and cache each domain's certificate expiry.
@@ -48,7 +52,7 @@ class RenewalManager:
48
52
  def __init__(
49
53
  self,
50
54
  cert_manager_client,
51
- sync_watch_domains: Optional[Callable[[], None]] = None,
55
+ renewal_callback: Optional[Callable[[], None]] = None,
52
56
  renew_threshold_days: Optional[int] = None,
53
57
  min_renew_threshold_days: int = 10,
54
58
  sleep_slack_seconds: int = 300,
@@ -75,11 +79,10 @@ class RenewalManager:
75
79
  cert_manager_client: Backend object with an ``obtain(domains,
76
80
  **kwargs)`` method. Local managers and ``CertManagerClient`` are
77
81
  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.
82
+ renewal_callback: Optional callback invoked by background or forced
83
+ renewal cycles. Integrations should use this to recompute their
84
+ domain list and call :meth:`update_watch_domains`. The callback
85
+ itself should not perform certificate issuance directly.
83
86
  renew_threshold_days: Renew certificates when they expire within
84
87
  this many days. Defaults to ``CERT_RENEW_THRESHOLD_DAYS`` or the
85
88
  certapi default.
@@ -113,7 +116,7 @@ class RenewalManager:
113
116
  certs.
114
117
  """
115
118
  self.cert_manager_client = cert_manager_client
116
- self.sync_watch_domains = sync_watch_domains
119
+ self.renewal_callback = renewal_callback
117
120
  self.key_type = key_type
118
121
  self.expiry_days = expiry_days
119
122
  self.batch_domains = batch_domains
@@ -154,6 +157,10 @@ class RenewalManager:
154
157
  self._cycle_running = False
155
158
  self._cycle_thread_id: Optional[int] = None
156
159
  self._cycle_generation = 0
160
+ self._renewal_running = False
161
+ self._renewal_requested = False
162
+ self._renewal_thread_id: Optional[int] = None
163
+ self._renewal_generation = 0
157
164
  self._blacklist: Dict[str, datetime] = {}
158
165
  self._last_error_message: Optional[str] = None
159
166
  self._last_error_timestamp: Optional[datetime] = None
@@ -166,30 +173,20 @@ class RenewalManager:
166
173
  Empty values are ignored. Cache and blacklist entries for domains no
167
174
  longer being watched are dropped.
168
175
 
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.
176
+ This method immediately runs or queues a renewal pass and blocks until
177
+ certapi has attempted any due obtain/renew work for the latest published
178
+ set. Concurrent calls are serialized inside the manager; if a renewal
179
+ pass is already running, the current pass is allowed to finish and a
180
+ follow-up pass processes the newest watch set before callers return.
174
181
  """
175
182
  new_watch_set = {x for x in domains if x}
176
183
  with self._lock:
177
184
  self._watch_domains = new_watch_set
178
185
  self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
179
186
  self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
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
190
187
  self._lock.notify_all()
191
188
 
192
- self._run_cycle(force=False)
189
+ self._run_renewal_pass(force=False)
193
190
 
194
191
  def start(self):
195
192
  """
@@ -227,15 +224,13 @@ class RenewalManager:
227
224
 
228
225
  def trigger_now(self):
229
226
  """
230
- Run a renewal pass immediately.
227
+ Run the renewal trigger immediately.
231
228
 
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.
229
+ When a renewal callback is configured, this method invokes that callback
230
+ and waits for it to return. The callback should call
231
+ :meth:`update_watch_domains`, which performs due renewal work. Without a
232
+ callback, this method performs a direct forced renewal pass over the
233
+ existing watch set.
239
234
 
240
235
  If called from inside the renewal cycle itself, the method only requests
241
236
  a follow-up pass and returns immediately to avoid deadlock.
@@ -321,14 +316,6 @@ class RenewalManager:
321
316
  # Testing hook: allows deterministic no-op sleep behavior.
322
317
  self.sleep_fn(wait_seconds)
323
318
 
324
- def _sync_watch_domains(self):
325
- if self.sync_watch_domains is None:
326
- return
327
- try:
328
- self.sync_watch_domains()
329
- except Exception as e:
330
- self._set_error(e)
331
-
332
319
  def _due_window_secs(self) -> float:
333
320
  return max(self.update_threshold_secs, self.cert_min_renew_threshold_secs)
334
321
 
@@ -366,7 +353,49 @@ class RenewalManager:
366
353
  self._lock.notify_all()
367
354
 
368
355
  def _run_cycle_body(self, force: bool = False) -> int:
369
- self._sync_watch_domains()
356
+ if self.renewal_callback is None:
357
+ return self._run_renewal_pass(force=force)
358
+
359
+ try:
360
+ self.renewal_callback()
361
+ except Exception as e:
362
+ self._set_error(e)
363
+ return 0
364
+
365
+ def _run_renewal_pass(self, force: bool = False) -> int:
366
+ count = 0
367
+ current_thread_id = threading.get_ident()
368
+ with self._lock:
369
+ if self._renewal_thread_id == current_thread_id:
370
+ return 0
371
+ if self._renewal_running:
372
+ target_generation = self._renewal_generation + 2
373
+ self._renewal_requested = True
374
+ self._lock.notify_all()
375
+ while self._renewal_generation < target_generation:
376
+ self._lock.wait()
377
+ return 0
378
+
379
+ self._renewal_running = True
380
+ self._renewal_thread_id = current_thread_id
381
+
382
+ try:
383
+ while True:
384
+ count += self._run_renewal_pass_body(force=force)
385
+ with self._lock:
386
+ self._renewal_generation += 1
387
+ if not self._renewal_requested:
388
+ return count
389
+ self._renewal_requested = False
390
+ force = False
391
+ self._lock.notify_all()
392
+ finally:
393
+ with self._lock:
394
+ self._renewal_running = False
395
+ self._renewal_thread_id = None
396
+ self._lock.notify_all()
397
+
398
+ def _run_renewal_pass_body(self, force: bool = False) -> int:
370
399
  self._seed_cache_from_local_keystore()
371
400
  now = self.clock_fn()
372
401
  self._clean_blacklist(now)
@@ -390,8 +419,8 @@ class RenewalManager:
390
419
  if (expiry - now).total_seconds() < due_window:
391
420
  due_domains.append(domain)
392
421
 
393
- for domain in due_domains:
394
- self._renew_domain(domain, now)
422
+ if due_domains:
423
+ self._renew_domains(due_domains, now)
395
424
 
396
425
  return len(due_domains)
397
426
 
@@ -434,10 +463,12 @@ class RenewalManager:
434
463
  self._cache[domain] = expiry
435
464
  self._lock.notify_all()
436
465
 
437
- def _renew_domain(self, domain: str, now: datetime):
438
- domain_had_cached_entry = False
466
+ def _renew_domains(self, domains: List[str], now: datetime):
467
+ domains_with_cached_entries = []
439
468
  with self._lock:
440
- domain_had_cached_entry = domain in self._cache
469
+ for domain in domains:
470
+ if domain in self._cache:
471
+ domains_with_cached_entries.append(domain)
441
472
 
442
473
  try:
443
474
  renew_threshold_days = self.cert_min_renew_threshold_secs // (24 * 3600)
@@ -446,7 +477,7 @@ class RenewalManager:
446
477
  "expiry_days": self.expiry_days,
447
478
  "renew_threshold_days": renew_threshold_days,
448
479
  "skip_failing": False,
449
- "batch_domains": self.batch_domains,
480
+ "batch_domains": True,
450
481
  "self_verify": self.self_verify,
451
482
  "country": self.country,
452
483
  "state": self.state,
@@ -456,7 +487,7 @@ class RenewalManager:
456
487
  }
457
488
  res = self._call_certificate_backend(
458
489
  self.cert_manager_client.obtain,
459
- [domain],
490
+ domains,
460
491
  obtain_options,
461
492
  )
462
493
  if res is None:
@@ -464,11 +495,12 @@ class RenewalManager:
464
495
  self._update_expiry_cache(res.issued + res.existing)
465
496
  except Exception as e:
466
497
  self._set_error(e)
467
- self._add_to_blacklist(domain, now)
468
- if domain_had_cached_entry or self._has_local_certificate(domain):
469
- self._schedule_existing_certificate_retry(domain, now)
470
- else:
471
- self._register_self_signed(domain)
498
+ for domain in domains:
499
+ self._add_to_blacklist(domain, now)
500
+ if domain in domains_with_cached_entries or self._has_local_certificate(domain):
501
+ self._schedule_existing_certificate_retry(domain, now)
502
+ else:
503
+ self._register_self_signed(domain)
472
504
 
473
505
  def _schedule_existing_certificate_retry(self, domain: str, now: datetime):
474
506
  retry_interval_seconds = min(self.renew_retry_interval_seconds, 24 * 3600)
@@ -95,7 +95,7 @@ class AcmeCertIssuer(CertIssuer):
95
95
  return order.get_certificate()
96
96
  return None
97
97
  elif order.status == "processing":
98
- return obtain_cert()
98
+ return obtain_cert(count - 1)
99
99
  return None # TODO: error throwing here
100
100
 
101
101
  return obtain_cert()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.5
3
+ Version: 1.1.7
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
@@ -10,7 +10,7 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.6
12
12
  Description-Content-Type: text/markdown
13
- Requires-Dist: cryptography
13
+ Requires-Dist: cryptography>=39.0.0
14
14
  Requires-Dist: requests
15
15
  Dynamic: author
16
16
  Dynamic: author-email
@@ -0,0 +1,2 @@
1
+ cryptography>=39.0.0
2
+ requests
@@ -25,7 +25,7 @@ class DummyClient:
25
25
 
26
26
  def obtain(self, hosts, **kwargs):
27
27
  host = hosts[0] if isinstance(hosts, list) else hosts
28
- self.calls.append({"host": host, "kwargs": kwargs})
28
+ self.calls.append({"host": host, "hosts": hosts, "kwargs": kwargs})
29
29
  handler = self._handlers.get(host)
30
30
  if handler is None:
31
31
  return CertificateResponse()
@@ -47,12 +47,12 @@ class DummyClientWithObtain(DummyClient):
47
47
 
48
48
  def obtain(self, hosts, **kwargs):
49
49
  host = hosts[0] if isinstance(hosts, list) else hosts
50
- self.obtain_calls.append({"host": host, "kwargs": kwargs})
50
+ self.obtain_calls.append({"host": host, "hosts": hosts, "kwargs": kwargs})
51
51
  return super().obtain(hosts, **kwargs)
52
52
 
53
53
  def issue_certificate(self, hosts, **kwargs):
54
54
  host = hosts[0] if isinstance(hosts, list) else hosts
55
- self.issue_calls.append({"host": host, "kwargs": kwargs})
55
+ self.issue_calls.append({"host": host, "hosts": hosts, "kwargs": kwargs})
56
56
  return super().obtain(hosts, **kwargs)
57
57
 
58
58
 
@@ -325,7 +325,7 @@ def test_retry_deferral_suppresses_immediate_retry_and_force_when_blacklisted():
325
325
  assert len(client.calls) == 1
326
326
 
327
327
 
328
- def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replaces():
328
+ def test_watch_domain_replacement_drops_unwatched_cache_and_external_update_processes_new_set():
329
329
  now = datetime(2026, 1, 1, tzinfo=UTC)
330
330
  client = DummyClient()
331
331
  cert_c = _make_cert_pem("c.example.com", now, valid_for_days=60)
@@ -334,12 +334,7 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
334
334
  CertificateResponse(issued=[IssuedCert(cert=cert_c, domains=["c.example.com"])], existing=[]),
335
335
  )
336
336
 
337
- mgr = None
338
-
339
- def sync_watch_domains():
340
- mgr.update_watch_domains(["c.example.com"])
341
-
342
- mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
337
+ mgr = RenewalManager(client, clock_fn=lambda: now)
343
338
  mgr.update_watch_domains(["a.example.com", "b.example.com"])
344
339
  with mgr._lock:
345
340
  mgr._cache["a.example.com"] = now + timedelta(days=20)
@@ -349,8 +344,9 @@ def test_watch_domain_replacement_drops_unwatched_cache_and_sync_callback_replac
349
344
  with mgr._lock:
350
345
  assert "b.example.com" not in mgr._cache
351
346
 
352
- # callback replaces watch set with c.example.com before renewal pass.
353
- mgr.trigger_now()
347
+ # Integrations can publish new domain state directly, outside the timed
348
+ # renewal callback path.
349
+ mgr.update_watch_domains(["c.example.com"])
354
350
  state = mgr.get_state()
355
351
  assert state["watched_domains"] == ["c.example.com"]
356
352
  with mgr._lock:
@@ -527,21 +523,6 @@ def test_new_domain_failure_selfsigns_and_blacklists():
527
523
  assert len(client.calls) == 2
528
524
 
529
525
 
530
- def test_sync_callback_exception_sets_state_error():
531
- now = datetime(2026, 1, 1, tzinfo=UTC)
532
- mgr = RenewalManager(
533
- DummyClient(),
534
- sync_watch_domains=lambda: (_ for _ in ()).throw(RuntimeError("sync failed")),
535
- clock_fn=lambda: now,
536
- )
537
-
538
- mgr.trigger_now()
539
-
540
- state = mgr.get_state()
541
- assert state["last_error_message"] == "sync failed"
542
- assert state["last_error_timestamp"] == now.isoformat()
543
-
544
-
545
526
  def test_update_watch_domains_blocks_until_running_worker_processes_domains():
546
527
  now = datetime(2026, 1, 1, tzinfo=UTC)
547
528
  started = threading.Event()
@@ -572,7 +553,7 @@ def test_update_watch_domains_blocks_until_running_worker_processes_domains():
572
553
  assert client.calls[0]["host"] == "requested.example.com"
573
554
 
574
555
 
575
- def test_update_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
556
+ def test_renewal_cycle_invokes_callback_and_callback_updates_domains():
576
557
  now = datetime(2026, 1, 1, tzinfo=UTC)
577
558
  client = DummyClient()
578
559
  cert = _make_cert_pem("from-callback.example.com", now, valid_for_days=60)
@@ -582,26 +563,22 @@ def test_update_watch_domains_from_cycle_thread_does_not_request_extra_cycle():
582
563
  )
583
564
  mgr = None
584
565
 
585
- def sync_watch_domains():
566
+ def renewal_callback():
586
567
  mgr.update_watch_domains(["from-callback.example.com"])
587
568
 
588
- mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
589
- with mgr._lock:
590
- mgr._running = True
591
-
592
- mgr._run_cycle(force=False)
569
+ mgr = RenewalManager(client, renewal_callback=renewal_callback, clock_fn=lambda: now)
570
+ mgr.trigger_now()
593
571
 
594
572
  with mgr._lock:
573
+ assert mgr._watch_domains == {"from-callback.example.com"}
574
+ assert "from-callback.example.com" in mgr._cache
595
575
  assert mgr._force_trigger is False
596
576
  assert mgr._cycle_requested is False
597
577
  assert len(client.calls) == 1
598
578
 
599
579
 
600
- def test_running_trigger_now_blocks_until_synced_cycle_finishes():
580
+ def test_running_trigger_now_blocks_until_callback_update_finishes():
601
581
  now = datetime(2026, 1, 1, tzinfo=UTC)
602
- sync_count = 0
603
- sync_ready = threading.Event()
604
- sync_enabled = threading.Event()
605
582
  obtain_started = threading.Event()
606
583
  release_obtain = threading.Event()
607
584
  client = DummyClient()
@@ -613,20 +590,13 @@ def test_running_trigger_now_blocks_until_synced_cycle_finishes():
613
590
  release_obtain.wait(timeout=2)
614
591
  return CertificateResponse(issued=[IssuedCert(cert=cert, domains=[host])], existing=[])
615
592
 
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"])
593
+ def renewal_callback():
594
+ mgr.update_watch_domains(["blocking.example.com"])
622
595
 
623
596
  client.set_handler("blocking.example.com", slow_handler)
624
- mgr = RenewalManager(client, sync_watch_domains=sync_watch_domains, clock_fn=lambda: now)
597
+ mgr = RenewalManager(client, renewal_callback=renewal_callback, clock_fn=lambda: now)
625
598
 
626
599
  mgr.start()
627
- assert sync_ready.wait(timeout=2)
628
-
629
- sync_enabled.set()
630
600
  trigger_thread = threading.Thread(target=mgr.trigger_now)
631
601
  trigger_thread.start()
632
602
 
@@ -637,7 +607,6 @@ def test_running_trigger_now_blocks_until_synced_cycle_finishes():
637
607
 
638
608
  mgr.stop()
639
609
  assert not trigger_thread.is_alive()
640
- assert sync_count >= 2
641
610
  assert len(client.calls) == 1
642
611
  assert client.calls[0]["host"] == "blocking.example.com"
643
612
 
@@ -733,3 +702,16 @@ def test_stop_does_not_wait_for_hung_remote_request_thread():
733
702
 
734
703
  assert not mgr._thread.is_alive()
735
704
  release.set()
705
+
706
+
707
+ def test_renewal_manager_batches_due_domains():
708
+ now = datetime(2026, 1, 1, tzinfo=UTC)
709
+ client = DummyClientWithObtain()
710
+ client.set_handler("domain1.com", CertificateResponse())
711
+
712
+ mgr = RenewalManager(client, renew_threshold_days=30, clock_fn=lambda: now, batch_domains=True)
713
+ mgr.update_watch_domains(["domain1.com", "domain2.com"])
714
+
715
+ assert len(client.obtain_calls) == 1
716
+ assert "domain1.com" in client.obtain_calls[0]["hosts"]
717
+ assert "domain2.com" in client.obtain_calls[0]["hosts"]
@@ -1,2 +0,0 @@
1
- cryptography
2
- requests
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