Repository: asottile/covdefaults

Issue:
version specific coverage pragmas
might be a fun little regex game, but it would be nice to support `# pragma: >=3.7 no cover` or some such

Discussion:

Patch:
diff --git a/README.md b/README.md
index 281cd5a..4b25ec2 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,13 @@ exclude_lines =
     if __name__ == ['"]__main__['"]:$
 
     # additional platform related pragmas (see below)
+    # additional version related pragmas (see below)
+partial_branches =
+    # a more strict default pragma
+    \# pragma: no cover\b
+
+    # our version pragmas
+    \# pragma: (>=?|<=?|==|!=)\d+\.\d+ cover\b'
 ```
 
 ### platform specific `# pragma: no cover`
@@ -123,6 +130,28 @@ note here that `# pragma: win32 cover` will become a "no cover" for everything
 which is not `win32` -- whereas the `# pragma: win32 no cover` will be a
 "no cover" only on `win32`.
 
+### version specific `# pragma: no cover`
+
+several `# pragma: no cover` tags will be added automatically based on the
+platform and implementation.
+
+these will be in the form of:
+
+```python
+# pragma: >=#.# cover
+```
+
+where the comparison operator is one of `>`, `>=`, `<`, `<=`, `==`, `!=`
+
+for example:
+
+```python
+if sys.version_info >= (3, 9):  # pragma: >=3.9 cover
+    print('3.9+')
+else:  # pragma: <3.9 cover
+    print('old')
+```
+
 ### overriding options
 
 several of the options can be overridden / extended in your coverage
diff --git a/covdefaults.py b/covdefaults.py
index 981a9eb..cd863c5 100644
--- a/covdefaults.py
+++ b/covdefaults.py
@@ -27,6 +27,54 @@ def _plat_impl_pragmas() -> List[str]:
     return ret
 
 
+def _lt(n: int) -> str:
+    n_s = str(n)
+    digit = r'\d'
+
+    parts = [
+        f'{n_s[:i]}[0-{int(n_s[i]) - 1}]{len(n_s[i + 1:]) * digit}'
+        for i in range(len(n_s))
+        if n_s[i] != '0'
+    ]
+    if len(n_s) > 1:
+        parts.append(f'{digit}{{1,{len(n_s) - 1}}}')
+
+    return f'({"|".join(parts)})'
+
+
+def _gt(n: int) -> str:
+    n_s = str(n)
+    digit = r'\d'
+
+    parts = [
+        f'{n_s[:i]}[{int(n_s[i]) + 1}-9]{len(n_s[i + 1:]) * digit}'
+        for i in range(len(n_s))
+        if n_s[i] != '9'
+    ]
+    parts.append(f'{digit}{{{len(n_s) + 1},}}')
+
+    return f'({"|".join(parts)})'
+
+
+def _version_pragmas(
+        major: int = sys.version_info[0],
+        minor: int = sys.version_info[1],
+) -> List[str]:
+    return [
+        # <
+        fr'# pragma: <=?{_lt(major)}\.\d+ cover\b',
+        fr'# pragma: <=?{major}\.{_lt(minor)} cover\b',
+        fr'# pragma: <{major}\.{minor} cover\b',
+        # >
+        fr'# pragma: >=?{_gt(major)}\.\d+ cover\b',
+        fr'# pragma: >=?{major}\.{_gt(minor)} cover\b',
+        fr'# pragma: >{major}\.{minor} cover\b',
+        # != / ==
+        fr'# pragma: !={major}\.{minor} cover\b',
+        fr'# pragma: ==(?!{major}\.{minor})\d+\.\d+ cover\b',
+    ]
+
+
 OPTIONS: Tuple[Tuple[str, Any], ...] = (
     ('run:branch', True),
 
@@ -53,6 +101,15 @@ EXTEND = (
             # non-runnable code
             r'^if __name__ == [\'"]__main__[\'"]:$',
             *_plat_impl_pragmas(),
+            *_version_pragmas(),
+        ],
+    ),
+    (
+        'report:partial_branches',
+        [
+            r'# pragma: no branch\b',
+            # version specific no cover
+            r'# pragma: (>=?|<=?|==|!=)\d+\.\d+ cover\b',
         ],
     ),
 )


Test patch:
diff --git a/tests/covdefaults_test.py b/tests/covdefaults_test.py
index 05388c1..20fbb44 100644
--- a/tests/covdefaults_test.py
+++ b/tests/covdefaults_test.py
@@ -24,6 +24,89 @@ def test_plat_impl_pragmas():
         assert (c, pragma, no, cover) == ('#', 'pragma:', 'no', r'cover\b'), s
 
 
