Skip to content

Commit b42d5ef

Browse files
author
atollk
committed
Added support for "last modified time" in getinfo.
A new namespace "modified" was introduced to the `getinfo` function to potentially use cheaper commands compared to the general "details" namespace. In particular, `FTPFS` now uses the MDTM command if supported by the server.
1 parent 5f73778 commit b42d5ef

File tree

10 files changed

+87
-18
lines changed

10 files changed

+87
-18
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1313
- Added `fs.copy.copy_file_if`, `fs.copy.copy_dir_if`, and `fs.copy.copy_fs_if`.
1414
Closes [#458](https://github.com/PyFilesystem/pyfilesystem2/issues/458).
1515

16+
### Changed
17+
18+
- FTP servers that do not support the MLST command now try to use the MDTM command to
19+
retrieve the last modification timestamp of a resource.
20+
Closes [#456](https://github.com/PyFilesystem/pyfilesystem2/pull/456).
21+
1622
### Fixed
1723

1824
- Fixed performance bugs in `fs.copy.copy_dir_if_newer`. Test cases were adapted to catch those bugs in the future.

fs/base.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,25 @@ def readtext(
678678

679679
gettext = _new_name(readtext, "gettext")
680680

681+
def getmodified(self, path):
682+
# type: (Text) -> Optional[datetime]
683+
"""Get the timestamp of the last modifying access of a resource.
684+
685+
Arguments:
686+
path (str): A path to a resource.
687+
688+
Returns:
689+
datetime: The timestamp of the last modification.
690+
691+
The *modified timestamp* of a file is the point in time
692+
that the file was last changed. Depending on the file system,
693+
it might only have limited accuracy.
694+
695+
"""
696+
if self.getmeta().get("supports_mtime", False):
697+
return self.getinfo(path, namespaces=["modified"]).modified
698+
return self.getinfo(path, namespaces=["details"]).modified
699+
681700
def getmeta(self, namespace="standard"):
682701
# type: (Text) -> Mapping[Text, object]
683702
"""Get meta information regarding a filesystem.
@@ -715,6 +734,8 @@ def getmeta(self, namespace="standard"):
715734
read_only `True` if this filesystem is read only.
716735
supports_rename `True` if this filesystem supports an
717736
`os.rename` operation.
737+
supports_mtime `True` if this filesystem supports a native
738+
operation to retreive the "last modified" time.
718739
=================== ============================================
719740
720741
Most builtin filesystems will provide all these keys, and third-

fs/copy.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,8 @@ def _copy_is_necessary(
398398

399399
elif condition == "newer":
400400
try:
401-
namespace = ("details",)
402-
src_modified = src_fs.getinfo(src_path, namespace).modified
403-
dst_modified = dst_fs.getinfo(dst_path, namespace).modified
401+
src_modified = src_fs.getmodified(src_path)
402+
dst_modified = dst_fs.getmodified(dst_path)
404403
except ResourceNotFound:
405404
return True
406405
else:
@@ -412,9 +411,8 @@ def _copy_is_necessary(
412411

413412
elif condition == "older":
414413
try:
415-
namespace = ("details",)
416-
src_modified = src_fs.getinfo(src_path, namespace).modified
417-
dst_modified = dst_fs.getinfo(dst_path, namespace).modified
414+
src_modified = src_fs.getmodified(src_path)
415+
dst_modified = dst_fs.getmodified(dst_path)
418416
except ResourceNotFound:
419417
return True
420418
else:

fs/ftpfs.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from contextlib import contextmanager
1616
from ftplib import FTP
1717

18+
1819
try:
1920
from ftplib import FTP_TLS
2021
except ImportError as err:
@@ -667,6 +668,18 @@ def getinfo(self, path, namespaces=None):
667668
}
668669
)
669670

671+
if "modified" in namespaces:
672+
if "basic" in namespaces or "details" in namespaces:
673+
raise ValueError(
674+
'Cannot use the "modified" namespace in combination with others.'
675+
)
676+
with self._lock:
677+
with ftp_errors(self, path=path):
678+
cmd = "MDTM " + _encode(self.validatepath(path), self.ftp.encoding)
679+
response = self.ftp.sendcmd(cmd)
680+
modified_info = {"modified": self._parse_ftp_time(response.split()[1])}
681+
return Info({"modified": modified_info})
682+
670683
if self.supports_mlst:
671684
with self._lock:
672685
with ftp_errors(self, path=path):
@@ -692,6 +705,7 @@ def getmeta(self, namespace="standard"):
692705
if namespace == "standard":
693706
_meta = self._meta.copy()
694707
_meta["unicode_paths"] = "UTF8" in self.features
708+
_meta["supports_mtime"] = "MDTM" in self.features
695709
return _meta
696710

697711
def listdir(self, path):

fs/info.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,12 @@ def modified(self):
317317
namespace is not in the Info.
318318
319319
"""
320-
self._require_namespace("details")
321-
_time = self._make_datetime(self.get("details", "modified"))
322-
return _time
320+
try:
321+
self._require_namespace("details")
322+
return self._make_datetime(self.get("details", "modified"))
323+
except MissingInfoNamespace:
324+
self._require_namespace("modified")
325+
return self._make_datetime(self.get("modified", "modified"))
323326

324327
@property
325328
def created(self):

fs/osfs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def __init__(
144144
"network": False,
145145
"read_only": False,
146146
"supports_rename": True,
147+
"supports_mtime": False,
147148
"thread_safe": True,
148149
"unicode_paths": os.path.supports_unicode_filenames,
149150
"virtual": False,

tests/test_ftpfs.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ def test_manager_with_host(self):
144144
@mark.slow
145145
@unittest.skipIf(platform.python_implementation() == "PyPy", "ftp unreliable with PyPy")
146146
class TestFTPFS(FSTestCases, unittest.TestCase):
147-
148147
user = "user"
149148
pasw = "1234"
150149

@@ -243,6 +242,23 @@ def test_getmeta_unicode_path(self):
243242
del self.fs.features["UTF8"]
244243
self.assertFalse(self.fs.getmeta().get("unicode_paths"))
245244

245+
def test_getinfo_modified(self):
246+
self.assertIn("MDTM", self.fs.features)
247+
self.fs.create("bar")
248+
mtime_detail = self.fs.getinfo("bar", ("basic", "details")).modified
249+
mtime_modified = self.fs.getinfo("bar", ("modified",)).modified
250+
# Microsecond and seconds might not actually be supported by all
251+
# FTP commands, so we strip them before comparing if it looks
252+
# like at least one of the two values does not contain them.
253+
replacement = {}
254+
if mtime_detail.microsecond == 0 or mtime_modified.microsecond == 0:
255+
replacement["microsecond"] = 0
256+
if mtime_detail.second == 0 or mtime_modified.second == 0:
257+
replacement["second"] = 0
258+
self.assertEqual(
259+
mtime_detail.replace(**replacement), mtime_modified.replace(**replacement)
260+
)
261+
246262
def test_opener_path(self):
247263
self.fs.makedir("foo")
248264
self.fs.writetext("foo/bar", "baz")
@@ -301,7 +317,6 @@ def test_features(self):
301317
@mark.slow
302318
@unittest.skipIf(platform.python_implementation() == "PyPy", "ftp unreliable with PyPy")
303319
class TestAnonFTPFS(FSTestCases, unittest.TestCase):
304-
305320
user = "anonymous"
306321
pasw = ""
307322

tests/test_memoryfs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ def test_close_mem_free(self):
6969

7070

7171
class TestMemoryFile(unittest.TestCase):
72-
7372
def setUp(self):
7473
self.fs = memoryfs.MemoryFS()
7574

tests/test_opener.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,14 +300,26 @@ 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, tls=False
303+
"ftp.example.org",
304+
passwd="bar",
305+
port=21,
306+
user="foo",
307+
proxy=None,
308+
timeout=10,
309+
tls=False,
304310
)
305311

306312
@mock.patch("fs.ftpfs.FTPFS")
307313
def test_open_ftps(self, mock_FTPFS):
308314
open_fs("ftps://foo:bar@ftp.example.org")
309315
mock_FTPFS.assert_called_once_with(
310-
"ftp.example.org", passwd="bar", port=21, user="foo", proxy=None, timeout=10, tls=True
316+
"ftp.example.org",
317+
passwd="bar",
318+
port=21,
319+
user="foo",
320+
proxy=None,
321+
timeout=10,
322+
tls=True,
311323
)
312324

313325
@mock.patch("fs.ftpfs.FTPFS")

tests/test_wrap.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def test_scandir(self):
177177
]
178178
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
179179
self.assertEqual(sorted(self.cached.scandir("/"), key=key), expected)
180-
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
180+
scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
181181
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
182182
self.assertEqual(sorted(self.cached.scandir("/"), key=key), expected)
183183
scandir.assert_not_called()
@@ -187,7 +187,7 @@ def test_isdir(self):
187187
self.assertTrue(self.cached.isdir("foo"))
188188
self.assertFalse(self.cached.isdir("egg")) # is file
189189
self.assertFalse(self.cached.isdir("spam")) # doesn't exist
190-
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
190+
scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
191191
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
192192
self.assertTrue(self.cached.isdir("foo"))
193193
self.assertFalse(self.cached.isdir("egg"))
@@ -199,7 +199,7 @@ def test_isfile(self):
199199
self.assertTrue(self.cached.isfile("egg"))
200200
self.assertFalse(self.cached.isfile("foo")) # is dir
201201
self.assertFalse(self.cached.isfile("spam")) # doesn't exist
202-
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
202+
scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
203203
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
204204
self.assertTrue(self.cached.isfile("egg"))
205205
self.assertFalse(self.cached.isfile("foo"))
@@ -211,7 +211,7 @@ def test_getinfo(self):
211211
self.assertEqual(self.cached.getinfo("foo"), self.fs.getinfo("foo"))
212212
self.assertEqual(self.cached.getinfo("/"), self.fs.getinfo("/"))
213213
self.assertNotFound(self.cached.getinfo, "spam")
214-
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
214+
scandir.assert_has_calls([mock.call("/", namespaces=None, page=None)])
215215
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
216216
self.assertEqual(self.cached.getinfo("foo"), self.fs.getinfo("foo"))
217217
self.assertEqual(self.cached.getinfo("/"), self.fs.getinfo("/"))

0 commit comments

Comments
 (0)