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 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)