Skip to content

Commit 273b52b

Browse files
Merge pull request #144 from NHSDigital/feature/dh-MESH-2092-deps
MESH-2092 updated deps; added s3_ls tests
2 parents 4ff8316 + e18af25 commit 273b52b

File tree

7 files changed

+441
-230
lines changed

7 files changed

+441
-230
lines changed

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
poetry 1.5.1
1+
poetry 1.8.5
22
python 3.10.12 3.8.12 3.9.12 3.11.5

nhs_aws_helpers/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
from mypy_boto3_lambda.client import LambdaClient
6161
from mypy_boto3_logs.client import CloudWatchLogsClient
6262
from mypy_boto3_s3.client import S3Client
63-
from mypy_boto3_s3.service_resource import Bucket, Object, S3ServiceResource
63+
from mypy_boto3_s3.service_resource import Bucket, Object, ObjectSummary, ObjectVersion, S3ServiceResource
6464
from mypy_boto3_s3.type_defs import (
6565
CompletedPartTypeDef,
6666
DeleteMarkerEntryTypeDef,
@@ -203,7 +203,7 @@ def register_retry_handler(
203203

204204
retry_quota = RetryQuotaChecker(quota.RetryQuota())
205205

206-
max_attempts = client.meta.config.retries.get("total_max_attempts", DEFAULT_MAX_ATTEMPTS)
206+
max_attempts = client.meta.config.retries.get("total_max_attempts", DEFAULT_MAX_ATTEMPTS) # type: ignore[attr-defined]
207207

208208
service_id = client.meta.service_model.service_id
209209
service_event_name = service_id.hyphenize()
@@ -228,12 +228,12 @@ def register_retry_handler(
228228
# Re-register with our own handler
229229
client.meta.events.register(
230230
f"needs-retry.{service_event_name}",
231-
handler.needs_retry,
231+
handler.needs_retry, # type: ignore[arg-type]
232232
unique_id=unique_id,
233233
)
234234

235235
def on_response_received(**kwargs):
236-
if on_error is not None and kwargs.get("exception") or kwargs.get("parsed_response", {}).get("Error"):
236+
if (on_error is not None and kwargs.get("exception")) or kwargs.get("parsed_response", {}).get("Error"):
237237
assert on_error
238238
on_error(**kwargs)
239239

@@ -413,7 +413,7 @@ def s3_get_all_keys(
413413
page_iterator = paginator.paginate(Bucket=bucket, Prefix=prefix)
414414
keys = []
415415
for page in page_iterator:
416-
keys.extend([content["Key"] for content in page["Contents"]])
416+
keys.extend([content["Key"] for content in page.get("Contents", [])])
417417

418418
return keys
419419

@@ -524,7 +524,7 @@ def s3_ls(
524524
versioning: bool = False,
525525
session: Optional[Session] = None,
526526
config: Optional[Config] = None,
527-
) -> Generator[Object, None, None]:
527+
) -> Generator[Union[ObjectSummary, ObjectVersion], None, None]:
528528
_, bucket, path = s3_split_path(uri)
529529

530530
yield from s3_list_bucket(
@@ -540,7 +540,7 @@ def s3_list_bucket(
540540
versioning: bool = False,
541541
session: Optional[Session] = None,
542542
config: Optional[Config] = None,
543-
) -> Generator[Object, None, None]:
543+
) -> Generator[Union[ObjectSummary, ObjectVersion], None, None]:
544544
"""list contents of S3 bucket based on filter criteria and versioning flag
545545
546546
Args:
@@ -553,7 +553,7 @@ def s3_list_bucket(
553553
config (Config): optional botocore config
554554
555555
Returns:
556-
Generator[object, None, None]: resulting objects or versions
556+
Generator[object, None, None]: resulting objects (ObjectSummary) or versions (ObjectVersion)
557557
"""
558558
buck = s3_bucket(bucket, session=session, config=config)
559559
bc_objects = buck.object_versions if versioning else buck.objects

nhs_aws_helpers/dynamodb_model_store/base_model_store.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def table_key_fields(self) -> List[str]:
133133

134134
@classmethod
135135
def _deserialise_field(cls, field: dataclasses.Field, value: Any, **kwargs) -> Any:
136-
return cls.deserialise_value(field.type, value, **kwargs)
136+
return cls.deserialise_value(cast(type, field.type), value, **kwargs)
137137

138138
@classmethod
139139
def deserialise_value(cls, value_type: type, value: Any, **kwargs) -> Any: # noqa: C901

nhs_aws_helpers/fixtures.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,21 @@
1212
ddb_table,
1313
dynamodb,
1414
events_client,
15+
s3_client,
16+
s3_delete_all_versions,
1517
s3_resource,
1618
sqs_resource,
1719
)
1820

1921
__all__ = [
20-
"temp_s3_bucket_session_fixture",
21-
"temp_s3_bucket_fixture",
22-
"temp_event_bus_fixture",
23-
"temp_queue_fixture",
24-
"temp_fifo_queue_fixture",
2522
"clone_schema",
2623
"temp_dynamodb_table",
24+
"temp_event_bus_fixture",
25+
"temp_fifo_queue_fixture",
26+
"temp_queue_fixture",
27+
"temp_s3_bucket_fixture",
28+
"temp_s3_bucket_session_fixture",
29+
"temp_versioned_s3_bucket_fixture",
2730
]
2831

2932

@@ -38,11 +41,13 @@ def temp_s3_bucket_session_fixture() -> Generator[Bucket, None, None]:
3841

3942
bucket_name = f"temp-{petname.generate()}"
4043
bucket = resource.create_bucket(
41-
Bucket=bucket_name, CreateBucketConfiguration=CreateBucketConfigurationTypeDef(LocationConstraint="eu-west-2")
44+
Bucket=bucket_name,
45+
CreateBucketConfiguration=CreateBucketConfigurationTypeDef(LocationConstraint="eu-west-2"),
4246
)
4347
yield bucket
4448

45-
bucket.objects.all().delete()
49+
s3_delete_all_versions(bucket.name, "", dry_run=False)
50+
4651
bucket.delete()
4752

4853

@@ -61,6 +66,29 @@ def temp_s3_bucket_fixture(session_temp_s3_bucket: Bucket) -> Bucket:
6166
return bucket
6267

6368

69+
@pytest.fixture(name="temp_versioned_s3_bucket")
70+
def temp_versioned_s3_bucket_fixture(session_temp_s3_bucket: Bucket) -> Bucket:
71+
"""
72+
yields a temporary s3 bucket for use in unit tests
73+
74+
Returns:
75+
Bucket: a temporary empty s3 bucket
76+
"""
77+
bucket = session_temp_s3_bucket
78+
79+
s3_client().put_bucket_versioning(
80+
Bucket=bucket.name,
81+
VersioningConfiguration={
82+
"MFADelete": "Disabled",
83+
"Status": "Enabled",
84+
},
85+
)
86+
87+
bucket.objects.all().delete()
88+
89+
return bucket
90+
91+
6492
@pytest.fixture(name="temp_event_bus")
6593
def temp_event_bus_fixture() -> Generator[Tuple[Queue, str], None, None]:
6694
"""

poetry.lock

Lines changed: 264 additions & 207 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ repository = "https://github.com/NHSDigital/nhs-aws-helpers"
1414
[tool.poetry.dependencies]
1515
# core dependencies
1616
python = ">=3.8,<4.0"
17-
boto3 = "^1.35.29"
18-
boto3-stubs = {extras = ["s3", "ssm", "secretsmanager", "dynamodb", "stepfunctions", "sqs", "lambda", "logs", "ses", "sns", "events", "kms", "firehose", "athena"], version = "^1.35.29"}
19-
botocore-stubs = "^1.35.30"
17+
boto3 = "^1.35.93"
18+
boto3-stubs = {extras = ["s3", "ssm", "secretsmanager", "dynamodb", "stepfunctions", "sqs", "lambda", "logs", "ses", "sns", "events", "kms", "firehose", "athena"], version = "^1.35.93"}
19+
botocore-stubs = "^1.35.93"
2020

2121

2222
[tool.setuptools.package-data]
@@ -81,7 +81,6 @@ lint.select = [
8181
]
8282
src = ["."]
8383
lint.ignore = [
84-
"PT004"
8584
]
8685
exclude = [
8786
".git",

tests/aws_tests.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import logging
22
import os
3-
from typing import Any, List
3+
from typing import Any, List, cast
44
from uuid import uuid4
55

66
import pytest
77
from botocore.config import Config
88
from botocore.exceptions import ClientError
9-
from mypy_boto3_s3.service_resource import Bucket
9+
from mypy_boto3_s3.service_resource import Bucket, ObjectSummary, ObjectVersion
1010
from pytest_httpserver import HTTPServer
1111

1212
from nhs_aws_helpers import (
1313
dynamodb_retry_backoff,
1414
post_create_client,
1515
register_config_default,
1616
register_retry_handler,
17+
s3_build_uri,
1718
s3_client,
19+
s3_delete_keys,
20+
s3_get_all_keys,
1821
s3_list_folders,
22+
s3_ls,
1923
s3_resource,
2024
s3_upload_multipart_from_copy,
2125
transaction_cancellation_reasons,
@@ -59,6 +63,129 @@ def fail_me():
5963
assert len(calls) == 3
6064

6165

66+
def test_s3_delete_keys_all(temp_s3_bucket: Bucket):
67+
68+
keys = [str(key) for key in range(1003)]
69+
70+
for key in keys:
71+
temp_s3_bucket.put_object(Key=key, Body=f"Some data {uuid4().hex}".encode())
72+
73+
deleted_keys = s3_delete_keys(keys, temp_s3_bucket.name)
74+
75+
remaining_keys = list(s3_get_all_keys(temp_s3_bucket.name, ""))
76+
77+
assert len(deleted_keys) == len(keys)
78+
assert set(deleted_keys) == set(keys)
79+
assert len(remaining_keys) == 0
80+
81+
82+
def test_s3_delete_keys_partial(temp_s3_bucket: Bucket):
83+
84+
keys = [str(key) for key in range(1003)]
85+
86+
for key in keys:
87+
temp_s3_bucket.put_object(Key=key, Body=f"Some data {uuid4().hex}".encode())
88+
89+
leaving_keys = [keys.pop(i) for i in (1002, 456, 3)]
90+
91+
deleted_keys = s3_delete_keys(keys, temp_s3_bucket.name)
92+
93+
remaining_keys = list(s3_get_all_keys(temp_s3_bucket.name, ""))
94+
95+
assert len(deleted_keys) == len(keys)
96+
assert set(deleted_keys) == set(keys)
97+
assert len(remaining_keys) == len(leaving_keys)
98+
assert set(remaining_keys) == set(leaving_keys)
99+
100+
101+
@pytest.mark.parametrize(
102+
"keys",
103+
[(), ("a",), ("a/b",), ("a/b", "a/c")],
104+
)
105+
def test_s3_get_all_keys(temp_s3_bucket: Bucket, keys: List[str]):
106+
for key in keys:
107+
temp_s3_bucket.put_object(Key=key, Body=f"Some data {uuid4().hex}".encode())
108+
109+
result_keys = list(s3_get_all_keys(temp_s3_bucket.name, ""))
110+
111+
assert len(result_keys) == len(keys)
112+
assert set(result_keys) == set(keys)
113+
114+
115+
@pytest.mark.parametrize(
116+
"keys",
117+
[(), ("a",), ("a/b",), ("a/b", "a/c")],
118+
)
119+
def test_s3_get_all_keys_under_prefix(temp_s3_bucket: Bucket, keys: List[str]):
120+
expected_folder = uuid4().hex
121+
122+
# Keys that shouldn't be included
123+
for key in ("x", "y/z"):
124+
temp_s3_bucket.put_object(Key=key, Body=f"Some data {uuid4().hex}".encode())
125+
126+
for key in keys:
127+
temp_s3_bucket.put_object(Key=f"{expected_folder}/{key}", Body=f"Some data {uuid4().hex}".encode())
128+
129+
result_keys = list(s3_get_all_keys(temp_s3_bucket.name, expected_folder))
130+
131+
assert len(result_keys) == len(keys)
132+
assert set(result_keys) == {f"{expected_folder}/{key}" for key in keys}
133+
134+
135+
def test_s3_ls(temp_s3_bucket: Bucket):
136+
expected_folder = uuid4().hex
137+
temp_s3_bucket.put_object(Key=f"{expected_folder}/1/a.txt", Body=f"Some data {uuid4().hex}".encode())
138+
temp_s3_bucket.put_object(Key=f"{expected_folder}/1/b.txt", Body=f"Some data {uuid4().hex}".encode())
139+
temp_s3_bucket.put_object(Key=f"{expected_folder}/2/c.txt", Body=f"Some data {uuid4().hex}".encode())
140+
141+
files = cast(List[ObjectSummary], list(s3_ls(s3_build_uri(temp_s3_bucket.name, expected_folder))))
142+
143+
assert len(files) == 3
144+
assert {f.key for f in files} == {
145+
f"{expected_folder}/1/a.txt",
146+
f"{expected_folder}/1/b.txt",
147+
f"{expected_folder}/2/c.txt",
148+
}
149+
150+
151+
def test_s3_ls_versioning_on_non_versioned_bucket(temp_s3_bucket: Bucket):
152+
expected_folder = uuid4().hex
153+
temp_s3_bucket.put_object(Key=f"{expected_folder}/1/a.txt", Body=f"Some data {uuid4().hex}".encode())
154+
temp_s3_bucket.put_object(Key=f"{expected_folder}/1/b.txt", Body=f"Some data {uuid4().hex}".encode())
155+
temp_s3_bucket.put_object(Key=f"{expected_folder}/2/c.txt", Body=f"Some data {uuid4().hex}".encode())
156+
temp_s3_bucket.put_object(Key=f"{expected_folder}/2/c.txt", Body=f"Some new data {uuid4().hex}".encode())
157+
158+
files = cast(List[ObjectVersion], list(s3_ls(s3_build_uri(temp_s3_bucket.name, expected_folder), versioning=True)))
159+
160+
assert len(files) == 3
161+
assert {f.key for f in files} == {
162+
f"{expected_folder}/1/a.txt",
163+
f"{expected_folder}/1/b.txt",
164+
f"{expected_folder}/2/c.txt",
165+
}
166+
assert len({f.id for f in files}) == 1
167+
168+
169+
def test_s3_ls_versioning(temp_versioned_s3_bucket: Bucket):
170+
expected_folder = uuid4().hex
171+
temp_versioned_s3_bucket.put_object(Key=f"{expected_folder}/1/a.txt", Body=f"Some data {uuid4().hex}".encode())
172+
temp_versioned_s3_bucket.put_object(Key=f"{expected_folder}/1/b.txt", Body=f"Some data {uuid4().hex}".encode())
173+
temp_versioned_s3_bucket.put_object(Key=f"{expected_folder}/2/c.txt", Body=f"Some data {uuid4().hex}".encode())
174+
temp_versioned_s3_bucket.put_object(Key=f"{expected_folder}/2/c.txt", Body=f"Some new data {uuid4().hex}".encode())
175+
176+
files = cast(
177+
List[ObjectVersion], list(s3_ls(s3_build_uri(temp_versioned_s3_bucket.name, expected_folder), versioning=True))
178+
)
179+
180+
assert len(files) == 4
181+
assert {f.key for f in files} == {
182+
f"{expected_folder}/1/a.txt",
183+
f"{expected_folder}/1/b.txt",
184+
f"{expected_folder}/2/c.txt",
185+
}
186+
assert len({f.id for f in files}) == 4
187+
188+
62189
def test_s3_list_folders_root(temp_s3_bucket: Bucket):
63190
expected_folder = uuid4().hex
64191

0 commit comments

Comments
 (0)