diff --git a/changelog/4546.removal.rst b/changelog/4546.removal.rst new file mode 100644 index 00000000000..977a30e8cd0 --- /dev/null +++ b/changelog/4546.removal.rst @@ -0,0 +1,3 @@ +Remove ``Node.get_marker(name)`` the return value was not usable for more than a existence check. + +Use ``Node.get_closest_marker(name)`` as a replacement. diff --git a/changelog/891.removal.rst b/changelog/891.removal.rst new file mode 100644 index 00000000000..82d75c530a6 --- /dev/null +++ b/changelog/891.removal.rst @@ -0,0 +1 @@ +Remove ``testfunction.markername`` attributes - use ``Node.iter_markers(name=None)`` to iterate them. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index dc716951b0d..f3240cec7a1 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -77,13 +77,7 @@ Becomes: -``Node.get_marker`` -~~~~~~~~~~~~~~~~~~~ - -.. deprecated:: 3.6 -As part of a large :ref:`marker-revamp`, :meth:`_pytest.nodes.Node.get_marker` is deprecated. See -:ref:`the documentation ` on tips on how to update your code. Result log (``--result-log``) @@ -497,3 +491,21 @@ Removed all ``py.test-X*`` entry points. The versioned, suffixed entry points were never documented and a leftover from a pre-virtualenv era. These entry points also created broken entry points in wheels, so removing them also removes a source of confusion for users. + + +``Node.get_marker`` +~~~~~~~~~~~~~~~~~~~ + +*Removed in version 4.0* + +As part of a large :ref:`marker-revamp`, :meth:`_pytest.nodes.Node.get_marker` is deprecated. See +:ref:`the documentation ` on tips on how to update your code. + + +``somefunction.markname`` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +*Removed in version 4.0* + +As part of a large :ref:`marker-revamp` we already deprecated using ``MarkInfo`` +the only correct way to get markers of an element is via ``node.iter_markers(name)``. diff --git a/doc/en/reference.rst b/doc/en/reference.rst index 754035d16b8..9305cbb9586 100644 --- a/doc/en/reference.rst +++ b/doc/en/reference.rst @@ -724,13 +724,6 @@ MarkGenerator :members: -MarkInfo -~~~~~~~~ - -.. autoclass:: _pytest.mark.MarkInfo - :members: - - Mark ~~~~ diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index f20bb036e3b..051eda79d3c 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -268,10 +268,14 @@ def parse_hookimpl_opts(self, plugin, name): # collect unmarked hooks as long as they have the `pytest_' prefix if opts is None and name.startswith("pytest_"): opts = {} - if opts is not None: + # TODO: DeprecationWarning, people should use hookimpl + # https://github.com/pytest-dev/pytest/issues/4562 + known_marks = {m.name for m in getattr(method, "pytestmark", [])} + for name in ("tryfirst", "trylast", "optionalhook", "hookwrapper"): - opts.setdefault(name, hasattr(method, name)) + + opts.setdefault(name, hasattr(method, name) or name in known_marks) return opts def parse_hookspec_opts(self, module_or_class, name): @@ -280,10 +284,16 @@ def parse_hookspec_opts(self, module_or_class, name): ) if opts is None: method = getattr(module_or_class, name) + if name.startswith("pytest_"): + # todo: deprecate hookspec hacks + # https://github.com/pytest-dev/pytest/issues/4562 + known_marks = {m.name for m in getattr(method, "pytestmark", [])} opts = { - "firstresult": hasattr(method, "firstresult"), - "historic": hasattr(method, "historic"), + "firstresult": hasattr(method, "firstresult") + or "firstresult" in known_marks, + "historic": hasattr(method, "historic") + or "historic" in known_marks, } return opts diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 1da7a6c4893..0a1f258e571 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1207,19 +1207,20 @@ def pytest_generate_tests(self, metafunc): if faclist: fixturedef = faclist[-1] if fixturedef.params is not None: - parametrize_func = getattr(metafunc.function, "parametrize", None) - if parametrize_func is not None: - parametrize_func = parametrize_func.combined - func_params = getattr(parametrize_func, "args", [[None]]) - func_kwargs = getattr(parametrize_func, "kwargs", {}) - # skip directly parametrized arguments - if "argnames" in func_kwargs: - argnames = parametrize_func.kwargs["argnames"] + markers = list(metafunc.definition.iter_markers("parametrize")) + for parametrize_mark in markers: + if "argnames" in parametrize_mark.kwargs: + argnames = parametrize_mark.kwargs["argnames"] + else: + argnames = parametrize_mark.args[0] + + if not isinstance(argnames, (tuple, list)): + argnames = [ + x.strip() for x in argnames.split(",") if x.strip() + ] + if argname in argnames: + break else: - argnames = func_params[0] - if not isinstance(argnames, (tuple, list)): - argnames = [x.strip() for x in argnames.split(",") if x.strip()] - if argname not in func_params and argname not in argnames: metafunc.parametrize( argname, fixturedef.params, diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index b6495dd0345..bc4c467f918 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -11,19 +11,10 @@ from .structures import MARK_GEN from .structures import MarkDecorator from .structures import MarkGenerator -from .structures import MarkInfo from .structures import ParameterSet -from .structures import transfer_markers from _pytest.config import UsageError -__all__ = [ - "Mark", - "MarkInfo", - "MarkDecorator", - "MarkGenerator", - "transfer_markers", - "get_empty_parameterset_mark", -] +__all__ = ["Mark", "MarkDecorator", "MarkGenerator", "get_empty_parameterset_mark"] def param(*values, **kw): diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 18fb6fa6db2..49695b56f65 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -1,18 +1,15 @@ import inspect import warnings from collections import namedtuple -from functools import reduce from operator import attrgetter import attr import six -from six.moves import map from ..compat import ascii_escaped from ..compat import getfslineno from ..compat import MappingMixin from ..compat import NOTSET -from ..deprecated import MARK_INFO_ATTRIBUTE from _pytest.outcomes import fail @@ -233,11 +230,7 @@ def __call__(self, *args, **kwargs): func = args[0] is_class = inspect.isclass(func) if len(args) == 1 and (istestfunc(func) or is_class): - if is_class: - store_mark(func, self.mark) - else: - store_legacy_markinfo(func, self.mark) - store_mark(func, self.mark) + store_mark(func, self.mark) return func return self.with_args(*args, **kwargs) @@ -259,7 +252,13 @@ def normalize_mark_list(mark_list): :type mark_list: List[Union[Mark, Markdecorator]] :rtype: List[Mark] """ - return [getattr(mark, "mark", mark) for mark in mark_list] # unpack MarkDecorator + extracted = [ + getattr(mark, "mark", mark) for mark in mark_list + ] # unpack MarkDecorator + for mark in extracted: + if not isinstance(mark, Mark): + raise TypeError("got {!r} instead of Mark".format(mark)) + return [x for x in extracted if isinstance(x, Mark)] def store_mark(obj, mark): @@ -272,90 +271,6 @@ def store_mark(obj, mark): obj.pytestmark = get_unpacked_marks(obj) + [mark] -def store_legacy_markinfo(func, mark): - """create the legacy MarkInfo objects and put them onto the function - """ - if not isinstance(mark, Mark): - raise TypeError("got {mark!r} instead of a Mark".format(mark=mark)) - holder = getattr(func, mark.name, None) - if holder is None: - holder = MarkInfo.for_mark(mark) - setattr(func, mark.name, holder) - elif isinstance(holder, MarkInfo): - holder.add_mark(mark) - - -def transfer_markers(funcobj, cls, mod): - """ - this function transfers class level markers and module level markers - into function level markinfo objects - - this is the main reason why marks are so broken - the resolution will involve phasing out function level MarkInfo objects - - """ - for obj in (cls, mod): - for mark in get_unpacked_marks(obj): - if not _marked(funcobj, mark): - store_legacy_markinfo(funcobj, mark) - - -def _marked(func, mark): - """ Returns True if :func: is already marked with :mark:, False otherwise. - This can happen if marker is applied to class and the test file is - invoked more than once. - """ - try: - func_mark = getattr(func, getattr(mark, "combined", mark).name) - except AttributeError: - return False - return any(mark == info.combined for info in func_mark) - - -@attr.s(repr=False) -class MarkInfo(object): - """ Marking object created by :class:`MarkDecorator` instances. """ - - _marks = attr.ib(converter=list) - - @_marks.validator - def validate_marks(self, attribute, value): - for item in value: - if not isinstance(item, Mark): - raise ValueError( - "MarkInfo expects Mark instances, got {!r} ({!r})".format( - item, type(item) - ) - ) - - combined = attr.ib( - repr=False, - default=attr.Factory( - lambda self: reduce(Mark.combined_with, self._marks), takes_self=True - ), - ) - - name = alias("combined.name", warning=MARK_INFO_ATTRIBUTE) - args = alias("combined.args", warning=MARK_INFO_ATTRIBUTE) - kwargs = alias("combined.kwargs", warning=MARK_INFO_ATTRIBUTE) - - @classmethod - def for_mark(cls, mark): - return cls([mark]) - - def __repr__(self): - return "".format(self.combined) - - def add_mark(self, mark): - """ add a MarkInfo with the given args and kwargs. """ - self._marks.append(mark) - self.combined = self.combined.combined_with(mark) - - def __iter__(self): - """ yield MarkInfo objects each relating to a marking-call. """ - return map(MarkInfo.for_mark, self._marks) - - class MarkGenerator(object): """ Factory for :class:`MarkDecorator` objects - exposed as a ``pytest.mark`` singleton instance. Example:: diff --git a/src/_pytest/nodes.py b/src/_pytest/nodes.py index b324b1f6899..00ec80894c9 100644 --- a/src/_pytest/nodes.py +++ b/src/_pytest/nodes.py @@ -10,7 +10,6 @@ import _pytest._code from _pytest.compat import getfslineno -from _pytest.mark.structures import MarkInfo from _pytest.mark.structures import NodeKeywords from _pytest.outcomes import fail @@ -211,20 +210,6 @@ def get_closest_marker(self, name, default=None): """ return next(self.iter_markers(name=name), default) - def get_marker(self, name): - """ get a marker object from this node or None if - the node doesn't have a marker with that name. - - .. deprecated:: 3.6 - This function has been deprecated in favor of - :meth:`Node.get_closest_marker <_pytest.nodes.Node.get_closest_marker>` and - :meth:`Node.iter_markers <_pytest.nodes.Node.iter_markers>`, see :ref:`update marker code` - for more details. - """ - markers = list(self.iter_markers(name=name)) - if markers: - return MarkInfo(markers) - def listextrakeywords(self): """ Return a set of all extra keywords in self and any parents.""" extra_keywords = set() diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 5a77b09adcb..48a50178f65 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -41,7 +41,6 @@ from _pytest.mark import MARK_GEN from _pytest.mark.structures import get_unpacked_marks from _pytest.mark.structures import normalize_mark_list -from _pytest.mark.structures import transfer_markers from _pytest.outcomes import fail from _pytest.pathlib import parts from _pytest.warning_types import PytestWarning @@ -125,10 +124,10 @@ def pytest_generate_tests(metafunc): # those alternative spellings are common - raise a specific error to alert # the user alt_spellings = ["parameterize", "parametrise", "parameterise"] - for attr in alt_spellings: - if hasattr(metafunc.function, attr): + for mark_name in alt_spellings: + if metafunc.definition.get_closest_marker(mark_name): msg = "{0} has '{1}' mark, spelling should be 'parametrize'" - fail(msg.format(metafunc.function.__name__, attr), pytrace=False) + fail(msg.format(metafunc.function.__name__, mark_name), pytrace=False) for marker in metafunc.definition.iter_markers(name="parametrize"): metafunc.parametrize(*marker.args, **marker.kwargs) @@ -385,7 +384,6 @@ def _genfunctions(self, name, funcobj): module = self.getparent(Module).obj clscol = self.getparent(Class) cls = clscol and clscol.obj or None - transfer_markers(funcobj, cls, module) fm = self.session._fixturemanager definition = FunctionDefinition(name=name, parent=self, callobj=funcobj) @@ -1291,6 +1289,20 @@ def __init__( if keywords: self.keywords.update(keywords) + # todo: this is a hell of a hack + # https://github.com/pytest-dev/pytest/issues/4569 + + self.keywords.update( + dict.fromkeys( + [ + mark.name + for mark in self.iter_markers() + if mark.name not in self.keywords + ], + True, + ) + ) + if fixtureinfo is None: fixtureinfo = self.session._fixturemanager.getfixtureinfo( self, self.obj, self.cls, funcargs=not self._isyieldedfunction() diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index d9881cd8751..4a886c2e113 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -14,8 +14,6 @@ from _pytest.outcomes import xfail from _pytest.python import Class from _pytest.python import Function -from _pytest.python import Module -from _pytest.python import transfer_markers def pytest_pycollect_makeitem(collector, name, obj): @@ -54,14 +52,12 @@ def collect(self): return self.session._fixturemanager.parsefactories(self, unittest=True) loader = TestLoader() - module = self.getparent(Module).obj foundsomething = False for name in loader.getTestCaseNames(self.obj): x = getattr(self.obj, name) if not getattr(x, "__test__", True): continue funcobj = getimfunc(x) - transfer_markers(funcobj, cls, module) yield TestCaseFunction(name, parent=self, callobj=funcobj) foundsomething = True diff --git a/testing/test_mark.py b/testing/test_mark.py index 4888b1c557c..a10e2e19de4 100644 --- a/testing/test_mark.py +++ b/testing/test_mark.py @@ -10,7 +10,6 @@ import pytest from _pytest.mark import EMPTY_PARAMETERSET_OPTION from _pytest.mark import MarkGenerator as Mark -from _pytest.mark import transfer_markers from _pytest.nodes import Collector from _pytest.nodes import Node from _pytest.warnings import SHOW_PYTEST_WARNINGS_ARG @@ -26,12 +25,6 @@ class TestMark(object): - def test_markinfo_repr(self): - from _pytest.mark import MarkInfo, Mark - - m = MarkInfo.for_mark(Mark("hello", (1, 2), {})) - repr(m) - @pytest.mark.parametrize("attr", ["mark", "param"]) @pytest.mark.parametrize("modulename", ["py.test", "pytest"]) def test_pytest_exists_in_namespace_all(self, attr, modulename): @@ -57,105 +50,8 @@ class SomeClass(object): def test_pytest_mark_name_starts_with_underscore(self): mark = Mark() - pytest.raises(AttributeError, getattr, mark, "_some_name") - - def test_pytest_mark_bare(self): - mark = Mark() - - def f(): - pass - - mark.hello(f) - assert f.hello - - def test_mark_legacy_ignore_fail(self): - def add_attribute(func): - func.foo = 1 - return func - - @pytest.mark.foo - @add_attribute - def test_fun(): - pass - - assert test_fun.foo == 1 - assert test_fun.pytestmark - - @ignore_markinfo - def test_pytest_mark_keywords(self): - mark = Mark() - - def f(): - pass - - mark.world(x=3, y=4)(f) - assert f.world - assert f.world.kwargs["x"] == 3 - assert f.world.kwargs["y"] == 4 - - @ignore_markinfo - def test_apply_multiple_and_merge(self): - mark = Mark() - - def f(): - pass - - mark.world - mark.world(x=3)(f) - assert f.world.kwargs["x"] == 3 - mark.world(y=4)(f) - assert f.world.kwargs["x"] == 3 - assert f.world.kwargs["y"] == 4 - mark.world(y=1)(f) - assert f.world.kwargs["y"] == 1 - assert len(f.world.args) == 0 - - @ignore_markinfo - def test_pytest_mark_positional(self): - mark = Mark() - - def f(): - pass - - mark.world("hello")(f) - assert f.world.args[0] == "hello" - mark.world("world")(f) - - @ignore_markinfo - def test_pytest_mark_positional_func_and_keyword(self): - mark = Mark() - - def f(): - raise Exception - - m = mark.world(f, omega="hello") - - def g(): - pass - - assert m(g) == g - assert g.world.args[0] is f - assert g.world.kwargs["omega"] == "hello" - - @ignore_markinfo - def test_pytest_mark_reuse(self): - mark = Mark() - - def f(): - pass - - w = mark.some - w("hello", reason="123")(f) - assert f.some.args[0] == "hello" - assert f.some.kwargs["reason"] == "123" - - def g(): - pass - - w("world", reason2="456")(g) - assert g.some.args[0] == "world" - assert "reason" not in g.some.kwargs - assert g.some.kwargs["reason2"] == "456" + with pytest.raises(AttributeError): + mark._some_name def test_marked_class_run_twice(testdir, request): @@ -505,116 +401,6 @@ def test_func(a, b): class TestFunctional(object): - def test_mark_per_function(self, testdir): - p = testdir.makepyfile( - """ - import pytest - @pytest.mark.hello - def test_hello(): - assert hasattr(test_hello, 'hello') - """ - ) - result = testdir.runpytest(p) - result.stdout.fnmatch_lines(["*1 passed*"]) - - def test_mark_per_module(self, testdir): - item = testdir.getitem( - """ - import pytest - pytestmark = pytest.mark.hello - def test_func(): - pass - """ - ) - keywords = item.keywords - assert "hello" in keywords - - def test_marklist_per_class(self, testdir): - item = testdir.getitem( - """ - import pytest - class TestClass(object): - pytestmark = [pytest.mark.hello, pytest.mark.world] - def test_func(self): - assert TestClass.test_func.hello - assert TestClass.test_func.world - """ - ) - keywords = item.keywords - assert "hello" in keywords - - def test_marklist_per_module(self, testdir): - item = testdir.getitem( - """ - import pytest - pytestmark = [pytest.mark.hello, pytest.mark.world] - class TestClass(object): - def test_func(self): - assert TestClass.test_func.hello - assert TestClass.test_func.world - """ - ) - keywords = item.keywords - assert "hello" in keywords - assert "world" in keywords - - def test_mark_per_class_decorator(self, testdir): - item = testdir.getitem( - """ - import pytest - @pytest.mark.hello - class TestClass(object): - def test_func(self): - assert TestClass.test_func.hello - """ - ) - keywords = item.keywords - assert "hello" in keywords - - def test_mark_per_class_decorator_plus_existing_dec(self, testdir): - item = testdir.getitem( - """ - import pytest - @pytest.mark.hello - class TestClass(object): - pytestmark = pytest.mark.world - def test_func(self): - assert TestClass.test_func.hello - assert TestClass.test_func.world - """ - ) - keywords = item.keywords - assert "hello" in keywords - assert "world" in keywords - - @ignore_markinfo - def test_merging_markers(self, testdir): - p = testdir.makepyfile( - """ - import pytest - pytestmark = pytest.mark.hello("pos1", x=1, y=2) - class TestClass(object): - # classlevel overrides module level - pytestmark = pytest.mark.hello(x=3) - @pytest.mark.hello("pos0", z=4) - def test_func(self): - pass - """ - ) - items, rec = testdir.inline_genitems(p) - item, = items - keywords = item.keywords - marker = keywords["hello"] - assert marker.args == ("pos0", "pos1") - assert marker.kwargs == {"x": 1, "y": 2, "z": 4} - - # test the new __iter__ interface - values = list(marker) - assert len(values) == 3 - assert values[0].args == ("pos0",) - assert values[1].args == () - assert values[2].args == ("pos1",) - def test_merging_markers_deep(self, testdir): # issue 199 - propagate markers into nested classes p = testdir.makepyfile( @@ -677,11 +463,6 @@ class TestOtherSub(TestBase): items, rec = testdir.inline_genitems(p) base_item, sub_item, sub_item_other = items print(items, [x.nodeid for x in items]) - # legacy api smears - assert hasattr(base_item.obj, "b") - assert hasattr(sub_item_other.obj, "b") - assert hasattr(sub_item.obj, "b") - # new api seregates assert not list(base_item.iter_markers(name="b")) assert not list(sub_item_other.iter_markers(name="b")) @@ -767,26 +548,6 @@ def test_func(arg): result = testdir.runpytest() result.stdout.fnmatch_lines(["keyword: *hello*"]) - @ignore_markinfo - def test_merging_markers_two_functions(self, testdir): - p = testdir.makepyfile( - """ - import pytest - @pytest.mark.hello("pos1", z=4) - @pytest.mark.hello("pos0", z=3) - def test_func(): - pass - """ - ) - items, rec = testdir.inline_genitems(p) - item, = items - keywords = item.keywords - marker = keywords["hello"] - values = list(marker) - assert len(values) == 2 - assert values[0].args == ("pos0",) - assert values[1].args == ("pos1",) - def test_no_marker_match_on_unmarked_names(self, testdir): p = testdir.makepyfile( """ @@ -860,7 +621,7 @@ def test_some(request): assert "mark2" in request.keywords assert "mark3" in request.keywords assert 10 not in request.keywords - marker = request.node.get_marker("mark1") + marker = request.node.get_closest_marker("mark1") assert marker.name == "mark1" assert marker.args == () assert marker.kwargs == {} @@ -876,15 +637,11 @@ def assert_markers(self, items, **expected): .. note:: this could be moved to ``testdir`` if proven to be useful to other modules. """ - from _pytest.mark import MarkInfo items = {x.name: x for x in items} for name, expected_markers in expected.items(): - markers = items[name].keywords._markers - marker_names = { - name for (name, v) in markers.items() if isinstance(v, MarkInfo) - } - assert marker_names == set(expected_markers) + markers = {m.name for m in items[name].iter_markers()} + assert markers == set(expected_markers) @pytest.mark.issue1540 @pytest.mark.filterwarnings("ignore") @@ -1043,26 +800,6 @@ def assert_test_is_not_selected(keyword): assert_test_is_not_selected("()") -def test_legacy_transfer(): - class FakeModule(object): - pytestmark = [] - - class FakeClass(object): - pytestmark = pytest.mark.nofun - - @pytest.mark.fun - def fake_method(self): - pass - - transfer_markers(fake_method, FakeClass, FakeModule) - - # legacy marks transfer smeared - assert fake_method.nofun - assert fake_method.fun - # pristine marks dont transfer - assert fake_method.pytestmark == [pytest.mark.fun.mark] - - class TestMarkDecorator(object): @pytest.mark.parametrize( "lhs, rhs, expected", @@ -1163,19 +900,12 @@ class TestBarClass(BaseTests): deselected_tests = dlist[0].items assert len(deselected_tests) == 1 + # todo: fixed # keywords smear - expected behaviour - reprec_keywords = testdir.inline_run("-k", "FOO") - passed_k, skipped_k, failed_k = reprec_keywords.countoutcomes() - assert passed_k == 2 - assert skipped_k == failed_k == 0 - - -def test_addmarker_getmarker(): - node = Node("Test", config=mock.Mock(), session=mock.Mock(), nodeid="Test") - node.add_marker(pytest.mark.a(1)) - node.add_marker("b") - node.get_marker("a").combined - node.get_marker("b").combined + # reprec_keywords = testdir.inline_run("-k", "FOO") + # passed_k, skipped_k, failed_k = reprec_keywords.countoutcomes() + # assert passed_k == 2 + # assert skipped_k == failed_k == 0 def test_addmarker_order(): @@ -1199,7 +929,7 @@ def test_markers_from_parametrize(testdir): custom_mark = pytest.mark.custom_mark @pytest.fixture(autouse=True) def trigger(request): - custom_mark =request.node.get_marker('custom_mark') + custom_mark = list(request.node.iter_markers('custom_mark')) print("Custom mark %s" % custom_mark) @custom_mark("custom mark non parametrized") diff --git a/tox.ini b/tox.ini index 65e40116a56..7a69000c817 100644 --- a/tox.ini +++ b/tox.ini @@ -121,6 +121,7 @@ setenv= setenv = {[testenv:py27-pluggymaster]setenv} [testenv:docs] +basepython = python3 skipsdist = True usedevelop = True changedir = doc/en