• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

basilisp-lang / basilisp / 5697667142

29 Jul 2023 01:09AM UTC coverage: 99.159%. First build
5697667142

push

github

chrisrink10
Use Tox 4.0 run command

1634 of 1636 branches covered (99.88%)

Branch coverage included in aggregate %.

7686 of 7763 relevant lines covered (99.01%)

0.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.52
/src/basilisp/contrib/pytest/testrunner.py
1
import inspect
1✔
2
import traceback
1✔
3
from types import GeneratorType
1✔
4
from typing import Callable, Iterable, Iterator, Optional, Tuple
1✔
5

6
import py
1✔
7
import pytest
1✔
8
from _pytest.config import Config
1✔
9
from _pytest.main import Session
1✔
10

11
from basilisp import main as basilisp
1✔
12
from basilisp.lang import keyword as kw
1✔
13
from basilisp.lang import map as lmap
1✔
14
from basilisp.lang import runtime as runtime
1✔
15
from basilisp.lang import vector as vec
1✔
16
from basilisp.lang.obj import lrepr
1✔
17
from basilisp.util import Maybe
1✔
18

19
_EACH_FIXTURES_META_KW = kw.keyword("each-fixtures", "basilisp.test")
1✔
20
_ONCE_FIXTURES_NUM_META_KW = kw.keyword("once-fixtures", "basilisp.test")
1✔
21
_TEST_META_KW = kw.keyword("test", "basilisp.test")
1✔
22

23

24
# pylint: disable=unused-argument
25
def pytest_configure(config):
1✔
26
    basilisp.bootstrap("basilisp.test")
1✔
27

28

29
def pytest_collect_file(parent, path):
1✔
30
    """Primary PyTest hook to identify Basilisp test files."""
31
    if path.ext == ".lpy":
1✔
32
        if path.basename.startswith("test_") or path.purebasename.endswith("_test"):
1✔
33
            return BasilispFile.from_parent(parent, fspath=path)
1✔
34
    return None
1✔
35

36

37
class TestFailuresInfo(Exception):
1✔
38
    __slots__ = ("_msg", "_data")
1✔
39

40
    def __init__(self, message: str, data: lmap.PersistentMap) -> None:
1✔
41
        super().__init__()
1✔
42
        self._msg = message
1✔
43
        self._data = data
1✔
44

45
    def __repr__(self):
46
        return (
47
            "basilisp.contrib.pytest..testrunner.TestFailuresInfo"
48
            f"({self._msg}, {lrepr(self._data)})"
49
        )
50

51
    def __str__(self):
1✔
52
        return f"{self._msg} {lrepr(self._data)}"
×
53

54
    @property
1✔
55
    def data(self) -> lmap.PersistentMap:
1✔
56
        return self._data
1✔
57

58
    @property
1✔
59
    def message(self) -> str:
1✔
60
        return self._msg
×
61

62

63
TestFunction = Callable[[], lmap.PersistentMap]
1✔
64
FixtureTeardown = Iterator[None]
1✔
65
FixtureFunction = Callable[[], Optional[FixtureTeardown]]
1✔
66

67

68
class FixtureManager:
1✔
69
    """FixtureManager instances manage `basilisp.test` style fixtures on behalf of a
70
    BasilispFile or BasilispTestItem node."""
71

72
    __slots__ = ("_fixtures", "_teardowns")
1✔
73

74
    def __init__(self, fixtures: Iterable[FixtureFunction]):
1✔
75
        self._fixtures: Iterable[FixtureFunction] = fixtures
1✔
76
        self._teardowns: Iterable[FixtureTeardown] = ()
1✔
77

78
    @staticmethod
1✔
79
    def _run_fixture(fixture: FixtureFunction) -> Optional[Iterator[None]]:
1✔
80
        """Run a fixture function. If the fixture is a generator function, return the
81
        generator/coroutine. Otherwise, simply return the value from the function, if
82
        one."""
