Skip to content

Commit 5ab6b79

Browse files
committed
Use a pyparsing PEG parser rather than a load of regexes, even if it means monkeypatching part of coverage.py
1 parent 61f7695 commit 5ab6b79

18 files changed

+618
-564
lines changed

.bumpversion.cfg

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
[bumpversion]
2-
current_version = 0.0.6
2+
current_version = 0.2.0
33
commit = True
44
tag = True
55

6+
[bumpversion:file:pyproject.toml]
7+
8+
[bumpversion:file:repo_helper.yml]
9+
610
[bumpversion:file:__pkginfo__.py]
711

812
[bumpversion:file:README.rst]
913

1014
[bumpversion:file:doc-source/index.rst]
1115

12-
[bumpversion:file:repo_helper.yml]
13-
1416
[bumpversion:file:coverage_pyver_pragma/__init__.py]

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ coverage_pyver_pragma
101101
.. |language| image:: https://img.shields.io/github/languages/top/domdfcoding/coverage_pyver_pragma
102102
:alt: GitHub top language
103103

104-
.. |commits-since| image:: https://img.shields.io/github/commits-since/domdfcoding/coverage_pyver_pragma/v0.0.6
104+
.. |commits-since| image:: https://img.shields.io/github/commits-since/domdfcoding/coverage_pyver_pragma/v0.2.0
105105
:target: https://github.com/domdfcoding/coverage_pyver_pragma/pulse
106106
:alt: GitHub commits since tagged version
107107

__pkginfo__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@
2626
2020-2021 Dominic Davis-Foster <[email protected]>
2727
"""
2828

29-
__version__ = "0.0.6"
30-
29+
__version__ = "0.2.0"
3130
repo_root = pathlib.Path(__file__).parent
3231
install_requires = (repo_root / "requirements.txt").read_text(encoding="utf-8").split('\n')
3332
extras_require = {"all": []}

cov-report.ini

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[run]
2+
plugins = coverage_pyver_pragma
3+
4+
[report]
5+
fail_under = 80
6+
exclude_lines =
7+
raise AssertionError
8+
raise NotImplementedError
9+
if 0:
10+
if False:
11+
if TYPE_CHECKING:
12+
if typing.TYPE_CHECKING:
13+
if __name__ == .__main__.:

coverage_pyver_pragma/__init__.py

Lines changed: 41 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
#
33
# __init__.py
44
"""
5-
Plugin for `Coverage.py <https://coverage.readthedocs.io/en/coverage-5.3/>`_
6-
to selectively ignore branches depending on the Python version.
7-
""" # noqa: D400
5+
Plugin for Coverage.py to selectively ignore branches depending on the Python version.
6+
"""
87
#
98
# Copyright © 2020-2021 Dominic Davis-Foster <[email protected]>
109
#
@@ -28,175 +27,74 @@
2827
#
2928

3029
# stdlib
31-
import platform
3230
import re
33-
import sys
34-
from typing import TYPE_CHECKING, Any, List, NamedTuple, Pattern, Tuple, Union
31+
from contextlib import suppress
3532

3633
# 3rd party
37-
import coverage # type: ignore
38-
39-
if TYPE_CHECKING:
34+
import coverage.python # type: ignore
35+
import pyparsing # type: ignore
36+
from coverage.config import DEFAULT_EXCLUDE # type: ignore
37+
from coverage.misc import join_regex # type: ignore
4038

41-
# stdlib
42-
from sys import _version_info as VersionInfo # pragma: no cover (typing only)
39+
# this package
40+
from coverage_pyver_pragma.grammar import GRAMMAR
4341

4442
__author__: str = "Dominic Davis-Foster"
4543
__copyright__: str = "2020-2021 Dominic Davis-Foster"
4644
__license__: str = "MIT License"
47-
__version__: str = "0.0.6"
45+
__version__: str = "0.2.0"
4846
__email__: str = "[email protected]"
4947

