Skip to content

Commit 04d8228

Browse files
committed
Merge remote-tracking branch 'y-p/fast_testing'
* y-p/fast_testing: TST: Added Script for running tox instances in parallel. BLD: added caching to setup.py - speeds up testing dramatically.
2 parents b91756f + c8e8779 commit 04d8228

File tree

3 files changed

+302
-28
lines changed

3 files changed

+302
-28
lines changed

setup.py

+225-28
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@
1111
import shutil
1212
import warnings
1313

14+
try:
15+
BUILD_CACHE_DIR=None
16+
17+
# uncomment to activate the build cache
18+
#BUILD_CACHE_DIR="/tmp/.pandas_build_cache/"
19+
20+
if os.isdir(BUILD_CACHE_DIR):
21+
print("--------------------------------------------------------")
22+
print("BUILD CACHE ACTIVATED. be careful, this is experimental.")
23+
print("--------------------------------------------------------")
24+
else:
25+
BUILD_CACHE_DIR=None
26+
except :
27+
pass
28+
1429
# may need to work around setuptools bug by providing a fake Pyrex
1530
try:
1631
import Cython
@@ -87,8 +102,15 @@
87102

88103
from distutils.extension import Extension
89104
from distutils.command.build import build
90-
from distutils.command.build_ext import build_ext
91105
from distutils.command.sdist import sdist
106+
from distutils.command.build_ext import build_ext
107+
108+
try:
109+
from Cython.Distutils import build_ext
110+
#from Cython.Distutils import Extension # to get pyrex debugging symbols
111+
cython=True
112+
except ImportError:
113+
cython=False
92114

93115
from os.path import splitext, basename, join as pjoin
94116

@@ -314,38 +336,213 @@ def build_extensions(self):
314336
for ext in self.extensions:
315337
self.build_extension(ext)
316338

