Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-certbot-dns-route53 for 
openSUSE:Factory checked in at 2025-06-16 12:26:31
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-certbot-dns-route53 (Old)
 and      /work/SRC/openSUSE:Factory/.python-certbot-dns-route53.new.19631 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-certbot-dns-route53"

Mon Jun 16 12:26:31 2025 rev:46 rq:1286017 version:4.1.1

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-certbot-dns-route53/python-certbot-dns-route53.changes
    2025-04-22 17:29:58.171331834 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-certbot-dns-route53.new.19631/python-certbot-dns-route53.changes
 2025-06-16 12:26:38.100659717 +0200
@@ -1,0 +2,7 @@
+Fri Jun 13 14:52:31 UTC 2025 - Markéta Machová <mmach...@suse.com>
+
+- update to version 4.1.1
+  * Switched to src-layout from flat-layout to accommodate PEP 517 pip
+    editable installs
+
+-------------------------------------------------------------------

Old:
----
  certbot_dns_route53-4.0.0.tar.gz

New:
----
  certbot_dns_route53-4.1.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-certbot-dns-route53.spec ++++++
--- /var/tmp/diff_new_pack.yh9D1L/_old  2025-06-16 12:26:38.696684536 +0200
+++ /var/tmp/diff_new_pack.yh9D1L/_new  2025-06-16 12:26:38.696684536 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-certbot-dns-route53
-Version:        4.0.0
+Version:        4.1.1
 Release:        0
 Summary:        Route53 DNS Authenticator plugin for Certbot
 License:        Apache-2.0

++++++ certbot_dns_route53-4.0.0.tar.gz -> certbot_dns_route53-4.1.1.tar.gz 
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/certbot_dns_route53-4.0.0/MANIFEST.in 
new/certbot_dns_route53-4.1.1/MANIFEST.in
--- old/certbot_dns_route53-4.0.0/MANIFEST.in   2025-04-08 00:03:33.000000000 
+0200
+++ new/certbot_dns_route53-4.1.1/MANIFEST.in   2025-06-12 20:08:34.000000000 
+0200
@@ -1,6 +1,6 @@
 include LICENSE.txt
 include README.rst
 recursive-include docs *
-include certbot_dns_route53/py.typed
+include src/certbot_dns_route53/py.typed
 global-exclude __pycache__
 global-exclude *.py[cod]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/certbot_dns_route53-4.0.0/PKG-INFO 
new/certbot_dns_route53-4.1.1/PKG-INFO
--- old/certbot_dns_route53-4.0.0/PKG-INFO      2025-04-08 00:03:41.034472200 
+0200
+++ new/certbot_dns_route53-4.1.1/PKG-INFO      2025-06-12 20:08:43.940638000 
+0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: certbot-dns-route53
-Version: 4.0.0
+Version: 4.1.1
 Summary: Route53 DNS Authenticator plugin for Certbot
 Home-page: https://github.com/certbot/certbot
 Author: Certbot Project
@@ -25,11 +25,11 @@
 Classifier: Topic :: System :: Networking
 Classifier: Topic :: System :: Systems Administration
 Classifier: Topic :: Utilities
-Requires-Python: >=3.9
+Requires-Python: >=3.9.2
 License-File: LICENSE.txt
 Requires-Dist: boto3>=1.15.15
