diff -r 1e00b161f5f5 Doc/library/ftplib.rst --- a/Doc/library/ftplib.rst Wed Mar 09 12:53:30 2011 +0100 +++ b/Doc/library/ftplib.rst Wed Mar 09 23:30:20 2011 +0100 @@ -254,13 +254,12 @@ Retrieve a file or directory listing in ASCII transfer mode. *cmd* should be an appropriate ``RETR`` command (see :meth:`retrbinary`) or a command such as - ``LIST``, ``NLST`` or ``MLSD`` (usually just the string ``'LIST'``). + ``LIST`` or ``NLST`` (usually just the string ``'LIST'``). ``LIST`` retrieves a list of files and information about those files. - ``NLST`` retrieves a list of file names. On some servers, ``MLSD`` retrieves - a machine readable list of files and information about those files. The - *callback* function is called for each line with a string argument containing - the line with the trailing CRLF stripped. The default *callback* prints the - line to ``sys.stdout``. + ``NLST`` retrieves a list of file names. + The *callback* function is called for each line with a string argument + containing the line with the trailing CRLF stripped. The default *callback* + prints the line to ``sys.stdout``. .. method:: FTP.set_pasv(boolean) @@ -320,6 +319,20 @@ in :meth:`transfercmd`. +.. method:: FTP.mlsd(path="", facts=[]) + + List a directory in a standardized format by using MLSD command + (:rfc:`3659`). If *path* is omitted the current directory is assumed. + *facts* is a list of strings representing the type of information desired + (e.g. *["type", "size", "perm"]*). Return a generator object yielding a + tuple of two elements for every file found in path. First element is the + file name, the second one is a dictionary including a variable number of + "facts" depending on the server and whether *facts* argument has been + provided. + + .. versionadded:: 3.3 + + .. method:: FTP.nlst(argument[, ...]) Return a list of file names as returned by the ``NLST`` command. The @@ -327,6 +340,8 @@ directory). Multiple arguments can be used to pass non-standard options to the ``NLST`` command. + .. deprecated:: 3.3 use :meth:`mlsd` instead + .. method:: FTP.dir(argument[, ...]) @@ -337,6 +352,8 @@ as a *callback* function as for :meth:`retrlines`; the default prints to ``sys.stdout``. This method returns ``None``. + .. deprecated:: 3.3 use :meth:`mlsd` instead + .. method:: FTP.rename(fromname, toname) diff -r 1e00b161f5f5 Lib/ftplib.py --- a/Lib/ftplib.py Wed Mar 09 12:53:30 2011 +0100 +++ b/Lib/ftplib.py Wed Mar 09 23:30:20 2011 +0100 @@ -426,7 +426,7 @@ """Retrieve data in line mode. A new port is created for you. Args: - cmd: A RETR, LIST, NLST, or MLSD command. + cmd: A RETR, LIST, or NLST command. callback: An optional single parameter callable that is called for each line with the trailing CRLF stripped. [default: print_line()] @@ -527,6 +527,35 @@ cmd = cmd + (' ' + arg) self.retrlines(cmd, func) + def mlsd(self, path="", facts=[]): + '''List a directory in a standardized format by using MLSD + command (RFC-3659). If path is omitted the current directory + is assumed. "facts" is a list of strings representing the type + of information desired (e.g. ["type", "size", "perm"]). + + Return a generator object yielding a tuple of two elements + for every file found in path. + First element is the file name, the second one is a dictionary + including a variable number of "facts" depending on the server + and whether "facts" argument has been provided. + ''' + if facts: + self.sendcmd("OPTS MLST " + ";".join(facts) + ";") + if path: + cmd = "MLSD %s" % path + else: + cmd = "MLSD" + lines = [] + self.retrlines(cmd, lines.append) + for line in lines: + facts_found, _, name = line.rstrip(CRLF).partition(' ') + facts_found = facts_found.strip(" ").rstrip(";") + entry = {} + for fact in facts_found.split(";"): + key, _, value = fact.partition("=") + entry[key.lower()] = value + yield (name, entry) + def rename(self, fromname, toname): '''Rename a file.''' resp = self.sendcmd('RNFR ' + fromname) diff -r 1e00b161f5f5 Lib/test/test_ftplib.py --- a/Lib/test/test_ftplib.py Wed Mar 09 12:53:30 2011 +0100 +++ b/Lib/test/test_ftplib.py Wed Mar 09 23:30:20 2011 +0100 @@ -22,10 +22,25 @@ threading = support.import_module('threading') # the dummy data returned by server over the data channel when -# RETR, LIST and NLST commands are issued +# RETR, LIST, NLST, MLSD commands are issued RETR_DATA = 'abcde12345\r\n' * 1000 LIST_DATA = 'foo\r\nbar\r\n' NLST_DATA = 'foo\r\nbar\r\n' +MLSD_DATA = ("type=cdir;perm=el;unique==keVO1+ZF4; test\r\n" + "type=pdir;perm=e;unique==keVO1+d?3; ..\r\n" + "type=OS.unix=slink:/foobar;perm=;unique==keVO1+4G4; foobar\r\n" + "type=OS.unix=chr-13/29;perm=;unique==keVO1+5G4; device\r\n" + "type=OS.unix=blk-11/108;perm=;unique==keVO1+6G4; block\r\n" + "type=file;perm=awr;unique==keVO1+8G4; writable\r\n" + "type=dir;perm=cpmel;unique==keVO1+7G4; promiscuous\r\n" + "type=dir;perm=;unique==keVO1+1t2; no-exec\r\n" + "type=file;perm=r;unique==keVO1+EG4; two words\r\n" + "type=file;perm=r;unique==keVO1+IH4; leading space\r\n" + "type=file;perm=r;unique==keVO1+1G4; file1\r\n" + "type=dir;perm=cpmel;unique==keVO1+7G4; incoming\r\n" + "type=file;perm=r;unique==keVO1+1G4; file2\r\n" + "type=file;perm=r;unique==keVO1+1G4; file3\r\n" + "type=file;perm=r;unique==keVO1+1G4; file4\r\n") class DummyDTPHandler(asynchat.async_chat): @@ -49,6 +64,11 @@ self.dtp_conn_closed = True def push(self, what): + if self.baseclass.next_data is not None: + what = self.baseclass.next_data + self.baseclass.next_data = None + if not what: + return self.close_when_done() super(DummyDTPHandler, self).push(what.encode('ascii')) def handle_error(self): @@ -67,6 +87,7 @@ self.last_received_cmd = None self.last_received_data = '' self.next_response = '' + self.next_data = None self.rest = None self.push('220 welcome') @@ -208,6 +229,14 @@ self.dtp.push(NLST_DATA) self.dtp.close_when_done() + def cmd_opts(self, arg): + self.push('200 opts ok') + + def cmd_mlsd(self, arg): + self.push('125 mlsd ok') + self.dtp.push(MLSD_DATA) + self.dtp.close_when_done() + class DummyFTPServer(asyncore.dispatcher, threading.Thread): @@ -550,6 +579,61 @@ self.client.dir(lambda x: l.append(x)) self.assertEqual(''.join(l), LIST_DATA.replace('\r\n', '')) + def test_mlsd(self): + list(self.client.mlsd()) + list(self.client.mlsd(path='/')) + list(self.client.mlsd(path='/', facts=['size', 'type'])) + + ls = list(self.client.mlsd()) + for name, facts in ls: + self.assertTrue(name) + self.assertTrue('type' in facts) + self.assertTrue('perm' in facts) + self.assertTrue('unique' in facts) + + def set_data(data): + self.server.handler_instance.next_data = data + + def test_entry(line, type=None, perm=None, unique=None, name=None): + type = 'type' if type is None else type + perm = 'perm' if perm is None else perm + unique = 'unique' if unique is None else unique + name = 'name' if name is None else name + set_data(line) + _name, facts = next(self.client.mlsd()) + self.assertEqual(_name, name) + self.assertEqual(facts['type'], type) + self.assertEqual(facts['perm'], perm) + self.assertEqual(facts['unique'], unique) + + # plain + test_entry('type=type;perm=perm;unique=unique; name\r\n') + # "=" in fact value + test_entry('type=ty=pe;perm=perm;unique=unique; name\r\n', type="ty=pe") + test_entry('type==type;perm=perm;unique=unique; name\r\n', type="=type") + test_entry('type=t=y=pe;perm=perm;unique=unique; name\r\n', type="t=y=pe") + test_entry('type=====;perm=perm;unique=unique; name\r\n', type="====") + # spaces in name + test_entry('type=type;perm=perm;unique=unique; na me\r\n', name="na me") + test_entry('type=type;perm=perm;unique=unique; name \r\n', name="name ") + test_entry('type=type;perm=perm;unique=unique; name\r\n', name=" name") + test_entry('type=type;perm=perm;unique=unique; n am e\r\n', name="n am e") + # ";" in name + test_entry('type=type;perm=perm;unique=unique; na;me\r\n', name="na;me") + test_entry('type=type;perm=perm;unique=unique; ;name\r\n', name=";name") + test_entry('type=type;perm=perm;unique=unique; ;name;\r\n', name=";name;") + test_entry('type=type;perm=perm;unique=unique; ;;;;\r\n', name=";;;;") + # case sensitiveness + set_data('Type=type;TyPe=perm;UNIQUE=unique; name\r\n') + _name, facts = next(self.client.mlsd()) + [self.assertTrue(x.islower()) for x in facts.keys()] + # no data (directory empty) + set_data('') + self.assertRaises(StopIteration, next, self.client.mlsd()) + set_data('') + for x in self.client.mlsd(): + self.fail("unexpected data %s" % data) + def test_makeport(self): with self.client.makeport(): # IPv4 is in use, just make sure send_eprt has not been used