339+
class CompilationCacheMixin(object):
340+
def __init__(self,*args,**kwds):
341+
cache_dir=kwds.pop("cache_dir",BUILD_CACHE_DIR)
342+
self.cache_dir=cache_dir
343+
if not os.path.isdir(cache_dir):
344+
raise Exception("Error: path to Cache directory [%s] is not a dir");
345+
346+
def _copy_from_cache(self,hash,target):
347+
src=os.path.join(self.cache_dir,hash)
348+
if os.path.exists(src):
349+
# print("Cache HIT: asked to copy file %s in %s" % (src,os.path.abspath(target)))
350+
s="."
351+
for d in target.split(os.path.sep)[:-1]:
352+
s=os.path.join(s,d)
353+
if not os.path.exists(s):
354+
os.mkdir(s)
355+
shutil.copyfile(src,target)
356+
357+
return True
358+
359+
return False
360+
361+
def _put_to_cache(self,hash,src):
362+
target=os.path.join(self.cache_dir,hash)
363+
# print( "Cache miss: asked to copy file from %s to %s" % (src,target))
364+
s="."
365+
for d in target.split(os.path.sep)[:-1]:
366+
s=os.path.join(s,d)
367+
if not os.path.exists(s):
368+
os.mkdir(s)
369+
shutil.copyfile(src,target)
370+
371+
def _hash_obj(self,obj):
372+
"""
373+
you should override this method to provide a sensible
374+
implementation of hashing functions for your intended objects
375+
"""
376+
try:
377+
return hash(obj)
378+
except:
379+
raise NotImplementedError("You must override this method")
380+
381+
# this is missing in 2.5, mro will do the right thing
382+
def get_ext_fullpath(self, ext_name):
383+
"""Returns the path of the filename for a given extension.
384+
385+
The file is located in `build_lib` or directly in the package
386+
(inplace option).
387+
"""
388+
import string
389+
# makes sure the extension name is only using dots
390+
all_dots = string.maketrans('/'+os.sep, '..')
391+
ext_name = ext_name.translate(all_dots)
392+
393+
fullname = self.get_ext_fullname(ext_name)
394+
modpath = fullname.split('.')
395+
filename = self.get_ext_filename(ext_name)
396+
filename = os.path.split(filename)[-1]
397+
398+
if not self.inplace:
399+
# no further work needed
400+
# returning :
401+
# build_dir/package/path/filename
402+
filename = os.path.join(*modpath[:-1]+[filename])
403+
return os.path.join(self.build_lib, filename)
404+
405+
# the inplace option requires to find the package directory
406+
# using the build_py command for that
407+
package = '.'.join(modpath[0:-1])
408+
build_py = self.get_finalized_command('build_py')
409+
package_dir = os.path.abspath(build_py.get_package_dir(package))
410+
411+
# returning
412+
# package_dir/filename
413+
return os.path.join(package_dir, filename)
414+
415+
class CompilationCacheExtMixin(CompilationCacheMixin):
416+
def __init__(self,*args,**kwds):
417+
CompilationCacheMixin.__init__(self,*args,**kwds)
418+
419+
def _hash_file(self,fname):
420+
from hashlib import sha1
421+
try:
422+
hash=sha1()
423+
hash.update(self.build_lib.encode('utf-8'))
424+
try:
425+
if sys.version_info[0] >= 3:
426+
import io
427+
f=io.open(fname,"rb")
428+
else:
429+
f=open(fname)
430+
431+
first_line=f.readline()
432+
# ignore cython generation timestamp header
433+
if "Generated by Cython" not in first_line.decode('utf-8'):
434+
hash.update(first_line)
435+
hash.update(f.read())
436+
return hash.hexdigest()
437+
438+
except:
439+
raise
440+
return None
441+
finally:
442+
f.close()
443+
444+
except IOError:
445+
return None
446+
447+
def _hash_obj(self,ext):
448+
from hashlib import sha1
449+
450+
sources = ext.sources
451+
if sources is None or \
452+
(not hasattr(sources,'__iter__') ) or \
453+
isinstance(sources,str) or \
454+
sys.version[0]==2 and isinstance(sources,unicode): #argh
455+
return False
456+
457+
sources = list(sources) + ext.depends
458+
hash=sha1()
459+
try:
460+
for fname in sources:
461+
fhash=self._hash_file(fname)
462+
if fhash:
463+
hash.update(fhash.encode('utf-8'))
464+
except:
465+
return None
466+
467+
return hash.hexdigest()
468+
469+
class CachingBuildExt(build_ext,CompilationCacheExtMixin):
470+
def __init__(self,*args,**kwds):
471+
CompilationCacheExtMixin.__init__(self,*args,**kwds)
472+
kwds.pop("cache_dir",None)
473+
build_ext.__init__(self,*args,**kwds)
474+
475+
def build_extension(self, ext,*args,**kwds):
476+
ext_path = self.get_ext_fullpath(ext.name)
477+
build_path = os.path.join(self.build_lib,os.path.basename(ext_path))
478+
479+
hash=self._hash_obj(ext)
480+
if hash and self._copy_from_cache(hash,ext_path):
481+
return
482+
483+
build_ext.build_extension(self,ext,*args,**kwds)
484+
485+
hash=self._hash_obj(ext)
486+
if os.path.exists(build_path):
487+
self._put_to_cache(hash,build_path) # build_ext
488+
if os.path.exists(ext_path):
489+
self._put_to_cache(hash,ext_path) # develop
490+
491+
492+
def cython_sources(self, sources, extension):
493+
import re
494+
cplus = self.cython_cplus or getattr(extension, 'cython_cplus', 0) or \
495+
(extension.language and extension.language.lower() == 'c++')
496+
target_ext = '.c'
497+
if cplus:
498+
target_ext = '.cpp'
499+
500+
for i,s in enumerate(sources):
501+
if not re.search("\.(pyx|pxi|pxd)$",s):
502+
continue
503+
ext_dir=os.path.dirname(s)
504+
ext_basename=re.sub("\.[^\.]+$","",os.path.basename(s))
505+
ext_basename += target_ext
506+
target= os.path.join(ext_dir,ext_basename)
507+
hash=self._hash_file(s)
508+
sources[i]=target
509+
if hash and self._copy_from_cache(hash,target):
510+
continue
511+
build_ext.cython_sources(self,[s],extension)
512+
self._put_to_cache(hash,target)
513+
514+
return sources
515+
516+
class CythonCommand(build_ext):
517+
"""Custom distutils command subclassed from Cython.Distutils.build_ext
518+
to compile pyx->c, and stop there. All this does is override the
519+
C-compile method build_extension() with a no-op."""
520+
def build_extension(self, ext):
521+
pass
522+
523+
class DummyBuildSrc(Command):
524+
""" numpy's build_src command interferes with Cython's build_ext.
525+
"""
526+
user_options = []
527+
def initialize_options(self):
528+
self.py_modules_dict = {}
529+
def finalize_options(self):
530+
pass
531+
def run(self):
532+
pass
533+
317534
cmdclass = {'clean': CleanCommand,
318535
'build': build}
319-
320-
try:
321-
from Cython.Distutils import build_ext
322-
#from Cython.Distutils import Extension # to get pyrex debugging symbols
323-
cython=True
324-
except ImportError:
325-
cython=False
326-
suffix = '.c'
327-
cmdclass['build_ext'] = CheckingBuildExt
328-
else:
536+
if cython:
329537
suffix = '.pyx'
330-
class CythonCommand(build_ext):
331-
"""Custom distutils command subclassed from Cython.Distutils.build_ext
332-
to compile pyx->c, and stop there. All this does is override the
333-
C-compile method build_extension() with a no-op."""
334-
def build_extension(self, ext):
335-
pass
336-
337-
class DummyBuildSrc(Command):
338-
""" numpy's build_src command interferes with Cython's build_ext.
339-
"""
340-
user_options = []
341-
def initialize_options(self):
342-
self.py_modules_dict = {}
343-
def finalize_options(self):
344-
pass
345-
def run(self):
346-
pass
538+
cmdclass['build_ext'] = build_ext
539+
if BUILD_CACHE_DIR: # use the cache
540+
cmdclass['build_ext'] = CachingBuildExt
541+
else:
347542

