Skip to content

Commit d2da568

Browse files
authored
better handling of ftp features (#91)
* better handling of ftp features * linter cleanup * changelog * e -> error * call base class
1 parent d22fba6 commit d2da568

File tree

3 files changed

+79
-72
lines changed

3 files changed

+79
-72
lines changed

CHANGELOG.md

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

1111
- settext, appendtext, appendbytes, setbytes now raise a TypeError if
1212
the type is wrong, rather than ValueError
13+
- More efficient feature detection for FTPFS
14+
- Fixes for `fs.filesize`
1315

1416
## [2.0.11]
1517

fs/ftpfs.py

Lines changed: 77 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from contextlib import contextmanager
1212
from ftplib import FTP
13-
from ftplib import error_reply
1413
from ftplib import error_perm
1514
from ftplib import error_temp
1615

@@ -44,16 +43,18 @@ def ftp_errors(fs, path=None):
4443
raise errors.RemoteConnectionError(
4544
msg='unable to connect to {}'.format(fs.host)
4645
)
47-
except error_temp as e:
46+
except error_temp as error:
4847
if path is not None:
4948
raise errors.ResourceError(
5049
path,
51-
msg="ftp error on resource '{}' ({})".format(path, e)
50+
msg="ftp error on resource '{}' ({})".format(path, error)
5251
)
5352
else:
54-
raise errors.OperationFailed(msg='ftp error ({})'.format(e))
55-
except error_perm as e:
56-
code, message = parse_ftp_error(e)
53+
raise errors.OperationFailed(
54+
msg='ftp error ({})'.format(error)
55+
)
56+
except error_perm as error:
57+
code, message = _parse_ftp_error(error)
5758
if code == 552:
5859
raise errors.InsufficientStorage(
5960
path=path,
@@ -76,8 +77,10 @@ def manage_ftp(ftp):
7677
except: # pragma: nocover
7778
pass
7879

79-
def parse_ftp_error(e):
80-
code, _, message = text_type(e).partition(' ')
80+
81+
def _parse_ftp_error(error):
82+
"""Extract code and message from ftp error."""
83+
code, _, message = text_type(error).partition(' ')
8184
if code.isdigit():
8285
code = int(code)
8386
return code, message
@@ -98,6 +101,7 @@ def _decode(st, _):
98101
class FTPFile(io.IOBase):
99102

100103
def __init__(self, ftpfs, path, mode):
104+
super(FTPFile, self).__init__()
101105
self.fs = ftpfs
102106
self.path = path
103107
self.mode = Mode(mode)
@@ -108,7 +112,8 @@ def __init__(self, ftpfs, path, mode):
108112
self._write_conn = None
109113

110114
def _open_ftp(self):
111-
ftp = self.fs._open_ftp(self.fs.ftp.encoding)
115+
"""Open an ftp object for the file."""
116+
ftp = self.fs._open_ftp()
112117
ftp.voidcmd(str('TYPE I'))
113118
return ftp
114119

@@ -302,6 +307,7 @@ def __init__(self,
302307
self.timeout = timeout
303308
self.port = port
304309

310+
self.encoding = 'latin-1'
305311
self._ftp = None
306312
self._welcome = None
307313
self._features = None
@@ -317,21 +323,52 @@ def __str__(self):
317323
)
318324
return _fmt.format(host=self.host, port=self.port)
319325

320-
def _open_ftp(self, encoding="utf-8"):
326+
@classmethod
327+
def _parse_features(cls, feat_response):
328+
"""Parse a dict of features from FTP feat response."""
329+
features = {}
330+
if feat_response.split('-')[0] == '211':
331+
for line in feat_response.splitlines():
332+
if line.startswith(' '):
333+
key, _, value = line[1:].partition(' ')
334+
features[key] = value
335+
return features
336+
337+
def _open_ftp(self):
338+
"""Open a new ftp object."""
321339
_ftp = FTP()
322340
_ftp.set_debuglevel(0)
323341
with ftp_errors(self):
324-
_ftp.encoding = encoding
325342
_ftp.connect(self.host, self.port, self.timeout)
326343
_ftp.login(self.user, self.passwd, self.acct)
344+
self._features = {}
345+
try:
346+
feat_response = _decode(_ftp.sendcmd("FEAT"), 'latin-1')
347+
except error_perm:
348+
self.encoding = 'latin-1'
349+
else:
350+
self._features = self._parse_features(feat_response)
351+
self.encoding = (
352+
'utf-8'
353+
if 'UTF8' in self._features
354+
else 'latin-1'
355+
)
356+
if not PY2:
357+
_ftp.file = _ftp.sock.makefile(
358+
'r',
359+
encoding=self.encoding
360+
)
361+
_ftp.encoding = self.encoding
362+
self._welcome = _ftp.welcome
327363
return _ftp
328364

329365
def _manage_ftp(self):
330-
ftp = self._open_ftp(self.ftp.encoding)
366+
ftp = self._open_ftp()
331367
return manage_ftp(ftp)
332368

333369
@property
334370
def ftp_url(self):
371+
"""Get the FTP url this filesystem will open."""
335372
url = (
336373
"ftp://{}".format(self.host)
337374
if self.port == 21
@@ -342,39 +379,17 @@ def ftp_url(self):
342379
@property
343380
def ftp(self):
344381
"""Get a FTP (ftplib) object."""
382+
return self._get_ftp()
383+
384+
def _get_ftp(self):
345385
if self._ftp is None:
346-
_ftp = self._open_ftp('latin-1')
347-
try:
348-
encoding = (
349-
'utf-8'
350-
if 'UTF8' in _ftp.sendcmd('FEAT')
351-
else 'latin-1'
352-
)
353-
except error_perm:
354-
encoding = 'latin-1'
355-
self._ftp = (
356-
_ftp
357-
if encoding == 'latin-1'
358-
else self._open_ftp(encoding)
359-
)
360-
self._welcome = self._ftp.getwelcome()
386+
self._ftp = self._open_ftp()
361387
return self._ftp
362388

363389
@property
364390
def features(self):
365391
"""Get features dict from FTP server."""
366-
if self._features is None:
367-
try:
368-
response = _decode(self.ftp.sendcmd("FEAT"), "ascii")
369-
except error_perm:
370-
self._features = {}
371-
else:
372-
self._features = {}
373-
if response.split('-')[0] == '211':
374-
for line in response.splitlines():
375-
if line.startswith(' '):
376-
k, _, v = line[1:].partition(' ')
377-
self._features[k] = v
392+
self._get_ftp()
378393
return self._features
379394

380395
def _read_dir(self, path):
@@ -395,6 +410,7 @@ def _read_dir(self, path):
395410

396411
@property
397412
def supports_mlst(self):
413+
"""Check if server supports MLST feature."""
398414
return 'MLST' in self.features
399415

400416
def create(self, path, wipe=False):
@@ -408,14 +424,15 @@ def create(self, path, wipe=False):
408424
)
409425

410426
@classmethod
411-
def _parse_ftp_time(cls, t):
427+
def _parse_ftp_time(cls, time_text):
428+
"""Parse a time from an ftp directory listing."""
412429
try:
413-
tm_year = int(t[0:4])
414-
tm_month = int(t[4:6])
415-
tm_day = int(t[6:8])
416-
tm_hour = int(t[8:10])
417-
tm_min = int(t[10:12])
418-
tm_sec = int(t[12:14])
430+
tm_year = int(time_text[0:4])
431+
tm_month = int(time_text[4:6])
432+
tm_day = int(time_text[6:8])
433+
tm_hour = int(time_text[8:10])
434+
tm_min = int(time_text[10:12])
435+
tm_sec = int(time_text[12:14])
419436
except ValueError:
420437
return None
421438
epoch_time = calendar.timegm((
@@ -433,11 +450,11 @@ def _parse_facts(cls, line):
433450
name = None
434451
facts = {}
435452
for fact in line.split(';'):
436-
k, sep, v = fact.partition('=')
453+
key, sep, value = fact.partition('=')
437454
if sep:
438-
k = k.strip().lower()
439-
v = v.strip()
440-
facts[k] = v
455+
key = key.strip().lower()
456+
value = value.strip()
457+
facts[key] = value
441458
else:
442459
name = basename(fact.rstrip('/').strip())
443460
return name if name not in ('.', '..') else None, facts
@@ -514,6 +531,7 @@ def getinfo(self, path, namespaces=None):
514531

515532
def getmeta(self, namespace="standard"):
516533
_meta = {}
534+
self._get_ftp()
517535
if namespace == "standard":
518536
_meta = self._meta.copy()
519537
_meta['unicode_paths'] = "UTF8" in self.features
@@ -542,8 +560,8 @@ def makedir(self, path, permissions=None, recreate=False):
542560
if not (recreate and self.isdir(path)):
543561
try:
544562
self.ftp.mkd(_encode(_path, self.ftp.encoding))
545-
except error_perm as e:
546-
code, _ = parse_ftp_error(e)
563+
except error_perm as error:
564+
code, _ = _parse_ftp_error(error)
547565
if code == 550:
548566
if self.isdir(path):
549567
raise errors.DirectoryExists(path)
@@ -569,13 +587,12 @@ def openbin(self, path, mode="r", buffering=-1, **options):
569587
raise errors.FileExpected(path)
570588
if _mode.exclusive:
571589
raise errors.FileExists(path)
572-
f = FTPFile(self, _path, mode)
573-
return f
590+
ftp_file = FTPFile(self, _path, mode)
591+
return ftp_file
574592

575593
def remove(self, path):
576594
self.check()
577595
_path = self.validatepath(path)
578-
dir_name, file_name = split(_path)
579596
with self._lock:
580597
if self.isdir(path):
581598
raise errors.FileExpected(path=path)
@@ -586,13 +603,12 @@ def removedir(self, path):
586603
_path = self.validatepath(path)
587604
if _path == '/':
588605
raise errors.RemoveRootError()
589-
_dir_name, file_name = split(_path)
590606

591607
with ftp_errors(self, path):
592608
try:
593609
self.ftp.rmd(_encode(_path, self.ftp.encoding))
594-
except error_perm as e:
595-
code, _ = parse_ftp_error(e)
610+
except error_perm as error:
611+
code, _ = _parse_ftp_error(error)
596612
if code == 550:
597613
if self.isfile(path):
598614
raise errors.DirectoryExpected(path)
@@ -611,7 +627,7 @@ def _scandir(self, path, namespaces=None):
611627
str("MLSD ") + _encode(_path, self.ftp.encoding),
612628
lambda l: lines.append(_decode(l, self.ftp.encoding))
613629
)
614-
except error_perm as e:
630+
except error_perm:
615631
if not self.getinfo(path).is_dir:
616632
raise errors.DirectoryExpected(path)
617633
raise # pragma: no cover
@@ -661,8 +677,8 @@ def getbytes(self, path):
661677
str("RETR ") + _encode(_path, self.ftp.encoding),
662678
data.write
663679
)
664-
except error_perm as e:
665-
code, _ = parse_ftp_error(e)
680+
except error_perm as error:
681+
code, _ = _parse_ftp_error(error)
666682
if code == 550:
667683
if self.isdir(path):
668684
raise errors.FileExpected(path)
@@ -679,8 +695,3 @@ def close(self):
679695
pass
680696
self._ftp = None
681697
super(FTPFS, self).close()
682-
683-
684-
if __name__ == "__main__": # pragma: no cover
685-
fs = FTPFS('ftp.mirror.nl', 'anonymous', '[email protected]')
686-
print(list(fs.scandir('/')))

tests/test_ftpfs.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,6 @@ def test_connection_error(self):
143143
with self.assertRaises(errors.RemoteConnectionError):
144144
fs.open('foo.txt')
145145

146-
def test_features(self):
147-
def broken_sendcmd(cmd):
148-
raise ftplib.error_perm('nope')
149-
self.fs.ftp.sendcmd = broken_sendcmd
150-
self.assertEqual(self.fs.features, {})
151-
152146
def test_getmeta_unicode_path(self):
153147
self.assertTrue(self.fs.getmeta().get('unicode_paths'))
154148
self.fs.features

0 commit comments

Comments
 (0)