From c8ebcb49bd5a1890ecd6284b11f5fafe17920ad0 Mon Sep 17 00:00:00 2001 From: Mike Fara <35661811+faratech@users.noreply.github.com> Date: Wed, 6 May 2026 12:35:38 -0400 Subject: [PATCH] Migrate certbot-dns-cloudflare to cloudflare 4.x SDK (#10587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Migrate `certbot-dns-cloudflare` from the archived `python-cloudflare` 2.x library (`import CloudFlare`) to the actively maintained Cloudflare Python SDK 4.x (`import cloudflare`) - Update all API calls to the new SDK surface: `dns.records.create/list/delete`, `zones.list`, typed response objects instead of dicts - Replace `CloudFlare.exceptions.CloudFlareAPIError` with `cloudflare.APIStatusError` and extract CF error codes from `response.json()` - Bump dependency from `cloudflare>=2.19, <2.20` to `cloudflare>=4.0` - Update oldest pinning from `cloudflare 2.19` to `4.0.0` - Update all test mocks and assertions accordingly Fixes #9938 ## API Migration | Operation | Old 2.x | New 4.x | |---|---|---| | Import | `import CloudFlare` | `import cloudflare` | | Client (token) | `CloudFlare.CloudFlare(token=t)` | `cloudflare.Cloudflare(api_token=t)` | | Client (key) | `CloudFlare.CloudFlare(email, key)` | `cloudflare.Cloudflare(api_email=e, api_key=k)` | | List zones | `cf.zones.get(params={...})` → `list[dict]` | `cf.zones.list(name=n)` → iterable of Zone objects | | Create record | `cf.zones.dns_records.post(zone_id, data={...})` | `cf.dns.records.create(zone_id=id, **data)` | | List records | `cf.zones.dns_records.get(zone_id, params={...})` | `cf.dns.records.list(zone_id=id, type=..., ...)` | | Delete record | `cf.zones.dns_records.delete(zone_id, record_id)` | `cf.dns.records.delete(dns_record_id=rid, zone_id=zid)` | | Exceptions | `CloudFlare.exceptions.CloudFlareAPIError` | `cloudflare.APIStatusError` | ## Test plan - [x] All 20 existing tests pass with updated mocks - [x] Credentials INI file format is unchanged — no user-facing config changes - [x] Live dry-run renewal tested successfully across 5 domains --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Ember Co-authored-by: Brad Warren --- certbot-dns-cloudflare/setup.py | 4 +- .../_internal/dns_cloudflare.py | 150 ++++++++----- .../_internal/tests/dns_cloudflare_test.py | 197 ++++++++++++------ certbot/setup.py | 2 +- newsfragments/10587.changed | 1 + pytest.ini | 8 + tools/oldest_constraints.txt | 50 +++-- tools/pinning/oldest/pyproject.toml | 8 +- tools/requirements.txt | 8 +- 9 files changed, 278 insertions(+), 150 deletions(-) create mode 100644 newsfragments/10587.changed diff --git a/certbot-dns-cloudflare/setup.py b/certbot-dns-cloudflare/setup.py index 2f3516357..9a0dbcdfb 100644 --- a/certbot-dns-cloudflare/setup.py +++ b/certbot-dns-cloudflare/setup.py @@ -5,9 +5,7 @@ from setuptools import setup version = '5.6.0.dev0' install_requires = [ - # for now, do not upgrade to cloudflare>=2.20 to avoid deprecation warnings and the breaking - # changes in version 3.0. see https://github.com/certbot/certbot/issues/9938 - 'cloudflare>=2.19, <2.20', + 'cloudflare>=4.0', ] if os.environ.get('SNAP_BUILD'): diff --git a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py index 25ce84171..002311064 100644 --- a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py +++ b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py @@ -1,11 +1,21 @@ """DNS Authenticator for Cloudflare.""" import logging +import warnings from typing import Any from typing import Callable +from typing import Literal from typing import Optional -from typing import cast +from typing import TypedDict -import CloudFlare +# cloudflare 4.x includes a pydantic v1 compatibility shim that emits a +# UserWarning on Python 3.14+. Suppress it here so that this internal-detail +# warning is not shown to users during plugin discovery. In our test suite it +# is filtered out via pytest.ini so it does not affect filterwarnings=error. +with warnings.catch_warnings(): + warnings.filterwarnings('ignore', message='Core Pydantic V1 functionality', + category=UserWarning) + import cloudflare + from cloudflare.types.zones import Zone from certbot import errors from certbot.plugins import dns_common @@ -95,15 +105,15 @@ class _CloudflareClient: api_token: Optional[str] = None) -> None: if email: # If an email was specified, we're using an email/key combination and not a token. - # We can't use named arguments in this case, as it would break compatibility with - # the Cloudflare library since version 2.10.1, as the `token` argument was used for - # tokens and keys alike and the `key` argument did not exist in earlier versions. - self.cf = CloudFlare.CloudFlare(email, api_key) + # We use named arguments here to match the cloudflare 4.x SDK's explicit parameter + # names (api_email and api_key), which correspond to the Global API Key credentials + # found in the Cloudflare dashboard under My Profile > API Tokens. + self.cf = cloudflare.Cloudflare(api_email=email, api_key=api_key) else: - # If no email was specified, we're using just a token. Let's use the named argument - # for simplicity, which is compatible with all (current) versions of the Cloudflare - # library. - self.cf = CloudFlare.CloudFlare(token=api_token) + # If no email was specified, we're using just an API token. We use the named argument + # for clarity. API Tokens are the recommended authentication method as they support + # fine-grained permissions scoped to specific zones and operations. + self.cf = cloudflare.Cloudflare(api_token=api_token) def add_txt_record(self, domain: str, record_name: str, record_content: str, record_ttl: int) -> None: @@ -119,24 +129,30 @@ class _CloudflareClient: zone_id = self._find_zone_id(domain) - data = {'type': 'TXT', - 'name': record_name, - 'content': record_content, - 'ttl': record_ttl} + data: _RecordData = { + 'type': 'TXT', + 'name': record_name, + 'content': record_content, + 'ttl': record_ttl, + } try: logger.debug('Attempting to add record to zone %s: %s', zone_id, data) - self.cf.zones.dns_records.post(zone_id, data=data) # zones | pylint: disable=no-member - except CloudFlare.exceptions.CloudFlareAPIError as e: - code = int(e) + self.cf.dns.records.create(zone_id=zone_id, **data) + except cloudflare.APIStatusError as e: + code = _cf_error_code(e) hint = None if code == 1009: hint = 'Does your API token have "Zone:DNS:Edit" permissions?' - logger.error('Encountered CloudFlareAPIError adding TXT record: %d %s', e, e) + logger.error('Encountered Cloudflare API error adding TXT record: %s', e) raise errors.PluginError('Error communicating with the Cloudflare API: {0}{1}' .format(e, ' ({0})'.format(hint) if hint else '')) + except cloudflare.APIConnectionError as e: + logger.error('Network error talking to the Cloudflare API: %s', e) + raise errors.PluginError('Network error communicating with the Cloudflare API ' + 'while adding a TXT record: {0}'.format(e)) record_id = self._find_txt_record_id(zone_id, record_name, record_content) logger.debug('Successfully added TXT record with record_id: %s', record_id) @@ -159,52 +175,48 @@ class _CloudflareClient: zone_id = self._find_zone_id(domain) except errors.PluginError as e: logger.debug('Encountered error finding zone_id during deletion: %s', e) + logger.debug('Zone not found; no cleanup needed.') return - if zone_id: - record_id = self._find_txt_record_id(zone_id, record_name, record_content) - if record_id: - try: - # zones | pylint: disable=no-member - self.cf.zones.dns_records.delete(zone_id, record_id) - logger.debug('Successfully deleted TXT record.') - except CloudFlare.exceptions.CloudFlareAPIError as e: - logger.warning('Encountered CloudFlareAPIError deleting TXT record: %s', e) - else: - logger.debug('TXT record not found; no cleanup needed.') + record_id = self._find_txt_record_id(zone_id, record_name, record_content) + if record_id: + try: + self.cf.dns.records.delete(dns_record_id=record_id, zone_id=zone_id) + logger.debug('Successfully deleted TXT record.') + except cloudflare.APIStatusError as e: + logger.warning('Encountered Cloudflare API error deleting TXT record: %s', e) + except cloudflare.APIConnectionError as e: + logger.warning('Network error deleting TXT record from Cloudflare: %s', e) else: - logger.debug('Zone not found; no cleanup needed.') + logger.debug('TXT record not found; no cleanup needed.') def _find_zone_id(self, domain: str) -> str: """ Find the zone_id for a given domain. :param str domain: The domain for which to find the zone_id. - :returns: The zone_id, if found. + :returns: The zone_id for the first matching zone that has a non-empty + identifier. A zone with an empty/invalid id is treated as if no zone + were found, so this method never returns an empty string. :rtype: str :raises certbot.errors.PluginError: if no zone_id is found. """ zone_name_guesses = dns_common.base_domain_name_guesses(domain) - zones: list[dict[str, Any]] = [] + zone: Zone | None = None code = msg = None for zone_name in zone_name_guesses: - params = {'name': zone_name, - 'per_page': 1} - try: - zones = self.cf.zones.get(params=params) # zones | pylint: disable=no-member - except CloudFlare.exceptions.CloudFlareAPIError as e: - code = int(e) + zone = next(iter(self.cf.zones.list(name=zone_name, per_page=1)), None) + except cloudflare.APIStatusError as e: + code = _cf_error_code(e) msg = str(e) hint = None if code == 6003: - hint = ('Did you copy your entire API token/key? To use Cloudflare tokens, ' - 'you\'ll need the python package cloudflare>=2.3.1.{}' - .format(' This certbot is running cloudflare ' + str(CloudFlare.__version__) - if hasattr(CloudFlare, '__version__') else '')) + hint = ('Did you copy your entire API token/key? ' + 'See {} to manage your API tokens.'.format(ACCOUNT_URL)) elif code == 9103: hint = 'Did you enter the correct email address and Global key?' elif code == 9109: @@ -215,13 +227,19 @@ class _CloudflareClient: 'that you have supplied valid Cloudflare API credentials. ({2})' .format(code, msg, hint)) else: - logger.debug('Unrecognised CloudFlareAPIError while finding zone_id: %d %s. ' - 'Continuing with next zone guess...', e, e) + logger.debug('Unrecognised Cloudflare API error while finding zone_id: %s. ' + 'Continuing with next zone guess...', e) + except cloudflare.APIConnectionError as e: + raise errors.PluginError('Network error contacting the Cloudflare API while ' + 'looking up the zone for {0}: {1}'.format(domain, e)) - if zones: - zone_id: str = zones[0]['id'] - logger.debug('Found zone_id of %s for %s using name %s', zone_id, domain, zone_name) - return zone_id + if zone: + zone_id = zone.id + if zone_id: + logger.debug('Found zone_id of %s for %s using name %s', + zone_id, domain, zone_name) + return zone_id + break # Found a zone but it has no usable ID; stop searching if msg is not None: if 'com.cloudflare.api.account.zone.list' in msg: @@ -252,20 +270,38 @@ class _CloudflareClient: :rtype: str """ - params = {'type': 'TXT', - 'name': record_name, - 'content': record_content, - 'per_page': 1} try: - # zones | pylint: disable=no-member - records = self.cf.zones.dns_records.get(zone_id, params=params) - except CloudFlare.exceptions.CloudFlareAPIError as e: - logger.debug('Encountered CloudFlareAPIError getting TXT record_id: %s', e) + records = list(self.cf.dns.records.list( + zone_id=zone_id, type='TXT', name={'exact': record_name}, + content={'exact': record_content}, per_page=1)) + except cloudflare.APIStatusError as e: + logger.debug('Encountered Cloudflare API error getting TXT record_id: %s', e) + records = [] + except cloudflare.APIConnectionError as e: + logger.debug('Network error getting TXT record_id from Cloudflare: %s', e) records = [] if records: # Cleanup is returning the system to the state we found it. If, for some reason, # there are multiple matching records, we only delete one because we only added one. - return cast(str, records[0]['id']) + return records[0].id logger.debug('Unable to find TXT record.') return None + + +class _RecordData(TypedDict): + """Offers type hints for dictionaries of Cloudflare API parameters.""" + + type: Literal['TXT'] + name: str + content: str + ttl: int + + +def _cf_error_code(e: cloudflare.APIStatusError) -> int | None: + """Extract the first Cloudflare error code from an API error response.""" + try: + body = e.response.json() + return int(body['errors'][0]['code']) + except (ValueError, KeyError, IndexError, TypeError): # pragma: no cover + return None diff --git a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py index 42d56b616..fe77d3b70 100644 --- a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py +++ b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py @@ -2,9 +2,10 @@ import sys import unittest +from typing import Any from unittest import mock -import CloudFlare +import cloudflare import pytest from certbot import errors @@ -13,7 +14,23 @@ from certbot.plugins import dns_test_common from certbot.plugins.dns_test_common import DOMAIN from certbot.tests import util as test_util -API_ERROR = CloudFlare.exceptions.CloudFlareAPIError(1000, '', '') + +def _make_api_error(cf_code: int, msg: str = '', http_status: int = 400 + ) -> cloudflare.APIStatusError: + """Build a cloudflare.APIStatusError with a Cloudflare error code in the body.""" + body: Any = {'success': False, 'errors': [{'code': cf_code, 'message': msg}]} + response = mock.Mock(status_code=http_status, request=mock.Mock()) + response.json.return_value = body + return cloudflare.APIStatusError(message=msg or str(cf_code), response=response, body=body) + + +def _make_connection_error(msg: str = 'Connection error.') -> cloudflare.APIConnectionError: + """Build a cloudflare.APIConnectionError (e.g. transient network failure).""" + return cloudflare.APIConnectionError(message=msg, request=mock.Mock()) + + +API_ERROR = _make_api_error(1000) +CONNECTION_ERROR = _make_connection_error() API_TOKEN = 'an-api-token' @@ -96,13 +113,43 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic with pytest.raises(errors.PluginError): self.auth.perform([self.achall]) + def test_get_cloudflare_client_with_api_token(self): + from certbot_dns_cloudflare._internal.dns_cloudflare import Authenticator + from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient + mock_auth = mock.MagicMock() + mock_auth.credentials.conf.return_value = API_TOKEN + client = Authenticator._get_cloudflare_client(mock_auth) + self.assertIsInstance(client, _CloudflareClient) + + def test_get_cloudflare_client_with_email_key(self): + from certbot_dns_cloudflare._internal.dns_cloudflare import Authenticator + from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient + mock_auth = mock.MagicMock() + mock_auth.credentials.conf.side_effect = lambda k: None if k == 'api-token' else 'some_value' + client = Authenticator._get_cloudflare_client(mock_auth) + self.assertIsInstance(client, _CloudflareClient) + + +def _mock_zone(zone_id: str | None) -> mock.MagicMock: + """Create a mock zone object with an .id attribute.""" + zone = mock.MagicMock() + zone.id = zone_id + return zone + + +def _mock_record(record_id: str | None) -> mock.MagicMock: + """Create a mock DNS record object with an .id attribute.""" + record = mock.MagicMock() + record.id = record_id + return record + class CloudflareClientTest(unittest.TestCase): record_name = "foo" record_content = "bar" record_ttl = 42 - zone_id = 1 - record_id = 2 + zone_id = "zone-id-1" + record_id = "record-id-2" def setUp(self): from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient @@ -113,119 +160,145 @@ class CloudflareClientTest(unittest.TestCase): self.cloudflare_client.cf = self.cf def test_add_txt_record(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) - self.cf.zones.dns_records.post.assert_called_with(self.zone_id, data=mock.ANY) - - post_data = self.cf.zones.dns_records.post.call_args[1]['data'] - - assert 'TXT' == post_data['type'] - assert self.record_name == post_data['name'] - assert self.record_content == post_data['content'] - assert self.record_ttl == post_data['ttl'] + self.cf.dns.records.create.assert_called_with( + zone_id=self.zone_id, type='TXT', name=self.record_name, + content=self.record_content, ttl=self.record_ttl) def test_add_txt_record_error(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] - self.cf.zones.dns_records.post.side_effect = CloudFlare.exceptions.CloudFlareAPIError(1009, '', '') + self.cf.dns.records.create.side_effect = _make_api_error(1009) with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) def test_add_txt_record_error_during_zone_lookup(self): - self.cf.zones.get.side_effect = API_ERROR + self.cf.zones.list.side_effect = API_ERROR with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) def test_add_txt_record_zone_not_found(self): - self.cf.zones.get.return_value = [] + self.cf.zones.list.return_value = [] with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) + + def test_add_txt_record_connection_error_on_create(self): + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.create.side_effect = CONNECTION_ERROR + + with pytest.raises(errors.PluginError, match='Network error'): + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) + + def test_add_txt_record_connection_error_during_zone_lookup(self): + self.cf.zones.list.side_effect = CONNECTION_ERROR + + with pytest.raises(errors.PluginError, match='Network error'): + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) def test_add_txt_record_bad_creds(self): - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(6003, '', '') + self.cf.zones.list.side_effect = _make_api_error(6003) with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(9103, '', '') + self.cf.zones.list.side_effect = _make_api_error(9103) with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(9109, '', '') + self.cf.zones.list.side_effect = _make_api_error(9109) with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(0, 'com.cloudflare.api.account.zone.list', '') + self.cf.zones.list.side_effect = _make_api_error(0, 'com.cloudflare.api.account.zone.list') with pytest.raises(errors.PluginError): - self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) + self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, + self.record_ttl) def test_del_txt_record(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}] + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.list.return_value = [_mock_record(self.record_id)] self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY), - mock.call.zones.dns_records.delete(self.zone_id, self.record_id)] - - assert expected == self.cf.mock_calls - - get_data = self.cf.zones.dns_records.get.call_args[1]['params'] - - assert 'TXT' == get_data['type'] - assert self.record_name == get_data['name'] - assert self.record_content == get_data['content'] + self.cf.zones.list.assert_called_once() + self.cf.dns.records.list.assert_called_once_with( + zone_id=self.zone_id, type='TXT', name={'exact': self.record_name}, + content={'exact': self.record_content}, per_page=1) + self.cf.dns.records.delete.assert_called_once_with( + dns_record_id=self.record_id, zone_id=self.zone_id) def test_del_txt_record_error_during_zone_lookup(self): - self.cf.zones.get.side_effect = API_ERROR + self.cf.zones.list.side_effect = API_ERROR self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) def test_del_txt_record_error_during_delete(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}] - self.cf.zones.dns_records.delete.side_effect = API_ERROR + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.list.return_value = [_mock_record(self.record_id)] + self.cf.dns.records.delete.side_effect = API_ERROR self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY), - mock.call.zones.dns_records.delete(self.zone_id, self.record_id)] - assert expected == self.cf.mock_calls + self.cf.dns.records.delete.assert_called_once_with( + dns_record_id=self.record_id, zone_id=self.zone_id) def test_del_txt_record_error_during_get(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.side_effect = API_ERROR + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.list.side_effect = API_ERROR self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)] - assert expected == self.cf.mock_calls + self.cf.dns.records.list.assert_called_once() + self.cf.dns.records.delete.assert_not_called() def test_del_txt_record_no_record(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.return_value = [] + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.list.return_value = [] self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)] - assert expected == self.cf.mock_calls + self.cf.dns.records.list.assert_called_once() + self.cf.dns.records.delete.assert_not_called() def test_del_txt_record_no_zone(self): - self.cf.zones.get.return_value = [{'id': None}] + self.cf.zones.list.return_value = [_mock_zone(None)] self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY)] - assert expected == self.cf.mock_calls + self.cf.zones.list.assert_called_once() + + def test_del_txt_record_connection_error_on_delete(self): + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.list.return_value = [_mock_record(self.record_id)] + self.cf.dns.records.delete.side_effect = CONNECTION_ERROR + + self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) + + self.cf.dns.records.delete.assert_called_once_with( + dns_record_id=self.record_id, zone_id=self.zone_id) + + def test_del_txt_record_connection_error_on_get(self): + self.cf.zones.list.return_value = [_mock_zone(self.zone_id)] + self.cf.dns.records.list.side_effect = CONNECTION_ERROR + + self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) + + self.cf.dns.records.list.assert_called_once() + self.cf.dns.records.delete.assert_not_called() if __name__ == "__main__": diff --git a/certbot/setup.py b/certbot/setup.py index 1c4c194f1..4b4dee5d5 100644 --- a/certbot/setup.py +++ b/certbot/setup.py @@ -30,7 +30,7 @@ install_requires = [ 'ConfigArgParse>=1.5.3', 'configobj>=5.0.6', 'cryptography>=43.0.0', - 'distro>=1.0.1', + 'distro>=1.7.0', 'importlib_metadata>=8.6.1; python_version < "3.10"', 'josepy>=2.0.0', 'parsedatetime>=2.6', diff --git a/newsfragments/10587.changed b/newsfragments/10587.changed new file mode 100644 index 000000000..99483a0b8 --- /dev/null +++ b/newsfragments/10587.changed @@ -0,0 +1 @@ +certbot now requires version 1.7+ of the library distro and certbot-dns-cloudflare requires 4.0+ of the Cloudflare Python library. diff --git a/pytest.ini b/pytest.ini index 381919e93..87008a4e3 100644 --- a/pytest.ini +++ b/pytest.ini @@ -13,6 +13,14 @@ # 1) In v2.28.0, google-api-core added an annoying message that the current python's EOL # is coming up. We deprecate python versions on schedule, so mostly this is just an # annoyance for our own tests, and can probably be silenced forever. +# 2) cloudflare 4.x imports pydantic.v1 for compatibility, which emits a +# UserWarning on Python 3.14+. Cloudflare plan to add official 3.13/3.14 +# support in their upcoming v5 release; track progress (and remove this +# entry once released) via https://github.com/cloudflare/cloudflare-python/issues/2679 +# When this pytest.ini entry is removed, the warnings filter at the top of +# certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py +# should be removed as well. filterwarnings = error ignore:You are using a Python version (.*) which Google will stop supporting:FutureWarning + ignore:Core Pydantic V1 functionality:UserWarning diff --git a/tools/oldest_constraints.txt b/tools/oldest_constraints.txt index b7be924e5..9cb5c80ae 100644 --- a/tools/oldest_constraints.txt +++ b/tools/oldest_constraints.txt @@ -1,44 +1,47 @@ # This file was generated by tools/pinning/oldest/repin.sh and can be updated using # that script. +annotated-types==0.7.0 ; python_version == "3.10" +anyio==4.12.1 ; python_version == "3.10" apacheconfig==0.3.2 ; python_version == "3.10" asn1crypto==0.24.0 ; python_version == "3.10" -astroid==4.0.3 ; python_version == "3.10" -attrs==25.4.0 ; python_version == "3.10" +astroid==4.0.4 ; python_version == "3.10" beautifulsoup4==4.14.3 ; python_version == "3.10" boto3==1.20.34 ; python_version == "3.10" botocore==1.23.34 ; python_version == "3.10" cachetools==5.5.2 ; python_version == "3.10" -certifi==2026.1.4 ; python_version == "3.10" +certifi==2026.2.25 ; python_version == "3.10" cffi==1.14.1 ; python_version == "3.10" chardet==3.0.4 ; python_version == "3.10" -cloudflare==2.19.0 ; python_version == "3.10" +cloudflare==4.0.0 ; python_version == "3.10" colorama==0.4.6 ; (sys_platform == "win32" or platform_system == "Windows") and python_version == "3.10" configargparse==1.5.3 ; python_version == "3.10" configobj==5.0.6 ; python_version == "3.10" -coverage==7.13.3 ; python_version == "3.10" +coverage==7.13.4 ; python_version == "3.10" cryptography==43.0.0 ; python_version == "3.10" cython==0.29.37 ; python_version == "3.10" dill==0.4.1 ; python_version == "3.10" distlib==0.4.0 ; python_version == "3.10" -distro==1.0.1 ; python_version == "3.10" +distro==1.7.0 ; python_version == "3.10" dns-lexicon==3.15.1 ; python_version == "3.10" dnspython==2.6.1 ; python_version == "3.10" exceptiongroup==1.3.1 ; python_version == "3.10" execnet==2.1.2 ; python_version == "3.10" -filelock==3.20.3 ; python_version == "3.10" +filelock==3.25.0 ; python_version == "3.10" funcsigs==0.4 ; python_version == "3.10" google-api-python-client==1.6.5 ; python_version == "3.10" google-auth==2.16.0 ; python_version == "3.10" +h11==0.16.0 ; python_version == "3.10" +httpcore==1.0.9 ; python_version == "3.10" httplib2==0.9.2 ; python_version == "3.10" -idna==2.6 ; python_version == "3.10" +httpx==0.28.1 ; python_version == "3.10" +idna==2.8 ; python_version == "3.10" iniconfig==2.3.0 ; python_version == "3.10" ipaddress==1.0.16 ; python_version == "3.10" -isort==7.0.0 ; python_version == "3.10" +isort==8.0.1 ; python_version == "3.10" jmespath==0.10.0 ; python_version == "3.10" josepy==2.2.0 ; python_version == "3.10" -jsonlines==4.0.0 ; python_version == "3.10" jsonpickle==4.1.1 ; python_version == "3.10" -librt==0.7.8 ; python_version == "3.10" and platform_python_implementation != "PyPy" +librt==0.8.1 ; python_version == "3.10" and platform_python_implementation != "PyPy" mccabe==0.7.0 ; python_version == "3.10" mypy-extensions==1.1.0 ; python_version == "3.10" mypy==1.19.1 ; python_version == "3.10" @@ -48,16 +51,18 @@ packaging==26.0 ; python_version == "3.10" parsedatetime==2.6 ; python_version == "3.10" pathspec==1.0.4 ; python_version == "3.10" pbr==1.8.0 ; python_version == "3.10" -pip==26.0 ; python_version == "3.10" -platformdirs==4.5.1 ; python_version == "3.10" +pip==26.0.1 ; python_version == "3.10" +platformdirs==4.9.2 ; python_version == "3.10" pluggy==1.6.0 ; python_version == "3.10" ply==3.4 ; python_version == "3.10" py==1.11.0 ; python_version == "3.10" pyasn1-modules==0.4.1 ; python_version == "3.10" pyasn1==0.4.8 ; python_version == "3.10" pycparser==2.14 ; python_version == "3.10" +pydantic-core==2.41.5 ; python_version == "3.10" +pydantic==2.12.5 ; python_version == "3.10" pygments==2.19.2 ; python_version == "3.10" -pylint==4.0.4 ; python_version == "3.10" +pylint==4.0.5 ; python_version == "3.10" pyopenssl==25.0.0 ; python_version == "3.10" pyotp==2.9.0 ; python_version == "3.10" pyparsing==3.0.0 ; python_version == "3.10" @@ -68,16 +73,18 @@ pytest==9.0.2 ; python_version == "3.10" python-augeas==0.5.0 ; python_version == "3.10" python-dateutil==2.9.0.post0 ; python_version == "3.10" python-digitalocean==1.15.0 ; python_version == "3.10" -pytz==2025.2 ; python_version == "3.10" +python-discovery==1.1.0 ; python_version == "3.10" +pytz==2026.1.post1 ; python_version == "3.10" pywin32==311 ; python_version == "3.10" and sys_platform == "win32" pyyaml==6.0.3 ; python_version == "3.10" requests-file==3.0.1 ; python_version == "3.10" requests==2.25.1 ; python_version == "3.10" rsa==4.9.1 ; python_version == "3.10" -ruff==0.15.0 ; python_version == "3.10" +ruff==0.15.4 ; python_version == "3.10" s3transfer==0.5.2 ; python_version == "3.10" -setuptools==80.10.2 ; python_version == "3.10" +setuptools==82.0.0 ; python_version == "3.10" six==1.16.0 ; python_version == "3.10" +sniffio==1.3.1 ; python_version == "3.10" soupsieve==2.8.3 ; python_version == "3.10" tldextract==5.3.1 ; python_version == "3.10" tomli==2.4.0 ; python_version == "3.10" @@ -85,14 +92,15 @@ tomlkit==0.14.0 ; python_version == "3.10" tox==3.28.0 ; python_version == "3.10" types-httplib2==0.31.2.20260125 ; python_version == "3.10" types-pyrfc3339==2.0.1.20250825 ; python_version == "3.10" -types-python-dateutil==2.9.0.20260124 ; python_version == "3.10" +types-python-dateutil==2.9.0.20260302 ; python_version == "3.10" types-pywin32==311.0.0.20251008 ; python_version == "3.10" types-requests==2.31.0.6 ; python_version == "3.10" -types-setuptools==80.10.0.20260124 ; python_version == "3.10" +types-setuptools==82.0.0.20260210 ; python_version == "3.10" types-urllib3==1.26.25.14 ; python_version == "3.10" typing-extensions==4.15.0 ; python_version == "3.10" +typing-inspection==0.4.2 ; python_version == "3.10" uritemplate==3.0.1 ; python_version == "3.10" urllib3==1.26.5 ; python_version == "3.10" -uv==0.9.28 ; python_version == "3.10" -virtualenv==20.36.1 ; python_version == "3.10" +uv==0.10.8 ; python_version == "3.10" +virtualenv==21.1.0 ; python_version == "3.10" wheel==0.46.3 ; python_version == "3.10" diff --git a/tools/pinning/oldest/pyproject.toml b/tools/pinning/oldest/pyproject.toml index 875a567d9..b3534b96f 100644 --- a/tools/pinning/oldest/pyproject.toml +++ b/tools/pinning/oldest/pyproject.toml @@ -18,7 +18,7 @@ license = "Apache License 2.0" [tool.poetry.dependencies] # The Python version here should be kept in sync with the one used in our # oldest tests in tox.ini. -python = "<3.11 >= 3.10" +python = "<3.11,>=3.10" # Local dependencies # Any local packages that have dependencies on other local packages must be @@ -59,17 +59,17 @@ boto3 = "1.20.34" botocore = "1.23.34" cffi = "1.14.1" chardet = "3.0.4" -cloudflare = "2.19" +cloudflare = "4.0.0" configobj = "5.0.6" cryptography = "43.0.0" -distro = "1.0.1" +distro = "1.7.0" dns-lexicon = "3.15.1" dnspython = "2.6.1" funcsigs = "0.4" google-api-python-client = "1.6.5" google-auth = "2.16.0" httplib2 = "0.9.2" -idna = "2.6" +idna = "2.8" ipaddress = "1.0.16" ndg-httpsclient = "0.3.2" parsedatetime = "2.6" diff --git a/tools/requirements.txt b/tools/requirements.txt index 59984f848..a662889fc 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -6,6 +6,7 @@ # https://docs.github.com/en/github/visualizing-repository-data-with-graphs/about-the-dependency-graph#supported-package-ecosystems # for more info. alabaster==1.0.0 ; python_version >= "3.10" and python_version < "4.0" +annotated-types==0.7.0 ; python_version >= "3.10" and python_version < "4.0" anyio==4.13.0 ; python_version >= "3.10" and python_version < "4.0" apacheconfig==0.3.2 ; python_version >= "3.10" and python_version < "4.0" astroid==3.3.11 ; python_version >= "3.10" and python_version < "4.0" @@ -27,7 +28,7 @@ cffi==2.0.0 ; python_version >= "3.10" and python_version < "4.0" charset-normalizer==3.4.7 ; python_version >= "3.10" and python_version < "4.0" cleo==2.1.0 ; python_version >= "3.10" and python_version < "4.0" click==8.3.3 ; python_version >= "3.10" and python_version < "4.0" -cloudflare==2.19.4 ; python_version >= "3.10" and python_version < "4.0" +cloudflare==4.3.1 ; python_version >= "3.10" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" configargparse==1.7.5 ; python_version >= "3.10" and python_version < "4.0" configobj==5.0.9 ; python_version >= "3.10" and python_version < "4.0" @@ -84,7 +85,6 @@ jeepney==0.9.0 ; python_version >= "3.10" and python_version < "4.0" and sys_pla jinja2==3.1.6 ; python_version >= "3.10" and python_version < "4.0" jmespath==1.1.0 ; python_version >= "3.10" and python_version < "4.0" josepy==2.2.0 ; python_version >= "3.10" and python_version < "4.0" -jsonlines==4.0.0 ; python_version >= "3.10" and python_version < "4.0" jsonpickle==4.1.1 ; python_version >= "3.10" and python_version < "4.0" keyring==25.7.0 ; python_version >= "3.10" and python_version < "4.0" markdown-it-py==4.0.0 ; python_version >= "3.10" and python_version < "4.0" @@ -122,6 +122,8 @@ pure-eval==0.2.3 ; python_version >= "3.10" and python_version < "4.0" pyasn1-modules==0.4.2 ; python_version >= "3.10" and python_version < "4.0" pyasn1==0.6.3 ; python_version >= "3.10" and python_version < "4.0" pycparser==3.0 ; python_version >= "3.10" and python_version < "4.0" and implementation_name != "PyPy" +pydantic==2.12.5 ; python_version >= "3.10" and python_version < "4.0" +pydantic-core==2.41.5 ; python_version >= "3.10" and python_version < "4.0" pygments==2.20.0 ; python_version >= "3.10" and python_version < "4.0" pylint==3.3.3 ; python_version >= "3.10" and python_version < "4.0" pynacl==1.6.2 ; python_version >= "3.10" and python_version < "4.0" @@ -159,6 +161,7 @@ setuptools-rust==1.12.1 ; python_version >= "3.10" and python_version < "4.0" setuptools==82.0.1 ; python_version >= "3.10" and python_version < "4.0" shellingham==1.5.4 ; python_version >= "3.10" and python_version < "4.0" six==1.17.0 ; python_version >= "3.10" and python_version < "4.0" +sniffio==1.3.1 ; python_version >= "3.10" and python_version < "4.0" snowballstemmer==3.0.1 ; python_version >= "3.10" and python_version < "4.0" soupsieve==2.8.3 ; python_version >= "3.10" and python_version < "4.0" sphinx-rtd-theme==3.1.0 ; python_version >= "3.10" and python_version < "4.0" @@ -189,6 +192,7 @@ types-pywin32==311.0.0.20260408 ; python_version >= "3.10" and python_version < types-requests==2.33.0.20260408 ; python_version >= "3.10" and python_version < "4.0" types-setuptools==82.0.0.20260408 ; python_version >= "3.10" and python_version < "4.0" typing-extensions==4.15.0 ; python_version >= "3.10" and python_version < "4.0" +typing-inspection==0.4.2 ; python_version >= "3.10" and python_version < "4.0" uritemplate==4.2.0 ; python_version >= "3.10" and python_version < "4.0" urllib3==2.6.3 ; python_version >= "3.10" and python_version < "4.0" uv==0.11.8 ; python_version >= "3.10" and python_version < "4.0"