Skip to content

Commit a71370d

Browse files
Martin Larraldewillmcgugan
authored andcommitted
Implement OSFS.copy using sendfile or shutil.copy (#216)
* Use `shutil.copy2` as the backend implementation of `fs.osfs.OSFS.copy` * Add support for `pyfastcopy` extra in `fs.osfs` if installed * Include backport of `shutil.copyfile` using `sendfile` to `fs.osfs` * Add tests to check for unsupported `sendfile` in `tests.test_osfs` * Refactor `OSFS.copy` into a single method for both implementations * Add test for line not covered in `OSFS.copy` * Use `os.fstat` in `OSFS.copy` to get the size for `sendfile`
1 parent 1a1ed3f commit a71370d

File tree

2 files changed

+173
-76
lines changed

2 files changed

+173
-76
lines changed

fs/osfs.py

Lines changed: 154 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import logging
1515
import os
1616
import platform
17+
import shutil
1718
import stat
1819
import sys
1920
import typing
@@ -28,13 +29,21 @@
2829
except ImportError: # pragma: no cover
2930
scandir = None
3031

32+
try:
33+
from os import sendfile
34+
except ImportError:
35+
try:
36+
from sendfile import sendfile
37+
except ImportError:
38+
sendfile = None
39+
3140
from . import errors
3241
from .errors import FileExists
3342
from .base import FS
3443
from .enums import ResourceType
3544
from ._fscompat import fsencode, fsdecode, fspath
3645
from .info import Info
37-
from .path import basename
46+
from .path import basename, dirname
3847
from .permissions import Permissions
3948
from .error_tools import convert_os_errors
4049
from .mode import Mode, validate_open_mode
@@ -361,90 +370,79 @@ def removedir(self, path):
361370
# Optional Methods
362371
# --------------------------------------------------------
363372

373+
# --- Type hint for opendir ------------------------------
374+
364375
if False: # typing.TYPE_CHECKING
365376

366377
def opendir(self, path, factory=None):
367378
# type: (_O, Text, Optional[_OpendirFactory]) -> SubFS[_O]
368379
pass
369380

370-
def getsyspath(self, path):
371-
# type: (Text) -> Text
372-
sys_path = os.path.join(self._root_path, path.lstrip("/").replace("/", os.sep))
373-
return sys_path
374-
375-
def geturl(self, path, purpose="download"):
376-
# type: (Text, Text) -> Text
377-
if purpose != "download":
378-
raise NoURL(path, purpose)
379-
return "file://" + self.getsyspath(path)
380381

381-
def gettype(self, path):
382-
# type: (Text) -> ResourceType
383-
self.check()
384-
sys_path = self._to_sys_path(path)
385-
with convert_os_errors("gettype", path):
386-
stat = os.stat(sys_path)
387-
resource_type = self._get_type_from_stat(stat)
388-
return resource_type
382+
# --- Backport of os.sendfile for Python < 3.8 -----------
383+
384+
def _check_copy(self, src_path, dst_path, overwrite=False):
385+
# validate individual paths
386+
_src_path = self.validatepath(src_path)
387+
_dst_path = self.validatepath(dst_path)
388+
# check src_path exists and is a file
389+
if self.gettype(src_path) is not ResourceType.file:
390+
raise errors.FileExpected(src_path)
391+
# check dst_path does not exist if we are not overwriting
392+
if not overwrite and self.exists(_dst_path):
393+
raise errors.DestinationExists(dst_path)
394+
# check parent dir of _dst_path exists and is a directory
395+
if self.gettype(dirname(dst_path)) is not ResourceType.directory:
396+
raise errors.DirectoryExpected(dirname(dst_path))
397+
return _src_path, _dst_path
398+
399+
400+
if sys.version_info[:2] < (3, 8) and sendfile is not None:
401+
402+
_sendfile_error_codes = frozenset({
403+
errno.EIO,
404+
errno.EINVAL,
405+
errno.ENOSYS,
406+
errno.ENOTSUP,
407+
errno.EBADF,
408+
errno.ENOTSOCK,
409+
errno.EOPNOTSUPP,
410+
})
411+
412+
def copy(self, src_path, dst_path, overwrite=False):
413+
# type: (Text, Text, bool) -> None
414+
with self._lock:
415+
# validate and canonicalise paths
416+
_src_path, _dst_path = self._check_copy(src_path, dst_path, overwrite)
417+
_src_sys, _dst_sys = self.getsyspath(_src_path), self.getsyspath(_dst_path)
418+
# attempt using sendfile
419+
try:
420+
# initialise variables to pass to sendfile
421+
# open files to obtain a file descriptor
422+
with io.open(_src_sys, 'r') as src:
423+
with io.open(_dst_sys, 'w') as dst:
424+
fd_src, fd_dst = src.fileno(), dst.fileno()
425+
sent = maxsize = os.fstat(fd_src).st_size
426+
offset = 0
427+
while sent > 0:
428+
sent = sendfile(fd_dst, fd_src, offset, maxsize)
429+
offset += sent
430+
except OSError as e:
431+
# the error is not a simple "sendfile not supported" error
432+
if e.errno not in self._sendfile_error_codes:
433+
raise
434+
# fallback using the shutil implementation
435+
shutil.copy2(_src_sys, _dst_sys)
389436

390-
def islink(self, path):
391-
# type: (Text) -> bool
392-
self.check()
393-
_path = self.validatepath(path)
394-
sys_path = self._to_sys_path(_path)
395-
if not self.exists(path):
396-
raise errors.ResourceNotFound(path)
397-
with convert_os_errors("islink", path):
398-
return os.path.islink(sys_path)
437+
else:
399438

400-
def open(
401-
self,
402-
path, # type: Text
403-
mode="r", # type: Text
404-
buffering=-1, # type: int
405-
encoding=None, # type: Optional[Text]
406-
errors=None, # type: Optional[Text]
407-
newline="", # type: Text
408-
line_buffering=False, # type: bool
409-
**options # type: Any
410-
):
411-
# type: (...) -> IO
412-
_mode = Mode(mode)
413-
validate_open_mode(mode)
414-
self.check()
415-
_path = self.validatepath(path)
416-
sys_path = self._to_sys_path(_path)
417-
with convert_os_errors("open", path):
418-
if six.PY2 and _mode.exclusive:
419-
sys_path = os.open(sys_path, os.O_RDWR | os.O_CREAT | os.O_EXCL)
420-
_encoding = encoding or "utf-8"
421-
return io.open(
422-
sys_path,
423-
mode=_mode.to_platform(),
424-
buffering=buffering,
425-
encoding=None if _mode.binary else _encoding,
426-
errors=errors,
427-
newline=None if _mode.binary else newline,
428-
**options
429-
)
439+
def copy(self, src_path, dst_path, overwrite=False):
440+
# type: (Text, Text, bool) -> None
441+
with self._lock:
442+
_src_path, _dst_path = self._check_copy(src_path, dst_path, overwrite)
443+
shutil.copy2(self.getsyspath(_src_path), self.getsyspath(_dst_path))
430444

431-
def setinfo(self, path, info):
432-
# type: (Text, RawInfo) -> None
433-
self.check()
434-
_path = self.validatepath(path)
435-
sys_path = self._to_sys_path(_path)
436-
if not os.path.exists(sys_path):
437-
raise errors.ResourceNotFound(path)
438-
if "details" in info:
439-
details = info["details"]
440-
if "accessed" in details or "modified" in details:
441-
_accessed = typing.cast(int, details.get("accessed"))
442-
_modified = typing.cast(int, details.get("modified", _accessed))
443-
accessed = int(_modified if _accessed is None else _accessed)
444-
modified = int(_modified)
445-
if accessed is not None or modified is not None:
446-
with convert_os_errors("setinfo", path):
447-
os.utime(sys_path, (accessed, modified))
445+
# --- Backport of os.scandir for Python < 3.5 ------------
448446

449447
if scandir:
450448

@@ -545,3 +543,84 @@ def scandir(
545543
start, end = page
546544
iter_info = itertools.islice(iter_info, start, end)
547545
return iter_info
546+
547+
# --- Miscellaneous --------------------------------------
548+
549+
def getsyspath(self, path):
550+
# type: (Text) -> Text
551+
sys_path = os.path.join(self._root_path, path.lstrip("/").replace("/", os.sep))
552+
return sys_path
553+
554+
def geturl(self, path, purpose="download"):
555+
# type: (Text, Text) -> Text
556+
if purpose != "download":
557+
raise NoURL(path, purpose)
558+
return "file://" + self.getsyspath(path)
559+
560+
def gettype(self, path):
561+
# type: (Text) -> ResourceType
562+
self.check()
563+
sys_path = self._to_sys_path(path)
564+
with convert_os_errors("gettype", path):
565+
stat = os.stat(sys_path)
566+
resource_type = self._get_type_from_stat(stat)
567+
return resource_type
568+
569+
def islink(self, path):
570+
# type: (Text) -> bool
571+
self.check()
572+
_path = self.validatepath(path)
573+
sys_path = self._to_sys_path(_path)
574+
if not self.exists(path):
575+
raise errors.ResourceNotFound(path)
576+
with convert_os_errors("islink", path):
577+
return os.path.islink(sys_path)
578+
579+
def open(
580+
self,
581+
path, # type: Text
582+
mode="r", # type: Text
583+
buffering=-1, # type: int
584+
encoding=None, # type: Optional[Text]
585+
errors=None, # type: Optional[Text]
586+
newline="", # type: Text
587+
line_buffering=False, # type: bool
588+
**options # type: Any
589+
):
590+
# type: (...) -> IO
591+
_mode = Mode(mode)
592+
validate_open_mode(mode)
593+
self.check()
594+
_path = self.validatepath(path)
595+
sys_path = self._to_sys_path(_path)
596+
with convert_os_errors("open", path):
597+
if six.PY2 and _mode.exclusive:
598+
sys_path = os.open(sys_path, os.O_RDWR | os.O_CREAT | os.O_EXCL)
599+
_encoding = encoding or "utf-8"
600+
return io.open(
601+
sys_path,
602+
mode=_mode.to_platform(),
603+
buffering=buffering,
604+
encoding=None if _mode.binary else _encoding,
605+
errors=errors,
606+
newline=None if _mode.binary else newline,
607+
**options
608+
)
609+
610+
def setinfo(self, path, info):
611+
# type: (Text, RawInfo) -> None
612+
self.check()
613+
_path = self.validatepath(path)
614+
sys_path = self._to_sys_path(_path)
615+
if not os.path.exists(sys_path):
616+
raise errors.ResourceNotFound(path)
617+
if "details" in info:
618+
details = info["details"]
619+
if "accessed" in details or "modified" in details:
620+
_accessed = typing.cast(int, details.get("accessed"))
621+
_modified = typing.cast(int, details.get("modified", _accessed))
622+
accessed = int(_modified if _accessed is None else _accessed)
623+
modified = int(_modified)
624+
if accessed is not None or modified is not None:
625+
with convert_os_errors("setinfo", path):
626+
os.utime(sys_path, (accessed, modified))

tests/test_osfs.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import unicode_literals
22

3+
import errno
34
import io
45
import os
56
import mock
@@ -14,7 +15,6 @@
1415

1516
from fs.test import FSTestCases
1617

17-
1818
from six import text_type
1919

2020

@@ -68,6 +68,24 @@ def test_not_exists(self):
6868
with self.assertRaises(errors.CreateFailed):
6969
fs = osfs.OSFS("/does/not/exists/")
7070

71+
@unittest.skipIf(osfs.sendfile is None, 'sendfile not supported')
72+
def test_copy_sendfile(self):
73+
# try copying using sendfile
74+
with mock.patch.object(osfs, 'sendfile') as sendfile:
75+
sendfile.side_effect = OSError(errno.ENOTSUP, 'sendfile not supported')
76+
self.test_copy()
77+
# check other errors are transmitted
78+
self.fs.touch('foo')
79+
with mock.patch.object(osfs, 'sendfile') as sendfile:
80+
sendfile.side_effect = OSError(errno.EWOULDBLOCK)
81+
with self.assertRaises(OSError):
82+
self.fs.copy('foo', 'foo_copy')
83+
# check parent exist and is dir
84+
with self.assertRaises(errors.ResourceNotFound):
85+
self.fs.copy('foo', 'spam/eggs')
86+
with self.assertRaises(errors.DirectoryExpected):
87+
self.fs.copy('foo', 'foo_copy/foo')
88+
7189
def test_create(self):
7290
"""Test create=True"""
7391

0 commit comments

Comments
 (0)