Skip to content

Commit 16694ae

Browse files
authored
Merge pull request #40 from aiokitchen/feature/39-adoption
Backport features from #37
2 parents ca9088b + 52703d4 commit 16694ae

3 files changed

Lines changed: 246 additions & 5 deletions

File tree

README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ async def main():
6565
async with client.delete("bucket/key") as resp:
6666
assert resp == HTTPStatus.NO_CONTENT
6767

68+
# Server-side copy
69+
async with client.copy("bucket/src-key", "bucket/dst-key") as resp:
70+
assert resp.status == HTTPStatus.OK
71+
72+
# Rename (copy + delete source, not atomic)
73+
await client.rename("bucket/old-key", "bucket/new-key")
74+
6875
# List objects by prefix
6976
async for result, prefixes in client.list_objects_v2(
7077
"bucket/", prefix="prefix",
@@ -358,6 +365,87 @@ async def main():
358365
asyncio.run(main())
359366
```
360367

368+
## Content-Type inference
369+
370+
When uploading objects the client automatically infers the `Content-Type`
371+
header from the object key (or local file path) using Python's
372+
`mimetypes.guess_type`. For example, uploading to `bucket/photo.jpg` will
373+
set `Content-Type: image/jpeg`. If the type cannot be determined it falls
374+
back to `application/octet-stream`.
375+
376+
You can always override this by passing an explicit `Content-Type` header:
377+
378+
```python
379+
async with client.put(
380+
"bucket/data.bin",
381+
some_bytes,
382+
headers={"Content-Type": "application/x-custom"},
383+
) as resp:
384+
...
385+
```
386+
387+
## Custom metadata
388+
389+
S3 allows you to attach arbitrary key-value metadata to objects using
390+
`x-amz-meta-<key>` headers. You can pass these via the `headers` parameter
391+
on any upload method.
392+
393+
With `client.put()`:
394+
395+
```python
396+
async with client.put(
397+
"bucket/report.json",
398+
b'{"result": 42}',
399+
headers={
400+
"x-amz-meta-author": "alice",
401+
"x-amz-meta-version": "3",
402+
},
403+
) as resp:
404+
assert resp.status == 200
405+
```
406+
407+
With `client.put_file()`:
408+
409+
```python
410+
resp = await client.put_file(
411+
"bucket/photo.jpg",
412+
"/path/to/photo.jpg",
413+
headers={
414+
"x-amz-meta-camera": "Nikon D850",
415+
"x-amz-meta-location": "Paris",
416+
},
417+
)
418+
```
419+
420+
With `client.put_file_multipart()`:
421+
422+
```python
423+
await client.put_file_multipart(
424+
"bucket/bigfile.csv",
425+
"/path/to/bigfile.csv",
426+
headers={
427+
"Content-Type": "text/csv",
428+
"x-amz-meta-source": "etl-pipeline",
429+
},
430+
workers_count=8,
431+
)
432+
```
433+
434+
Metadata can also be set or replaced during a server-side copy by passing
435+
`replace_metadata=True`:
436+
437+
```python
438+
async with client.copy(
439+
"bucket/src-key",
440+
"bucket/dst-key",
441+
replace_metadata=True,
442+
headers={
443+
"x-amz-meta-status": "archived",
444+
},
445+
) as resp:
446+
assert resp.status == 200
447+
```
448+
361449
## Parallel download to file
362450

363451
S3 supports `GET` requests with `Range` header. It's possible to download

aiohttp_s3_client/client.py

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import asyncio
22
import hashlib
33
import logging
4+
import mimetypes
5+
import sys
46
import threading
57
from collections.abc import (
68
AsyncIterable,
@@ -11,7 +13,6 @@
1113
)
1214
from functools import cached_property
1315
from http import HTTPStatus
14-
from mimetypes import guess_type
1516
from mmap import PAGESIZE
1617
from pathlib import Path
1718
from tempfile import TemporaryFile
@@ -51,6 +52,16 @@
5152
EMPTY_STR_HASH = hashlib.sha256(b"").hexdigest()
5253
PART_SIZE = 5 * 1024 * 1024 # 5MB
5354

55+
if sys.version_info >= (3, 13):
56+
57+
def _guess_content_type(path: str) -> str | None:
58+
return mimetypes.guess_file_type(path)[0]
59+
else:
60+
61+
def _guess_content_type(path: str) -> str | None:
62+
return mimetypes.guess_type(path)[0]
63+
64+
5465
HeadersType = dict | CIMultiDict | CIMultiDictProxy
5566

5667

@@ -208,12 +219,19 @@ def _make_headers(headers: HeadersType | None) -> CIMultiDict:
208219
def _prepare_headers(
209220
self,
210221
headers: HeadersType | None,
211-
file_path: str = "",
222+
path_hint: str = "",
212223
) -> CIMultiDict:
224+
"""Prepare headers and infer Content-Type when not explicitly set.
225+
226+
If a ``Content-Type`` header is already present it is left unchanged.
227+
Otherwise the type is guessed from *path_hint* (typically the object
228+
key or a local file path) using :func:`mimetypes.guess_type`, falling
229+
back to ``application/octet-stream``.
230+
"""
213231
headers = self._make_headers(headers)
214232

215233
if hdrs.CONTENT_TYPE not in headers:
216-
content_type = guess_type(file_path)[0]
234+
content_type = _guess_content_type(path_hint)
217235
if content_type is None:
218236
content_type = "application/octet-stream"
219237

@@ -227,15 +245,35 @@ def put(
227245
data: bytes | str | AsyncIterable[bytes],
228246
**kwargs,
229247
) -> RequestContextManager:
230-
return self.request("PUT", object_name, data=data, **kwargs)
248+
headers = self._prepare_headers(
249+
kwargs.pop("headers", None),
250+
object_name,
251+
)
252+
return self.request(
253+
"PUT",
254+
object_name,
255+
data=data,
256+
headers=headers,
257+
**kwargs,
258+
)
231259

232260
def post(
233261
self,
234262
object_name: str,
235263
data: bytes | str | AsyncIterable[bytes] | None = None,
236264
**kwargs,
237265
) -> RequestContextManager:
238-
return self.request("POST", object_name, data=data, **kwargs)
266+
headers = self._prepare_headers(
267+
kwargs.pop("headers", None),
268+
object_name,
269+
)
270+
return self.request(
271+
"POST",
272+
object_name,
273+
data=data,
274+
headers=headers,
275+
**kwargs,
276+
)
239277

240278
async def put_file(
241279
self,
@@ -324,6 +362,8 @@ async def put_file_multipart(
324362
part_size,
325363
)
326364

365+
headers = self._prepare_headers(headers, str(file_path))
366+
327367
async with (
328368
Reader(
329369
file_path,
@@ -399,6 +439,8 @@ async def put_multipart(
399439
for integrity purposes
400440
"""
401441

442+
headers = self._prepare_headers(headers, str(object_name))
443+
402444
with TemporaryFile() as fp:
403445

404446
def place_temp_file():
@@ -706,6 +748,86 @@ def presign_url(
706748
)
707749
)
708750

751+
def copy(
752+
self,
753+
source: str,
754+
destination: str,
755+
*,
756+
headers: HeadersType | None = None,
757+
replace_metadata: bool = False,
758+
content_type: str | None = None,
759+
content_sha256: str = EMPTY_STR_HASH,
760+
**kwargs,
761+
) -> RequestContextManager:
762+
"""Server-side copy of an S3 object.
763+
764+
source: source object key (e.g. ``bucket/key``)
765+
destination: destination object key
766+
headers: additional headers
767+
replace_metadata: when *True* the destination receives the supplied
768+
metadata instead of inheriting the source metadata
769+
content_type: optional Content-Type override for the destination
770+
"""
771+
headers = self._prepare_headers(headers, destination)
772+
source_url = self._url / source.lstrip("/")
773+
headers["x-amz-copy-source"] = quote(
774+
source_url.path,
775+
safe="/",
776+
)
777+
if replace_metadata:
778+
headers["x-amz-metadata-directive"] = "REPLACE"
779+
if content_type is not None:
780+
headers[hdrs.CONTENT_TYPE] = content_type
781+
return self.request(
782+
"PUT",
783+
destination,
784+
headers=headers,
785+
content_sha256=content_sha256,
786+
**kwargs,
787+
)
788+
789+
async def rename(
790+
self,
791+
source: str,
792+
destination: str,
793+
*,
794+
headers: HeadersType | None = None,
795+
**kwargs,
796+
) -> None:
797+
"""Move an S3 object by copying then deleting the source.
798+
799+
This operation is **not** atomic — if the delete fails after a
800+
successful copy, the object will exist at both locations.
801+
802+
source: source object key
803+
destination: destination object key
804+
headers: additional headers forwarded to ``copy()``
805+
"""
806+
async with self.copy(
807+
source,
808+
destination,
809+
headers=headers,
810+
**kwargs,
811+
) as resp:
812+
if resp.status != HTTPStatus.OK:
813+
payload = await resp.text()
814+
raise AwsUploadError(
815+
resp,
816+
f"Copy failed with status {resp.status}: {payload}",
817+
)
818+
819+
async with self.delete(source) as resp:
820+
if resp.status not in (
821+
HTTPStatus.OK,
822+
HTTPStatus.NO_CONTENT,
823+
):
824+
payload = await resp.text()
825+
raise AwsError(
826+
resp,
827+
f"Delete of source failed with status "
828+
f"{resp.status}: {payload}",
829+
)
830+
709831
def multipart_upload(self, object_name: str) -> "MultipartUploader":
710832
"""
711833
Get S3MultipartUploader for object_name

tests/test_simple.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,34 @@ async def gen():
237237
headers = call.kwargs.get("headers")
238238
trailer = "STREAMING-UNSIGNED-PAYLOAD-TRAILER"
239239
assert headers.get("x-amz-content-sha256") == trailer
240+
241+
242+
async def test_copy_object(s3_client: S3Client, s3_read):
243+
data = b"hello, copy"
244+
source = "/test/copy-source"
245+
destination = "/test/copy-destination"
246+
247+
resp = await s3_client.put(source, data)
248+
assert resp.status == HTTPStatus.OK
249+
250+
async with s3_client.copy(source, destination) as resp:
251+
assert resp.status == HTTPStatus.OK
252+
253+
assert (await s3_read(source)) == data
254+
assert (await s3_read(destination)) == data
255+
256+
257+
async def test_rename_object(s3_client: S3Client, s3_read):
258+
data = b"hello, rename"
259+
source = "/test/rename-source"
260+
destination = "/test/rename-destination"
261+
262+
resp = await s3_client.put(source, data)
263+
assert resp.status == HTTPStatus.OK
264+
265+
await s3_client.rename(source, destination)
266+
267+
assert (await s3_read(destination)) == data
268+
269+
async with s3_client.get(source) as resp:
270+
assert resp.status == HTTPStatus.NOT_FOUND

0 commit comments

Comments
 (0)