diff --git a/.github/workflows/python-nightly.yml b/.github/workflows/python-nightly.yml index c2b38953d..9319af7b4 100644 --- a/.github/workflows/python-nightly.yml +++ b/.github/workflows/python-nightly.yml @@ -58,6 +58,7 @@ jobs: # https://launchpad.net/~deadsnakes/+archive/ubuntu/nightly/+packages - "3.12-dev" - "3.13-dev" + - "3.14-dev" # https://github.com/actions/setup-python#available-versions-of-pypy - "pypy-3.8-nightly" - "pypy-3.9-nightly" diff --git a/CHANGES.rst b/CHANGES.rst index 98f263de7..822b0fecd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -22,6 +22,36 @@ upgrading your version of coverage.py. .. scriv-start-here +.. _changes_7-5-2: + +Version 7.5.2 — 2024-05-24 +-------------------------- + +- Fix: nested matches of exclude patterns could exclude too much code, as + reported in `issue 1779`_. This is now fixed. + +- Changed: previously, coverage.py would consider a module docstring to be an + executable statement if it appeared after line 1 in the file, but not + executable if it was the first line. Now module docstrings are never counted + as executable statements. This can change coverage.py's count of the number + of statements in a file, which can slightly change the coverage percentage + reported. + +- In the HTML report, the filter term and "hide covered" checkbox settings are + remembered between viewings, thanks to `Daniel Diniz `_. + +- Python 3.13.0b1 is supported. + +- Fix: parsing error handling is improved to ensure bizarre source files are + handled gracefully, and to unblock oss-fuzz fuzzing, thanks to `Liam DeVoe + `_. Closes `issue 1787`_. + +.. _pull 1776: https://github.com/nedbat/coveragepy/pull/1776 +.. _issue 1779: https://github.com/nedbat/coveragepy/issues/1779 +.. _issue 1787: https://github.com/nedbat/coveragepy/issues/1787 +.. _pull 1788: https://github.com/nedbat/coveragepy/pull/1788 + + .. _changes_7-5-1: Version 7.5.1 — 2024-05-04 diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 809ad01a7..98230cd58 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -132,6 +132,7 @@ Latrice Wilgus Leonardo Pistone Lewis Gaul Lex Berezhny +Liam DeVoe Loïc Dachary Lorenzo Micò Louis Heredero diff --git a/README.rst b/README.rst index dabdc84fc..0c5e56a19 100644 --- a/README.rst +++ b/README.rst @@ -25,7 +25,7 @@ Coverage.py runs on these versions of Python: .. PYVERSIONS -* Python 3.8 through 3.12, and 3.13.0a6 and up. +* Python 3.8 through 3.12, and 3.13.0b1 and up. * PyPy3 versions 3.8 through 3.10. Documentation is on `Read the Docs`_. Code repository and issue tracker are on diff --git a/coverage/html.py b/coverage/html.py index fcb5ab5ed..51a7f9419 100644 --- a/coverage/html.py +++ b/coverage/html.py @@ -597,7 +597,7 @@ def write_index_page(self, index_page: IndexPage, **kwargs: str) -> str: "regions": index_page.summaries, "totals": index_page.totals, "noun": index_page.noun, - "column2": index_page.noun if index_page.noun != "file" else "", + "region_noun": index_page.noun if index_page.noun != "file" else "", "skip_covered": self.skip_covered, "skipped_covered_msg": skipped_covered_msg, "skipped_empty_msg": skipped_empty_msg, diff --git a/coverage/htmlfiles/coverage_html.js b/coverage/htmlfiles/coverage_html.js index 0a859a537..1face13de 100644 --- a/coverage/htmlfiles/coverage_html.js +++ b/coverage/htmlfiles/coverage_html.js @@ -125,6 +125,16 @@ coverage.assign_shortkeys = function () { // Create the events for the filter box. coverage.wire_up_filter = function () { + // Populate the filter and hide100 inputs if there are saved values for them. + const saved_filter_value = localStorage.getItem(coverage.FILTER_STORAGE); + if (saved_filter_value) { + document.getElementById("filter").value = saved_filter_value; + } + const saved_hide100_value = localStorage.getItem(coverage.HIDE100_STORAGE); + if (saved_hide100_value) { + document.getElementById("hide100").checked = JSON.parse(saved_hide100_value); + } + // Cache elements. const table = document.querySelector("table.index"); const table_body_rows = table.querySelectorAll("tbody tr"); @@ -138,8 +148,12 @@ coverage.wire_up_filter = function () { totals[totals.length - 1] = { "numer": 0, "denom": 0 }; // nosemgrep: eslint.detect-object-injection var text = document.getElementById("filter").value; + // Store filter value + localStorage.setItem(coverage.FILTER_STORAGE, text); const casefold = (text === text.toLowerCase()); const hide100 = document.getElementById("hide100").checked; + // Store hide value. + localStorage.setItem(coverage.HIDE100_STORAGE, JSON.stringify(hide100)); // Hide / show elements. table_body_rows.forEach(row => { @@ -240,6 +254,8 @@ coverage.wire_up_filter = function () { document.getElementById("filter").dispatchEvent(new Event("input")); document.getElementById("hide100").dispatchEvent(new Event("input")); }; +coverage.FILTER_STORAGE = "COVERAGE_FILTER_VALUE"; +coverage.HIDE100_STORAGE = "COVERAGE_HIDE100_VALUE"; // Set up the click-to-sort columns. coverage.wire_up_sorting = function () { diff --git a/coverage/htmlfiles/index.html b/coverage/htmlfiles/index.html index f75d18b43..e856011d8 100644 --- a/coverage/htmlfiles/index.html +++ b/coverage/htmlfiles/index.html @@ -31,7 +31,7 @@

