Migrate certbot-dns-cloudflare to cloudflare 4.x SDK (#10587)

## 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 <noreply@anthropic.com>
Co-authored-by: Ember <BeigeBox@users.noreply.github.com>
Co-authored-by: Brad Warren <bmw@users.noreply.github.com>
This commit is contained in:
Mike Fara 2026-05-06 12:35:38 -04:00 committed by GitHub
parent 9ec4105ff3
commit c8ebcb49bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 278 additions and 150 deletions

View file

@ -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'):

View file

@ -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

View file

@ -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__":

View file

@ -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',

View file

@ -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.

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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"