im-pytest 0.1.18__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.
- im_pytest/__init__.py +24 -0
- im_pytest/cli.py +43 -0
- im_pytest/plugin.py +152 -0
- im_pytest/report.py +107 -0
- im_pytest/resources.py +34 -0
- im_pytest/runner.py +197 -0
- im_pytest/widget.py +249 -0
- im_pytest-0.1.18.dist-info/METADATA +86 -0
- im_pytest-0.1.18.dist-info/RECORD +13 -0
- im_pytest-0.1.18.dist-info/WHEEL +5 -0
- im_pytest-0.1.18.dist-info/entry_points.txt +5 -0
- im_pytest-0.1.18.dist-info/licenses/LICENSE +674 -0
- im_pytest-0.1.18.dist-info/top_level.txt +1 -0
im_pytest/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""im-pytest — run the course's project tests three ways.
|
|
2
|
+
|
|
3
|
+
* ``check("translationproject")`` or ``%%test translationproject`` — friendly,
|
|
4
|
+
hidden test runner for the early weeks (mode 1).
|
|
5
|
+
* ``pytest test_translationproject.py`` — the raw pytest CLI, once students learn
|
|
6
|
+
to read its output (mode 2).
|
|
7
|
+
* students write their own ``assert`` tests to validate AI-produced code (mode 3).
|
|
8
|
+
|
|
9
|
+
Importing the package registers the ``%%test`` cell magic when run in IPython.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .report import Report, Outcome
|
|
14
|
+
from .runner import run, run_injected
|
|
15
|
+
from .widget import check, register_test_magic, TestResultWidget
|
|
16
|
+
|
|
17
|
+
__all__ = ["check", "run", "run_injected", "Report", "Outcome",
|
|
18
|
+
"TestResultWidget", "register_test_magic"]
|
|
19
|
+
|
|
20
|
+
# Auto-register the cell magic in notebooks (mirrors steps-widget / script-widget).
|
|
21
|
+
try: # pragma: no cover
|
|
22
|
+
register_test_magic()
|
|
23
|
+
except Exception: # pragma: no cover
|
|
24
|
+
pass
|
im_pytest/cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""The ``pytest-check`` command: the terminal way to run a project's tests.
|
|
2
|
+
|
|
3
|
+
pytest-check translationproject.py # point at your solution file
|
|
4
|
+
pytest-check translationproject # or just the project name
|
|
5
|
+
|
|
6
|
+
Finds ``test_<project>.py`` (in the working folder or IM_PROJECT_TESTS), runs it
|
|
7
|
+
against your solution, and prints friendly output.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .resources import resolve_test
|
|
15
|
+
from .runner import run
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv=None) -> int:
|
|
19
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
20
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
21
|
+
print(__doc__)
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
target = argv[0]
|
|
25
|
+
all_tests = "--all" in argv[1:]
|
|
26
|
+
|
|
27
|
+
project = os.path.basename(target)
|
|
28
|
+
if project.endswith(".py"):
|
|
29
|
+
project = project[:-3]
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
test_path = resolve_test(project)
|
|
33
|
+
except FileNotFoundError as exc:
|
|
34
|
+
print(exc)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
report = run(test_path, project=project, failfast=not all_tests)
|
|
38
|
+
print(report.to_text())
|
|
39
|
+
return 0 if report.ok else 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
raise SystemExit(main())
|
im_pytest/plugin.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""The pytest plugin that makes the course test files work.
|
|
2
|
+
|
|
3
|
+
Registered as a ``pytest11`` entry point, so it is active whenever pytest runs
|
|
4
|
+
against an installed ``im_pytest`` — which is what makes *raw* ``pytest
|
|
5
|
+
test_<project>.py`` (course mode 2) work with no boilerplate in the test file.
|
|
6
|
+
|
|
7
|
+
It provides three things the per-project test files rely on:
|
|
8
|
+
|
|
9
|
+
* a ``sol`` fixture — the student's solution, imported fresh from the working
|
|
10
|
+
directory (so edits are picked up on re-run) with the student's own ``print``
|
|
11
|
+
output suppressed;
|
|
12
|
+
* a ``requires`` marker — ``@pytest.mark.requires("translate_codon", ...)`` skips
|
|
13
|
+
a test when the student has not defined (or has misspelled) a needed name, and
|
|
14
|
+
collects the missing names;
|
|
15
|
+
* a "functions not defined" summary at the end of a raw pytest run.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib
|
|
20
|
+
import importlib.util
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import types
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
# %%test injects a module here so the fixture can return an in-notebook cell
|
|
28
|
+
# instead of a file on disk. Keyed by module name.
|
|
29
|
+
_INJECTED: dict[str, types.ModuleType] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
# importing the student's solution
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
|
|
36
|
+
def student_module_name(test_module_name: str) -> str:
|
|
37
|
+
"""`test_translationproject` -> `translationproject`."""
|
|
38
|
+
base = test_module_name.rsplit(".", 1)[-1]
|
|
39
|
+
return base[5:] if base.startswith("test_") else base
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def import_student(name: str, cwd: str | None = None) -> types.ModuleType:
|
|
43
|
+
"""Import (or re-import) the student's `<name>.py` from `cwd`.
|
|
44
|
+
|
|
45
|
+
Loaded by explicit file path (so it can't accidentally resolve to a stale
|
|
46
|
+
copy left on ``sys.path`` by a previous run), with ``cwd`` also put at the
|
|
47
|
+
front of ``sys.path`` so a solution that imports a *sibling* project file
|
|
48
|
+
still works. Raises on any error in the student's code so callers can report
|
|
49
|
+
it. The student's ``print`` output is *not* suppressed here — the caller
|
|
50
|
+
(the mode-1 runner, or pytest's own capture in mode 2) captures it so it can
|
|
51
|
+
be shown in the widget's terminal-output area.
|
|
52
|
+
"""
|
|
53
|
+
if name in _INJECTED:
|
|
54
|
+
return _INJECTED[name]
|
|
55
|
+
|
|
56
|
+
cwd = os.path.abspath(cwd or os.getcwd())
|
|
57
|
+
if sys.path and sys.path[0] != cwd:
|
|
58
|
+
# keep cwd first so sibling-file imports resolve to this folder
|
|
59
|
+
while cwd in sys.path:
|
|
60
|
+
sys.path.remove(cwd)
|
|
61
|
+
sys.path.insert(0, cwd)
|
|
62
|
+
|
|
63
|
+
sys.modules.pop(name, None) # drop any cached copy so edits are seen
|
|
64
|
+
importlib.invalidate_caches()
|
|
65
|
+
|
|
66
|
+
path = os.path.join(cwd, name + ".py")
|
|
67
|
+
if os.path.exists(path):
|
|
68
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
69
|
+
module = importlib.util.module_from_spec(spec)
|
|
70
|
+
sys.modules[name] = module # so recursive/self references resolve
|
|
71
|
+
try:
|
|
72
|
+
spec.loader.exec_module(module)
|
|
73
|
+
except Exception:
|
|
74
|
+
sys.modules.pop(name, None)
|
|
75
|
+
raise
|
|
76
|
+
return module
|
|
77
|
+
# Fall back to a normal import (e.g. an injected/installed module).
|
|
78
|
+
return importlib.import_module(name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_student(config, name):
|
|
82
|
+
"""Import once per session and cache (module or the exception raised)."""
|
|
83
|
+
cache = getattr(config, "_im_student_cache", None)
|
|
84
|
+
if cache is None:
|
|
85
|
+
cache = config._im_student_cache = {}
|
|
86
|
+
if name not in cache:
|
|
87
|
+
try:
|
|
88
|
+
cache[name] = import_student(name)
|
|
89
|
+
except Exception as exc: # noqa: BLE001
|
|
90
|
+
cache[name] = exc
|
|
91
|
+
return cache[name]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
# pytest hooks
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
|
|
98
|
+
def pytest_configure(config):
|
|
99
|
+
config.addinivalue_line(
|
|
100
|
+
"markers",
|
|
101
|
+
"requires(*names): skip this test when the student has not defined the named "
|
|
102
|
+
"functions/variables, and report them as not-yet-defined.",
|
|
103
|
+
)
|
|
104
|
+
config._im_undefined = set()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.fixture
|
|
108
|
+
def sol(request):
|
|
109
|
+
"""The student's solution module (fresh, prints suppressed)."""
|
|
110
|
+
name = student_module_name(request.module.__name__)
|
|
111
|
+
obj = _get_student(request.config, name)
|
|
112
|
+
if isinstance(obj, Exception):
|
|
113
|
+
pytest.fail(
|
|
114
|
+
"Your code could not be run:\n\n" + "".join(
|
|
115
|
+
__import__("traceback").format_exception_only(type(obj), obj)
|
|
116
|
+
),
|
|
117
|
+
pytrace=False,
|
|
118
|
+
)
|
|
119
|
+
return obj
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def pytest_collection_modifyitems(config, items):
|
|
123
|
+
for item in items:
|
|
124
|
+
marker = item.get_closest_marker("requires")
|
|
125
|
+
if marker is None:
|
|
126
|
+
continue
|
|
127
|
+
name = student_module_name(item.module.__name__)
|
|
128
|
+
obj = _get_student(config, name)
|
|
129
|
+
if isinstance(obj, Exception):
|
|
130
|
+
# the sol fixture will surface the import error; nothing to skip on
|
|
131
|
+
continue
|
|
132
|
+
missing = [n for n in marker.args if not hasattr(obj, n)]
|
|
133
|
+
if missing:
|
|
134
|
+
config._im_undefined.update(missing)
|
|
135
|
+
item.add_marker(
|
|
136
|
+
pytest.mark.skip(reason="not defined: " + ", ".join(missing))
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|
141
|
+
undefined = sorted(getattr(config, "_im_undefined", ()) or [])
|
|
142
|
+
if undefined:
|
|
143
|
+
tr = terminalreporter
|
|
144
|
+
tr.write_line("")
|
|
145
|
+
tr.write_line("*" * 57)
|
|
146
|
+
tr.write_line("ATTENTION! These functions are not defined (yet):")
|
|
147
|
+
tr.write_line("")
|
|
148
|
+
for n in undefined:
|
|
149
|
+
tr.write_line("\t" + n)
|
|
150
|
+
tr.write_line("")
|
|
151
|
+
tr.write_line("They are either misspelled or not written yet.")
|
|
152
|
+
tr.write_line("*" * 57)
|
im_pytest/report.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Data model for a test run plus a plain-text rendering (used by the CLI).
|
|
2
|
+
|
|
3
|
+
The notebook rendering is a real anywidget — see ``widget.py``. Both consume
|
|
4
|
+
*this* object, so the two never drift.
|
|
5
|
+
|
|
6
|
+
A run has two distinct kinds of result:
|
|
7
|
+
|
|
8
|
+
* **check results** — one :class:`Outcome` per tested function: it passed, an
|
|
9
|
+
assertion about its return value failed (``FAIL``), or the student's own code
|
|
10
|
+
raised while it was being tested (``ERROR``);
|
|
11
|
+
* **terminal output** — anything the student's code printed, plus the traceback
|
|
12
|
+
of a non-assertion error in their code (or an import/syntax error that stopped
|
|
13
|
+
the file running at all). This mirrors what the ``%%exercise`` widget shows.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
PASS = "pass"
|
|
21
|
+
FAIL = "fail" # an assertion about the return value failed
|
|
22
|
+
ERROR = "error" # the student's code raised while being tested
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Outcome:
|
|
27
|
+
name: str # the function being tested, e.g. "translate_codon"
|
|
28
|
+
status: str # PASS / FAIL / ERROR
|
|
29
|
+
message: str = "" # the failing assertion, or a short "raised X" note
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Report:
|
|
34
|
+
project: str = ""
|
|
35
|
+
outcomes: List[Outcome] = field(default_factory=list)
|
|
36
|
+
undefined: List[str] = field(default_factory=list) # required names not defined yet
|
|
37
|
+
stdout: str = "" # captured student prints
|
|
38
|
+
traceback: str = "" # colored traceback of a code error
|
|
39
|
+
import_error: Optional[str] = None # short "cannot run" summary
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def passed(self) -> int:
|
|
43
|
+
return sum(o.status == PASS for o in self.outcomes)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def failed(self) -> int:
|
|
47
|
+
return sum(o.status in (FAIL, ERROR) for o in self.outcomes)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total(self) -> int:
|
|
51
|
+
return len(self.outcomes)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def has_terminal(self) -> bool:
|
|
55
|
+
return bool(self.stdout) or bool(self.traceback)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self) -> bool:
|
|
59
|
+
return self.import_error is None and self.failed == 0 and not self.undefined
|
|
60
|
+
|
|
61
|
+
# ---- plain-text rendering (CLI) -------------------------------------- #
|
|
62
|
+
|
|
63
|
+
def to_text(self) -> str:
|
|
64
|
+
lines = []
|
|
65
|
+
if self.import_error is not None:
|
|
66
|
+
lines.append("YOUR CODE CANNOT BE RUN")
|
|
67
|
+
lines.append("")
|
|
68
|
+
lines.append(self.import_error)
|
|
69
|
+
else:
|
|
70
|
+
for o in self.outcomes:
|
|
71
|
+
lines.append(f"[{'PASS' if o.status == PASS else 'FAIL'}] {o.name}")
|
|
72
|
+
if o.status != PASS and o.message:
|
|
73
|
+
lines.append(_indent(o.message))
|
|
74
|
+
if self.undefined:
|
|
75
|
+
lines += ["", "*" * 57,
|
|
76
|
+
"ATTENTION! These functions are not defined (yet):", ""]
|
|
77
|
+
lines += [f"\t{n}" for n in self.undefined]
|
|
78
|
+
lines += ["", "They are either misspelled or not written yet.", "*" * 57]
|
|
79
|
+
|
|
80
|
+
if self.has_terminal:
|
|
81
|
+
lines += ["", "----- terminal output -----"]
|
|
82
|
+
if self.stdout:
|
|
83
|
+
lines.append(self.stdout.rstrip("\n"))
|
|
84
|
+
if self.traceback:
|
|
85
|
+
lines.append(_strip_ansi(self.traceback).rstrip("\n"))
|
|
86
|
+
|
|
87
|
+
lines.append("")
|
|
88
|
+
if self.import_error is not None:
|
|
89
|
+
lines.append("Fix the error above, then run the tests again.")
|
|
90
|
+
elif self.ok:
|
|
91
|
+
lines.append(f"All {self.passed} checks passed. Nice work!")
|
|
92
|
+
else:
|
|
93
|
+
bits = f"{self.passed} passed, {self.failed} to fix"
|
|
94
|
+
if self.undefined:
|
|
95
|
+
bits += f", {len(self.undefined)} not defined"
|
|
96
|
+
lines.append(bits + ".")
|
|
97
|
+
return "\n".join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _indent(text: str, n: int = 4) -> str:
|
|
101
|
+
pad = " " * n
|
|
102
|
+
return "\n".join(pad + line for line in str(text).splitlines())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _strip_ansi(text: str) -> str:
|
|
106
|
+
import re
|
|
107
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
im_pytest/resources.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Find a project's test file.
|
|
2
|
+
|
|
3
|
+
Course projects ship as a downloadable ``test_<project>.py`` (plus any data)
|
|
4
|
+
that lands in the student's working folder, so by default we look there. The
|
|
5
|
+
``IM_PROJECT_TESTS`` environment variable (or an explicit ``search`` dir) points
|
|
6
|
+
at a shared course-repo directory instead.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_test(project: str, search: Optional[str] = None) -> str:
|
|
16
|
+
fname = f"test_{project}.py"
|
|
17
|
+
candidates = []
|
|
18
|
+
if search:
|
|
19
|
+
candidates.append(Path(search) / fname)
|
|
20
|
+
env = os.environ.get("IM_PROJECT_TESTS")
|
|
21
|
+
if env:
|
|
22
|
+
candidates.append(Path(env) / fname)
|
|
23
|
+
candidates.append(Path.cwd() / fname)
|
|
24
|
+
candidates.append(Path.cwd() / "project_tests" / fname)
|
|
25
|
+
for c in candidates:
|
|
26
|
+
if c.exists():
|
|
27
|
+
return str(c)
|
|
28
|
+
looked = "\n ".join(str(c) for c in candidates)
|
|
29
|
+
raise FileNotFoundError(
|
|
30
|
+
f"Could not find tests for project {project!r} ({fname}).\n"
|
|
31
|
+
f"Looked in:\n {looked}\n"
|
|
32
|
+
f"Download the project's test file into your working folder, or set "
|
|
33
|
+
f"IM_PROJECT_TESTS to the folder that holds it."
|
|
34
|
+
)
|
im_pytest/runner.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Run a project's pytest file in-process and return a :class:`Report`.
|
|
2
|
+
|
|
3
|
+
pytest does the real work; this collects results into a plain object and, crucially,
|
|
4
|
+
separates the two kinds of outcome the widget shows differently:
|
|
5
|
+
|
|
6
|
+
* an **assertion** about a return value failing → a ``FAIL`` check;
|
|
7
|
+
* the student's **own code raising** (a runtime error, or an import/syntax error
|
|
8
|
+
that stops the file running) → an ``ERROR`` check *and* a colored traceback +
|
|
9
|
+
their captured prints in the widget's terminal-output area, just like
|
|
10
|
+
``%%exercise``.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import os
|
|
16
|
+
import types
|
|
17
|
+
from contextlib import redirect_stdout, redirect_stderr
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from . import plugin as _plugin
|
|
22
|
+
from .report import Report, Outcome, PASS, FAIL, ERROR
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from IPython import get_ipython
|
|
26
|
+
except Exception: # pragma: no cover
|
|
27
|
+
def get_ipython():
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _clean_name(nodeid: str) -> str:
|
|
32
|
+
func = nodeid.rsplit("::", 1)[-1]
|
|
33
|
+
return func[5:] if func.startswith("test_") else func
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _assert_message(report) -> str:
|
|
37
|
+
lr = getattr(report, "longrepr", None)
|
|
38
|
+
crash = getattr(lr, "reprcrash", None)
|
|
39
|
+
msg = getattr(crash, "message", None) or (str(lr) if lr else "")
|
|
40
|
+
lines = [ln for ln in msg.splitlines() if ln.strip()]
|
|
41
|
+
if len(lines) > 6:
|
|
42
|
+
lines = lines[:6] + ["... (run `pytest` for the full diff)"]
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tb_formatter():
|
|
47
|
+
"""The traceback formatter to use, in order of fidelity.
|
|
48
|
+
|
|
49
|
+
In a live notebook, the kernel's own ``InteractiveTB`` renders tracebacks
|
|
50
|
+
exactly as the notebook does for uncaught exceptions — the truest match to
|
|
51
|
+
``%%exercise``. Off a kernel (the CLI), fall back to a standalone
|
|
52
|
+
``FormattedTB`` on the *neutral* theme (the same theme ``%%exercise`` forces),
|
|
53
|
+
handling the IPython 9 ``theme_name`` / pre-9 ``color_scheme`` API split.
|
|
54
|
+
"""
|
|
55
|
+
ip = get_ipython()
|
|
56
|
+
itb = getattr(ip, "InteractiveTB", None) if ip is not None else None
|
|
57
|
+
if itb is not None:
|
|
58
|
+
return itb
|
|
59
|
+
from IPython.core.ultratb import FormattedTB
|
|
60
|
+
try:
|
|
61
|
+
return FormattedTB(mode="Context", theme_name="neutral") # IPython >= 9
|
|
62
|
+
except TypeError: # pragma: no cover - older IPython
|
|
63
|
+
tb = FormattedTB(mode="Context")
|
|
64
|
+
try:
|
|
65
|
+
tb.set_colors("Neutral")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
return tb
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def format_traceback(exc_type, exc_value, exc_tb, student_file=None) -> str:
|
|
72
|
+
"""A colored, student-focused traceback — same look as the ``%%exercise`` widget.
|
|
73
|
+
|
|
74
|
+
If ``student_file`` is given, the traceback is sliced to start at the first
|
|
75
|
+
frame inside the student's own file, so pytest/plumbing frames are hidden.
|
|
76
|
+
"""
|
|
77
|
+
use_tb = exc_tb
|
|
78
|
+
if student_file and exc_tb is not None:
|
|
79
|
+
target = os.path.abspath(student_file)
|
|
80
|
+
cur = exc_tb
|
|
81
|
+
while cur is not None:
|
|
82
|
+
fn = cur.tb_frame.f_code.co_filename
|
|
83
|
+
if os.path.abspath(fn) == target or fn == student_file:
|
|
84
|
+
use_tb = cur
|
|
85
|
+
break
|
|
86
|
+
cur = cur.tb_next
|
|
87
|
+
try:
|
|
88
|
+
tbf = _tb_formatter()
|
|
89
|
+
return tbf.stb2text(tbf.structured_traceback(exc_type, exc_value, use_tb))
|
|
90
|
+
except Exception:
|
|
91
|
+
import traceback
|
|
92
|
+
return "".join(traceback.format_exception(exc_type, exc_value, use_tb))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Capture:
|
|
96
|
+
"""Records outcomes, student prints and a code-error traceback for one run."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, student_file=None):
|
|
99
|
+
self.outcomes: list[Outcome] = []
|
|
100
|
+
self.config = None
|
|
101
|
+
self.student_file = student_file
|
|
102
|
+
self.stdout_parts: list[str] = []
|
|
103
|
+
self.traceback = ""
|
|
104
|
+
self._done: set[str] = set()
|
|
105
|
+
|
|
106
|
+
def pytest_configure(self, config):
|
|
107
|
+
self.config = config
|
|
108
|
+
|
|
109
|
+
def _record(self, name, status, message=""):
|
|
110
|
+
if name in self._done:
|
|
111
|
+
return
|
|
112
|
+
self._done.add(name)
|
|
113
|
+
self.outcomes.append(Outcome(name, status, message))
|
|
114
|
+
|
|
115
|
+
def pytest_exception_interact(self, node, call, report):
|
|
116
|
+
exc = getattr(call, "excinfo", None)
|
|
117
|
+
if exc is None:
|
|
118
|
+
return
|
|
119
|
+
name = _clean_name(report.nodeid)
|
|
120
|
+
if issubclass(exc.type, AssertionError):
|
|
121
|
+
self._record(name, FAIL, _assert_message(report))
|
|
122
|
+
else:
|
|
123
|
+
# the student's code raised -> show it in the terminal-output area
|
|
124
|
+
self._record(name, ERROR, f"raised {exc.type.__name__}: {exc.value}")
|
|
125
|
+
if not self.traceback:
|
|
126
|
+
self.traceback = format_traceback(exc.type, exc.value, exc.tb, self.student_file)
|
|
127
|
+
|
|
128
|
+
def pytest_runtest_logreport(self, report):
|
|
129
|
+
if report.when == "call":
|
|
130
|
+
cap = getattr(report, "capstdout", "")
|
|
131
|
+
if cap:
|
|
132
|
+
self.stdout_parts.append(cap)
|
|
133
|
+
if report.passed:
|
|
134
|
+
self._record(_clean_name(report.nodeid), PASS)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_pytest(test_path, student_file, failfast) -> _Capture:
|
|
138
|
+
cap = _Capture(student_file=student_file)
|
|
139
|
+
args = [str(test_path), "-p", "no:cacheprovider", "-q", "--no-header"]
|
|
140
|
+
if failfast:
|
|
141
|
+
args.append("-x")
|
|
142
|
+
sink = io.StringIO()
|
|
143
|
+
with redirect_stdout(sink), redirect_stderr(sink):
|
|
144
|
+
pytest.main(args, plugins=[cap])
|
|
145
|
+
return cap
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_report(project, cap, pre_stdout="") -> Report:
|
|
149
|
+
rep = Report(project=project, outcomes=cap.outcomes)
|
|
150
|
+
if cap.config is not None:
|
|
151
|
+
rep.undefined = sorted(getattr(cap.config, "_im_undefined", set()) or [])
|
|
152
|
+
stdout = (pre_stdout or "") + "".join(cap.stdout_parts)
|
|
153
|
+
rep.stdout = stdout.rstrip("\n")
|
|
154
|
+
rep.traceback = cap.traceback
|
|
155
|
+
return rep
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run(test_path: str, *, project: str = "", failfast: bool = True) -> Report:
|
|
159
|
+
"""Run ``test_path`` against the student's ``<project>.py`` in the cwd."""
|
|
160
|
+
project = project or _plugin.student_module_name(os.path.basename(test_path).rsplit(".", 1)[0])
|
|
161
|
+
|
|
162
|
+
# Import the student's file *once*, capturing its own prints; a failure here
|
|
163
|
+
# (syntax error, exception at import time) becomes a friendly "cannot run".
|
|
164
|
+
pre = io.StringIO()
|
|
165
|
+
already_injected = project in _plugin._INJECTED
|
|
166
|
+
try:
|
|
167
|
+
with redirect_stdout(pre), redirect_stderr(pre):
|
|
168
|
+
module = _plugin.import_student(project)
|
|
169
|
+
except Exception as exc: # noqa: BLE001
|
|
170
|
+
student_file = os.path.join(os.getcwd(), project + ".py")
|
|
171
|
+
return Report(
|
|
172
|
+
project=project,
|
|
173
|
+
import_error=f"{type(exc).__name__}: {exc}",
|
|
174
|
+
traceback=format_traceback(type(exc), exc, exc.__traceback__, student_file),
|
|
175
|
+
stdout=pre.getvalue().rstrip("\n"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Reuse this exact module inside pytest (no second import, no double prints).
|
|
179
|
+
if not already_injected:
|
|
180
|
+
_plugin._INJECTED[project] = module
|
|
181
|
+
try:
|
|
182
|
+
cap = _run_pytest(test_path, getattr(module, "__file__", None), failfast)
|
|
183
|
+
finally:
|
|
184
|
+
if not already_injected:
|
|
185
|
+
_plugin._INJECTED.pop(project, None)
|
|
186
|
+
return _build_report(project, cap, pre_stdout=pre.getvalue())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_injected(project: str, module: types.ModuleType, test_path: str, *,
|
|
190
|
+
pre_stdout: str = "", failfast: bool = True) -> Report:
|
|
191
|
+
"""Run against an in-notebook module built by the ``%%test`` cell magic."""
|
|
192
|
+
_plugin._INJECTED[project] = module
|
|
193
|
+
try:
|
|
194
|
+
cap = _run_pytest(test_path, getattr(module, "__file__", None), failfast)
|
|
195
|
+
finally:
|
|
196
|
+
_plugin._INJECTED.pop(project, None)
|
|
197
|
+
return _build_report(project, cap, pre_stdout=pre_stdout)
|