1import os
2import sys
3import glob
4import optparse
5
6import pyutilib.subprocess
7from pyutilib.th import TestCase
8
9from os.path import dirname
10
11if sys.platform.startswith('win'):
12    platform='win'
13    use_exec = False # Try to use subprocess.run
14else:
15    platform = 'linux'
16    use_exec = True
17
18
19def run(package, basedir, argv, use_exec=use_exec, env=None):
20    if type(package) not in (list, tuple):
21        package = [package]
22
23    parser = optparse.OptionParser(usage='run [OPTIONS] <dirs>')
24
25    parser.add_option(
26        '-v',
27        '--verbose',
28        action='store_true',
29        dest='verbose',
30        default=False,
31        help='Verbose output')
32    parser.add_option(
33        '--cat',
34        '--category',
35        action='append',
36        dest='cat',
37        default=[],
38        help='Specify the test category.')
39    parser.add_option(
40        '--cov',
41        '--coverage',
42        action='store_true',
43        dest='coverage',
44        default=False,
45        help='Enable the computation of coverage information')
46    parser.add_option(
47        '--cover-erase',
48        action='store_true',
49        dest='cover_erase',
50        default=False,
51        help='Erase any previous coverage data files')
52    parser.add_option(
53        '-d',
54        '--dir',
55        action='store',
56        dest='dir',
57        default=None,
58        help='Top-level source directory where the tests are applied.')
59    parser.add_option(
60        '-p',
61        '--package',
62        action='store',
63        dest='pkg',
64        default=package[0],
65        help='Limit the coverage to this package')
66    parser.add_option(
67        '-o',
68        '--output',
69        action='store',
70        dest='output',
71        default=None,
72        help='Redirect output to a file')
73    parser.add_option('--with-doctest',
74        action='store_true',
75        dest='doctests',
76        default=False,
77        help='Run tests included in Sphinx documentation')
78    parser.add_option('--doc-dir',
79        action='store',
80        dest='docdir',
81        default=None,
82        help='Top-level source directory for Sphinx documentation')
83    parser.add_option('--no-xunit',
84        action='store_false',
85        dest='xunit',
86        default=True,
87        help='Disable the nose XUnit plugin')
88    parser.add_option('--dry-run',
89        action='store_true',
90        dest='dryrun',
91        default=False,
92        help='Dry run: collect but do not execute the tests')
93
94    options, args = parser.parse_args(argv)
95
96    if env is None:
97        env = os.environ.copy()
98
99    if options.output:
100        options.output = os.path.abspath(options.output)
101
102    if options.dir is None:
103        os.chdir(basedir)
104    else:
105        os.chdir(options.dir)
106
107    CWD = os.getcwd()
108    print("Running tests in directory %s" % (CWD,))
109
110    if platform == 'win':
111        binDir = os.path.join(sys.exec_prefix, 'Scripts')
112        nosetests = os.path.join(binDir, 'nosetests.exe')
113        #
114        # JDS [2 Oct 2017]: I am not sure why this was here.  If we find
115        # we need it, we can re-add it with an explanation as to why
116        # Windows needs special PYTHONPATH handling.
117        #
118        #srcdirs = []
119        #for dir in glob.glob('*'):
120        #    if os.path.isdir(dir):
121        #        srcdirs.append(os.path.abspath(dir))
122        #if 'PYTHONPATH' in env:
123        #    srcdirs.append(env['PYTHONPATH'])
124        #env['PYTHONPATH'] = os.pathsep.join(srcdirs)
125    else:
126        binDir = os.path.join(sys.exec_prefix, 'bin')
127        nosetests = os.path.join(binDir, 'nosetests')
128
129    if os.path.exists(nosetests):
130        cmd = [nosetests]
131    else:
132        cmd = ['nosetests']
133
134    if (platform == 'win' and sys.version_info[0:2] >= (3, 8)):
135        #######################################################
136        # This option is required due to a (likely) bug within nosetests.
137        # Nose is no longer maintained, but this workaround is based on a public forum suggestion:
138        #   https://stackoverflow.com/questions/58556183/nose-unittest-discovery-broken-on-python-3-8
139        #######################################################
140        cmd.append('--traverse-namespace')
141
142    if binDir not in env['PATH']:
143        env['PATH'] = os.pathsep.join([binDir, env.get('PATH','')])
144
145    if options.coverage:
146        cmd.append('--with-coverage')
147        if options.cover_erase:
148            cmd.append('--cover-erase')
149        if options.pkg:
150            cmd.append('--cover-package=%s' % options.pkg)
151        #env['COVERAGE_FILE'] = os.path.join(CWD, '.coverage')
152
153    if options.verbose:
154        cmd.append('-v')
155    if options.dryrun:
156        cmd.append('--collect-only')
157
158    if options.doctests:
159        cmd.extend(['--with-doctest', '--doctest-extension=.rst'])
160        if options.docdir:
161            docdir = os.path.abspath(_options.docdir)
162            if not os.path.exists(docdir):
163                raise ValueError("Invalid documentation directory, "
164                                 "path does not exist")
165
166    if options.xunit:
167        cmd.append('--with-xunit')
168        cmd.append('--xunit-file=TEST-' + package[0] + '.xml')
169
170    attr = []
171    _with_performance = False
172    if 'PYUTILIB_UNITTEST_CATEGORY' in env:
173        _categories = TestCase.parse_categories(
174            env.get('PYUTILIB_UNITTEST_CATEGORY', '') )
175    else:
176        _categories = []
177        for x in options.cat:
178            _categories.extend( TestCase.parse_categories(x) )
179
180    # If no one specified a category, default to "smoke" (and anything
181    # not built on pyutilib.th.TestCase)
182    if not _categories:
183        _categories = [ (('smoke',1),), (('pyutilib_th',0),) ]
184    # process each category set (that is, each conjunction of categories)
185    for _category_set in _categories:
186        _attrs = []
187        # "ALL" deletes the categories, and just runs everything.  Note
188        # that "ALL" disables performance testing
189        if ('all', 1) in _category_set:
190            _categories = []
191            _with_performance = False
192            attr = []
193            break
194        # For each category set, unless the user explicitly says
195        # something about fragile, assume that fragile should be
196        # EXCLUDED.
197        if ('fragile',1) not in _category_set \
198           and ('fragile',0) not in _category_set:
199            _category_set = _category_set + (('fragile',0),)
200        # Process each category in the conjection and add to the nose
201        # "attrib" plugin arguments
202        for _category, _value in _category_set:
203            if not _category:
204                continue
205            if _value:
206                _attrs.append(_category)
207            else:
208                _attrs.append("(not %s)" % (_category,))
209            if _category == 'performance' and _value == 1:
210                _with_performance = True
211        if _attrs:
212            #attr.append('--eval-attr')
213            attr.append("--eval-attr=%s" % (' and '.join(_attrs),))
214    cmd.extend(attr)
215    if attr:
216        print(" ... for test categor%s: %s" %
217              ('y' if len(attr)<=2 else 'ies',
218               ' '.join(attr[1::2])))
219
220    if _with_performance:
221        cmd.append('--with-testdata')
222        env['NOSE_WITH_TESTDATA'] = '1'
223        env['NOSE_WITH_FORCED_GC'] = '1'
224
225    targets = set()
226    if len(args) <= 1:
227        targets.update(package)
228    else:
229        for arg in args[1:]:
230            if '*' in arg or '?' in arg:
231                targets.update(glob.glob(arg))
232            else:
233                targets.add(arg)
234    cmd.extend(list(targets))
235
236    print("Running...\n    %s\n" % (
237            ' '.join( (x if ' ' not in x else '"'+x+'"') for x in cmd ), ))
238    rc = 0
239    if sys.platform.startswith('java'):
240        import subprocess
241        p = subprocess.Popen(cmd, env=env)
242        p.wait()
243        rc = p.returncode
244    elif options.output:
245        sys.stdout.write("Redirecting output to file '%s' ..." % options.output)
246        rc, _ = pyutilib.subprocess.run(cmd, env=env, outfile=options.output)
247    elif use_exec and not (
248            sys.platform.startswith('win')
249            and sys.version_info[:2] in ((3,4),(3,5)) ):
250        # NOTE: execvpe seems to generate a fatal error on Windows with
251        # 3.4 and 3.5.
252        #
253        # In other Python versions it fails to return the new process'
254        # return code to the owning shell (so the build harness doesn't
255        # see the command "fail" when there are failing tests.
256        rc = None
257        sys.stderr.flush()
258        sys.stdout.flush()
259        os.execvpe(cmd[0], cmd, env)
260    else:
261        sys.stdout.flush()
262        rc, _ = pyutilib.subprocess.run(cmd, env=env, ostream=sys.stdout)
263    return rc
264
265
266def runPyUtilibTests(argv=None, use_exec=use_exec):
267    if argv is None:
268        argv = sys.argv
269
270    return pyutilib.dev.runtests.run(
271        'pyutilib',
272        dirname(dirname(dirname(os.path.abspath(__file__)))),
273        argv,
274        use_exec=use_exec )
275