83
        if inspect.isgeneratorfunction(fixture):
1✔
84
            coro = fixture()
1✔
85
            assert isinstance(coro, GeneratorType)
1✔
86
            next(coro)
1✔
87
            return coro
1✔
88
        else:
89
            fixture()
1✔
90
            return None
1✔
91

92
    @classmethod
1✔
93
    def _setup_fixtures(
1✔
94
        cls, fixtures: Iterable[FixtureFunction]
95
    ) -> Iterable[FixtureTeardown]:
96
        """Set up fixtures by running them as by `_run_fixture`. Collect any fixtures
97
        teardown steps (e.g. suspended coroutines) and return those so they can be
98
        resumed later for any cleanup."""
99
        teardown_fixtures = []
1✔
100
        try:
1✔
101
            for fixture in fixtures:
1✔
102
                teardown = cls._run_fixture(fixture)
1✔
103
                if teardown is not None:
1✔
104
                    teardown_fixtures.append(teardown)
1✔
105
        except Exception as e:
1✔
106
            raise runtime.RuntimeException(
1✔
107
                "Exception occurred during fixture setup"
108
            ) from e
109
        else:
110
            return teardown_fixtures
1✔
111

112
    @staticmethod
1✔
113
    def _teardown_fixtures(teardowns: Iterable[FixtureTeardown]) -> None:
1✔
114
        """Perform teardown steps returned from a fixture function."""
115
        for teardown in teardowns:
1✔
116
            try:
1✔
117
                next(teardown)
1✔
118
            except StopIteration:
1✔
119
                pass
1✔
120
            except Exception as e:
1✔
121
                raise runtime.RuntimeException(
1✔
122
                    "Exception occurred during fixture teardown"
123
                ) from e
124

125
    def setup(self) -> None:
1✔
126
        """Setup fixtures and store any teardowns for cleanup later.
127

128
        Should have a corresponding call to `FixtureManager.teardown` to clean up
129
        fixtures."""
130
        self._teardowns = self._setup_fixtures(self._fixtures)
1✔
131

132
    def teardown(self) -> None:
1✔
133
        """Teardown fixtures from a previous call to `setup`."""
134
        self._teardown_fixtures(self._teardowns)
1✔
135
        self._teardowns = ()
1✔
136

137

138
class BasilispFile(pytest.File):
1✔
139
    """Files represent a test module in Python or a test namespace in Basilisp."""
140

141
    def __init__(  # pylint: disable=too-many-arguments
1✔
142
        self,
143
        fspath: py.path.local,
144
        parent=None,
145
        config: Optional[Config] = None,
146
        session: Optional["Session"] = None,
147
        nodeid: Optional[str] = None,
148
    ) -> None:
149
        super().__init__(fspath, parent, config, session, nodeid)
1✔
150
        self._fixture_manager: Optional[FixtureManager] = None
1✔
151

152
    @staticmethod
1✔
153
    def _collected_fixtures(
1✔
154
        ns: runtime.Namespace,
155
    ) -> Tuple[Iterable[FixtureFunction], Iterable[FixtureFunction]]:
156
        """Collect all of the declared fixtures of the namespace."""
157
        if ns.meta is not None:
1✔
158
            return (
1✔
159
                ns.meta.val_at(_ONCE_FIXTURES_NUM_META_KW) or (),
160
                ns.meta.val_at(_EACH_FIXTURES_META_KW) or (),
161
            )
162
        return (), ()
1✔
163

164
    @staticmethod
1✔
165
    def _collected_tests(ns: runtime.Namespace) -> Iterable[runtime.Var]:
1✔
166
        """Return the sorted sequence of collected tests from the Namespace `ns`.
167

168
        Tests defined by `deftest` are annotated with `:basilisp.test/test` metadata.
169
        Tests are sorted by their line number, which matches the default behavior of
170
        PyTest."""
171

172
        def _test_num(var: runtime.Var) -> int:
