Repository: PyFilesystem/pyfilesystem2

Issue:
Add support for different FTP LIST locales (Windows CE exclusive?)
We are using pyfilesystem2 to access a German locale FTP server on an MS Windows CE operating system. I cannot pinpoint whether it is the locale, the OS, or a different factor altogether, but the FTP LIST command, which is apparently used almost everywhere in the library, yields its data in a different format than Windows NT. Thus, `FTPFS.read_dir` fails to parse any items and always returns an empty dict and most operations fail.

To be precise, the LIST output only differs slightly in that it uses 24 hour days rather than 12 hour AM/PM days.

WinNT:
    `11-02-18  02:12PM       <DIR>          images`
    `11-02-18  03:33PM                 9276 logo.gif`

WinCE:
    `11-02-18  14:12       <DIR>          images`
    `11-02-18  15:33                 9276 logo.gif`

We would like to see support for that format added, either as an extension to the "WindowsNT" parsing, or as a new kind of response parsing. If this is an acceptable change, we'd create the suitable PR for it. It would be great if this fix would make it into 2.4.12, as we'd have to keep using a custom fix in our code until 2.4.13 otherwise. 

Thanks!

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b965c9..9e0a768 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Missing `mode` attribute to `_MemoryFile` objects returned by `MemoryFS.openbin`.
 - Missing `readinto` method for `MemoryFS` and `FTPFS` file objects. Closes
   [#380](https://github.com/PyFilesystem/pyfilesystem2/issues/380).
+- Added compatibility if a Windows FTP server returns file information to the
+  `LIST` command with 24-hour times. Closes [#438](https://github.com/PyFilesystem/pyfilesystem2/issues/438).
 
 ### Changed
 
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index ba15fd7..5df2715 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -2,6 +2,7 @@
 
 Many thanks to the following developers for contributing to this project:
 
+- [Andreas Tollkötter](https://github.com/atollk)
 - [C. W.](https://github.com/chfw)
 - [Diego Argueta](https://github.com/dargueta)
 - [Geoff Jukes](https://github.com/geoffjukes)
@@ -9,7 +10,7 @@ Many thanks to the following developers for contributing to this project:
 - [Justin Charlong](https://github.com/jcharlong)
 - [Louis Sautier](https://github.com/sbraz)
 - [Martin Larralde](https://github.com/althonos)
+- [Morten Engelhardt Olsen](https://github.com/xoriath)
 - [Nick Henderson](https://github.com/nwh)
 - [Will McGugan](https://github.com/willmcgugan)
 - [Zmej Serow](https://github.com/zmej-serow)
-- [Morten Engelhardt Olsen](https://github.com/xoriath)
diff --git a/fs/_ftp_parse.py b/fs/_ftp_parse.py
index b503d73..9e15a26 100644
--- a/fs/_ftp_parse.py
+++ b/fs/_ftp_parse.py
@@ -41,14 +41,17 @@ RE_LINUX = re.compile(
 RE_WINDOWSNT = re.compile(
     r"""
     ^
-    (?P<modified>.*?(AM|PM))
-    \s*
-    (?P<size>(<DIR>|\d*))
-    \s*
+    (?P<modified_date>\S+)
+    \s+
+    (?P<modified_time>\S+(AM|PM)?)
+    \s+
+    (?P<size>(<DIR>|\d+))
+    \s+
     (?P<name>.*)
     $
     """,
-    re.VERBOSE)
+    re.VERBOSE,
+)
 
 
 def get_decoders():
@@ -82,15 +85,13 @@ def parse_line(line):
 
 
 def _parse_time(t, formats):
-    t = " ".join(token.strip() for token in t.lower().split(" "))
-
-    _t = None
     for frmt in formats:
         try:
             _t = time.strptime(t, frmt)
+            break
         except ValueError:
             continue
-    if not _t:
+    else:
         return None
 
     year = _t.tm_year if _t.tm_year != 1900 else time.localtime().tm_year
@@ -104,6 +105,10 @@ def _parse_time(t, formats):
     return epoch_time
 
 
+def _decode_linux_time(mtime):
+    return _parse_time(mtime, formats=["%b %d %Y", "%b %d %H:%M"])
+
+
 def decode_linux(line, match):
     perms, links, uid, gid, size, mtime, name = match.groups()
     is_link = perms.startswith("l")
@@ -114,7 +119,7 @@ def decode_linux(line, match):
         _link_name = _link_name.strip()
     permissions = Permissions.parse(perms[1:])
 
-    mtime_epoch = _parse_time(mtime, formats=["%b %d %Y", "%b %d %H:%M"])
+    mtime_epoch = _decode_linux_time(mtime)
 
     name = unicodedata.normalize("NFC", name)
 
@@ -138,12 +143,18 @@ def decode_linux(line, match):
     return raw_info
 
 
+def _decode_windowsnt_time(mtime):
+    return _parse_time(mtime, formats=["%d-%m-%y %I:%M%p", "%d-%m-%y %H:%M"])
+
+
 def decode_windowsnt(line, match):
     """
-    Decodes a Windows NT FTP LIST line like these two:
+    Decodes a Windows NT FTP LIST line like one of these:
 
     `11-02-18  02:12PM       <DIR>          images`
     `11-02-18  03:33PM                 9276 logo.gif`
+
+    Alternatively, the time (02:12PM) might also be present in 24-hour format (14:12).
     """
     is_dir = match.group("size") == "<DIR>"
 
@@ -161,7 +172,9 @@ def decode_windowsnt(line, match):
     if not is_dir:
         raw_info["details"]["size"] = int(match.group("size"))
 
-    modified = _parse_time(match.group("modified"), formats=["%d-%m-%y %I:%M%p"])
+    modified = _decode_windowsnt_time(
+        match.group("modified_date") + " " + match.group("modified_time")
+    )
     if modified is not None:
         raw_info["details"]["modified"] = modified
 


Test patch:
diff --git a/tests/test_ftp_parse.py b/tests/test_ftp_parse.py
index d0abc05..b9a69cf 100644
--- a/tests/test_ftp_parse.py
+++ b/tests/test_ftp_parse.py
@@ -17,17 +17,18 @@ class TestFTPParse(unittest.TestCase):
     @mock.patch("time.localtime")
     def test_parse_time(self, mock_localtime):
         self.assertEqual(
-            ftp_parse._parse_time("JUL 05 1974", formats=["%b %d %Y"]),
-            142214400.0)
+            ftp_parse._parse_time("JUL 05 1974", formats=["%b %d %Y"]), 142214400.0
+        )
 
         mock_localtime.return_value = time2017
         self.assertEqual(
-            ftp_parse._parse_time("JUL 05 02:00", formats=["%b %d %H:%M"]),
-            1499220000.0)
+            ftp_parse._parse_time("JUL 05 02:00", formats=["%b %d %H:%M"]), 1499220000.0
+        )
 
         self.assertEqual(
             ftp_parse._parse_time("05-07-17  02:00AM", formats=["%d-%m-%y %I:%M%p"]),
-            1499220000.0)
+            1499220000.0,
+        )
 
         self.assertEqual(ftp_parse._parse_time("notadate", formats=["%b %d %Y"]), None)
 
@@ -164,39 +165,68 @@ drwxr-xr-x   2 foo-user foo-group         0 Jan  5 11:59 240485
     def test_decode_windowsnt(self, mock_localtime):
         mock_localtime.return_value = time2017
         directory = """\
+unparsable line
 11-02-17  02:00AM       <DIR>          docs
 11-02-17  02:12PM       <DIR>          images
-11-02-17  02:12PM       <DIR>          AM to PM
+11-02-17 02:12PM <DIR> AM to PM
 11-02-17  03:33PM                 9276 logo.gif
+05-11-20   22:11  <DIR>       src
+11-02-17   01:23       1      12
+11-02-17    4:54                 0 icon.bmp
+11-02-17    4:54AM                 0 icon.gif
+11-02-17    4:54PM                 0 icon.png
+11-02-17    16:54                 0 icon.jpg
 """
         expected = [
             {
                 "basic": {"is_dir": True, "name": "docs"},
                 "details": {"modified": 1486778400.0, "type": 1},
-                "ftp": {
-                    "ls": "11-02-17  02:00AM       <DIR>          docs"
-                },
+                "ftp": {"ls": "11-02-17  02:00AM       <DIR>          docs"},
             },
             {
                 "basic": {"is_dir": True, "name": "images"},
                 "details": {"modified": 1486822320.0, "type": 1},
-                "ftp": {
-                    "ls": "11-02-17  02:12PM       <DIR>          images"
-                },
+                "ftp": {"ls": "11-02-17  02:12PM       <DIR>          images"},
             },
             {
                 "basic": {"is_dir": True, "name": "AM to PM"},
                 "details": {"modified": 1486822320.0, "type": 1},
-                "ftp": {
-                    "ls": "11-02-17  02:12PM       <DIR>          AM to PM"
-                },
+                "ftp": {"ls": "11-02-17 02:12PM <DIR> AM to PM"},
             },
             {
                 "basic": {"is_dir": False, "name": "logo.gif"},
                 "details": {"modified": 1486827180.0, "size": 9276, "type": 2},
-                "ftp": {
-                    "ls": "11-02-17  03:33PM                 9276 logo.gif"
-                },
+                "ftp": {"ls": "11-02-17  03:33PM                 9276 logo.gif"},
+            },
+            {
+                "basic": {"is_dir": True, "name": "src"},
+                "details": {"modified": 1604614260.0, "type": 1},
+                "ftp": {"ls": "05-11-20   22:11  <DIR>       src"},
+            },
+            {
+                "basic": {"is_dir": False, "name": "12"},
+                "details": {"modified": 1486776180.0, "size": 1, "type": 2},
+                "ftp": {"ls": "11-02-17   01:23       1      12"},
+            },
+            {
+                "basic": {"is_dir": False, "name": "icon.bmp"},
+                "details": {"modified": 1486788840.0, "size": 0, "type": 2},
+                "ftp": {"ls": "11-02-17    4:54                 0 icon.bmp"},
+            },
+            {
+                "basic": {"is_dir": False, "name": "icon.gif"},
+                "details": {"modified": 1486788840.0, "size": 0, "type": 2},
+                "ftp": {"ls": "11-02-17    4:54AM                 0 icon.gif"},
+            },
+            {
+                "basic": {"is_dir": False, "name": "icon.png"},
+                "details": {"modified": 1486832040.0, "size": 0, "type": 2},
+                "ftp": {"ls": "11-02-17    4:54PM                 0 icon.png"},
+            },
+            {
+                "basic": {"is_dir": False, "name": "icon.jpg"},
+                "details": {"modified": 1486832040.0, "size": 0, "type": 2},
+                "ftp": {"ls": "11-02-17    16:54                 0 icon.jpg"},
             },
         ]