diff --git a/CHANGES.rst b/CHANGES.rst index 49a8d92b..4f8bb80f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,10 @@ Changes ------- + +2.24.2 (2025-08-26) +^^^^^^^^^^^^^^^^^^^ +* bump botocore dependency specification + 2.24.1 (2025-08-15) ^^^^^^^^^^^^^^^^^^^ * fix endpoint circular import error diff --git a/aiobotocore/__init__.py b/aiobotocore/__init__.py index 57a522ce..64841a4f 100644 --- a/aiobotocore/__init__.py +++ b/aiobotocore/__init__.py @@ -1 +1 @@ -__version__ = '2.24.1' +__version__ = '2.24.2' diff --git a/aiobotocore/credentials.py b/aiobotocore/credentials.py index acb24bd4..605c97e1 100644 --- a/aiobotocore/credentials.py +++ b/aiobotocore/credentials.py @@ -5,8 +5,9 @@ from copy import deepcopy import botocore.compat +import dateutil.parser from botocore import UNSIGNED -from botocore.compat import compat_shell_split +from botocore.compat import compat_shell_split, total_seconds from botocore.config import Config from botocore.credentials import ( _DEFAULT_ADVISORY_REFRESH_TIMEOUT, @@ -1048,7 +1049,16 @@ async def _get_credentials(self): initial_token_data = self._token_provider.load_token() token = (await initial_token_data.get_frozen_token()).token else: - token = self._token_loader(self._start_url)['accessToken'] + token_dict = self._token_loader(self._start_url) + token = token_dict['accessToken'] + + # raise an UnauthorizedSSOTokenError if the loaded legacy token + # is expired to save a call to GetRoleCredentials with an + # expired token. + expiration = dateutil.parser.parse(token_dict['expiresAt']) + remaining = total_seconds(expiration - self._time_fetcher()) + if remaining <= 0: + raise UnauthorizedSSOTokenError() kwargs = { 'roleName': self._role_name, diff --git a/aiobotocore/discovery.py b/aiobotocore/discovery.py index b9ac6b3f..08b2dffb 100644 --- a/aiobotocore/discovery.py +++ b/aiobotocore/discovery.py @@ -33,7 +33,8 @@ async def describe_endpoint(self, **kwargs): if not self._always_discover and not discovery_required: # Discovery set to only run on required operations logger.debug( - f'Optional discovery disabled. Skipping discovery for Operation: {operation}' + 'Optional discovery disabled. Skipping discovery for Operation: %s', + operation, ) return None diff --git a/aiobotocore/httpchecksum.py b/aiobotocore/httpchecksum.py index e46aa810..7c3b29f9 100644 --- a/aiobotocore/httpchecksum.py +++ b/aiobotocore/httpchecksum.py @@ -196,8 +196,8 @@ async def handle_checksum_body( return logger.debug( - f'Skipping checksum validation. Response did not contain one of the ' - f'following algorithms: {algorithms}.' + 'Skipping checksum validation. Response did not contain one of the following algorithms: %s.', + algorithms, ) diff --git a/aiobotocore/regions.py b/aiobotocore/regions.py index 04120d01..0cccf6b4 100644 --- a/aiobotocore/regions.py +++ b/aiobotocore/regions.py @@ -27,7 +27,7 @@ async def construct_endpoint( operation_model, call_args, request_context ) LOG.debug( - f'Calling endpoint provider with parameters: {provider_params}' + 'Calling endpoint provider with parameters: %s', provider_params ) try: provider_result = self._provider.resolve_endpoint( @@ -41,10 +41,14 @@ async def construct_endpoint( raise else: raise botocore_exception from ex - LOG.debug(f'Endpoint provider result: {provider_result.url}') + LOG.debug('Endpoint provider result: %s', provider_result.url) # The endpoint provider does not support non-secure transport. - if not self._use_ssl and provider_result.url.startswith('https://'): + if ( + not self._use_ssl + and provider_result.url.startswith('https://') + and 'Endpoint' not in provider_params + ): provider_result = provider_result._replace( url=f'http://{provider_result.url[8:]}' ) diff --git a/aiobotocore/session.py b/aiobotocore/session.py index 957e5cbe..7f08bc5e 100644 --- a/aiobotocore/session.py +++ b/aiobotocore/session.py @@ -191,8 +191,9 @@ async def _create_client( aws_session_token, aws_account_id ): logger.debug( - f"Ignoring the following credential-related values which were set without " - f"an access key id and secret key on the session or client: {ignored_credentials}" + "Ignoring the following credential-related values which were set without " + "an access key id and secret key on the session or client: %s", + ignored_credentials, ) credentials = await self.get_credentials() auth_token = self.get_auth_token() diff --git a/aiobotocore/signers.py b/aiobotocore/signers.py index c33647b7..912e45d1 100644 --- a/aiobotocore/signers.py +++ b/aiobotocore/signers.py @@ -2,6 +2,7 @@ import botocore import botocore.auth +from botocore.compat import get_current_datetime from botocore.exceptions import ParamValidationError, UnknownClientMethodError from botocore.signers import ( RequestSigner, @@ -157,6 +158,10 @@ async def get_auth_instance( return auth credentials = request_credentials or self._credentials + if credentials and ( + cred_method := getattr(credentials, 'method', None) + ): + self.check_and_register_feature_id(cred_method) if getattr(cls, "REQUIRES_IDENTITY_CACHE", None) is True: cache = kwargs["identity_cache"] key = kwargs["cache_key"] @@ -368,7 +373,7 @@ async def generate_presigned_post( policy = {} # Create an expiration date for the policy - datetime_now = datetime.datetime.utcnow() + datetime_now = get_current_datetime() expire_date = datetime_now + datetime.timedelta(seconds=expires_in) policy['expiration'] = expire_date.strftime(botocore.auth.ISO8601) diff --git a/aiobotocore/tokens.py b/aiobotocore/tokens.py index 6daaee91..3945274e 100644 --- a/aiobotocore/tokens.py +++ b/aiobotocore/tokens.py @@ -125,7 +125,7 @@ async def _refresh_access_token(self, token): expiry = dateutil.parser.parse(token["registrationExpiresAt"]) if total_seconds(expiry - self._now()) <= 0: - logger.info(f"SSO token registration expired at {expiry}") + logger.info("SSO token registration expired at %s", expiry) return None try: @@ -137,10 +137,10 @@ async def _refresh_access_token(self, token): async def _refresher(self): start_url = self._sso_config["sso_start_url"] session_name = self._sso_config["session_name"] - logger.info(f"Loading cached SSO token for {session_name}") + logger.info("Loading cached SSO token for %s", session_name) token_dict = self._token_loader(start_url, session_name=session_name) expiration = dateutil.parser.parse(token_dict["expiresAt"]) - logger.debug(f"Cached SSO token expires at {expiration}") + logger.debug("Cached SSO token expires at %s", expiration) remaining = total_seconds(expiration - self._now()) if remaining < self._REFRESH_WINDOW: diff --git a/aiobotocore/utils.py b/aiobotocore/utils.py index b375e612..ea3a4c05 100644 --- a/aiobotocore/utils.py +++ b/aiobotocore/utils.py @@ -486,16 +486,21 @@ async def redirect_from_error( if new_region is None: logger.debug( - f"S3 client configured for region {client_region} but the " - f"bucket {bucket} is not in that region and the proper region " - "could not be automatically determined." + "S3 client configured for region %s but the " + "bucket %s is not in that region and the proper region " + "could not be automatically determined.", + client_region, + bucket, ) return logger.debug( - f"S3 client configured for region {client_region} but the bucket {bucket} " - f"is in region {new_region}; Please configure the proper region to " - f"avoid multiple unnecessary redirects and signing attempts." + "S3 client configured for region %s but the bucket %s " + "is in region %s; Please configure the proper region to " + "avoid multiple unnecessary redirects and signing attempts.", + client_region, + bucket, + new_region, ) # Adding the new region to _cache will make construct_endpoint() to # use the new region as value for the AWS::Region builtin parameter. @@ -622,16 +627,21 @@ async def redirect_from_error( if new_region is None: logger.debug( - f"S3 client configured for region {client_region} but the bucket {bucket} is not " + "S3 client configured for region %s but the bucket %s is not " "in that region and the proper region could not be " - "automatically determined." + "automatically determined.", + client_region, + bucket, ) return logger.debug( - f"S3 client configured for region {client_region} but the bucket {bucket} is in region" - f" {new_region}; Please configure the proper region to avoid multiple " - "unnecessary redirects and signing attempts." + "S3 client configured for region %s but the bucket %s is in region" + " %s; Please configure the proper region to avoid multiple " + "unnecessary redirects and signing attempts.", + client_region, + bucket, + new_region, ) endpoint = self._endpoint_resolver.resolve('s3', new_region) endpoint = endpoint['endpoint_url'] diff --git a/pyproject.toml b/pyproject.toml index eb68c9a4..ab0ccafa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dynamic = ["version", "readme"] dependencies = [ "aiohttp >= 3.9.2, < 4.0.0", "aioitertools >= 0.5.1, < 1.0.0", - "botocore >= 1.39.9, < 1.39.12", # NOTE: When updating, always keep `project.optional-dependencies` aligned + "botocore >= 1.40.15, < 1.40.19", # NOTE: When updating, always keep `project.optional-dependencies` aligned "python-dateutil >= 2.1, < 3.0.0", "jmespath >= 0.7.1, < 2.0.0", "multidict >= 6.0.0, < 7.0.0", @@ -41,10 +41,10 @@ dependencies = [ [project.optional-dependencies] awscli = [ - "awscli >= 1.41.9, < 1.41.12", + "awscli >= 1.42.15, < 1.42.19", ] boto3 = [ - "boto3 >= 1.39.9, < 1.39.12", + "boto3 >= 1.40.15, < 1.40.19", ] httpx = [ "httpx >= 0.25.1, < 0.29" @@ -145,10 +145,17 @@ indent-width = 4 target-version = "py39" [tool.ruff.lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or # McCabe complexity (`C901`) by default. -select = ["E4", "E7", "E9", "F", "I", "UP"] +select = [ + "E4", # pycodestyle + "E7", # pycodestyle + "E9", # pycodestyle + "F", # Pyflakes + "I", # Import sorting + "UP", # PyUpgrade + "G", # Log formatting +] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/tests/botocore_tests/unit/test_credentials.py b/tests/botocore_tests/unit/test_credentials.py index d6149dd7..c8f3826c 100644 --- a/tests/botocore_tests/unit/test_credentials.py +++ b/tests/botocore_tests/unit/test_credentials.py @@ -1089,14 +1089,18 @@ async def ssl_credential_fetcher_setup(): self.start_url = 'https://d-92671207e4.awsapps.com/start' self.role_name = 'test-role' self.account_id = '1234567890' - self.access_token = 'some.sso.token' + self.access_token = { + 'accessToken': 'some.sso.token', + 'expiresAt': '2018-10-18T22:26:40Z', + } # This is just an arbitrary point in time we can pin to self.now = datetime(2008, 9, 23, 12, 26, 40, tzinfo=tzutc()) # The SSO endpoint uses ms whereas the OIDC endpoint uses seconds self.now_timestamp = 1222172800000 + self.mock_time_fetcher = mock.Mock(return_value=self.now) self.loader = mock.Mock(spec=SSOTokenLoader) - self.loader.return_value = {'accessToken': self.access_token} + self.loader.return_value = self.access_token self.fetcher = AioSSOCredentialFetcher( self.start_url, self.sso_region, @@ -1105,11 +1109,13 @@ async def ssl_credential_fetcher_setup(): self.mock_session.create_client, token_loader=self.loader, cache=self.cache, + time_fetcher=self.mock_time_fetcher, ) tc = TestCase() self.assertEqual = tc.assertEqual self.assertRaises = tc.assertRaises + self.assertFalse = tc.assertFalse yield self @@ -1292,7 +1298,7 @@ async def test_sso_credential_fetcher_can_fetch_credentials( expected_params = { 'roleName': self.role_name, 'accountId': self.account_id, - 'accessToken': self.access_token, + 'accessToken': self.access_token['accessToken'], } expected_response = { 'roleCredentials': { @@ -1334,7 +1340,7 @@ async def test_sso_cred_fetcher_raises_helpful_message_on_unauthorized_exception expected_params = { 'roleName': self.role_name, 'accountId': self.account_id, - 'accessToken': self.access_token, + 'accessToken': self.access_token['accessToken'], } self.stubber.add_client_error( 'get_role_credentials', @@ -1346,6 +1352,31 @@ async def test_sso_cred_fetcher_raises_helpful_message_on_unauthorized_exception await self.fetcher.fetch_credentials() +async def test_sso_cred_fetcher_expired_legacy_token_has_expected_behavior( + ssl_credential_fetcher_setup, +): + self = ssl_credential_fetcher_setup + # Mock the current time to be in the future after the access token has expired + now = datetime(2018, 10, 19, 12, 26, 40, tzinfo=tzutc()) + mock_client = mock.AsyncMock() + create_mock_client = mock.Mock(return_value=mock_client) + fetcher = AioSSOCredentialFetcher( + self.start_url, + self.sso_region, + self.role_name, + self.account_id, + create_mock_client, + token_loader=self.loader, + cache=self.cache, + time_fetcher=mock.Mock(return_value=now), + ) + # since the cached token is expired, an UnauthorizedSSOTokenError should be + # raised and GetRoleCredentials should not be called. + with self.assertRaises(botocore.exceptions.UnauthorizedSSOTokenError): + await fetcher.fetch_credentials() + self.assertFalse(mock_client.get_role_credentials.called) + + # from TestSSOProvider @pytest.fixture async def sso_provider_setup(): diff --git a/tests/botocore_tests/unit/test_signers.py b/tests/botocore_tests/unit/test_signers.py index cb002c41..2d7756be 100644 --- a/tests/botocore_tests/unit/test_signers.py +++ b/tests/botocore_tests/unit/test_signers.py @@ -44,7 +44,7 @@ async def test_signers_generate_db_auth_token(rds_client): clock = datetime.datetime(2016, 11, 7, 17, 39, 33, tzinfo=timezone.utc) with mock.patch('datetime.datetime') as dt: - dt.utcnow.return_value = clock + dt.now.return_value = clock result = await aiobotocore.signers.generate_db_auth_token( rds_client, hostname, port, username ) diff --git a/tests/test_patches.py b/tests/test_patches.py index 5e26b78c..9e3618ff 100644 --- a/tests/test_patches.py +++ b/tests/test_patches.py @@ -407,7 +407,7 @@ def test_protocol_parsers(): ( SSOCredentialFetcher._get_credentials, { - '13ac3b73e0745dfeaa934a8873179ca6c22a164f', + 'd15db30acdb7295a055e89b6c3bc077847e9b37c', }, ), ( @@ -907,8 +907,7 @@ def test_protocol_parsers(): ( EndpointRulesetResolver.construct_endpoint, { - 'ccbed61e316a0e92e1d0f67c554ee15efa4ee6b8', - 'ab22bb1ec171713e548567fbe84dd88a3d5f4b76', + '05e4f37e807b57bf9ff37dbe870308b684c62c02', }, ), ( @@ -928,7 +927,7 @@ def test_protocol_parsers(): ( StreamingBody, { - 'e6f0cb3b61c8b0a7c6961e77949e27c520b30a5c', + 'e358f72191c4c1cb37d8fe90871342abf79afde2', }, ), ( @@ -1028,13 +1027,13 @@ def test_protocol_parsers(): ( RequestSigner.get_auth, { - '13e90d57d536179621ac012ace97e4c2cbaa096e', + '10ba1f446244906daf170e68dbd1f13c54ec2d93', }, ), ( RequestSigner.get_auth_instance, { - '13e90d57d536179621ac012ace97e4c2cbaa096e', + '10ba1f446244906daf170e68dbd1f13c54ec2d93', }, ), ( @@ -1065,8 +1064,7 @@ def test_protocol_parsers(): ( S3PostPresigner.generate_presigned_post, { - '269efc9af054a2fd2728d5b0a27db82c48053d7f', - '48418dc6c9b04fdc8689c7cb5b6eb987321a84e3', + '01c61dc0f33392a19def738afc634f5def401e32', }, ), ( @@ -1158,13 +1156,13 @@ def test_protocol_parsers(): ( SSOTokenProvider._refresh_access_token, { - 'cb179d1f262e41cc03a7c218e624e8c7fbeeaf19', + '6263c009e6d86011ca1226d61ea95163bbfca258', }, ), ( SSOTokenProvider._refresher, { - '824d41775dbb8a05184f6e9c7b2ea7202b72f2a9', + 'd89b3446344826e5b740840c12fd9279810c45d2', }, ), ( @@ -1285,7 +1283,7 @@ def test_protocol_parsers(): ( S3RegionRedirectorv2.redirect_from_error, { - '8e3003ec881c7eab0945fe4b6e021ca488fbcd78', + '2a715115e94bddcea4cce936bf7c7013f1f6ecdf', }, ), ( @@ -1297,8 +1295,7 @@ def test_protocol_parsers(): ( S3RegionRedirector.redirect_from_error, { - '3863b2c6472513b7896bfccc9dfd2567c472f441', - 'e1d93a4a85dfbfa810b9249da0b22ce14744b99d', + '5db904d0311db5c875aed0f6a78278f173ca40a7', }, ), ( @@ -1428,8 +1425,7 @@ def test_protocol_parsers(): ( EndpointDiscoveryManager.describe_endpoint, { - 'b2f1b29177cf30f299e61b85ddec09eaa070e54e', - 'cbd237b874daef01cf7be82fef30516557ba17f9', + '2d7c40eec571a14e6e3968b710c23677ae3685e6', }, ), ( @@ -1524,7 +1520,7 @@ def test_protocol_parsers(): ( handle_checksum_body, { - '040cb48d8ebfb5ca195d41deb55b38d1fcb489f8', + 'f019114f7fc3a4e200157b9689ed8a1cc9d72825', }, ), ( @@ -1644,6 +1640,7 @@ def test_protocol_parsers(): { 'bccf23c3733cc656b909f5130cba80dbc9540b05', '7c01f505134b5ea3f4886e2288ea7f389577efd5', + '0ff1c068779d3e8a84c4da0655cfdf5861fe1b2c', }, ), ], diff --git a/uv.lock b/uv.lock index 0132aacd..cdeba720 100644 --- a/uv.lock +++ b/uv.lock @@ -56,9 +56,9 @@ dev = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.9.2,<4.0.0" }, { name = "aioitertools", specifier = ">=0.5.1,<1.0.0" }, - { name = "awscli", marker = "extra == 'awscli'", specifier = ">=1.41.9,<1.41.12" }, - { name = "boto3", marker = "extra == 'boto3'", specifier = ">=1.39.9,<1.39.12" }, - { name = "botocore", specifier = ">=1.39.9,<1.39.12" }, + { name = "awscli", marker = "extra == 'awscli'", specifier = ">=1.42.15,<1.42.19" }, + { name = "boto3", marker = "extra == 'boto3'", specifier = ">=1.40.15,<1.40.19" }, + { name = "botocore", specifier = ">=1.40.15,<1.40.19" }, { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.25.1,<0.29" }, { name = "jmespath", specifier = ">=0.7.1,<2.0.0" }, { name = "multidict", specifier = ">=6.0.0,<7.0.0" }, @@ -306,7 +306,7 @@ wheels = [ [[package]] name = "awscli" -version = "1.41.11" +version = "1.42.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, @@ -316,9 +316,9 @@ dependencies = [ { name = "rsa" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/06/a56e9677ddb6271af16b8f4a54d460684a3346b8ec84ab454d1c78f0af7e/awscli-1.41.11.tar.gz", hash = "sha256:22201a49e81e22c027fbd0eb40105a96344be371ffe79b4eec7ff2668c58641a", size = 1911975, upload-time = "2025-07-22T19:26:46.611Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/21/59e3b2a824225c510285f62083ff7430f1853b804d7d2af07e30544c5cf3/awscli-1.42.18.tar.gz", hash = "sha256:2226a399788a58f94bad96811e26a4b3e92a4ddd08f0e345a826a7ef655ee76e", size = 1917849, upload-time = "2025-08-26T19:21:33.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/f4/514f692d4968fdbae622a17564b63046be2f9dfbec8b50cf14c80f51d391/awscli-1.41.11-py3-none-any.whl", hash = "sha256:e96387dad4b35a4e6bb20c6287cb3d701eb8438ecdf3edd081b19453435b8df6", size = 4714304, upload-time = "2025-07-22T19:26:44.712Z" }, + { url = "https://files.pythonhosted.org/packages/39/19/e01eed489aca8636facbb8cb79a2d2443e3a43519254430778ca03692c8b/awscli-1.42.18-py3-none-any.whl", hash = "sha256:957038eb39aafb4fe87aeb47f9331dfbfb641616c56550ea1f7547d41d486ed0", size = 4728425, upload-time = "2025-08-26T19:21:29.33Z" }, ] [[package]] @@ -332,21 +332,21 @@ wheels = [ [[package]] name = "boto3" -version = "1.39.11" +version = "1.40.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/2e/ed75ea3ee0fd1afacc3379bc2b7457c67a6b0f0e554e1f7ccbdbaed2351b/boto3-1.39.11.tar.gz", hash = "sha256:3027edf20642fe1d5f9dc50a420d0fe2733073ed6a9f0f047b60fe08c3682132", size = 111869, upload-time = "2025-07-22T19:26:50.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/35/a30dc21ca6582358e0ce963f38e85d42ea619f12e7be4101a834c21d749d/boto3-1.40.18.tar.gz", hash = "sha256:64301d39adecc154e3e595eaf0d4f28998ef0a5551f1d033aeac51a9e1a688e5", size = 111994, upload-time = "2025-08-26T19:21:38.61Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/66/88566a6484e746c0b075f7c9bb248e8548eda0a486de4460d150a41e2d57/boto3-1.39.11-py3-none-any.whl", hash = "sha256:af8f1dad35eceff7658fab43b39b0f55892b6e3dd12308733521cc24dd2c9a02", size = 139900, upload-time = "2025-07-22T19:26:48.706Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b5/3fc1802eb24aef135c3ba69fff2a9bfcc6a7a8258fb396706b1a6a44de36/boto3-1.40.18-py3-none-any.whl", hash = "sha256:daa776ba1251a7458c9d6c7627873d0c2460c8e8272d35759065580e9193700a", size = 140076, upload-time = "2025-08-26T19:21:36.484Z" }, ] [[package]] name = "botocore" -version = "1.39.11" +version = "1.40.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, @@ -354,9 +354,9 @@ dependencies = [ { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/d0/9d64261186cff650fe63168441edb4f4cd33f085a74c0c54455630a71f91/botocore-1.39.11.tar.gz", hash = "sha256:953b12909d6799350e346ab038e55b6efe622c616f80aef74d7a6683ffdd972c", size = 14217749, upload-time = "2025-07-22T19:26:40.723Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/91/2e745382793fa7d30810a7d5ca3e05f6817b6db07601ca5aaab12720caf9/botocore-1.40.18.tar.gz", hash = "sha256:afd69bdadd8c55cc89d69de0799829e555193a352d87867f746e19020271cc0f", size = 14375007, upload-time = "2025-08-26T19:21:24.996Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/2c/8a0b02d60a1dbbae7faa5af30484b016aa3023f9833dfc0d19b0b770dd6a/botocore-1.39.11-py3-none-any.whl", hash = "sha256:1545352931a8a186f3e977b1e1a4542d7d434796e274c3c62efd0210b5ea76dc", size = 13876276, upload-time = "2025-07-22T19:26:35.164Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/bd57bf21fdcc4e500cc406ed2c296e626ddd160f0fee2a4932256e5d62d8/botocore-1.40.18-py3-none-any.whl", hash = "sha256:57025c46ca00cf8cec25de07a759521bfbfb3036a0f69b272654a354615dc45f", size = 14039935, upload-time = "2025-08-26T19:21:19.085Z" }, ] [[package]]