Skip to content

Commit d5497c9

Browse files
committed
Merge pull request #1391 from effigies/issue_1387
REF/FIX: Correct link/copy behavior: closes #1387
2 parents f504736 + c557058 commit d5497c9

File tree

8 files changed

+204
-74
lines changed

8 files changed

+204
-74
lines changed

.travis.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ before_install:
3434
echo 'include_dirs = /usr/include:/usr/include/X11' >> $HOME/.numpy-site.cfg;
3535
fi
3636
install:
37+
- sudo apt-get install fusefat
3738
- conda update --yes conda
3839
- conda create -n testenv --yes pip python=$TRAVIS_PYTHON_VERSION
3940
- source activate testenv

CHANGES

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Next release
2424
* ENH: New interfaces for interacting with AWS S3: S3DataSink and S3DataGrabber (https://github.com/nipy/nipype/pull/1201)
2525
* ENH: Interfaces for MINC tools (https://github.com/nipy/nipype/pull/1304)
2626
* FIX: Use realpath to determine hard link source (https://github.com/nipy/nipype/pull/1388)
27+
* FIX: Correct linking/copying fallback behavior (https://github.com/nipy/nipype/pull/1391)
2728

2829
Release 0.11.0 (September 15, 2015)
2930
============

circle.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ dependencies:
1212
- bash <(wget -q -O- http://neuro.debian.net/_files/neurodebian-travis.sh)
1313
override:
1414
# Install apt packages
15-
- sudo apt-get install -y fsl-core fsl-atlases fsl-mni152-templates fsl-feeds afni swig python-vtk xvfb
15+
- sudo apt-get install -y fsl-core fsl-atlases fsl-mni152-templates fsl-feeds afni swig python-vtk xvfb fusefat
1616
- echo 'source /etc/fsl/fsl.sh' >> $HOME/.profile
1717
- echo 'source /etc/afni/afni.sh' >> $HOME/.profile
1818
- mkdir -p ~/examples/ && ln -sf /usr/share/fsl-feeds/ ~/examples/feeds

nipype/testing/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from numpy.testing import *
3030

3131
from . import decorators as dec
32-
from .utils import skip_if_no_package, package_check
32+
from .utils import skip_if_no_package, package_check, TempFATFS
3333

3434
skipif = dec.skipif
3535

nipype/testing/tests/test_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
2+
# vi: set ft=python sts=4 ts=4 sw=4 et:
3+
"""Test testing utilities
4+
"""
5+
6+
import os
7+
import warnings
8+
from nipype.testing.utils import TempFATFS
9+
from nose.tools import assert_true
10+
11+
12+
def test_tempfatfs():
13+
try:
14+
fatfs = TempFATFS()
15+
except IOError:
16+
warnings.warn("Cannot mount FAT filesystems with FUSE")
17+
else:
18+
with fatfs as tmpdir:
19+
yield assert_true, os.path.exists(tmpdir)

nipype/testing/utils.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
"""
55
__docformat__ = 'restructuredtext'
66

7+
import os
8+
import time
9+
import shutil
10+
import signal
11+
import subprocess
12+
from tempfile import mkdtemp
713
from ..utils.misc import package_check
814
from nose import SkipTest
915

@@ -19,3 +25,66 @@ def skip_if_no_package(*args, **kwargs):
1925
package_check(exc_failed_import=SkipTest,
2026
exc_failed_check=SkipTest,
2127
*args, **kwargs)
28+
29+
30+
class TempFATFS(object):
31+
def __init__(self, size_in_mbytes=8, delay=0.5):
32+
"""Temporary filesystem for testing non-POSIX filesystems on a POSIX
33+
system.
34+
35+
with TempFATFS() as fatdir:
36+
target = os.path.join(fatdir, 'target')
37+
copyfile(file1, target, copy=False)
38+
assert_false(os.path.islink(target))
39+
40+
Arguments
41+
---------
42+
size_in_mbytes : int
43+
Size (in MiB) of filesystem to create
44+
delay : float
45+
Time (in seconds) to wait for fusefat to start, stop
46+
"""
47+
self.delay = delay
48+
self.tmpdir = mkdtemp()
49+
self.dev_null = open(os.devnull, 'wb')
50+
51+
vfatfile = os.path.join(self.tmpdir, 'vfatblock')
52+
self.vfatmount = os.path.join(self.tmpdir, 'vfatmount')
53+
self.canary = os.path.join(self.vfatmount, '.canary')
54+
55+
with open(vfatfile, 'wb') as fobj:
56+
fobj.write(b'\x00' * (int(size_in_mbytes) << 20))
57+
os.mkdir(self.vfatmount)
58+
59+
mkfs_args = ['mkfs.vfat', vfatfile]
60+
mount_args = ['fusefat', '-o', 'rw+', '-f', vfatfile, self.vfatmount]
61+
62+
subprocess.check_call(args=mkfs_args, stdout=self.dev_null,
63+
stderr=self.dev_null)
64+
self.fusefat = subprocess.Popen(args=mount_args, stdout=self.dev_null,
65+
stderr=self.dev_null)
66+
time.sleep(self.delay)
67+
68+
if self.fusefat.poll() is not None:
69+
raise IOError("fatfuse terminated too soon")
70+
71+
open(self.canary, 'wb').close()
72+
73+
def __enter__(self):
74+
return self.vfatmount
75+
76+
def __exit__(self, exc_type, exc_val, exc_tb):
77+
if self.fusefat is not None:
78+
self.fusefat.send_signal(signal.SIGINT)
79+
80+
# Allow 1s to return without sending terminate
81+
for count in range(10):
82+
time.sleep(0.1)
83+
if self.fusefat.poll() is not None:
84+
break
85+
else:
86+
self.fusefat.terminate()
87+
time.sleep(self.delay)
88+
assert not os.path.exists(self.canary)
89+
self.dev_null.close()
90+
shutil.rmtree(self.tmpdir)

nipype/utils/filemanip.py

Lines changed: 79 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
import os
1616
import re
1717
import shutil
18+
import posixpath
1819

1920
import numpy as np
2021

2122
from .misc import is_container
22-
from .config import mkdir_p
2323
from ..external.six import string_types
2424
from ..interfaces.traits_extension import isdefined
2525

@@ -31,31 +31,6 @@ class FileNotFoundError(Exception):
3131
pass
3232

3333

34-
def nipype_hardlink_wrapper(raw_src, raw_dst):
35-
"""Attempt to use hard link instead of file copy.
36-
The intent is to avoid unnnecessary duplication
37-
of large files when using a DataSink.
38-
Hard links are not supported on all file systems
39-
or os environments, and will not succeed if the
40-
src and dst are not on the same physical hardware
41-
partition.
42-
If the hardlink fails, then fall back to using
43-
a standard copy.
44-
"""
45-
# Use realpath to avoid hardlinking symlinks
46-
src = os.path.realpath(raw_src)
47-
# Use normpath, in case destination is a symlink
48-
dst = os.path.normpath(raw_dst)
49-
del raw_src
50-
del raw_dst
51-
if src != dst and os.path.exists(dst):
52-
os.unlink(dst) # First remove destination
53-
try:
54-
os.link(src, dst) # Reference same inode to avoid duplication
55-
except:
56-
shutil.copyfile(src, dst) # Fall back to traditional copy
57-
58-
5934
def split_filename(fname):
6035
"""Split a filename into parts: path, base filename and extension.
6136
@@ -201,7 +176,13 @@ def hash_timestamp(afile):
201176

202177
def copyfile(originalfile, newfile, copy=False, create_new=False,
203178
hashmethod=None, use_hardlink=False):
204-
"""Copy or symlink ``originalfile`` to ``newfile``.
179+
"""Copy or link ``originalfile`` to ``newfile``.
180+
181+
If ``use_hardlink`` is True, and the file can be hard-linked, then a
182+
link is created, instead of copying the file.
183+
184+
If a hard link is not created and ``copy`` is False, then a symbolic
185+
link is created.
205186
206187
Parameters
207188
----------
@@ -212,6 +193,9 @@ def copyfile(originalfile, newfile, copy=False, create_new=False,
212193
copy : Bool
213194
specifies whether to copy or symlink files
214195
(default=False) but only for POSIX systems
196+
use_hardlink : Bool
197+
specifies whether to hard-link files, when able
198+
(Default=False), taking precedence over copy
215199
216200
Returns
217201
-------
@@ -237,63 +221,87 @@ def copyfile(originalfile, newfile, copy=False, create_new=False,
237221
if hashmethod is None:
238222
hashmethod = config.get('execution', 'hash_method').lower()
239223

240-
elif os.path.exists(newfile):
241-
if hashmethod == 'timestamp':
242-
newhash = hash_timestamp(newfile)
243-
elif hashmethod == 'content':
244-
newhash = hash_infile(newfile)
245-
fmlogger.debug("File: %s already exists,%s, copy:%d"
246-
% (newfile, newhash, copy))
247-
# the following seems unnecessary
248-
# if os.name is 'posix' and copy:
249-
# if os.path.lexists(newfile) and os.path.islink(newfile):
250-
# os.unlink(newfile)
251-
# newhash = None
252-
if os.name is 'posix' and not copy:
253-
if os.path.lexists(newfile):
254-
if hashmethod == 'timestamp':
255-
orighash = hash_timestamp(originalfile)
256-
elif hashmethod == 'content':
257-
orighash = hash_infile(originalfile)
258-
fmlogger.debug('Original hash: %s, %s' % (originalfile, orighash))
259-
if newhash != orighash:
260-
os.unlink(newfile)
261-
if (newhash is None) or (newhash != orighash):
262-
os.symlink(originalfile, newfile)
263-
else:
264-
if newhash:
224+
# Existing file
225+
# -------------
226+
# Options:
227+
# symlink
228+
# to originalfile (keep if not (use_hardlink or copy))
229+
# to other file (unlink)
230+
# regular file
231+
# hard link to originalfile (keep)
232+
# copy of file (same hash) (keep)
233+
# different file (diff hash) (unlink)
234+
keep = False
235+
if os.path.lexists(newfile):
236+
if os.path.islink(newfile):
237+
if all(os.path.readlink(newfile) == originalfile, not use_hardlink,
238+
not copy):
239+
keep = True
240+
elif posixpath.samefile(newfile, originalfile):
241+
keep = True
242+
else:
265243
if hashmethod == 'timestamp':
266-
orighash = hash_timestamp(originalfile)
244+
hashfn = hash_timestamp
267245
elif hashmethod == 'content':
268-
orighash = hash_infile(originalfile)
269-
if (newhash is None) or (newhash != orighash):
270-
try:
271-
fmlogger.debug("Copying File: %s->%s" %
272-
(newfile, originalfile))
273-
if use_hardlink:
274-
nipype_hardlink_wrapper(originalfile, newfile)
275-
else:
276-
shutil.copyfile(originalfile, newfile)
277-
except shutil.Error as e:
278-
fmlogger.warn(e.message)
279-
else:
246+
hashfn = hash_infile
247+
newhash = hashfn(newfile)
248+
fmlogger.debug("File: %s already exists,%s, copy:%d" %
249+
(newfile, newhash, copy))
250+
orighash = hashfn(originalfile)
251+
keep = newhash == orighash
252+
if keep:
280253
fmlogger.debug("File: %s already exists, not overwriting, copy:%d"
281254
% (newfile, copy))
255+
else:
256+
os.unlink(newfile)
257+
258+
# New file
259+
# --------
260+
# use_hardlink & can_hardlink => hardlink
261+
# ~hardlink & ~copy & can_symlink => symlink
262+
# ~hardlink & ~symlink => copy
263+
if not keep and use_hardlink:
264+
try:
265+
fmlogger.debug("Linking File: %s->%s" % (newfile, originalfile))
266+
# Use realpath to avoid hardlinking symlinks
267+
os.link(os.path.realpath(originalfile), newfile)
268+
except OSError:
269+
use_hardlink = False # Disable hardlink for associated files
270+
else:
271+
keep = True
272+
273+
if not keep and not copy and os.name == 'posix':
274+
try:
275+
fmlogger.debug("Symlinking File: %s->%s" % (newfile, originalfile))
276+
os.symlink(originalfile, newfile)
277+
except OSError:
278+
copy = True # Disable symlink for associated files
279+
else:
280+
keep = True
281+
282+
if not keep:
283+
try:
284+
fmlogger.debug("Copying File: %s->%s" % (newfile, originalfile))
285+
shutil.copyfile(originalfile, newfile)
286+
except shutil.Error as e:
287+
fmlogger.warn(e.message)
288+
289+
# Associated files
282290
if originalfile.endswith(".img"):
283291
hdrofile = originalfile[:-4] + ".hdr"
284292
hdrnfile = newfile[:-4] + ".hdr"
285293
matofile = originalfile[:-4] + ".mat"
286294
if os.path.exists(matofile):
287295
matnfile = newfile[:-4] + ".mat"
288-
copyfile(matofile, matnfile, copy, create_new, hashmethod,
289-
use_hardlink)
290-
copyfile(hdrofile, hdrnfile, copy, create_new, hashmethod,
291-
use_hardlink)
296+
copyfile(matofile, matnfile, copy, hashmethod=hashmethod,
297+
use_hardlink=use_hardlink)
298+
copyfile(hdrofile, hdrnfile, copy, hashmethod=hashmethod,
299+
use_hardlink=use_hardlink)
292300
elif originalfile.endswith(".BRIK"):
293301
hdrofile = originalfile[:-5] + ".HEAD"
294302
hdrnfile = newfile[:-5] + ".HEAD"
295-
copyfile(hdrofile, hdrnfile, copy, create_new, hashmethod,
296-
use_hardlink)
303+
copyfile(hdrofile, hdrnfile, copy, hashmethod=hashmethod,
304+
use_hardlink=use_hardlink)
297305

298306
return newfile
299307

nipype/utils/tests/test_filemanip.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44

55
import os
66
from tempfile import mkstemp, mkdtemp
7+
import warnings
78

8-
from nipype.testing import assert_equal, assert_true, assert_false
9+
from nipype.testing import assert_equal, assert_true, assert_false, TempFATFS
910
from nipype.utils.filemanip import (save_json, load_json,
1011
fname_presuffix, fnames_presuffix,
1112
hash_rename, check_forhash,
@@ -167,6 +168,37 @@ def test_linkchain():
167168
os.unlink(orig_hdr)
168169

169170

171+
def test_copyfallback():
172+
if os.name is not 'posix':
173+
return
174+
orig_img, orig_hdr = _temp_analyze_files()
175+
pth, imgname = os.path.split(orig_img)
176+
pth, hdrname = os.path.split(orig_hdr)
177+
try:
178+
fatfs = TempFATFS()
179+
except IOError:
180+
warnings.warn('Fuse mount failed. copyfile fallback tests skipped.')
181+
else:
182+
with fatfs as fatdir:
183+
tgt_img = os.path.join(fatdir, imgname)
184+
tgt_hdr = os.path.join(fatdir, hdrname)
185+
for copy in (True, False):
186+
for use_hardlink in (True, False):
187+
copyfile(orig_img, tgt_img, copy=copy,
188+
use_hardlink=use_hardlink)
189+
yield assert_true, os.path.exists(tgt_img)
190+
yield assert_true, os.path.exists(tgt_hdr)
191+
yield assert_false, os.path.islink(tgt_img)
192+
yield assert_false, os.path.islink(tgt_hdr)
193+
yield assert_false, os.path.samefile(orig_img, tgt_img)
194+
yield assert_false, os.path.samefile(orig_hdr, tgt_hdr)
195+
os.unlink(tgt_img)
196+
os.unlink(tgt_hdr)
197+
finally:
198+
os.unlink(orig_img)
199+
os.unlink(orig_hdr)
200+
201+
170202
def test_filename_to_list():
171203
x = filename_to_list('foo.nii')
172204
yield assert_equal, x, ['foo.nii']

0 commit comments

Comments
 (0)