Skip to content

Commit

Permalink
Merge pull request #449 from odgalvin/ftps_support
Browse files Browse the repository at this point in the history
  • Loading branch information
althonos committed Jan 31, 2021
2 parents 8784fd6 + e2c8bec commit 1c68499
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 5 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added FTP over TLS (FTPS) support to FTPFS.
Closes [#437](https://github.com/PyFilesystem/pyfilesystem2/issues/437),
[#449](https://github.com/PyFilesystem/pyfilesystem2/pull/449).

### Changed

- Make `FS.upload` explicit about the expected error when the parent directory of the destination does not exist.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ Many thanks to the following developers for contributing to this project:
- [Martin Larralde](https://github.com/althonos)
- [Morten Engelhardt Olsen](https://github.com/xoriath)
- [Nick Henderson](https://github.com/nwh)
- [Oliver Galvin](https://github.com/odgalvin)
- [Will McGugan](https://github.com/willmcgugan)
- [Zmej Serow](https://github.com/zmej-serow)
47 changes: 44 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 # type: ignore
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

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,15 @@ 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)
try:
_ftp.prot_p() # type: ignore
except AttributeError:
pass
self._features = {}
try:
feat_response = _decode(_ftp.sendcmd("FEAT"), "latin-1")
Expand Down Expand Up @@ -471,7 +510,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:wfc@ftp.example.org")
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:bar@ftp.example.org")
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:bar@ftp.example.org")
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,
)

0 comments on commit 1c68499

Please sign in to comment.