Skip to content

Commit

Permalink
Add support for omitting entire modules (#221)
Browse files Browse the repository at this point in the history
* Add support for omitting entire modules

This commit will enable entire modules to be conditionally omitted from
test coverage measurement. Modules will be omitted using the coverage.py
`[run] omit` configuration setting.

https://coverage.readthedocs.io/en/stable/config.html#run-omit
https://coverage.readthedocs.io/en/stable/source.html

* Update coverage_conditional_plugin/__init__.py

* Resolve gitpython vulnerability

* Finish the omit implementation

* Fix CI

---------

Co-authored-by: Nikita Sobolev <mail@sobolevn.me>
  • Loading branch information
br3ndonland and sobolevn committed Jun 2, 2023
1 parent 0d8287f commit 2c70523
Show file tree
Hide file tree
Showing 16 changed files with 796 additions and 699 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']

steps:
- uses: actions/checkout@v3
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

We follow [Semantic Versions](https://semver.org/).


## Version 0.9.0

### Features

- Adds `python@3.11` support
- Now, only `coverage@7` is officially supported
- We can now omit whole modules,
using `[tool.coverage.coverage_conditional_plugin.omit]` feature
in TOML configuration files


## Version 0.8.0

### Features
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ plugins =
coverage_conditional_plugin

[coverage:coverage_conditional_plugin]
# Here we specify files to conditionally omit:
omit =
"sys_platform == 'win32'":
"my_project/omit*.py"
"my_project/win.py"
# Here we specify our pragma rules:
rules =
"sys_version_info >= (3, 8)": py-gte-38
Expand All @@ -46,11 +51,16 @@ rules =
```

Or to your `pyproject.toml`:

```toml
[tool.coverage.run]
# Here we specify plugins for coverage to be used:
plugins = ["coverage_conditional_plugin"]

[tool.coverage.coverage_conditional_plugin.omit]
# Here we specify files to conditionally omit:
"my_project/omit*.py" = "sys_platform == 'win32'"

[tool.coverage.coverage_conditional_plugin.rules]
# Here we specify our pragma rules:
py-gte-38 = "sys_version_info >= (3, 8)"
Expand Down Expand Up @@ -128,6 +138,27 @@ get_env_info()
```


## Writing omits

Omits allow entire files to be conditionally omitted from coverage measurement.

The TOML format for omits is:

```toml
[tool.coverage.coverage_conditional_plugin.omit]
"pragma-condition" = ["project/prefix*.py", "project/filename.py"]
# or
"pragma-condition" = "project/filename.py"
```

**Note**: `ini` format is not supported for `omit` configuration option,
because there's no easy way to parse `ini` complex configuration.
PRs with the fix are welcome!

File name patterns should follow coverage.py's `[run] omit` syntax.
See [coverage.py](https://coverage.readthedocs.io/en/stable/source.html).


## License

[MIT](https://github.com/wemake.services/coverage-conditional-plugin/blob/master/LICENSE)
144 changes: 98 additions & 46 deletions coverage_conditional_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@
import sys
import traceback
from importlib import import_module
from typing import ClassVar, Dict, Iterable, Tuple, Union
from typing import Any, ClassVar, Dict, List, Tuple, Union

from coverage import CoveragePlugin
from coverage.config import CoverageConfig
from packaging.markers import default_environment

from coverage_conditional_plugin.version import package_version

#: Used for `omit` specification.
_OmitConfigSpec = Dict[str, Union[str, List[str]]]

_INI_OMIT_ERROR = (
'Improperly configured: `ini` does not ' +
'support `omit` specification, ' +
'current setting is: {0}'
)


def get_env_info() -> Dict[str, object]:
"""Public helper to get the same env we pass to the plugin."""
Expand All @@ -29,24 +38,49 @@ def get_env_info() -> Dict[str, object]:

class _ConditionalCovPlugin(CoveragePlugin):
_rules_opt_name: ClassVar[str] = 'coverage_conditional_plugin:rules'
# We use `exlude_line` and not `exclude_also`,
# because settings are already post-processed, which means that
# `exlude_line` and `exclude_also` are already joined:
_ignore_opt_name: ClassVar[str] = 'report:exclude_lines'
_omit_opt_name_plugin: ClassVar[str] = 'coverage_conditional_plugin:omit'
_omit_opt_name_coverage: ClassVar[str] = 'run:omit'

def configure(self, config: CoverageConfig) -> None:
def configure( # type: ignore[override]
self, config: CoverageConfig,
) -> None:
"""
Main hook for adding extra configuration.
Part of the ``coverage`` public API.
Called right after ``coverage_init`` function.
"""
rules: Iterable[str]

self._configure_omits(config)
self._configure_rules(config)

def _configure_omits(self, config: CoverageConfig) -> None:
omits: Union[str, _OmitConfigSpec, None] = _get_option(
config,
self._omit_opt_name_plugin,
)
if omits is None:
return # No setting, ignoring
elif not isinstance(omits, dict):
raise RuntimeError(_INI_OMIT_ERROR.format(omits))

for code, patterns in omits.items():
if isinstance(patterns, str):
patterns = [patterns]
if _should_be_applied(code):
self._omit_pattern(config, patterns)

def _configure_rules(self, config: CoverageConfig) -> None:
try: # ini format
rules = filter(
bool,
config.get_option(self._rules_opt_name).splitlines(),
_get_option(config, self._rules_opt_name).splitlines(),
)
except AttributeError: # toml format
rules = config.get_option(self._rules_opt_name).items()
rules = _get_option(config, self._rules_opt_name).items()

for rule in rules:
self._process_rule(config, rule)
Expand All @@ -62,52 +96,25 @@ def _process_rule(
code = rule[1]
else:
raise ValueError("Invalid type for 'rule'.")
if self._should_be_applied(code):
if _should_be_applied(code):
self._ignore_marker(config, marker)

def _should_be_applied(self, code: str) -> bool:
"""
Determines whether some specific marker should be applied or not.
Uses ``exec`` on the code you pass with the marker.
Be sure, that this code is safe.
We also try to provide useful global functions
to cover the most popular cases, like:
- python version
- OS name, platform, and version
- helpers to work with installed packages
Some examples:
.. code:: ini
[coverage:coverage_conditional_plugin]
rules =
"sys_version_info >= (3, 8)": py-gte-38
"is_installed('mypy')": has-mypy
So, code marked with `# pragma: py-gte-38` will be ignored
for all version of Python prior to 3.8 release.
And at the same time,
this code will be included to the coverage on 3.8+ releases.
"""
env_info = get_env_info()
try:
return eval(code, env_info) # noqa: WPS421, S307
except Exception:
msg = 'Exception during conditional coverage evaluation:'
print(msg, traceback.format_exc()) # noqa: WPS421
return False

def _ignore_marker(self, config: CoverageConfig, marker: str) -> None:
def _ignore_marker(
self, config: CoverageConfig, marker: str,
) -> None:
"""Adds a marker to the ignore list."""
exclude_lines = config.get_option(self._ignore_opt_name)
exclude_lines = _get_option(config, self._ignore_opt_name)
exclude_lines.append(marker)
config.set_option(self._ignore_opt_name, exclude_lines)

def _omit_pattern(
self, config: CoverageConfig, patterns: List[str],
) -> None:
"""Adds a file name pattern to the omit list."""
omit_patterns = _get_option(config, self._omit_opt_name_coverage)
omit_patterns.extend(patterns)
config.set_option(self._omit_opt_name_coverage, omit_patterns)


def _is_installed(package: str) -> bool:
"""Helper function to detect if some package is installed."""
Expand All @@ -118,6 +125,51 @@ def _is_installed(package: str) -> bool:
return True


def _should_be_applied(code: str) -> bool:
"""
Determines whether some specific marker should be applied or not.
Uses ``exec`` on the code you pass with the marker.
Be sure, that this code is safe.
We also try to provide useful global functions
to cover the most popular cases, like:
- python version
- OS name, platform, and version
- helpers to work with installed packages
Some examples:
.. code:: ini
[coverage:coverage_conditional_plugin]
rules =
"sys_version_info >= (3, 8)": py-gte-38
"is_installed('mypy')": has-mypy
So, code marked with `# pragma: py-gte-38` will be ignored
for all version of Python prior to 3.8 release.
And at the same time,
this code will be included to the coverage on 3.8+ releases.
"""
env_info = get_env_info()
try:
return eval(code, env_info) # noqa: WPS421, S307
except Exception:
msg = 'Exception during conditional coverage evaluation:'
print(msg, traceback.format_exc()) # noqa: WPS421
return False


def _get_option( # type: ignore[misc]
config: CoverageConfig, option: str,
) -> Any:
# Hack to silence all new typing issues.
return config.get_option(option)


def coverage_init(reg, options) -> None:
"""
Entrypoint, part of the ``coverage`` API.
Expand Down

0 comments on commit 2c70523

Please sign in to comment.