1✔
173
            assert var.meta is not None
1✔
174
            order = var.meta.val_at(_LINE_KW)
1✔
175
            assert isinstance(order, int)
1✔
176
            return order
1✔
177

178
        return sorted(
1✔
179
            (
180
                var
181
                for _, var in ns.interns.items()
182
                if var.meta is not None and var.meta.val_at(_TEST_META_KW)
183
            ),
184
            key=_test_num,
185
        )
186

187
    def setup(self) -> None:
1✔
188
        assert self._fixture_manager is not None
1✔
189
        self._fixture_manager.setup()
1✔
190

191
    def teardown(self) -> None:
1✔
192
        assert self._fixture_manager is not None
1✔
193
        self._fixture_manager.teardown()
1✔
194

195
    def collect(self):
1✔
196
        """Collect all of the tests in the namespace (module) given.
197

198
        Basilisp's test runner imports the namespace which will (as a side effect)
199
        collect all of the test functions in a namespace (represented by `deftest`
200
        forms in Basilisp). BasilispFile.collect fetches those test functions and
201
        generates BasilispTestItems for PyTest to run the tests."""
202
        filename = self.fspath.basename
1✔
203
        module = self.fspath.pyimport()
1✔
204
        assert isinstance(module, runtime.BasilispModule)
1✔
205
        ns = module.__basilisp_namespace__
1✔
206
        once_fixtures, each_fixtures = self._collected_fixtures(ns)
1✔
207
        self._fixture_manager = FixtureManager(once_fixtures)
1✔
208
        for test in self._collected_tests(ns):
1✔
209
            f: TestFunction = test.value
1✔
210
            yield BasilispTestItem.from_parent(
1✔
211
                self,
212
                name=test.name.name,
213
                run_test=f,
214
                namespace=ns,
215
                filename=filename,
216
                fixture_manager=FixtureManager(each_fixtures),
217
            )
218

219

220
_ACTUAL_KW = kw.keyword("actual")
1✔
221
_ERROR_KW = kw.keyword("error")
1✔
222
_EXPECTED_KW = kw.keyword("expected")
1✔
223
_FAILURE_KW = kw.keyword("failure")
1✔
224
_FAILURES_KW = kw.keyword("failures")
1✔
225
_MESSAGE_KW = kw.keyword("message")
1✔
226
_LINE_KW = kw.keyword("line")
1✔
227
_EXPR_KW = kw.keyword("expr")
1✔
228
_TEST_SECTION_KW = kw.keyword("test-section")
1✔
229
_TYPE_KW = kw.keyword("type")
1✔
230

231

232
class BasilispTestItem(pytest.Item):
1✔
233
    """Test items correspond to a single `deftest` form in a Basilisp test.
234

235
    `deftest` forms run each `is` assertion and collect all failures in an atom,
236
    reporting their results as a vector of failures when each test concludes.
237

238
    The BasilispTestItem collects all the failures and returns a report to PyTest to
239
    show to the end-user."""
240

241
    def __init__(  # pylint: disable=too-many-arguments
1✔
242
        self,
243
        name: str,
244
        parent: BasilispFile,
245
        run_test: TestFunction,
246
        namespace: runtime.Namespace,
247
        filename: str,
248
        fixture_manager: FixtureManager,
249
    ) -> None:
250
        super().__init__(name, parent)
1✔
251
        self._run_test = run_test
1✔
252
        self._namespace = namespace
1✔
253
        self._filename = filename
1✔
254
        self._fixture_manager = fixture_manager
1✔
255

256
    @classmethod
1✔
257
    def from_parent(  # pylint: disable=arguments-differ,too-many-arguments
1✔
258
        cls,
259
        parent: "BasilispFile",
260
        name: str,
261
        run_test: TestFunction,
262
        namespace: runtime.Namespace,
263
        filename: str,
264
        fixture_manager: FixtureManager,
265
    ):
