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.
Files changed (49) hide show
  1. pyics/__init__.py +0 -0
  2. pyics/app/actions.py +179 -0
  3. pyics/app/cli.py +335 -0
  4. pyics/app/key_listener.py +133 -0
  5. pyics/app/prompts.py +439 -0
  6. pyics/app/pyics.py +454 -0
  7. pyics/data/__init__.py +4 -0
  8. pyics/data/course.py +531 -0
  9. pyics/data/task.py +744 -0
  10. pyics/distribution/__init__.py +5 -0
  11. pyics/distribution/archive.py +667 -0
  12. pyics/distribution/dependencies.py +452 -0
  13. pyics/distribution/fetch.py +128 -0
  14. pyics/distribution/pull.py +376 -0
  15. pyics/distribution/slugs.py +24 -0
  16. pyics/distribution/sources.py +561 -0
  17. pyics/notebook/__init__.py +1 -0
  18. pyics/notebook/cells.py +184 -0
  19. pyics/notebook/loader.py +590 -0
  20. pyics/notebook/sections.py +189 -0
  21. pyics/plugins.py +90 -0
  22. pyics/scripts/console.py +86 -0
  23. pyics/scripts/create_course.py +301 -0
  24. pyics/scripts/create_task.py +850 -0
  25. pyics/scripts/edit_course.py +133 -0
  26. pyics/scripts/edit_task.py +109 -0
  27. pyics/scripts/main.py +118 -0
  28. pyics/scripts/pack.py +198 -0
  29. pyics/scripts/pull.py +123 -0
  30. pyics/scripts/sources.py +118 -0
  31. pyics/tests/__init__.py +3 -0
  32. pyics/tests/capture.py +114 -0
  33. pyics/tests/check_specs.py +528 -0
  34. pyics/tests/check_structure.py +769 -0
  35. pyics/tests/compare.py +184 -0
  36. pyics/tests/pytest_integration.py +228 -0
  37. pyics/tests/results.py +145 -0
  38. pyics/tests/tests.py +440 -0
  39. pyics/tests/trace.py +156 -0
  40. pyics/util/discovery.py +92 -0
  41. pyics/util/json_handling.py +182 -0
  42. pyics/util/logging.py +39 -0
  43. pyics/util/paths.py +38 -0
  44. pyics/util/terminal.py +57 -0
  45. python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
  46. python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
  47. python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
  48. python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
  49. 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
+ )