-Requires-Dist: acme>=4.0.0
-Requires-Dist: certbot>=4.0.0
+Requires-Dist: acme>=4.1.1
+Requires-Dist: certbot>=4.1.1
 Provides-Extra: docs
 Requires-Dist: Sphinx>=1.0; extra == "docs"
 Requires-Dist: sphinx_rtd_theme; extra == "docs"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/__init__.py 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/__init__.py
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53/__init__.py       
2025-04-08 00:03:33.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/__init__.py       
1970-01-01 01:00:00.000000000 +0100
@@ -1,105 +0,0 @@
-"""
-The `~certbot_dns_route53.dns_route53` plugin automates the process of
-completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and
-subsequently removing, TXT records using the Amazon Web Services Route 53 API.
-
-.. note::
-   The plugin is not installed by default. It can be installed by heading to
-   `certbot.eff.org <https://certbot.eff.org/instructions#wildcard>`_, 
choosing your system and
-   selecting the Wildcard tab.
-
-Credentials
------------
-Use of this plugin requires a configuration file containing Amazon Web Sevices
-API credentials for an account with the following permissions:
-
-* ``route53:ListHostedZones``
-* ``route53:GetChange``
-* ``route53:ChangeResourceRecordSets``
-
-These permissions can be captured in an AWS policy like the one below. Amazon
-provides `information about managing access 
<https://docs.aws.amazon.com/Route53
-/latest/DeveloperGuide/access-control-overview.html>`_ and `information about
-the required permissions <https://docs.aws.amazon.com/Route53/latest
-/DeveloperGuide/r53-api-permissions-ref.html>`_
-
-.. code-block:: json
-   :name: sample-aws-policy.json
-   :caption: Example AWS policy file:
-
-   {
-       "Version": "2012-10-17",
-       "Id": "certbot-dns-route53 sample policy",
-       "Statement": [
-           {
-               "Effect": "Allow",
-               "Action": [
-                   "route53:ListHostedZones",
-                   "route53:GetChange"
-               ],
-               "Resource": [
-                   "*"
-               ]
-           },
-           {
-               "Effect" : "Allow",
-               "Action" : [
-                   "route53:ChangeResourceRecordSets"
-               ],
-               "Resource" : [
-                   "arn:aws:route53:::hostedzone/YOURHOSTEDZONEID"
-               ]
-           }
-       ]
-   }
-
-The `access keys <https://docs.aws.amazon.com/general/latest/gr
-/aws-sec-cred-types.html#access-keys-and-secret-access-keys>`_ for an account
-with these permissions must be supplied in one of the following ways, which are
-discussed in more detail in the Boto3 library's documentation about 
`configuring
-credentials <https://boto3.readthedocs.io/en/latest/guide/configuration.html
-#best-practices-for-configuring-credentials>`_.
-
-* Using the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment
-  variables.
-* Using a credentials configuration file at the default location,
-  ``~/.aws/credentials``. If you're running on sudo, the credentials
-  will be picked up from the root home.
-* Using a credentials configuration file at a path supplied using the
-  ``AWS_CONFIG_FILE`` environment variable.
-
-.. code-block:: ini
-   :name: config.ini
-   :caption: Example credentials config file:
-
-   [default]
-   aws_access_key_id=AKIAIOSFODNN7EXAMPLE
-   aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
-
-.. caution::
-   You should protect these API credentials as you would a password. Users who
-   can read this file can use these credentials to issue some types of API 
calls
-   on your behalf, limited by the permissions assigned to the account. Users 
who
-   can cause Certbot to run using these credentials can complete a ``dns-01``
-   challenge to acquire new certificates or revoke existing certificates for
-   domains these credentials are authorized to manage.
-
-
-Examples
---------
-.. code-block:: bash
-   :caption: To acquire a certificate for ``example.com``
-
-   certbot certonly \\
-     --dns-route53 \\
-     -d example.com
-
-.. code-block:: bash
-   :caption: To acquire a single certificate for both ``example.com`` and
-             ``www.example.com``
-
-   certbot certonly \\
-     --dns-route53 \\
-     -d example.com \\
-     -d www.example.com
-"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/__init__.py 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/__init__.py
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/__init__.py     
2025-04-08 00:03:33.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/__init__.py     
1970-01-01 01:00:00.000000000 +0100
@@ -1 +0,0 @@
-"""Internal implementation of `~certbot_dns_route53.dns_route53` plugin."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/dns_route53.py 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/dns_route53.py
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/dns_route53.py  
2025-04-08 00:03:33.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/dns_route53.py  
1970-01-01 01:00:00.000000000 +0100
@@ -1,191 +0,0 @@
-"""Certbot Route53 authenticator plugin."""
-import collections
-import logging
-import time
-from typing import Any
-from typing import Callable
-from typing import DefaultDict
-from typing import Dict
-from typing import Iterable
-from typing import List
-from typing import Type
-from typing import cast
-
-import boto3
-from botocore.exceptions import ClientError
-from botocore.exceptions import NoCredentialsError
-
-from acme import challenges
-from certbot import achallenges
-from certbot import errors
-from certbot import interfaces
-from certbot.achallenges import AnnotatedChallenge
-from certbot.plugins import common
-
-logger = logging.getLogger(__name__)
-
-INSTRUCTIONS = (
-    "To use certbot-dns-route53, configure credentials as described at "
-    
"https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials
 "  # pylint: disable=line-too-long
-    "and add the necessary permissions for Route53 access.")
-
-
-class Authenticator(common.Plugin, interfaces.Authenticator):
-    """Route53 Authenticator
-
-    This authenticator solves a DNS01 challenge by uploading the answer to AWS
-    Route53.
-    """
-
-    description = ("Obtain certificates using a DNS TXT record (if you are 
using AWS Route53 for "
-                   "DNS).")
-    ttl = 10
-
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
-        super().__init__(*args, **kwargs)
-        self.r53 = boto3.client("route53")
-        self._attempt_cleanup = False
-        self._resource_records: DefaultDict[str, List[Dict[str, str]]] = \
-            collections.defaultdict(list)
-
-    def more_info(self) -> str:
-        return "Solve a DNS01 challenge using AWS Route53"
-
-    @classmethod
-    def add_parser_arguments(cls, add: Callable[..., None]) -> None:
-        # This authenticator currently adds no extra arguments.
-        pass
-
-    def auth_hint(self, failed_achalls: List[achallenges.AnnotatedChallenge]) 
-> str:
-        return (
-            'The Certificate Authority failed to verify the DNS TXT records 
created by '
-            '--dns-route53. Ensure the above domains have their DNS hosted by 
AWS Route53.'
-        )
-
-    def prepare(self) -> None:
-        pass
-
-    def get_chall_pref(self, unused_domain: str) -> 
Iterable[Type[challenges.Challenge]]:
-        return [challenges.DNS01]
-
-    def perform(self, achalls: List[AnnotatedChallenge]) -> 
List[challenges.ChallengeResponse]:
-        self._attempt_cleanup = True
-
-        try:
-            change_ids = [
-                self._change_txt_record("UPSERT",
-                  achall.validation_domain_name(achall.domain),
-                  achall.validation(achall.account_key))
-                for achall in achalls
-            ]
-
-            for change_id in change_ids:
-                self._wait_for_change(change_id)
-        except (NoCredentialsError, ClientError) as e:
-            logger.debug('Encountered error during perform: %s', e, 
exc_info=True)
-            raise errors.PluginError("\n".join([str(e), INSTRUCTIONS]))
-        return [achall.response(achall.account_key) for achall in achalls]
-
-    def cleanup(self, achalls: List[achallenges.AnnotatedChallenge]) -> None:
-        if self._attempt_cleanup:
-            for achall in achalls:
-                domain = achall.domain
-                validation_domain_name = achall.validation_domain_name(domain)
-                validation = achall.validation(achall.account_key)
-
-                self._cleanup(validation_domain_name, validation)
-
-    def _cleanup(self, validation_name: str, validation: str) -> None:
-        try:
-            self._change_txt_record("DELETE", validation_name, validation)
-        except (NoCredentialsError, ClientError) as e:
-            logger.debug('Encountered error during cleanup: %s', e, 
exc_info=True)
-
-    def _find_zone_id_for_domain(self, domain: str) -> str:
-        """Find the zone id responsible a given FQDN.
-
-           That is, the id for the zone whose name is the longest parent of the
-           domain.
-        """
-        paginator = self.r53.get_paginator("list_hosted_zones")
-        zones: list[tuple[str, str]] = []
-        target_labels = domain.rstrip(".").split(".")
-        for page in paginator.paginate():
-            for zone in page["HostedZones"]:
-                if zone["Config"]["PrivateZone"]:
-                    continue
-
-                candidate_labels = zone["Name"].rstrip(".").split(".")
-                if candidate_labels == target_labels[-len(candidate_labels):]:
-                    zones.append((zone["Name"], zone["Id"]))
-
-        if not zones:
-            raise errors.PluginError(
-                "Unable to find a Route53 hosted zone for {0}".format(domain)
-            )
-
-        # Order the zones that are suffixes for our desired to domain by
-        # length, this puts them in an order like:
-        # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"]
-        # And then we choose the first one, which will be the most specific.
-        zones.sort(key=lambda z: len(z[0]), reverse=True)
-        return zones[0][1]
-
-    def _change_txt_record(self, action: str, validation_domain_name: str, 
validation: str) -> str:
-        zone_id = self._find_zone_id_for_domain(validation_domain_name)
-
-        rrecords = self._resource_records[validation_domain_name]
-        challenge = {"Value": '"{0}"'.format(validation)}
-        if action == "DELETE":
-            # Remove the record being deleted from the list of tracked records
-            rrecords.remove(challenge)
-            if rrecords:
-                # Need to update instead, as we're not deleting the rrset
-                action = "UPSERT"
-            else:
-                # Create a new list containing the record to use with DELETE
-                rrecords = [challenge]
-        else:
-            rrecords.append(challenge)
-
-        response = self.r53.change_resource_record_sets(
-            HostedZoneId=zone_id,
-            ChangeBatch={
-                "Comment": "certbot-dns-route53 certificate validation " + 
action,
-                "Changes": [
-                    {
-                        "Action": action,
-                        "ResourceRecordSet": {
-                            "Name": validation_domain_name,
-                            "Type": "TXT",
-                            "TTL": self.ttl,
-                            "ResourceRecords": rrecords,
-                        }
-                    }
-                ]
-            }
-        )
-        return cast(str, response["ChangeInfo"]["Id"])
-
-    def _wait_for_change(self, change_id: str) -> None:
-        """Wait for a change to be propagated to all Route53 DNS servers.
-           
https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html
-        """
-        for unused_n in range(0, 120):
-            response = self.r53.get_change(Id=change_id)
-            if response["ChangeInfo"]["Status"] == "INSYNC":
-                return
-            time.sleep(5)
-        raise errors.PluginError(
-            "Timed out waiting for Route53 change. Current status: %s" %
-            response["ChangeInfo"]["Status"])
-
-
-# Our route53 plugin was initially a 3rd party plugin named 
`certbot-route53:auth` as described at
-# https://github.com/certbot/certbot/issues/4688. This shim exists to allow 
installations using the
-# old plugin name of `certbot-route53:auth` to continue to work without 
cluttering things like
-# Certbot's help output with two route53 plugins.
-class HiddenAuthenticator(Authenticator):
-    """A hidden shim around certbot-dns-route53 for backwards compatibility."""
-
-    hidden = True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/__init__.py 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/__init__.py
--- 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/__init__.py   
    2025-04-08 00:03:33.000000000 +0200
