Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a coverage plugin that handles ipykernel cells #110

Merged
merged 18 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ __pycache__/
.ipynb_checkpoints/
.pytest_cache/
.coverage
coverage.json

/.python-version
6 changes: 6 additions & 0 deletions Changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Note: development is tracked on the [`develop` branch](https://github.com/chmp/ipytest/tree/develop).

## `0.14.1`

- Add a [Coverage.py](https://coverage.readthedocs.io/en/latest/index.html)
plugin (`ipytest.cov`) to support collecting coverage information in
notebooks. See the Readme for usage notes.

## `0.14.0`

- Removed support for Python 3.7 after it reached its end of life
Expand Down
35 changes: 34 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ this case, using [`ipytest.run()`][ipytest.run] and
| [`clean`][ipytest.clean]
| [`force_reload`][ipytest.force_reload]
| [`Error`][ipytest.Error]
| [`ipytest.cov`](#ipytestcov)

<!-- minidoc "function": "ipytest.autoconfig", "header_depth": 3 -->
### `ipytest.autoconfig(rewrite_asserts=<default>, magics=<default>, clean=<default>, addopts=<default>, run_in_thread=<default>, defopts=<default>, display_columns=<default>, raise_on_error=<default>)`
Expand Down Expand Up @@ -304,6 +305,36 @@ Error raised by ipytest on test failure

<!-- minidoc -->

<!-- minidoc "module": "ipytest.cov", "header_depth": 3 -->
### `ipytest.cov`

A coverage.py plugin to support coverage in Jupyter notebooks

The plugin must be enabled in a `.coveragerc` next to the current notebook or
the `pyproject.toml` file. See the [coverage.py docs][coverage-py-config-docs]
for details. In case of a `.coveragerc` file, the minimal configuration reads:

```ini
[run]
plugins =
ipytest.cov
```

With this config file, the coverage can be collected using
[pytest-cov][ipytest-cov-pytest-cov] with

```pyhton
%%ipytest --cov

def test():
...
```

[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

<!-- minidoc -->

## Development

Setup a Python 3.10 virtual environment and install the requirements via
Expand Down Expand Up @@ -345,9 +376,11 @@ previous runs. These packages include:
While PyTest itself is generally supported, support for PyTest plugins depends
very much on the plugin. The following plugins are known to not work:

- [pytest-cov](https://github.com/chmp/ipytest/issues/88)
- [pytest-xdist](https://github.com/chmp/ipytest/issues/90)

See [`ipytest.cov`](#ipytestcov) on how to use `ipytest` with
[pytest-cov](https://pytest-cov.readthedocs.io/en/latest/config.html).

Please create an issue, if I missed a packaged or mischaracterized any package.

## License
Expand Down
2 changes: 1 addition & 1 deletion ipytest/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def _fmt(arg):
if defopts == "auto":
defopts = eval_defopts_auto(all_args, arg_mapping)

return [*all_args, *([filename] if defopts else [])]
return [*all_args, *(["--", filename] if defopts else [])]


class ArgMapping(dict):
Expand Down
106 changes: 106 additions & 0 deletions ipytest/cov.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""A coverage.py plugin to support coverage in Jupyter notebooks

The plugin must be enabled in a `.coveragerc` next to the current notebook or
the `pyproject.toml` file. See the [coverage.py docs][coverage-py-config-docs]
for details. In case of a `.coveragerc` file, the minimal configuration reads:

```ini
[run]
plugins =
ipytest.cov
```

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The %%file cell magic can be used to create files like .coveragerc from a notebook.

%%file .coveragerc
[run]
plugins =
    ipytest.cov

Would an ipytest.generate_coveragerc() or similar be too much? or Just autoconfig(coverage=True) or something?

Copy link
Owner Author

@chmp chmp Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH. I would rather prefer, that there is an option to configure pytest-cov programatically and inject the plugin in that way. I will open an issue for further discussion.

With this config file, the coverage can be collected using
[pytest-cov][ipytest-cov-pytest-cov] with

```pyhton
chmp marked this conversation as resolved.
Show resolved Hide resolved
%%ipytest --cov

def test():
...
```

[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
"""
import linecache
import os
import os.path
import re

import coverage.parser
import coverage.plugin
import coverage.python

# prevent the definitions from being documented in the readme
__all__ = []


def coverage_init(reg, options):
reg.add_file_tracer(IPythonPlugin())


class IPythonPlugin(coverage.plugin.CoveragePlugin):
def __init__(self):
self._filename_pattern = self._build_filename_pattern()

@classmethod
def _build_filename_pattern(self):
try:
import ipykernel.compiler

except ImportError:
return None

else:
return re.compile(
r"^"
+ re.escape(ipykernel.compiler.get_tmp_directory())
+ re.escape(os.sep)
+ r"\d+.py"
)

def file_tracer(self, filename):
if not self._is_ipython_cell_file(filename):
return None

return IPythonFileTracer(filename)

def file_reporter(self, filename):
return IPythonFileReporter(filename)

def _is_ipython_cell_file(self, filename: str):
if self._filename_pattern is None:
return False

if os.path.exists(filename):
return False

if self._filename_pattern.match(filename) is None:
return False

return filename in linecache.cache


class IPythonFileTracer(coverage.plugin.FileTracer):
def __init__(self, filename):
self._filename = filename

def source_filename(self):
return self._filename


class IPythonFileReporter(coverage.python.PythonFileReporter):
@property
def parser(self):
if self._parser is None:
self._parser = coverage.parser.PythonParser(text=self.source())
self._parser.parse_source()

return self._parser

def source(self):
if self.filename not in linecache.cache:
raise ValueError()

return "".join(linecache.cache[self.filename][2])
Loading
Loading