50-
__all__ = [
51-
"regex_main",
52-
"Version",
53-
"make_regexes",
54-
"PyVerPragmaPlugin",
55-
"make_not_exclude_regexs",
56-
"coverage_init",
57-
]
58-
59-
regex_main: str = re.compile(r"(?i)#\s*pragma[:\s]?\s*no\s*cover").pattern
60-
48+
__all__ = ["DSL_EXCLUDE", "evaluate_exclude"]
6149

62-
class Version(NamedTuple):
63-
"""
64-
:class:`~typing.NamedTuple` with the same elements as :py:data:`sys.version_info`.
65-
"""
50+
DSL_EXCLUDE = re.compile(r'.*#\s*(?:pragma|PRAGMA)[:\s]?\s*(?:no|NO)\s*(?:cover|COVER)\s*\((.*)\)')
51+
"""
52+
Compiled regular expression to match comments in the for ``# pragma: no cover (XXX)``,
53+
where ``XXX`` is an expression to be evaluated to determine whether the line should be excluded from coverage.
6654
67-
major: int
68-
minor: int
69-
micro: int = 0
70-
releaselevel: str = ''
71-
serial: str = ''
55+
.. versionadded:: 0.2.0
56+
"""
7257

7358

74-
def make_regexes(
75-
version_tuple: Union["Version", "VersionInfo", Tuple[int, ...]],
76-
current_platform: str,
77-
current_implementation: str,
78-
) -> List[Pattern]:
59+
def evaluate_exclude(expression: str) -> bool:
7960
"""
80-
Generates a list of regular expressions to match all valid ignores for the given Python version.
61+
Evaluate the given expression to determine whether the line should be excluded from coverage.
8162
82-
:param version_tuple: The Python version.
83-
:type version_tuple: :class:`~typing.NamedTuple` with the attributes ``major`` and ``minor``
84-
:param current_platform:
85-
:param current_implementation:
63+
.. versionadded:: 0.2.0
8664
87-
:return: List of regular expressions.
65+
:param expression:
8866
"""
8967

90-
version_tuple = Version(*version_tuple)
91-
92-
if version_tuple.major == 3:
93-
# Python 3.X
94-
95-
greater_than_versions = [str(x) for x in range(0, version_tuple.minor)]
96-
greater_equal_versions = [*greater_than_versions, str(version_tuple.minor)]
97-
less_than_versions = [str(x) for x in range(version_tuple.minor + 1, 10)]
98-
# Current max Python version is 3.9
99-
less_equal_versions = [str(version_tuple.minor), *less_than_versions] # pragma: no cover (!Linux)
100-
exact_versions = [str(version_tuple.minor)]
101-
102-
wrong_platforms_string = fr"(?!.*!{current_platform})" # (?!.*Windows)(?!.*Darwin)
103-
wrong_implementations_string = fr"(?!.*!{current_implementation})" # (?!.*Windows)(?!.*Darwin)
104-
# correct_platforms_string = r"(?=\s*(Linux)?)"
105-
106-
# Add regular expressions for relevant python versions
107-
# We do it with re.compile to get the syntax highlighting in PyCharm
108-
excludes = [
109-
re.compile(
110-
fr"{regex_main}\s*\((?=\s*<py3({'|'.join(less_than_versions)})){wrong_platforms_string}{wrong_implementations_string}.*\)"
111-
),
112-
re.compile(
113-
fr"{regex_main}\s*\((?=\s*<=py3({'|'.join(less_equal_versions)})){wrong_platforms_string}{wrong_implementations_string}.*\)"
114-
),
115-
re.compile(
116-
fr"{regex_main}\s*\((?=\s*>py3({'|'.join(greater_than_versions)})){wrong_platforms_string}{wrong_implementations_string}.*\)"
117-
),
118-
re.compile(
119-
fr"{regex_main}\s*\((?=\s*py3({'|'.join(greater_equal_versions)})\+){wrong_platforms_string}{wrong_implementations_string}.*\)"
120-
),
121-
re.compile(
122-
fr"{regex_main}\s*\((?=\s*>=py3({'|'.join(greater_equal_versions)})){wrong_platforms_string}{wrong_implementations_string}.*\)"
123-
),
124-
re.compile(
125-
fr"{regex_main}\s*\((?=\s*py3({'|'.join(exact_versions)})){wrong_platforms_string}{wrong_implementations_string}.*\)"
126-
),
127-
re.compile(
128-
fr"{regex_main}\s*\((?!.*py[0-9]+){wrong_platforms_string}{wrong_implementations_string}.*\)"
129-
),
130-
]
131-
132-
# print(excludes)
133-
return excludes
134-
135-
else:
136-
raise ValueError("Unknown Python version.")
137-
138-
139-
class PyVerPragmaPlugin(coverage.CoveragePlugin):
140-
"""
141-
Plugin for `Coverage.py <https://coverage.readthedocs.io/en/coverage-5.3/>`_
142-
to selectively ignore branches depending on the Python version.
143-
""" # noqa: D400
144-
145-
def configure(self, config: Any) -> None:
146-
"""
147-
Configure the plugin.
148-
149-
:param config:
150-
"""
151-
152-
# Coverage.py gives either a Coverage() object, or a CoverageConfig() object.
153-
if isinstance(config, coverage.Coverage):
154-
config = config.config # pragma: no cover
68+
return all(list(GRAMMAR.parseString(expression.lower(), parseAll=True)))
15569