+++ 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/__init__.py   
    1970-01-01 01:00:00.000000000 +0100
@@ -1 +0,0 @@
-"""certbot-dns-route53 tests"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/dns_route53_test.py
 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/dns_route53_test.py
--- 
old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/dns_route53_test.py
       2025-04-08 00:03:33.000000000 +0200
+++ 
new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/dns_route53_test.py
       1970-01-01 01:00:00.000000000 +0100
@@ -1,273 +0,0 @@
-"""Tests for certbot_dns_route53._internal.dns_route53.Authenticator"""
-
-import sys
-import unittest
-from unittest import mock
-
-from botocore.exceptions import ClientError
-from botocore.exceptions import NoCredentialsError
-import josepy as jose
-import pytest
-
-from acme import challenges
-from certbot import achallenges
-from certbot import errors
-from certbot.compat import os
-from certbot.plugins.dns_test_common import DOMAIN
-from certbot.tests import acme_util
-from certbot.tests import util as test_util
-
-DOMAIN = 'example.com'
-KEY = jose.jwk.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
-
-
-class AuthenticatorTest(unittest.TestCase):
-    # pylint: disable=protected-access
-
-    achall = achallenges.KeyAuthorizationAnnotatedChallenge(
-        challb=acme_util.DNS01, domain=DOMAIN, account_key=KEY)
-
-    def setUp(self):
-        from certbot_dns_route53._internal.dns_route53 import Authenticator
-
-        super().setUp()
-
-        self.config = mock.MagicMock()
-
-        # Set up dummy credentials for testing
-        os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key"
-        os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key"
-
-        self.auth = Authenticator(self.config, "route53")
-
-    def tearDown(self):
-        # Remove the dummy credentials from env vars
-        del os.environ["AWS_ACCESS_KEY_ID"]
-        del os.environ["AWS_SECRET_ACCESS_KEY"]
-
-    def test_more_info(self) -> None:
-        self.assertTrue(isinstance(self.auth.more_info(), str))
-
-    def test_get_chall_pref(self) -> None:
-        self.assertEqual(self.auth.get_chall_pref("example.org"), 
[challenges.DNS01])
-
-    def test_perform(self):
-        self.auth._change_txt_record = mock.MagicMock() # type: 
ignore[method-assign, unused-ignore]
-        self.auth._wait_for_change = mock.MagicMock() # type: ignore 
[method-assign, unused-ignore]
-
-        self.auth.perform([self.achall])
-
-        self.auth._change_txt_record.assert_called_once_with("UPSERT",
-                                                             
'_acme-challenge.' + DOMAIN,
-                                                             mock.ANY)
-        assert self.auth._wait_for_change.call_count == 1
-
-    def test_perform_no_credentials_error(self):
-        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
-            side_effect=NoCredentialsError)
-
-        with pytest.raises(errors.PluginError):
-            self.auth.perform([self.achall])
-
-    def test_perform_client_error(self):
-        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
-            side_effect=ClientError({"Error": {"Code": "foo"}}, "bar"))
-
-        with pytest.raises(errors.PluginError):
-            self.auth.perform([self.achall])
-
-    def test_cleanup(self):
-        self.auth._attempt_cleanup = True
-
-        self.auth._change_txt_record = mock.MagicMock() # type: 
ignore[method-assign, unused-ignore]
-
-        self.auth.cleanup([self.achall])
-
-        self.auth._change_txt_record.assert_called_once_with("DELETE",
-                                                             
'_acme-challenge.'+DOMAIN,
-                                                             mock.ANY)
-
-    def test_cleanup_no_credentials_error(self):
-        self.auth._attempt_cleanup = True
-
-        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
-        side_effect=NoCredentialsError)
-
-        self.auth.cleanup([self.achall])
-
-    def test_cleanup_client_error(self):
-        self.auth._attempt_cleanup = True
-
-        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
-            side_effect=ClientError({"Error": {"Code": "foo"}}, "bar"))
-
-        self.auth.cleanup([self.achall])
-
-
-class ClientTest(unittest.TestCase):
-    # pylint: disable=protected-access
-
-    PRIVATE_ZONE = {
-                        "Id": "BAD-PRIVATE",
-                        "Name": "example.com",
-                        "Config": {
-                            "PrivateZone": True
-                        }
-                    }
-
-    EXAMPLE_NET_ZONE = {
-                            "Id": "BAD-WRONG-TLD",
-                            "Name": "example.net",
-                            "Config": {
-                                "PrivateZone": False
-                            }
-                        }
-
-    EXAMPLE_COM_ZONE = {
-                            "Id": "EXAMPLE",
-                            "Name": "example.com",
-                            "Config": {
-                                "PrivateZone": False
-                            }
-                        }
-
-    FOO_EXAMPLE_COM_ZONE = {
-                                "Id": "FOO",
-                                "Name": "foo.example.com",
-                                "Config": {
-                                    "PrivateZone": False
-                                }
-                            }
-
-    def setUp(self):
-        from certbot_dns_route53._internal.dns_route53 import Authenticator
-
-        self.config = mock.MagicMock()
-
-        # Set up dummy credentials for testing
-        os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key"
-        os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key"
-
-        self.client = Authenticator(self.config, "route53")
-
-    def tearDown(self):
-        # Remove the dummy credentials from env vars
-        del os.environ["AWS_ACCESS_KEY_ID"]
-        del os.environ["AWS_SECRET_ACCESS_KEY"]
-
-    def test_find_zone_id_for_domain(self):
-        self.client.r53.get_paginator = mock.MagicMock()
-        self.client.r53.get_paginator().paginate.return_value = [
-            {
-                "HostedZones": [
-                    self.EXAMPLE_NET_ZONE,
-                    self.EXAMPLE_COM_ZONE,
-                ]
-            }
-        ]
-
-        result = self.client._find_zone_id_for_domain("foo.example.com")
-        assert result == "EXAMPLE"
-
-    def test_find_zone_id_for_domain_pagination(self):
-        self.client.r53.get_paginator = mock.MagicMock()
-        self.client.r53.get_paginator().paginate.return_value = [
-            {
-                "HostedZones": [
-                    self.PRIVATE_ZONE,
-                    self.EXAMPLE_COM_ZONE,
-                ]
-            },
-            {
-                "HostedZones": [
-                    self.PRIVATE_ZONE,
-                    self.FOO_EXAMPLE_COM_ZONE,
-                ]
-            }
-        ]
-
-        result = self.client._find_zone_id_for_domain("foo.example.com")
-        assert result == "FOO"
-
-    def test_find_zone_id_for_domain_no_results(self):
-        self.client.r53.get_paginator = mock.MagicMock()
-        self.client.r53.get_paginator().paginate.return_value = []
-
-        with pytest.raises(errors.PluginError):
-            self.client._find_zone_id_for_domain("foo.example.com")
-
-    def test_find_zone_id_for_domain_no_correct_results(self):
-        self.client.r53.get_paginator = mock.MagicMock()
-        self.client.r53.get_paginator().paginate.return_value = [
-            {
-                "HostedZones": [
-                    self.PRIVATE_ZONE,
-                    self.EXAMPLE_NET_ZONE,
-                ]
-            },
-        ]
-
-        with pytest.raises(errors.PluginError):
-            self.client._find_zone_id_for_domain("foo.example.com")
-
-    def test_change_txt_record(self):
-        self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore 
[method-assign, unused-ignore]
-        self.client.r53.change_resource_record_sets = mock.MagicMock(
-            return_value={"ChangeInfo": {"Id": 1}})
-
-        self.client._change_txt_record("FOO", DOMAIN, "foo")
-
-        call_count = self.client.r53.change_resource_record_sets.call_count
-        assert call_count == 1
-
-    def test_change_txt_record_delete(self):
-        self.client._find_zone_id_for_domain = mock.MagicMock() # type: 
ignore[ method-assign, unused-ignore]
-        self.client.r53.change_resource_record_sets = mock.MagicMock(
-            return_value={"ChangeInfo": {"Id": 1}})
-
-        validation = "some-value"
-        validation_record = {"Value": '"{0}"'.format(validation)}
-        self.client._resource_records[DOMAIN] = [validation_record]
-
-        self.client._change_txt_record("DELETE", DOMAIN, validation)
-
-        call_count = self.client.r53.change_resource_record_sets.call_count
-        assert call_count == 1
-        call_args = 
self.client.r53.change_resource_record_sets.call_args_list[0][1]
-        call_args_batch = call_args["ChangeBatch"]["Changes"][0]
-        assert call_args_batch["Action"] == "DELETE"
-        assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \
-            [validation_record]
-
-    def test_change_txt_record_multirecord(self):
-        self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore 
[method-assign, unused-ignore]
-        self.client._resource_records[DOMAIN] = [
-            {"Value": "\"pre-existing-value\""},
-            {"Value": "\"pre-existing-value-two\""},
-        ]
-        self.client.r53.change_resource_record_sets = mock.MagicMock(
-            return_value={"ChangeInfo": {"Id": 1}})
-
-        self.client._change_txt_record("DELETE", DOMAIN, "pre-existing-value")
-
-        call_count = self.client.r53.change_resource_record_sets.call_count
-        call_args = 
self.client.r53.change_resource_record_sets.call_args_list[0][1]
-        call_args_batch = call_args["ChangeBatch"]["Changes"][0]
-        assert call_args_batch["Action"] == "UPSERT"
-        assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \
-            [{"Value": "\"pre-existing-value-two\""}]
-
-        assert call_count == 1
-
-    def test_wait_for_change(self):
-        self.client.r53.get_change = mock.MagicMock(
-            side_effect=[{"ChangeInfo": {"Status": "PENDING"}},
-                         {"ChangeInfo": {"Status": "INSYNC"}}])
-
-        self.client._wait_for_change("1")
-
-        assert self.client.r53.get_change.called
-
-
-if __name__ == "__main__":
-    sys.exit(pytest.main(sys.argv[1:] + [__file__]))  # pragma: no cover
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/PKG-INFO 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/PKG-INFO
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/PKG-INFO 
2025-04-08 00:03:41.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/PKG-INFO 
1970-01-01 01:00:00.000000000 +0100
@@ -1,48 +0,0 @@
-Metadata-Version: 2.4
-Name: certbot-dns-route53
-Version: 4.0.0
-Summary: Route53 DNS Authenticator plugin for Certbot
-Home-page: https://github.com/certbot/certbot
-Author: Certbot Project
-Author-email: certbot-...@eff.org
-License: Apache License 2.0
-Keywords: certbot,route53,aws
-Classifier: Development Status :: 5 - Production/Stable
-Classifier: Environment :: Plugins
-Classifier: Intended Audience :: System Administrators
-Classifier: License :: OSI Approved :: Apache Software License
-Classifier: Operating System :: POSIX :: Linux
-Classifier: Programming Language :: Python
-Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.9
-Classifier: Programming Language :: Python :: 3.10
-Classifier: Programming Language :: Python :: 3.11
-Classifier: Programming Language :: Python :: 3.12
-Classifier: Programming Language :: Python :: 3.13
-Classifier: Topic :: Internet :: WWW/HTTP
-Classifier: Topic :: Security
-Classifier: Topic :: System :: Installation/Setup
-Classifier: Topic :: System :: Networking
-Classifier: Topic :: System :: Systems Administration
-Classifier: Topic :: Utilities
-Requires-Python: >=3.9
-License-File: LICENSE.txt
-Requires-Dist: boto3>=1.15.15
-Requires-Dist: acme>=4.0.0
-Requires-Dist: certbot>=4.0.0
-Provides-Extra: docs
-Requires-Dist: Sphinx>=1.0; extra == "docs"
-Requires-Dist: sphinx_rtd_theme; extra == "docs"
-Provides-Extra: test
-Requires-Dist: pytest; extra == "test"
-Dynamic: author
-Dynamic: author-email
-Dynamic: classifier
-Dynamic: home-page
-Dynamic: keywords
-Dynamic: license
-Dynamic: license-file
-Dynamic: provides-extra
-Dynamic: requires-dist
-Dynamic: requires-python
-Dynamic: summary
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/SOURCES.txt 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/SOURCES.txt
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/SOURCES.txt      
2025-04-08 00:03:41.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/SOURCES.txt      
1970-01-01 01:00:00.000000000 +0100
@@ -1,22 +0,0 @@
-LICENSE.txt
-MANIFEST.in
-README.rst
-setup.py
-certbot_dns_route53/__init__.py
-certbot_dns_route53/py.typed
-certbot_dns_route53.egg-info/PKG-INFO
-certbot_dns_route53.egg-info/SOURCES.txt
-certbot_dns_route53.egg-info/dependency_links.txt
-certbot_dns_route53.egg-info/entry_points.txt
-certbot_dns_route53.egg-info/requires.txt
-certbot_dns_route53.egg-info/top_level.txt
-certbot_dns_route53/_internal/__init__.py
-certbot_dns_route53/_internal/dns_route53.py
-certbot_dns_route53/_internal/tests/__init__.py
-certbot_dns_route53/_internal/tests/dns_route53_test.py
-docs/.gitignore
-docs/Makefile
-docs/api.rst
-docs/conf.py
-docs/index.rst
-docs/make.bat
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/dependency_links.txt 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/dependency_links.txt
--- 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/dependency_links.txt 
    2025-04-08 00:03:41.000000000 +0200
+++ 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/dependency_links.txt 
    1970-01-01 01:00:00.000000000 +0100
@@ -1 +0,0 @@
-
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/entry_points.txt 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/entry_points.txt
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/entry_points.txt 
2025-04-08 00:03:41.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/entry_points.txt 
1970-01-01 01:00:00.000000000 +0100
@@ -1,3 +0,0 @@
-[certbot.plugins]
-certbot-route53:auth = 
certbot_dns_route53._internal.dns_route53:HiddenAuthenticator
-dns-route53 = certbot_dns_route53._internal.dns_route53:Authenticator
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/requires.txt 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/requires.txt
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/requires.txt     
2025-04-08 00:03:41.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/requires.txt     
1970-01-01 01:00:00.000000000 +0100
@@ -1,10 +0,0 @@
-boto3>=1.15.15
-acme>=4.0.0
-certbot>=4.0.0
-
-[docs]
-Sphinx>=1.0
-sphinx_rtd_theme
-
-[test]
-pytest
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/top_level.txt 
new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/top_level.txt
--- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/top_level.txt    
2025-04-08 00:03:41.000000000 +0200
+++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/top_level.txt    
1970-01-01 01:00:00.000000000 +0100
@@ -1 +0,0 @@
-certbot_dns_route53
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/certbot_dns_route53-4.0.0/setup.py 
new/certbot_dns_route53-4.1.1/setup.py
--- old/certbot_dns_route53-4.0.0/setup.py      2025-04-08 00:03:33.000000000 
+0200
+++ new/certbot_dns_route53-4.1.1/setup.py      2025-06-12 20:08:35.000000000 
+0200
@@ -4,7 +4,7 @@
 from setuptools import find_packages
 from setuptools import setup
 
-version = '4.0.0'
+version = '4.1.1'
 
 install_requires = [
     'boto3>=1.15.15',
@@ -38,7 +38,7 @@
     author="Certbot Project",
     author_email='certbot-...@eff.org',
     license='Apache License 2.0',
-    python_requires='>=3.9',
+    python_requires='>=3.9.2',
     classifiers=[
         'Development Status :: 5 - Production/Stable',
         'Environment :: Plugins',
@@ -59,7 +59,8 @@
         'Topic :: System :: Systems Administration',
         'Topic :: Utilities',
     ],
-    packages=find_packages(),
+    packages=find_packages(where='src'),
+    package_dir={'': 'src'},
     include_package_data=True,
     install_requires=install_requires,
     keywords=['certbot', 'route53', 'aws'],
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/__init__.py 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/__init__.py
--- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/__init__.py   
1970-01-01 01:00:00.000000000 +0100
+++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/__init__.py   
2025-06-12 20:08:34.000000000 +0200
@@ -0,0 +1,105 @@
+"""
+The `~certbot_dns_route53.dns_route53` plugin automates the process of
+completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and
+subsequently removing, TXT records using the Amazon Web Services Route 53 API.
+
+.. note::
+   The plugin is not installed by default. It can be installed by heading to
+   `certbot.eff.org <https://certbot.eff.org/instructions#wildcard>`_, 
choosing your system and
+   selecting the Wildcard tab.
+
+Credentials
+-----------
+Use of this plugin requires a configuration file containing Amazon Web Sevices
+API credentials for an account with the following permissions:
+
+* ``route53:ListHostedZones``
+* ``route53:GetChange``
+* ``route53:ChangeResourceRecordSets``
+
+These permissions can be captured in an AWS policy like the one below. Amazon
+provides `information about managing access 
<https://docs.aws.amazon.com/Route53
+/latest/DeveloperGuide/access-control-overview.html>`_ and `information about
+the required permissions <https://docs.aws.amazon.com/Route53/latest
+/DeveloperGuide/r53-api-permissions-ref.html>`_
+
+.. code-block:: json
+   :name: sample-aws-policy.json
+   :caption: Example AWS policy file:
+
+   {
+       "Version": "2012-10-17",
+       "Id": "certbot-dns-route53 sample policy",
+       "Statement": [
+           {
+               "Effect": "Allow",
+               "Action": [
+                   "route53:ListHostedZones",
+                   "route53:GetChange"
+               ],
+               "Resource": [
+                   "*"
+               ]
+           },
+           {
+               "Effect" : "Allow",
+               "Action" : [
+                   "route53:ChangeResourceRecordSets"
+               ],
+               "Resource" : [
+                   "arn:aws:route53:::hostedzone/YOURHOSTEDZONEID"
+               ]
+           }
+       ]
+   }
+
+The `access keys <https://docs.aws.amazon.com/general/latest/gr
+/aws-sec-cred-types.html#access-keys-and-secret-access-keys>`_ for an account
+with these permissions must be supplied in one of the following ways, which are
+discussed in more detail in the Boto3 library's documentation about 
`configuring
+credentials <https://boto3.readthedocs.io/en/latest/guide/configuration.html
+#best-practices-for-configuring-credentials>`_.
+
+* Using the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment
+  variables.
+* Using a credentials configuration file at the default location,
+  ``~/.aws/credentials``. If you're running on sudo, the credentials
+  will be picked up from the root home.
+* Using a credentials configuration file at a path supplied using the
+  ``AWS_CONFIG_FILE`` environment variable.
+
+.. code-block:: ini
+   :name: config.ini
+   :caption: Example credentials config file:
+
+   [default]
+   aws_access_key_id=AKIAIOSFODNN7EXAMPLE
+   aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
+
+.. caution::
+   You should protect these API credentials as you would a password. Users who
+   can read this file can use these credentials to issue some types of API 
calls
+   on your behalf, limited by the permissions assigned to the account. Users 
who
+   can cause Certbot to run using these credentials can complete a ``dns-01``
+   challenge to acquire new certificates or revoke existing certificates for
+   domains these credentials are authorized to manage.
+
+
+Examples
+--------
+.. code-block:: bash
+   :caption: To acquire a certificate for ``example.com``
+
+   certbot certonly \\
+     --dns-route53 \\
+     -d example.com
+
+.. code-block:: bash
+   :caption: To acquire a single certificate for both ``example.com`` and
+             ``www.example.com``
+
+   certbot certonly \\
+     --dns-route53 \\
+     -d example.com \\
+     -d www.example.com
+"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/__init__.py 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/__init__.py
--- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/__init__.py 
1970-01-01 01:00:00.000000000 +0100
+++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/__init__.py 
2025-06-12 20:08:34.000000000 +0200
@@ -0,0 +1 @@
+"""Internal implementation of `~certbot_dns_route53.dns_route53` plugin."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/dns_route53.py 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/dns_route53.py
--- 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/dns_route53.py  
    1970-01-01 01:00:00.000000000 +0100
+++ 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/dns_route53.py  
    2025-06-12 20:08:34.000000000 +0200
@@ -0,0 +1,191 @@
+"""Certbot Route53 authenticator plugin."""
+import collections
+import logging
+import time
+from typing import Any
+from typing import Callable
+from typing import DefaultDict
+from typing import Dict
+from typing import Iterable
+from typing import List
+from typing import Type
+from typing import cast
+
+import boto3
+from botocore.exceptions import ClientError
+from botocore.exceptions import NoCredentialsError
+
+from acme import challenges
+from certbot import achallenges
+from certbot import errors
+from certbot import interfaces
+from certbot.achallenges import AnnotatedChallenge
+from certbot.plugins import common
+
+logger = logging.getLogger(__name__)
+
+INSTRUCTIONS = (
+    "To use certbot-dns-route53, configure credentials as described at "
+    
"https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials
 "  # pylint: disable=line-too-long
+    "and add the necessary permissions for Route53 access.")
+
+
+class Authenticator(common.Plugin, interfaces.Authenticator):
+    """Route53 Authenticator
+
+    This authenticator solves a DNS01 challenge by uploading the answer to AWS
+    Route53.
+    """
+
+    description = ("Obtain certificates using a DNS TXT record (if you are 
using AWS Route53 for "
+                   "DNS).")
+    ttl = 10
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        super().__init__(*args, **kwargs)
+        self.r53 = boto3.client("route53")
+        self._attempt_cleanup = False
+        self._resource_records: DefaultDict[str, List[Dict[str, str]]] = \
+            collections.defaultdict(list)
+
+    def more_info(self) -> str:
+        return "Solve a DNS01 challenge using AWS Route53"
+
+    @classmethod
+    def add_parser_arguments(cls, add: Callable[..., None]) -> None:
+        # This authenticator currently adds no extra arguments.
+        pass
+
+    def auth_hint(self, failed_achalls: List[achallenges.AnnotatedChallenge]) 
-> str:
+        return (
+            'The Certificate Authority failed to verify the DNS TXT records 
created by '
+            '--dns-route53. Ensure the above domains have their DNS hosted by 
AWS Route53.'
+        )
+
+    def prepare(self) -> None:
+        pass
+
+    def get_chall_pref(self, unused_domain: str) -> 
Iterable[Type[challenges.Challenge]]:
+        return [challenges.DNS01]
+
+    def perform(self, achalls: List[AnnotatedChallenge]) -> 
List[challenges.ChallengeResponse]:
+        self._attempt_cleanup = True
+
+        try:
+            change_ids = [
+                self._change_txt_record("UPSERT",
+                  achall.validation_domain_name(achall.domain),
+                  achall.validation(achall.account_key))
+                for achall in achalls
+            ]
+
+            for change_id in change_ids:
+                self._wait_for_change(change_id)
+        except (NoCredentialsError, ClientError) as e:
+            logger.debug('Encountered error during perform: %s', e, 
exc_info=True)
+            raise errors.PluginError("\n".join([str(e), INSTRUCTIONS]))
+        return [achall.response(achall.account_key) for achall in achalls]
+
+    def cleanup(self, achalls: List[achallenges.AnnotatedChallenge]) -> None:
+        if self._attempt_cleanup:
+            for achall in achalls:
+                domain = achall.domain
+                validation_domain_name = achall.validation_domain_name(domain)
+                validation = achall.validation(achall.account_key)
+
+                self._cleanup(validation_domain_name, validation)
+
+    def _cleanup(self, validation_name: str, validation: str) -> None:
+        try:
+            self._change_txt_record("DELETE", validation_name, validation)
+        except (NoCredentialsError, ClientError) as e:
+            logger.debug('Encountered error during cleanup: %s', e, 
exc_info=True)
+
+    def _find_zone_id_for_domain(self, domain: str) -> str:
+        """Find the zone id responsible a given FQDN.
+
+           That is, the id for the zone whose name is the longest parent of the
+           domain.
+        """
+        paginator = self.r53.get_paginator("list_hosted_zones")
+        zones: list[tuple[str, str]] = []
+        target_labels = domain.rstrip(".").split(".")
+        for page in paginator.paginate():
+            for zone in page["HostedZones"]:
+                if zone["Config"]["PrivateZone"]:
+                    continue
+
+                candidate_labels = zone["Name"].rstrip(".").split(".")
+                if candidate_labels == target_labels[-len(candidate_labels):]:
+                    zones.append((zone["Name"], zone["Id"]))
+
+        if not zones:
+            raise errors.PluginError(
+                "Unable to find a Route53 hosted zone for {0}".format(domain)
+            )
+
+        # Order the zones that are suffixes for our desired to domain by
+        # length, this puts them in an order like:
+        # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"]
+        # And then we choose the first one, which will be the most specific.
+        zones.sort(key=lambda z: len(z[0]), reverse=True)
+        return zones[0][1]
+
+    def _change_txt_record(self, action: str, validation_domain_name: str, 
validation: str) -> str:
+        zone_id = self._find_zone_id_for_domain(validation_domain_name)
+
+        rrecords = self._resource_records[validation_domain_name]
+        challenge = {"Value": '"{0}"'.format(validation)}
+        if action == "DELETE":
+            # Remove the record being deleted from the list of tracked records
+            rrecords.remove(challenge)
+            if rrecords:
+                # Need to update instead, as we're not deleting the rrset
+                action = "UPSERT"
+            else:
+                # Create a new list containing the record to use with DELETE
+                rrecords = [challenge]
+        else:
+            rrecords.append(challenge)
+
+        response = self.r53.change_resource_record_sets(
+            HostedZoneId=zone_id,
+            ChangeBatch={
+                "Comment": "certbot-dns-route53 certificate validation " + 
action,
+                "Changes": [
+                    {
+                        "Action": action,
+                        "ResourceRecordSet": {
+                            "Name": validation_domain_name,
+                            "Type": "TXT",
+                            "TTL": self.ttl,
+                            "ResourceRecords": rrecords,
+                        }
+                    }
+                ]
+            }
+        )
+        return cast(str, response["ChangeInfo"]["Id"])
+
+    def _wait_for_change(self, change_id: str) -> None:
+        """Wait for a change to be propagated to all Route53 DNS servers.
+           
https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html
+        """
+        for unused_n in range(0, 120):
+            response = self.r53.get_change(Id=change_id)
+            if response["ChangeInfo"]["Status"] == "INSYNC":
+                return
+            time.sleep(5)
+        raise errors.PluginError(
+            "Timed out waiting for Route53 change. Current status: %s" %
+            response["ChangeInfo"]["Status"])
+
+
+# Our route53 plugin was initially a 3rd party plugin named 
`certbot-route53:auth` as described at
+# https://github.com/certbot/certbot/issues/4688. This shim exists to allow 
installations using the
+# old plugin name of `certbot-route53:auth` to continue to work without 
cluttering things like
+# Certbot's help output with two route53 plugins.
+class HiddenAuthenticator(Authenticator):
+    """A hidden shim around certbot-dns-route53 for backwards compatibility."""
+
+    hidden = True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/__init__.py
 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/__init__.py
--- 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/__init__.py
   1970-01-01 01:00:00.000000000 +0100
+++ 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/__init__.py
   2025-06-12 20:08:34.000000000 +0200
@@ -0,0 +1 @@
+"""certbot-dns-route53 tests"""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/dns_route53_test.py
 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/dns_route53_test.py
--- 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/dns_route53_test.py
   1970-01-01 01:00:00.000000000 +0100
+++ 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/dns_route53_test.py
   2025-06-12 20:08:34.000000000 +0200
@@ -0,0 +1,273 @@
+"""Tests for certbot_dns_route53._internal.dns_route53.Authenticator"""
+
+import sys
+import unittest
+from unittest import mock
+
+from botocore.exceptions import ClientError
+from botocore.exceptions import NoCredentialsError
+import josepy as jose
+import pytest
+
+from acme import challenges
+from certbot import achallenges
+from certbot import errors
+from certbot.compat import os
+from certbot.plugins.dns_test_common import DOMAIN
+from certbot.tests import acme_util
+from certbot.tests import util as test_util
+
+DOMAIN = 'example.com'
+KEY = jose.jwk.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
+
+
+class AuthenticatorTest(unittest.TestCase):
+    # pylint: disable=protected-access
+
+    achall = achallenges.KeyAuthorizationAnnotatedChallenge(
+        challb=acme_util.DNS01, domain=DOMAIN, account_key=KEY)
+
+    def setUp(self):
+        from certbot_dns_route53._internal.dns_route53 import Authenticator
+
+        super().setUp()
+
+        self.config = mock.MagicMock()
+
+        # Set up dummy credentials for testing
+        os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key"
+        os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key"
+
+        self.auth = Authenticator(self.config, "route53")
+
+    def tearDown(self):
+        # Remove the dummy credentials from env vars
+        del os.environ["AWS_ACCESS_KEY_ID"]
+        del os.environ["AWS_SECRET_ACCESS_KEY"]
+
+    def test_more_info(self) -> None:
+        self.assertTrue(isinstance(self.auth.more_info(), str))
+
+    def test_get_chall_pref(self) -> None:
+        self.assertEqual(self.auth.get_chall_pref("example.org"), 
[challenges.DNS01])
+
+    def test_perform(self):
+        self.auth._change_txt_record = mock.MagicMock() # type: 
ignore[method-assign, unused-ignore]
+        self.auth._wait_for_change = mock.MagicMock() # type: ignore 
[method-assign, unused-ignore]
+
+        self.auth.perform([self.achall])
+
+        self.auth._change_txt_record.assert_called_once_with("UPSERT",
+                                                             
'_acme-challenge.' + DOMAIN,
+                                                             mock.ANY)
+        assert self.auth._wait_for_change.call_count == 1
+
+    def test_perform_no_credentials_error(self):
+        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
+            side_effect=NoCredentialsError)
+
+        with pytest.raises(errors.PluginError):
+            self.auth.perform([self.achall])
+
+    def test_perform_client_error(self):
+        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
+            side_effect=ClientError({"Error": {"Code": "foo"}}, "bar"))
+
+        with pytest.raises(errors.PluginError):
+            self.auth.perform([self.achall])
+
+    def test_cleanup(self):
+        self.auth._attempt_cleanup = True
+
+        self.auth._change_txt_record = mock.MagicMock() # type: 
ignore[method-assign, unused-ignore]
+
+        self.auth.cleanup([self.achall])
+
+        self.auth._change_txt_record.assert_called_once_with("DELETE",
+                                                             
'_acme-challenge.'+DOMAIN,
+                                                             mock.ANY)
+
+    def test_cleanup_no_credentials_error(self):
+        self.auth._attempt_cleanup = True
+
+        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
+        side_effect=NoCredentialsError)
+
+        self.auth.cleanup([self.achall])
+
+    def test_cleanup_client_error(self):
+        self.auth._attempt_cleanup = True
+
+        self.auth._change_txt_record = mock.MagicMock( # type: ignore 
[method-assign, unused-ignore]
+            side_effect=ClientError({"Error": {"Code": "foo"}}, "bar"))
+
+        self.auth.cleanup([self.achall])
+
+
+class ClientTest(unittest.TestCase):
+    # pylint: disable=protected-access
+
+    PRIVATE_ZONE = {
+                        "Id": "BAD-PRIVATE",
+                        "Name": "example.com",
+                        "Config": {
+                            "PrivateZone": True
+                        }
+                    }
+
+    EXAMPLE_NET_ZONE = {
+                            "Id": "BAD-WRONG-TLD",
+                            "Name": "example.net",
+                            "Config": {
+                                "PrivateZone": False
+                            }
+                        }
+
+    EXAMPLE_COM_ZONE = {
+                            "Id": "EXAMPLE",
+                            "Name": "example.com",
+                            "Config": {
+                                "PrivateZone": False
+                            }
+                        }
+
+    FOO_EXAMPLE_COM_ZONE = {
+                                "Id": "FOO",
+                                "Name": "foo.example.com",
+                                "Config": {
+                                    "PrivateZone": False
+                                }
+                            }
+
+    def setUp(self):
+        from certbot_dns_route53._internal.dns_route53 import Authenticator
+
+        self.config = mock.MagicMock()
+
+        # Set up dummy credentials for testing
+        os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key"
+        os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key"
+
+        self.client = Authenticator(self.config, "route53")
+
+    def tearDown(self):
+        # Remove the dummy credentials from env vars
+        del os.environ["AWS_ACCESS_KEY_ID"]
+        del os.environ["AWS_SECRET_ACCESS_KEY"]
+
+    def test_find_zone_id_for_domain(self):
+        self.client.r53.get_paginator = mock.MagicMock()
+        self.client.r53.get_paginator().paginate.return_value = [
+            {
+                "HostedZones": [
+                    self.EXAMPLE_NET_ZONE,
+                    self.EXAMPLE_COM_ZONE,
+                ]
+            }
+        ]
+
+        result = self.client._find_zone_id_for_domain("foo.example.com")
+        assert result == "EXAMPLE"
+
+    def test_find_zone_id_for_domain_pagination(self):
+        self.client.r53.get_paginator = mock.MagicMock()
+        self.client.r53.get_paginator().paginate.return_value = [
+            {
+                "HostedZones": [
+                    self.PRIVATE_ZONE,
+                    self.EXAMPLE_COM_ZONE,
+                ]
+            },
+            {
+                "HostedZones": [
+                    self.PRIVATE_ZONE,
+                    self.FOO_EXAMPLE_COM_ZONE,
+                ]
+            }
+        ]
+
+        result = self.client._find_zone_id_for_domain("foo.example.com")
+        assert result == "FOO"
+
+    def test_find_zone_id_for_domain_no_results(self):
+        self.client.r53.get_paginator = mock.MagicMock()
+        self.client.r53.get_paginator().paginate.return_value = []
+
+        with pytest.raises(errors.PluginError):
+            self.client._find_zone_id_for_domain("foo.example.com")
+
+    def test_find_zone_id_for_domain_no_correct_results(self):
+        self.client.r53.get_paginator = mock.MagicMock()
+        self.client.r53.get_paginator().paginate.return_value = [
+            {
+                "HostedZones": [
+                    self.PRIVATE_ZONE,
+                    self.EXAMPLE_NET_ZONE,
+                ]
+            },
+        ]
+
+        with pytest.raises(errors.PluginError):
+            self.client._find_zone_id_for_domain("foo.example.com")
+
+    def test_change_txt_record(self):
+        self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore 
[method-assign, unused-ignore]
+        self.client.r53.change_resource_record_sets = mock.MagicMock(
+            return_value={"ChangeInfo": {"Id": 1}})
+
+        self.client._change_txt_record("FOO", DOMAIN, "foo")
+
+        call_count = self.client.r53.change_resource_record_sets.call_count
+        assert call_count == 1
+
+    def test_change_txt_record_delete(self):
+        self.client._find_zone_id_for_domain = mock.MagicMock() # type: 
ignore[ method-assign, unused-ignore]
+        self.client.r53.change_resource_record_sets = mock.MagicMock(
+            return_value={"ChangeInfo": {"Id": 1}})
+
+        validation = "some-value"
+        validation_record = {"Value": '"{0}"'.format(validation)}
+        self.client._resource_records[DOMAIN] = [validation_record]
+
+        self.client._change_txt_record("DELETE", DOMAIN, validation)
+
+        call_count = self.client.r53.change_resource_record_sets.call_count
+        assert call_count == 1
+        call_args = 
self.client.r53.change_resource_record_sets.call_args_list[0][1]
+        call_args_batch = call_args["ChangeBatch"]["Changes"][0]
+        assert call_args_batch["Action"] == "DELETE"
+        assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \
+            [validation_record]
+
+    def test_change_txt_record_multirecord(self):
+        self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore 
[method-assign, unused-ignore]
+        self.client._resource_records[DOMAIN] = [
+            {"Value": "\"pre-existing-value\""},
+            {"Value": "\"pre-existing-value-two\""},
+        ]
+        self.client.r53.change_resource_record_sets = mock.MagicMock(
+            return_value={"ChangeInfo": {"Id": 1}})
+
+        self.client._change_txt_record("DELETE", DOMAIN, "pre-existing-value")
+
+        call_count = self.client.r53.change_resource_record_sets.call_count
+        call_args = 
self.client.r53.change_resource_record_sets.call_args_list[0][1]
+        call_args_batch = call_args["ChangeBatch"]["Changes"][0]
+        assert call_args_batch["Action"] == "UPSERT"
+        assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \
+            [{"Value": "\"pre-existing-value-two\""}]
+
+        assert call_count == 1
+
+    def test_wait_for_change(self):
+        self.client.r53.get_change = mock.MagicMock(
+            side_effect=[{"ChangeInfo": {"Status": "PENDING"}},
+                         {"ChangeInfo": {"Status": "INSYNC"}}])
+
+        self.client._wait_for_change("1")
+
+        assert self.client.r53.get_change.called
+
+
+if __name__ == "__main__":
+    sys.exit(pytest.main(sys.argv[1:] + [__file__]))  # pragma: no cover
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/PKG-INFO 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/PKG-INFO
--- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/PKG-INFO     
1970-01-01 01:00:00.000000000 +0100
+++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/PKG-INFO     
2025-06-12 20:08:43.000000000 +0200
@@ -0,0 +1,48 @@
+Metadata-Version: 2.4
+Name: certbot-dns-route53
+Version: 4.1.1
+Summary: Route53 DNS Authenticator plugin for Certbot
+Home-page: https://github.com/certbot/certbot
+Author: Certbot Project
+Author-email: certbot-...@eff.org
+License: Apache License 2.0
+Keywords: certbot,route53,aws
+Classifier: Development Status :: 5 - Production/Stable
+Classifier: Environment :: Plugins
+Classifier: Intended Audience :: System Administrators
+Classifier: License :: OSI Approved :: Apache Software License
+Classifier: Operating System :: POSIX :: Linux
+Classifier: Programming Language :: Python
+Classifier: Programming Language :: Python :: 3
+Classifier: Programming Language :: Python :: 3.9
+Classifier: Programming Language :: Python :: 3.10
+Classifier: Programming Language :: Python :: 3.11
+Classifier: Programming Language :: Python :: 3.12
+Classifier: Programming Language :: Python :: 3.13
+Classifier: Topic :: Internet :: WWW/HTTP
+Classifier: Topic :: Security
+Classifier: Topic :: System :: Installation/Setup
+Classifier: Topic :: System :: Networking
+Classifier: Topic :: System :: Systems Administration
+Classifier: Topic :: Utilities
+Requires-Python: >=3.9.2
+License-File: LICENSE.txt
+Requires-Dist: boto3>=1.15.15
+Requires-Dist: acme>=4.1.1
+Requires-Dist: certbot>=4.1.1
+Provides-Extra: docs
+Requires-Dist: Sphinx>=1.0; extra == "docs"
+Requires-Dist: sphinx_rtd_theme; extra == "docs"
+Provides-Extra: test
+Requires-Dist: pytest; extra == "test"
+Dynamic: author
+Dynamic: author-email
+Dynamic: classifier
+Dynamic: home-page
+Dynamic: keywords
+Dynamic: license
+Dynamic: license-file
+Dynamic: provides-extra
+Dynamic: requires-dist
+Dynamic: requires-python
+Dynamic: summary
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/SOURCES.txt 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/SOURCES.txt
--- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/SOURCES.txt  
1970-01-01 01:00:00.000000000 +0100
+++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/SOURCES.txt  
2025-06-12 20:08:43.000000000 +0200
@@ -0,0 +1,22 @@
+LICENSE.txt
+MANIFEST.in
+README.rst
+setup.py
+docs/.gitignore
+docs/Makefile
+docs/api.rst
+docs/conf.py
+docs/index.rst
+docs/make.bat
+src/certbot_dns_route53/__init__.py
+src/certbot_dns_route53/py.typed
+src/certbot_dns_route53.egg-info/PKG-INFO
+src/certbot_dns_route53.egg-info/SOURCES.txt
+src/certbot_dns_route53.egg-info/dependency_links.txt
+src/certbot_dns_route53.egg-info/entry_points.txt
+src/certbot_dns_route53.egg-info/requires.txt
+src/certbot_dns_route53.egg-info/top_level.txt
+src/certbot_dns_route53/_internal/__init__.py
+src/certbot_dns_route53/_internal/dns_route53.py
+src/certbot_dns_route53/_internal/tests/__init__.py
+src/certbot_dns_route53/_internal/tests/dns_route53_test.py
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/dependency_links.txt
 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/dependency_links.txt
--- 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/dependency_links.txt
 1970-01-01 01:00:00.000000000 +0100
+++ 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/dependency_links.txt
 2025-06-12 20:08:43.000000000 +0200
@@ -0,0 +1 @@
+
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/entry_points.txt 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/entry_points.txt
--- 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/entry_points.txt 
    1970-01-01 01:00:00.000000000 +0100
+++ 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/entry_points.txt 
    2025-06-12 20:08:43.000000000 +0200
@@ -0,0 +1,3 @@
+[certbot.plugins]
+certbot-route53:auth = 
certbot_dns_route53._internal.dns_route53:HiddenAuthenticator
+dns-route53 = certbot_dns_route53._internal.dns_route53:Authenticator
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/requires.txt 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/requires.txt
--- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/requires.txt 
1970-01-01 01:00:00.000000000 +0100
+++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/requires.txt 
2025-06-12 20:08:43.000000000 +0200
@@ -0,0 +1,10 @@
+boto3>=1.15.15
+acme>=4.1.1
+certbot>=4.1.1
+
+[docs]
+Sphinx>=1.0
+sphinx_rtd_theme
+
+[test]
+pytest
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/top_level.txt 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/top_level.txt
--- 
old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/top_level.txt    
    1970-01-01 01:00:00.000000000 +0100
+++ 
new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/top_level.txt    
    2025-06-12 20:08:43.000000000 +0200
@@ -0,0 +1 @@
+certbot_dns_route53

Reply via email to