Skip to content

Commit 3cb42a2

Browse files
Apply /simplify findings and add filesystem documentation
- Extract _list_object_versions_pages, shared by ls(versions=True) and object_version_info, and _directory_object/_versioned_file_object factories replacing the repeated S3Object literals - Move the version-aware staleness decision into info(): a version-less cached entry is re-headed there, removing the file-layer refresh workaround and the doubled HEAD on version-aware opens - Route S3File.commit's multipart branch through _finish_multipart_upload so the complete-or-abort protocol has a single owner (commit now also aborts on failure) - Bound the ls(versions=True) object-path fallback listing with a delimiter; drop the S3ObjectVersion round-trip in the fallback - Drop the dead clobber parameter of register_s3_filesystem and reuse the helper in the test fixture; remove mocked tests duplicating integration coverage - Add the docs/filesystem.md user documentation page and fix the RST list formatting in the S3FileSystem class docstring Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 15b8e6c commit 3cb42a2

5 files changed

Lines changed: 279 additions & 203 deletions

File tree

docs/filesystem.md

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

99

10-
def register_s3_filesystem(clobber: bool = True) -> None:
10+
def register_s3_filesystem() -> None:
1111
"""Register PyAthena's S3 filesystem as fsspec's "s3" / "s3a" protocols.
1212
1313
PyAthena registers its own filesystem so that the pandas/polars result
@@ -22,10 +22,6 @@ def register_s3_filesystem(clobber: bool = True) -> None:
2222
re-register it after importing ``pyathena.pandas`` / ``pyathena.polars``::
2323
2424
fsspec.register_implementation("s3", s3fs.S3FileSystem, clobber=True)
25-
26-
Args:
27-
clobber: Whether to replace an existing registration of the
28-
protocols.
2925
"""
3026
for protocol in ("s3", "s3a"):
3127
registered = fsspec.registry.get(protocol)
@@ -36,4 +32,4 @@ def register_s3_filesystem(clobber: bool = True) -> None:
3632
f"{S3FileSystem.__module__}.{S3FileSystem.__qualname__}."
3733
)
3834
_logger.debug(f"Registering {S3FileSystem} as the fsspec {protocol!r} protocol.")
39-
fsspec.register_implementation(protocol, S3FileSystem, clobber=clobber)
35+
fsspec.register_implementation(protocol, S3FileSystem, clobber=True)

0 commit comments

Comments
 (0)