Skip to content

Commit 1c68499

Browse files
authored
Merge pull request #449 from odgalvin/ftps_support
2 parents 8784fd6 + e2c8bec commit 1c68499

File tree

6 files changed

+66
-5
lines changed

6 files changed

+66
-5
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
88

99
## Unreleased
1010

11+
### Added
12+
13+
- Added FTP over TLS (FTPS) support to FTPFS.
14+
Closes [#437](https://github.com/PyFilesystem/pyfilesystem2/issues/437),
15+
[#449](https://github.com/PyFilesystem/pyfilesystem2/pull/449).
16+
1117
### Changed
1218

1319
- Make `FS.upload` explicit about the expected error when the parent directory of the destination does not exist.

CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ Many thanks to the following developers for contributing to this project:
1313
- [Martin Larralde](https://github.com/althonos)
1414
- [Morten Engelhardt Olsen](https://github.com/xoriath)
1515
- [Nick Henderson](https://github.com/nwh)
16+
- [Oliver Galvin](https://github.com/odgalvin)
1617
- [Will McGugan](https://github.com/willmcgugan)
1718
- [Zmej Serow](https://github.com/zmej-serow)

fs/ftpfs.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,18 @@
1414
from collections import OrderedDict
1515
from contextlib import contextmanager
1616
from ftplib import FTP
17+
18+
try:
19+
from ftplib import FTP_TLS
20+
except ImportError as err:
21+
FTP_TLS = err # type: ignore
1722
from ftplib import error_perm
1823
from ftplib import error_temp
1924
from typing import cast
2025

2126
from six import PY2
2227
from six import text_type
28+
from six import raise_from
2329

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

347353

348354
class FTPFS(FS):
349-
"""A FTP (File Transport Protocol) Filesystem."""
355+
"""A FTP (File Transport Protocol) Filesystem.
356+
357+
Optionally, the connection can be made securely via TLS. This is known as
358+
FTPS, or FTP Secure. TLS will be enabled when using the ftps:// protocol,
359+
or when setting the `tls` argument to True in the constructor.
360+
361+
362+
Examples:
363+
Create with the constructor::
364+
365+
>>> from fs.ftpfs import FTPFS
366+
>>> ftp_fs = FTPFS()
367+
368+
Or via an FS URL::
369+
370+
>>> import fs
371+
>>> ftp_fs = fs.open_fs('ftp://')
372+
373+
Or via an FS URL, using TLS::
374+
375+
>>> import fs
376+
>>> ftp_fs = fs.open_fs('ftps://')
377+
378+
"""
350379

351380
_meta = {
352381
"invalid_path_chars": "\0",
@@ -366,6 +395,7 @@ def __init__(
366395
timeout=10, # type: int
367396
port=21, # type: int
368397
proxy=None, # type: Optional[Text]
398+
tls=False, # type: bool
369399
):
370400
# type: (...) -> None
371401
"""Create a new `FTPFS` instance.
@@ -380,6 +410,7 @@ def __init__(
380410
port (int): FTP port number (default 21).
381411
proxy (str, optional): An FTP proxy, or ``None`` (default)
382412
for no proxy.
413+
tls (bool): Attempt to use FTP over TLS (FTPS) (default: False)
383414
384415
"""
385416
super(FTPFS, self).__init__()
@@ -390,6 +421,10 @@ def __init__(
390421
self.timeout = timeout
391422
self.port = port
392423
self.proxy = proxy
424+
self.tls = tls
425+
426+
if self.tls and isinstance(FTP_TLS, Exception):
427+
raise_from(errors.CreateFailed("FTP over TLS not supported"), FTP_TLS)
393428

394429
self.encoding = "latin-1"
395430
self._ftp = None # type: Optional[FTP]
@@ -432,11 +467,15 @@ def _parse_features(cls, feat_response):
432467
def _open_ftp(self):
433468
# type: () -> FTP
434469
"""Open a new ftp object."""
435-
_ftp = FTP()
470+
_ftp = FTP_TLS() if self.tls else FTP()
436471
_ftp.set_debuglevel(0)
437472
with ftp_errors(self):
438473
_ftp.connect(self.host, self.port, self.timeout)
439474
_ftp.login(self.user, self.passwd, self.acct)
475+
try:
476+
_ftp.prot_p() # type: ignore
477+
except AttributeError:
478+
pass
440479
self._features = {}
441480
try:
442481
feat_response = _decode(_ftp.sendcmd("FEAT"), "latin-1")
@@ -471,7 +510,9 @@ def ftp_url(self):
471510
_user_part = ""
472511
else:
473512
_user_part = "{}:{}@".format(self.user, self.passwd)
474-
url = "ftp://{}{}".format(_user_part, _host_part)
513+
514+
scheme = "ftps" if self.tls else "ftp"
515+
url = "{}://{}{}".format(scheme, _user_part, _host_part)
475516
return url
476517

477518
@property

fs/opener/ftpfs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
class FTPOpener(Opener):
2424
"""`FTPFS` opener."""
2525

26-
protocols = ["ftp"]
26+
protocols = ["ftp", "ftps"]
2727

2828
@CreateFailed.catch_all
2929
def open_fs(
@@ -48,6 +48,7 @@ def open_fs(
4848
passwd=parse_result.password,
4949
proxy=parse_result.params.get("proxy"),
5050
timeout=int(parse_result.params.get("timeout", "10")),
51+
tls=bool(parse_result.protocol == "ftps"),
5152
)
5253
if dir_path:
5354
if create:

tests/test_ftpfs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ def test_opener(self):
8888
self.assertIsInstance(ftp_fs, FTPFS)
8989
self.assertEqual(ftp_fs.host, "ftp.example.org")
9090

91+
ftps_fs = open_fs("ftps://will:wfc@ftp.example.org")
92+
self.assertIsInstance(ftps_fs, FTPFS)
93+
self.assertTrue(ftps_fs.tls)
94+
9195

9296
class TestFTPErrors(unittest.TestCase):
9397
"""Test the ftp_errors context manager."""

tests/test_opener.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,14 @@ def test_user_data_opener(self, app_dir):
300300
def test_open_ftp(self, mock_FTPFS):
301301
open_fs("ftp://foo:bar@ftp.example.org")
302302
mock_FTPFS.assert_called_once_with(
303-
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10
303+
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=False
304+
)
305+
306+
@mock.patch("fs.ftpfs.FTPFS")
307+
def test_open_ftps(self, mock_FTPFS):
308+
open_fs("ftps://foo:bar@ftp.example.org")
309+
mock_FTPFS.assert_called_once_with(
310+
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=True
304311
)
305312

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

0 commit comments

Comments
 (0)