Skip to content

Commit f8af3d0

Browse files
authored
Merge pull request #1221 from jakob-keller/bump-botocore
Bump `botocore` dependency specification
2 parents f7e5acb + a73f8c2 commit f8af3d0

File tree

12 files changed

+342
-35
lines changed

12 files changed

+342
-35
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Changes
22
-------
33

4+
2.16.0 (2024-12-16)
5+
^^^^^^^^^^^^^^^^^^^
6+
* bump botocore dependency specification
7+
48
2.15.2 (2024-10-09)
59
^^^^^^^^^^^^^^^^^^^
610
* relax botocore dependency specification

aiobotocore/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '2.15.2'
1+
__version__ = '2.16.0'

aiobotocore/handlers.py

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from botocore.handlers import (
22
ETree,
3-
XMLParseError,
43
_get_cross_region_presigned_url,
54
_get_presigned_url_source_and_destination_regions,
5+
_looks_like_special_case_error,
66
logger,
77
)
88

99

1010
async def check_for_200_error(response, **kwargs):
11+
"""This function has been deprecated, but is kept for backwards compatibility."""
1112
# From: http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectCOPY.html
1213
# There are two opportunities for a copy request to return an error. One
1314
# can occur when Amazon S3 receives the copy request and the other can
@@ -28,7 +29,9 @@ async def check_for_200_error(response, **kwargs):
2829
# trying to retrieve the response. See Endpoint._get_response().
2930
return
3031
http_response, parsed = response
31-
if await _looks_like_special_case_error(http_response):
32+
if _looks_like_special_case_error(
33+
http_response.status_code, await http_response.content
34+
):
3235
logger.debug(
3336
"Error found for response with 200 status code, "
3437
"errors: %s, changing status code to "
@@ -38,24 +41,6 @@ async def check_for_200_error(response, **kwargs):
3841
http_response.status_code = 500
3942

4043

41-
async def _looks_like_special_case_error(http_response):
42-
if http_response.status_code == 200:
43-
try:
44-
parser = ETree.XMLParser(
45-
target=ETree.TreeBuilder(), encoding='utf-8'
46-
)
47-
parser.feed(await http_response.content)
48-
root = parser.close()
49-
except XMLParseError:
50-
# In cases of network disruptions, we may end up with a partial
51-
# streamed response from S3. We need to treat these cases as
52-
# 500 Service Errors and try again.
53-
return True
54-
if root.tag == 'Error':
55-
return True
56-
return False
57-
58-
5944
async def inject_presigned_url_ec2(params, request_signer, model, **kwargs):
6045
# The customer can still provide this, so we should pass if they do.
6146
if 'PresignedUrl' in params['body']:

aiobotocore/hooks.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from botocore.handlers import check_for_200_error as boto_check_for_200_error
21
from botocore.handlers import (
32
inject_presigned_url_ec2 as boto_inject_presigned_url_ec2,
43
)
@@ -9,6 +8,9 @@
98
parse_get_bucket_location as boto_parse_get_bucket_location,
109
)
1110
from botocore.hooks import HierarchicalEmitter, logger
11+
from botocore.signers import (
12+
add_dsql_generate_db_auth_token_methods as boto_add_dsql_generate_db_auth_token_methods,
13+
)
1214
from botocore.signers import (
1315
add_generate_db_auth_token as boto_add_generate_db_auth_token,
1416
)
@@ -21,12 +23,12 @@
2123

2224
from ._helpers import resolve_awaitable
2325
from .handlers import (
24-
check_for_200_error,
2526
inject_presigned_url_ec2,
2627
inject_presigned_url_rds,
2728
parse_get_bucket_location,
2829
)
2930
from .signers import (
31+
add_dsql_generate_db_auth_token_methods,
3032
add_generate_db_auth_token,
3133
add_generate_presigned_post,
3234
add_generate_presigned_url,
@@ -39,7 +41,7 @@
3941
boto_add_generate_presigned_post: add_generate_presigned_post,
4042
boto_add_generate_db_auth_token: add_generate_db_auth_token,
4143
boto_parse_get_bucket_location: parse_get_bucket_location,
42-
boto_check_for_200_error: check_for_200_error,
44+
boto_add_dsql_generate_db_auth_token_methods: add_dsql_generate_db_auth_token_methods,
4345
}
4446

4547

