Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from fsspec import AbstractFileSystem
from fsspec.callbacks import _DEFAULT_CALLBACK
from fsspec.spec import AbstractBufferedFile
from fsspec.utils import tokenize
from fsspec.utils import isfilelike, tokenize

import pyathena
from pyathena.connection import Connection
Expand Down Expand Up @@ -1839,32 +1839,50 @@ def put_file(

self.invalidate_cache(rpath)

def get_file(self, rpath: str, lpath: str, callback=_DEFAULT_CALLBACK, outfile=None, **kwargs):
def get_file(self, rpath: str, lpath=None, callback=_DEFAULT_CALLBACK, outfile=None, **kwargs):
"""Download an S3 file to local filesystem.

Downloads a file from S3 to the local filesystem with progress tracking.
Reads the file in chunks to handle large files efficiently.

As with fsspec's ``AbstractFileSystem.get_file()``, a directory
``rpath`` creates the local directory ``lpath``, and the parent
directories of a local file ``lpath`` are created as needed.

Args:
rpath: S3 source path (s3://bucket/key).
lpath: Local destination file path.
lpath: Local destination path, or a file-like object to write to.
Not needed when ``outfile`` is given.
callback: Progress callback for tracking download progress.
outfile: Unused parameter for fsspec compatibility.
outfile: A file-like object to write to instead of ``lpath``.
**kwargs: Additional S3 parameters passed to open().

Note:
If lpath is a directory, the method returns without performing
any operation.
"""
if os.path.isdir(lpath):
_, _, path_version_id = self.parse_path(self._strip_protocol(rpath))
if outfile is None and isfilelike(lpath):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one after the rewrite to get_file() only (implementation behavior): CLEAN

Base 28826be7d37983cb54050e849b32a4fe2331ea7d, head 2a68d3481253291aef5089c1d1c8e56f59d9da83. Full pass over git diff 28826be7d37983cb54050e849b32a4fe2331ea7d..2a68d3481253291aef5089c1d1c8e56f59d9da83 (2 files). The earlier review threads on this PR refer to the previous head c6782a50, which included cp_file() and mv(). That part moved to #1008.

Covered:

  • fsspec contract: the branching matches fsspec 2026.9.0 AbstractFileSystem.get_file() (spec.py:981-1007):
    • a file-like lpath becomes outfile;
    • the directory check is skipped when outfile is given;
    • makedirs(lpath) is called for a directory;
    • the parent directories of a local lpath are created;
    • an outfile from the caller is not closed.
  • Intentional differences from fsspec:
    • The remote file is opened before the local file and its parents are created, so a missing object leaves nothing behind (master's ordering, kept).
    • A requested version skips isdir().
  • Failure paths:
    • A missing rpath makes isdir() return False (OSError is caught), and open() then raises FileNotFoundError before any local file or parent directory is created.
    • A file rpath with an existing local directory as lpath raises IsADirectoryError (release-noted).
  • Callers:
    • AioS3FileSystem._get_file() passes rpath, lpath and **kwargs (including outfile) to the sync get_file().
    • fsspec's sync get() and async _get() call get_file() once for each expanded path, directories included.
  • Requests: a file rpath adds an isdir() → info() lookup. When the listings cache is on, the HeadObject result is cached and reused by open(). When it is off, a download sends 2 HeadObject requests, as fsspec's own get_file() does.
  • Tests: 4 of the 6 new offline cases fail on master. The 2 test_get_file_version_id cases guard the new version skip and pass on master. Master's test_get_file_directory, which pinned FileNotFoundError, is replaced.

Pre-existing and out of scope: lpath stays a required argument, while fsspec defaults it to None for outfile-only calls.

outfile = lpath

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (relayed): FINDINGS

  • Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, run with --sandbox read-only --ephemeral, session 01a1016c-9dab-7160-994a-725258653bd1.
  • Scope: base 28826be7d37983cb54050e849b32a4fe2331ea7d, head 2a68d3481253291aef5089c1d1c8e56f59d9da83, in a clean detached snapshot.
  • Package: the literal diff and the intended behavior, plus read-only access to the installed fsspec 2026.9.0 sources. The PR number, description, commit messages and self-review findings were not included.
  • Constraints: static review only. The reviewer made no edits and ran no tests, network or GitHub calls. The snapshot and the PR worktree were unchanged afterwards.

Reviewer output (verbatim):

Covered surfaces: the exact diff, metadata/cache/version handling, stream ownership and failure paths, sync get(), async _get(), AioS3FileSystem._get_file(), installed fsspec 2026.9.0, tests, and docstring conventions. Static review only; no tests or network access.

FINDINGS

  1. P2 — Introduced regression: parent creation can target the wrong directory. pyathena/filesystem/s3.py:1853
    abspath() collapses .. lexically, whereas open(lpath) resolves symlinks first. Suppose /a/link points to /b/sub, /b/out is a directory, and /a/out is a regular file. Downloading to /a/link/../out/data previously succeeded at /b/out/data; the new makedirs("/a/out") raises FileExistsError. Preserve the original path’s symlink/.. semantics when creating its parent.

  2. P2 — Pre-existing contract gap remains: outfile alone is rejected. pyathena/filesystem/s3.py:1817
    fs.get_file("s3://bucket/key", outfile=stream) raises TypeError because lpath remains mandatory. Installed fsspec declares lpath=None, allowing this call. The newly implemented outfile support still requires a dummy destination argument. Default lpath to None and cover the omitted-argument call. This is retained incompatibility, not a regression.

Other review results:

  • Requests: For unversioned local destinations, caching normally avoids an additional HEAD. Disabled caching adds one HEAD; an uncached missing key repeats the HEAD/ListObjectsV2 lookup. Explicit versions and supplied streams bypass the new directory probe.
  • Failures: Remote-open failures precede local creation. ExitStack closes owned handles while leaving supplied streams open. Async _get() already creates parents before delegation, so its missing-object failures can leave directories independently of this change.
  • Tests: Parent-directory, stream, and recursive tests assert observable fixes and would fail the base. Version tests also pass the base; they preserve existing behavior. Missing-object and exception-cleanup behavior lacks direct coverage. The directory test’s baseline failure is additionally affected by its fake filesystem lacking default_cache_type.
  • Documentation/conventions: The docstring otherwise describes the implemented branches accurately, uses Google style, and introduces no import-placement or name-mangling violations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair in a87a59e, with both self-review perspectives applied

Patch series 2a68d348..a87a59e2. Base unchanged (28826be7d37983cb54050e849b32a4fe2331ea7d).

  1. Finding 1 (P2, introduced regression): verified and fixed. The scenario reproduced locally: a symlink a/link → b/sub, a directory b/out, a regular file a/out, and lpath = a/link/../out/data.

    • makedirs(dirname(abspath(lpath))) raised FileExistsError.
    • makedirs(dirname(lpath)) let open() write b/out/data.

    get_file() now creates os.path.dirname(lpath), skipping the call when the parent is empty, as for a bare file name. New test: test_get_file_parent_through_symlink.

  2. Finding 2 (P2, pre-existing): folded in as a contained fix. lpath now defaults to None as in fsspec 2026.9.0, and the docstring says it is not needed with outfile. Covered in test_get_file_file_like.

  3. Test notes:

    • test_get_file_directory now sets default_cache_type, so on master it fails because open() raises FileNotFoundError, not because of a missing fixture attribute.
    • New test_get_file_missing pins that a missing object leaves no local file or parent directory.

Round one (behavior):

  • A PathLike lpath works with os.path.dirname().
  • lpath=None without outfile raises TypeError, as in fsspec, and only after open() has checked that the remote object exists.
  • AioS3FileSystem._get_file() is unchanged and still passes outfile through **kwargs.

Round two (claims):

  • The code comment "abspath() would resolve .. before symlinks" matches posixpath.abspath(), which normalizes lexically.
  • The PR body now records the symlink handling, the lpath=None default, the tested commit a87a59e, and the new test results.

Validation on a87a59e:

  • just lint passed.
  • uv run --env-file .env pytest -n 2 -p no:cacheprovider -q tests/pyathena/filesystem/ gave 443 passed.
  • The new and changed offline tests give 8 passed. With master's get_file(), 5 fail; with 2a68d34's, 2 fail (symlink and outfile-only).

A Codex follow-up on 2a68d348..a87a59e2 follows.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent follow-up on the repair (relayed): CLEAN

  • Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, run with --sandbox read-only --ephemeral, session 01a10177-b108-7c53-91d9-42705ab40353.
  • Scope: patch series 2a68d3481253291aef5089c1d1c8e56f59d9da83..a87a59e2, base 28826be7d37983cb54050e849b32a4fe2331ea7d.
  • Constraints: static review only. The snapshot and the PR worktree were unchanged afterwards.
  • Note: the reviewer labels finding 1 as pre-existing. It was introduced by this PR's first head, 2a68d34, relative to master; master never created parent directories.

Reviewer output (verbatim):

Covered surfaces: the exact repair, fsspec 2026.9.0 compatibility, parent creation, relative paths, PathLike inputs, bare filenames, lpath=None, stream ownership, missing objects, and regression tests.

CLEAN — both prior defects are resolved; no introduced regression found.

  • Pre-existing symlink/.. defect: resolved. s3.py:1855 preserves filesystem path resolution. The symlink test blocks the incorrectly normalized destination with a regular file and verifies the correct destination, catching the original failure.
  • Pre-existing required-lpath defect: resolved. s3.py:1817 permits omission. The outfile test directly exercises it.
  • Relative paths retain their intended resolution; PathLike inputs remain supported; bare filenames skip empty-parent creation. Explicit None works with outfile; without either destination, it remains invalid.
  • Relative paths, direct PathLike inputs, bare filenames, and None without outfile lack dedicated regression tests. The two reported defects are directly guarded.

Static review only; no tests run. Checkout remained clean at a87a59e2.

elif (
outfile is None
and not (path_version_id or kwargs.get("version_id"))
and self.isdir(rpath)
):
# A requested version always names an object, while isdir()
# would look up the latest version, or the prefix of the same
# name when the version does not exist.
os.makedirs(lpath, exist_ok=True)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims and operational behavior): FINDINGS (PR description only, corrected)

Base 28826be7d37983cb54050e849b32a4fe2331ea7d, head 2a68d3481253291aef5089c1d1c8e56f59d9da83. Full claim pass over the PR body, the commit message and the changed docstring.

  • "A directory rpath creates the local directory instead of raising FileNotFoundError": master's test_get_file_directory asserted FileNotFoundError for a directory rpath; this PR replaces that test.
  • "A file rpath with an existing local directory as lpath now raises IsADirectoryError; before, it returned without downloading": master's get_file() returned early on os.path.isdir(lpath). The new code reaches open(lpath, "wb").
  • Corrected: the draft said async recursive get() worked on master because _get() "creates the directories itself". In fact AsyncFileSystem._get() creates only the parent directory of each target (fsspec/asyn.py:808), and master's isdir(lpath) early return then skipped the directory targets that already existed. The PR body now states this.
  • "Item 2 moved to Recursive copy() and mv() send CopyObject for directories, and mv() can delete overlapping copies #1008": Recursive copy() and mv() send CopyObject for directories, and mv() can delete overlapping copies #1008 records the live S3 measurements on master 28826be that I ran for this split:
    • files-only mv("src/*", "src/archive/", recursive=True) and mv("data/*.csv", "data/", recursive=True) delete all data;
    • plain directory mv() raises FileNotFoundError.
  • Evidence scope: the 441 passed, including the live S3 tests, are from a local run on 2a68d348. AWS CI runs only after Ready.
  • AWS operator: test_get_recursive creates 2 small objects under a unique prefix and does not delete them; many live filesystem tests do the same, and the CI bucket expires objects after 1 day (cloudformation/github_actions_oidc.yaml:329-333).

return

# The remote file is opened first so that no local file is created

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase record and self-review of the integration: 68f6499 rebased onto master 4950996 (after #986 rm(), #987 ranges/open(), #988 aio parity, #999), new head 156d460.
Result: FINDINGS (one contract conflict, resolved as below).

Perspective one (behavior):

Perspective two (claims):

# when open() finds no object at the path.
with self.open(rpath, "rb", **kwargs) as remote, open(lpath, "wb") as local:
with contextlib.ExitStack() as stack:
remote = stack.enter_context(self.open(rpath, "rb", **kwargs))
if outfile is None:
# Not abspath(), which would resolve ".." before symlinks.
if parent := os.path.dirname(lpath):
os.makedirs(parent, exist_ok=True)
outfile = stack.enter_context(open(lpath, "wb"))
callback.set_size(remote.size)
while data := remote.read(remote.blocksize):
local.write(data)
outfile.write(data)
callback.relative_update(len(data))

def checksum(self, path: str, **kwargs):
Expand Down
96 changes: 93 additions & 3 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1665,15 +1665,93 @@ def test_cat_file_range_key_ending_in_slash(self, start, end):
fs.cat_file("s3://bucket/dir/", start=start, end=end)
assert ranges == []

def test_get_file_directory(self, tmp_path):
@pytest.mark.parametrize("key", ["dir", None])
def test_get_file_directory(self, tmp_path, key):
# GH-974: recursive get() passes directories, including the bucket,
# which become local directories as with fsspec's get_file().
fs = self._make_fs()
fs.default_cache_type = "bytes"
fs.info = mock.MagicMock(return_value=S3FileSystem._directory_object("bucket", "dir"))
fs.info = mock.MagicMock(return_value=S3FileSystem._directory_object("bucket", key))
rpath = f"s3://bucket/{key}" if key else "s3://bucket"
lpath = tmp_path / "out" / "dir"

fs.get_file(rpath, str(lpath))
assert lpath.is_dir()
# Existing directories are kept.
fs.get_file(rpath, str(lpath))
assert lpath.is_dir()
fs._call.assert_not_called()

def test_get_file_missing(self, tmp_path):
# A missing object leaves no local file or parent directory.
fs = self._make_fs()
fs.default_cache_type = "bytes"
fs.info = mock.MagicMock(side_effect=FileNotFoundError("bucket/key"))

with pytest.raises(FileNotFoundError):
fs.get_file("s3://bucket/dir", str(tmp_path / "dir"))
fs.get_file("s3://bucket/key", str(tmp_path / "new" / "key"))
assert list(tmp_path.iterdir()) == []

def test_get_file_creates_parent_directories(self, tmp_path):
# GH-974: the parent directories used to raise FileNotFoundError.
fs, _ = self._make_object_fs(b"data")
lpath = tmp_path / "new" / "dir" / "key"
callback = Callback()

fs.get_file("s3://bucket/key", str(lpath), callback=callback)
assert lpath.read_bytes() == b"data"
assert callback.size == callback.value == 4

def test_get_file_parent_through_symlink(self, tmp_path):
# The parent is created as open() resolves it: "link/.." is the
# parent of the symlink's target, not tmp_path / "a".
fs, _ = self._make_object_fs(b"data")
(tmp_path / "b" / "sub").mkdir(parents=True)
(tmp_path / "a").mkdir()
(tmp_path / "a" / "link").symlink_to(tmp_path / "b" / "sub")
(tmp_path / "a" / "out").touch()

fs.get_file("s3://bucket/key", str(tmp_path / "a" / "link" / ".." / "out" / "key"))
assert (tmp_path / "b" / "out" / "key").read_bytes() == b"data"

@pytest.mark.parametrize(
("rpath", "kwargs"),
[
("s3://bucket/key", {"version_id": "v1"}),
("s3://bucket/key?versionId=v1", {}),
],
)
def test_get_file_version_id(self, tmp_path, rpath, kwargs):
# A requested version is looked up only by open(), not as a possible
# directory: isdir() would look up the latest version, or the prefix
# of the same name when the version does not exist.
fs, _ = self._make_object_fs(b"data")
lpath = tmp_path / "key"

fs.get_file(rpath, str(lpath), **kwargs)
assert lpath.read_bytes() == b"data"
assert fs.info.call_count == 1

def test_get_file_file_like(self, tmp_path):
# GH-974: a file-like lpath used to raise TypeError, and outfile was
# ignored in favor of the local file lpath.
fs, _ = self._make_object_fs(b"data")
lpath = io.BytesIO()
fs.get_file("s3://bucket/key", lpath)
assert lpath.getvalue() == b"data"
assert not lpath.closed

outfile = io.BytesIO()
fs.get_file("s3://bucket/key", str(tmp_path / "key"), outfile=outfile)
assert outfile.getvalue() == b"data"
assert not outfile.closed
assert not (tmp_path / "key").exists()

# As with fsspec, lpath may be omitted when outfile is given.
outfile = io.BytesIO()
fs.get_file("s3://bucket/key", outfile=outfile)
assert outfile.getvalue() == b"data"

def test_cat_ranges_range(self):
fs, ranges = self._make_object_fs(b"0123456789")

Expand Down Expand Up @@ -3127,6 +3205,18 @@ def test_move(self, fs):
# assert fs.cat(f"{dir2}test_{i}") == bytes(i)
# assert not fs.exists(f"{dir1}test_{i}")

def test_get_recursive(self, fs, tmp_path):
# GH-974: the directory entries used to be written as empty files.
base = (
f"s3://{ENV.s3_staging_bucket}/{ENV.s3_staging_key}{ENV.schema}/"
f"filesystem/test_get_recursive/{uuid.uuid4()}"
)
fs.pipe(f"{base}/a", b"a")
fs.pipe(f"{base}/sub/b", b"b")
fs.get(base, str(tmp_path / "out"), recursive=True)
assert (tmp_path / "out" / "a").read_bytes() == b"a"
assert (tmp_path / "out" / "sub" / "b").read_bytes() == b"b"

def test_get_file(self, fs):
with tempfile.TemporaryDirectory() as tmp:
rpath = f"s3://{ENV.s3_staging_bucket}/{ENV.s3_filesystem_test_file_key}"
Expand Down
Loading