266
        """Create a new BasilispTestItem from the parent Node."""
267
        # https://github.com/pytest-dev/pytest/pull/6680
268
        return super().from_parent(
1✔
269
            parent,
270
            name=name,
271
            run_test=run_test,
272
            namespace=namespace,
273
            filename=filename,
274
            fixture_manager=fixture_manager,
275
        )
276

277
    def setup(self) -> None:
1✔
278
        self._fixture_manager.setup()
1✔
279

280
    def teardown(self) -> None:
1✔
281
        self._fixture_manager.teardown()
1✔
282

283
    def runtest(self):
1✔
284
        """Run the tests associated with this test item.
285

286
        If any tests fail, raise an ExceptionInfo exception with the test failures.
287
        PyTest will invoke self.repr_failure to display the failures to the user."""
288
        results: lmap.PersistentMap = self._run_test()
1✔
289
        failures: Optional[vec.PersistentVector] = results.val_at(_FAILURES_KW)
1✔
290
        if runtime.to_seq(failures):
1✔
291
            raise TestFailuresInfo("Test failures", lmap.map(results))
1✔
292

293
    def repr_failure(self, excinfo, style=None):
1✔
294
        """Representation function called when self.runtest() raises an exception."""
295
        if isinstance(excinfo.value, TestFailuresInfo):
1✔
296
            exc = excinfo.value
1✔
297
            failures = exc.data.val_at(_FAILURES_KW)
1✔
298
            messages = []
1✔
299

300
            for details in failures:
1✔
301
                type_ = details.val_at(_TYPE_KW)
1✔
302
                if type_ == _FAILURE_KW:
1✔
303
                    messages.append(self._failure_msg(details))
1✔
304
                elif type_ == _ERROR_KW:
1✔
305
                    exc = details.val_at(_ACTUAL_KW)
1✔
306
                    line = details.val_at(_LINE_KW)
1✔
307
                    messages.append(self._error_msg(exc, line=line))
1✔
308
                else:  # pragma: no cover
309
                    assert False, "Test failure type must be in #{:error :failure}"
310

311
            return "\n\n".join(messages)
1✔
312
        elif isinstance(excinfo.value, Exception):
1✔
313
            return self._error_msg(excinfo.value)
1✔
314
        else:
315
            return None
×
316

317
    def reportinfo(self):
1✔
318
        return self.fspath, 0, self.name
1✔
319

320
    def _error_msg(self, exc: Exception, line: Optional[int] = None) -> str:
1✔
321
        line_msg = Maybe(line).map(lambda l: f":{l}").or_else_get("")
1✔
322
        messages = [f"ERROR in ({self.name}) ({self._filename}{line_msg})", "\n\n"]
1✔
323
        messages.extend(traceback.format_exception(Exception, exc, exc.__traceback__))
1✔
324
        return "".join(messages)
1✔
325

326
    def _failure_msg(self, details: lmap.PersistentMap) -> str:
1✔
327
        assert details.val_at(_TYPE_KW) == _FAILURE_KW
1✔
328
        msg: str = details.val_at(_MESSAGE_KW)
1✔
329

330
        actual = details.val_at(_ACTUAL_KW)
1✔
331
        expected = details.val_at(_EXPECTED_KW)
1✔
332

333
        test_section = details.val_at(_TEST_SECTION_KW)
1✔
334
        line = details.val_at(_LINE_KW)
1✔
335
        line_msg = Maybe(line).map(lambda l: f":{l}").or_else_get("")
1✔
336
        section_msg = Maybe(test_section).map(lambda s: f" {s} :: ").or_else_get("")
1✔
337

338
        return "\n".join(
1✔
339
            [
340
                f"FAIL in ({self.name}) ({self._filename}{line_msg})",
341
                f"    {section_msg}{msg}",
342
                "",
343
                f"    expected: {lrepr(expected)}",
344
                f"      actual: {lrepr(actual)}",
345
            ]
346
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc