Skip to content

Commit 99d5314

Browse files
authored
Merge pull request #470 from PyFilesystem/readonly-removetree
Fix miscellaneous bugs in `fs.wrap`
2 parents 94f0323 + 235b56a commit 99d5314

File tree

3 files changed

+218
-69
lines changed

3 files changed

+218
-69
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
3232
arguments for the `write` and `writelines` methods, as expected by their base class [`io.RawIOBase`](https://docs.python.org/3/library/io.html#io.RawIOBase).
3333
- Various documentation issues, including `MemoryFS` docstring not rendering properly.
3434
- Avoid creating a new connection on every call of `FTPFS.upload`. Closes [#455](https://github.com/PyFilesystem/pyfilesystem2/issues/455).
35+
- `WrapReadOnly.removetree` not raising a `ResourceReadOnly` when called. Closes [#468](https://github.com/PyFilesystem/pyfilesystem2/issues/468).
36+
- `WrapCachedDir.isdir` and `WrapCachedDir.isfile` raising a `ResourceNotFound` error on non-existing path ([#470](https://github.com/PyFilesystem/pyfilesystem2/pull/470)).
3537

3638

3739
## [2.4.12] - 2021-01-14

fs/wrap.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,22 @@ class WrapCachedDir(WrapFS[_F], typing.Generic[_F]):
9292
9393
"""
9494

95+
# FIXME (@althonos): The caching data structure can very likely be
96+
# improved. With the current implementation, if `scandir` result was
97+
# cached for `namespaces=["details", "access"]`, calling `scandir`
98+
# again only with `names=["details"]` will miss the cache, even though
99+
# we are already storing the totality of the required metadata.
100+
#
101+
# A possible solution would be to replaced the cached with a
102+
# Dict[Text, Dict[Text, Dict[Text, Info]]]
103+
# ^ ^ ^ ^-- the actual info object
104+
# | | \-- the path of the directory entry
105+
# | \-- the namespace of the info
106+
# \-- the cached directory entry
107+
#
108+
# Furthermore, `listdir` and `filterdir` calls should be cached as well,
109+
# since they can be written as wrappers of `scandir`.
110+
95111
wrap_name = "cached-dir"
96112

97113
def __init__(self, wrap_fs): # noqa: D107
@@ -135,13 +151,17 @@ def getinfo(self, path, namespaces=None):
135151

136152
def isdir(self, path):
137153
# type: (Text) -> bool
138-
# FIXME(@althonos): this raises an error on non-existing file !
139-
return self.getinfo(path).is_dir
154+
try:
155+
return self.getinfo(path).is_dir
156+
except ResourceNotFound:
157+
return False
140158

141159
def isfile(self, path):
142160
# type: (Text) -> bool
143-
# FIXME(@althonos): this raises an error on non-existing file !
144-
return not self.getinfo(path).is_dir
161+
try:
162+
return not self.getinfo(path).is_dir
163+
except ResourceNotFound:
164+
return False
145165

146166

147167
class WrapReadOnly(WrapFS[_F], typing.Generic[_F]):
@@ -203,6 +223,11 @@ def removedir(self, path):
203223
self.check()
204224
raise ResourceReadOnly(path)
205225

226+
def removetree(self, path):
227+
# type: (Text) -> None
228+
self.check()
229+
raise ResourceReadOnly(path)
230+
206231
def setinfo(self, path, info):
207232
# type: (Text, RawInfo) -> None
208233
self.check()

tests/test_wrap.py

Lines changed: 187 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,219 @@
11
from __future__ import unicode_literals
22

3+
import operator
34
import unittest
45

5-
from fs import errors
6+
try:
7+
from unittest import mock
8+
except ImportError:
9+
import mock
10+
11+
import six
12+
13+
import fs.copy
14+
import fs.mirror
15+
import fs.move
16+
import fs.wrap
17+
import fs.errors
618
from fs import open_fs
7-
from fs import wrap
19+
from fs.info import Info
820

921

10-
class TestWrap(unittest.TestCase):
11-
def test_readonly(self):
12-
mem_fs = open_fs("mem://")
13-
fs = wrap.read_only(mem_fs)
22+
class TestWrapReadOnly(unittest.TestCase):
23+
def setUp(self):
24+
self.fs = open_fs("mem://")
25+
self.ro = fs.wrap.read_only(self.fs)
1426

15-
with self.assertRaises(errors.ResourceReadOnly):
16-
fs.open("foo", "w")
27+
def tearDown(self):
28+
self.fs.close()
1729

18-
with self.assertRaises(errors.ResourceReadOnly):
19-
fs.appendtext("foo", "bar")
30+
def assertReadOnly(self, func, *args, **kwargs):
31+
self.assertRaises(fs.errors.ResourceReadOnly, func, *args, **kwargs)
2032

21-
with self.assertRaises(errors.ResourceReadOnly):
22-
fs.appendbytes("foo", b"bar")
33+
def test_open_w(self):
34+
self.assertReadOnly(self.ro.open, "foo", "w")
2335

24-
with self.assertRaises(errors.ResourceReadOnly):
25-
fs.makedir("foo")
36+
def test_appendtext(self):
37+
self.assertReadOnly(self.ro.appendtext, "foo", "bar")
2638

27-
with self.assertRaises(errors.ResourceReadOnly):
28-
fs.move("foo", "bar")
39+
def test_appendbytes(self):
40+
self.assertReadOnly(self.ro.appendbytes, "foo", b"bar")
2941

30-
with self.assertRaises(errors.ResourceReadOnly):
31-
fs.openbin("foo", "w")
42+
def test_makedir(self):
43+
self.assertReadOnly(self.ro.makedir, "foo")
3244

33-
with self.assertRaises(errors.ResourceReadOnly):
34-
fs.remove("foo")
45+
def test_move(self):
46+
self.assertReadOnly(self.ro.move, "foo", "bar")
3547

36-
with self.assertRaises(errors.ResourceReadOnly):
37-
fs.removedir("foo")
48+
def test_openbin_w(self):
49+
self.assertReadOnly(self.ro.openbin, "foo", "w")
3850

39-
with self.assertRaises(errors.ResourceReadOnly):
40-
fs.setinfo("foo", {})
51+
def test_remove(self):
52+
self.assertReadOnly(self.ro.remove, "foo")
4153

42-
with self.assertRaises(errors.ResourceReadOnly):
43-
fs.settimes("foo", {})
54+
def test_removedir(self):
55+
self.assertReadOnly(self.ro.removedir, "foo")
4456

45-
with self.assertRaises(errors.ResourceReadOnly):
46-
fs.copy("foo", "bar")
57+
def test_removetree(self):
58+
self.assertReadOnly(self.ro.removetree, "foo")
4759

48-
with self.assertRaises(errors.ResourceReadOnly):
49-
fs.create("foo")
60+
def test_setinfo(self):
61+
self.assertReadOnly(self.ro.setinfo, "foo", {})
5062

51-
with self.assertRaises(errors.ResourceReadOnly):
52-
fs.writetext("foo", "bar")
63+
def test_settimes(self):
64+
self.assertReadOnly(self.ro.settimes, "foo", {})
5365

54-
with self.assertRaises(errors.ResourceReadOnly):
55-
fs.writebytes("foo", b"bar")
66+
def test_copy(self):
67+
self.assertReadOnly(self.ro.copy, "foo", "bar")
5668

57-
with self.assertRaises(errors.ResourceReadOnly):
58-
fs.makedirs("foo/bar")
69+
def test_create(self):
70+
self.assertReadOnly(self.ro.create, "foo")
5971

60-
with self.assertRaises(errors.ResourceReadOnly):
61-
fs.touch("foo")
72+
def test_writetext(self):
73+
self.assertReadOnly(self.ro.writetext, "foo", "bar")
6274

63-
with self.assertRaises(errors.ResourceReadOnly):
64-
fs.upload("foo", None)
75+
def test_writebytes(self):
76+
self.assertReadOnly(self.ro.writebytes, "foo", b"bar")
6577

66-
with self.assertRaises(errors.ResourceReadOnly):
67-
fs.writefile("foo", None)
78+
def test_makedirs(self):
79+
self.assertReadOnly(self.ro.makedirs, "foo/bar")
6880

69-
self.assertTrue(mem_fs.isempty("/"))
70-
mem_fs.writebytes("file", b"read me")
71-
with fs.openbin("file") as read_file:
72-
self.assertEqual(read_file.read(), b"read me")
81+
def test_touch(self):
82+
self.assertReadOnly(self.ro.touch, "foo")
7383

74-
with fs.open("file", "rb") as read_file:
75-
self.assertEqual(read_file.read(), b"read me")
84+
def test_upload(self):
85+
self.assertReadOnly(self.ro.upload, "foo", six.BytesIO())
7686

77-
def test_cachedir(self):
78-
mem_fs = open_fs("mem://")
79-
mem_fs.makedirs("foo/bar/baz")
80-
mem_fs.touch("egg")
87+
def test_writefile(self):
88+
self.assertReadOnly(self.ro.writefile, "foo", six.StringIO())
8189

82-
fs = wrap.cache_directory(mem_fs)
83-
self.assertEqual(sorted(fs.listdir("/")), ["egg", "foo"])
84-
self.assertEqual(sorted(fs.listdir("/")), ["egg", "foo"])
85-
self.assertTrue(fs.isdir("foo"))
86-
self.assertTrue(fs.isdir("foo"))
87-
self.assertTrue(fs.isfile("egg"))
88-
self.assertTrue(fs.isfile("egg"))
90+
def test_openbin_r(self):
91+
self.fs.writebytes("file", b"read me")
92+
with self.ro.openbin("file") as read_file:
93+
self.assertEqual(read_file.read(), b"read me")
8994

90-
self.assertEqual(fs.getinfo("foo"), mem_fs.getinfo("foo"))
91-
self.assertEqual(fs.getinfo("foo"), mem_fs.getinfo("foo"))
95+
def test_open_r(self):
96+
self.fs.writebytes("file", b"read me")
97+
with self.ro.open("file", "rb") as read_file:
98+
self.assertEqual(read_file.read(), b"read me")
9299

93-
self.assertEqual(fs.getinfo("/"), mem_fs.getinfo("/"))
94-
self.assertEqual(fs.getinfo("/"), mem_fs.getinfo("/"))
95100

96-
with self.assertRaises(errors.ResourceNotFound):
97-
fs.getinfo("/foofoo")
101+
class TestWrapReadOnlySyspath(unittest.TestCase):
102+
# If the wrapped fs has a syspath, there is a chance that somewhere
103+
# in fs.copy or fs.mirror we try to use it to our advantage, but
104+
# we want to make sure these implementations don't circumvent the
105+
# wrapper.
106+
107+
def setUp(self):
108+
self.fs = open_fs("temp://")
109+
self.ro = fs.wrap.read_only(self.fs)
110+
self.src = open_fs("temp://")
111+
self.src.touch("foo")
112+
self.src.makedir("bar")
113+
114+
def tearDown(self):
115+
self.fs.close()
116+
self.src.close()
117+
118+
def assertReadOnly(self, func, *args, **kwargs):
119+
self.assertRaises(fs.errors.ResourceReadOnly, func, *args, **kwargs)
120+
121+
def test_copy_fs(self):
122+
self.assertReadOnly(fs.copy.copy_fs, self.src, self.ro)
123+
124+
def test_copy_fs_if_newer(self):
125+
self.assertReadOnly(fs.copy.copy_fs_if_newer, self.src, self.ro)
126+
127+
def test_copy_file(self):
128+
self.assertReadOnly(fs.copy.copy_file, self.src, "foo", self.ro, "foo")
129+
130+
def test_copy_file_if_newer(self):
131+
self.assertReadOnly(fs.copy.copy_file_if_newer, self.src, "foo", self.ro, "foo")
132+
133+
def test_copy_structure(self):
134+
self.assertReadOnly(fs.copy.copy_structure, self.src, self.ro)
135+
136+
def test_mirror(self):
137+
self.assertReadOnly(fs.mirror.mirror, self.src, self.ro)
138+
fs.mirror.mirror(self.src, self.fs)
139+
self.fs.touch("baz")
140+
self.assertReadOnly(fs.mirror.mirror, self.src, self.ro)
141+
142+
def test_move_fs(self):
143+
self.assertReadOnly(fs.move.move_fs, self.src, self.ro)
144+
self.src.removetree("/")
145+
self.fs.touch("foo")
146+
self.assertReadOnly(fs.move.move_fs, self.ro, self.src)
147+
148+
def test_move_file(self):
149+
self.assertReadOnly(fs.move.move_file, self.src, "foo", self.ro, "foo")
150+
self.fs.touch("baz")
151+
self.assertReadOnly(fs.move.move_file, self.ro, "baz", self.src, "foo")
152+
153+
def test_move_dir(self):
154+
self.assertReadOnly(fs.move.move_file, self.src, "bar", self.ro, "bar")
155+
self.fs.makedir("baz")
156+
self.assertReadOnly(fs.move.move_dir, self.ro, "baz", self.src, "baz")
157+
158+
159+
class TestWrapCachedDir(unittest.TestCase):
160+
def setUp(self):
161+
self.fs = open_fs("mem://")
162+
self.fs.makedirs("foo/bar/baz")
163+
self.fs.touch("egg")
164+
self.cached = fs.wrap.cache_directory(self.fs)
165+
166+
def tearDown(self):
167+
self.fs.close()
168+
169+
def assertNotFound(self, func, *args, **kwargs):
170+
self.assertRaises(fs.errors.ResourceNotFound, func, *args, **kwargs)
171+
172+
def test_scandir(self):
173+
key = operator.attrgetter("name")
174+
expected = [
175+
Info({"basic": {"name": "egg", "is_dir": False}}),
176+
Info({"basic": {"name": "foo", "is_dir": True}}),
177+
]
178+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
179+
self.assertEqual(sorted(self.cached.scandir("/"), key=key), expected)
180+
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
181+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
182+
self.assertEqual(sorted(self.cached.scandir("/"), key=key), expected)
183+
scandir.assert_not_called()
184+
185+
def test_isdir(self):
186+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
187+
self.assertTrue(self.cached.isdir("foo"))
188+
self.assertFalse(self.cached.isdir("egg")) # is file
189+
self.assertFalse(self.cached.isdir("spam")) # doesn't exist
190+
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
191+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
192+
self.assertTrue(self.cached.isdir("foo"))
193+
self.assertFalse(self.cached.isdir("egg"))
194+
self.assertFalse(self.cached.isdir("spam"))
195+
scandir.assert_not_called()
196+
197+
def test_isfile(self):
198+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
199+
self.assertTrue(self.cached.isfile("egg"))
200+
self.assertFalse(self.cached.isfile("foo")) # is dir
201+
self.assertFalse(self.cached.isfile("spam")) # doesn't exist
202+
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
203+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
204+
self.assertTrue(self.cached.isfile("egg"))
205+
self.assertFalse(self.cached.isfile("foo"))
206+
self.assertFalse(self.cached.isfile("spam"))
207+
scandir.assert_not_called()
208+
209+
def test_getinfo(self):
210+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
211+
self.assertEqual(self.cached.getinfo("foo"), self.fs.getinfo("foo"))
212+
self.assertEqual(self.cached.getinfo("/"), self.fs.getinfo("/"))
213+
self.assertNotFound(self.cached.getinfo, "spam")
214+
scandir.assert_has_calls([mock.call('/', namespaces=None, page=None)])
215+
with mock.patch.object(self.fs, "scandir", wraps=self.fs.scandir) as scandir:
216+
self.assertEqual(self.cached.getinfo("foo"), self.fs.getinfo("foo"))
217+
self.assertEqual(self.cached.getinfo("/"), self.fs.getinfo("/"))
218+
self.assertNotFound(self.cached.getinfo, "spam")
219+
scandir.assert_not_called()

0 commit comments

Comments
 (0)