Repository navigation
Model S3 paths with one value class instead of parsing strings at each call site #1049
Description
Use case
pyathena/filesystem/ handles S3 paths as strings, and each call site takes them apart again in its own way. Line numbers are from pyathena/filesystem/s3.py on master 28ec68d.
- Scheme. Two parsers remove it: the
PATTERN_PATHregular expression inparse_path()(s3://,s3a://or none) and fsspec's_strip_protocol()(10 calls).get_file()runs both (parse_path(self._strip_protocol(rpath)), L2193). - Bucket and key.
parse_path()splits them with the regular expression and keeps a trailing slash in the key, while_strip_protocol()removes it. Other call sites decide "bucket or key path" themselves:ls()checks"/" in path(L635),rm()treatsbucket//as the bucket withnot key or not key.strip("/")(L1124), andinfo()useskey.rstrip("/")for directory entries (L797, L829). - Version.
parse_path()is called 30 times ins3.pyand 3 times ins3_async.py, each unpacking a(bucket, key, version_id)tuple. 7 places build a version-qualified path by hand withf"...?versionId={version_id}"(_head_object(), the paths of_delete_objects,_move_target(),S3File.__init__(), ...), and_head_object()andinvalidate_cache()remove the version by splitting at the first?(path.partition("?"))._move_target()adds its own rule for thenullversion. - Display. Log and error messages rebuild
s3://{bucket}/{key}?versionId={version_id}by hand.
The string handling comes from the s3fs design that this filesystem started from. Changing how a version is parsed or spelled means finding every one of these places by hand: #979 changes the parsing rule (only a trailing ?versionId= query is a version, so keys may contain ?), and the partition("?") sites are the ones that change would silently miss.
Proposed change
Add a public frozen value class for an S3 path, in a new module pyathena/filesystem/s3_path.py, following ExecuteOptions (pyathena/options.py) for the frozen dataclass style, and make it the one place that knows the path syntax:
@dataclass(frozen=True)
class S3Path:
bucket: str
key: str | None = None # None for a bucket path; a trailing "/" is kept ("dir/" names a directory, #998)
version_id: str | None = None
@classmethod
def parse(cls, path: str | os.PathLike[str]) -> S3Path: ...
# s3://, s3a:// or no scheme; bucket; key; trailing version query. ValueError otherwise.
@property
def is_bucket(self) -> bool: ... # no key, or a key of only slashes ("bucket//")
@property
def name(self) -> str: ... # "bucket/key" without the trailing slash, or "bucket": what _strip_protocol() gives, used as the fsspec name and cache key
@property
def uri(self) -> str: ... # "s3://bucket/key?versionId=<id>", for messages
def __str__(self) -> str: ... # name, plus "?versionId=<id>" when there is a version
def with_version_id(self, version_id: str | None) -> S3Path: ...- Use
S3Pathat the internal call sites ofparse_path(), in place of_strip_protocol()followed by parsing, of the bucket/key checks above, and of the hand-built and hand-split version queries and URIs. - Keep
S3FileSystem.parse_path()andAioS3FileSystem.parse_path()as public methods returning the same tuple, implemented withS3Path.parse(), and keepS3FileSystem.PATTERN_PATH. - Keep fsspec's
_strip_protocol()and_parent()for the names fsspec itself builds (glob, find, expand_path);S3Path.namereturns the same string, so cache keys do not change. - No behavior change: the parsing rule and the trailing-slash semantics stay as they are, and Keys containing '?' are rejected, and pinned or listed versions are not addressable #979 changes the version rule afterward in one place.
Decided with the maintainer: the root path ("", "/", "s3://") stays outside S3Path. parse() rejects it as parse_path() does today, so bucket is never empty, and ls()/info()/invalidate_cache() keep checking the root before parsing. parse() accepts the schemes that PATTERN_PATH accepts today (s3://, s3a://, none).
Out of scope: S3 access point ARNs and other bucket name forms that PATTERN_PATH does not accept, and any change to fsspec's naming or cache key conventions.
Alternatives considered: small private helpers (strip or append a version query) next to parse_path(). They would remove the duplicated version handling, but leave the scheme and bucket/key handling scattered and give the path no type.
Validation plan (if implementing)
- Unit tests for
S3Path(parse with each scheme, bucket paths includingbucket/andbucket//, trailing-slash keys, versions in each spelling,name,uri,str(),with_version_id(), invalid paths), next to the existingtest_parse_pathcases. - The existing offline filesystem tests and the S3 integration tests in
tests/pyathena/filesystem/pass unchanged.
Activity