156-
# Remove standard "pragma: no cover" regex
157-
if regex_main in config.exclude_list:
158-
config.exclude_list.remove(regex_main)
15970

160-
if "pragma: no cover" in config.exclude_list:
161-
config.exclude_list.remove("pragma: no cover")
71+
class PythonParser(coverage.python.PythonParser): # noqa: D102
16272

163-
excludes = make_regexes(sys.version_info, platform.system(), platform.python_implementation())
164-
for exc_pattern in excludes:
165-
config.exclude_list.append(exc_pattern.pattern)
73+
def lines_matching(self, *regexes): # noqa: D102
16674

167-
# Reinstate the general regex, but making sure it isn't followed by a left bracket.
168-
config.exclude_list += [
169-
p.pattern for p in make_not_exclude_regexs(platform.system(), platform.python_implementation())
170-
]
75+
combined = join_regex([*regexes, *DEFAULT_EXCLUDE])
17176

172-
# TODO: Python 4.X
77+
regex_c = re.compile(combined)
78+
matches = set()
17379

80+
for idx, ltext in enumerate(self.lines, start=1):
17481

175-
def make_not_exclude_regexs(
176-
current_platform: str,
177-
current_implementation: str,
178-
) -> List[Pattern]:
179-
"""
180-
Generates a list of regular expressions for lines that should not be excluded.
82+
dsl_m = DSL_EXCLUDE.match(ltext)
18183

182-
:param current_platform:
183-
:param current_implementation:
184-
"""
84+
# Check if it matches the DSL regex:
85+
if dsl_m:
86+
exclude_source = dsl_m.group(1)
18587

186-
return [
187-
re.compile(
188-
fr"{regex_main} (?!\(.*(.{{0,2}}py3\d(\+)?|!{current_platform}|!{current_implementation}).*\)).*$"
189-
),
190-
re.compile(fr"{regex_main}$"),
191-
]
88+
with suppress(pyparsing.ParseBaseException):
89+
if evaluate_exclude(exclude_source):
90+
matches.add(idx)
91+
continue
19292

93+
if regex_c.search(ltext):
94+
matches.add(idx)
19395

194-
def coverage_init(reg, options):
195-
"""
196-
Initialise the plugin.
96+
return matches
19797

198-
:param reg:
199-
:param options:
200-
"""
20198

202-
reg.add_configurer(PyVerPragmaPlugin()) # pragma: no cover
99+
def coverage_init(*args, **kwargs):
100+
coverage.python.PythonParser.lines_matching = PythonParser.lines_matching

0 commit comments

Comments
 (0)