Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FTPS support #449

Merged
merged 9 commits into from
Jan 31, 2021
45 changes: 42 additions & 3 deletions fs/ftpfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@
from collections import OrderedDict
from contextlib import contextmanager
from ftplib import FTP

try:
from ftplib import FTP_TLS
except ImportError as err:
FTP_TLS = err
from ftplib import error_perm
from ftplib import error_temp
from typing import cast

from six import PY2
from six import text_type
from six import raise_from

from . import errors
from .base import FS
Expand Down Expand Up @@ -346,7 +352,30 @@ def seek(self, pos, whence=Seek.set):


class FTPFS(FS):
"""A FTP (File Transport Protocol) Filesystem."""
"""A FTP (File Transport Protocol) Filesystem.

Optionally, the connection can be made securely via TLS. This is known as
FTPS, or FTP Secure. TLS will be enabled when using the ftps:// protocol,
or when setting the `tls` argument to True in the constructor.


Examples:
Create with the constructor::

>>> from fs.ftpfs import FTPFS
>>> ftp_fs = FTPFS()

Or via an FS URL::

>>> import fs
>>> ftp_fs = fs.open_fs('ftp://')

Or via an FS URL, using TLS::

>>> import fs
>>> ftp_fs = fs.open_fs('ftps://')

"""

_meta = {
"invalid_path_chars": "\0",
Expand All @@ -366,6 +395,7 @@ def __init__(
timeout=10, # type: int
port=21, # type: int
proxy=None, # type: Optional[Text]
tls=False, # type: bool
):
# type: (...) -> None
"""Create a new `FTPFS` instance.
Expand All @@ -380,6 +410,7 @@ def __init__(
port (int): FTP port number (default 21).
proxy (str, optional): An FTP proxy, or ``None`` (default)
for no proxy.
tls (bool): Attempt to use FTP over TLS (FTPS) (default: False)

"""
super(FTPFS, self).__init__()
Expand All @@ -390,6 +421,10 @@ def __init__(
self.timeout = timeout
self.port = port
self.proxy = proxy
self.tls = tls

odgalvin marked this conversation as resolved.
Show resolved Hide resolved
if self.tls and isinstance(FTP_TLS, Exception):
raise_from(errors.CreateFailed("FTP over TLS not supported"), FTP_TLS)

self.encoding = "latin-1"
self._ftp = None # type: Optional[FTP]
Expand Down Expand Up @@ -432,11 +467,13 @@ def _parse_features(cls, feat_response):
def _open_ftp(self):
# type: () -> FTP
"""Open a new ftp object."""
_ftp = FTP()
_ftp = FTP_TLS() if self.tls else FTP()
_ftp.set_debuglevel(0)
with ftp_errors(self):
_ftp.connect(self.host, self.port, self.timeout)
_ftp.login(self.user, self.passwd, self.acct)
if self.tls:
_ftp.prot_p()
self._features = {}
try:
feat_response = _decode(_ftp.sendcmd("FEAT"), "latin-1")
Expand Down Expand Up @@ -471,7 +508,9 @@ def ftp_url(self):
_user_part = ""
else:
_user_part = "{}:{}@".format(self.user, self.passwd)
url = "ftp://{}{}".format(_user_part, _host_part)

scheme = "ftps" if self.tls else "ftp"
url = "{}://{}{}".format(scheme, _user_part, _host_part)
return url

@property
Expand Down
3 changes: 2 additions & 1 deletion fs/opener/ftpfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
class FTPOpener(Opener):
"""`FTPFS` opener."""

protocols = ["ftp"]
protocols = ["ftp", "ftps"]

@CreateFailed.catch_all
def open_fs(
Expand All @@ -48,6 +48,7 @@ def open_fs(
passwd=parse_result.password,
proxy=parse_result.params.get("proxy"),
timeout=int(parse_result.params.get("timeout", "10")),
tls=bool(parse_result.protocol == "ftps"),
)
if dir_path:
if create:
Expand Down
4 changes: 4 additions & 0 deletions tests/test_ftpfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ def test_opener(self):
self.assertIsInstance(ftp_fs, FTPFS)
self.assertEqual(ftp_fs.host, "ftp.example.org")

ftps_fs = open_fs("ftps://will:[email protected]")
self.assertIsInstance(ftps_fs, FTPFS)
self.assertTrue(ftps_fs.tls)


class TestFTPErrors(unittest.TestCase):
"""Test the ftp_errors context manager."""
Expand Down
10 changes: 9 additions & 1 deletion tests/test_opener.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,14 @@ def test_user_data_opener(self, app_dir):
def test_open_ftp(self, mock_FTPFS):
open_fs("ftp://foo:[email protected]")
mock_FTPFS.assert_called_once_with(
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=False
)

@mock.patch("fs.ftpfs.FTPFS")
def test_open_ftps(self, mock_FTPFS):
open_fs("ftps://foo:[email protected]")
mock_FTPFS.assert_called_once_with(
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=True
)

@mock.patch("fs.ftpfs.FTPFS")
Expand All @@ -313,4 +320,5 @@ def test_open_ftp_proxy(self, mock_FTPFS):
user="foo",
proxy="ftp.proxy.org",
timeout=10,
tls=False,
)