Skip to content

Commit 7ff6cdd

Browse files
Merge pull request #730 from pyathena-dev/feature/pipe-file-direct-put-722
Complete the filesystem parity checklist: pipe_file, version-aware mode, and fsspec registry DX
2 parents 3c53f86 + bef67e6 commit 7ff6cdd

13 files changed

Lines changed: 1061 additions & 186 deletions

File tree

docs/filesystem.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
(filesystem)=
2+
3+
# S3 filesystem
4+
5+
PyAthena ships its own [fsspec](https://filesystem-spec.readthedocs.io/en/latest/)-compatible
6+
filesystem implementation for Amazon S3 (`S3FileSystem`), built on boto3, with an API
7+
surface compatible with [s3fs](https://github.com/fsspec/s3fs) for users migrating from it.
8+
9+
The filesystem is used internally by the pandas/polars result sets to read query results
10+
from S3, and can also be used independently for S3 file operations.
11+
12+
## fsspec registration
13+
14+
Importing `pyathena.pandas` or `pyathena.polars` registers `S3FileSystem` as the fsspec
15+
`s3` / `s3a` protocols via `pyathena.filesystem.register_s3_filesystem`. This replaces
16+
fsspec's default lazy mapping of the `s3` protocol to s3fs, which means
17+
`fsspec.filesystem("s3")` returns PyAthena's implementation and s3fs-specific settings
18+
(such as the `S3FS_LOGGING_LEVEL` environment variable) have no effect.
19+
20+
A filesystem class that has already been registered explicitly is also overwritten,
21+
with a warning log, so that the replacement is diagnosable. To restore another
22+
implementation, re-register it afterwards:
23+
24+
```python
25+
import fsspec
26+
import s3fs
27+
28+
import pyathena.pandas # Registers PyAthena's S3FileSystem.
29+
30+
fsspec.register_implementation("s3", s3fs.S3FileSystem, clobber=True)
31+
```
32+
33+
## Basic usage
34+
35+
The filesystem can be constructed from a PyAthena connection, or directly with
36+
s3fs-compatible credential arguments:
37+
38+
```python
39+
from pyathena import connect
40+
from pyathena.filesystem.s3 import S3FileSystem
41+
42+
fs = S3FileSystem(connect(region_name="us-west-2"))
43+
44+
# Or with direct credentials (s3fs-compatible arguments).
45+
fs = S3FileSystem(key="YOUR_ACCESS_KEY", secret="YOUR_SECRET_KEY")
46+
47+
# Or anonymously for public buckets.
48+
fs = S3FileSystem(anon=True)
49+
```
50+
51+
Standard fsspec operations work as expected:
52+
53+
```python
54+
fs.ls("s3://YOUR_S3_BUCKET/path/to/")
55+
fs.find("s3://YOUR_S3_BUCKET/path/to/")
56+
fs.exists("s3://YOUR_S3_BUCKET/path/to/object")
57+
fs.info("s3://YOUR_S3_BUCKET/path/to/object")
58+
59+
with fs.open("s3://YOUR_S3_BUCKET/path/to/object", "rb") as f:
60+
data = f.read()
61+
62+
fs.pipe("s3://YOUR_S3_BUCKET/path/to/object", b"data")
63+
fs.cat("s3://YOUR_S3_BUCKET/path/to/object")
64+
fs.cp("s3://YOUR_S3_BUCKET/src", "s3://YOUR_S3_BUCKET/dst")
65+
fs.rm("s3://YOUR_S3_BUCKET/path/to/", recursive=True)
66+
```
67+
68+
Writes with `pipe`/`pipe_file` issue a single PutObject request for data up to the
69+
block size (5 MiB by default); larger data is uploaded as a parallel multipart upload
70+
through the buffered file path. Inside an
71+
[fsspec transaction](https://filesystem-spec.readthedocs.io/en/latest/features.html#transactions),
72+
writes are deferred until the transaction commits and are discarded on rollback.
73+
74+
## Error translation
75+
76+
S3 error responses are translated into standard Python exceptions, so filesystem
77+
operations raise natural errors instead of botocore's `ClientError`:
78+
79+
| S3 error | Python exception |
80+
| --- | --- |
81+
| `404` / `NoSuchKey` / `NoSuchBucket` | `FileNotFoundError` |
82+
| `403` / `AccessDenied` | `PermissionError` |
83+
| `BucketAlreadyExists` / `BucketAlreadyOwnedByYou` | `FileExistsError` |
84+
| `RequestTimeout` | `TimeoutError` |
85+
| Others | `OSError` with the matching `errno` |
86+
87+
## Object metadata, tags, and ACLs
88+
89+
```python
90+
# User-defined metadata (x-amz-meta-*).
91+
fs.setxattr("s3://YOUR_S3_BUCKET/path/to/object", attr1="value1")
92+
metadata = fs.metadata("s3://YOUR_S3_BUCKET/path/to/object")
93+
metadata["attr1"] # User-defined metadata via the mapping interface.
94+
metadata.content_type # System-defined metadata as typed properties.
95+
fs.getxattr("s3://YOUR_S3_BUCKET/path/to/object", "attr1")
96+
97+
# Object tagging.
98+
fs.put_tags("s3://YOUR_S3_BUCKET/path/to/object", {"tag1": "value1"})
99+
fs.put_tags("s3://YOUR_S3_BUCKET/path/to/object", {"tag2": "value2"}, mode="m") # Merge.
100+
fs.get_tags("s3://YOUR_S3_BUCKET/path/to/object")
101+
102+
# Canned ACLs.
103+
fs.chmod("s3://YOUR_S3_BUCKET/path/to/object", "bucket-owner-full-control")
104+
fs.chmod("s3://YOUR_S3_BUCKET/path/to/", "private", recursive=True)
105+
```
106+
107+
Note that `setxattr` rewrites the object by copying it onto itself (S3 does not allow
108+
updating the metadata of an existing object in place), which updates its last-modified
109+
time.
110+
111+
## Multipart upload management
112+
113+
Incomplete multipart uploads continue to accrue storage costs until they are completed
114+
or aborted. The filesystem can discover and abort them:
115+
116+
```python
117+
uploads = fs.list_multipart_uploads("s3://YOUR_S3_BUCKET")
118+
for upload in uploads:
119+
print(upload.key, upload.upload_id, upload.initiated)
120+
121+
# Abort all incomplete uploads under a bucket or key prefix.
122+
fs.clear_multipart_uploads("s3://YOUR_S3_BUCKET/path/to/")
123+
```
124+
125+
## Versioning
126+
127+
With `version_aware=True`, reads pin the object version observed at open time, so a
128+
file handle keeps returning consistent data even if the object is overwritten while
129+
reading. Explicit versions can always be read with the `?versionId=` suffix or the
130+
`version_id` argument.
131+
132+
```python
133+
fs = S3FileSystem(connect(region_name="us-west-2"), version_aware=True)
134+
135+
with fs.open("s3://YOUR_S3_BUCKET/path/to/object", "rb") as f:
136+
data = f.read() # Pinned to the version observed at open time.
137+
138+
# List all versions of the objects under a prefix.
139+
fs.ls("s3://YOUR_S3_BUCKET/path/to/", versions=True, detail=True)
140+
141+
# Typed version information, including delete markers if requested.
142+
versions = fs.object_version_info("s3://YOUR_S3_BUCKET/path/to/object")
143+
for version in versions:
144+
print(version.version_id, version.is_latest, version.last_modified)
145+
```
146+
147+
Version-aware operations require the `s3:GetObjectVersion` and
148+
`s3:ListBucketVersions` permissions.
149+
150+
## Bucket lifecycle
151+
152+
Bucket creation and deletion are infrastructure-level changes and are disabled by
153+
default: `mkdir`/`makedirs` and `rmdir` raise `PermissionError` when they would
154+
create or delete a bucket. Pass the opt-in flags to enable them:
155+
156+
```python
157+
fs = S3FileSystem(
158+
connect(region_name="us-west-2"),
159+
allow_bucket_creation=True,
160+
allow_bucket_deletion=True,
161+
)
162+
fs.mkdir("s3://YOUR_NEW_BUCKET")
163+
fs.rmdir("s3://YOUR_NEW_BUCKET") # The bucket must be empty.
164+
```
165+
166+
Creating a key prefix under an existing bucket requires no operation (S3 has no real
167+
directories below the bucket level) and is always a no-op.
168+
169+
## Async filesystem
170+
171+
`AioS3FileSystem` provides the same functionality on top of fsspec's
172+
`AsyncFileSystem`, dispatching parallel operations through the asyncio event loop.
173+
See {ref}`aio-s3-filesystem` for details.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ sqlalchemy
7474
:maxdepth: 2
7575
:caption: Advanced Topics
7676
77+
filesystem
7778
null_handling
7879
testing
7980
```

pyathena/filesystem/__init__.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import logging
2+
3+
import fsspec
4+
5+
from pyathena.filesystem.s3 import S3FileSystem
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
10+
def register_s3_filesystem() -> None:
11+
"""Register PyAthena's S3 filesystem as fsspec's "s3" / "s3a" protocols.
12+
13+
PyAthena registers its own filesystem so that the pandas/polars result
14+
sets can read query results from S3 without depending on s3fs. The
15+
registration replaces fsspec's default lazy mapping of the "s3" protocol
16+
to s3fs, which means ``fsspec.filesystem("s3")`` returns PyAthena's
17+
implementation and s3fs-specific settings (e.g., the ``S3FS_LOGGING_LEVEL``
18+
environment variable) have no effect.
19+
20+
A filesystem class that has already been registered explicitly is also
21+
overwritten, with a warning log. To restore another implementation,
22+
re-register it after importing ``pyathena.pandas`` / ``pyathena.polars``::
23+
24+
fsspec.register_implementation("s3", s3fs.S3FileSystem, clobber=True)
25+
"""
26+
for protocol in ("s3", "s3a"):
27+
registered = fsspec.registry.get(protocol)
28+
if registered is not None and registered is not S3FileSystem:
29+
_logger.warning(
30+
f"The fsspec {protocol!r} protocol is already registered as "
31+
f"{registered.__module__}.{registered.__qualname__} and will be overwritten by "
32+
f"{S3FileSystem.__module__}.{S3FileSystem.__qualname__}."
33+
)
34+
_logger.debug(f"Registering {S3FileSystem} as the fsspec {protocol!r} protocol.")
35+
fsspec.register_implementation(protocol, S3FileSystem, clobber=True)

0 commit comments

Comments
 (0)