Skip to content

Commit d0e883a

Browse files
Merge pull request #1090 from pyathena-dev/refactor/1086-tags-acl-metadata
Move tagging, ACL, metadata and presign requests into S3Core
2 parents 5a0a5cb + 5c233f8 commit d0e883a

5 files changed

Lines changed: 597 additions & 154 deletions

File tree

‎docs/filesystem.md‎

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,13 +300,15 @@ directories below the bucket level) and is always a no-op.
300300
## Typed S3 operations
301301

302302
`S3FileSystem.core` is an `S3Core`, the typed operations that the filesystem sends
303-
its listing, lookup, read, write, delete, multipart upload and copy requests with. It can also be
304-
built on a boto3 S3 client. Each operation sends one request (one per page for the
305-
iterators and `list_object_annotations()`); `plan_multipart_copy()` and
306-
`copy_object_annotation()`, described below, send several. The requests are sent with
307-
the retry policy. An operation raises `FileNotFoundError` for a missing bucket or
308-
multipart upload, or for a missing object or version that it reads, and caches
309-
nothing. Requests sent through `fs.core` do not invalidate the filesystem's cache: call
303+
its listing, lookup, read, write, delete, multipart upload, copy, tagging, ACL and
304+
metadata replacement requests with. It can also be built on a boto3 S3 client. Each
305+
operation sends one request (one per page for the iterators and
306+
`list_object_annotations()`); `plan_multipart_copy()` and `copy_object_annotation()`,
307+
described below, send several, and `generate_presigned_url()` signs a URL locally
308+
without a request. The requests are sent with the retry policy. An operation raises
309+
`FileNotFoundError` for a missing bucket or multipart upload, or for a missing object or
310+
version that it reads, and caches nothing. Requests sent through `fs.core` do not
311+
invalidate the filesystem's cache: call
310312
`fs.invalidate_cache()` after a change, or make it through the filesystem.
311313

312314
```python
@@ -357,6 +359,42 @@ for batch in S3DeleteBatch.from_paths(paths):
357359
print(error) # path (code: message)
358360
```
359361

362+
`get_object_tagging()` returns the tags of an object as a dictionary, and
363+
`put_object_tagging()` replaces all of its tags with the given ones. Both act on the
364+
version ID of the path, if any, including `null`. `put_object_acl()` and
365+
`put_bucket_acl()` apply a canned ACL, which must be in `S3Core.OBJECT_ACLS` or
366+
`S3Core.BUCKET_ACLS`, respectively; another value raises `ValueError`, and an object
367+
ACL also applies to the version ID of the path.
368+
369+
`replace_object_metadata()` replaces the user-defined metadata of an object, given the
370+
`head_object()` result of the same path, by copying the object onto itself. The copy
371+
rewrites the object, or creates a new version in a versioned bucket, so the path must
372+
not have a version ID. The copy retains the content headers (`CacheControl`,
373+
`ContentDisposition`, `ContentEncoding`, `ContentLanguage`, `ContentType`), `Expires`,
374+
`WebsiteRedirectLocation`, `StorageClass`, and the `ServerSideEncryption`,
375+
`SSEKMSKeyId` and `BucketKeyEnabled` of the object. Additional CopyObject parameters
376+
take precedence over the retained fields, and any encryption parameter replaces all of
377+
the retained encryption fields.
378+
379+
```python
380+
path = S3Path.parse("s3://YOUR_S3_BUCKET/path/to/object")
381+
core.put_object_tagging(path, {**core.get_object_tagging(path), "tag2": "value2"})
382+
383+
head = core.head_object(path)
384+
core.replace_object_metadata(path, head, {**head, "attr1": "value1"})
385+
```
386+
387+
`generate_presigned_url()` signs a URL locally, for `get_object` unless another client
388+
method is given, and sends no request. Its parameters take precedence over the
389+
`Bucket`, `Key` and `VersionId` of the path. The `request_kwargs` of the core, such as
390+
`RequestPayer`, are not included in the signed parameters; pass them as parameters
391+
of `generate_presigned_url()` to sign them.
392+
393+
```python
394+
url = core.generate_presigned_url(path, expires_in=600)
395+
upload_url = core.generate_presigned_url(path, "put_object", ContentType="text/csv")
396+
```
397+
360398
### Multipart writer
361399

362400
`S3MultipartWriter` provides synchronous multipart requests and part planning without fsspec.

‎pyathena/filesystem/s3.py‎

