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 support for omitting entire modules #221

Merged
merged 5 commits into from
Jun 2, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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