From 368c19d91e654f689ef26009181810bd9be9f70a Mon Sep 17 00:00:00 2001 From: Andreas Poehlmann Date: Wed, 8 Oct 2025 14:05:21 +0200 Subject: [PATCH] upath.implementations: add tar path --- typesafety/test_upath_signatures.yml | 8 +++ typesafety/test_upath_types.yml | 12 ++++ upath/core.py | 8 +++ upath/implementations/tar.py | 70 +++++++++++++++++++++ upath/registry.py | 4 ++ upath/tests/implementations/test_tar.py | 84 +++++++++++++++++++++++++ upath/tests/test_registry.py | 1 + upath/types/storage_options.py | 17 +++++ 8 files changed, 204 insertions(+) create mode 100644 upath/implementations/tar.py create mode 100644 upath/tests/implementations/test_tar.py diff --git a/typesafety/test_upath_signatures.yml b/typesafety/test_upath_signatures.yml index e3904e1e..0a681aa3 100644 --- a/typesafety/test_upath_signatures.yml +++ b/typesafety/test_upath_signatures.yml @@ -88,6 +88,8 @@ cls: SFTPPath - module: upath.implementations.smb cls: SMBPath + - module: upath.implementations.tar + cls: TarPath - module: upath.implementations.webdav cls: WebdavPath - module: upath.implementations.zip @@ -271,6 +273,8 @@ cls: SFTPPath - module: upath.implementations.smb cls: SMBPath + - module: upath.implementations.tar + cls: TarPath - module: upath.implementations.webdav cls: WebdavPath - module: upath.implementations.zip @@ -584,6 +588,8 @@ cls: SFTPPath - module: upath.implementations.smb cls: SMBPath + - module: upath.implementations.tar + cls: TarPath - module: upath.implementations.webdav cls: WebdavPath - module: upath.implementations.zip @@ -976,6 +982,8 @@ cls: SFTPPath - module: upath.implementations.smb cls: SMBPath + - module: upath.implementations.tar + cls: TarPath - module: upath.implementations.webdav cls: WebdavPath - module: upath.implementations.zip diff --git a/typesafety/test_upath_types.yml b/typesafety/test_upath_types.yml index 1bd56815..754e6316 100644 --- a/typesafety/test_upath_types.yml +++ b/typesafety/test_upath_types.yml @@ -47,6 +47,8 @@ protocol: sftp - cls_fqn: upath.implementations.smb.SMBPath protocol: smb + - cls_fqn: upath.implementations.tar.TarPath + protocol: tar - cls_fqn: upath.implementations.webdav.WebdavPath protocol: webdav - cls_fqn: upath.implementations.zip.ZipPath @@ -120,6 +122,8 @@ protocol: sftp - cls_fqn: upath.implementations.smb.SMBPath protocol: smb + - cls_fqn: upath.implementations.tar.TarPath + protocol: tar - cls_fqn: upath.implementations.webdav.WebdavPath protocol: webdav - cls_fqn: upath.implementations.zip.ZipPath @@ -201,6 +205,10 @@ cls: SMBPath supported_example_name: timeout supported_example_value: 60 + - module: upath.implementations.tar + cls: TarPath + supported_example_name: compression + supported_example_value: '"gzip"' - module: upath.implementations.webdav cls: WebdavPath supported_example_name: base_url @@ -266,6 +274,10 @@ cls: SMBPath supported_example_name: host unsupported_example_value: 'False' + - module: upath.implementations.tar + cls: TarPath + supported_example_name: compression + unsupported_example_value: '[]' - module: upath.implementations.webdav cls: WebdavPath supported_example_name: base_url diff --git a/upath/core.py b/upath/core.py index 02bfa8b4..95529b1f 100644 --- a/upath/core.py +++ b/upath/core.py @@ -554,6 +554,14 @@ def __new__( **storage_options: Any, ) -> _uimpl.smb.SMBPath: ... @overload # noqa: E301 + def __new__( + cls, + *args: JoinablePathLike, + protocol: Literal["tar"], + chain_parser: FSSpecChainParser = ..., + **storage_options: Any, + ) -> _uimpl.tar.TarPath: ... + @overload # noqa: E301 def __new__( cls, *args: JoinablePathLike, diff --git a/upath/implementations/tar.py b/upath/implementations/tar.py new file mode 100644 index 00000000..7b29a47c --- /dev/null +++ b/upath/implementations/tar.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import stat +import sys +import warnings +from typing import TYPE_CHECKING + +from upath._stat import UPathStatResult +from upath.core import UPath +from upath.types import JoinablePathLike + +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Literal + + if sys.version_info >= (3, 11): + from typing import Self + from typing import Unpack + else: + from typing_extensions import Self + from typing_extensions import Unpack + + from upath._chain import FSSpecChainParser + from upath.types.storage_options import TarStorageOptions + + +__all__ = ["TarPath"] + + +class TarPath(UPath): + __slots__ = () + + if TYPE_CHECKING: + + def __init__( + self, + *args: JoinablePathLike, + protocol: Literal["zip"] | None = ..., + chain_parser: FSSpecChainParser = ..., + **storage_options: Unpack[TarStorageOptions], + ) -> None: ... + + def stat( + self, + *, + follow_symlinks: bool = True, + ) -> UPathStatResult: + if not follow_symlinks: + warnings.warn( + f"{type(self).__name__}.stat(follow_symlinks=False):" + " is currently ignored.", + UserWarning, + stacklevel=2, + ) + info = self.fs.info(self.path).copy() + # convert mode + if info["type"] == "directory": + info["mode"] = stat.S_IFDIR + elif info["type"] == "file": + info["mode"] = stat.S_IFREG + return UPathStatResult.from_info(info) + + def iterdir(self) -> Iterator[Self]: + if self.is_file(): + raise NotADirectoryError(str(self)) + it = iter(super().iterdir()) + p0 = next(it) + if p0.name != "": + yield p0 + yield from it diff --git a/upath/registry.py b/upath/registry.py index 36d2b2d1..ceff1b92 100644 --- a/upath/registry.py +++ b/upath/registry.py @@ -66,6 +66,7 @@ from upath.implementations.memory import MemoryPath as _MemoryPath from upath.implementations.sftp import SFTPPath as _SFTPPath from upath.implementations.smb import SMBPath as _SMBPath + from upath.implementations.tar import TarPath as _TarPath from upath.implementations.webdav import WebdavPath as _WebdavPath from upath.implementations.zip import ZipPath as _ZipPath @@ -102,6 +103,7 @@ class _Registry(MutableMapping[str, "type[upath.UPath]"]): "simplecache": "upath.implementations.cached.SimpleCachePath", "sftp": "upath.implementations.sftp.SFTPPath", "ssh": "upath.implementations.sftp.SFTPPath", + "tar": "upath.implementations.tar.TarPath", "webdav": "upath.implementations.webdav.WebdavPath", "webdav+http": "upath.implementations.webdav.WebdavPath", "webdav+https": "upath.implementations.webdav.WebdavPath", @@ -236,6 +238,8 @@ def get_upath_class(protocol: Literal["sftp", "ssh"]) -> type[_SFTPPath]: ... @overload def get_upath_class(protocol: Literal["smb"]) -> type[_SMBPath]: ... @overload + def get_upath_class(protocol: Literal["tar"]) -> type[_TarPath]: ... + @overload def get_upath_class(protocol: Literal["webdav"]) -> type[_WebdavPath]: ... @overload def get_upath_class(protocol: Literal["zip"]) -> type[_ZipPath]: ... diff --git a/upath/tests/implementations/test_tar.py b/upath/tests/implementations/test_tar.py new file mode 100644 index 00000000..62fac242 --- /dev/null +++ b/upath/tests/implementations/test_tar.py @@ -0,0 +1,84 @@ +import tarfile + +import pytest + +from upath import UPath +from upath.implementations.tar import TarPath + +from ..cases import BaseTests + + +@pytest.fixture(scope="function") +def tarred_testdir_file(local_testdir, tmp_path_factory): + base = tmp_path_factory.mktemp("tarpath") + tar_path = base / "test.tar" + with tarfile.TarFile(tar_path, "w") as tf: + tf.add(local_testdir, arcname="", recursive=True) + return str(tar_path) + + +class TestTarPath(BaseTests): + + @pytest.fixture(autouse=True) + def path(self, tarred_testdir_file): + self.path = UPath("tar://", fo=tarred_testdir_file) + # self.prepare_file_system() done outside of UPath + + def test_is_TarPath(self): + assert isinstance(self.path, TarPath) + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_mkdir(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_mkdir_exists_ok_false(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_mkdir_parents_true_exists_ok_false(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_rename(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_rename2(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_touch(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_touch_unlink(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_write_bytes(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_write_text(self): + pass + + @pytest.mark.skip(reason="Tar filesystem is read-only") + def test_fsspec_compat(self): + pass + + @pytest.mark.skip(reason="Only testing read on TarPath") + def test_move_local(self, tmp_path): + pass + + @pytest.mark.skip(reason="Only testing read on TarPath") + def test_move_into_local(self, tmp_path): + pass + + @pytest.mark.skip(reason="Only testing read on TarPath") + def test_move_memory(self, clear_fsspec_memory_cache): + pass + + @pytest.mark.skip(reason="Only testing read on TarPath") + def test_move_into_memory(self, clear_fsspec_memory_cache): + pass diff --git a/upath/tests/test_registry.py b/upath/tests/test_registry.py index ce300da8..29f358fb 100644 --- a/upath/tests/test_registry.py +++ b/upath/tests/test_registry.py @@ -38,6 +38,7 @@ "webdav+https", "github", "zip", + "tar", } diff --git a/upath/types/storage_options.py b/upath/types/storage_options.py index c5254a1a..8ff25283 100644 --- a/upath/types/storage_options.py +++ b/upath/types/storage_options.py @@ -29,6 +29,7 @@ "SMBStorageOptions", "WebdavStorageOptions", "ZipStorageOptions", + "TarStorageOptions", ] @@ -376,3 +377,19 @@ class ZipStorageOptions( compression: int # Compression method (e.g., zipfile.ZIP_STORED, ZIP_DEFLATED) allowZip64: bool # Enable ZIP64 extensions for large files compresslevel: int | None # Compression level (None uses default for method) + + +class TarStorageOptions( + _AbstractStorageOptions, + _ChainableStorageOptions, + total=False, +): + """Storage options for TAR archive filesystem (read-only)""" + + # Archive file settings + fo: str | Any # Path to TAR file or file-like object + # Compression settings + compression: ( + str | None + ) # Compression method: 'gzip', 'bz2', 'xz', or None for auto-detect + index_store: str | None # Path to store/load the file index cache