Lines changed: 15 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -161,34 +161,6 @@ class S3FileSystem(AbstractFileSystem):
161161
"""
162162

163163
DEFAULT_BLOCK_SIZE: int = 5 * 2**20 # 5MiB
164-
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
165-
OBJECT_ACLS: frozenset[str] = frozenset(
166-
{
167-
"private",
168-
"public-read",
169-
"public-read-write",
170-
"authenticated-read",
171-
"aws-exec-read",
172-
"bucket-owner-read",
173-
"bucket-owner-full-control",
174-
}
175-
)
176-
BUCKET_ACLS: frozenset[str] = frozenset(
177-
{"private", "public-read", "public-read-write", "authenticated-read"}
178-
)
179-
# https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html
180-
# The CopyObject parameters that set the encryption of the copy.
181-
_SSE_COPY_PARAMS: frozenset[str] = frozenset(
182-
{
183-
"ServerSideEncryption",
184-
"SSEKMSKeyId",
185-
"SSEKMSEncryptionContext",
186-
"BucketKeyEnabled",
187-
"SSECustomerAlgorithm",
188-
"SSECustomerKey",
189-
"SSECustomerKeyMD5",
190-
}
191-
)
192164
PATTERN_PATH: Pattern[str] = S3Path.PATTERN
193165

194166
protocol = ("s3", "s3a")
@@ -1282,8 +1254,8 @@ def mkdir(self, path: str, create_parents: bool = True, **kwargs) -> None:
12821254
"Set allow_bucket_creation=True on the filesystem to enable it."
12831255
)
12841256
acl = kwargs.pop("acl", "")
1285-
if acl and acl not in self.BUCKET_ACLS:
1286-
raise ValueError(f"ACL not in {self.BUCKET_ACLS}.")
1257+
if acl and acl not in self.core.BUCKET_ACLS:
1258+
raise ValueError(f"ACL not in {self.core.BUCKET_ACLS}.")
12871259
request: dict[str, Any] = {"Bucket": s3_path.bucket}
12881260
if acl:
12891261
request.update({"ACL": acl})
@@ -2197,23 +2169,9 @@ def sign(self, path: str, expiration: int = 3600, **kwargs):
21972169
... client_method="put_object"
21982170
... )
21992171
"""
2200-
s3_path = S3Path.parse(path)
22012172
client_method = kwargs.pop("client_method", "get_object")
2202-
params = {"Bucket": s3_path.bucket, "Key": s3_path.key}
2203-
if s3_path.version_id:
2204-
params.update({"VersionId": s3_path.version_id})
2205-
if kwargs:
2206-
params.update(kwargs)
2207-
request = {
2208-
"ClientMethod": client_method,
2209-
"Params": params,
2210-
"ExpiresIn": expiration,
2211-
}
2212-
2213-
_logger.debug(f"Generate signed url: {s3_path.uri}")
2214-
return self._call(
2215-
self._client.generate_presigned_url,
2216-
**request,
2173+
return self.core.generate_presigned_url(
2174+
S3Path.parse(path), client_method, expiration, **kwargs
22172175
)
22182176

22192177
def metadata(self, path: str, **kwargs) -> S3Metadata:
@@ -2295,43 +2253,7 @@ def setxattr(self, path: str, copy_kwargs: dict[str, Any] | None = None, **kw_ar
22952253
metadata.pop(k, None)
22962254
else:
22972255
metadata[k] = v
2298-
2299-
# With the REPLACE directive, S3 does not copy what the request
2300-
# omits: the system-defined metadata is dropped, and the copy is
2301-
# written as STANDARD with the default encryption of the bucket.
2302-
kept: dict[str, Any] = {
2303-
"CacheControl": head.cache_control,
2304-
"ContentDisposition": head.content_disposition,
2305-
"ContentEncoding": head.content_encoding,
2306-
"ContentLanguage": head.content_language,
2307-
"ContentType": head.content_type,
2308-
"Expires": head.expires,
2309-
"WebsiteRedirectLocation": head.website_redirect_location,
2310-
"StorageClass": head.storage_class,
2311-
}
2312-
copy_kwargs = copy_kwargs if copy_kwargs else {}
2313-
if not self._SSE_COPY_PARAMS.intersection(copy_kwargs):
2314-
kept.update(
2315-
{
2316-
"ServerSideEncryption": head.server_side_encryption,
2317-
"SSEKMSKeyId": head.sse_kms_key_id,
2318-
"BucketKeyEnabled": head.bucket_key_enabled,
2319-
}
2320-
)
2321-
2322-
_logger.debug(f"Set object metadata: {s3_path.uri}")
2323-
self._call(
2324-
self._client.copy_object,
2325-
CopySource={"Bucket": s3_path.bucket, "Key": s3_path.key},
2326-
Bucket=s3_path.bucket,
2327-
Key=s3_path.key,
2328-
Metadata=metadata,
2329-
MetadataDirective="REPLACE",
2330-
**{
2331-
**{k: v for k, v in kept.items() if v is not None},
2332-
**copy_kwargs,
2333-
},
2334-
)
2256+
self.core.replace_object_metadata(s3_path, head, metadata, **(copy_kwargs or {}))
23352257
self.invalidate_cache(path)
23362258

23372259
def get_tags(self, path: str) -> dict[str, str]:
@@ -2346,16 +2268,7 @@ def get_tags(self, path: str) -> dict[str, str]:
23462268
s3_path = S3Path.parse(path)
23472269
if not s3_path.key:
23482270
raise ValueError("Cannot get tags of a bucket.")
2349-
request: dict[str, Any] = {"Bucket": s3_path.bucket, "Key": s3_path.key}
2350-
if s3_path.version_id:
2351-
request.update({"VersionId": s3_path.version_id})
2352-
2353-
_logger.debug(f"Get object tagging: {s3_path.uri}")
2354-
response = self._call(
2355-
self._client.get_object_tagging,
2356-
**request,
2357-
)
2358-
return {v["Key"]: v["Value"] for v in response["TagSet"]}
2271+
return self.core.get_object_tagging(s3_path)
23592272

23602273
def put_tags(self, path: str, tags: dict[str, str], mode: str = "o") -> None:
23612274
"""Set the tags for the given existing key.
@@ -2377,26 +2290,12 @@ def put_tags(self, path: str, tags: dict[str, str], mode: str = "o") -> None:
23772290
if not s3_path.key:
23782291
raise ValueError("Cannot put tags of a bucket.")
23792292
if mode == "m":
2380-
existing_tags = self.get_tags(path)
2381-
existing_tags.update(tags)
2382-
new_tags = [{"Key": k, "Value": v} for k, v in existing_tags.items()]
2293+
new_tags = {**self.core.get_object_tagging(s3_path), **tags}
23832294
elif mode == "o":
2384-
new_tags = [{"Key": k, "Value": v} for k, v in tags.items()]
2295+
new_tags = tags
23852296
else:
23862297
raise ValueError(f"Mode must be {{'o', 'm'}}, not {mode}.")
2387-
request: dict[str, Any] = {
2388-
"Bucket": s3_path.bucket,
2389-
"Key": s3_path.key,
2390-
"Tagging": {"TagSet": new_tags},
2391-
}
2392-
if s3_path.version_id:
2393-
request.update({"VersionId": s3_path.version_id})
2394-
2395-
_logger.debug(f"Put object tagging: {s3_path.uri}")
2396-
self._call(
2397-
self._client.put_object_tagging,
2398-
**request,
2399-
)
2298+
self.core.put_object_tagging(s3_path, new_tags)
24002299

24012300
def chmod(self, path: str, acl: str, recursive: bool = False, **kwargs) -> None:
24022301
"""Set the Access Control on a bucket/key.
@@ -2414,10 +2313,10 @@ def chmod(self, path: str, acl: str, recursive: bool = False, **kwargs) -> None:
24142313
s3_path = S3Path.parse(path)
24152314
# Validate before any ACL is applied so that a recursive call cannot
24162315
# partially apply object ACLs and then fail on the bucket ACL.
2417-
if not s3_path.key and acl not in self.BUCKET_ACLS:
2418-
raise ValueError(f"ACL not in {self.BUCKET_ACLS}.")
2419-
if s3_path.key and acl not in self.OBJECT_ACLS:
2420-
raise ValueError(f"ACL not in {self.OBJECT_ACLS}.")
2316+
if not s3_path.key and acl not in self.core.BUCKET_ACLS:
2317+
raise ValueError(f"ACL not in {self.core.BUCKET_ACLS}.")
2318+
if s3_path.key and acl not in self.core.OBJECT_ACLS:
2319+
raise ValueError(f"ACL not in {self.core.OBJECT_ACLS}.")
24212320
if recursive:
24222321
with self._create_executor(max_workers=self.max_workers) as executor:
24232322
futures = [
@@ -2431,24 +2330,9 @@ def chmod(self, path: str, acl: str, recursive: bool = False, **kwargs) -> None:
24312330
# below it have ACLs.
24322331
return
24332332
if s3_path.key:
2434-
request: dict[str, Any] = {"Bucket": s3_path.bucket, "Key": s3_path.key, "ACL": acl}
2435-
if s3_path.version_id:
2436-
request.update({"VersionId": s3_path.version_id})
2437-
2438-
_logger.debug(f"Put object acl: {s3_path.uri}")
2439-
self._call(
2440-
self._client.put_object_acl,
2441-
**request,
2442-
**kwargs,
2443-
)
2333+
self.core.put_object_acl(s3_path, acl, **kwargs)
24442334
else:
2445-
_logger.debug(f"Put bucket acl: {s3_path.uri}")
2446-
self._call(
2447-
self._client.put_bucket_acl,
2448-
Bucket=s3_path.bucket,
2449-
ACL=acl,
2450-
**kwargs,
2451-
)
2335+
self.core.put_bucket_acl(s3_path.bucket, acl, **kwargs)
24522336

24532337
def list_multipart_uploads(self, path: str) -> list[S3MultipartUpload]:
24542338
"""List in-progress (incomplete) multipart uploads in a bucket.

0 commit comments

Comments
 (0)