This commit is contained in:
Mike Fara 2026-03-23 15:27:10 +10:00 committed by GitHub
commit 140a297593
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 233 additions and 178 deletions

View file

@ -5,9 +5,7 @@ from setuptools import setup
version = '5.5.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,19 @@
"""DNS Authenticator for Cloudflare."""
import logging
import warnings
from typing import Any
from typing import Callable
from typing import Optional
from typing import cast
import CloudFlare
# cloudflare 4.x includes a pydantic v1 compatibility shim that raises a
# UserWarning on Python 3.14+. Suppress it here so that certbot's
# filterwarnings=error test configuration does not turn the warning into a
# fatal exception during plugin discovery.
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 +103,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,22 +127,20 @@ class _CloudflareClient:
zone_id = self._find_zone_id(domain)
data = {'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)
logger.debug('Attempting to add record to zone %s: %s', zone_id,
{'type': 'TXT', 'name': record_name,
'content': record_content, 'ttl': record_ttl})
self.cf.dns.records.create(zone_id=zone_id, type='TXT', name=record_name,
content=record_content, ttl=record_ttl)
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 ''))
@ -161,19 +167,15 @@ class _CloudflareClient:
logger.debug('Encountered error finding zone_id during deletion: %s', e)
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)
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:
"""
@ -186,25 +188,20 @@ class _CloudflareClient:
"""
zone_name_guesses = dns_common.base_domain_name_guesses(domain)
zones: list[dict[str, Any]] = []
zone: Optional[Zone] = 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 +212,16 @@ 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)
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 +252,26 @@ 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 = []
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
def _cf_error_code(e: cloudflare.APIStatusError) -> Optional[int]:
"""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,17 @@
import sys
import unittest
from typing import Any
from typing import Optional
from unittest import mock
import CloudFlare
import warnings
with warnings.catch_warnings():
warnings.filterwarnings('ignore', message='Core Pydantic V1 functionality',
category=UserWarning)
import cloudflare
import httpx
import pytest
from certbot import errors
@ -13,7 +21,17 @@ 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 = httpx.Response(http_status, json=body,
request=httpx.Request('GET', 'https://api.cloudflare.com'))
return cloudflare.APIStatusError(message=msg or str(cf_code), response=response, body=body)
API_ERROR = _make_api_error(1000)
API_TOKEN = 'an-api-token'
@ -96,13 +114,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: Optional[str]) -> 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: Optional[str]) -> 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 +161,111 @@ 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_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()
if __name__ == "__main__":

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,35 +6,34 @@
# 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.12.1 ; 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"
asttokens==3.0.1 ; python_version >= "3.10" and python_version < "4.0"
attrs==25.4.0 ; python_version >= "3.10" and python_version < "4.0"
azure-core==1.38.0 ; python_version >= "3.10" and python_version < "4.0"
azure-core==1.38.2 ; python_version >= "3.10" and python_version < "4.0"
azure-devops==7.1.0b4 ; python_version >= "3.10" and python_version < "4.0"
babel==2.18.0 ; python_version >= "3.10" and python_version < "4.0"
backports-tarfile==1.2.0 ; python_version >= "3.10" and python_version < "3.12"
bcrypt==5.0.0 ; python_version >= "3.10" and python_version < "4.0"
beautifulsoup4==4.14.3 ; python_version >= "3.10" and python_version < "4.0"
boto3==1.42.40 ; python_version >= "3.10" and python_version < "4.0"
botocore==1.42.40 ; python_version >= "3.10" and python_version < "4.0"
boto3==1.42.59 ; python_version >= "3.10" and python_version < "4.0"
botocore==1.42.59 ; python_version >= "3.10" and python_version < "4.0"
build==1.4.0 ; python_version >= "3.10" and python_version < "4.0"
cachecontrol==0.14.4 ; python_version >= "3.10" and python_version < "4.0"
cachetools==7.0.0 ; python_version >= "3.10" and python_version < "4.0"
certifi==2026.1.4 ; python_version >= "3.10" and python_version < "4.0"
cachetools==7.0.2 ; python_version >= "3.10" and python_version < "4.0"
certifi==2026.2.25 ; python_version >= "3.10" and python_version < "4.0"
cffi==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0"
charset-normalizer==3.4.4 ; 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.1 ; 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.1 ; python_version >= "3.10" and python_version < "4.0"
configobj==5.0.9 ; python_version >= "3.10" and python_version < "4.0"
coverage==7.13.3 ; python_version >= "3.10" and python_version < "4.0"
coverage==7.13.4 ; python_version >= "3.10" and python_version < "4.0"
crashtest==0.4.1 ; python_version >= "3.10" and python_version < "4.0"
cryptography==46.0.4 ; python_version >= "3.10" and python_version < "4.0"
cryptography==46.0.5 ; python_version >= "3.10" and python_version < "4.0"
cython==0.29.37 ; python_version >= "3.10" and python_version <= "3.12"
cython==3.2.4 ; python_version >= "3.13" and python_version < "4.0"
decorator==5.2.1 ; python_version >= "3.10" and python_version < "4.0"
@ -46,16 +45,16 @@ dns-lexicon==3.23.2 ; python_version >= "3.10" and python_version < "4.0"
dnspython==2.8.0 ; python_version >= "3.10" and python_version < "4.0"
docutils==0.21.2 ; python_version == "3.10"
docutils==0.22.4 ; python_version >= "3.11" and python_version < "4.0"
dulwich==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
dulwich==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
exceptiongroup==1.3.1 ; python_version == "3.10"
execnet==2.1.2 ; python_version >= "3.10" and python_version < "4.0"
executing==2.2.1 ; python_version >= "3.10" and python_version < "4.0"
fabric==3.2.2 ; python_version >= "3.10" and python_version < "4.0"
fastjsonschema==2.21.2 ; python_version >= "3.10" and python_version < "4.0"
filelock==3.20.3 ; python_version >= "3.10" and python_version < "4.0"
filelock==3.25.0 ; python_version >= "3.10" and python_version < "4.0"
findpython==0.7.1 ; python_version >= "3.10" and python_version < "4.0"
google-api-core==2.29.0 ; python_version >= "3.10" and python_version < "4.0"
google-api-python-client==2.188.0 ; python_version >= "3.10" and python_version < "4.0"
google-api-core==2.30.0 ; python_version >= "3.10" and python_version < "4.0"
google-api-python-client==2.191.0 ; python_version >= "3.10" and python_version < "4.0"
google-auth-httplib2==0.3.0 ; python_version >= "3.10" and python_version < "4.0"
google-auth==2.48.0 ; python_version >= "3.10" and python_version < "4.0"
googleapis-common-protos==1.72.0 ; python_version >= "3.10" and python_version < "4.0"
@ -63,7 +62,7 @@ h11==0.16.0 ; python_version >= "3.10" and python_version < "4.0"
httpcore==1.0.9 ; python_version >= "3.10" and python_version < "4.0"
httplib2==0.31.2 ; python_version >= "3.10" and python_version < "4.0"
httpx==0.28.1 ; python_version >= "3.10" and python_version < "4.0"
id==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
id==1.6.1 ; python_version >= "3.10" and python_version < "4.0"
idna==3.11 ; python_version >= "3.10" and python_version < "4.0"
imagesize==1.4.1 ; python_version >= "3.10" and python_version < "4.0"
importlib-metadata==8.7.1 ; python_version >= "3.10" and python_version < "3.12"
@ -84,7 +83,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"
@ -97,17 +95,17 @@ msgpack==1.1.2 ; python_version >= "3.10" and python_version < "4.0"
msrest==0.7.1 ; python_version >= "3.10" and python_version < "4.0"
mypy-extensions==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
mypy==1.9.0 ; python_version >= "3.10" and python_version < "4.0"
nh3==0.3.2 ; python_version >= "3.10" and python_version < "4.0"
nh3==0.3.3 ; python_version >= "3.10" and python_version < "4.0"
oauthlib==3.3.1 ; python_version >= "3.10" and python_version < "4.0"
packaging==26.0 ; python_version >= "3.10" and python_version < "4.0"
paramiko==4.0.0 ; python_version >= "3.10" and python_version < "4.0"
parsedatetime==2.6 ; python_version >= "3.10" and python_version < "4.0"
parso==0.8.5 ; python_version >= "3.10" and python_version < "4.0"
pbs-installer==2026.1.27 ; python_version >= "3.10" and python_version < "4.0"
parso==0.8.6 ; python_version >= "3.10" and python_version < "4.0"
pbs-installer==2026.2.11 ; python_version >= "3.10" and python_version < "4.0"
pexpect==4.9.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "emscripten"
pip==26.0 ; python_version >= "3.10" and python_version < "4.0"
pip==26.0.1 ; python_version >= "3.10" and python_version < "4.0"
pkginfo==1.12.1.2 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.5.1 ; python_version >= "3.10" and python_version < "4.0"
platformdirs==4.9.2 ; python_version >= "3.10" and python_version < "4.0"
pluggy==1.6.0 ; python_version >= "3.10" and python_version < "4.0"
ply==3.11 ; python_version >= "3.10" and python_version < "4.0"
poetry-core==2.3.1 ; python_version >= "3.10" and python_version < "4.0"
@ -121,6 +119,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.2 ; 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-core==2.41.5 ; python_version >= "3.10" and python_version < "4.0"
pydantic==2.12.5 ; python_version >= "3.10" and python_version < "4.0"
pygments==2.19.2 ; 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"
@ -136,6 +136,7 @@ pytest==9.0.2 ; python_version >= "3.10" and python_version < "4.0"
python-augeas==1.2.0 ; python_version >= "3.10" and python_version < "4.0"
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0"
python-digitalocean==1.17.0 ; python_version >= "3.10" and python_version < "4.0"
python-discovery==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
pywin32-ctypes==0.2.3 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
pywin32==311 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
pyyaml==6.0.3 ; python_version >= "3.10" and python_version < "4.0"
@ -146,17 +147,18 @@ requests-oauthlib==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
requests-toolbelt==1.0.0 ; python_version >= "3.10" and python_version < "4.0"
requests==2.32.5 ; python_version >= "3.10" and python_version < "4.0"
rfc3986==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
rich==14.3.2 ; python_version >= "3.10" and python_version < "4.0"
rich==14.3.3 ; python_version >= "3.10" and python_version < "4.0"
roman-numerals==4.1.0 ; python_version >= "3.11" and python_version < "4.0"
rsa==4.9.1 ; python_version >= "3.10" and python_version < "4.0"
ruff==0.15.0 ; python_version >= "3.10" and python_version < "4.0"
ruff==0.15.4 ; python_version >= "3.10" and python_version < "4.0"
s3transfer==0.16.0 ; python_version >= "3.10" and python_version < "4.0"
secretstorage==3.5.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "linux"
semantic-version==2.10.0 ; python_version >= "3.10" and python_version < "4.0"
setuptools-rust==1.12.0 ; python_version >= "3.10" and python_version < "4.0"
setuptools==80.10.2 ; python_version >= "3.10" and python_version < "4.0"
setuptools==82.0.0 ; 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"
@ -175,22 +177,23 @@ tldextract==5.3.1 ; python_version >= "3.10" and python_version < "4.0"
tomli==2.4.0 ; python_version == "3.10"
tomlkit==0.14.0 ; python_version >= "3.10" and python_version < "4.0"
towncrier==25.8.0 ; python_version >= "3.10" and python_version < "4.0"
tox==4.34.1 ; python_version >= "3.10" and python_version < "4.0"
tox==4.47.0 ; python_version >= "3.10" and python_version < "4.0"
traitlets==5.14.3 ; python_version >= "3.10" and python_version < "4.0"
trove-classifiers==2026.1.14.14 ; python_version >= "3.10" and python_version < "4.0"
twine==6.2.0 ; python_version >= "3.10" and python_version < "4.0"
types-httplib2==0.31.2.20260125 ; python_version >= "3.10" and python_version < "4.0"
types-pyrfc3339==2.0.1.20250825 ; python_version >= "3.10" and python_version < "4.0"
types-python-dateutil==2.9.0.20260124 ; python_version >= "3.10" and python_version < "4.0"
types-python-dateutil==2.9.0.20260302 ; python_version >= "3.10" and python_version < "4.0"
types-pywin32==311.0.0.20251008 ; python_version >= "3.10" and python_version < "4.0"
types-requests==2.32.4.20260107 ; python_version >= "3.10" and python_version < "4.0"
types-setuptools==80.10.0.20260124 ; python_version >= "3.10" and python_version < "4.0"
types-setuptools==82.0.0.20260210 ; 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.9.28 ; python_version >= "3.10" and python_version < "4.0"
virtualenv==20.36.1 ; python_version >= "3.10" and python_version < "4.0"
wcwidth==0.5.3 ; python_version >= "3.10" and python_version < "4.0"
uv==0.10.7 ; python_version >= "3.10" and python_version < "4.0"
virtualenv==21.1.0 ; python_version >= "3.10" and python_version < "4.0"
wcwidth==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
wheel==0.46.3 ; python_version >= "3.10" and python_version < "4.0"
wrapt==2.1.1 ; python_version >= "3.10" and python_version < "4.0"
xattr==1.3.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "darwin"