Skip to content

Commit ab308c6

Browse files
authored
Add SFTPPath implementation (#265)
* tests: add sshpath tests * upath: add SFTPPath implementation * tests: add paramiko dependency * upath.implementations.sftp: remove monkeypatch * tests: add missing protocols to registry tests * tests: fix sftp tests for fsspec<2022.10.0 * tests: xfail mkdir sftp tests on old fsspec
1 parent 3d4ec00 commit ab308c6

File tree

7 files changed

+130
-0
lines changed

7 files changed

+130
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dev = [
5151
"s3fs",
5252
"moto[s3,server]",
5353
"webdav4[fsspec]",
54+
"paramiko",
5455
"wsgidav",
5556
"cheroot",
5657
# "hadoop-test-cluster",

upath/_flavour.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ class WrappedFileSystemFlavour: # (pathlib_abc.FlavourBase)
109109
"https",
110110
"s3",
111111
"s3a",
112+
"sftp",
113+
"ssh",
112114
"smb",
113115
"gs",
114116
"gcs",

upath/implementations/sftp.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import Any
5+
from typing import Generator
6+
7+
if sys.version_info >= (3, 11):
8+
from typing import Self
9+
else:
10+
from typing_extensions import Self
11+
12+
from upath import UPath
13+
14+
_unset: Any = object()
15+
16+
17+
class SFTPPath(UPath):
18+
__slots__ = ()
19+
20+
def iterdir(self) -> Generator[Self, None, None]:
21+
if not self.is_dir():
22+
raise NotADirectoryError(str(self))
23+
else:
24+
return super().iterdir()

upath/registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ class _Registry(MutableMapping[str, "type[upath.UPath]"]):
7676
"memory": "upath.implementations.memory.MemoryPath",
7777
"s3": "upath.implementations.cloud.S3Path",
7878
"s3a": "upath.implementations.cloud.S3Path",
79+
"sftp": "upath.implementations.sftp.SFTPPath",
80+
"ssh": "upath.implementations.sftp.SFTPPath",
7981
"webdav": "upath.implementations.webdav.WebdavPath",
8082
"webdav+http": "upath.implementations.webdav.WebdavPath",
8183
"webdav+https": "upath.implementations.webdav.WebdavPath",

upath/tests/conftest.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from fsspec.registry import _registry
1717
from fsspec.registry import register_implementation
1818
from fsspec.utils import stringify_path
19+
from packaging.version import Version
1920

2021
from .utils import posixify
2122

@@ -464,3 +465,61 @@ def smb_fixture(local_testdir, smb_url, smb_container):
464465
smb.put(local_testdir, "/home/testdir", recursive=True)
465466
yield url
466467
smb.delete("/home/testdir", recursive=True)
468+
469+
470+
@pytest.fixture(scope="module")
471+
def ssh_container():
472+
if shutil.which("docker") is None:
473+
pytest.skip("docker not installed")
474+
475+
name = "fsspec_test_ssh"
476+
stop_docker(name)
477+
cmd = (
478+
"docker run"
479+
" -d"
480+
f" --name {name}"
481+
" -e USER_NAME=user"
482+
" -e PASSWORD_ACCESS=true"
483+
" -e USER_PASSWORD=pass"
484+
" -p 2222:2222"
485+
" linuxserver/openssh-server:latest"
486+
)
487+
try:
488+
subprocess.run(shlex.split(cmd))
489+
time.sleep(1)
490+
yield {
491+
"host": "localhost",
492+
"port": 2222,
493+
"username": "user",
494+
"password": "pass",
495+
}
496+
finally:
497+
stop_docker(name)
498+
499+
500+
@pytest.fixture
501+
def ssh_fixture(ssh_container, local_testdir, monkeypatch):
502+
pytest.importorskip("paramiko", reason="sftp tests require paramiko")
503+
504+
cls = fsspec.get_filesystem_class("ssh")
505+
if cls.put != fsspec.AbstractFileSystem.put:
506+
monkeypatch.setattr(cls, "put", fsspec.AbstractFileSystem.put)
507+
if Version(fsspec.__version__) < Version("2022.10.0"):
508+
from fsspec.callbacks import _DEFAULT_CALLBACK
509+
510+
monkeypatch.setattr(_DEFAULT_CALLBACK, "relative_update", lambda *args: None)
511+
512+
fs = fsspec.filesystem(
513+
"ssh",
514+
host=ssh_container["host"],
515+
port=ssh_container["port"],
516+
username=ssh_container["username"],
517+
password=ssh_container["password"],
518+
)
519+
fs.put(local_testdir, "/app/testdir", recursive=True)
520+
try:
521+
yield "ssh://{username}:{password}@{host}:{port}/app/testdir/".format(
522+
**ssh_container
523+
)
524+
finally:
525+
fs.delete("/app/testdir", recursive=True)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import pytest
2+
3+
from upath import UPath
4+
from upath.tests.cases import BaseTests
5+
from upath.tests.utils import skip_on_windows
6+
from upath.tests.utils import xfail_if_version
7+
8+
_xfail_old_fsspec = xfail_if_version(
9+
"fsspec",
10+
lt="2022.7.0",
11+
reason="fsspec<2022.7.0 sftp does not support create_parents",
12+
)
13+
14+
15+
@skip_on_windows
16+
class TestUPathSFTP(BaseTests):
17+
18+
@pytest.fixture(autouse=True)
19+
def path(self, ssh_fixture):
20+
self.path = UPath(ssh_fixture)
21+
22+
@_xfail_old_fsspec
23+
def test_mkdir(self):
24+
super().test_mkdir()
25+
26+
@_xfail_old_fsspec
27+
def test_mkdir_exists_ok_true(self):
28+
super().test_mkdir_exists_ok_true()
29+
30+
@_xfail_old_fsspec
31+
def test_mkdir_exists_ok_false(self):
32+
super().test_mkdir_exists_ok_false()
33+
34+
@_xfail_old_fsspec
35+
def test_mkdir_parents_true_exists_ok_false(self):
36+
super().test_mkdir_parents_true_exists_ok_false()
37+
38+
@_xfail_old_fsspec
39+
def test_mkdir_parents_true_exists_ok_true(self):
40+
super().test_mkdir_parents_true_exists_ok_true()

upath/tests/test_registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
"memory",
2323
"s3",
2424
"s3a",
25+
"sftp",
2526
"smb",
27+
"ssh",
2628
"webdav",
2729
"webdav+http",
2830
"webdav+https",

0 commit comments

Comments
 (0)