+def _matches_version_pragma(major, minor, s):
+    regexes = covdefaults._version_pragmas(major, minor)
+    return any(re.match(reg, s) for reg in regexes)
+
+
+@pytest.mark.parametrize(
+    ('s', 'expected'),
+    (
+        # <
+        ('# pragma: <2.7 cover', True),
+        ('# pragma: <3.6 cover', True),
+        ('# pragma: <3.7 cover', True),
+        ('# pragma: <3.8 cover', False),
+        ('# pragma: <3.10 cover', False),
+        # <=
+        ('# pragma: <=2.7 cover', True),
+        ('# pragma: <=3.6 cover', True),
+        ('# pragma: <=3.7 cover', False),
+        ('# pragma: <=3.8 cover', False),
+        ('# pragma: <=3.10 cover', False),
+        # >
+        ('# pragma: >2.7 cover', False),
+        ('# pragma: >3.6 cover', False),
+        ('# pragma: >3.7 cover', True),
+        ('# pragma: >3.8 cover', True),
+        ('# pragma: >3.10 cover', True),
+        # >=
+        ('# pragma: >=2.7 cover', False),
+        ('# pragma: >=3.6 cover', False),
+        ('# pragma: >=3.7 cover', False),
+        ('# pragma: >=3.8 cover', True),
+        ('# pragma: >=3.10 cover', True),
+        # ==
+        ('# pragma: ==3.6 cover', True),
+        ('# pragma: ==3.7 cover', False),
+        ('# pragma: ==3.8 cover', True),
+        # !=
+        ('# pragma: !=3.6 cover', False),
+        ('# pragma: !=3.7 cover', True),
+        ('# pragma: !=3.8 cover', False),
+    ),
+)
+def test_version_pragmas_py37(s, expected):
+    assert _matches_version_pragma(3, 7, s) == expected
+
+
+@pytest.mark.parametrize(
+    ('s', 'expected'),
+    (
+        # <
+        ('# pragma: <2.7 cover', True),
+        ('# pragma: <3.9 cover', True),
+        ('# pragma: <3.10 cover', True),
+        ('# pragma: <3.11 cover', False),
+        # <=
+        ('# pragma: <=2.7 cover', True),
+        ('# pragma: <=3.9 cover', True),
+        ('# pragma: <=3.10 cover', False),
+        ('# pragma: <=3.11 cover', False),
+        # >
+        ('# pragma: >2.7 cover', False),
+        ('# pragma: >3.9 cover', False),
+        ('# pragma: >3.10 cover', True),
+        ('# pragma: >3.11 cover', True),
+        # >=
+        ('# pragma: >=2.7 cover', False),
+        ('# pragma: >=3.9 cover', False),
+        ('# pragma: >=3.10 cover', False),
+        ('# pragma: >=3.11 cover', True),
+        # ==
+        ('# pragma: ==3.9 cover', True),
+        ('# pragma: ==3.10 cover', False),
+        ('# pragma: ==3.11 cover', True),
+        # !=
+        ('# pragma: !=3.9 cover', False),
+        ('# pragma: !=3.10 cover', True),
+        ('# pragma: !=3.11 cover', False),
+    ),
+)
+def test_version_pragmas_py310(s, expected):
+    assert _matches_version_pragma(3, 10, s) == expected
+
+
 @pytest.fixture
 def configured():
     cfg = CoverageConfig()
@@ -102,6 +185,26 @@ def test_excludes_lines(configured, src):
         raise AssertionError(f'no regex matched {src!r}')
 
 
+@pytest.mark.parametrize(
+    'src',
+    (
+        'if True:  # pragma: no branch\n',
+        'if sys.version_info >= (3, 9):  # pragma: >=3.9 cover\n',
+        'if sys.version_info > (3, 9):  # pragma: >3.9 cover\n',
+        'if sys.version_info <= (3, 9):  # pragma: <=3.9 cover\n',
+        'if sys.version_info < (3, 9):  # pragma: <3.9 cover\n',
+        'if sys.version_info == (3, 9):  # pragma: ==3.9 cover\n',
+        'if sys.version_info != (3, 9):  # pragma: !=3.9 cover\n',
+    ),
+)
+def test_partial_branches(configured, src):
+    for reg in configured.get_option('report:partial_branches'):
+        if any(re.search(reg, line) for line in src.splitlines()):
+            break
+    else:
+        raise AssertionError(f'no regex matched {src!r}')
+
+
 def test_extends_existing_exclude_lines():
     cfg = CoverageConfig()
     cfg.set_option('report:exclude_lines', ['^if MYPY:$'])