Refactor Lexicon-based DNS plugins (#9746)

* Refactor Lexicon-based DNS plugins and upgrade minimal version of Lexicon

* Relax filterwarning to comply with envs where boto3 is not installed

* Update pinned dependencies

* Use our previous method to deprecate part of modules

* Safe import internally

* Add changelog

Co-authored-by: Brad Warren <bmw@users.noreply.github.com>
This commit is contained in:
Adrien Ferrand 2023-09-26 00:15:04 +02:00 committed by GitHub
parent 694c758db7
commit 732a3ac962
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 542 additions and 669 deletions

View file

@ -2,34 +2,30 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import dnsimple
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
ACCOUNT_URL = 'https://dnsimple.com/user'
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for DNSimple
This Authenticator uses the DNSimple v2 API to fulfill a dns-01 challenge.
"""
description = 'Obtain certificates using a DNS TXT record (if you are using DNSimple for DNS).'
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('token',
f'User access token for DNSimple v2 API. (See {ACCOUNT_URL}.)',
'auth_token')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -41,42 +37,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the DNSimple API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'DNSimple credentials INI file',
{
'token': 'User access token for DNSimple v2 API. (See {0}.)'.format(ACCOUNT_URL)
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_dnsimple_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_dnsimple_client().del_txt_record(domain, validation_name, validation)
def _get_dnsimple_client(self) -> "_DNSimpleLexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _DNSimpleLexiconClient(cast(str, self.credentials.conf('token')), self.ttl)
class _DNSimpleLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the DNSimple via Lexicon.
"""
def __init__(self, token: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('dnssimple', {
'ttl': ttl,
}, {
'auth_token': token,
})
self.provider = dnsimple.Provider(config)
@property
def _provider_name(self) -> str:
return 'dnssimple'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> errors.PluginError:
hint = None

View file

@ -1,8 +1,6 @@
"""Tests for certbot_dns_dnsimple._internal.dns_dnsimple."""
import sys
import unittest
from unittest import mock
import sys
import pytest
from requests.exceptions import HTTPError
@ -16,7 +14,9 @@ TOKEN = 'foo'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: ...')
def setUp(self):
super().setUp()
@ -31,23 +31,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "dnsimple")
self.mock_client = mock.MagicMock()
# _get_dnsimple_client | pylint: disable=protected-access
self.auth._get_dnsimple_client = mock.MagicMock(return_value=self.mock_client)
class DNSimpleLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: ...')
def setUp(self):
from certbot_dns_dnsimple._internal.dns_dnsimple import _DNSimpleLexiconClient
self.client = _DNSimpleLexiconClient(TOKEN, 0)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -9,7 +9,7 @@ version = '2.7.0.dev0'
install_requires = [
# This version of lexicon is required to address the problem described in
# https://github.com/AnalogJ/lexicon/issues/387.
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -2,23 +2,19 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import dnsmadeeasy
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
ACCOUNT_URL = 'https://cp.dnsmadeeasy.com/account/info'
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for DNS Made Easy
This Authenticator uses the DNS Made Easy API to fulfill a dns-01 challenge.
@ -26,11 +22,17 @@ class Authenticator(dns_common.DNSAuthenticator):
description = ('Obtain certificates using a DNS TXT record (if you are using DNS Made Easy for '
'DNS).')
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('api-key',
'API key for DNS Made Easy account, '
f'obtained from {ACCOUNT_URL}',
'auth_username')
self._add_provider_option('secret-key',
'Secret key for DNS Made Easy account, '
f'obtained from {ACCOUNT_URL}',
'auth_token')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -42,48 +44,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the DNS Made Easy API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'DNS Made Easy credentials INI file',
{
'api-key': 'API key for DNS Made Easy account, obtained from {0}'
.format(ACCOUNT_URL),
'secret-key': 'Secret key for DNS Made Easy account, obtained from {0}'
.format(ACCOUNT_URL)
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_dnsmadeeasy_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_dnsmadeeasy_client().del_txt_record(domain, validation_name, validation)
def _get_dnsmadeeasy_client(self) -> "_DNSMadeEasyLexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _DNSMadeEasyLexiconClient(cast(str, self.credentials.conf('api-key')),
cast(str, self.credentials.conf('secret-key')),
self.ttl)
class _DNSMadeEasyLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the DNS Made Easy via Lexicon.
"""
def __init__(self, api_key: str, secret_key: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('dnsmadeeasy', {
'ttl': ttl,
}, {
'auth_username': api_key,
'auth_token': secret_key,
})
self.provider = dnsmadeeasy.Provider(config)
@property
def _provider_name(self) -> str:
return 'dnsmadeeasy'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]:
if domain_name in str(e) and str(e).startswith('404 Client Error: Not Found for url:'):

View file

@ -1,7 +1,6 @@
"""Tests for certbot_dns_dnsmadeeasy._internal.dns_dnsmadeeasy."""
import sys
import unittest
from unittest import mock
import pytest
@ -18,7 +17,10 @@ SECRET_KEY = 'bar'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.')
LOGIN_ERROR = HTTPError(f'403 Client Error: Forbidden for url: {DOMAIN}.')
def setUp(self):
super().setUp()
@ -35,24 +37,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "dnsmadeeasy")
self.mock_client = mock.MagicMock()
# _get_dnsmadeeasy_client | pylint: disable=protected-access
self.auth._get_dnsmadeeasy_client = mock.MagicMock(return_value=self.mock_client)
class DNSMadeEasyLexiconClientTest(unittest.TestCase,
dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN))
LOGIN_ERROR = HTTPError('403 Client Error: Forbidden for url: {0}.'.format(DOMAIN))
def setUp(self):
from certbot_dns_dnsmadeeasy._internal.dns_dnsmadeeasy import _DNSMadeEasyLexiconClient
self.client = _DNSMadeEasyLexiconClient(API_KEY, SECRET_KEY, 0)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -2,23 +2,19 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import gehirn
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
DASHBOARD_URL = "https://gis.gehirn.jp/"
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for Gehirn Infrastructure Service DNS
This Authenticator uses the Gehirn Infrastructure Service API to fulfill
@ -27,11 +23,17 @@ class Authenticator(dns_common.DNSAuthenticator):
description = 'Obtain certificates using a DNS TXT record ' + \
'(if you are using Gehirn Infrastructure Service for DNS).'
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('api-token',
'API token for Gehirn Infrastructure Service '
f'API obtained from {DASHBOARD_URL}',
'auth_token')
self._add_provider_option('api-secret',
'API secret for Gehirn Infrastructure Service '
f'API obtained from {DASHBOARD_URL}',
'auth_secret')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -43,50 +45,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the Gehirn Infrastructure Service API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'Gehirn Infrastructure Service credentials file',
{
'api-token': 'API token for Gehirn Infrastructure Service ' + \
'API obtained from {0}'.format(DASHBOARD_URL),
'api-secret': 'API secret for Gehirn Infrastructure Service ' + \
'API obtained from {0}'.format(DASHBOARD_URL),
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_gehirn_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_gehirn_client().del_txt_record(domain, validation_name, validation)
def _get_gehirn_client(self) -> "_GehirnLexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _GehirnLexiconClient(
cast(str, self.credentials.conf('api-token')),
cast(str, self.credentials.conf('api-secret')),
self.ttl
)
class _GehirnLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the Gehirn Infrastructure Service via Lexicon.
"""
def __init__(self, api_token: str, api_secret: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('gehirn', {
'ttl': ttl,
}, {
'auth_token': api_token,
'auth_secret': api_secret,
})
self.provider = gehirn.Provider(config)
@property
def _provider_name(self) -> str:
return 'gehirn'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]:
if domain_name in str(e) and (str(e).startswith('404 Client Error: Not Found for url:')):

View file

@ -16,8 +16,12 @@ from certbot.tests import util as test_util
API_TOKEN = '00000000-0000-0000-0000-000000000000'
API_SECRET = 'MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.')
LOGIN_ERROR = HTTPError(f'401 Client Error: Unauthorized for url: {DOMAIN}.')
def setUp(self):
super().setUp()
@ -35,23 +39,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "gehirn")
self.mock_client = mock.MagicMock()
# _get_gehirn_client | pylint: disable=protected-access
self.auth._get_gehirn_client = mock.MagicMock(return_value=self.mock_client)
class GehirnLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN))
LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: {0}.'.format(DOMAIN))
def setUp(self):
from certbot_dns_gehirn._internal.dns_gehirn import _GehirnLexiconClient
self.client = _GehirnLexiconClient(API_TOKEN, API_SECRET, 0)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -7,13 +7,8 @@ from typing import cast
from typing import Optional
from typing import Union
from lexicon.providers import linode
from lexicon.providers import linode4
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
@ -21,7 +16,7 @@ API_KEY_URL = 'https://manager.linode.com/profile/api'
API_KEY_URL_V4 = 'https://cloud.linode.com/profile/tokens'
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for Linode
This Authenticator uses the Linode API to fulfill a dns-01 challenge.
@ -31,7 +26,10 @@ class Authenticator(dns_common.DNSAuthenticator):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('key',
'API key for Linode account, '
f'obtained from {API_KEY_URL} or {API_KEY_URL_V4}',
'auth_token')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -43,29 +41,13 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the Linode API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'Linode credentials INI file',
{
'key': 'API key for Linode account, obtained from {0} or {1}'
.format(API_KEY_URL, API_KEY_URL_V4)
}
)
@property
def _provider_name(self) -> str:
if not hasattr(self, '_credentials'): # pragma: no cover
self._setup_credentials()
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_linode_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_linode_client().del_txt_record(domain, validation_name, validation)
def _get_linode_client(self) -> '_LinodeLexiconClient':
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
api_key = cast(str, self.credentials.conf('key'))
api_version: Optional[Union[str, int]] = self.credentials.conf('version')
if api_version == '':
api_version = None
api_key = cast(str, self._credentials.conf('key'))
api_version: Optional[Union[str, int]] = self._credentials.conf('version')
if not api_version:
api_version = 3
@ -78,34 +60,19 @@ class Authenticator(dns_common.DNSAuthenticator):
else:
api_version = int(api_version)
return _LinodeLexiconClient(api_key, api_version)
class _LinodeLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the Linode API.
"""
def __init__(self, api_key: str, api_version: int) -> None:
super().__init__()
self.api_version = api_version
if api_version == 3:
config = dns_common_lexicon.build_lexicon_config('linode', {}, {
'auth_token': api_key,
})
self.provider = linode.Provider(config)
return 'linode'
elif api_version == 4:
config = dns_common_lexicon.build_lexicon_config('linode4', {}, {
'auth_token': api_key,
})
return 'linode4'
self.provider = linode4.Provider(config)
else:
raise errors.PluginError('Invalid api version specified: {0}. (Supported: 3, 4)'
.format(api_version))
raise errors.PluginError(f'Invalid api version specified: {api_version}. (Supported: 3, 4)')
def _setup_credentials(self) -> None:
self._credentials = self._configure_credentials(
key='credentials',
label='Credentials INI file for linode DNS authenticator',
required_variables={item[0]: item[1] for item in self._provider_options},
)
def _handle_general_error(self, e: Exception, domain_name: str) -> Optional[errors.PluginError]:
if not str(e).startswith('Domain not found'):

View file

@ -19,7 +19,9 @@ TOKEN_V4 = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
DOMAIN_NOT_FOUND = Exception('Domain not found')
def setUp(self):
super().setUp()
@ -32,10 +34,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "linode")
self.mock_client = mock.MagicMock()
# _get_linode_client | pylint: disable=protected-access
self.auth._get_linode_client = mock.MagicMock(return_value=self.mock_client)
# pylint: disable=protected-access
def test_api_version_3_detection(self):
path = os.path.join(self.tempdir, 'file_3_auto.ini')
@ -44,9 +42,8 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
client = auth._get_linode_client()
assert 3 == client.api_version
assert auth._provider_name == "linode"
# pylint: disable=protected-access
def test_api_version_4_detection(self):
@ -56,9 +53,8 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
client = auth._get_linode_client()
assert 4 == client.api_version
assert auth._provider_name == "linode4"
# pylint: disable=protected-access
def test_api_version_3_detection_empty_version(self):
@ -68,9 +64,8 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
client = auth._get_linode_client()
assert 3 == client.api_version
assert auth._provider_name == "linode"
# pylint: disable=protected-access
def test_api_version_4_detection_empty_version(self):
@ -80,9 +75,8 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
client = auth._get_linode_client()
assert 4 == client.api_version
assert auth._provider_name == "linode4"
# pylint: disable=protected-access
def test_api_version_3_manual(self):
@ -92,9 +86,8 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
client = auth._get_linode_client()
assert 3 == client.api_version
assert auth._provider_name == "linode"
# pylint: disable=protected-access
def test_api_version_4_manual(self):
@ -104,9 +97,8 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
client = auth._get_linode_client()
assert 4 == client.api_version
assert auth._provider_name == "linode4"
# pylint: disable=protected-access
def test_api_version_error(self):
@ -116,35 +108,9 @@ class AuthenticatorTest(test_util.TempDirTestCase,
config = mock.MagicMock(linode_credentials=path,
linode_propagation_seconds=0)
auth = Authenticator(config, "linode")
auth._setup_credentials()
with pytest.raises(errors.PluginError):
auth._get_linode_client()
class LinodeLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = Exception('Domain not found')
def setUp(self):
from certbot_dns_linode._internal.dns_linode import _LinodeLexiconClient
self.client = _LinodeLexiconClient(TOKEN, 3)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
class Linode4LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = Exception('Domain not found')
def setUp(self):
from certbot_dns_linode._internal.dns_linode import _LinodeLexiconClient
self.client = _LinodeLexiconClient(TOKEN, 4)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
assert auth._provider_name == "linode4"
if __name__ == "__main__":

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -2,34 +2,33 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import luadns
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
ACCOUNT_URL = 'https://api.luadns.com/settings'
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for LuaDNS
This Authenticator uses the LuaDNS API to fulfill a dns-01 challenge.
"""
description = 'Obtain certificates using a DNS TXT record (if you are using LuaDNS for DNS).'
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('email',
'email address associated with LuaDNS account',
'auth_username')
self._add_provider_option('token',
f'API token for LuaDNS account, obtained from {ACCOUNT_URL}',
'auth_token')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -41,46 +40,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the LuaDNS API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'LuaDNS credentials INI file',
{
'email': 'email address associated with LuaDNS account',
'token': 'API token for LuaDNS account, obtained from {0}'.format(ACCOUNT_URL)
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_luadns_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_luadns_client().del_txt_record(domain, validation_name, validation)
def _get_luadns_client(self) -> "_LuaDNSLexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _LuaDNSLexiconClient(cast(str, self.credentials.conf('email')),
cast(str, self.credentials.conf('token')),
self.ttl)
class _LuaDNSLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the LuaDNS via Lexicon.
"""
def __init__(self, email: str, token: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('luadns', {
'ttl': ttl,
}, {
'auth_username': email,
'auth_token': token,
})
self.provider = luadns.Provider(config)
@property
def _provider_name(self) -> str:
return 'luadns'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> errors.PluginError:
hint = None

View file

@ -1,7 +1,5 @@
"""Tests for certbot_dns_luadns._internal.dns_luadns."""
import sys
import unittest
from unittest import mock
import pytest
@ -17,7 +15,9 @@ TOKEN = 'foo'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
LOGIN_ERROR = HTTPError("401 Client Error: Unauthorized for url: ...")
def setUp(self):
super().setUp()
@ -32,23 +32,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "luadns")
self.mock_client = mock.MagicMock()
# _get_luadns_client | pylint: disable=protected-access
self.auth._get_luadns_client = mock.MagicMock(return_value=self.mock_client)
class LuaDNSLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
LOGIN_ERROR = HTTPError("401 Client Error: Unauthorized for url: ...")
def setUp(self):
from certbot_dns_luadns._internal.dns_luadns import _LuaDNSLexiconClient
self.client = _LuaDNSLexiconClient(EMAIL, TOKEN, 0)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -2,34 +2,31 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import nsone
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
ACCOUNT_URL = 'https://my.nsone.net/#/account/settings'
class Authenticator(dns_common.DNSAuthenticator):
"""DNS Authenticator for NS1
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""
DNS Authenticator for NS1
This Authenticator uses the NS1 API to fulfill a dns-01 challenge.
"""
description = 'Obtain certificates using a DNS TXT record (if you are using NS1 for DNS).'
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('api-key',
f'API key for NS1 API, obtained from {ACCOUNT_URL}',
'auth_token')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -41,42 +38,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the NS1 API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'NS1 credentials file',
{
'api-key': 'API key for NS1 API, obtained from {0}'.format(ACCOUNT_URL)
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_nsone_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_nsone_client().del_txt_record(domain, validation_name, validation)
def _get_nsone_client(self) -> "_NS1LexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _NS1LexiconClient(cast(str, self.credentials.conf('api-key')), self.ttl)
class _NS1LexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the NS1 via Lexicon.
"""
def __init__(self, api_key: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('nsone', {
'ttl': ttl,
}, {
'auth_token': api_key,
})
self.provider = nsone.Provider(config)
@property
def _provider_name(self) -> str:
return 'nsone'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]:
if domain_name in str(e) and (str(e).startswith('404 Client Error: Not Found for url:') or

View file

@ -1,7 +1,5 @@
"""Tests for certbot_dns_nsone._internal.dns_nsone."""
import sys
import unittest
from unittest import mock
import pytest
@ -17,7 +15,10 @@ API_KEY = 'foo'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.')
LOGIN_ERROR = HTTPError(f'401 Client Error: Unauthorized for url: {DOMAIN}.')
def setUp(self):
super().setUp()
@ -32,23 +33,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "nsone")
self.mock_client = mock.MagicMock()
# _get_nsone_client | pylint: disable=protected-access
self.auth._get_nsone_client = mock.MagicMock(return_value=self.mock_client)
class NS1LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN))
LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: {0}.'.format(DOMAIN))
def setUp(self):
from certbot_dns_nsone._internal.dns_nsone import _NS1LexiconClient
self.client = _NS1LexiconClient(API_KEY, 0)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -2,34 +2,40 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import ovh
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
TOKEN_URL = 'https://eu.api.ovh.com/createToken/ or https://ca.api.ovh.com/createToken/'
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for OVH
This Authenticator uses the OVH API to fulfill a dns-01 challenge.
"""
description = 'Obtain certificates using a DNS TXT record (if you are using OVH for DNS).'
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('endpoint',
'OVH API endpoint (ovh-eu or ovh-ca)',
'auth_entrypoint')
self._add_provider_option('application-key',
f'Application key for OVH API, obtained from {TOKEN_URL}',
'auth_application_key')
self._add_provider_option('application-secret',
f'Application secret for OVH API, obtained from {TOKEN_URL}',
'auth_application_secret')
self._add_provider_option('consumer-key',
f'Consumer key for OVH API, obtained from {TOKEN_URL}',
'auth_consumer_key')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -41,58 +47,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the OVH API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'OVH credentials INI file',
{
'endpoint': 'OVH API endpoint (ovh-eu or ovh-ca)',
'application-key': 'Application key for OVH API, obtained from {0}'
.format(TOKEN_URL),
'application-secret': 'Application secret for OVH API, obtained from {0}'
.format(TOKEN_URL),
'consumer-key': 'Consumer key for OVH API, obtained from {0}'
.format(TOKEN_URL),
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_ovh_client().add_txt_record(domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_ovh_client().del_txt_record(domain, validation_name, validation)
def _get_ovh_client(self) -> "_OVHLexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _OVHLexiconClient(
cast(str, self.credentials.conf('endpoint')),
cast(str, self.credentials.conf('application-key')),
cast(str, self.credentials.conf('application-secret')),
cast(str, self.credentials.conf('consumer-key')),
self.ttl
)
class _OVHLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the OVH API via Lexicon.
"""
def __init__(self, endpoint: str, application_key: str, application_secret: str,
consumer_key: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('ovh', {
'ttl': ttl,
}, {
'auth_entrypoint': endpoint,
'auth_application_key': application_key,
'auth_application_secret': application_secret,
'auth_consumer_key': consumer_key,
})
self.provider = ovh.Provider(config)
@property
def _provider_name(self) -> str:
return 'ovh'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> errors.PluginError:
hint = None

View file

@ -1,8 +1,6 @@
"""Tests for certbot_dns_ovh._internal.dns_ovh."""
import sys
import unittest
from unittest import mock
import sys
import pytest
from requests.exceptions import HTTPError
@ -19,7 +17,10 @@ CONSUMER_KEY = 'spam'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
DOMAIN_NOT_FOUND = Exception('Domain example.com not found')
LOGIN_ERROR = HTTPError('403 Client Error: Forbidden for url: https://eu.api.ovh.com/1.0/...')
def setUp(self):
super().setUp()
@ -38,26 +39,7 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.config = mock.MagicMock(ovh_credentials=path,
ovh_propagation_seconds=0) # don't wait during tests
self.auth = Authenticator(self.config, "ovh")
self.mock_client = mock.MagicMock()
# _get_ovh_client | pylint: disable=protected-access
self.auth._get_ovh_client = mock.MagicMock(return_value=self.mock_client)
class OVHLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = Exception('Domain example.com not found')
LOGIN_ERROR = HTTPError('403 Client Error: Forbidden for url: https://eu.api.ovh.com/1.0/...')
def setUp(self):
from certbot_dns_ovh._internal.dns_ovh import _OVHLexiconClient
self.client = _OVHLexiconClient(
ENDPOINT, APPLICATION_KEY, APPLICATION_SECRET, CONSUMER_KEY, 0
)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
self.auth = Authenticator(self.config, 'ovh')
if __name__ == "__main__":

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -2,23 +2,19 @@
import logging
from typing import Any
from typing import Callable
from typing import cast
from typing import Optional
from lexicon.providers import sakuracloud
from requests import HTTPError
from certbot import errors
from certbot.plugins import dns_common
from certbot.plugins import dns_common_lexicon
from certbot.plugins.dns_common import CredentialsConfiguration
logger = logging.getLogger(__name__)
APIKEY_URL = "https://secure.sakura.ad.jp/cloud/#!/apikey/top/"
class Authenticator(dns_common.DNSAuthenticator):
class Authenticator(dns_common_lexicon.LexiconDNSAuthenticator):
"""DNS Authenticator for Sakura Cloud DNS
This Authenticator uses the Sakura Cloud API to fulfill a dns-01 challenge.
@ -26,11 +22,15 @@ class Authenticator(dns_common.DNSAuthenticator):
description = 'Obtain certificates using a DNS TXT record ' + \
'(if you are using Sakura Cloud for DNS).'
ttl = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.credentials: Optional[CredentialsConfiguration] = None
self._add_provider_option('api-token',
f'API token for Sakura Cloud API obtained from {APIKEY_URL}',
'auth_token')
self._add_provider_option('api-secret',
f'API secret for Sakura Cloud API obtained from {APIKEY_URL}',
'auth_secret')
@classmethod
def add_parser_arguments(cls, add: Callable[..., None],
@ -43,50 +43,9 @@ class Authenticator(dns_common.DNSAuthenticator):
return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the Sakura Cloud API.'
def _setup_credentials(self) -> None:
self.credentials = self._configure_credentials(
'credentials',
'Sakura Cloud credentials file',
{
'api-token': f'API token for Sakura Cloud API obtained from {APIKEY_URL}',
'api-secret': f'API secret for Sakura Cloud API obtained from {APIKEY_URL}',
}
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
self._get_sakuracloud_client().add_txt_record(
domain, validation_name, validation)
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
self._get_sakuracloud_client().del_txt_record(
domain, validation_name, validation)
def _get_sakuracloud_client(self) -> "_SakuraCloudLexiconClient":
if not self.credentials: # pragma: no cover
raise errors.Error("Plugin has not been prepared.")
return _SakuraCloudLexiconClient(
cast(str, self.credentials.conf('api-token')),
cast(str, self.credentials.conf('api-secret')),
self.ttl
)
class _SakuraCloudLexiconClient(dns_common_lexicon.LexiconClient):
"""
Encapsulates all communication with the Sakura Cloud via Lexicon.
"""
def __init__(self, api_token: str, api_secret: str, ttl: int) -> None:
super().__init__()
config = dns_common_lexicon.build_lexicon_config('sakuracloud', {
'ttl': ttl,
}, {
'auth_token': api_token,
'auth_secret': api_secret,
})
self.provider = sakuracloud.Provider(config)
@property
def _provider_name(self) -> str:
return 'sakuracloud'
def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]:
if domain_name in str(e) and (str(e).startswith('404 Client Error: Not Found for url:')):

View file

@ -1,7 +1,5 @@
"""Tests for certbot_dns_sakuracloud._internal.dns_sakuracloud."""
import sys
import unittest
from unittest import mock
import pytest
@ -16,8 +14,12 @@ from certbot.tests import util as test_util
API_TOKEN = '00000000-0000-0000-0000-000000000000'
API_SECRET = 'MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAw'
class AuthenticatorTest(test_util.TempDirTestCase,
dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest):
DOMAIN_NOT_FOUND = HTTPError(f'404 Client Error: Not Found for url: {DOMAIN}.')
LOGIN_ERROR = HTTPError(f'401 Client Error: Unauthorized for url: {DOMAIN}.')
def setUp(self):
super().setUp()
@ -35,24 +37,6 @@ class AuthenticatorTest(test_util.TempDirTestCase,
self.auth = Authenticator(self.config, "sakuracloud")
self.mock_client = mock.MagicMock()
# _get_sakuracloud_client | pylint: disable=protected-access
self.auth._get_sakuracloud_client = mock.MagicMock(return_value=self.mock_client)
class SakuraCloudLexiconClientTest(unittest.TestCase,
dns_test_common_lexicon.BaseLexiconClientTest):
DOMAIN_NOT_FOUND = HTTPError('404 Client Error: Not Found for url: {0}.'.format(DOMAIN))
LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: {0}.'.format(DOMAIN))
def setUp(self):
from certbot_dns_sakuracloud._internal.dns_sakuracloud import _SakuraCloudLexiconClient
self.client = _SakuraCloudLexiconClient(API_TOKEN, API_SECRET, 0)
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -7,7 +7,7 @@ from setuptools import setup
version = '2.7.0.dev0'
install_requires = [
'dns-lexicon>=3.2.1',
'dns-lexicon>=3.14.1',
'setuptools>=41.6.0',
]

View file

@ -7,6 +7,11 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
### Added
* Add `certbot.util.LooseVersion` class. See [GH #9489](https://github.com/certbot/certbot/issues/9489).
* Add a new base class `certbot.plugins.dns_common_lexicon.LexiconDNSAuthenticator` to implement a DNS
authenticator plugin backed by Lexicon to communicate with the provider DNS API. This approach relies
heavily on conventions to reduce the implementation complexity of a new plugin.
* Add a new test base class `certbot.plugins.dns_test_common_lexicon.BaseLexiconDNSAuthenticatorTest` to
help testing DNS plugins implemented on top of `LexiconDNSAuthenticator`.
### Changed
@ -14,6 +19,10 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
of global state previously needed to inspect whether a user set an argument or not.
* Support for Python 3.7 was deprecated and will be removed in our next planned release.
* Added `RENEWED_DOMAINS` and `FAILED_DOMAINS` environment variables for consumption by post renewal hooks.
* Deprecates `LexiconClient` base class and `build_lexicon_config` function in
`certbot.plugins.dns_common_lexicon` module in favor of `LexiconDNSAuthenticator`.
* Deprecates `BaseLexiconAuthenticatorTest` and `BaseLexiconClientTest` test base classes of
`certbot.plugins.dns_test_common_lexicon` module in favor of `BaseLexiconDNSAuthenticatorTest`.
### Fixed

View file

@ -1,29 +0,0 @@
"""Tests for certbot.plugins.dns_common_lexicon."""
import sys
import unittest
from unittest import mock
import pytest
from certbot.plugins import dns_common_lexicon
from certbot.plugins import dns_test_common_lexicon
class LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
class _FakeLexiconClient(dns_common_lexicon.LexiconClient):
pass
def setUp(self):
super().setUp()
self.client = LexiconClientTest._FakeLexiconClient()
self.provider_mock = mock.MagicMock()
self.client.provider = self.provider_mock
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover

View file

@ -1,14 +1,22 @@
"""Common code for DNS Authenticator Plugins built on Lexicon."""
import abc
import logging
import sys
from types import ModuleType
from typing import Any
from typing import cast
from typing import Dict
from typing import List
from typing import Mapping
from typing import Optional
from typing import Tuple
from typing import Union
import warnings
from requests.exceptions import HTTPError
from requests.exceptions import RequestException
from certbot import configuration
from certbot import errors
from certbot.plugins import dns_common
@ -18,18 +26,23 @@ from certbot.plugins import dns_common
# always importable, even if it does not make sense to use it
# if Lexicon is not available, obviously.
try:
from lexicon.client import Client
from lexicon.config import ConfigResolver
from lexicon.providers.base import Provider
except ImportError:
from lexicon.interfaces import Provider
except ImportError: # pragma: no cover
Client = None
ConfigResolver = None
Provider = None
logger = logging.getLogger(__name__)
class LexiconClient:
class LexiconClient: # pragma: no cover
"""
Encapsulates all communication with a DNS provider via Lexicon.
.. deprecated:: 2.7.0
Please use certbot.plugins.dns_common_lexicon.LexiconDNSAuthenticator instead.
"""
def __init__(self) -> None:
@ -122,14 +135,18 @@ class LexiconClient:
def build_lexicon_config(lexicon_provider_name: str,
lexicon_options: Mapping[str, Any], provider_options: Mapping[str, Any]
) -> Union[ConfigResolver, Dict[str, Any]]:
) -> Union[ConfigResolver, Dict[str, Any]]: # pragma: no cover
"""
Convenient function to build a Lexicon 2.x/3.x config object.
:param str lexicon_provider_name: the name of the lexicon provider to use
:param dict lexicon_options: options specific to lexicon
:param dict provider_options: options specific to provider
:return: configuration to apply to the provider
:rtype: ConfigurationResolver or dict
.. deprecated:: 2.7.0
Please use certbot.plugins.dns_common_lexicon.LexiconDNSAuthenticator instead.
"""
config: Union[ConfigResolver, Dict[str, Any]] = {'provider_name': lexicon_provider_name}
config.update(lexicon_options)
@ -144,3 +161,143 @@ def build_lexicon_config(lexicon_provider_name: str,
config = ConfigResolver().with_dict(config).with_env()
return config
class LexiconDNSAuthenticator(dns_common.DNSAuthenticator):
"""
Base class for a DNS authenticator that uses Lexicon client
as backend to execute DNS record updates
"""
def __init__(self, config: configuration.NamespaceConfig, name: str):
super().__init__(config, name)
self._provider_options: List[Tuple[str, str, str]] = []
self._credentials: dns_common.CredentialsConfiguration
@property
@abc.abstractmethod
def _provider_name(self) -> str:
"""
The name of the Lexicon provider to use
"""
@property
def _ttl(self) -> int:
"""
Time to live to apply to the DNS records created by this Authenticator
"""
return 60
def _add_provider_option(self, creds_var_name: str, creds_var_label: str,
lexicon_provider_option_name: str) -> None:
self._provider_options.append(
(creds_var_name, creds_var_label, lexicon_provider_option_name))
def _build_lexicon_config(self, domain: str) -> ConfigResolver:
if not hasattr(self, '_credentials'): # pragma: no cover
self._setup_credentials()
dict_config = {
'domain': domain,
'provider_name': self._provider_name,
'ttl': self._ttl,
self._provider_name: {item[2]: self._credentials.conf(item[0])
for item in self._provider_options}
}
return ConfigResolver().with_dict(dict_config).with_env()
def _setup_credentials(self) -> None:
self._credentials = self._configure_credentials(
key='credentials',
label=f'Credentials INI file for {self._provider_name} DNS authenticator',
required_variables={item[0]: item[1] for item in self._provider_options},
)
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
resolved_domain = self._resolve_domain(domain)
try:
with Client(self._build_lexicon_config(resolved_domain)) as operations:
operations.create_record(rtype='TXT', name=validation_name, content=validation)
except RequestException as e:
logger.debug('Encountered error adding TXT record: %s', e, exc_info=True)
raise errors.PluginError('Error adding TXT record: {0}'.format(e))
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
try:
resolved_domain = self._resolve_domain(domain)
except errors.PluginError as e:
logger.debug('Encountered error finding domain_id during deletion: %s', e,
exc_info=True)
return
try:
with Client(self._build_lexicon_config(resolved_domain)) as operations:
operations.delete_record(rtype='TXT', name=validation_name, content=validation)
except RequestException as e:
logger.debug('Encountered error deleting TXT record: %s', e, exc_info=True)
def _resolve_domain(self, domain: str) -> str:
domain_name_guesses = dns_common.base_domain_name_guesses(domain)
for domain_name in domain_name_guesses:
try:
# Using client as a context manager requires `dns-lexicon>=3.14` and we may want to
# provide better checks and error handling around this in the future.
with Client(self._build_lexicon_config(domain_name)):
return domain_name
except HTTPError as e:
result1 = self._handle_http_error(e, domain_name)
if result1:
raise result1
except Exception as e: # pylint: disable=broad-except
result2 = self._handle_general_error(e, domain_name)
if result2:
raise result2 # pylint: disable=raising-bad-type
raise errors.PluginError('Unable to determine zone identifier for {0} using zone names: {1}'
.format(domain, domain_name_guesses))
def _handle_http_error(self, e: HTTPError, domain_name: str) -> Optional[errors.PluginError]:
return errors.PluginError('Error determining zone identifier for {0}: {1}.'
.format(domain_name, e))
def _handle_general_error(self, e: Exception, domain_name: str) -> Optional[errors.PluginError]:
if not str(e).startswith('No domain found'):
return errors.PluginError('Unexpected error determining zone identifier for {0}: {1}'
.format(domain_name, e))
return None
# This class takes a similar approach to the cryptography project to deprecate attributes
# in public modules. See the _ModuleWithDeprecation class here:
# https://github.com/pyca/cryptography/blob/91105952739442a74582d3e62b3d2111365b0dc7/src/cryptography/utils.py#L129
class _DeprecationModule:
"""
Internal class delegating to a module, and displaying warnings when attributes
related to deprecated attributes in the current module.
"""
def __init__(self, module: ModuleType):
self.__dict__['_module'] = module
def __getattr__(self, attr: str) -> Any:
if attr in ('LexiconClient', 'build_lexicon_config'):
warnings.warn(f'{attr} attribute in {__name__} module is deprecated '
'and will be removed soon.',
DeprecationWarning, stacklevel=2)
return getattr(self._module, attr)
def __setattr__(self, attr: str, value: Any) -> None: # pragma: no cover
setattr(self._module, attr, value)
def __delattr__(self, attr: str) -> Any: # pragma: no cover
delattr(self._module, attr)
def __dir__(self) -> List[str]: # pragma: no cover
return ['_module'] + dir(self._module)
# Patching ourselves to warn about deprecation and planned removal of some elements in the module.
sys.modules[__name__] = cast(ModuleType, _DeprecationModule(sys.modules[__name__]))

View file

@ -40,6 +40,12 @@ class _AuthenticatorCallableTestCase(Protocol):
https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertEqual
"""
def assertRaises(self, *unused_args: Any) -> None:
"""
See
https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertRaises
"""
class BaseAuthenticatorTest:
"""

View file

@ -1,8 +1,16 @@
"""Base test class for DNS authenticators built on Lexicon."""
import contextlib
import sys
from types import ModuleType
from typing import Any
from typing import cast
from typing import Generator
from typing import List
from typing import Tuple
from typing import TYPE_CHECKING
from unittest import mock
from unittest.mock import MagicMock
import warnings
import josepy as jose
from requests.exceptions import HTTPError
@ -11,11 +19,15 @@ from requests.exceptions import RequestException
from certbot import errors
from certbot.achallenges import AnnotatedChallenge
from certbot.plugins import dns_test_common
from certbot.plugins.dns_common_lexicon import LexiconClient
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=DeprecationWarning)
from certbot.plugins.dns_common_lexicon import LexiconClient
from certbot.plugins.dns_test_common import _AuthenticatorCallableTestCase
from certbot.tests import util as test_util
if TYPE_CHECKING:
if TYPE_CHECKING: # pragma: no cover
from typing_extensions import Protocol
else:
Protocol = object
@ -23,6 +35,11 @@ else:
DOMAIN = 'example.com'
KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
DOMAIN_NOT_FOUND = Exception('No domain found')
GENERIC_ERROR = RequestException
LOGIN_ERROR = HTTPError('400 Client Error: ...')
UNKNOWN_LOGIN_ERROR = HTTPError('500 Surprise! Error: ...')
class _AuthenticatorCallableLexiconTestCase(_AuthenticatorCallableTestCase, Protocol):
"""
@ -59,7 +76,7 @@ class _LexiconAwareTestCase(Protocol):
# These classes are intended to be subclassed/mixed in, so not all members are defined.
# pylint: disable=no-member
class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest):
class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest): # pragma: no cover
@test_util.patch_display_util()
def test_perform(self: _AuthenticatorCallableLexiconTestCase,
@ -77,11 +94,11 @@ class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest):
self.assertEqual(expected, self.mock_client.mock_calls)
class BaseLexiconClientTest:
DOMAIN_NOT_FOUND = Exception('No domain found')
GENERIC_ERROR = RequestException
LOGIN_ERROR = HTTPError('400 Client Error: ...')
UNKNOWN_LOGIN_ERROR = HTTPError('500 Surprise! Error: ...')
class BaseLexiconClientTest: # pragma: no cover
DOMAIN_NOT_FOUND = DOMAIN_NOT_FOUND
GENERIC_ERROR = GENERIC_ERROR
LOGIN_ERROR = LOGIN_ERROR
UNKNOWN_LOGIN_ERROR = UNKNOWN_LOGIN_ERROR
record_prefix = "_acme-challenge"
record_name = record_prefix + "." + DOMAIN
@ -175,3 +192,169 @@ class BaseLexiconClientTest:
self.provider_mock.delete_record.side_effect = self.GENERIC_ERROR
self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
class _BaseLexiconDNSAuthenticatorTestProto(_AuthenticatorCallableTestCase, Protocol):
"""Protocol for BaseLexiconDNSAuthenticatorTest instances"""
DOMAIN_NOT_FOUND: Exception
GENERIC_ERROR: Exception
LOGIN_ERROR: Exception
UNKNOWN_LOGIN_ERROR: Exception
achall: AnnotatedChallenge
class BaseLexiconDNSAuthenticatorTest(dns_test_common.BaseAuthenticatorTest):
DOMAIN_NOT_FOUND = DOMAIN_NOT_FOUND
GENERIC_ERROR = GENERIC_ERROR
LOGIN_ERROR = LOGIN_ERROR
UNKNOWN_LOGIN_ERROR = UNKNOWN_LOGIN_ERROR
def test_perform_succeed(self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (mock_client, mock_operations):
self.auth.perform([self.achall])
mock_client.assert_called()
config = mock_client.call_args[0][0]
self.assertEqual(DOMAIN, config.resolve('lexicon:domain'))
mock_operations.create_record.assert_called_with(
rtype='TXT', name=f'_acme-challenge.{DOMAIN}', content=mock.ANY)
def test_perform_with_one_domain_resolution_failure_succeed(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (mock_client, mock_operations):
mock_client.return_value.__enter__.side_effect = [
self.DOMAIN_NOT_FOUND, # First resolution domain attempt
mock_operations, # Second resolution domain attempt
mock_operations, # Create record operation
]
self.auth.perform([self.achall])
def test_perform_with_two_domain_resolution_failures_raise(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (mock_client, _):
mock_client.return_value.__enter__.side_effect = self.DOMAIN_NOT_FOUND
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_perform_with_domain_resolution_general_failure_raise(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (mock_client, _):
mock_client.return_value.__enter__.side_effect = self.GENERIC_ERROR
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_perform_with_auth_failure_raise(self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (mock_client, _):
mock_client.side_effect = self.LOGIN_ERROR
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_perform_with_unknown_auth_failure_raise(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (mock_client, _):
mock_client.side_effect = self.UNKNOWN_LOGIN_ERROR
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_perform_with_create_record_failure_raise(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with test_util.patch_display_util():
with _patch_lexicon_client() as (_, mock_operations):
mock_operations.create_record.side_effect = self.GENERIC_ERROR
self.assertRaises(errors.PluginError,
self.auth.perform,
[self.achall])
def test_cleanup_success(self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
self.auth._attempt_cleanup = True # _attempt_cleanup | pylint: disable=protected-access
with _patch_lexicon_client() as (mock_client, mock_operations):
self.auth.cleanup([self.achall])
mock_client.assert_called()
config = mock_client.call_args[0][0]
self.assertEqual(DOMAIN, config.resolve('lexicon:domain'))
mock_operations.delete_record.assert_called_with(
rtype='TXT', name=f'_acme-challenge.{DOMAIN}', content=mock.ANY)
def test_cleanup_with_auth_failure_ignore(self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with _patch_lexicon_client() as (mock_client, _):
mock_client.side_effect = self.LOGIN_ERROR
self.auth.cleanup([self.achall])
def test_cleanup_with_unknown_auth_failure_ignore(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with _patch_lexicon_client() as (mock_client, _):
mock_client.side_effect = self.LOGIN_ERROR
self.auth.cleanup([self.achall])
def test_cleanup_with_domain_resolution_failure_ignore(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with _patch_lexicon_client() as (mock_client, _):
mock_client.return_value.__enter__.side_effect = self.DOMAIN_NOT_FOUND
self.auth.cleanup([self.achall])
def test_cleanup_with_domain_resolution_general_failure_ignore(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with _patch_lexicon_client() as (mock_client, _):
mock_client.return_value.__enter__.side_effect = self.GENERIC_ERROR
self.auth.cleanup([self.achall])
def test_cleanup_with_delete_record_failure_ignore(
self: _BaseLexiconDNSAuthenticatorTestProto) -> None:
with _patch_lexicon_client() as (_, mock_operations):
mock_operations.create_record.side_effect = self.GENERIC_ERROR
self.auth.cleanup([self.achall])
@contextlib.contextmanager
def _patch_lexicon_client() -> Generator[Tuple[MagicMock, MagicMock], None, None]:
with mock.patch('certbot.plugins.dns_common_lexicon.Client') as mock_client:
mock_operations = MagicMock()
mock_client.return_value.__enter__.return_value = mock_operations
yield mock_client, mock_operations
# This class takes a similar approach to the cryptography project to deprecate attributes
# in public modules. See the _ModuleWithDeprecation class here:
# https://github.com/pyca/cryptography/blob/91105952739442a74582d3e62b3d2111365b0dc7/src/cryptography/utils.py#L129
class _DeprecationModule:
"""
Internal class delegating to a module, and displaying warnings when attributes
related to deprecated attributes in the current module.
"""
def __init__(self, module: ModuleType):
self.__dict__['_module'] = module
def __getattr__(self, attr: str) -> Any:
if attr in ('BaseLexiconAuthenticatorTest', 'BaseLexiconClientTest'):
warnings.warn(f'{attr} attribute in {__name__} module is deprecated '
'and will be removed soon.',
DeprecationWarning, stacklevel=2)
return getattr(self._module, attr)
def __setattr__(self, attr: str, value: Any) -> None: # pragma: no cover
setattr(self._module, attr, value)
def __delattr__(self, attr: str) -> Any: # pragma: no cover
delattr(self._module, attr)
def __dir__(self) -> List[str]: # pragma: no cover
return ['_module'] + dir(self._module)
# Patching ourselves to warn about deprecation and planned removal of some elements in the module.
sys.modules[__name__] = cast(ModuleType, _DeprecationModule(sys.modules[__name__]))

View file

@ -4,6 +4,7 @@ apacheconfig==0.3.2 ; python_version >= "3.7" and python_version < "3.8"
appdirs==1.4.4 ; python_version >= "3.7" and python_version < "3.8"
asn1crypto==0.24.0 ; python_version >= "3.7" and python_version < "3.8"
astroid==2.15.6 ; python_full_version >= "3.7.2" and python_version < "3.8"
beautifulsoup4==4.12.2 ; python_version >= "3.7" and python_version < "3.8"
boto3==1.15.15 ; python_version >= "3.7" and python_version < "3.8"
botocore==1.18.15 ; python_version >= "3.7" and python_version < "3.8"
cachetools==5.3.1 ; python_version >= "3.7" and python_version < "3.8"
@ -11,7 +12,7 @@ certifi==2023.7.22 ; python_version >= "3.7" and python_version < "3.8"
cffi==1.11.5 ; python_version >= "3.7" and python_version < "3.8"
chardet==3.0.4 ; python_version >= "3.7" and python_version < "3.8"
cloudflare==1.5.1 ; python_version >= "3.7" and python_version < "3.8"
colorama==0.4.6 ; python_version >= "3.7" and python_version < "3.8" and sys_platform == "win32"
colorama==0.4.6 ; python_version < "3.8" and sys_platform == "win32" and python_version >= "3.7"
configargparse==1.5.3 ; python_version >= "3.7" and python_version < "3.8"
configobj==5.0.6 ; python_version >= "3.7" and python_version < "3.8"
coverage==7.2.7 ; python_version >= "3.7" and python_version < "3.8"
@ -20,7 +21,7 @@ cython==0.29.36 ; python_version >= "3.7" and python_version < "3.8"
dill==0.3.7 ; python_full_version >= "3.7.2" and python_version < "3.8"
distlib==0.3.7 ; python_version >= "3.7" and python_version < "3.8"
distro==1.0.1 ; python_version >= "3.7" and python_version < "3.8"
dns-lexicon==3.2.1 ; python_version >= "3.7" and python_version < "3.8"
dns-lexicon==3.14.1 ; python_version >= "3.7" and python_version < "3.8"
dnspython==1.15.0 ; python_version >= "3.7" and python_version < "3.8"
exceptiongroup==1.1.3 ; python_version >= "3.7" and python_version < "3.8"
execnet==2.0.2 ; python_version >= "3.7" and python_version < "3.8"
@ -75,11 +76,12 @@ rsa==4.9 ; python_version >= "3.7" and python_version < "3.8"
s3transfer==0.3.7 ; python_version >= "3.7" and python_version < "3.8"
setuptools==41.6.0 ; python_version >= "3.7" and python_version < "3.8"
six==1.11.0 ; python_version >= "3.7" and python_version < "3.8"
soupsieve==2.4.1 ; python_version >= "3.7" and python_version < "3.8"
tldextract==3.5.0 ; python_version >= "3.7" and python_version < "3.8"
tomli==2.0.1 ; python_version >= "3.7" and python_version < "3.8"
tomli==2.0.1 ; python_version < "3.8" and python_version >= "3.7"
tomlkit==0.12.1 ; python_full_version >= "3.7.2" and python_version < "3.8"
tox==1.9.2 ; python_version >= "3.7" and python_version < "3.8"
typed-ast==1.5.5 ; python_version >= "3.7" and python_version < "3.8"
typed-ast==1.5.5 ; python_version < "3.8" and python_version >= "3.7"
types-cryptography==3.3.23.2 ; python_version >= "3.7" and python_version < "3.8"
types-httplib2==0.22.0.2 ; python_version >= "3.7" and python_version < "3.8"
types-pyopenssl==23.0.0.0 ; python_version >= "3.7" and python_version < "3.8"
@ -91,7 +93,7 @@ types-requests==2.31.0.2 ; python_version >= "3.7" and python_version < "3.8"
types-setuptools==68.2.0.0 ; python_version >= "3.7" and python_version < "3.8"
types-six==1.16.21.9 ; python_version >= "3.7" and python_version < "3.8"
types-urllib3==1.26.25.14 ; python_version >= "3.7" and python_version < "3.8"
typing-extensions==4.7.1 ; python_version >= "3.7" and python_version < "3.8"
typing-extensions==4.7.1 ; python_version < "3.8" and python_version >= "3.7"
uritemplate==3.0.1 ; python_version >= "3.7" and python_version < "3.8"
urllib3==1.24.2 ; python_version >= "3.7" and python_version < "3.8"
virtualenv==20.4.7 ; python_version >= "3.7" and python_version < "3.8"

View file

@ -75,11 +75,6 @@ poetry = "<1.3.0"
# https://github.com/certbot/certbot/issues/9606.
setuptools = "<67.5.0"
# Lexicon 3.14+ deprecates several private APIs and create stubs packages, leading
# to several warnings and lint/mypy errors. Let's pin it to 3.13 until this is fixed
# with https://github.com/certbot/certbot/pull/9746.
dns-lexicon = "<3.14"
[tool.poetry.dev-dependencies]
[build-system]

View file

@ -54,7 +54,7 @@ cloudflare = "1.5.1"
configobj = "5.0.6"
cryptography = "3.2.1"
distro = "1.0.1"
dns-lexicon = "3.2.1"
dns-lexicon = "3.14.1"
dnspython = "1.15.0"
funcsigs = "0.4"
google-api-python-client = "1.6.5"

View file

@ -18,8 +18,8 @@ backports-cached-property==1.0.2 ; python_version >= "3.7" and python_version <
bcrypt==4.0.1 ; python_version >= "3.7" and python_version < "4.0"
beautifulsoup4==4.12.2 ; python_version >= "3.7" and python_version < "4.0"
bleach==6.0.0 ; python_version >= "3.7" and python_version < "4.0"
boto3==1.28.43 ; python_version >= "3.7" and python_version < "4.0"
botocore==1.31.43 ; python_version >= "3.7" and python_version < "4.0"
boto3==1.28.45 ; python_version >= "3.7" and python_version < "4.0"
botocore==1.31.45 ; python_version >= "3.7" and python_version < "4.0"
cachecontrol==0.12.14 ; python_version >= "3.7" and python_version < "4.0"
cachetools==5.3.1 ; python_version >= "3.7" and python_version < "4.0"
cachy==0.3.0 ; python_version >= "3.7" and python_version < "4.0"
@ -28,7 +28,7 @@ cffi==1.15.1 ; python_version >= "3.7" and python_version < "4.0"
charset-normalizer==3.2.0 ; python_version >= "3.7" and python_version < "4.0"
cleo==1.0.0a5 ; python_version >= "3.7" and python_version < "4.0"
cloudflare==2.11.7 ; python_version >= "3.7" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.7" and python_version < "4.0" and (sys_platform == "win32" or platform_system == "Windows")
colorama==0.4.6 ; python_version < "4.0" and sys_platform == "win32" and python_version >= "3.7" or python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows"
configargparse==1.7 ; python_version >= "3.7" and python_version < "4.0"
configobj==5.0.8 ; python_version >= "3.7" and python_version < "4.0"
coverage==7.2.7 ; python_version >= "3.7" and python_version < "4.0"
@ -40,18 +40,18 @@ deprecated==1.2.14 ; python_version >= "3.7" and python_version < "4.0"
dill==0.3.7 ; python_full_version >= "3.7.2" and python_version < "4.0"
distlib==0.3.7 ; python_version >= "3.7" and python_version < "4.0"
distro==1.8.0 ; python_version >= "3.7" and python_version < "4.0"
dns-lexicon==3.13.0 ; python_version >= "3.7" and python_version < "4.0"
dns-lexicon==3.14.1 ; python_version >= "3.7" and python_version < "4.0"
dnspython==2.3.0 ; python_version >= "3.7" and python_version < "4.0"
docutils==0.19 ; python_version >= "3.7" and python_version < "4.0"
docutils==0.18.1 ; python_version >= "3.7" and python_version < "4.0"
dulwich==0.20.50 ; python_version >= "3.7" and python_version < "4.0"
exceptiongroup==1.1.3 ; python_version >= "3.7" and python_version < "3.11"
execnet==2.0.2 ; python_version >= "3.7" and python_version < "4.0"
fabric==3.2.2 ; python_version >= "3.7" and python_version < "4.0"
filelock==3.12.2 ; python_version >= "3.7" and python_version < "4.0"
google-api-core==2.11.1 ; python_version >= "3.7" and python_version < "4.0"
google-api-python-client==2.98.0 ; python_version >= "3.7" and python_version < "4.0"
google-auth-httplib2==0.1.0 ; python_version >= "3.7" and python_version < "4.0"
google-auth==2.22.0 ; python_version >= "3.7" and python_version < "4.0"
google-api-python-client==2.99.0 ; python_version >= "3.7" and python_version < "4.0"
google-auth-httplib2==0.1.1 ; python_version >= "3.7" and python_version < "4.0"
google-auth==2.23.0 ; python_version >= "3.7" and python_version < "4.0"
googleapis-common-protos==1.60.0 ; python_version >= "3.7" and python_version < "4.0"
html5lib==1.1 ; python_version >= "3.7" and python_version < "4.0"
httplib2==0.22.0 ; python_version >= "3.7" and python_version < "4.0"
@ -97,7 +97,7 @@ pickleshare==0.7.5 ; python_version >= "3.7" and python_version < "4.0"
pip==23.2.1 ; python_version >= "3.7" and python_version < "4.0"
pkginfo==1.9.6 ; python_version >= "3.7" and python_version < "4.0"
pkgutil-resolve-name==1.3.10 ; python_version >= "3.7" and python_version < "3.9"
platformdirs==2.6.2 ; python_version >= "3.7" and python_version < "4.0"
platformdirs==2.6.2 ; python_version < "4.0" and python_version >= "3.7"
pluggy==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
ply==3.11 ; python_version >= "3.7" and python_version < "4.0"
poetry-core==1.3.2 ; python_version >= "3.7" and python_version < "4.0"
@ -147,21 +147,22 @@ shellingham==1.5.3 ; python_version >= "3.7" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.7" and python_version < "4.0"
snowballstemmer==2.2.0 ; python_version >= "3.7" and python_version < "4.0"
soupsieve==2.4.1 ; python_version >= "3.7" and python_version < "4.0"
sphinx-rtd-theme==0.5.1 ; python_version >= "3.7" and python_version < "4.0"
sphinx-rtd-theme==1.3.0 ; python_version >= "3.7" and python_version < "4.0"
sphinx==5.3.0 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-applehelp==1.0.2 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-devhelp==1.0.2 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-htmlhelp==2.0.0 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-jquery==4.1 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-jsmath==1.0.1 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-qthelp==1.0.3 ; python_version >= "3.7" and python_version < "4.0"
sphinxcontrib-serializinghtml==1.1.5 ; python_version >= "3.7" and python_version < "4.0"
tldextract==3.5.0 ; python_version >= "3.7" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.7" and python_full_version <= "3.11.0a6"
tomlkit==0.12.1 ; python_version >= "3.7" and python_version < "4.0"
tomlkit==0.12.1 ; python_version < "4.0" and python_version >= "3.7"
tox==3.28.0 ; python_version >= "3.7" and python_version < "4.0"
traitlets==5.9.0 ; python_version >= "3.7" and python_version < "4.0"
twine==4.0.2 ; python_version >= "3.7" and python_version < "4.0"
typed-ast==1.5.5 ; python_version >= "3.7" and python_version < "3.8"
typed-ast==1.5.5 ; python_version < "3.8" and python_version >= "3.7"
types-httplib2==0.22.0.2 ; python_version >= "3.7" and python_version < "4.0"
types-pyopenssl==23.2.0.2 ; python_version >= "3.7" and python_version < "4.0"
types-pyrfc3339==1.1.1.5 ; python_version >= "3.7" and python_version < "4.0"
@ -179,7 +180,7 @@ virtualenv==20.21.1 ; python_version >= "3.7" and python_version < "4.0"
wcwidth==0.2.6 ; python_version >= "3.7" and python_version < "4.0"
webencodings==0.5.1 ; python_version >= "3.7" and python_version < "4.0"
wheel==0.41.2 ; python_version >= "3.7" and python_version < "4.0"
wrapt==1.15.0 ; python_version >= "3.7" and python_version < "4.0"
wrapt==1.15.0 ; python_version < "4.0" and python_version >= "3.7"
xattr==0.9.9 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "darwin"
yarg==0.1.9 ; python_version >= "3.7" and python_version < "4.0"
zipp==3.15.0 ; python_version >= "3.7" and python_version < "4.0"