Skip to content

Commit 62d2def

Browse files
authored
Merge pull request #523 from tfeldmann/move_file-optimization
Optimize `move.move_file` for moving files between different OSFS instances.
2 parents 4834232 + da33922 commit 62d2def

File tree

9 files changed

+242
-38
lines changed

9 files changed

+242
-38
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1717
([#527](https://github.com/PyFilesystem/pyfilesystem2/pull/527)).
1818
- Mark `fs.zipfs.ReadZipFS` as a case-sensitive filesystem
1919
([#527](https://github.com/PyFilesystem/pyfilesystem2/pull/527)).
20+
- Optimized moving files between filesystems with syspaths.
21+
([#523](https://github.com/PyFilesystem/pyfilesystem2/pull/523)).
22+
- Fixed `fs.move.move_file` to clean up the copy on the destination in case of errors.
23+
- `fs.opener.manage_fs` with `writeable=True` will now raise a `ResourceReadOnly`
24+
exception if the managed filesystem is not writeable.
25+
- Marked filesystems wrapped with `fs.wrap.WrapReadOnly` as read-only.
2026

2127

2228
## [2.4.15] - 2022-02-07

CONTRIBUTORS.md

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Many thanks to the following developers for contributing to this project:
4040
- [Silvan Spross](https://github.com/sspross)
4141
- [@sqwishy](https://github.com/sqwishy)
4242
- [Sven Schliesing](https://github.com/muffl0n)
43+
- [Thomas Feldmann](https://github.com/tfeldmann)
4344
- [Tim Gates](https://github.com/timgates42/)
4445
- [@tkossak](https://github.com/tkossak)
4546
- [Todd Levi](https://github.com/televi)

fs/_pathcompat.py

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# mypy: ignore-errors
2+
try:
3+
from os.path import commonpath
4+
except ImportError:
5+
# Return the longest common sub-path of the sequence of paths given as input.
6+
# The paths are not normalized before comparing them (this is the
7+
# responsibility of the caller). Any trailing separator is stripped from the
8+
# returned path.
9+
10+
def commonpath(paths):
11+
"""Given a sequence of path names, returns the longest common sub-path."""
12+
13+
if not paths:
14+
raise ValueError("commonpath() arg is an empty sequence")
15+
16+
paths = tuple(paths)
17+
if isinstance(paths[0], bytes):
18+
sep = b"/"
19+
curdir = b"."
20+
else:
21+
sep = "/"
22+
curdir = "."
23+
24+
split_paths = [path.split(sep) for path in paths]
25+
26+
try:
27+
(isabs,) = set(p[:1] == sep for p in paths)
28+
except ValueError:
29+
raise ValueError("Can't mix absolute and relative paths")
30+
31+
split_paths = [[c for c in s if c and c != curdir] for s in split_paths]
32+
s1 = min(split_paths)
33+
s2 = max(split_paths)
34+
common = s1
35+
for i, c in enumerate(s1):
36+
if c != s2[i]:
37+
common = s1[:i]
38+
break
39+
40+
prefix = sep if isabs else sep[:0]
41+
return prefix + sep.join(common)

fs/base.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
import six
2323

24-
from . import copy, errors, fsencode, iotools, move, tools, walk, wildcard
24+
from . import copy, errors, fsencode, iotools, tools, walk, wildcard
2525
from .copy import copy_modified_time
2626
from .glob import BoundGlobber
2727
from .mode import validate_open_mode
@@ -1083,10 +1083,12 @@ def movedir(self, src_path, dst_path, create=False, preserve_time=False):
10831083
ancestors is not a directory.
10841084
10851085
"""
1086+
from .move import move_dir
1087+
10861088
with self._lock:
10871089
if not create and not self.exists(dst_path):
10881090
raise errors.ResourceNotFound(dst_path)
1089-
move.move_dir(self, src_path, self, dst_path, preserve_time=preserve_time)
1091+
move_dir(self, src_path, self, dst_path, preserve_time=preserve_time)
10901092

10911093
def makedirs(
10921094
self,

fs/move.py

+59-31
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88

99
from .copy import copy_dir
1010
from .copy import copy_file
11+
from .errors import FSError
1112
from .opener import manage_fs
13+
from .osfs import OSFS
14+
from .path import frombase
15+
from ._pathcompat import commonpath
1216

1317
if typing.TYPE_CHECKING:
1418
from .base import FS
@@ -42,6 +46,7 @@ def move_file(
4246
dst_fs, # type: Union[Text, FS]
4347
dst_path, # type: Text
4448
preserve_time=False, # type: bool
49+
cleanup_dst_on_error=True, # type: bool
4550
):
4651
# type: (...) -> None
4752
"""Move a file from one filesystem to another.
@@ -53,26 +58,55 @@ def move_file(
5358
dst_path (str): Path to a file on ``dst_fs``.
5459
preserve_time (bool): If `True`, try to preserve mtime of the
5560
resources (defaults to `False`).
61+
cleanup_dst_on_error (bool): If `True`, tries to delete the file copied to
62+
``dst_fs`` if deleting the file from ``src_fs`` fails (defaults to `True`).
5663
5764
"""
58-
with manage_fs(src_fs) as _src_fs:
59-
with manage_fs(dst_fs, create=True) as _dst_fs:
65+
with manage_fs(src_fs, writeable=True) as _src_fs:
66+
with manage_fs(dst_fs, writeable=True, create=True) as _dst_fs:
6067
if _src_fs is _dst_fs:
6168
# Same filesystem, may be optimized
6269
_src_fs.move(
6370
src_path, dst_path, overwrite=True, preserve_time=preserve_time
6471
)
65-
else:
66-
# Standard copy and delete
67-
with _src_fs.lock(), _dst_fs.lock():
68-
copy_file(
69-
_src_fs,
70-
src_path,
71-
_dst_fs,
72-
dst_path,
73-
preserve_time=preserve_time,
74-
)
72+
return
73+
74+
if _src_fs.hassyspath(src_path) and _dst_fs.hassyspath(dst_path):
75+
# if both filesystems have a syspath we create a new OSFS from a
76+
# common parent folder and use it to move the file.
77+
try:
78+
src_syspath = _src_fs.getsyspath(src_path)
79+
dst_syspath = _dst_fs.getsyspath(dst_path)
80+
common = commonpath([src_syspath, dst_syspath])
81+
if common:
82+
rel_src = frombase(common, src_syspath)
83+
rel_dst = frombase(common, dst_syspath)
84+
with _src_fs.lock(), _dst_fs.lock():
85+
with OSFS(common) as base:
86+
base.move(rel_src, rel_dst, preserve_time=preserve_time)
87+
return # optimization worked, exit early
88+
except ValueError:
89+
# This is raised if we cannot find a common base folder.
90+
# In this case just fall through to the standard method.
91+
pass
92+
93+
# Standard copy and delete
94+
with _src_fs.lock(), _dst_fs.lock():
95+
copy_file(
96+
_src_fs,
97+
src_path,
98+
_dst_fs,
99+
dst_path,
100+
preserve_time=preserve_time,
101+
)
102+
try:
75103
_src_fs.remove(src_path)
104+
except FSError as e:
105+
# if the source cannot be removed we delete the copy on the
106+
# destination
107+
if cleanup_dst_on_error:
108+
_dst_fs.remove(dst_path)
109+
raise e
76110

77111

78112
def move_dir(
@@ -97,22 +131,16 @@ def move_dir(
97131
resources (defaults to `False`).
98132
99133
"""
100-
101-
def src():
102-
return manage_fs(src_fs, writeable=False)
103-
104-
def dst():
105-
return manage_fs(dst_fs, create=True)
106-
107-
with src() as _src_fs, dst() as _dst_fs:
108-
with _src_fs.lock(), _dst_fs.lock():
109-
_dst_fs.makedir(dst_path, recreate=True)
110-
copy_dir(
111-
src_fs,
112-
src_path,
113-
dst_fs,
114-
dst_path,
115-
workers=workers,
116-
preserve_time=preserve_time,
117-
)
118-
_src_fs.removetree(src_path)
134+
with manage_fs(src_fs, writeable=True) as _src_fs:
135+
with manage_fs(dst_fs, writeable=True, create=True) as _dst_fs:
136+
with _src_fs.lock(), _dst_fs.lock():
137+
_dst_fs.makedir(dst_path, recreate=True)
138+
copy_dir(
139+
src_fs,
140+
src_path,
141+
dst_fs,
142+
dst_path,
143+
workers=workers,
144+
preserve_time=preserve_time,
145+
)
146+
_src_fs.removetree(src_path)

fs/opener/registry.py

+9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from .base import Opener
1616
from .errors import UnsupportedProtocol, EntryPointError
17+
from ..errors import ResourceReadOnly
1718
from .parse import parse_fs_url
1819

1920
if typing.TYPE_CHECKING:
@@ -282,10 +283,18 @@ def manage_fs(
282283
"""
283284
from ..base import FS
284285

286+
def assert_writeable(fs):
287+
if fs.getmeta().get("read_only", True):
288+
raise ResourceReadOnly(path="/")
289+
285290
if isinstance(fs_url, FS):
291+
if writeable:
292+
assert_writeable(fs_url)
286293
yield fs_url
287294
else:
288295
_fs = self.open_fs(fs_url, create=create, writeable=writeable, cwd=cwd)
296+
if writeable:
297+
assert_writeable(_fs)
289298
try:
290299
yield _fs
291300
finally:

fs/wrap.py

+8
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
Dict,
3232
Iterator,
3333
IO,
34+
Mapping,
3435
Optional,
3536
Text,
3637
Tuple,
@@ -320,3 +321,10 @@ def touch(self, path):
320321
# type: (Text) -> None
321322
self.check()
322323
raise ResourceReadOnly(path)
324+
325+
def getmeta(self, namespace="standard"):
326+
# type: (Text) -> Mapping[Text, object]
327+
self.check()
328+
meta = dict(self.delegate_fs().getmeta(namespace=namespace))
329+
meta.update(read_only=True, supports_rename=False)
330+
return meta

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ per-file-ignores =
9696
tests/*:E501
9797
fs/opener/*:F811
9898
fs/_fscompat.py:F401
99+
fs/_pathcompat.py:C401
99100
100101
[isort]
101102
default_section = THIRD_PARTY

0 commit comments

Comments
 (0)