1414from collections import OrderedDict
1515from contextlib import contextmanager
1616from ftplib import FTP
17+
18+ try :
19+ from ftplib import FTP_TLS
20+ except ImportError as err :
21+ FTP_TLS = err # type: ignore
1722from ftplib import error_perm
1823from ftplib import error_temp
1924from typing import cast
2025
2126from six import PY2
2227from six import text_type
28+ from six import raise_from
2329
2430from . import errors
2531from .base import FS
@@ -346,7 +352,30 @@ def seek(self, pos, whence=Seek.set):
346352
347353
348354class 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
0 commit comments