diff --git a/Changes.md b/Changes.md index f50e4aa..cf271f4 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ Note: development is tracked on the [`develop` branch](https://github.com/chmp/ipytest/tree/develop). +## `0.14.2` + +- Support collecting branch coverage in notebooks (e.g., via `--cov--branch`) +- Add `ipytest.autoconfig(coverage=True)` to simplify using `pytest-cov` inside + notebooks +- Add experimental `ipytest.cov.translate_cell_filenames()` to simplify + interpretation of collected coverage information + ## `0.14.1` - Add a [Coverage.py](https://coverage.readthedocs.io/en/latest/index.html) diff --git a/Readme.md b/Readme.md index 5b0ff74..4307b2d 100644 --- a/Readme.md +++ b/Readme.md @@ -99,22 +99,23 @@ this case, using [`ipytest.run()`][ipytest.run] and | [`ipytest.cov`](#ipytestcov) -### `ipytest.autoconfig(rewrite_asserts=, magics=, clean=, addopts=, run_in_thread=, defopts=, display_columns=, raise_on_error=)` +### `ipytest.autoconfig(rewrite_asserts=, magics=, clean=, addopts=, run_in_thread=, defopts=, display_columns=, raise_on_error=, coverage=)` -[ipytest.autoconfig]: #ipytestautoconfigrewrite_assertsdefault-magicsdefault-cleandefault-addoptsdefault-run_in_threaddefault-defoptsdefault-display_columnsdefault-raise_on_errordefault +[ipytest.autoconfig]: #ipytestautoconfigrewrite_assertsdefault-magicsdefault-cleandefault-addoptsdefault-run_in_threaddefault-defoptsdefault-display_columnsdefault-raise_on_errordefault-coveragedefault Configure `ipytest` with reasonable defaults. Specifically, it sets: -* `rewrite_asserts`: `True` -* `magics`: `True` -* `clean`: `'[Tt]est*'` * `addopts`: `('-q', '--color=yes')` -* `run_in_thread`: `False` +* `clean`: `'[Tt]est*'` +* `coverage`: `False` * `defopts`: `'auto'` * `display_columns`: `100` +* `magics`: `True` * `raise_on_error`: `False` +* `rewrite_asserts`: `True` +* `run_in_thread`: `False` See [`ipytest.config`][ipytest.config] for details. @@ -169,9 +170,9 @@ inside a CI/CD context, use `ipytest.autoconfig(raise_on_error=True)`. -### `ipytest.config(rewrite_asserts=, magics=, clean=, addopts=, run_in_thread=, defopts=, display_columns=, raise_on_error=)` +### `ipytest.config(rewrite_asserts=, magics=, clean=, addopts=, run_in_thread=, defopts=, display_columns=, raise_on_error=, coverage=)` -[ipytest.config]: #ipytestconfigrewrite_assertskeep-magicskeep-cleankeep-addoptskeep-run_in_threadkeep-defoptskeep-display_columnskeep-raise_on_errorkeep +[ipytest.config]: #ipytestconfigrewrite_assertskeep-magicskeep-cleankeep-addoptskeep-run_in_threadkeep-defoptskeep-display_columnskeep-raise_on_errorkeep-coveragedefault Configure `ipytest` @@ -186,6 +187,12 @@ The following settings are supported: * `rewrite_asserts` (default: `False`): enable ipython AST transforms globally to rewrite asserts * `magics` (default: `False`): if set to `True` register the ipytest magics +* `coverage` (default: `False`): if `True` configure `pytest` to collect + coverage information. This functionality requires the `pytest-cov` package + to be installed. It adds `--cov --cov-config={GENERATED_CONFIG}` to the + arguments when invoking `pytest`. **WARNING**: this option will hide + existing coverage configuration files. See [`ipytest.cov`](#ipytestcov) + for details * `clean` (default: `[Tt]est*`): the pattern used to clean variables * `addopts` (default: `()`): pytest command line arguments to prepend to every pytest invocation. For example setting @@ -217,9 +224,9 @@ The following settings are supported: The return code of the last pytest invocation. -### `ipytest.run(*args, module=None, plugins=(), run_in_thread=, raise_on_error=, addopts=, defopts=, display_columns=)` +### `ipytest.run(*args, module=None, plugins=(), run_in_thread=, raise_on_error=, addopts=, defopts=, display_columns=, coverage=)` -[ipytest.run]: #ipytestrunargs-modulenone-plugins-run_in_threaddefault-raise_on_errordefault-addoptsdefault-defoptsdefault-display_columnsdefault +[ipytest.run]: #ipytestrunargs-modulenone-plugins-run_in_threaddefault-raise_on_errordefault-addoptsdefault-defoptsdefault-display_columnsdefault-coveragedefault Execute all tests in the passed module (defaults to `__main__`) with pytest. @@ -320,7 +327,7 @@ plugins = ipytest.cov ``` -With this config file, the coverage can be collected using +With this config file, coverage information can be collected using [pytest-cov][ipytest-cov-pytest-cov] with ```python @@ -330,9 +337,35 @@ def test(): ... ``` +`ipytest.autoconfig(coverage=True)` automatically adds the `--cov` flag and the +path of a generated config file to the Pytest invocation. In this case no +further configuration is required. + +There are some known issues of `ipytest.cov` + +- Each notebook cell is reported as an individual file +- Lines that are executed at import time may not be encountered in tracing and + may be reported as not-covered (One example is the line of a function + definition) +- Marking code to be excluded in branch coverage is currently not supported + (incl. coveragepy pragmas) + [coverage-py-config-docs]: https://coverage.readthedocs.io/en/latest/config.html [ipytest-cov-pytest-cov]: https://pytest-cov.readthedocs.io/en/latest/config.html +#### `ipytest.cov.translate_cell_filenames(enabled=True)` + +[ipytest.cov.translate_cell_filenames]: #ipytestcovtranslate_cell_filenamesenabledtrue + +Translate the filenames of notebook cells in coverage information. + +If enabled, `ipytest.cov` will translate the temporary file names generated +by ipykernel (e.g, `ipykernel_24768/3920661193.py`) to their cell names +(e.g., `In[6]`). + +**Warning**: this is an experimental feature and not subject to any +stability guarantees. + ## Development diff --git a/ipytest/_config.py b/ipytest/_config.py index b82ee27..6aa6c0e 100644 --- a/ipytest/_config.py +++ b/ipytest/_config.py @@ -5,25 +5,27 @@ default_clean = "[Tt]est*" defaults = { - "rewrite_asserts": True, - "magics": True, - "clean": default_clean, "addopts": ("-q", "--color=yes"), - "run_in_thread": False, + "clean": default_clean, + "coverage": False, "defopts": "auto", "display_columns": 100, + "magics": True, "raise_on_error": False, + "rewrite_asserts": True, + "run_in_thread": False, } current_config = { - "rewrite_asserts": False, - "magics": False, - "clean": default_clean, "addopts": (), - "run_in_thread": False, + "clean": default_clean, + "coverage": False, "defopts": "auto", "display_columns": 100, + "magics": False, "raise_on_error": False, + "rewrite_asserts": False, + "run_in_thread": False, } _rewrite_transformer = None @@ -67,6 +69,7 @@ def autoconfig( defopts=default, display_columns=default, raise_on_error=default, + coverage=default, ): """Configure `ipytest` with reasonable defaults. @@ -91,6 +94,7 @@ def config( defopts=keep, display_columns=keep, raise_on_error=keep, + coverage=default, ): """Configure `ipytest` @@ -105,6 +109,12 @@ def config( * `rewrite_asserts` (default: `False`): enable ipython AST transforms globally to rewrite asserts * `magics` (default: `False`): if set to `True` register the ipytest magics + * `coverage` (default: `False`): if `True` configure `pytest` to collect + coverage information. This functionality requires the `pytest-cov` package + to be installed. It adds `--cov --cov-config={GENERATED_CONFIG}` to the + arguments when invoking `pytest`. **WARNING**: this option will hide + existing coverage configuration files. See [`ipytest.cov`](#ipytestcov) + for details * `clean` (default: `[Tt]est*`): the pattern used to clean variables * `addopts` (default: `()`): pytest command line arguments to prepend to every pytest invocation. For example setting diff --git a/ipytest/_impl.py b/ipytest/_impl.py index 0d93d8c..1d874f6 100644 --- a/ipytest/_impl.py +++ b/ipytest/_impl.py @@ -4,6 +4,7 @@ import importlib import os import pathlib +import re import shlex import sys import threading @@ -28,6 +29,7 @@ def run( addopts=default, defopts=default, display_columns=default, + coverage=default, ): """Execute all tests in the passed module (defaults to `__main__`) with pytest. @@ -64,6 +66,7 @@ def run( addopts = default.unwrap(addopts, current_config["addopts"]) defopts = default.unwrap(defopts, current_config["defopts"]) display_columns = default.unwrap(display_columns, current_config["display_columns"]) + coverage = default.unwrap(coverage, current_config["coverage"]) if module is None: import __main__ as module @@ -77,6 +80,7 @@ def run( addopts=addopts, defopts=defopts, display_columns=display_columns, + coverage=coverage, ) ipytest.exit_code = exit_code @@ -250,13 +254,18 @@ def force_reload(*include: str, modules: Optional[Dict[str, ModuleType]] = None) modules.pop(name, None) -def _run_impl(*args, module, plugins, addopts, defopts, display_columns): +def _run_impl(*args, module, plugins, addopts, defopts, display_columns, coverage): with _prepared_env(module, display_columns=display_columns) as filename: - full_args = _build_full_args(args, filename, addopts=addopts, defopts=defopts) + full_args = _build_full_args( + args, filename, addopts=addopts, defopts=defopts, coverage=coverage + ) + if coverage: + warn_for_existing_coverage_configs() + return pytest.main(full_args, plugins=[*plugins, FixProgramNamePlugin()]) -def _build_full_args(args, filename, *, addopts, defopts): +def _build_full_args(args, filename, *, addopts, defopts, coverage): arg_mapping = ArgMapping( # use basename to ensure --deselect works # (see also: https://github.com/pytest-dev/pytest/issues/6751) @@ -266,7 +275,16 @@ def _build_full_args(args, filename, *, addopts, defopts): def _fmt(arg): return arg.format_map(arg_mapping) + if coverage: + import ipytest.cov + + coverage_args = ("--cov", f"--cov-config={ipytest.cov.config_path}") + + else: + coverage_args = () + all_args = [ + *coverage_args, *(_fmt(arg) for arg in addopts), *(_fmt(arg) for arg in args), ] @@ -521,3 +539,44 @@ def is_notebook_node_id(prev: Optional[str], arg: str) -> bool: return all( not is_notebook_node_id(prev, arg) for prev, arg in zip([None, *args], args) ) + + +def warn_for_existing_coverage_configs(): + if configs := find_coverage_configs("."): + print( + "Warning: found existing coverage.py configuration in " + f"{[p.name for p in configs]}. " + "These config files are ignored when using " + "`ipytest.autoconfig(coverage=True)`." + "Consider adding the `ipytest.cov` plugin directly to the config " + "files and adding `--cov` to the `%%ipytest` invocation.", + file=sys.stderr, + ) + + +def find_coverage_configs(root): + root = pathlib.Path(root) + + result = [] + if (p := root.joinpath(".coveragerc")).exists(): + result.append(p) + + result += _find_files_with_lines(root, ["setup.cfg", "tox.ini"], r"^\[coverage:.*$") + result += _find_files_with_lines(root, ["pyproject.toml"], r"^\[tool\.coverage.*$") + + return result + + +def _find_files_with_lines(root, paths, pat): + for path in paths: + path = root.joinpath(path) + if path.exists(): + try: + with open(path, "rt") as fobj: + for line in fobj: + if re.match(pat, line) is not None: + yield path + break + + except Exception: + pass diff --git a/ipytest/cov.py b/ipytest/cov.py index 5590b25..5b9c327 100644 --- a/ipytest/cov.py +++ b/ipytest/cov.py @@ -10,7 +10,7 @@ ipytest.cov ``` -With this config file, the coverage can be collected using +With this config file, coverage information can be collected using [pytest-cov][ipytest-cov-pytest-cov] with ```python @@ -20,6 +20,19 @@ def test(): ... ``` +`ipytest.autoconfig(coverage=True)` automatically adds the `--cov` flag and the +path of a generated config file to the Pytest invocation. In this case no +further configuration is required. + +There are some known issues of `ipytest.cov` + +- Each notebook cell is reported as an individual file +- Lines that are executed at import time may not be encountered in tracing and + may be reported as not-covered (One example is the line of a function + definition) +- Marking code to be excluded in branch coverage is currently not supported + (incl. coveragepy pragmas) + [coverage-py-config-docs]: https://coverage.readthedocs.io/en/latest/config.html [ipytest-cov-pytest-cov]: https://pytest-cov.readthedocs.io/en/latest/config.html """ @@ -27,13 +40,39 @@ def test(): import os import os.path import re +from typing import Optional import coverage.parser import coverage.plugin import coverage.python -# prevent the definitions from being documented in the readme -__all__ = [] +__all__ = ["translate_cell_filenames"] + +_cell_filenames_tracker = None +config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "coveragerc") + + +def translate_cell_filenames(enabled=True): + """Translate the filenames of notebook cells in coverage information. + + If enabled, `ipytest.cov` will translate the temporary file names generated + by ipykernel (e.g, `ipykernel_24768/3920661193.py`) to their cell names + (e.g., `In[6]`). + + **Warning**: this is an experimental feature and not subject to any + stability guarantees. + """ + global _cell_filenames_tracker + + from IPython import get_ipython + + if enabled and _cell_filenames_tracker is None: + _cell_filenames_tracker = CellFilenamesTracker() + _cell_filenames_tracker.register(get_ipython()) + + elif not enabled and _cell_filenames_tracker is not None: + _cell_filenames_tracker.unregister() + _cell_filenames_tracker = None def coverage_init(reg, options): @@ -91,6 +130,11 @@ def source_filename(self): class IPythonFileReporter(coverage.python.PythonFileReporter): + # TODO: implement fully from scratch to be independent from PythonFileReporter impl + + def __repr__(self) -> str: + return f"" + @property def parser(self): if self._parser is None: @@ -101,6 +145,74 @@ def parser(self): def source(self): if self.filename not in linecache.cache: - raise ValueError() + raise RuntimeError(f"Could not lookup source for {self.filename!r}") return "".join(linecache.cache[self.filename][2]) + + def no_branch_lines(self): + # TODO: figure out how to implement this (require coverage config) + return set() + + def relative_filename(self) -> str: + if _cell_filenames_tracker is None: + return self.filename + + return _cell_filenames_tracker.translate_filename(self.filename) + + +class CellFilenamesTracker: + """An IPython plugin to map temporary filenames to cells""" + + def __init__(self): + self._info = {} + self._execution_count_counts = {} + self._shell = None + + def register(self, shell): + if self._shell is not None: + self.unregister() + + shell.events.register("post_run_cell", self.on_post_run_cell) + self._shell = shell + + def unregister(self): + if self._shell is not None: + self._shell.events.unregister("post_run_cell", self.on_post_run_cell) + self._shell = None + + def on_post_run_cell(self, result): + if self._shell is None: + return + + try: + filename = self._shell.compile.get_code_name( + result.info.raw_cell, + None, + None, + ) + except Exception as _exc: + # TODO: log exception + return + + # NOTE: inside magic cells, the cell may be executed without storing the + # history, e.g., inside the `%%ipytest` cell magic. In that case the + # `execution_count` is `None`. Use the shell's execution count. However, + # now it may be found multiple times. Therefore use an increasing + # counter to avoid collisions + execution_count = ( + result.execution_count + if result.execution_count is not None + else self._shell.execution_count + ) + if execution_count in self._execution_count_counts: + self._execution_count_counts[execution_count] += 1 + self._info[ + filename + ] = f"In[{execution_count}/{self._execution_count_counts[execution_count]}]" + + else: + self._execution_count_counts[execution_count] = 0 + self._info[filename] = f"In[{execution_count}]" + + def translate_filename(self, filename: str) -> Optional[int]: + return self._info.get(filename, filename) diff --git a/tests/.coveragerc b/ipytest/coveragerc similarity index 100% rename from tests/.coveragerc rename to ipytest/coveragerc diff --git a/pyproject.toml b/pyproject.toml index b388464..c6da236 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,10 +60,12 @@ select = [ ] ignore = [ - "E501", + "E501", "SIM117", # poetry uses a non-standard pyproject.toml format "RUF200", # trailing comma rule may conflict with the formatter "COM812", + # wrong types in pytest.mark.parametrize + "PT006", ] diff --git a/tests/TestCoverage.ipynb b/tests/TestCoverage.ipynb index 40fef2b..006fb72 100644 --- a/tests/TestCoverage.ipynb +++ b/tests/TestCoverage.ipynb @@ -1,34 +1,20 @@ { "cells": [ - { - "cell_type": "markdown", - "id": "bf900d0d-7eba-4ca1-b738-a64d55f82f61", - "metadata": {}, - "source": [ - "Note: this notebook requires a `.coveragerc` file next to it with the following content:\n", - "\n", - "```ini\n", - "[run]\n", - "plugins =\n", - " ipytest.cov\n", - "```" - ] - }, { "cell_type": "code", "execution_count": null, - "id": "bdf258d0-b8b7-4fbb-96c7-664d120c4abc", + "id": "d32b9292-2a37-47b7-8451-c24b4c6cf65a", "metadata": {}, "outputs": [], "source": [ "import ipytest\n", - "ipytest.autoconfig()" + "ipytest.autoconfig(coverage=True)" ] }, { "cell_type": "code", "execution_count": null, - "id": "dc478ce9-6551-4332-9d21-b9a50f455d1d", + "id": "4ffcb1df-d1fd-42f0-a0d4-c227e3be17ac", "metadata": {}, "outputs": [], "source": [ @@ -45,23 +31,61 @@ " ]\n", "\n", "\n", + "def check_coverage():\n", + " assert Path(\".coverage\").exists()\n", + " assert Path(\"coverage.json\").exists()\n", + " \n", + " with open(\"coverage.json\", \"rt\") as fobj:\n", + " data = json.load(fobj)\n", + " \n", + " assert get_executed_lines(data, func.__code__.co_filename) == [\n", + " \"if x % 2 == 0:\", \n", + " \"return \\\"even\\\"\", \n", + " \"return \\\"odd\\\"\",\n", + " ]\n", + " \n", + " assert get_executed_lines(data, test.__code__.co_filename) == [\n", + " \"assert func(0) == \\\"even\\\"\",\n", + " \"assert func(1) == \\\"odd\\\"\",\n", + " \"assert func(2) == \\\"even\\\"\",\n", + " \"assert func(3) == \\\"odd\\\"\",\n", + " ]\n", + "\n", + "\n", + "def delete_generated_files():\n", + " Path(\".coverage\").unlink(missing_ok=True)\n", + " Path(\"coverage.json\").unlink(missing_ok=True)\n", + "\n", "def func(x):\n", " if x % 2 == 0:\n", " return \"even\"\n", "\n", " else:\n", - " return \"odd\"\n", - "\n", - "\n", - "# delete generated files\n", - "Path(\".coverage\").unlink(missing_ok=True)\n", - "Path(\"coverage.json\").unlink(missing_ok=True)" + " return \"odd\"" + ] + }, + { + "cell_type": "markdown", + "id": "84a35ab0-24c0-492b-8338-598029e0d7e1", + "metadata": {}, + "source": [ + "# without branch coverage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5083c7e5-05a5-4e5e-8149-8d2897180bbf", + "metadata": {}, + "outputs": [], + "source": [ + "delete_generated_files()" ] }, { "cell_type": "code", "execution_count": null, - "id": "f82fbdcb-2460-4f14-9ee5-c69571b4acfd", + "id": "6003ecec-0ee8-4a95-bec2-c12cfa4ceb31", "metadata": {}, "outputs": [], "source": [ @@ -77,34 +101,61 @@ { "cell_type": "code", "execution_count": null, - "id": "e8919526-cff6-4d8b-924b-bb313c4d8484", + "id": "5b4e226a-8170-4830-88b3-90fe8fe4fd2a", "metadata": {}, "outputs": [], "source": [ - "assert Path(\".coverage\").exists()\n", - "assert Path(\"coverage.json\").exists()\n", - "\n", - "with open(\"coverage.json\", \"rt\") as fobj:\n", - " data = json.load(fobj)\n", - "\n", - "assert get_executed_lines(data, func.__code__.co_filename) == [\n", - " \"if x % 2 == 0:\", \n", - " \"return \\\"even\\\"\", \n", - " \"return \\\"odd\\\"\",\n", - "]\n", + "check_coverage()" + ] + }, + { + "cell_type": "markdown", + "id": "52973cee-3ae9-498b-bd83-10fd053331ce", + "metadata": {}, + "source": [ + "# with branch coverage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d91378c5-ff72-41f7-a457-1517e3c6fb87", + "metadata": {}, + "outputs": [], + "source": [ + "delete_generated_files()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d46f99a2-97e7-4493-899d-13e66ef82d34", + "metadata": {}, + "outputs": [], + "source": [ + "%%ipytest --cov --cov-branch --cov-report=json\n", "\n", - "assert get_executed_lines(data, test.__code__.co_filename) == [\n", - " \"assert func(0) == \\\"even\\\"\",\n", - " \"assert func(1) == \\\"odd\\\"\",\n", - " \"assert func(2) == \\\"even\\\"\",\n", - " \"assert func(3) == \\\"odd\\\"\",\n", - "]" + "def test():\n", + " assert func(0) == \"even\"\n", + " assert func(1) == \"odd\"\n", + " assert func(2) == \"even\"\n", + " assert func(3) == \"odd\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e411051-a6b7-4b65-b2fc-1d6ddc9874f8", + "metadata": {}, + "outputs": [], + "source": [ + "check_coverage()" ] }, { "cell_type": "code", "execution_count": null, - "id": "db0a0b1c-d7bc-4550-8033-f0f569b8cdad", + "id": "d74022a8-4fae-45e3-8684-be4bd8093fde", "metadata": {}, "outputs": [], "source": [] diff --git a/tests/TestCoverageWithConfig/.coveragerc b/tests/TestCoverageWithConfig/.coveragerc new file mode 100644 index 0000000..35f472c --- /dev/null +++ b/tests/TestCoverageWithConfig/.coveragerc @@ -0,0 +1,3 @@ +[run] +plugins = + ipytest.cov diff --git a/tests/TestCoverageWithConfig/TestCoverage.ipynb b/tests/TestCoverageWithConfig/TestCoverage.ipynb new file mode 100644 index 0000000..7ee21fb --- /dev/null +++ b/tests/TestCoverageWithConfig/TestCoverage.ipynb @@ -0,0 +1,172 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bf900d0d-7eba-4ca1-b738-a64d55f82f61", + "metadata": {}, + "source": [ + "Note: this notebook requires a `.coveragerc` file next to it with the following content:\n", + "\n", + "```ini\n", + "[run]\n", + "plugins =\n", + " ipytest.cov\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bdf258d0-b8b7-4fbb-96c7-664d120c4abc", + "metadata": {}, + "outputs": [], + "source": [ + "import ipytest\n", + "ipytest.autoconfig()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc478ce9-6551-4332-9d21-b9a50f455d1d", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import linecache\n", + "\n", + "from pathlib import Path\n", + "\n", + "\n", + "def get_executed_lines(data, filename):\n", + " return [\n", + " linecache.getline(filename, line).strip()\n", + " for line in sorted(data[\"files\"][filename][\"executed_lines\"])\n", + " ]\n", + "\n", + "\n", + "def check_coverage():\n", + " assert Path(\".coverage\").exists()\n", + " assert Path(\"coverage.json\").exists()\n", + " \n", + " with open(\"coverage.json\", \"rt\") as fobj:\n", + " data = json.load(fobj)\n", + " \n", + " assert get_executed_lines(data, func.__code__.co_filename) == [\n", + " \"if x % 2 == 0:\", \n", + " \"return \\\"even\\\"\", \n", + " \"return \\\"odd\\\"\",\n", + " ]\n", + " \n", + " assert get_executed_lines(data, test.__code__.co_filename) == [\n", + " \"assert func(0) == \\\"even\\\"\",\n", + " \"assert func(1) == \\\"odd\\\"\",\n", + " \"assert func(2) == \\\"even\\\"\",\n", + " \"assert func(3) == \\\"odd\\\"\",\n", + " ]\n", + "\n", + "\n", + "def func(x):\n", + " if x % 2 == 0:\n", + " return \"even\"\n", + "\n", + " else:\n", + " return \"odd\"\n", + "\n", + "\n", + "# delete generated files\n", + "Path(\".coverage\").unlink(missing_ok=True)\n", + "Path(\"coverage.json\").unlink(missing_ok=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f82fbdcb-2460-4f14-9ee5-c69571b4acfd", + "metadata": {}, + "outputs": [], + "source": [ + "%%ipytest --cov --cov-report=json\n", + "\n", + "def test():\n", + " assert func(0) == \"even\"\n", + " assert func(1) == \"odd\"\n", + " assert func(2) == \"even\"\n", + " assert func(3) == \"odd\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e8919526-cff6-4d8b-924b-bb313c4d8484", + "metadata": {}, + "outputs": [], + "source": [ + "check_coverage()" + ] + }, + { + "cell_type": "markdown", + "id": "8f29c4bd-7bc4-4e72-a27a-e5b2fd52b8af", + "metadata": {}, + "source": [ + "# Branch coverage also works " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f063c773-a9c9-4c65-9bed-75095d3f1891", + "metadata": {}, + "outputs": [], + "source": [ + "%%ipytest --cov --cov-branch --cov-report=json\n", + "\n", + "def test():\n", + " assert func(0) == \"even\"\n", + " assert func(1) == \"odd\"\n", + " assert func(2) == \"even\"\n", + " assert func(3) == \"odd\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8a01bbcd-8569-4e49-b89f-82e575824e8a", + "metadata": {}, + "outputs": [], + "source": [ + "check_coverage()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8cd681fc-745d-438f-8cbf-968543d3afc9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/TestCoverageWithConfig/TestCoverageWarning.ipynb b/tests/TestCoverageWithConfig/TestCoverageWarning.ipynb new file mode 100644 index 0000000..fd56fd5 --- /dev/null +++ b/tests/TestCoverageWithConfig/TestCoverageWarning.ipynb @@ -0,0 +1,65 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "12d3268b-7d27-4ac9-b21a-fdd6f825d0bf", + "metadata": {}, + "source": [ + "**Note:** This notebook only tests that the warning with `coverage=True` and an existing config files runs without issue" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bdf258d0-b8b7-4fbb-96c7-664d120c4abc", + "metadata": {}, + "outputs": [], + "source": [ + "import ipytest\n", + "ipytest.autoconfig(coverage=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f82fbdcb-2460-4f14-9ee5-c69571b4acfd", + "metadata": {}, + "outputs": [], + "source": [ + "%%ipytest\n", + "\n", + "def test():\n", + " pass" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8cd681fc-745d-438f-8cbf-968543d3afc9", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/test_ipytest_cov.py b/tests/test_ipytest_cov.py new file mode 100644 index 0000000..eb58334 --- /dev/null +++ b/tests/test_ipytest_cov.py @@ -0,0 +1,31 @@ +import pytest + +from ipytest._impl import find_coverage_configs + + +@pytest.mark.parametrize( + "files, expected", + [ + pytest.param({".coveragerc": ""}, [".coveragerc"]), + pytest.param({"setup.cfg": ""}, []), + pytest.param({"setup.cfg": "[coverage:"}, ["setup.cfg"]), + pytest.param({"tox.ini": ""}, []), + pytest.param({"tox.ini": "[coverage:"}, ["tox.ini"]), + pytest.param({"pyproject.toml": ""}, []), + pytest.param({"pyproject.toml": "[tool.coverage"}, ["pyproject.toml"]), + pytest.param( + { + ".coveragerc": "", + "setup.cfg": "[coverage:", + "pyproject.toml": "[tool.coverage", + "tox.ini": "[coverage:", + }, + [".coveragerc", "setup.cfg", "tox.ini", "pyproject.toml"], + ), + ], +) +def test_find_coverage_configs(tmp_path, files, expected): + for name, content in files.items(): + tmp_path.joinpath(name).write_text(content) + + assert [p.name for p in find_coverage_configs(tmp_path)] == expected