1 # Copyright (c) Microsoft Corporation. All rights reserved.
2 # Licensed under the MIT License.
4 from __future__ import absolute_import
11 from .errors import UnsupportedCommandError
12 from .info import TestInfo, TestPath, ParentInfo
15 def add_cli_subparser(cmd, name, parent):
16 """Add a new subparser to the given parent and add args to it."""
17 parser = parent.add_parser(name)
19 # For now we don't have any tool-specific CLI options to add.
22 raise UnsupportedCommandError(cmd)
26 def discover(pytestargs=None, hidestdio=False,
27 _pytest_main=pytest.main, _plugin=None, **_ignored):
28 """Return the results of test discovery."""
30 _plugin = TestCollector()
32 pytestargs = _adjust_pytest_args(pytestargs)
33 # We use this helper rather than "-pno:terminal" due to possible
34 # platform-dependent issues.
35 with util.hide_stdio() if hidestdio else util.noop_cm():
36 ec = _pytest_main(pytestargs, [_plugin])
38 raise Exception('pytest discovery failed (exit code {})'.format(ec))
39 if not _plugin._started:
40 raise Exception('pytest discovery did not start')
42 _plugin._tests.parents,
44 # id=p.id.lstrip('.' + os.path.sep),
45 # parentid=p.parentid.lstrip('.' + os.path.sep),
47 # for p in _plugin._tests.parents],
52 def _adjust_pytest_args(pytestargs):
53 pytestargs = list(pytestargs) if pytestargs else []
54 # Duplicate entries should be okay.
55 pytestargs.insert(0, '--collect-only')
56 # TODO: pull in code from:
57 # src/client/unittests/pytest/services/discoveryService.ts
58 # src/client/unittests/pytest/services/argsService.ts
62 class TestCollector(object):
63 """This is a pytest plugin that collects the discovered tests."""
65 NORMCASE = staticmethod(os.path.normcase)
68 def __init__(self, tests=None):
70 tests = DiscoveredTests()
74 # Relevant plugin hooks:
75 # https://docs.pytest.org/en/latest/reference.html#collection-hooks
77 def pytest_collection_modifyitems(self, session, config, items):
81 test, suiteids = _parse_item(item, self.NORMCASE, self.PATHSEP)
82 self._tests.add_test(test, suiteids)
84 # This hook is not specified in the docs, so we also provide
85 # the "modifyitems" hook just in case.
86 def pytest_collection_finish(self, session):
90 except AttributeError:
91 # TODO: Is there an alternative?
95 test, suiteids = _parse_item(item, self.NORMCASE, self.PATHSEP)
96 self._tests.add_test(test, suiteids)
99 class DiscoveredTests(object):
105 return len(self._tests)
107 def __getitem__(self, index):
108 return self._tests[index]
112 return sorted(self._parents.values(), key=lambda v: (v.root or v.name, v.id))
118 def add_test(self, test, suiteids):
119 parentid = self._ensure_parent(test.path, test.parentid, suiteids)
120 test = test._replace(parentid=parentid)
121 if not test.id.startswith('.' + os.path.sep):
122 test = test._replace(id=os.path.join('.', test.id))
123 self._tests.append(test)
125 def _ensure_parent(self, path, parentid, suiteids):
126 if not parentid.startswith('.' + os.path.sep):
127 parentid = os.path.join('.', parentid)
128 fileid = self._ensure_file(path.root, path.relfile)
134 fullsuite, _, funcname = path.func.rpartition('.')
135 suiteid = self._ensure_suites(fullsuite, rootdir, fileid, suiteids)
136 parent = suiteid if suiteid else fileid
139 if (rootdir, parentid) not in self._parents:
140 funcinfo = ParentInfo(parentid, 'function', funcname,
142 self._parents[(rootdir, parentid)] = funcinfo
143 elif parent != parentid:
145 raise NotImplementedError
148 def _ensure_file(self, rootdir, relfile):
149 if (rootdir, '.') not in self._parents:
150 self._parents[(rootdir, '.')] = ParentInfo('.', 'folder', rootdir)
151 if relfile.startswith('.' + os.path.sep):
154 fileid = relfile = os.path.join('.', relfile)
156 if (rootdir, fileid) not in self._parents:
157 folderid, filebase = os.path.split(fileid)
158 fileinfo = ParentInfo(fileid, 'file', filebase, rootdir, folderid)
159 self._parents[(rootdir, fileid)] = fileinfo
161 while folderid != '.' and (rootdir, folderid) not in self._parents:
162 parentid, name = os.path.split(folderid)
163 folderinfo = ParentInfo(folderid, 'folder', name, rootdir, parentid)
164 self._parents[(rootdir, folderid)] = folderinfo
168 def _ensure_suites(self, fullsuite, rootdir, fileid, suiteids):
172 raise NotImplementedError
174 if len(suiteids) != fullsuite.count('.') + 1:
176 raise NotImplementedError
178 suiteid = suiteids.pop()
179 if not suiteid.startswith('.' + os.path.sep):
180 suiteid = os.path.join('.', suiteid)
182 while '.' in fullsuite and (rootdir, suiteid) not in self._parents:
183 parentid = suiteids.pop()
184 if not parentid.startswith('.' + os.path.sep):
185 parentid = os.path.join('.', parentid)
186 fullsuite, _, name = fullsuite.rpartition('.')
187 suiteinfo = ParentInfo(suiteid, 'suite', name, rootdir, parentid)
188 self._parents[(rootdir, suiteid)] = suiteinfo
193 suiteinfo = ParentInfo(suiteid, 'suite', name, rootdir, fileid)
194 if (rootdir, suiteid) not in self._parents:
195 self._parents[(rootdir, suiteid)] = suiteinfo
199 def _parse_item(item, _normcase, _pathsep):
210 #_debug_item(item, showsummary=True)
211 kind, _ = _get_item_kind(item)
212 # Figure out the func, suites, and subs.
213 (fileid, suiteids, suites, funcid, basename, parameterized
214 ) = _parse_node_id(item.nodeid, kind)
215 if kind == 'function':
217 if funcid and item.function.__name__ != funcname:
219 raise NotImplementedError
221 testfunc = '.'.join(suites) + '.' + funcname
224 elif kind == 'doctest':
228 # Figure out the file.
229 fspath = str(item.fspath)
230 if not fspath.endswith(_pathsep + fileid):
231 raise NotImplementedError
232 filename = fspath[-len(fileid):]
233 testroot = str(item.fspath)[:-len(fileid)].rstrip(_pathsep)
234 if _pathsep in filename:
237 relfile = '.' + _pathsep + filename
238 srcfile, lineno, fullname = item.location
239 if srcfile != fileid:
240 # pytest supports discovery of tests imported from other
241 # modules. This is reflected by a different filename
243 if _normcase(fileid) == _normcase(srcfile):
247 location = '{}:{}'.format(srcfile, lineno)
248 if kind == 'function':
249 if testfunc and fullname != testfunc + parameterized:
250 print(fullname, testfunc)
252 raise NotImplementedError
253 elif kind == 'doctest':
254 if testfunc and fullname != testfunc + parameterized:
255 print(fullname, testfunc)
257 raise NotImplementedError
259 # Sort out the parent.
263 parentid = suiteids[-1]
268 # See: https://docs.pytest.org/en/latest/reference.html#marks
270 for marker in item.own_markers:
271 if marker.name == 'parameterize':
272 # We've already covered these.
274 elif marker.name == 'skip':
276 elif marker.name == 'skipif':
277 markers.add('skip-if')
278 elif marker.name == 'xfail':
279 markers.add('expected-failure')
280 # TODO: Support other markers?
289 sub=[parameterized] if parameterized else None,
292 markers=sorted(markers) if markers else None,
295 return test, suiteids
298 def _parse_node_id(nodeid, kind='function'):
299 if kind == 'doctest':
301 parentid, name = nodeid.split('::')
303 # TODO: Unexpected! What to do?
304 raise NotImplementedError
309 if nodeid.endswith(']'):
310 funcid, sep, parameterized = nodeid.partition('[')
312 # TODO: Unexpected! What to do?
313 raise NotImplementedError
314 parameterized = sep + parameterized
318 parentid, _, name = funcid.rpartition('::')
320 # TODO: What to do? We expect at least a filename and a function
321 raise NotImplementedError
325 while '::' in parentid:
326 suiteids.insert(0, parentid)
327 parentid, _, suitename = parentid.rpartition('::')
328 suites.insert(0, suitename)
331 return fileid, suiteids, suites, funcid, name, parameterized
334 def _get_item_kind(item):
335 """Return (kind, isunittest) for the given item."""
338 except AttributeError:
339 itemtype = item.__class__.__name__
341 if itemtype == 'DoctestItem':
342 return 'doctest', False
343 elif itemtype == 'Function':
344 return 'function', False
345 elif itemtype == 'TestCaseFunction':
346 return 'function', True
347 elif item.hasattr('function'):
348 return 'function', False
353 #############################
354 # useful for debugging
356 def _debug_item(item, showsummary=False):
357 item._debugging = True
359 # TODO: Make a PytestTest class to wrap the item?
362 'kind': _get_item_kind(item),
363 'class': item.__class__.__name__,
365 'fspath': item.fspath,
366 'location': item.location,
367 'func': getattr(item, 'function', None),
368 'markers': item.own_markers,
369 #'markers': list(item.iter_markers()),
370 'props': item.user_properties,
371 'attrnames': dir(item),
374 item._debugging = False
378 for key in ('kind', 'class', 'name', 'fspath', 'location', 'func',
380 print(' {:12} {}'.format(key, summary[key]))
386 def _group_attr_names(attrnames):
388 'dunder': [n for n in attrnames
389 if n.startswith('__') and n.endswith('__')],
390 'private': [n for n in attrnames if n.startswith('_')],
391 'constants': [n for n in attrnames if n.isupper()],
392 'classes': [n for n in attrnames
393 if n == n.capitalize() and not n.isupper()],
394 'vars': [n for n in attrnames if n.islower()],
396 grouped['other'] = [n for n in attrnames
397 if n not in grouped['dunder']
398 and n not in grouped['private']
399 and n not in grouped['constants']
400 and n not in grouped['classes']
401 and n not in grouped['vars']