diff --git a/CHANGELOG b/CHANGELOG index 77a9a3c57..87f46fff8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,20 @@ 2.7.0.dev (compared to 2.6.4) ----------------------------- +- fix issue616: conftest.py files and their contained fixutres are now + properly considered for visibility, independently from the exact + current working directory and test arguments that are used. + Many thanks to Eric Siegerman and his PR235 which contains + systematic tests for conftest visibility and now passes. + This change also introduces the concept of a ``rootdir`` which + is printed as a new pytest header and documented in the pytest + customize web page. + +- change reporting of "diverted" tests, i.e. tests that are collected + in one file but actually come from another (e.g. when tests in a test class + come from a base class in a different file). We now show the nodeid + and indicate via a postfix the other file. + - add ability to set command line options by environment variable PYTEST_ADDOPTS. - added documentation on the new pytest-dev teams on bitbucket and diff --git a/_pytest/config.py b/_pytest/config.py index f4be2d062..c6a6403f6 100644 --- a/_pytest/config.py +++ b/_pytest/config.py @@ -657,6 +657,12 @@ class Config(object): sys.stderr.write("INTERNALERROR> %s\n" %line) sys.stderr.flush() + def cwd_relative_nodeid(self, nodeid): + # nodeid's are relative to the rootpath, compute relative to cwd + if self.invocation_dir != self.rootdir: + fullpath = self.rootdir.join(nodeid) + nodeid = self.invocation_dir.bestrelpath(fullpath) + return nodeid @classmethod def fromdictargs(cls, option_dict, args): @@ -691,14 +697,9 @@ class Config(object): def _initini(self, args): parsed_args = self._parser.parse_known_args(args) - if parsed_args.inifilename: - iniconfig = py.iniconfig.IniConfig(parsed_args.inifilename) - if 'pytest' in iniconfig.sections: - self.inicfg = iniconfig['pytest'] - else: - self.inicfg = {} - else: - self.inicfg = getcfg(args, ["pytest.ini", "tox.ini", "setup.cfg"]) + r = determine_setup(parsed_args.inifilename, parsed_args.file_or_dir) + self.rootdir, self.inifile, self.inicfg = r + self.invocation_dir = py.path.local() self._parser.addini('addopts', 'extra command line options', 'args') self._parser.addini('minversion', 'minimally required pytest version') @@ -859,8 +860,58 @@ def getcfg(args, inibasenames): if exists(p): iniconfig = py.iniconfig.IniConfig(p) if 'pytest' in iniconfig.sections: - return iniconfig['pytest'] - return {} + return base, p, iniconfig['pytest'] + elif inibasename == "pytest.ini": + # allowed to be empty + return base, p, {} + return None, None, None + + +def get_common_ancestor(args): + # args are what we get after early command line parsing (usually + # strings, but can be py.path.local objects as well) + common_ancestor = None + for arg in args: + if str(arg)[0] == "-": + continue + p = py.path.local(arg) + if common_ancestor is None: + common_ancestor = p + else: + if p.relto(common_ancestor) or p == common_ancestor: + continue + elif common_ancestor.relto(p): + common_ancestor = p + else: + shared = p.common(common_ancestor) + if shared is not None: + common_ancestor = shared + if common_ancestor is None: + common_ancestor = py.path.local() + elif not common_ancestor.isdir(): + common_ancestor = common_ancestor.dirpath() + return common_ancestor + + +def determine_setup(inifile, args): + if inifile: + iniconfig = py.iniconfig.IniConfig(inifile) + try: + inicfg = iniconfig["pytest"] + except KeyError: + inicfg = None + rootdir = get_common_ancestor(args) + else: + ancestor = get_common_ancestor(args) + rootdir, inifile, inicfg = getcfg( + [ancestor], ["pytest.ini", "tox.ini", "setup.cfg"]) + if rootdir is None: + for rootdir in ancestor.parts(reverse=True): + if rootdir.join("setup.py").exists(): + break + else: + rootdir = ancestor + return rootdir, inifile, inicfg or {} def setns(obj, dic): diff --git a/_pytest/main.py b/_pytest/main.py index 124120bf0..f70e06d56 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -457,9 +457,7 @@ class FSCollector(Collector): self.fspath = fspath def _makeid(self): - if self == self.session: - return "." - relpath = self.session.fspath.bestrelpath(self.fspath) + relpath = self.fspath.relto(self.config.rootdir) if os.sep != "/": relpath = relpath.replace(os.sep, "/") return relpath @@ -510,7 +508,7 @@ class Session(FSCollector): __module__ = 'builtins' # for py3 def __init__(self, config): - FSCollector.__init__(self, py.path.local(), parent=None, + FSCollector.__init__(self, config.rootdir, parent=None, config=config, session=self) self.config.pluginmanager.register(self, name="session", prepend=True) self._testsfailed = 0 @@ -520,6 +518,9 @@ class Session(FSCollector): self.startdir = py.path.local() self._fs2hookproxy = {} + def _makeid(self): + return "" + def pytest_collectstart(self): if self.shouldstop: raise self.Interrupted(self.shouldstop) @@ -663,7 +664,7 @@ class Session(FSCollector): arg = self._tryconvertpyarg(arg) parts = str(arg).split("::") relpath = parts[0].replace("/", os.sep) - path = self.fspath.join(relpath, abs=True) + path = self.config.invocation_dir.join(relpath, abs=True) if not path.check(): if self.config.option.pyargs: msg = "file or package not found: " diff --git a/_pytest/pytester.py b/_pytest/pytester.py index e732417ed..7057814ff 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -306,9 +306,8 @@ class TmpTestdir: session = Session(config) assert '::' not in str(arg) p = py.path.local(arg) - x = session.fspath.bestrelpath(p) config.hook.pytest_sessionstart(session=session) - res = session.perform_collect([x], genitems=False)[0] + res = session.perform_collect([str(p)], genitems=False)[0] config.hook.pytest_sessionfinish(session=session, exitstatus=EXIT_OK) return res @@ -395,8 +394,7 @@ class TmpTestdir: def parseconfigure(self, *args): config = self.parseconfig(*args) config.do_configure() - self.request.addfinalizer(lambda: - config.do_unconfigure()) + self.request.addfinalizer(config.do_unconfigure) return config def getitem(self, source, funcname="test_func"): diff --git a/_pytest/python.py b/_pytest/python.py index c1d383034..183585302 100644 --- a/_pytest/python.py +++ b/_pytest/python.py @@ -1653,11 +1653,9 @@ class FixtureManager: # what fixtures are visible for particular tests (as denoted # by their test id) if p.basename.startswith("conftest.py"): - nodeid = self.session.fspath.bestrelpath(p.dirpath()) + nodeid = p.dirpath().relto(self.config.rootdir) if p.sep != "/": nodeid = nodeid.replace(p.sep, "/") - if nodeid == ".": - nodeid = "" self.parsefactories(plugin, nodeid) self._seenplugins.add(plugin) diff --git a/_pytest/skipping.py b/_pytest/skipping.py index 4a77f72c8..f95edf8bd 100644 --- a/_pytest/skipping.py +++ b/_pytest/skipping.py @@ -218,14 +218,14 @@ def show_simple(terminalreporter, lines, stat, format): failed = terminalreporter.stats.get(stat) if failed: for rep in failed: - pos = rep.nodeid - lines.append(format %(pos, )) + pos = terminalreporter.config.cwd_relative_nodeid(rep.nodeid) + lines.append(format %(pos,)) def show_xfailed(terminalreporter, lines): xfailed = terminalreporter.stats.get("xfailed") if xfailed: for rep in xfailed: - pos = rep.nodeid + pos = terminalreporter.config.cwd_relative_nodeid(rep.nodeid) reason = rep.wasxfail lines.append("XFAIL %s" % (pos,)) if reason: @@ -235,7 +235,7 @@ def show_xpassed(terminalreporter, lines): xpassed = terminalreporter.stats.get("xpassed") if xpassed: for rep in xpassed: - pos = rep.nodeid + pos = terminalreporter.config.cwd_relative_nodeid(rep.nodeid) reason = rep.wasxfail lines.append("XPASS %s %s" %(pos, reason)) diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 1f235e7ef..538bf3d8e 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -95,7 +95,7 @@ class TerminalReporter: self._numcollected = 0 self.stats = {} - self.startdir = self.curdir = py.path.local() + self.startdir = py.path.local() if file is None: file = sys.stdout self._tw = self.writer = py.io.TerminalWriter(file) @@ -111,12 +111,12 @@ class TerminalReporter: char = {'xfailed': 'x', 'skipped': 's'}.get(char, char) return char in self.reportchars - def write_fspath_result(self, fspath, res): + def write_fspath_result(self, nodeid, res): + fspath = self.config.rootdir.join(nodeid.split("::")[0]) if fspath != self.currentfspath: self.currentfspath = fspath - #fspath = self.startdir.bestrelpath(fspath) + fspath = self.startdir.bestrelpath(fspath) self._tw.line() - #relpath = self.startdir.bestrelpath(fspath) self._tw.write(fspath + " ") self._tw.write(res) @@ -182,12 +182,12 @@ class TerminalReporter: def pytest_runtest_logstart(self, nodeid, location): # ensure that the path is printed before the # 1st test of a module starts running - fspath = nodeid.split("::")[0] if self.showlongtestinfo: - line = self._locationline(fspath, *location) + line = self._locationline(nodeid, *location) self.write_ensure_prefix(line, "") elif self.showfspath: - self.write_fspath_result(fspath, "") + fsid = nodeid.split("::")[0] + self.write_fspath_result(fsid, "") def pytest_runtest_logreport(self, report): rep = report @@ -200,7 +200,7 @@ class TerminalReporter: return if self.verbosity <= 0: if not hasattr(rep, 'node') and self.showfspath: - self.write_fspath_result(rep.fspath, letter) + self.write_fspath_result(rep.nodeid, letter) else: self._tw.write(letter) else: @@ -213,7 +213,7 @@ class TerminalReporter: markup = {'red':True} elif rep.skipped: markup = {'yellow':True} - line = self._locationline(str(rep.fspath), *rep.location) + line = self._locationline(rep.nodeid, *rep.location) if not hasattr(rep, 'node'): self.write_ensure_prefix(line, word, **markup) #self._tw.write(word, **markup) @@ -237,7 +237,7 @@ class TerminalReporter: items = [x for x in report.result if isinstance(x, pytest.Item)] self._numcollected += len(items) if self.hasmarkup: - #self.write_fspath_result(report.fspath, 'E') + #self.write_fspath_result(report.nodeid, 'E') self.report_collect() def report_collect(self, final=False): @@ -288,6 +288,10 @@ class TerminalReporter: self.write_line(line) def pytest_report_header(self, config): + inifile = "" + if config.inifile: + inifile = config.rootdir.bestrelpath(config.inifile) + lines = ["rootdir: %s, inifile: %s" %(config.rootdir, inifile)] plugininfo = config.pluginmanager._plugin_distinfo if plugininfo: l = [] @@ -296,7 +300,8 @@ class TerminalReporter: if name.startswith("pytest-"): name = name[7:] l.append(name) - return "plugins: %s" % ", ".join(l) + lines.append("plugins: %s" % ", ".join(l)) + return lines def pytest_collection_finish(self, session): if self.config.option.collectonly: @@ -378,19 +383,24 @@ class TerminalReporter: else: excrepr.reprcrash.toterminal(self._tw) - def _locationline(self, collect_fspath, fspath, lineno, domain): + def _locationline(self, nodeid, fspath, lineno, domain): + def mkrel(nodeid): + line = self.config.cwd_relative_nodeid(nodeid) + if domain and line.endswith(domain): + line = line[:-len(domain)] + l = domain.split("[") + l[0] = l[0].replace('.', '::') # don't replace '.' in params + line += "[".join(l) + return line # collect_fspath comes from testid which has a "/"-normalized path - if fspath and fspath.replace("\\", "/") != collect_fspath: - fspath = "%s <- %s" % (collect_fspath, fspath) + if fspath: - line = str(fspath) - if domain: - split = str(domain).split('[') - split[0] = split[0].replace('.', '::') # don't replace '.' in params - line += "::" + '['.join(split) + res = mkrel(nodeid).replace("::()", "") # parens-normalization + if nodeid.split("::")[0] != fspath.replace("\\", "/"): + res += " <- " + self.startdir.bestrelpath(fspath) else: - line = "[location]" - return line + " " + res = "[location]" + return res + " " def _getfailureheadline(self, rep): if hasattr(rep, 'location'): diff --git a/doc/en/customize.txt b/doc/en/customize.txt index d323dd638..c2d4c2617 100644 --- a/doc/en/customize.txt +++ b/doc/en/customize.txt @@ -12,37 +12,73 @@ configurations files by using the general help option:: This will display command line and configuration file settings which were registered by installed plugins. +.. _rootdir: .. _inifiles: -How test configuration is read from configuration INI-files -------------------------------------------------------------- +initialization: determining rootdir and inifile +----------------------------------------------- -``pytest`` searches for the first matching ini-style configuration file -in the directories of command line argument and the directories above. -It looks for file basenames in this order:: +.. versionadded:: 2.7 +pytest determines a "rootdir" for each test run which depends on +the command line arguments (specified test files, paths) and on +the existence of inifiles. The determined rootdir and ini-file are +printed as part of the pytest header. The rootdir is used for constructing +"nodeids" during collection and may also be used by plugins to store +project/testrun-specific information. + +Here is the algorithm which finds the rootdir from ``args``: + +- determine the common ancestor directory for the specified ``args``. + +- look for ``pytest.ini``, ``tox.ini`` and ``setup.cfg`` files in the + ancestor directory and upwards. If one is matched, it becomes the + ini-file and its directory becomes the rootdir. An existing + ``pytest.ini`` file will always be considered a match whereas + ``tox.ini`` and ``setup.cfg`` will only match if they contain + a ``[pytest]`` section. + +- if no ini-file was found, look for ``setup.py`` upwards from + the common ancestor directory to determine the ``rootdir``. + +- if no ini-file and no ``setup.py`` was found, use the already + determined common ancestor as root directory. This allows to + work with pytest in structures that are not part of a package + and don't have any particular ini-file configuration. + +Note that options from multiple ini-files candidates are never merged, +the first one wins (``pytest.ini`` always wins even if it does not +contain a ``[pytest]`` section). + +The ``config`` object will subsequently carry these attributes: + +- ``config.rootdir``: the determined root directory, guaranteed to exist. + +- ``config.inifile``: the determined ini-file, may be ``None``. + +The rootdir is used a reference directory for constructing test +addresses ("nodeids") and can be used also by plugins for storing +per-testrun information. + +Example:: + + py.test path/to/testdir path/other/ + +will determine the common ancestor as ``path`` and then +check for ini-files as follows:: + + # first look for pytest.ini files + path/pytest.ini + path/setup.cfg # must also contain [pytest] section to match + path/tox.ini # must also contain [pytest] section to match pytest.ini - tox.ini - setup.cfg + ... # all the way down to the root -Searching stops when the first ``[pytest]`` section is found in any of -these files. There is no merging of configuration values from multiple -files. Example:: + # now look for setup.py + path/setup.py + setup.py + ... # all the way down to the root - py.test path/to/testdir - -will look in the following dirs for a config file:: - - path/to/testdir/pytest.ini - path/to/testdir/tox.ini - path/to/testdir/setup.cfg - path/to/pytest.ini - path/to/tox.ini - path/to/setup.cfg - ... # up until root of filesystem - -If argument is provided to a ``pytest`` run, the current working directory -is used to start the search. .. _`how to change command line options defaults`: .. _`adding default options`: @@ -67,6 +103,8 @@ line options while the environment is in use:: From now on, running ``pytest`` will add the specified options. + + Builtin configuration file options ---------------------------------------------- diff --git a/testing/test_collection.py b/testing/test_collection.py index e8e47690f..0c8cabc67 100644 --- a/testing/test_collection.py +++ b/testing/test_collection.py @@ -343,7 +343,7 @@ class TestSession: ("pytest_make_collect_report", "collector.fspath == p"), ("pytest_pycollect_makeitem", "name == 'test_func'"), ("pytest_collectreport", "report.nodeid.startswith(p.basename)"), - ("pytest_collectreport", "report.nodeid == '.'") + ("pytest_collectreport", "report.nodeid == ''") ]) def test_collect_protocol_method(self, testdir): @@ -478,7 +478,7 @@ class Test_getinitialnodes: config = testdir.parseconfigure(x) col = testdir.getnode(config, x) assert isinstance(col, pytest.Module) - assert col.name == 'subdir/x.py' + assert col.name == 'x.py' assert col.parent.parent is None for col in col.listchain(): assert col.config is config diff --git a/testing/test_config.py b/testing/test_config.py index 9dc5e3f23..f1e3c5eb9 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -1,6 +1,6 @@ import py, pytest -from _pytest.config import getcfg +from _pytest.config import getcfg, get_common_ancestor, determine_setup class TestParseIni: def test_getcfg_and_config(self, testdir, tmpdir): @@ -10,7 +10,7 @@ class TestParseIni: [pytest] name = value """)) - cfg = getcfg([sub], ["setup.cfg"]) + rootdir, inifile, cfg = getcfg([sub], ["setup.cfg"]) assert cfg['name'] == "value" config = testdir.parseconfigure(sub) assert config.inicfg['name'] == 'value' @@ -400,3 +400,55 @@ class TestWarning: *WT1*test_warn_on_test_item*:5*hello* *1 warning* """) + +class TestRootdir: + def test_simple_noini(self, tmpdir): + assert get_common_ancestor([tmpdir]) == tmpdir + assert get_common_ancestor([tmpdir.mkdir("a"), tmpdir]) == tmpdir + assert get_common_ancestor([tmpdir, tmpdir.join("a")]) == tmpdir + with tmpdir.as_cwd(): + assert get_common_ancestor([]) == tmpdir + + @pytest.mark.parametrize("name", "setup.cfg tox.ini pytest.ini".split()) + def test_with_ini(self, tmpdir, name): + inifile = tmpdir.join(name) + inifile.write("[pytest]\n") + + a = tmpdir.mkdir("a") + b = a.mkdir("b") + for args in ([tmpdir], [a], [b]): + rootdir, inifile, inicfg = determine_setup(None, args) + assert rootdir == tmpdir + assert inifile == inifile + rootdir, inifile, inicfg = determine_setup(None, [b,a]) + assert rootdir == tmpdir + assert inifile == inifile + + @pytest.mark.parametrize("name", "setup.cfg tox.ini".split()) + def test_pytestini_overides_empty_other(self, tmpdir, name): + inifile = tmpdir.ensure("pytest.ini") + a = tmpdir.mkdir("a") + a.ensure(name) + rootdir, inifile, inicfg = determine_setup(None, [a]) + assert rootdir == tmpdir + assert inifile == inifile + + def test_setuppy_fallback(self, tmpdir): + a = tmpdir.mkdir("a") + a.ensure("setup.cfg") + tmpdir.ensure("setup.py") + rootdir, inifile, inicfg = determine_setup(None, [a]) + assert rootdir == tmpdir + assert inifile is None + assert inicfg == {} + + def test_nothing(self, tmpdir): + rootdir, inifile, inicfg = determine_setup(None, [tmpdir]) + assert rootdir == tmpdir + assert inifile is None + assert inicfg == {} + + def test_with_specific_inifile(self, tmpdir): + inifile = tmpdir.ensure("pytest.ini") + rootdir, inifile, inicfg = determine_setup(inifile, [tmpdir]) + assert rootdir == tmpdir diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 3f39cfc5d..1de92ec06 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -1,7 +1,9 @@ +from textwrap import dedent import py, pytest from _pytest.config import Conftest + @pytest.fixture(scope="module", params=["global", "inpackage"]) def basedir(request): from _pytest.tmpdir import tmpdir @@ -255,3 +257,89 @@ def test_conftest_found_with_double_dash(testdir): result.stdout.fnmatch_lines(""" *--hello-world* """) + + +class TestConftestVisibility: + def _setup_tree(self, testdir): # for issue616 + # example mostly taken from: + # https://mail.python.org/pipermail/pytest-dev/2014-September/002617.html + runner = testdir.mkdir("empty") + package = testdir.mkdir("package") + + package.join("conftest.py").write(dedent("""\ + import pytest + @pytest.fixture + def fxtr(): + return "from-package" + """)) + package.join("test_pkgroot.py").write(dedent("""\ + def test_pkgroot(fxtr): + assert fxtr == "from-package" + """)) + + swc = package.mkdir("swc") + swc.join("__init__.py").ensure() + swc.join("conftest.py").write(dedent("""\ + import pytest + @pytest.fixture + def fxtr(): + return "from-swc" + """)) + swc.join("test_with_conftest.py").write(dedent("""\ + def test_with_conftest(fxtr): + assert fxtr == "from-swc" + + """)) + + snc = package.mkdir("snc") + snc.join("__init__.py").ensure() + snc.join("test_no_conftest.py").write(dedent("""\ + def test_no_conftest(fxtr): + assert fxtr == "from-package" # No local conftest.py, so should + # use value from parent dir's + + """)) + print ("created directory structure:") + for x in testdir.tmpdir.visit(): + print (" " + x.relto(testdir.tmpdir)) + + return { + "runner": runner, + "package": package, + "swc": swc, + "snc": snc} + + # N.B.: "swc" stands for "subdir with conftest.py" + # "snc" stands for "subdir no [i.e. without] conftest.py" + @pytest.mark.parametrize("chdir,testarg,expect_ntests_passed", [ + ("runner", "..", 3), + ("package", "..", 3), + ("swc", "../..", 3), + ("snc", "../..", 3), + + ("runner", "../package", 3), + ("package", ".", 3), + ("swc", "..", 3), + ("snc", "..", 3), + + ("runner", "../package/swc", 1), + ("package", "./swc", 1), + ("swc", ".", 1), + ("snc", "../swc", 1), + + ("runner", "../package/snc", 1), + ("package", "./snc", 1), + ("swc", "../snc", 1), + ("snc", ".", 1), + ]) + @pytest.mark.issue616 + def test_parsefactories_relative_node_ids( + self, testdir, chdir,testarg, expect_ntests_passed): + dirs = self._setup_tree(testdir) + print("pytest run in cwd: %s" %( + dirs[chdir].relto(testdir.tmpdir))) + print("pytestarg : %s" %(testarg)) + print("expected pass : %s" %(expect_ntests_passed)) + with dirs[chdir].as_cwd(): + reprec = testdir.inline_run(testarg, "-q", "--traceconfig") + reprec.assertoutcome(passed=expect_ntests_passed) diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 351168ee6..c1139f773 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -1,9 +1,6 @@ from _pytest.doctest import DoctestItem, DoctestModule, DoctestTextfile import py, pytest -import pdb - - class TestDoctests: def test_collect_testtextfile(self, testdir): diff --git a/testing/test_terminal.py b/testing/test_terminal.py index b543e1c6d..afb79d00c 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -77,11 +77,11 @@ class TestTerminal: def test_writeline(self, testdir, linecomp): modcol = testdir.getmodulecol("def test_one(): pass") rep = TerminalReporter(modcol.config, file=linecomp.stringio) - rep.write_fspath_result(py.path.local("xy.py"), '.') + rep.write_fspath_result(modcol.nodeid, ".") rep.write_line("hello world") lines = linecomp.stringio.getvalue().split('\n') assert not lines[0] - assert lines[1].endswith("xy.py .") + assert lines[1].endswith(modcol.name + " .") assert lines[2] == "hello world" def test_show_runtest_logstart(self, testdir, linecomp): @@ -126,7 +126,7 @@ class TestTerminal: ]) result = testdir.runpytest("-v", p2) result.stdout.fnmatch_lines([ - "*test_p2.py <- *test_p1.py::TestMore::test_p1*", + "*test_p2.py::TestMore::test_p1* <- *test_p1.py*PASSED", ]) def test_itemreport_directclasses_not_shown_as_subclasses(self, testdir):