Skip to content

Commit 4a5332a

Browse files
committed
Merge branch 'morosi-feat' into 'master'
Add bucket context and improve e3.aws.s3.S3 See merge request it/e3-aws!54
2 parents 087d145 + 868d8a3 commit 4a5332a

File tree

2 files changed

+206
-1
lines changed

2 files changed

+206
-1
lines changed

src/e3/aws/s3/__init__.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
from __future__ import annotations
22
from typing import TYPE_CHECKING
3+
import os
34
import logging
45
from botocore.exceptions import ClientError
6+
from contextlib import contextmanager
7+
import botocore
8+
import boto3
59

610
if TYPE_CHECKING:
711
from typing import Any
12+
from collections.abc import Iterable, Iterator
813

914
logger = logging.getLogger("e3.aws.s3")
1015

1116

17+
class BucketExistsError(Exception):
18+
"""Exception when a bucket already exists."""
19+
20+
def __init__(self, bucket: str) -> None:
21+
"""Initialize BucketExistsError.
22+
23+
:param bucket: name of the bucket
24+
"""
25+
self.bucket = bucket
26+
27+
1228
class KeyExistsError(Exception):
1329
"""Exception when a key already exists."""
1430

@@ -47,6 +63,42 @@ def __init__(
4763
self.client = client
4864
self.bucket = bucket
4965

66+
def create_bucket(self, *, exist_ok: bool = True) -> None:
67+
"""Create the bucket.
68+
69+
:param exist_ok: don't raise an exception if the bucket already exists
70+
:raises BucketExistsError: if the bucket already exists
71+
"""
72+
try:
73+
params: dict[str, Any] = {}
74+
75+
# us-east-1 is the default location
76+
region = self.client.meta.region_name
77+
if region != "us-east-1":
78+
params["CreateBucketConfiguration"] = {"LocationConstraint": region}
79+
80+
self.client.create_bucket(Bucket=self.bucket, **params)
81+
except ClientError as error:
82+
# Raise any non already exists error
83+
if error.response["Error"]["Code"] not in [
84+
"BucketAlreadyExists",
85+
"BucketAlreadyOwnedByYou",
86+
]:
87+
raise
88+
89+
if not exist_ok:
90+
raise BucketExistsError(self.bucket) from error
91+
92+
def clear_bucket(self) -> None:
93+
"""Clear objects from S3."""
94+
for obj in list(self.iterate()):
95+
self.client.delete_object(Bucket=self.bucket, Key=obj["Key"])
96+
97+
def delete_bucket(self) -> None:
98+
"""Clear and delete the bucket."""
99+
self.clear_bucket()
100+
self.client.delete_bucket(Bucket=self.bucket)
101+
50102
def push(self, key: str, content: bytes, exist_ok: bool | None = None) -> None:
51103
"""Push content to S3.
52104
@@ -93,9 +145,84 @@ def get(self, key: str, default: bytes | None = None) -> bytes:
93145
raise KeyNotFoundError(key) from e
94146
raise e
95147

148+
def iterate(self, *, prefix: str | None = None) -> Iterable[dict[str, Any]]:
149+
"""Iterate all objects from S3.
150+
151+
:param prefix: limit to objects with that prefix
152+
:return: an iterator over objects from S3
153+
"""
154+
params = {"Bucket": self.bucket}
155+
156+
if prefix is not None:
157+
params["Prefix"] = prefix
158+
159+
paginator = self.client.get_paginator("list_objects_v2")
160+
for page in paginator.paginate(**params):
161+
for content in page.get("Contents", []):
162+
yield content
163+
96164
def delete(self, key: str) -> None:
97165
"""Delete content from S3.
98166
99167
:param key: object key
100168
"""
101169
self.client.delete_object(Bucket=self.bucket, Key=key)
170+
171+
@property
172+
def bucket_exists(self) -> bool:
173+
"""Return if the bucket exists."""
174+
try:
175+
self.client.head_bucket(Bucket=self.bucket)
176+
return True
177+
except ClientError as e:
178+
if e.response["Error"]["Code"] == "404":
179+
return False
180+
raise
181+
182+
@property
183+
def key_count(self) -> int:
184+
"""Return the number of keys from S3."""
185+
return len(list(self.iterate()))
186+
187+
188+
@contextmanager
189+
def bucket(
190+
name: str,
191+
*,
192+
client: botocore.client.S3 | None = None,
193+
region: str | None = None,
194+
auto_create: bool = True,
195+
auto_delete: bool = False,
196+
exist_ok: bool = True,
197+
) -> Iterator[S3]:
198+
"""Context manager to create and make AWS API calls on a bucket.
199+
200+
If auto_create is True, the bucket is created when entering the
201+
context. If the bucket already exists and exist_ok is False, an
202+
exception is raised.
203+
204+
If auto_delete is True, the bucket is cleared and deleted when
205+
leaving the context.
206+
207+
:param name: name of the bucket
208+
:param client: a client for the S3 API
209+
:param region: region of the client (default AWS_DEFAULT_REGION)
210+
:param auto_create: create the bucket when entering the context
211+
:param auto_delete: delete the bucket when leaving the context
212+
:param exist_ok: don't raise an exception if the bucket already exists
213+
:raises BucketExistsError: if the bucket already exists
214+
"""
215+
if client is None:
216+
region = region if region is not None else os.environ["AWS_DEFAULT_REGION"]
217+
client = boto3.client("s3", region_name=region)
218+
219+
s3 = S3(client=client, bucket=name)
220+
221+
if auto_create:
222+
s3.create_bucket(exist_ok=exist_ok)
223+
224+
try:
225+
yield s3
226+
finally:
227+
if auto_delete:
228+
s3.delete_bucket()

tests/tests_e3_aws/s3/main_test.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from __future__ import annotations
22
from typing import TYPE_CHECKING
33
import pytest
4+
from unittest.mock import ANY
45
from moto import mock_aws
56
import boto3
6-
from e3.aws.s3 import S3, KeyExistsError, KeyNotFoundError
7+
from e3.aws import s3
8+
from e3.aws.s3 import S3, BucketExistsError, KeyExistsError, KeyNotFoundError
79

810
if TYPE_CHECKING:
911
from collections.abc import Iterable
@@ -51,6 +53,46 @@ def test_get_not_found_error(client: S3) -> None:
5153
client.get("foo")
5254

5355

56+
def test_iterate(client: S3) -> None:
57+
"""Test iterating content."""
58+
client.push("hello", b"hello")
59+
client.push("prefix/world", b"world")
60+
assert list(client.iterate()) == [
61+
{
62+
"ChecksumAlgorithm": ANY,
63+
"ETag": ANY,
64+
"Key": "hello",
65+
"LastModified": ANY,
66+
"Size": 5,
67+
"StorageClass": "STANDARD",
68+
},
69+
{
70+
"ChecksumAlgorithm": ANY,
71+
"ETag": ANY,
72+
"Key": "prefix/world",
73+
"LastModified": ANY,
74+
"Size": 5,
75+
"StorageClass": ANY,
76+
},
77+
]
78+
79+
80+
def test_iterate_prefix(client: S3) -> None:
81+
"""Test iterating content with a prefix."""
82+
client.push("hello", b"hello")
83+
client.push("prefix/world", b"world")
84+
assert list(client.iterate(prefix="prefix")) == [
85+
{
86+
"ChecksumAlgorithm": ANY,
87+
"ETag": ANY,
88+
"Key": "prefix/world",
89+
"LastModified": ANY,
90+
"Size": 5,
91+
"StorageClass": ANY,
92+
}
93+
]
94+
95+
5496
def test_delete(client: S3) -> None:
5597
"""Test deleting content."""
5698
test_push(client)
@@ -61,3 +103,39 @@ def test_delete(client: S3) -> None:
61103
def test_delete_missing(client: S3) -> None:
62104
"""Test deleting content with a missing key."""
63105
client.delete("foo")
106+
107+
108+
@mock_aws
109+
def test_bucket() -> None:
110+
"""Test creating a bucket in a context."""
111+
with s3.bucket("test", region="eu-west-1") as client:
112+
# Bucket should have been created
113+
assert client.bucket_exists
114+
assert client.key_count == 0
115+
client.push("foo", b"hello")
116+
assert client.key_count == 1
117+
118+
# Bucket should still exist
119+
assert client.bucket_exists
120+
assert client.get("foo") == b"hello"
121+
122+
123+
@mock_aws
124+
def test_bucket_auto_delete() -> None:
125+
"""Test auto bucket deletion in a context."""
126+
with s3.bucket("test", region="eu-west-1", auto_delete=True) as client:
127+
# Bucket should have been created
128+
assert client.bucket_exists
129+
assert client.key_count == 0
130+
client.push("foo", b"hello")
131+
assert client.key_count == 1
132+
133+
# Bucket should no longer exist
134+
assert not client.bucket_exists
135+
136+
137+
def test_bucket_already_exist_error(client: S3) -> None:
138+
"""Test creating an already existing bucket in a context."""
139+
with pytest.raises(BucketExistsError):
140+
with s3.bucket("test", region="eu-west-1", exist_ok=False) as client:
141+
assert client.bucket_exists

0 commit comments

Comments
 (0)