File: classes.py

package info (click to toggle)
pytest-relaxed 2.0.2-5
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 212 kB
  • sloc: python: 960; makefile: 2
file content (155 lines) | stat: -rw-r--r-- 6,298 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
import inspect
import logging
import types

from pytest import Class, Module

# NOTE: don't see any other way to get access to pytest innards besides using
# the underscored name :(
from _pytest.fixtures import getfixturemarker
from _pytest.python import PyCollector


log = logging.getLogger("relaxed")


# NOTE: these are defined here for reuse by both pytest's own machinery and our
# internal bits.
def istestclass(name):
    return not name.startswith("_")


# NOTE: this is defined at top level due to a couple spots of reuse outside of
# the mixin class itself.
def istestfunction(obj, name):
    is_hidden_name = name.startswith("_") or name in (
        "setup",
        "setup_method",
        "teardown",
        "teardown_method",
    )
    is_fixture = getfixturemarker(obj) is not None
    return not (is_hidden_name or is_fixture)


# All other classes in here currently inherit from PyCollector, and it is what
# defines the default istestfunction/istestclass, so makes sense to inherit
# from it for our mixin. (PyobjMixin, another commonly found class, offers
# nothing of interest to us however.)
class RelaxedMixin(PyCollector):
    """
    A mixin applying collection rules to both modules and inner/nested classes.
    """

    # TODO:
    # - worth calling super() in these? Difficult to know what to do with it;
    # it would say "no" to lots of stuff we want to say "yes" to.
    # - are there other tests to apply to 'obj' in a vacuum? so far only thing
    # we test 'obj' for is its membership in a module, which must happen inside
    # SpecModule's override.

    def istestclass(self, obj, name):
        return istestclass(name)

    def istestfunction(self, obj, name):
        return istestfunction(obj, name)


class SpecModule(RelaxedMixin, Module):
    def _is_test_obj(self, test_func, obj, name):
        # First run our super() test, which should be RelaxedMixin's.
        good_name = getattr(super(), test_func)(obj, name)
        # If RelaxedMixin said no, we can't really say yes, as the name itself
        # was bad - private, other non test name like setup(), etc
        if not good_name:
            return False
        # Here, we dig further based on our own wrapped module obj, by
        # rejecting anything not defined locally.
        if inspect.getmodule(obj) is not self.obj:
            return False
        # No other complaints -> it's probably good
        return True

    def istestfunction(self, obj, name):
        return self._is_test_obj("istestfunction", obj, name)

    def istestclass(self, obj, name):
        return self._is_test_obj("istestclass", obj, name)

    def collect(self):
        # Given we've overridden naming constraints etc above, just use
        # superclass' collection logic for the rest of the necessary behavior.
        items = super().collect()
        collected = []
        for item in items:
            # Replace Class objects with recursive SpecClasses
            # NOTE: we could explicitly skip unittest objects here (we'd want
            # them to be handled by pytest's own unittest support) but since
            # those are almost always in test_prefixed_filenames anyways...meh
            if isinstance(item, Class):
                item = SpecClass.from_parent(item.parent, name=item.name)
            collected.append(item)
        return collected


class SpecClass(RelaxedMixin, Class):
    def _getobj(self):
        # Regular object-making first
        obj = super()._getobj()
        # Short circuit if this obj isn't a nested class (aka child):
        # - no parent attr: implies module-level obj definition
        # - parent attr, but isn't a class: implies method
        if not hasattr(self, "parent") or not isinstance(
            self.parent, SpecClass
        ):
            return obj
        # Then decorate it with our parent's extra attributes, allowing nested
        # test classes to appear as an aggregate of parents' "scopes".
        parent_obj = self.parent.obj
        # Obtain parent attributes, etc not found on our obj (serves as both a
        # useful identifier of "stuff added to an outer class" and a way of
        # ensuring that we can override such attrs), and set them on obj
        delta = set(dir(parent_obj)).difference(set(dir(obj)))
        for name in delta:
            value = getattr(parent_obj, name)
            # Pytest's pytestmark attributes always get skipped, we don't want
            # to spread that around where it's not wanted. (Besides, it can
            # cause a lot of collection level warnings.)
            if name == "pytestmark":
                continue
            # Classes get skipped; they'd always just be other 'inner' classes
            # that we don't want to copy elsewhere.
            if isinstance(value, type):
                continue
            # Functions (methods) may get skipped, or not, depending:
            # NOTE: as of pytest 7, for some reason the value appears as a
            # function and not a method (???) so covering both bases...
            if isinstance(value, (types.MethodType, types.FunctionType)):
                # If they look like tests, they get skipped; don't want to copy
                # tests around!
                if istestfunction(obj, name):
                    continue
                # Non-test == they're probably lifecycle methods
                # (setup/teardown) or helpers (_do_thing). Rebind them to the
                # target instance, otherwise the 'self' in the setup/helper is
                # not the same 'self' as that in the actual test method it runs
                # around or within!
                setattr(obj, name, value)
            # Anything else should be some data-type attribute, which is copied
            # verbatim / by-value.
            else:
                setattr(obj, name, value)
        return obj

    def collect(self):
        ret = []
        for item in super().collect():
            # More pytestmark skipping.
            if item.name == "pytestmark":
                continue
            if isinstance(item, Class):
                item = SpecClass.from_parent(
                    parent=item.parent, name=item.name, obj=item.obj
                )
            ret.append(item)
        return ret