aiobotocore/signers.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import botocore
44
import botocore.auth
5-
from botocore.exceptions import UnknownClientMethodError
5+
from botocore.exceptions import ParamValidationError, UnknownClientMethodError
66
from botocore.signers import (
77
RequestSigner,
88
S3PostPresigner,
@@ -200,6 +200,15 @@ def add_generate_db_auth_token(class_attributes, **kwargs):
200200
class_attributes['generate_db_auth_token'] = generate_db_auth_token
201201

202202

203+
def add_dsql_generate_db_auth_token_methods(class_attributes, **kwargs):
204+
class_attributes['generate_db_connect_auth_token'] = (
205+
dsql_generate_db_connect_auth_token
206+
)
207+
class_attributes['generate_db_connect_admin_auth_token'] = (
208+
dsql_generate_db_connect_admin_auth_token
209+
)
210+
211+
203212
async def generate_db_auth_token(
204213
self, DBHostname, Port, DBUsername, Region=None
205214
):
@@ -256,6 +265,86 @@ async def generate_db_auth_token(
256265
return presigned_url[len(scheme) :]
257266

258267

268+
async def _dsql_generate_db_auth_token(
269+
self, Hostname, Action, Region=None, ExpiresIn=900
270+
):
271+
"""Generate a DSQL database token for an arbitrary action.
272+
:type Hostname: str
273+
:param Hostname: The DSQL endpoint host name.
274+
:type Action: str
275+
:param Action: Action to perform on the cluster (DbConnectAdmin or DbConnect).
276+
:type Region: str
277+
:param Region: The AWS region where the DSQL Cluster is hosted. If None, the client region will be used.
278+
:type ExpiresIn: int
279+
:param ExpiresIn: The token expiry duration in seconds (default is 900 seconds).
280+
:return: A presigned url which can be used as an auth token.
281+
"""
282+
possible_actions = ("DbConnect", "DbConnectAdmin")
283+
284+
if Action not in possible_actions:
285+
raise ParamValidationError(
286+
report=f"Received {Action} for action but expected one of: {', '.join(possible_actions)}"
287+
)
288+
289+
if Region is None:
290+
Region = self.meta.region_name
291+
292+
request_dict = {
293+
'url_path': '/',
294+
'query_string': '',
295+
'headers': {},
296+
'body': {
297+
'Action': Action,
298+
},
299+
'method': 'GET',
300+
}
301+
scheme = 'https://'
302+
endpoint_url = f'{scheme}{Hostname}'
303+
prepare_request_dict(request_dict, endpoint_url)
304+
presigned_url = await self._request_signer.generate_presigned_url(
305+
operation_name=Action,
306+
request_dict=request_dict,
307+
region_name=Region,
308+
expires_in=ExpiresIn,
309+
signing_name='dsql',
310+
)
311+
return presigned_url[len(scheme) :]
312+
313+
314+
async def dsql_generate_db_connect_auth_token(
315+
self, Hostname, Region=None, ExpiresIn=900
316+
):
317+
"""Generate a DSQL database token for the "DbConnect" action.
318+
:type Hostname: str
319+
:param Hostname: The DSQL endpoint host name.
320+
:type Region: str
321+
:param Region: The AWS region where the DSQL Cluster is hosted. If None, the client region will be used.
322+
:type ExpiresIn: int
323+
:param ExpiresIn: The token expiry duration in seconds (default is 900 seconds).
324+
:return: A presigned url which can be used as an auth token.
325+
"""
326+
return await _dsql_generate_db_auth_token(
327+
self, Hostname, "DbConnect", Region, ExpiresIn
328+
)
329+
330+
331+
async def dsql_generate_db_connect_admin_auth_token(
332+
self, Hostname, Region=None, ExpiresIn=900
333+
):
334+
"""Generate a DSQL database token for the "DbConnectAdmin" action.
335+
:type Hostname: str
336+
:param Hostname: The DSQL endpoint host name.
337+
:type Region: str
338+
:param Region: The AWS region where the DSQL Cluster is hosted. If None, the client region will be used.
339+
:type ExpiresIn: int
340+
:param ExpiresIn: The token expiry duration in seconds (default is 900 seconds).
341+
:return: A presigned url which can be used as an auth token.
342+
"""
343+
return await _dsql_generate_db_auth_token(
344+
self, Hostname, "DbConnectAdmin", Region, ExpiresIn
345+
)
346+
347+
259348
class AioS3PostPresigner(S3PostPresigner):
260349
async def generate_presigned_post(
261350
self,

aiobotocore/utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,13 +461,18 @@ async def redirect_from_error(
461461
0
462462
].status_code in (301, 302, 307)
463463
is_permanent_redirect = error_code == 'PermanentRedirect'
464+
is_opt_in_region_redirect = (
465+
error_code == 'IllegalLocationConstraintException'
466+
and operation.name != 'CreateBucket'
467+
)
464468
if not any(
465469
[
466470
is_special_head_object,
467471
is_wrong_signing_region,
468472
is_permanent_redirect,
469473
is_special_head_bucket,
470474
is_redirect_status,
475+
is_opt_in_region_redirect,
471476
]
472477
):
473478
return

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,18 @@ classifiers = [
3030
dynamic = ["version", "readme"]
3131

3232
dependencies = [
33-
"botocore >=1.35.16, <1.35.37", # NOTE: When updating, always keep `project.optional-dependencies` aligned
33+
"botocore >=1.35.74, <1.35.82", # NOTE: When updating, always keep `project.optional-dependencies` aligned
3434
"aiohttp >=3.9.2, <4.0.0",
3535
"wrapt >=1.10.10, <2.0.0",
3636
"aioitertools >=0.5.1, <1.0.0",
3737
]
3838

3939
[project.optional-dependencies]
4040
awscli = [
41-
"awscli >=1.34.16, <1.35.3",
41+
"awscli >=1.36.15, <1.36.23",
4242
]
4343
boto3 = [
44-
"boto3 >=1.35.16, <1.35.37",
44+
"boto3 >=1.35.74, <1.35.82",
4545
]
4646

4747
[project.urls]

requirements-dev.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ docker~=7.1
2222
moto[server,s3,sqs,awslambda,dynamodb,cloudformation,sns,batch,ec2,rds]~=4.2.9
2323
pre-commit~=3.5.0
2424
pytest-asyncio~=0.23.8
25+
time-machine~=2.15.0
2526
tomli; python_version < "3.11" # Requirement for tests/test_version.py

tests/boto_tests/__init__.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright 2012-2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
14+
from botocore.compat import parse_qs, urlparse
15+
16+
17+
def _urlparse(url):
18+
if isinstance(url, bytes):
19+
# Not really necessary, but it helps to reduce noise on Python 2.x
20+
url = url.decode('utf8')
21+
return urlparse(url)
22+
23+
24+
def assert_url_equal(url1, url2):
25+
parts1 = _urlparse(url1)
26+
parts2 = _urlparse(url2)
27+
28+
# Because the query string ordering isn't relevant, we have to parse
29+
# every single part manually and then handle the query string.
30+
assert parts1.scheme == parts2.scheme
31+
assert parts1.netloc == parts2.netloc
32+
assert parts1.path == parts2.path
33+
assert parts1.params == parts2.params
34+
assert parts1.fragment == parts2.fragment
35+
assert parts1.username == parts2.username
36+
assert parts1.password == parts2.password
37+
assert parts1.hostname == parts2.hostname
38+
assert parts1.port == parts2.port
39+
assert parse_qs(parts1.query) == parse_qs(parts2.query)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright 2012-2014 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
14+
from unittest import mock
15+
16+
from aiobotocore import handlers
17+
18+
19+
class TestHandlers:
20+
async def test_500_status_code_set_for_200_response(self):
21+
http_response = mock.Mock()
22+
http_response.status_code = 200
23+
24+
async def content():
25+
return """
26+
<Error>
27+
<Code>AccessDenied</Code>
28+
<Message>Access Denied</Message>
29+
<RequestId>id</RequestId>
30+
<HostId>hostid</HostId>
31+
</Error>
32+
"""
33+
34+
http_response.content = content()
35+
await handlers.check_for_200_error((http_response, {}))
36+
assert http_response.status_code == 500
37+
38+
async def test_200_response_with_no_error_left_untouched(self):
39+
http_response = mock.Mock()
40+
http_response.status_code = 200
41+
42+
async def content():
43+
return "<NotAnError></NotAnError>"
44+
45+
http_response.content = content()
46+
await handlers.check_for_200_error((http_response, {}))
47+
# We don't touch the status code since there are no errors present.
48+
assert http_response.status_code == 200
49+
50+
async def test_500_response_can_be_none(self):
51+
# A 500 response can raise an exception, which means the response
52+
# object is None. We need to handle this case.
53+
await handlers.check_for_200_error(None)

0 commit comments

Comments
 (0)