From ec6205a8de972af6a09453235d02a7ebea6aea8e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sun, 23 Oct 2022 14:03:17 -0400 Subject: [PATCH] fix: use glob matching instead of fnmatch. #1407 I didn't understand that fnmatch considers the entire string to be a filename, even if it has slashes in it. This led to incorrect matching. Now we use our own implementation of glob matching to get the correct behavior. --- CHANGES.rst | 5 + coverage/files.py | 79 +++++++++++----- coverage/inorout.py | 6 +- coverage/report.py | 6 +- doc/cmd.rst | 40 ++++---- doc/config.rst | 2 +- doc/source.rst | 29 +++++- tests/test_api.py | 1 - tests/test_files.py | 209 +++++++++++++++++++++++++++++------------- tests/test_summary.py | 24 +++++ 10 files changed, 284 insertions(+), 117 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 372c639dd..b0ea7bb6f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,6 +20,10 @@ development at the same time, such as 4.5.x and 5.0. Unreleased ---------- +- Fixes to file pattern matching, fixing `issue 1407`_. Previously, `*` would + incorrectly match directory separators, making precise matching difficult. + This is now fixed. + - Improvements to combining data files when using the :ref:`config_run_relative_files` setting: @@ -39,6 +43,7 @@ Unreleased implementations other than CPython or PyPy (`issue 1474`_). .. _issue 991: https://github.com/nedbat/coveragepy/issues/991 +.. _issue 1407: https://github.com/nedbat/coveragepy/issues/1407 .. _issue 1474: https://github.com/nedbat/coveragepy/issues/1474 .. _issue 1481: https://github.com/nedbat/coveragepy/issues/1481 diff --git a/coverage/files.py b/coverage/files.py index 2c520b8ab..76ecbef9d 100644 --- a/coverage/files.py +++ b/coverage/files.py @@ -3,7 +3,6 @@ """File wrangling.""" -import fnmatch import hashlib import ntpath import os @@ -172,7 +171,7 @@ def isabs_anywhere(filename): def prep_patterns(patterns): - """Prepare the file patterns for use in a `FnmatchMatcher`. + """Prepare the file patterns for use in a `GlobMatcher`. If a pattern starts with a wildcard, it is used as a pattern as-is. If it does not start with a wildcard, then it is made @@ -253,15 +252,15 @@ def match(self, module_name): return False -class FnmatchMatcher: +class GlobMatcher: """A matcher for files by file name pattern.""" def __init__(self, pats, name="unknown"): self.pats = list(pats) - self.re = fnmatches_to_regex(self.pats, case_insensitive=env.WINDOWS) + self.re = globs_to_regex(self.pats, case_insensitive=env.WINDOWS) self.name = name def __repr__(self): - return f"" + return f"" def info(self): """A list of strings for displaying when dumping state.""" @@ -282,12 +281,55 @@ def sep(s): return the_sep -def fnmatches_to_regex(patterns, case_insensitive=False, partial=False): - """Convert fnmatch patterns to a compiled regex that matches any of them. +# Tokenizer for _glob_to_regex. +# None as a sub means disallowed. +G2RX_TOKENS = [(re.compile(rx), sub) for rx, sub in [ + (r"\*\*\*+", None), # Can't have *** + (r"[^/]+\*\*+", None), # Can't have x** + (r"\*\*+[^/]+", None), # Can't have **x + (r"\*\*/\*\*", None), # Can't have **/** + (r"^\*+/", r"(.*[/\\\\])?"), # ^*/ matches any prefix-slash, or nothing. + (r"/\*+$", r"[/\\\\].*"), # /*$ matches any slash-suffix. + (r"\*\*/", r"(.*[/\\\\])?"), # **/ matches any subdirs, including none + (r"/", r"[/\\\\]"), # / matches either slash or backslash + (r"\*", r"[^/\\\\]*"), # * matches any number of non slash-likes + (r"\?", r"[^/\\\\]"), # ? matches one non slash-like + (r"\[.*?\]", r"\g<0>"), # [a-f] matches [a-f] + (r"[a-zA-Z0-9_-]+", r"\g<0>"), # word chars match themselves + (r"[\[\]+{}]", None), # Can't have regex special chars + (r".", r"\\\g<0>"), # Anything else is escaped to be safe +]] + +def _glob_to_regex(pattern): + """Convert a file-path glob pattern into a regex.""" + # Turn all backslashes into slashes to simplify the tokenizer. + pattern = pattern.replace("\\", "/") + if "/" not in pattern: + pattern = "**/" + pattern + path_rx = [] + pos = 0 + while pos < len(pattern): + for rx, sub in G2RX_TOKENS: + m = rx.match(pattern, pos=pos) + if m: + if sub is None: + raise ConfigError(f"File pattern can't include {m[0]!r}") + path_rx.append(m.expand(sub)) + pos = m.end() + break + return "".join(path_rx) + + +def globs_to_regex(patterns, case_insensitive=False, partial=False): + """Convert glob patterns to a compiled regex that matches any of them. Slashes are always converted to match either slash or backslash, for Windows support, even when running elsewhere. + If the pattern has no slash or backslash, then it is interpreted as + matching a file name anywhere it appears in the tree. Otherwise, the glob + pattern must match the whole file path. + If `partial` is true, then the pattern will match if the target string starts with the pattern. Otherwise, it must match the entire string. @@ -295,24 +337,13 @@ def fnmatches_to_regex(patterns, case_insensitive=False, partial=False): strings. """ - regexes = (fnmatch.translate(pattern) for pattern in patterns) - # */ at the start should also match nothing. - regexes = (re.sub(r"^\(\?s:\.\*(\\\\|/)", r"(?s:^(.*\1)?", regex) for regex in regexes) - # Be agnostic: / can mean backslash or slash. - regexes = (re.sub(r"/", r"[\\\\/]", regex) for regex in regexes) - - if partial: - # fnmatch always adds a \Z to match the whole string, which we don't - # want, so we remove the \Z. While removing it, we only replace \Z if - # followed by paren (introducing flags), or at end, to keep from - # destroying a literal \Z in the pattern. - regexes = (re.sub(r'\\Z(\(\?|$)', r'\1', regex) for regex in regexes) - flags = 0 if case_insensitive: flags |= re.IGNORECASE - compiled = re.compile(join_regex(regexes), flags=flags) - + rx = join_regex(map(_glob_to_regex, patterns)) + if not partial: + rx = rf"(?:{rx})\Z" + compiled = re.compile(rx, flags=flags) return compiled @@ -342,7 +373,7 @@ def pprint(self): def add(self, pattern, result): """Add the `pattern`/`result` pair to the list of aliases. - `pattern` is an `fnmatch`-style pattern. `result` is a simple + `pattern` is an `glob`-style pattern. `result` is a simple string. When mapping paths, if a path starts with a match against `pattern`, then that match is replaced with `result`. This models isomorphic source trees being rooted at different places on two @@ -370,7 +401,7 @@ def add(self, pattern, result): pattern += pattern_sep # Make a regex from the pattern. - regex = fnmatches_to_regex([pattern], case_insensitive=True, partial=True) + regex = globs_to_regex([pattern], case_insensitive=True, partial=True) # Normalize the result: it must end with a path separator. result_sep = sep(result) diff --git a/coverage/inorout.py b/coverage/inorout.py index ec89d1b49..2e534c853 100644 --- a/coverage/inorout.py +++ b/coverage/inorout.py @@ -16,7 +16,7 @@ from coverage import env from coverage.disposition import FileDisposition, disposition_init from coverage.exceptions import CoverageException, PluginError -from coverage.files import TreeMatcher, FnmatchMatcher, ModuleMatcher +from coverage.files import TreeMatcher, GlobMatcher, ModuleMatcher from coverage.files import prep_patterns, find_python_files, canonical_filename from coverage.misc import sys_modules_saved from coverage.python import source_for_file, source_for_morf @@ -260,10 +260,10 @@ def debug(msg): self.pylib_match = TreeMatcher(self.pylib_paths, "pylib") debug(f"Python stdlib matching: {self.pylib_match!r}") if self.include: - self.include_match = FnmatchMatcher(self.include, "include") + self.include_match = GlobMatcher(self.include, "include") debug(f"Include matching: {self.include_match!r}") if self.omit: - self.omit_match = FnmatchMatcher(self.omit, "omit") + self.omit_match = GlobMatcher(self.omit, "omit") debug(f"Omit matching: {self.omit_match!r}") self.cover_match = TreeMatcher(self.cover_paths, "coverage") diff --git a/coverage/report.py b/coverage/report.py index 6382eb515..0c05b0446 100644 --- a/coverage/report.py +++ b/coverage/report.py @@ -6,7 +6,7 @@ import sys from coverage.exceptions import CoverageException, NoDataError, NotPython -from coverage.files import prep_patterns, FnmatchMatcher +from coverage.files import prep_patterns, GlobMatcher from coverage.misc import ensure_dir_for_file, file_be_gone @@ -57,11 +57,11 @@ def get_analysis_to_report(coverage, morfs): config = coverage.config if config.report_include: - matcher = FnmatchMatcher(prep_patterns(config.report_include), "report_include") + matcher = GlobMatcher(prep_patterns(config.report_include), "report_include") file_reporters = [fr for fr in file_reporters if matcher.match(fr.filename)] if config.report_omit: - matcher = FnmatchMatcher(prep_patterns(config.report_omit), "report_omit") + matcher = GlobMatcher(prep_patterns(config.report_omit), "report_omit") file_reporters = [fr for fr in file_reporters if not matcher.match(fr.filename)] if not file_reporters: diff --git a/doc/cmd.rst b/doc/cmd.rst index cb9a147ee..f8de0cb30 100644 --- a/doc/cmd.rst +++ b/doc/cmd.rst @@ -342,7 +342,7 @@ single directory, and use the **combine** command to combine them into one $ coverage combine -You can also name directories or files on the command line:: +You can also name directories or files to be combined on the command line:: $ coverage combine data1.dat windows_data_files/ @@ -364,22 +364,6 @@ An existing combined data file is ignored and re-written. If you want to use runs, use the ``--append`` switch on the **combine** command. This behavior was the default before version 4.2. -To combine data for a source file, coverage has to find its data in each of the -data files. Different test runs may run the same source file from different -locations. For example, different operating systems will use different paths -for the same file, or perhaps each Python version is run from a different -subdirectory. Coverage needs to know that different file paths are actually -the same source file for reporting purposes. - -You can tell coverage.py how different source locations relate with a -``[paths]`` section in your configuration file (see :ref:`config_paths`). -It might be more convenient to use the ``[run] relative_files`` -setting to store relative file paths (see :ref:`relative_files -`). - -If data isn't combining properly, you can see details about the inner workings -with ``--debug=pathmap``. - If any of the data files can't be read, coverage.py will print a warning indicating the file and the problem. @@ -414,6 +398,28 @@ want to keep those files, use the ``--keep`` command-line option. .. [[[end]]] (checksum: 0bdd83f647ee76363c955bedd9ddf749) +.. _cmd_combine_remapping: + +Re-mapping paths +................ + +To combine data for a source file, coverage has to find its data in each of the +data files. Different test runs may run the same source file from different +locations. For example, different operating systems will use different paths +for the same file, or perhaps each Python version is run from a different +subdirectory. Coverage needs to know that different file paths are actually +the same source file for reporting purposes. + +You can tell coverage.py how different source locations relate with a +``[paths]`` section in your configuration file (see :ref:`config_paths`). +It might be more convenient to use the ``[run] relative_files`` +setting to store relative file paths (see :ref:`relative_files +`). + +If data isn't combining properly, you can see details about the inner workings +with ``--debug=pathmap``. + + .. _cmd_erase: Erase data: ``coverage erase`` diff --git a/doc/config.rst b/doc/config.rst index 6b7535795..c6f6442a5 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -357,7 +357,7 @@ The first list that has a match will be used. The ``--debug=pathmap`` option can be used to log details of the re-mapping of paths. See :ref:`the --debug option `. -See :ref:`cmd_combine` for more information. +See :ref:`cmd_combine_remapping` and :ref:`source_glob` for more information. .. _config_report: diff --git a/doc/source.rst b/doc/source.rst index cfd0e6fc5..64ebd1320 100644 --- a/doc/source.rst +++ b/doc/source.rst @@ -59,10 +59,10 @@ removed from the set. .. highlight:: ini -The ``include`` and ``omit`` file name patterns follow typical shell syntax: -``*`` matches any number of characters and ``?`` matches a single character. -Patterns that start with a wildcard character are used as-is, other patterns -are interpreted relative to the current directory:: +The ``include`` and ``omit`` file name patterns follow common shell syntax, +described below in :ref:`source_glob`. Patterns that start with a wildcard +character are used as-is, other patterns are interpreted relative to the +current directory:: [run] omit = @@ -77,7 +77,7 @@ The ``source``, ``include``, and ``omit`` values all work together to determine the source that will be measured. If both ``source`` and ``include`` are set, the ``include`` value is ignored -and a warning is printed on the standard output. +and a warning is issued. .. _source_reporting: @@ -103,3 +103,22 @@ reporting. Note that these are ways of specifying files to measure. You can also exclude individual source lines. See :ref:`excluding` for details. + + +.. _source_glob: + +File patterns +------------- + +File path patterns are used for include and omit, and for combining path +remapping. They follow common shell syntax: + +- ``*`` matches any number of file name characters, not including the directory + separator. + +- ``?`` matches a single file name character. + +- ``**`` matches any number of nested directory names, including none. + +- Both ``/`` and ``\`` will match either a slash or a backslash, to make + cross-platform matching easier. diff --git a/tests/test_api.py b/tests/test_api.py index 375edcec1..07bd07f33 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -71,7 +71,6 @@ def test_unexecuted_file(self): assert missing == [1] def test_filenames(self): - self.make_file("mymain.py", """\ import mymod a = 1 diff --git a/tests/test_files.py b/tests/test_files.py index 8fea61d07..9a4cea7f8 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -3,8 +3,10 @@ """Tests for files.py""" +import itertools import os import os.path +import re from unittest import mock import pytest @@ -12,8 +14,8 @@ from coverage import env, files from coverage.exceptions import ConfigError from coverage.files import ( - FnmatchMatcher, ModuleMatcher, PathAliases, TreeMatcher, abs_file, - actual_path, find_python_files, flat_rootname, fnmatches_to_regex, + GlobMatcher, ModuleMatcher, PathAliases, TreeMatcher, abs_file, + actual_path, find_python_files, flat_rootname, globs_to_regex, ) from tests.coveragetest import CoverageTest @@ -104,59 +106,138 @@ def test_flat_rootname(original, flat): assert flat_rootname(original) == flat +def globs_to_regex_params( + patterns, case_insensitive=False, partial=False, matches=(), nomatches=(), +): + """Generate parameters for `test_globs_to_regex`. + + `patterns`, `case_insensitive`, and `partial` are arguments for + `globs_to_regex`. `matches` is a list of strings that should match, and + `nomatches` is a list of strings that should not match. + + Everything is yielded so that `test_globs_to_regex` can call + `globs_to_regex` once and check one result. + """ + pat_id = "|".join(patterns) + for text in matches: + yield pytest.param( + patterns, case_insensitive, partial, text, True, + id=f"{pat_id}:ci{case_insensitive}:par{partial}:{text}:match", + ) + for text in nomatches: + yield pytest.param( + patterns, case_insensitive, partial, text, False, + id=f"{pat_id}:ci{case_insensitive}:par{partial}:{text}:nomatch", + ) + @pytest.mark.parametrize( - "patterns, case_insensitive, partial," + - "matches," + - "nomatches", -[ - ( - ["abc", "xyz"], False, False, + "patterns, case_insensitive, partial, text, result", + list(itertools.chain.from_iterable([ + globs_to_regex_params( ["abc", "xyz"], - ["ABC", "xYz", "abcx", "xabc", "axyz", "xyza"], - ), - ( - ["abc", "xyz"], True, False, - ["abc", "xyz", "Abc", "XYZ", "AbC"], - ["abcx", "xabc", "axyz", "xyza"], - ), - ( - ["abc/hi.py"], True, False, - ["abc/hi.py", "ABC/hi.py", r"ABC\hi.py"], - ["abc_hi.py", "abc/hi.pyc"], - ), - ( - [r"abc\hi.py"], True, False, - [r"abc\hi.py", r"ABC\hi.py"], - ["abc/hi.py", "ABC/hi.py", "abc_hi.py", "abc/hi.pyc"], - ), - ( - ["abc/*/hi.py"], True, False, - ["abc/foo/hi.py", "ABC/foo/bar/hi.py", r"ABC\foo/bar/hi.py"], - ["abc/hi.py", "abc/hi.pyc"], - ), - ( - ["abc/[a-f]*/hi.py"], True, False, - ["abc/foo/hi.py", "ABC/foo/bar/hi.py", r"ABC\foo/bar/hi.py"], - ["abc/zoo/hi.py", "abc/hi.py", "abc/hi.pyc"], - ), - ( - ["abc/"], True, True, - ["abc/foo/hi.py", "ABC/foo/bar/hi.py", r"ABC\foo/bar/hi.py"], - ["abcd/foo.py", "xabc/hi.py"], - ), - ( - ["*/foo"], False, True, - ["abc/foo/hi.py", "foo/hi.py"], - ["abc/xfoo/hi.py"], - ), - + matches=["abc", "xyz", "sub/mod/abc"], + nomatches=[ + "ABC", "xYz", "abcx", "xabc", "axyz", "xyza", "sub/mod/abcd", "sub/abc/more", + ], + ), + globs_to_regex_params( + ["abc", "xyz"], case_insensitive=True, + matches=["abc", "xyz", "Abc", "XYZ", "AbC"], + nomatches=["abcx", "xabc", "axyz", "xyza"], + ), + globs_to_regex_params( + ["a*c", "x*z"], + matches=["abc", "xyz", "xYz", "azc", "xaz", "axyzc"], + nomatches=["ABC", "abcx", "xabc", "axyz", "xyza", "a/c"], + ), + globs_to_regex_params( + ["a?c", "x?z"], + matches=["abc", "xyz", "xYz", "azc", "xaz"], + nomatches=["ABC", "abcx", "xabc", "axyz", "xyza", "a/c"], + ), + globs_to_regex_params( + ["a??d"], + matches=["abcd", "azcd", "a12d"], + nomatches=["ABCD", "abcx", "axyz", "abcde"], + ), + globs_to_regex_params( + ["abc/hi.py"], case_insensitive=True, + matches=["abc/hi.py", "ABC/hi.py", r"ABC\hi.py"], + nomatches=["abc_hi.py", "abc/hi.pyc"], + ), + globs_to_regex_params( + [r"abc\hi.py"], case_insensitive=True, + matches=[r"abc\hi.py", r"ABC\hi.py", "abc/hi.py", "ABC/hi.py"], + nomatches=["abc_hi.py", "abc/hi.pyc"], + ), + globs_to_regex_params( + ["abc/*/hi.py"], case_insensitive=True, + matches=["abc/foo/hi.py", r"ABC\foo/hi.py"], + nomatches=["abc/hi.py", "abc/hi.pyc", "ABC/foo/bar/hi.py", r"ABC\foo/bar/hi.py"], + ), + globs_to_regex_params( + ["abc/**/hi.py"], case_insensitive=True, + matches=[ + "abc/foo/hi.py", r"ABC\foo/hi.py", "abc/hi.py", "ABC/foo/bar/hi.py", + r"ABC\foo/bar/hi.py", + ], + nomatches=["abc/hi.pyc"], + ), + globs_to_regex_params( + ["abc/[a-f]*/hi.py"], case_insensitive=True, + matches=["abc/foo/hi.py", r"ABC\boo/hi.py"], + nomatches=[ + "abc/zoo/hi.py", "abc/hi.py", "abc/hi.pyc", "abc/foo/bar/hi.py", + r"abc\foo/bar/hi.py", + ], + ), + globs_to_regex_params( + ["abc/[a-f]/hi.py"], case_insensitive=True, + matches=["abc/f/hi.py", r"ABC\b/hi.py"], + nomatches=[ + "abc/foo/hi.py", "abc/zoo/hi.py", "abc/hi.py", "abc/hi.pyc", "abc/foo/bar/hi.py", + r"abc\foo/bar/hi.py", + ], + ), + globs_to_regex_params( + ["abc/"], case_insensitive=True, partial=True, + matches=["abc/foo/hi.py", "ABC/foo/bar/hi.py", r"ABC\foo/bar/hi.py"], + nomatches=["abcd/foo.py", "xabc/hi.py"], + ), + globs_to_regex_params( + ["*/foo"], case_insensitive=False, partial=True, + matches=["abc/foo/hi.py", "foo/hi.py"], + nomatches=["abc/xfoo/hi.py"], + ), + globs_to_regex_params( + ["**/foo"], + matches=["foo", "hello/foo", "hi/there/foo"], + nomatches=["foob", "hello/foob", "hello/Foo"], + ), + ])) +) +def test_globs_to_regex(patterns, case_insensitive, partial, text, result): + regex = globs_to_regex(patterns, case_insensitive=case_insensitive, partial=partial) + assert bool(regex.match(text)) == result + + +@pytest.mark.parametrize("pattern, bad_word", [ + ("***/foo.py", "***"), + ("bar/***/foo.py", "***"), + ("*****/foo.py", "*****"), + ("Hello]there", "]"), + ("Hello[there", "["), + ("Hello+there", "+"), + ("{a,b}c", "{"), + ("x/a**/b.py", "a**"), + ("x/abcd**/b.py", "abcd**"), + ("x/**a/b.py", "**a"), + ("x/**/**/b.py", "**/**"), ]) -def test_fnmatches_to_regex(patterns, case_insensitive, partial, matches, nomatches): - regex = fnmatches_to_regex(patterns, case_insensitive=case_insensitive, partial=partial) - for s in matches: - assert regex.match(s) - for s in nomatches: - assert not regex.match(s) +def test_invalid_globs(pattern, bad_word): + msg = f"File pattern can't include {bad_word!r}" + with pytest.raises(ConfigError, match=re.escape(msg)): + globs_to_regex([pattern]) class MatcherTest(CoverageTest): @@ -217,7 +298,7 @@ def test_module_matcher(self): for modulename, matches in matches_to_try: assert mm.match(modulename) == matches, modulename - def test_fnmatch_matcher(self): + def test_glob_matcher(self): matches_to_try = [ (self.make_file("sub/file1.py"), True), (self.make_file("sub/file2.c"), False), @@ -225,23 +306,25 @@ def test_fnmatch_matcher(self): (self.make_file("sub3/file4.py"), True), (self.make_file("sub3/file5.c"), False), ] - fnm = FnmatchMatcher(["*.py", "*/sub2/*"]) + fnm = GlobMatcher(["*.py", "*/sub2/*"]) assert fnm.info() == ["*.py", "*/sub2/*"] for filepath, matches in matches_to_try: self.assertMatches(fnm, filepath, matches) - def test_fnmatch_matcher_overload(self): - fnm = FnmatchMatcher(["*x%03d*.txt" % i for i in range(500)]) + def test_glob_matcher_overload(self): + fnm = GlobMatcher(["*x%03d*.txt" % i for i in range(500)]) self.assertMatches(fnm, "x007foo.txt", True) self.assertMatches(fnm, "x123foo.txt", True) self.assertMatches(fnm, "x798bar.txt", False) + self.assertMatches(fnm, "x499.txt", True) + self.assertMatches(fnm, "x500.txt", False) - def test_fnmatch_windows_paths(self): + def test_glob_windows_paths(self): # We should be able to match Windows paths even if we are running on # a non-Windows OS. - fnm = FnmatchMatcher(["*/foo.py"]) + fnm = GlobMatcher(["*/foo.py"]) self.assertMatches(fnm, r"dir\foo.py", True) - fnm = FnmatchMatcher([r"*\foo.py"]) + fnm = GlobMatcher([r"*\foo.py"]) self.assertMatches(fnm, r"dir\foo.py", True) @@ -309,9 +392,9 @@ def test_multiple_patterns(self, rel_yn): assert msgs == [ "Aliases (relative=True):", " Rule: '/home/*/src' -> './mysrc/' using regex " + - "'(?s:[\\\\\\\\/]home[\\\\\\\\/].*[\\\\\\\\/]src[\\\\\\\\/])'", + "'[/\\\\\\\\]home[/\\\\\\\\][^/\\\\\\\\]*[/\\\\\\\\]src[/\\\\\\\\]'", " Rule: '/lib/*/libsrc' -> './mylib/' using regex " + - "'(?s:[\\\\\\\\/]lib[\\\\\\\\/].*[\\\\\\\\/]libsrc[\\\\\\\\/])'", + "'[/\\\\\\\\]lib[/\\\\\\\\][^/\\\\\\\\]*[/\\\\\\\\]libsrc[/\\\\\\\\]'", "Matched path '/home/foo/src/a.py' to rule '/home/*/src' -> './mysrc/', " + "producing './mysrc/a.py'", "Matched path '/lib/foo/libsrc/a.py' to rule '/lib/*/libsrc' -> './mylib/', " + @@ -321,9 +404,9 @@ def test_multiple_patterns(self, rel_yn): assert msgs == [ "Aliases (relative=False):", " Rule: '/home/*/src' -> './mysrc/' using regex " + - "'(?s:[\\\\\\\\/]home[\\\\\\\\/].*[\\\\\\\\/]src[\\\\\\\\/])'", + "'[/\\\\\\\\]home[/\\\\\\\\][^/\\\\\\\\]*[/\\\\\\\\]src[/\\\\\\\\]'", " Rule: '/lib/*/libsrc' -> './mylib/' using regex " + - "'(?s:[\\\\\\\\/]lib[\\\\\\\\/].*[\\\\\\\\/]libsrc[\\\\\\\\/])'", + "'[/\\\\\\\\]lib[/\\\\\\\\][^/\\\\\\\\]*[/\\\\\\\\]libsrc[/\\\\\\\\]'", "Matched path '/home/foo/src/a.py' to rule '/home/*/src' -> './mysrc/', " + f"producing {files.canonical_filename('./mysrc/a.py')!r}", "Matched path '/lib/foo/libsrc/a.py' to rule '/lib/*/libsrc' -> './mylib/', " + diff --git a/tests/test_summary.py b/tests/test_summary.py index d603062be..ac29f5175 100644 --- a/tests/test_summary.py +++ b/tests/test_summary.py @@ -138,6 +138,30 @@ def test_report_including(self): assert "mycode.py " in report assert self.last_line_squeezed(report) == "TOTAL 4 0 100%" + def test_omit_files_here(self): + # https://github.com/nedbat/coveragepy/issues/1407 + self.make_file("foo.py", "") + self.make_file("bar/bar.py", "") + self.make_file("tests/test_baz.py", """\ + def test_foo(): + assert True + test_foo() + """) + self.run_command("coverage run --source=. --omit='./*.py' -m tests.test_baz") + report = self.report_from_command("coverage report") + + # Name Stmts Miss Cover + # --------------------------------------- + # tests/test_baz.py 3 0 100% + # --------------------------------------- + # TOTAL 3 0 100% + + assert self.line_count(report) == 5 + assert "foo" not in report + assert "bar" not in report + assert "tests/test_baz.py" in report + assert self.last_line_squeezed(report) == "TOTAL 3 0 100%" + def test_run_source_vs_report_include(self): # https://github.com/nedbat/coveragepy/issues/621 self.make_file(".coveragerc", """\