543+
suffix = '.c'
348544
cmdclass['build_src'] = DummyBuildSrc
545+
349546
cmdclass['cython'] = CythonCommand
350547
cmdclass['build_ext'] = build_ext
351548
cmdclass['sdist'] = CheckSDist

tox_prll.ini

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Tox (http://tox.testrun.org/) is a tool for running tests
2+
# in multiple virtualenvs. This configuration file will run the
3+
# test suite on all supported python versions. To use it, "pip install tox"
4+
# and then run "tox" from this directory.
5+
6+
[tox]
7+
envlist = py25, py26, py27, py31, py32
8+
sdistsrc = {env:DISTFILE}
9+
10+
[testenv]
11+
deps =
12+
cython
13+
numpy >= 1.6.1
14+
nose
15+
pytz
16+
17+
# cd to anything but the default {toxinidir} which
18+
# contains the pandas subdirectory and confuses
19+
# nose away from the fresh install in site-packages
20+
changedir = {envdir}
21+
22+
commands =
23+
# TODO: --exe because of GH #761
24+
{envbindir}/nosetests --exe pandas.tests
25+
# cleanup the temp. build dir created by the tox build
26+
/bin/rm -rf {toxinidir}/build
27+
28+
# quietly rollback the install.
29+
# Note this line will only be reached if the tests
30+
# previous lines succeed (in particular, the tests),
31+
# but an uninstall is really only required when
32+
# files are removed from source tree, in which case,
33+
# stale versions of files will will remain in the venv,
34+
# until the next time uninstall is run.
35+
#
36+
# tox should provide a preinstall-commands hook.
37+
pip uninstall pandas -qy
38+
39+
40+
[testenv:py25]
41+
deps =
42+
cython
43+
numpy >= 1.6.1
44+
nose
45+
pytz
46+
simplejson
47+
48+
[testenv:py26]
49+
50+
[testenv:py27]
51+
52+
[testenv:py31]
53+
54+
[testenv:py32]

tox_prll.sh

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
#
3+
# tox has an undocumented (as of 1.4.2) config option called "sdistsrc"
4+
# which can make a run use a pre-prepared sdist file.
5+
# we prepare the sdist once , then launch the tox runs in parallel using it.
6+
#
7+
# currently (tox 1.4.2) We have to skip sdist generation when running in parallel
8+
# or we get a race.
9+
#
10+
11+
12+
ENVS=$(cat tox.ini | grep envlist | tr "," " " | cut -d " " -f 3-)
13+
TOX_INI_PAR="tox_prll.ini"
14+
15+
echo "[Creating distfile]"
16+
tox --sdistonly
17+
export DISTFILE="$(find .tox/dist -type f )"
18+
19+
echo -e "[Starting tests]\n"
20+
for e in $ENVS; do
21+
echo "[launching tox for $e]"
22+
tox -c "$TOX_INI_PAR" -e "$e" &
23+
done

0 commit comments

Comments
 (0)