python-interactive-courses 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
pyics/tests/compare.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comparing the result of a function with the expected one, for ``SimpleTest``.
|
|
3
|
+
|
|
4
|
+
Plain values are compared with ``==``. numpy arrays and pandas objects are compared as a
|
|
5
|
+
whole instead, since their ``==`` compares element by element. numpy and pandas aren't
|
|
6
|
+
dependencies of pyics: their objects are only looked for when they are imported, as
|
|
7
|
+
they are when a result or an expected value is one of them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from _pytest._io.saferepr import saferepr
|
|
14
|
+
from _pytest.assertion.util import assertrepr_compare, dummy_highlighter
|
|
15
|
+
|
|
16
|
+
# length of each side of a summary, as in pytest's: (80 - 15 - len("==") - 2) // 2
|
|
17
|
+
_SUMMARY_SIZE = 30
|
|
18
|
+
# positions listed when arrays differ
|
|
19
|
+
_MAX_POSITIONS = 10
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IncomparableError(AssertionError):
|
|
23
|
+
"""A result can't be compared with the expected value, e.g. since its ``==`` gives
|
|
24
|
+
neither ``True`` nor ``False``."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_array(value: Any) -> bool:
|
|
28
|
+
np = sys.modules.get("numpy")
|
|
29
|
+
return np is not None and isinstance(value, np.ndarray)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pandas_types() -> tuple[type, ...]:
|
|
33
|
+
pd = sys.modules.get("pandas")
|
|
34
|
+
return () if pd is None else (pd.DataFrame, pd.Series, pd.Index)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def summary(left: Any, right: Any, *, op: str = "==") -> str:
|
|
38
|
+
"""The comparison of ``left`` and ``right`` by ``op`` in a line, with both
|
|
39
|
+
shortened, e.g. ``'3' in '9'``."""
|
|
40
|
+
left = saferepr(left, maxsize=_SUMMARY_SIZE)
|
|
41
|
+
return f"{left} {op} {saferepr(right, maxsize=_SUMMARY_SIZE)}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _type_name(value: Any) -> str:
|
|
45
|
+
cls = type(value)
|
|
46
|
+
if cls.__module__ == "builtins":
|
|
47
|
+
return cls.__qualname__
|
|
48
|
+
return f"{cls.__module__}.{cls.__qualname__}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def equal(res: Any, expected: Any) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Whether ``res`` matches ``expected``. numpy arrays match if their shapes, the kinds of
|
|
54
|
+
their values (e.g. integers, see ``numpy.dtype.kind``) and their values do, with NaNs
|
|
55
|
+
matching NaNs. pandas objects match by their ``equals``. Lists, tuples and dicts are
|
|
56
|
+
compared item by item, so that they can hold arrays, too. Anything else matches if
|
|
57
|
+
``res == expected``.
|
|
58
|
+
|
|
59
|
+
Raises ``IncomparableError`` if ``==`` gives neither ``True`` nor ``False``.
|
|
60
|
+
"""
|
|
61
|
+
if _is_array(res) or _is_array(expected):
|
|
62
|
+
return not _array_differences(res, expected)
|
|
63
|
+
pandas_types = _pandas_types()
|
|
64
|
+
if isinstance(res, pandas_types) or isinstance(expected, pandas_types):
|
|
65
|
+
return isinstance(expected, pandas_types) and bool(expected.equals(res))
|
|
66
|
+
if type(res) is type(expected) and type(res) in (list, tuple):
|
|
67
|
+
return len(res) == len(expected) and all(
|
|
68
|
+
r is e or equal(r, e) for r, e in zip(res, expected)
|
|
69
|
+
)
|
|
70
|
+
if type(res) is type(expected) is dict:
|
|
71
|
+
return res.keys() == expected.keys() and all(
|
|
72
|
+
res[k] is expected[k] or equal(res[k], expected[k]) for k in res
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
eq = res == expected
|
|
76
|
+
try:
|
|
77
|
+
return bool(eq)
|
|
78
|
+
except Exception as error: # e.g. element by element, by an unknown array type
|
|
79
|
+
raise IncomparableError(
|
|
80
|
+
f"{summary(res, expected)}\n\nCan't tell whether they are equal: == gave "
|
|
81
|
+
f"{saferepr(eq, maxsize=_SUMMARY_SIZE)}, not True or False"
|
|
82
|
+
) from error
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _array_differences(res: Any, expected: Any) -> list[str]:
|
|
86
|
+
"""How ``res`` differs from ``expected``, one of which is a numpy array, as by
|
|
87
|
+
``equal``. Empty if they match."""
|
|
88
|
+
np = sys.modules["numpy"]
|
|
89
|
+
if not (isinstance(res, np.ndarray) and isinstance(expected, np.ndarray)):
|
|
90
|
+
return [f"Types differ: {_type_name(res)} != {_type_name(expected)}"]
|
|
91
|
+
if res.shape != expected.shape:
|
|
92
|
+
return [f"Shapes differ: {res.shape} != {expected.shape}"]
|
|
93
|
+
if res.dtype.kind != expected.dtype.kind:
|
|
94
|
+
return [f"Types of values differ: {res.dtype} != {expected.dtype}"]
|
|
95
|
+
|
|
96
|
+
if res.dtype.kind == "O": # any objects, e.g. arrays themselves
|
|
97
|
+
same = np.frompyfunc(equal, 2, 1)(res, expected)
|
|
98
|
+
else:
|
|
99
|
+
same = res == expected
|
|
100
|
+
if res.dtype.kind in "fc":
|
|
101
|
+
same |= np.isnan(res) & np.isnan(expected)
|
|
102
|
+
# all positions differ if the values weren't compared one by one
|
|
103
|
+
same = np.broadcast_to(np.asarray(same, dtype=bool), res.shape)
|
|
104
|
+
if same.all():
|
|
105
|
+
return []
|
|
106
|
+
if res.ndim == 0:
|
|
107
|
+
return [f"Values differ: {res.item()!r} != {expected.item()!r}"]
|
|
108
|
+
|
|
109
|
+
count = int(np.count_nonzero(~same))
|
|
110
|
+
lines = [f"Values differ at {count} of {same.size} positions:"]
|
|
111
|
+
for position in np.argwhere(~same)[:_MAX_POSITIONS]:
|
|
112
|
+
p = tuple(int(i) for i in position)
|
|
113
|
+
index = p[0] if len(p) == 1 else p
|
|
114
|
+
lines.append(f"At index {index} diff: {res.item(p)!r} != {expected.item(p)!r}")
|
|
115
|
+
if count > _MAX_POSITIONS:
|
|
116
|
+
lines.append(f"... and {count - _MAX_POSITIONS} more")
|
|
117
|
+
return lines
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _pandas_differences(res: Any, expected: Any) -> list[str]:
|
|
121
|
+
"""How ``res`` differs from ``expected``, one of which is a pandas object, as told by
|
|
122
|
+
``pandas.testing``."""
|
|
123
|
+
pd = sys.modules["pandas"]
|
|
124
|
+
checks = {
|
|
125
|
+
pd.DataFrame: pd.testing.assert_frame_equal,
|
|
126
|
+
pd.Series: pd.testing.assert_series_equal,
|
|
127
|
+
pd.Index: pd.testing.assert_index_equal,
|
|
128
|
+
}
|
|
129
|
+
for kind, check in checks.items():
|
|
130
|
+
if isinstance(res, kind) and isinstance(expected, kind):
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
return [f"Types differ: {_type_name(res)} != {_type_name(expected)}"]
|
|
134
|
+
try:
|
|
135
|
+
check(res, expected, check_exact=True)
|
|
136
|
+
except AssertionError as error:
|
|
137
|
+
return str(error).strip().splitlines()
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _Whole:
|
|
142
|
+
"""An array or pandas object in a list, tuple or dict, which pytest then compares
|
|
143
|
+
as a whole, by ``equal``, rather than element by element."""
|
|
144
|
+
|
|
145
|
+
__hash__ = None # type: ignore[assignment]
|
|
146
|
+
|
|
147
|
+
def __init__(self, value: Any):
|
|
148
|
+
self.value = value
|
|
149
|
+
|
|
150
|
+
def __eq__(self, other: object) -> bool:
|
|
151
|
+
return isinstance(other, _Whole) and equal(self.value, other.value)
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
return repr(self.value)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _wrap(value: Any) -> Any:
|
|
158
|
+
if _is_array(value) or isinstance(value, _pandas_types()):
|
|
159
|
+
return _Whole(value)
|
|
160
|
+
if type(value) in (list, tuple):
|
|
161
|
+
return type(value)(_wrap(v) for v in value)
|
|
162
|
+
if type(value) is dict:
|
|
163
|
+
return {k: _wrap(v) for k, v in value.items()}
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def explain(res: Any, expected: Any) -> list[str]:
|
|
168
|
+
"""Lines explaining how ``res`` differs from ``expected``: a summary, followed by
|
|
169
|
+
the details, e.g. the positions at which arrays differ."""
|
|
170
|
+
if _is_array(res) or _is_array(expected):
|
|
171
|
+
details = _array_differences(res, expected)
|
|
172
|
+
elif isinstance(res, _pandas_types()) or isinstance(expected, _pandas_types()):
|
|
173
|
+
details = _pandas_differences(res, expected)
|
|
174
|
+
else:
|
|
175
|
+
lines = assertrepr_compare(
|
|
176
|
+
"==",
|
|
177
|
+
_wrap(res),
|
|
178
|
+
_wrap(expected),
|
|
179
|
+
verbose=1,
|
|
180
|
+
highlighter=dummy_highlighter,
|
|
181
|
+
assertion_text_diff_style="ndiff",
|
|
182
|
+
)
|
|
183
|
+
return list(lines) or [f"{res!r} == {expected!r}"]
|
|
184
|
+
return [summary(res, expected), *(["", *details] if details else [])]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from os import PathLike
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from pytest import TestReport
|
|
10
|
+
from _pytest._code.code import TerminalRepr
|
|
11
|
+
from _pytest._io import TerminalWriter
|
|
12
|
+
from _pytest.nodes import Node
|
|
13
|
+
from rich.errors import MarkupError
|
|
14
|
+
from rich.style import Style
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from pyics.notebook import loader
|
|
18
|
+
from pyics.tests import Test
|
|
19
|
+
from pyics.tests.results import TestResult
|
|
20
|
+
from pyics.tests.tests import TASK_SECTIONS, MarkupAssertionError
|
|
21
|
+
from pyics.tests.trace import Trace, ansi_text
|
|
22
|
+
from pyics.util import paths
|
|
23
|
+
from pyics.util.paths import StrPath
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass(eq=False)
|
|
27
|
+
class RichFailureRepr(TerminalRepr):
|
|
28
|
+
"""
|
|
29
|
+
Failure representation of a ``PyicsItem``, keeping the highlighting of its message
|
|
30
|
+
(and its ``trace``, if the test was traced) for the main panel. Elsewhere, e.g. in
|
|
31
|
+
``TestReport.longreprtext``, it is plain text.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
text: Text
|
|
35
|
+
# the trace as text with ANSI escape codes (see ``Trace.ansi``), which a
|
|
36
|
+
# ``TestResult`` keeps as it is
|
|
37
|
+
trace_ansi: str | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def trace(self) -> Text | None:
|
|
41
|
+
"""The trace of the failed test, or why there is none, if it was traced."""
|
|
42
|
+
return None if self.trace_ansi is None else ansi_text(self.trace_ansi)
|
|
43
|
+
|
|
44
|
+
def toterminal(self, tw: TerminalWriter) -> None:
|
|
45
|
+
tw.line(self.text.plain)
|
|
46
|
+
if (trace := self.trace) is not None:
|
|
47
|
+
tw.line(trace.plain)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PyicsItem(pytest.Item):
|
|
51
|
+
"""
|
|
52
|
+
pytest.Item intended to wrap the tests below for access with pytest
|
|
53
|
+
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
name: str,
|
|
60
|
+
parent: Node,
|
|
61
|
+
test_obj: Test,
|
|
62
|
+
tracing: bool = False,
|
|
63
|
+
sections: Mapping[StrPath, str] | None = None,
|
|
64
|
+
):
|
|
65
|
+
self.test_obj = test_obj
|
|
66
|
+
# whether the test is traced, if it can be (see ``Test.run``), to show the trace
|
|
67
|
+
# if it fails
|
|
68
|
+
self.tracing = tracing
|
|
69
|
+
# the sections of its task, which it inherits while it runs (see
|
|
70
|
+
# ``TASK_SECTIONS``)
|
|
71
|
+
self.sections: Mapping[Path, str] = MappingProxyType(
|
|
72
|
+
{paths.absolute(path): name for path, name in (sections or {}).items()}
|
|
73
|
+
)
|
|
74
|
+
self.recorded_trace: Trace | None = None
|
|
75
|
+
# the errors in the cells of a notebook the test ran, named if it fails
|
|
76
|
+
self.cell_errors: list[loader.CellError] = []
|
|
77
|
+
super().__init__(name=self.test_obj.name, parent=parent)
|
|
78
|
+
|
|
79
|
+
def runtest(self) -> None:
|
|
80
|
+
loader.clear_last_errors()
|
|
81
|
+
task_sections = TASK_SECTIONS.set(self.sections)
|
|
82
|
+
try:
|
|
83
|
+
if not (self.tracing and self.test_obj.supports_trace):
|
|
84
|
+
self.test_obj.run()
|
|
85
|
+
return
|
|
86
|
+
# tests whose authors don't allow it ignore the trace
|
|
87
|
+
self.recorded_trace = Trace()
|
|
88
|
+
self.test_obj.run(trace=self.recorded_trace)
|
|
89
|
+
finally:
|
|
90
|
+
self.cell_errors = loader.last_errors()
|
|
91
|
+
TASK_SECTIONS.reset(task_sections)
|
|
92
|
+
|
|
93
|
+
def repr_failure(
|
|
94
|
+
self,
|
|
95
|
+
excinfo: pytest.ExceptionInfo[BaseException],
|
|
96
|
+
style: None
|
|
97
|
+
| Literal["long"]
|
|
98
|
+
| Literal["short"]
|
|
99
|
+
| Literal["line"]
|
|
100
|
+
| Literal["no"]
|
|
101
|
+
| Literal["native"]
|
|
102
|
+
| Literal["value"]
|
|
103
|
+
| Literal["auto"] = None,
|
|
104
|
+
) -> str | TerminalRepr:
|
|
105
|
+
message = Text(str(excinfo.value))
|
|
106
|
+
if isinstance(excinfo.value, MarkupAssertionError):
|
|
107
|
+
try:
|
|
108
|
+
message = Text.from_markup(str(excinfo.value), emoji=False)
|
|
109
|
+
except MarkupError:
|
|
110
|
+
pass # shown as it is, rather than failing the whole run
|
|
111
|
+
for error in self.cell_errors: # which may be why the test failed
|
|
112
|
+
message = Text.assemble(message, "\n", error.describe())
|
|
113
|
+
return RichFailureRepr(
|
|
114
|
+
Text.assemble(f"{self.name} failed: ", message), self._trace_ansi()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _trace_ansi(self) -> str | None:
|
|
118
|
+
"""The trace of the failed test, or why there is none (dimmed), if it is
|
|
119
|
+
traced, as text with ANSI escape codes (see ``Trace.ansi``)."""
|
|
120
|
+
if not self.tracing:
|
|
121
|
+
return None
|
|
122
|
+
dim = Style(dim=True).render
|
|
123
|
+
if not self.test_obj.supports_trace:
|
|
124
|
+
return dim(f"{type(self.test_obj).__name__}s can't be traced.")
|
|
125
|
+
if not getattr(self.test_obj, "traceable", True):
|
|
126
|
+
return dim("Tracing is turned off for this test.")
|
|
127
|
+
assert self.recorded_trace is not None
|
|
128
|
+
if not (trace := self.recorded_trace.ansi):
|
|
129
|
+
return dim(
|
|
130
|
+
"Nothing was traced: the test failed before running your code."
|
|
131
|
+
)
|
|
132
|
+
return trace
|
|
133
|
+
|
|
134
|
+
def reportinfo(self) -> tuple[PathLike | str, int | None, str]:
|
|
135
|
+
return self.path, 0, f"pyics test: {self.name}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class LiveTestCollectionPlugin:
|
|
139
|
+
"""
|
|
140
|
+
Injects pre-built Test objects as PyicsItems, bypassing pytest's file-based collection.
|
|
141
|
+
|
|
142
|
+
This plugin is used for the main pyics script.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
test_objs: list[Test],
|
|
148
|
+
*,
|
|
149
|
+
tracing: bool = False,
|
|
150
|
+
sections: Mapping[StrPath, str] | None = None,
|
|
151
|
+
):
|
|
152
|
+
"""
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
test_objs: list[Test]
|
|
156
|
+
The tests to run.
|
|
157
|
+
tracing: bool (optional, keyword only)
|
|
158
|
+
Trace the tests that can be traced, to show their traces if they fail (see
|
|
159
|
+
``PyicsItem``). Default is False.
|
|
160
|
+
sections: Mapping[StrPath, str] | None (optional, keyword only)
|
|
161
|
+
The section of each exercise notebook that the tests' task is about (see
|
|
162
|
+
``Task.sections``), which tests that weren't given a section of their own
|
|
163
|
+
inherit (see ``Test.resolved_section``). Default is None, i.e. none.
|
|
164
|
+
"""
|
|
165
|
+
self.test_objs = test_objs
|
|
166
|
+
self.tracing = tracing
|
|
167
|
+
self.sections = sections
|
|
168
|
+
self.reports = []
|
|
169
|
+
# the reports as results that can cross a process boundary, with the position
|
|
170
|
+
# of their test in ``test_objs``
|
|
171
|
+
self.results: list[TestResult] = []
|
|
172
|
+
self._indices: dict[int, int] = {}
|
|
173
|
+
self._index: int | None = None
|
|
174
|
+
|
|
175
|
+
@pytest.hookimpl(tryfirst=True)
|
|
176
|
+
def pytest_collection(self, session: pytest.Session):
|
|
177
|
+
session.items = [
|
|
178
|
+
PyicsItem.from_parent(
|
|
179
|
+
session,
|
|
180
|
+
name="",
|
|
181
|
+
test_obj=obj,
|
|
182
|
+
tracing=self.tracing,
|
|
183
|
+
sections=self.sections,
|
|
184
|
+
)
|
|
185
|
+
for obj in self.test_objs
|
|
186
|
+
]
|
|
187
|
+
session.testscollected = len(session.items)
|
|
188
|
+
# by identity, since tests can share their name, and with it their nodeid
|
|
189
|
+
self._indices = {id(item): i for i, item in enumerate(session.items)}
|
|
190
|
+
return (
|
|
191
|
+
True # @Claude: "short-circuits the default collection, no files are touched"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
@pytest.hookimpl(tryfirst=True)
|
|
195
|
+
def pytest_runtest_protocol(self, item: pytest.Item, nextitem: pytest.Item | None):
|
|
196
|
+
# the reports logged until the next test starts are this test's
|
|
197
|
+
self._index = self._indices.get(id(item))
|
|
198
|
+
|
|
199
|
+
def pytest_runtest_logreport(self, report):
|
|
200
|
+
self.reports.append(report)
|
|
201
|
+
self.results.append(TestResult.from_report(report, self._index))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class NotebookCollectionPlugin:
|
|
205
|
+
"""
|
|
206
|
+
Injects pre-built Test objects as PyicsItems, bypassing pytest's file-based collection.
|
|
207
|
+
|
|
208
|
+
This plugin is meant to be used in Jupyter Notebooks, e.g. the create_task notebook.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def __init__(self, test_objs, *, tracing: bool = False):
|
|
212
|
+
self.test_objs = test_objs
|
|
213
|
+
self.tracing = tracing
|
|
214
|
+
self.reports = []
|
|
215
|
+
|
|
216
|
+
@pytest.hookimpl(tryfirst=True)
|
|
217
|
+
def pytest_collection(self, session):
|
|
218
|
+
session.items = [
|
|
219
|
+
PyicsItem.from_parent(
|
|
220
|
+
session, name=f"test_{i}", test_obj=obj, tracing=self.tracing
|
|
221
|
+
)
|
|
222
|
+
for i, obj in enumerate(self.test_objs)
|
|
223
|
+
]
|
|
224
|
+
session.testscollected = len(session.items)
|
|
225
|
+
return True # short-circuits the default collection, no files are touched
|
|
226
|
+
|
|
227
|
+
def pytest_runtest_logreport(self, report):
|
|
228
|
+
self.reports.append(report)
|
pyics/tests/results.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test results as plain data, which can cross a process boundary: written as JSON by the
|
|
3
|
+
process that ran the tests (e.g. a notebook's kernel, see ``pyics.plugins``), and shown
|
|
4
|
+
by another (the TUI) as if it had run them itself.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import dataclasses
|
|
8
|
+
import math
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any, Self
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from rich.errors import MarkupError
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from pyics.tests.trace import ansi_text
|
|
18
|
+
|
|
19
|
+
def _field(data: Mapping[str, Any], name: str, *types: type) -> Any:
|
|
20
|
+
"""``data[name]``, which must be of one of ``types`` (a missing key counts as None).
|
|
21
|
+
JSON's ``true`` and ``false`` don't count as numbers."""
|
|
22
|
+
value = data.get(name)
|
|
23
|
+
if not isinstance(value, types) or (isinstance(value, bool) and bool not in types):
|
|
24
|
+
raise ValueError(f"{name!r} must be {' or '.join(t.__name__ for t in types)}")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True)
|
|
29
|
+
class TestResult:
|
|
30
|
+
"""
|
|
31
|
+
The result of one phase (setup, call or teardown) of one test, as the main panel
|
|
32
|
+
shows it (see ``Task.update_status``), in a form that can be stored as JSON (see
|
|
33
|
+
``to_json`` and ``from_json``).
|
|
34
|
+
|
|
35
|
+
Attributes
|
|
36
|
+
----------
|
|
37
|
+
nodeid: str
|
|
38
|
+
pytest's id of the test, e.g. "::adds".
|
|
39
|
+
when: str
|
|
40
|
+
The phase: "setup", "call" or "teardown".
|
|
41
|
+
outcome: str
|
|
42
|
+
"passed", "failed" or "skipped".
|
|
43
|
+
duration: float
|
|
44
|
+
How long the phase took, in seconds.
|
|
45
|
+
message: str | None
|
|
46
|
+
Why the test failed or was skipped, as rich console markup, e.g. "adds failed:
|
|
47
|
+
3 == 4" (see ``message_text``). None if it passed.
|
|
48
|
+
trace: str | None
|
|
49
|
+
If the test failed while it was traced: its trace, or a line saying why there
|
|
50
|
+
is none, as text with ANSI escape codes (see ``Trace.ansi`` and
|
|
51
|
+
``trace_text``). None if it wasn't traced.
|
|
52
|
+
index: int | None
|
|
53
|
+
The position of the test in the tests that were run, e.g. to match the results
|
|
54
|
+
of different runs of a task's tests. None if unknown.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__test__ = False # not a test class, despite its name
|
|
58
|
+
|
|
59
|
+
nodeid: str
|
|
60
|
+
when: str
|
|
61
|
+
outcome: str
|
|
62
|
+
duration: float
|
|
63
|
+
message: str | None = None
|
|
64
|
+
trace: str | None = None
|
|
65
|
+
index: int | None = None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def message_text(self) -> Text:
|
|
69
|
+
"""``message`` as highlighted text, empty if there is none. Malformed markup is
|
|
70
|
+
shown as it is."""
|
|
71
|
+
if self.message is None:
|
|
72
|
+
return Text()
|
|
73
|
+
try:
|
|
74
|
+
return Text.from_markup(self.message, emoji=False)
|
|
75
|
+
except MarkupError:
|
|
76
|
+
return Text(self.message)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def trace_text(self) -> Text | None:
|
|
80
|
+
"""``trace`` as highlighted text, or None if there is none."""
|
|
81
|
+
return None if self.trace is None else ansi_text(self.trace)
|
|
82
|
+
|
|
83
|
+
def to_json(self) -> dict[str, Any]:
|
|
84
|
+
"""The result as a dict of JSON types, the inverse of ``from_json``."""
|
|
85
|
+
return dataclasses.asdict(self)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_json(cls, data: Any) -> Self:
|
|
89
|
+
"""
|
|
90
|
+
The result stored as ``data`` by ``to_json``. Keys it doesn't know are ignored.
|
|
91
|
+
|
|
92
|
+
Raises
|
|
93
|
+
------
|
|
94
|
+
ValueError
|
|
95
|
+
If ``data`` isn't a stored result.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(data, Mapping):
|
|
98
|
+
raise ValueError("a test result must be an object")
|
|
99
|
+
duration = float(_field(data, "duration", int, float))
|
|
100
|
+
if not math.isfinite(duration):
|
|
101
|
+
raise ValueError("'duration' must be finite")
|
|
102
|
+
return cls(
|
|
103
|
+
nodeid=_field(data, "nodeid", str),
|
|
104
|
+
when=_field(data, "when", str),
|
|
105
|
+
outcome=_field(data, "outcome", str),
|
|
106
|
+
duration=duration,
|
|
107
|
+
message=_field(data, "message", str, type(None)),
|
|
108
|
+
trace=_field(data, "trace", str, type(None)),
|
|
109
|
+
index=_field(data, "index", int, type(None)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_report(cls, report: pytest.TestReport, index: int | None = None) -> Self:
|
|
114
|
+
"""
|
|
115
|
+
The result of the phase of a test ``report`` reports on, keeping the
|
|
116
|
+
highlighting of the message and the trace of a ``PyicsItem``.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
report: pytest.TestReport
|
|
121
|
+
The report.
|
|
122
|
+
index: int | None
|
|
123
|
+
The position of the test in the tests that were run (see ``index``).
|
|
124
|
+
"""
|
|
125
|
+
# imported here, because pytest_integration builds results with this class
|
|
126
|
+
from pyics.tests.pytest_integration import RichFailureRepr
|
|
127
|
+
|
|
128
|
+
message = trace = None
|
|
129
|
+
if isinstance(report.longrepr, RichFailureRepr):
|
|
130
|
+
message = report.longrepr.text.markup
|
|
131
|
+
trace = report.longrepr.trace_ansi
|
|
132
|
+
elif report.skipped and isinstance(report.longrepr, tuple):
|
|
133
|
+
# (file, line, reason) of a skipped test
|
|
134
|
+
message = escape(str(report.longrepr[-1]))
|
|
135
|
+
elif not report.passed:
|
|
136
|
+
message = escape(report.longreprtext)
|
|
137
|
+
return cls(
|
|
138
|
+
nodeid=report.nodeid,
|
|
139
|
+
when=report.when,
|
|
140
|
+
outcome=report.outcome,
|
|
141
|
+
duration=float(report.duration),
|
|
142
|
+
message=message,
|
|
143
|
+
trace=trace,
|
|
144
|
+
index=index,
|
|
145
|
+
)
|