{{ title|escape }}:

f - {% if column2 %} + {% if region_noun %} n {% endif %} s @@ -83,8 +83,8 @@

{# The title="" attr doesn't work in Safari. #} File - {% if column2 %} - {{ column2 }} + {% if region_noun %} + {{ region_noun }} {% endif %} statements missing @@ -100,7 +100,7 @@

{% for region in regions %} {{region.file}} - {% if column2 %} + {% if region_noun %} {{region.description}} {% endif %} {{region.nums.n_statements}} @@ -117,7 +117,7 @@

Total - {% if column2 %} + {% if region_noun %}   {% endif %} {{totals.n_statements}} diff --git a/coverage/misc.py b/coverage/misc.py index af28d7d56..93c9b61ba 100644 --- a/coverage/misc.py +++ b/coverage/misc.py @@ -13,7 +13,6 @@ import importlib import importlib.util import inspect -import locale import os import os.path import re @@ -22,7 +21,7 @@ from types import ModuleType from typing import ( - Any, IO, Iterable, Iterator, Mapping, NoReturn, Sequence, TypeVar, + Any, Iterable, Iterator, Mapping, NoReturn, Sequence, TypeVar, ) from coverage.exceptions import CoverageException @@ -156,18 +155,6 @@ def ensure_dir_for_file(path: str) -> None: ensure_dir(os.path.dirname(path)) -def output_encoding(outfile: IO[str] | None = None) -> str: - """Determine the encoding to use for output written to `outfile` or stdout.""" - if outfile is None: - outfile = sys.stdout - encoding = ( - getattr(outfile, "encoding", None) or - getattr(sys.__stdout__, "encoding", None) or - locale.getpreferredencoding() - ) - return encoding - - class Hasher: """Hashes Python data for fingerprinting.""" def __init__(self) -> None: diff --git a/coverage/parser.py b/coverage/parser.py index 7842b36c9..9c9ffbf0a 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -25,7 +25,7 @@ from coverage.bytecode import code_objects from coverage.debug import short_stack from coverage.exceptions import NoSource, NotPython -from coverage.misc import join_regex, nice_pair +from coverage.misc import nice_pair from coverage.phystokens import generate_tokens from coverage.types import TArc, TLineNo @@ -62,8 +62,8 @@ def __init__( self.exclude = exclude - # The text lines of the parsed code. - self.lines: list[str] = self.text.split("\n") + # The parsed AST of the text. + self._ast_root: ast.AST | None = None # The normalized line numbers of the statements in the code. Exclusions # are taken into account, and statements are adjusted to their first @@ -101,19 +101,16 @@ def __init__( self._all_arcs: set[TArc] | None = None self._missing_arc_fragments: TArcFragments | None = None - @functools.lru_cache() - def lines_matching(self, *regexes: str) -> set[TLineNo]: - """Find the lines matching one of a list of regexes. + def lines_matching(self, regex: str) -> set[TLineNo]: + """Find the lines matching a regex. - Returns a set of line numbers, the lines that contain a match for one - of the regexes in `regexes`. The entire line needn't match, just a - part of it. + Returns a set of line numbers, the lines that contain a match for + `regex`. The entire line needn't match, just a part of it. """ - combined = join_regex(regexes) - regex_c = re.compile(combined) + regex_c = re.compile(regex) matches = set() - for i, ltext in enumerate(self.lines, start=1): + for i, ltext in enumerate(self.text.split("\n"), start=1): if regex_c.search(ltext): matches.add(self._multiline.get(i, i)) return matches @@ -127,26 +124,18 @@ def _raw_parse(self) -> None: # Find lines which match an exclusion pattern. if self.exclude: self.raw_excluded = self.lines_matching(self.exclude) + self.excluded = set(self.raw_excluded) - # Tokenize, to find excluded suites, to find docstrings, and to find - # multi-line statements. - - # The last token seen. Start with INDENT to get module docstrings - prev_toktype: int = token.INDENT # The current number of indents. indent: int = 0 # An exclusion comment will exclude an entire clause at this indent. exclude_indent: int = 0 # Are we currently excluding lines? excluding: bool = False - # Are we excluding decorators now? - excluding_decorators: bool = False # The line number of the first line in a multi-line statement. first_line: int = 0 # Is the file empty? empty: bool = True - # Is this the first token on a line? - first_on_line: bool = True # Parenthesis (and bracket) nesting level. nesting: int = 0 @@ -162,42 +151,22 @@ def _raw_parse(self) -> None: indent += 1 elif toktype == token.DEDENT: indent -= 1 - elif toktype == token.NAME: - if ttext == "class": - # Class definitions look like branches in the bytecode, so - # we need to exclude them. The simplest way is to note the - # lines with the "class" keyword. - self.raw_classdefs.add(slineno) elif toktype == token.OP: if ttext == ":" and nesting == 0: should_exclude = ( - self.raw_excluded.intersection(range(first_line, elineno + 1)) - or excluding_decorators + self.excluded.intersection(range(first_line, elineno + 1)) ) if not excluding and should_exclude: # Start excluding a suite. We trigger off of the colon # token so that the #pragma comment will be recognized on # the same line as the colon. - self.raw_excluded.add(elineno) + self.excluded.add(elineno) exclude_indent = indent excluding = True - excluding_decorators = False - elif ttext == "@" and first_on_line: - # A decorator. - if elineno in self.raw_excluded: - excluding_decorators = True - if excluding_decorators: - self.raw_excluded.add(elineno) elif ttext in "([{": nesting += 1 elif ttext in ")]}": nesting -= 1 - elif toktype == token.STRING: - if prev_toktype == token.INDENT: - # Strings that are first on an indented line are docstrings. - # (a trick from trace.py in the stdlib.) This works for - # 99.9999% of cases. - self.raw_docstrings.update(range(slineno, elineno+1)) elif toktype == token.NEWLINE: if first_line and elineno != first_line: # We're at the end of a line, and we've ended on a @@ -206,7 +175,6 @@ def _raw_parse(self) -> None: for l in range(first_line, elineno+1): self._multiline[l] = first_line first_line = 0 - first_on_line = True if ttext.strip() and toktype != tokenize.COMMENT: # A non-white-space token. @@ -218,10 +186,7 @@ def _raw_parse(self) -> None: if excluding and indent <= exclude_indent: excluding = False if excluding: - self.raw_excluded.add(elineno) - first_on_line = False - - prev_toktype = toktype + self.excluded.add(elineno) # Find the starts of the executable statements. if not empty: @@ -234,6 +199,34 @@ def _raw_parse(self) -> None: if env.PYBEHAVIOR.module_firstline_1 and self._multiline: self._multiline[1] = min(self.raw_statements) + self.excluded = self.first_lines(self.excluded) + + # AST lets us find classes, docstrings, and decorator-affected + # functions and classes. + assert self._ast_root is not None + for node in ast.walk(self._ast_root): + # Find class definitions. + if isinstance(node, ast.ClassDef): + self.raw_classdefs.add(node.lineno) + # Find docstrings. + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef, ast.Module)): + if node.body: + first = node.body[0] + if ( + isinstance(first, ast.Expr) + and isinstance(first.value, ast.Constant) + and isinstance(first.value.value, str) + ): + self.raw_docstrings.update( + range(first.lineno, cast(int, first.end_lineno) + 1) + ) + # Exclusions carry from decorators and signatures to the bodies of + # functions and classes. + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + first_line = min((d.lineno for d in node.decorator_list), default=node.lineno) + if self.excluded.intersection(range(first_line, node.lineno + 1)): + self.excluded.update(range(first_line, cast(int, node.end_lineno) + 1)) + @functools.lru_cache(maxsize=1000) def first_line(self, lineno: TLineNo) -> TLineNo: """Return the first line number of the statement including `lineno`.""" @@ -268,6 +261,7 @@ def parse_source(self) -> None: """ try: + self._ast_root = ast.parse(self.text) self._raw_parse() except (tokenize.TokenError, IndentationError, SyntaxError) as err: if hasattr(err, "lineno"): @@ -279,8 +273,6 @@ def parse_source(self) -> None: f"{err.args[0]!r} at line {lineno}", ) from err - self.excluded = self.first_lines(self.raw_excluded) - ignore = self.excluded | self.raw_docstrings starts = self.raw_statements - ignore self.statements = self.first_lines(starts) - ignore @@ -303,7 +295,8 @@ def _analyze_ast(self) -> None: `_all_arcs` is the set of arcs in the code. """ - aaa = AstArcAnalyzer(self.text, self.raw_statements, self._multiline) + assert self._ast_root is not None + aaa = AstArcAnalyzer(self._ast_root, self.raw_statements, self._multiline) aaa.analyze() self._all_arcs = set() @@ -403,14 +396,9 @@ def __init__( self.code = code else: assert filename is not None - try: - self.code = compile(text, filename, "exec", dont_inherit=True) - except SyntaxError as synerr: - raise NotPython( - "Couldn't parse '%s' as Python source: '%s' at line %d" % ( - filename, synerr.msg, synerr.lineno or 0, - ), - ) from synerr + # We only get here if earlier ast parsing succeeded, so no need to + # catch errors. + self.code = compile(text, filename, "exec", dont_inherit=True) def child_parsers(self) -> Iterable[ByteParser]: """Iterate over all the code objects nested within this one. @@ -685,11 +673,11 @@ class AstArcAnalyzer: def __init__( self, - text: str, + root_node: ast.AST, statements: set[TLineNo], multiline: dict[TLineNo, TLineNo], ) -> None: - self.root_node = ast.parse(text) + self.root_node = root_node # TODO: I think this is happening in too many places. self.statements = {multiline.get(l, l) for l in statements} self.multiline = multiline diff --git a/coverage/python.py b/coverage/python.py index 089c27ea6..4ac241257 100644 --- a/coverage/python.py +++ b/coverage/python.py @@ -206,8 +206,10 @@ def translate_arcs(self, arcs: Iterable[TArc]) -> set[TArc]: def no_branch_lines(self) -> set[TLineNo]: assert self.coverage is not None no_branch = self.parser.lines_matching( - join_regex(self.coverage.config.partial_list), - join_regex(self.coverage.config.partial_always_list), + join_regex( + self.coverage.config.partial_list + + self.coverage.config.partial_always_list + ) ) return no_branch diff --git a/coverage/pytracer.py b/coverage/pytracer.py index 69d52c948..355810f94 100644 --- a/coverage/pytracer.py +++ b/coverage/pytracer.py @@ -166,12 +166,12 @@ def _trace( if event == "call": # Should we start a new context? if self.should_start_context and self.context is None: - context_maybe = self.should_start_context(frame) + context_maybe = self.should_start_context(frame) # pylint: disable=not-callable if context_maybe is not None: self.context = context_maybe started_context = True assert self.switch_context is not None - self.switch_context(self.context) + self.switch_context(self.context) # pylint: disable=not-callable else: started_context = False else: @@ -280,7 +280,7 @@ def _trace( if self.started_context: assert self.switch_context is not None self.context = None - self.switch_context(None) + self.switch_context(None) # pylint: disable=not-callable return self._cached_bound_method_trace def start(self) -> TTraceFn: diff --git a/coverage/version.py b/coverage/version.py index e38ac9e75..0c47cedea 100644 --- a/coverage/version.py +++ b/coverage/version.py @@ -8,7 +8,7 @@ # version_info: same semantics as sys.version_info. # _dev: the .devN suffix if any. -version_info = (7, 5, 1, "final", 0) +version_info = (7, 5, 2, "final", 0) _dev = 0 diff --git a/doc/branch.rst b/doc/branch.rst index f500287f8..a1a4e9d69 100644 --- a/doc/branch.rst +++ b/doc/branch.rst @@ -116,3 +116,16 @@ Here the while loop will never complete because the break will always be taken at some point. Coverage.py can't work that out on its own, but the "no branch" pragma indicates that the branch is known to be partial, and the line is not flagged. + +Generator expressions +===================== + +Generator expressions may also report partial branch coverage. Consider the +following example:: + + value = next(i in range(1)) + +While we might expect this line of code to be reported as covered, the +generator did not iterate until ``StopIteration`` is raised, the indication +that the loop is complete. This is another case +where adding ``# pragma: no branch`` may be desirable. diff --git a/doc/changes.rst b/doc/changes.rst index af39d0146..f097ff41a 100644 --- a/doc/changes.rst +++ b/doc/changes.rst @@ -845,10 +845,10 @@ Version 4.3.2 — 2017-01-16 would cause a "No data to report" error, as reported in `issue 549`_. This is now fixed; thanks, Loïc Dachary. -- If-statements can be optimized away during compilation, for example, `if 0:` - or `if __debug__:`. Coverage.py had problems properly understanding these - statements which existed in the source, but not in the compiled bytecode. - This problem, reported in `issue 522`_, is now fixed. +- If-statements can be optimized away during compilation, for example, + ``if 0:`` or ``if __debug__:``. Coverage.py had problems properly + understanding these statements which existed in the source, but not in the + compiled bytecode. This problem, reported in `issue 522`_, is now fixed. - If you specified ``--source`` as a directory, then coverage.py would look for importable Python files in that directory, and could identify ones that had diff --git a/doc/conf.py b/doc/conf.py index 9f718b548..e746730cc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -67,11 +67,11 @@ # @@@ editable copyright = "2009–2024, Ned Batchelder" # pylint: disable=redefined-builtin # The short X.Y.Z version. -version = "7.5.1" +version = "7.5.2" # The full version, including alpha/beta/rc tags. -release = "7.5.1" +release = "7.5.2" # The date of release, in "monthname day, year" format. -release_date = "May 4, 2024" +release_date = "May 24, 2024" # @@@ end rst_epilog = f""" diff --git a/doc/index.rst b/doc/index.rst index 4a49ed360..128d78c53 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -18,7 +18,7 @@ supported on: .. PYVERSIONS -* Python 3.8 through 3.12, and 3.13.0a6 and up. +* Python 3.8 through 3.12, and 3.13.0b1 and up. * PyPy3 versions 3.8 through 3.10. .. ifconfig:: prerelease diff --git a/doc/requirements.in b/doc/requirements.in index 3b00a4082..124b4a499 100644 --- a/doc/requirements.in +++ b/doc/requirements.in @@ -14,5 +14,6 @@ sphinx sphinx-autobuild sphinx_rtd_theme sphinx-code-tabs +sphinx-lint sphinxcontrib-restbuilder sphinxcontrib-spelling diff --git a/doc/requirements.pip b/doc/requirements.pip index d80cdcefe..f0b3eb1eb 100644 --- a/doc/requirements.pip +++ b/doc/requirements.pip @@ -12,7 +12,7 @@ anyio==4.3.0 # watchfiles attrs==23.2.0 # via scriv -babel==2.14.0 +babel==2.15.0 # via sphinx certifi==2024.2.2 # via requests @@ -45,7 +45,7 @@ idna==3.7 # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.3 +jinja2==3.1.4 # via # scriv # sphinx @@ -59,14 +59,18 @@ packaging==24.0 # via sphinx pbr==6.0.0 # via stevedore +polib==1.2.0 + # via sphinx-lint pyenchant==3.2.2 # via # -r doc/requirements.in # sphinxcontrib-spelling -pygments==2.17.2 +pygments==2.18.0 # via # doc8 # sphinx +regex==2024.4.28 + # via sphinx-lint requests==2.31.0 # via # scriv @@ -92,6 +96,8 @@ sphinx-autobuild==2024.4.16 # via -r doc/requirements.in sphinx-code-tabs==0.5.5 # via -r doc/requirements.in +sphinx-lint==0.9.1 + # via -r doc/requirements.in sphinx-rtd-theme==2.0.0 # via -r doc/requirements.in sphinxcontrib-applehelp==1.0.8 diff --git a/doc/sample_html/class_index.html b/doc/sample_html/class_index.html index bc16203e8..ad1948c35 100644 --- a/doc/sample_html/class_index.html +++ b/doc/sample_html/class_index.html @@ -5,7 +5,7 @@ Cog coverage - +
@@ -56,8 +56,8 @@

Classes

- coverage.py v7.5.1, - created at 2024-05-04 10:30 -0400 + coverage.py v7.5.2, + created at 2024-05-24 16:52 -0400

@@ -537,8 +537,8 @@

- coverage.py v7.5.1, - created at 2024-05-04 10:30 -0400 + coverage.py v7.5.2, + created at 2024-05-24 16:52 -0400

diff --git a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html index ff27cd08a..1b25d0242 100644 --- a/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html +++ b/doc/sample_html/z_7b071bdc2a35fa80_makefiles_py.html @@ -5,7 +5,7 @@ Coverage for cogapp/makefiles.py: 11.11% - +
@@ -66,8 +66,8 @@

^ index     » next       - coverage.py v7.5.1, - created at 2024-05-04 10:30 -0400 + coverage.py v7.5.2, + created at 2024-05-24 16:52 -0400