diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 67393a8ca..71b32f2fa 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -16,7 +16,7 @@ How can we reproduce the problem?  Please *be specific*. Don't link to a failing
 1. What version of coverage.py shows the problem? The output of `coverage debug sys` is helpful.
 1. What versions of what packages do you have installed? The output of `pip freeze` is helpful.
 1. What code shows the problem?  Give us a specific commit of a specific repo that we can check out. If you've already worked around the problem, please provide a commit before that fix.
-1. What commands did you run?
+1. What commands should we run to reproduce the problem? *Be specific*. Include everything, even `git clone`, `pip install`, and so on. Explain like we're five!
 
 **Expected behavior**
 A clear and concise description of what you expected to happen.
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 60e8d0a29..ab94a83e3 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -48,6 +48,7 @@ jobs:
           - "3.9"
           - "3.10"
           - "3.11"
+          - "3.12"
           - "pypy-3.7"
           - "pypy-3.8"
           - "pypy-3.9"
@@ -77,6 +78,7 @@ jobs:
         uses: "actions/setup-python@v4"
         with:
           python-version: "${{ matrix.python-version }}"
+          allow-prereleases: true
           cache: pip
           cache-dependency-path: 'requirements/*.pip'
 
@@ -178,7 +180,7 @@ jobs:
           echo "sha10=$SHA10" >> $GITHUB_ENV
           echo "slug=$SLUG" >> $GITHUB_ENV
           echo "report_dir=$REPORT_DIR" >> $GITHUB_ENV
-          echo "url=https://nedbat.github.io/coverage-reports/$REPORT_DIR" >> $GITHUB_ENV
+          echo "url=https://htmlpreview.github.io/?https://github.com/nedbat/coverage-reports/blob/main/reports/$SLUG/htmlcov/index.html" >> $GITHUB_ENV
           echo "branch=${REF#refs/heads/}" >> $GITHUB_ENV
 
       - name: "Summarize"
@@ -217,6 +219,8 @@ jobs:
           # Make the commit message.
           echo "${{ env.total }}% - $COMMIT_MESSAGE" > commit.txt
           echo "" >> commit.txt
+          echo "[View the report](${{ env.url }})" >> commit.txt
+          echo "" >> commit.txt
           echo "${{ env.url }}" >> commit.txt
           echo "${{ env.sha10 }}: ${{ env.branch }}" >> commit.txt
           # Commit.
diff --git a/.github/workflows/kit.yml b/.github/workflows/kit.yml
index 179f7a649..53e081455 100644
--- a/.github/workflows/kit.yml
+++ b/.github/workflows/kit.yml
@@ -77,9 +77,8 @@ jobs:
           #   }
           #   # PYVERSIONS. Available versions:
           #   # https://github.com/actions/python-versions/blob/main/versions-manifest.json
-          #   # Include prereleases if they are at rc stage.
           #   # PyPy versions are handled further below in the "pypy" step.
-          #   pys = ["cp37", "cp38", "cp39", "cp310", "cp311"]
+          #   pys = ["cp37", "cp38", "cp39", "cp310", "cp311", "cp312"]
           #
           #   # Some OS/arch combinations need overrides for the Python versions:
           #   os_arch_pys = {
@@ -104,16 +103,19 @@ jobs:
           - {"os": "ubuntu", "py": "cp39", "arch": "x86_64"}
           - {"os": "ubuntu", "py": "cp310", "arch": "x86_64"}
           - {"os": "ubuntu", "py": "cp311", "arch": "x86_64"}
+          - {"os": "ubuntu", "py": "cp312", "arch": "x86_64"}
           - {"os": "ubuntu", "py": "cp37", "arch": "i686"}
           - {"os": "ubuntu", "py": "cp38", "arch": "i686"}
           - {"os": "ubuntu", "py": "cp39", "arch": "i686"}
           - {"os": "ubuntu", "py": "cp310", "arch": "i686"}
           - {"os": "ubuntu", "py": "cp311", "arch": "i686"}
+          - {"os": "ubuntu", "py": "cp312", "arch": "i686"}
           - {"os": "ubuntu", "py": "cp37", "arch": "aarch64"}
           - {"os": "ubuntu", "py": "cp38", "arch": "aarch64"}
           - {"os": "ubuntu", "py": "cp39", "arch": "aarch64"}
           - {"os": "ubuntu", "py": "cp310", "arch": "aarch64"}
           - {"os": "ubuntu", "py": "cp311", "arch": "aarch64"}
+          - {"os": "ubuntu", "py": "cp312", "arch": "aarch64"}
           - {"os": "macos", "py": "cp38", "arch": "arm64"}
           - {"os": "macos", "py": "cp39", "arch": "arm64"}
           - {"os": "macos", "py": "cp310", "arch": "arm64"}
@@ -123,17 +125,20 @@ jobs:
           - {"os": "macos", "py": "cp39", "arch": "x86_64"}
           - {"os": "macos", "py": "cp310", "arch": "x86_64"}
           - {"os": "macos", "py": "cp311", "arch": "x86_64"}
+          - {"os": "macos", "py": "cp312", "arch": "x86_64"}
           - {"os": "windows", "py": "cp37", "arch": "x86"}
           - {"os": "windows", "py": "cp38", "arch": "x86"}
           - {"os": "windows", "py": "cp39", "arch": "x86"}
           - {"os": "windows", "py": "cp310", "arch": "x86"}
           - {"os": "windows", "py": "cp311", "arch": "x86"}
+          - {"os": "windows", "py": "cp312", "arch": "x86"}
           - {"os": "windows", "py": "cp37", "arch": "AMD64"}
           - {"os": "windows", "py": "cp38", "arch": "AMD64"}
           - {"os": "windows", "py": "cp39", "arch": "AMD64"}
           - {"os": "windows", "py": "cp310", "arch": "AMD64"}
           - {"os": "windows", "py": "cp311", "arch": "AMD64"}
-        # [[[end]]] (checksum: ded8a9f214bf59776562d91ae6828863)
+          - {"os": "windows", "py": "cp312", "arch": "AMD64"}
+        # [[[end]]] (checksum: 5e62f362263935c1e3a21299f8a1b649)
       fail-fast: false
 
     steps:
@@ -149,6 +154,7 @@ jobs:
       - name: "Install Python 3.8"
         uses: actions/setup-python@v4
         with:
+          # PYVERSIONS
           python-version: "3.8"
           cache: pip
           cache-dependency-path: 'requirements/*.pip'
@@ -162,6 +168,7 @@ jobs:
           CIBW_BUILD: ${{ matrix.py }}-*
           CIBW_ARCHS: ${{ matrix.arch }}
           CIBW_ENVIRONMENT: PIP_DISABLE_PIP_VERSION_CHECK=1
+          CIBW_PRERELEASE_PYTHONS: True
           CIBW_TEST_COMMAND: python -c "from coverage.tracer import CTracer; print('CTracer OK!')"
         run: |
           python -m cibuildwheel --output-dir wheelhouse
@@ -175,6 +182,7 @@ jobs:
         with:
           name: dist
           path: wheelhouse/*.whl
+          retention-days: 7
 
   sdist:
     name: "Source distribution"
@@ -186,6 +194,7 @@ jobs:
       - name: "Install Python 3.8"
         uses: actions/setup-python@v4
         with:
+          # PYVERSIONS
           python-version: "3.8"
           cache: pip
           cache-dependency-path: 'requirements/*.pip'
@@ -207,6 +216,7 @@ jobs:
         with:
           name: dist
           path: dist/*.tar.gz
+          retention-days: 7
 
   pypy:
     name: "PyPy wheel"
@@ -241,3 +251,40 @@ jobs:
         with:
           name: dist
           path: dist/*.whl
+          retention-days: 7
+
+  sign:
+    # This signs our artifacts, but we don't use the signatures for anything
+    # yet.  Someday maybe PyPI will have a way to upload and verify them.
+    name: "Sign artifacts"
+    needs:
+      - wheels
+      - sdist
+      - pypy
+    runs-on: ubuntu-latest
+    permissions:
+      id-token: write
+    steps:
+      - name: "Download artifacts"
+        uses: actions/download-artifact@v3
+        with:
+          name: dist
+
+      - name: "Sign artifacts"
+        uses: sigstore/gh-action-sigstore-python@v1.2.3
+        with:
+          inputs: coverage-*.*
+
+      - name: "List files"
+        run: |
+          ls -alR
+
+      - name: "Upload signatures"
+        uses: actions/upload-artifact@v3
+        with:
+          name: signatures
+          path: |
+            *.crt
+            *.sig
+            *.sigstore
+          retention-days: 7
diff --git a/.github/workflows/python-nightly.yml b/.github/workflows/python-nightly.yml
index 94a30ecc2..319064c94 100644
--- a/.github/workflows/python-nightly.yml
+++ b/.github/workflows/python-nightly.yml
@@ -53,6 +53,7 @@ jobs:
           - "pypy-3.7-nightly"
           - "pypy-3.8-nightly"
           - "pypy-3.9-nightly"
+          - "pypy-3.10-nightly"
       fail-fast: false
 
     steps:
diff --git a/.github/workflows/testsuite.yml b/.github/workflows/testsuite.yml
index b0f0ee6ca..8ab3608bc 100644
--- a/.github/workflows/testsuite.yml
+++ b/.github/workflows/testsuite.yml
@@ -49,6 +49,7 @@ jobs:
           - "3.9"
           - "3.10"
           - "3.11"
+          - "3.12"
           - "pypy-3.7"
           - "pypy-3.9"
         exclude:
@@ -65,6 +66,7 @@ jobs:
         uses: "actions/setup-python@v4"
         with:
           python-version: "${{ matrix.python-version }}"
+          allow-prereleases: true
           cache: pip
           cache-dependency-path: 'requirements/*.pip'
 
diff --git a/.treerc b/.treerc
index ddea2e92c..0916e24a9 100644
--- a/.treerc
+++ b/.treerc
@@ -14,5 +14,5 @@ ignore =
     *.gz *.zip
     _build _spell
     *.egg *.egg-info
-    .mypy_cache
+    .*_cache
     tmp
diff --git a/CHANGES.rst b/CHANGES.rst
index 937835ccc..4b567d6dc 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -19,6 +19,87 @@ development at the same time, such as 4.5.x and 5.0.
 
 .. scriv-start-here
 
+.. _changes_7-2-7:
+
+Version 7.2.7 — 2023-05-29
+--------------------------
+
+- Fix: reverted a `change from 6.4.3 <pull 1347_>`_ that helped Cython, but
+  also increased the size of data files when using dynamic contexts, as
+  described in the now-fixed `issue 1586`_. The problem is now avoided due to a
+  recent change (`issue 1538`_).  Thanks to `Anders Kaseorg <pull 1629_>`_
+  and David Szotten for persisting with problem reports and detailed diagnoses.
+
+- Wheels are now provided for CPython 3.12.
+
+.. _issue 1586: https://github.com/nedbat/coveragepy/issues/1586
+.. _pull 1629: https://github.com/nedbat/coveragepy/pull/1629
+
+
+.. _changes_7-2-6:
+
+Version 7.2.6 — 2023-05-23
+--------------------------
+
+- Fix: the ``lcov`` command could raise an IndexError exception if a file is
+  translated to Python but then executed under its own name.  Jinja2 does this
+  when rendering templates.  Fixes `issue 1553`_.
+
+- Python 3.12 beta 1 now inlines comprehensions.  Previously they were compiled
+  as invisible functions and coverage.py would warn you if they weren't
+  completely executed.  This no longer happens under Python 3.12.
+
+- Fix: the ``coverage debug sys`` command includes some environment variables
+  in its output.  This could have included sensitive data.  Those values are
+  now hidden with asterisks, closing `issue 1628`_.
+
+.. _issue 1553: https://github.com/nedbat/coveragepy/issues/1553
+.. _issue 1628: https://github.com/nedbat/coveragepy/issues/1628
+
+
+.. _changes_7-2-5:
+
+Version 7.2.5 — 2023-04-30
+--------------------------
+
+- Fix: ``html_report()`` could fail with an AttributeError on ``isatty`` if run
+  in an unusual environment where sys.stdout had been replaced.  This is now
+  fixed.
+
+
+.. _changes_7-2-4:
+
+Version 7.2.4 — 2023-04-28
+--------------------------
+
+PyCon 2023 sprint fixes!
+
+- Fix: with ``relative_files = true``, specifying a specific file to include or
+  omit wouldn't work correctly (`issue 1604`_).  This is now fixed, with
+  testing help by `Marc Gibbons <pull 1608_>`_.
+
+- Fix: the XML report would have an incorrect ``<source>`` element when using
+  relative files and the source option ended with a slash (`issue 1541`_).
+  This is now fixed, thanks to `Kevin Brown-Silva <pull 1608_>`_.
+
+- When the HTML report location is printed to the terminal, it's now a
+  terminal-compatible URL, so that you can click the location to open the HTML
+  file in your browser.  Finishes `issue 1523`_ thanks to `Ricardo Newbery
+  <pull 1613_>`_.
+
+- Docs: a new :ref:`Migrating page <migrating>` with details about how to
+  migrate between major versions of coverage.py.  It currently covers the
+  wildcard changes in 7.x.  Thanks, `Brian Grohe <pull 1610_>`_.
+
+.. _issue 1523: https://github.com/nedbat/coveragepy/issues/1523
+.. _issue 1541: https://github.com/nedbat/coveragepy/issues/1541
+.. _issue 1604: https://github.com/nedbat/coveragepy/issues/1604
+.. _pull 1608: https://github.com/nedbat/coveragepy/pull/1608
+.. _pull 1609: https://github.com/nedbat/coveragepy/pull/1609
+.. _pull 1610: https://github.com/nedbat/coveragepy/pull/1610
+.. _pull 1613: https://github.com/nedbat/coveragepy/pull/1613
+
+
 .. _changes_7-2-3:
 
 Version 7.2.3 — 2023-04-06
@@ -139,6 +220,7 @@ Version 7.1.0 — 2023-01-24
 .. _issue 1319: https://github.com/nedbat/coveragepy/issues/1319
 .. _issue 1538: https://github.com/nedbat/coveragepy/issues/1538
 
+
 .. _changes_7-0-5:
 
 Version 7.0.5 — 2023-01-10
@@ -352,7 +434,6 @@ update your settings.
 .. _pull 1479: https://github.com/nedbat/coveragepy/pull/1479
 
 
-
 .. _changes_6-6-0b1:
 
 Version 6.6.0b1 — 2022-10-31
@@ -1062,6 +1143,7 @@ Version 5.3.1 — 2020-12-19
 .. _issue 1010: https://github.com/nedbat/coveragepy/issues/1010
 .. _pull request 1066: https://github.com/nedbat/coveragepy/pull/1066
 
+
 .. _changes_53:
 
 Version 5.3 — 2020-09-13
diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt
index 0ba35f628..c3dfef428 100644
--- a/CONTRIBUTORS.txt
+++ b/CONTRIBUTORS.txt
@@ -16,6 +16,7 @@ Alexander Todorov
 Alexander Walters
 Alpha Chen
 Ammar Askar
+Anders Kaseorg
 Andrew Hoos
 Anthony Sottile
 Arcadiy Ivanov
@@ -23,6 +24,7 @@ Aron Griffis
 Artem Dayneko
 Arthur Deygin
 Arthur Rio
+Asher Foa
 Ben Carlsson
 Ben Finney
 Benjamin Parzella
@@ -32,20 +34,26 @@ Bill Hart
 Bradley Burns
 Brandon Rhodes
 Brett Cannon
+Brian Grohe
+Bruno Oliveira
 Bruno P. Kinoshita
 Buck Evan
+Buck Golemon
 Calen Pennington
 Carl Friedrich Bolz-Tereick
 Carl Gieringer
 Catherine Proulx
+Charles Chan
 Chris Adams
 Chris Jerdonek
 Chris Rose
 Chris Warrick
+Christian Clauss
 Christian Heimes
 Christine Lytwynec
 Christoph Blessing
 Christoph Zwerschke
+Christopher Pickering
 Clément Pit-Claudel
 Conrad Ho
 Cosimo Lupo
@@ -59,6 +67,7 @@ David Christian
 David MacIver
 David Stanek
 David Szotten
+Dennis Sweeney
 Detlev Offenbach
 Devin Jeanpierre
 Dirk Thomas
@@ -79,6 +88,7 @@ George-Cristian Bîrzan
 Greg Rogers
 Guido van Rossum
 Guillaume Chazarain
+Holger Krekel
 Hugo van Kemenade
 Ian Moore
 Ilia Meerovich
@@ -87,10 +97,13 @@ Ionel Cristian Mărieș
 Ivan Ciuvalschii
 J. M. F. Tsang
 JT Olds
+Jakub Wilk
+Janakarajan Natarajan
 Jerin Peter George
 Jessamyn Smith
 Joe Doherty
 Joe Jevnik
+John Vandenberg
 Jon Chappell
 Jon Dufresne
 Joseph Tate
@@ -99,21 +112,29 @@ Judson Neer
 Julian Berman
 Julien Voisin
 Justas Sadzevičius
+Karthikeyan Singaravelan
 Kassandra Keeton
+Kevin Brown-Silva
 Kjell Braden
 Krystian Kichewko
 Kyle Altendorf
 Lars Hupfeldt Nielsen
+Latrice Wilgus
 Leonardo Pistone
 Lewis Gaul
 Lex Berezhny
 Loïc Dachary
 Lorenzo Micò
+Louis Heredero
+Luis Nell
+Łukasz Stolcman
 Manuel Jacob
 Marc Abramowitz
+Marc Gibbons
 Marc Legendre
 Marcelo Trylesinski
 Marcus Cobden
+Mariatta
 Marius Gedminas
 Mark van der Wal
 Martin Fuzzey
@@ -123,41 +144,53 @@ Matthew Boehm
 Matthew Desmarais
 Matus Valo
 Max Linke
+Mayank Singhal
 Michael Krebs
 Michał Bultrowicz
 Michał Górny
 Mickie Betz
 Mike Fiedler
+Min ho Kim
 Nathan Land
+Naveen Srinivasan
 Naveen Yadav
 Neil Pilgrim
+Nicholas Nadeau
 Nikita Bloshchanevich
+Nikita Sobolev
 Nils Kattenbeck
 Noel O'Boyle
+Oleg Höfling
 Oleh Krehel
 Olivier Grisel
 Ori Avtalion
 Pablo Carballo
 Pankaj Pandey
 Patrick Mezard
+Pavel Tsialnou
 Peter Baughman
 Peter Ebden
 Peter Portante
 Phebe Polk
 Reya B
+Ricardo Newbery
 Rodrigue Cloutier
 Roger Hu
+Roland Illig
 Ross Lawley
 Roy Williams
 Russell Keith-Magee
+S. Y. Lee
 Salvatore Zagaria
 Sandra Martocchia
 Scott Belden
 Sebastián Ramírez
 Sergey B Kirpichev
+Shantanu
 Sigve Tjora
 Simon Willison
 Stan Hu
+Stanisław Pitucha
 Stefan Behnel
 Stephan Deibel
 Stephan Richter
@@ -167,15 +200,17 @@ Steve Leonard
 Steve Oswald
 Steve Peak
 Sviatoslav Sydorenko
-S. Y. Lee
 Teake Nutma
 Ted Wexler
 Thijs Triemstra
 Thomas Grainger
+Timo Furrer
 Titus Brown
+Tom Gurion
 Valentin Lab
 Ville Skyttä
 Vince Salvino
+Wonwin McBrootles
 Xie Yanbo
 Yilei "Dolee" Yang
 Yury Selivanov
diff --git a/Makefile b/Makefile
index f82f2ee27..b5276a944 100644
--- a/Makefile
+++ b/Makefile
@@ -20,6 +20,7 @@ clean_platform:
 clean: clean_platform			## Remove artifacts of test execution, installation, etc.
 	@echo "Cleaning..."
 	@-pip uninstall -yq coverage
+	@mkdir -p build	# so the chmod won't fail if build doesn't exist
 	@chmod -R 777 build
 	@rm -rf build coverage.egg-info dist htmlcov
 	@rm -f *.bak */*.bak */*/*.bak */*/*/*.bak */*/*/*/*.bak */*/*/*/*/*.bak
@@ -31,7 +32,7 @@ clean: clean_platform			## Remove artifacts of test execution, installation, etc
 	@rm -f tests/covmain.zip tests/zipmods.zip tests/zip1.zip
 	@rm -rf doc/_build doc/_spell doc/sample_html_beta
 	@rm -rf tmp
-	@rm -rf .cache .hypothesis .mypy_cache .pytest_cache
+	@rm -rf .cache .hypothesis .*_cache
 	@rm -rf tests/actual
 	@-make -C tests/gold/html clean
 
diff --git a/README.rst b/README.rst
index 897f8801d..5e4024d87 100644
--- a/README.rst
+++ b/README.rst
@@ -28,7 +28,7 @@ Coverage.py runs on these versions of Python:
 
 .. PYVERSIONS
 
-* CPython 3.7 through 3.12.0a7
+* CPython 3.7 through 3.12.0b1
 * PyPy3 7.3.11.
 
 Documentation is on `Read the Docs`_.  Code repository and issue tracker are on
@@ -39,6 +39,7 @@ Documentation is on `Read the Docs`_.  Code repository and issue tracker are on
 
 **New in 7.x:**
 improved data combining;
+``[run] exclude_also`` setting;
 ``report --format=``;
 type annotations.
 
diff --git a/coverage/__init__.py b/coverage/__init__.py
index 054e37dff..e3ed23223 100644
--- a/coverage/__init__.py
+++ b/coverage/__init__.py
@@ -14,8 +14,6 @@
 # so disable its warning.
 # pylint: disable=useless-import-alias
 
-import sys
-
 from coverage.version import (
     __version__ as __version__,
     version_info as version_info,
diff --git a/coverage/annotate.py b/coverage/annotate.py
index b4a02cb47..2ef89c967 100644
--- a/coverage/annotate.py
+++ b/coverage/annotate.py
@@ -13,7 +13,7 @@
 from coverage.files import flat_rootname
 from coverage.misc import ensure_dir, isolate_module
 from coverage.plugin import FileReporter
-from coverage.report import get_analysis_to_report
+from coverage.report_core import get_analysis_to_report
 from coverage.results import Analysis
 from coverage.types import TMorf
 
diff --git a/coverage/cmdline.py b/coverage/cmdline.py
index 4498eeec3..55f6c793e 100644
--- a/coverage/cmdline.py
+++ b/coverage/cmdline.py
@@ -952,13 +952,12 @@ def unglob_args(args: List[str]) -> List[str]:
         Use "{program_name} help <command>" for detailed help on any command.
     """,
 
-    "minimum_help": """\
-        Code coverage for Python, version {__version__} {extension_modifier}.  Use '{program_name} help' for help.
-    """,
+    "minimum_help": (
+        "Code coverage for Python, version {__version__} {extension_modifier}.  " +
+        "Use '{program_name} help' for help."
+    ),
 
-    "version": """\
-        Coverage.py, version {__version__} {extension_modifier}
-    """,
+    "version": "Coverage.py, version {__version__} {extension_modifier}",
 }
 
 
diff --git a/coverage/collector.py b/coverage/collector.py
index 2f8c17520..ca7f5d94b 100644
--- a/coverage/collector.py
+++ b/coverage/collector.py
@@ -456,7 +456,7 @@ def mapped_file_dict(self, d: Mapping[str, T]) -> Dict[str, T]:
             assert isinstance(runtime_err, Exception)
             raise runtime_err
 
-        return {self.cached_mapped_file(k): v for k, v in items}
+        return {self.cached_mapped_file(k): v for k, v in items if v}
 
     def plugin_was_disabled(self, plugin: CoveragePlugin) -> None:
         """Record that `plugin` was disabled during the run."""
diff --git a/coverage/control.py b/coverage/control.py
index e405a5bf4..723c4d876 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -29,7 +29,9 @@
 from coverage.config import CoverageConfig, read_coverage_config
 from coverage.context import should_start_context_test_function, combine_context_switchers
 from coverage.data import CoverageData, combine_parallel_data
-from coverage.debug import DebugControl, NoDebugging, short_stack, write_formatted_info
+from coverage.debug import (
+    DebugControl, NoDebugging, short_stack, write_formatted_info, relevant_environment_display
+)
 from coverage.disposition import disposition_debug_msg
 from coverage.exceptions import ConfigError, CoverageException, CoverageWarning, PluginError
 from coverage.files import PathAliases, abs_file, relative_filename, set_relative_directory
@@ -37,15 +39,15 @@
 from coverage.inorout import InOrOut
 from coverage.jsonreport import JsonReporter
 from coverage.lcovreport import LcovReporter
-from coverage.misc import bool_or_none, join_regex, human_sorted
+from coverage.misc import bool_or_none, join_regex
 from coverage.misc import DefaultValue, ensure_dir_for_file, isolate_module
 from coverage.multiproc import patch_multiprocessing
 from coverage.plugin import FileReporter
 from coverage.plugin_support import Plugins
 from coverage.python import PythonFileReporter
-from coverage.report import render_report
+from coverage.report import SummaryReporter
+from coverage.report_core import render_report
 from coverage.results import Analysis
-from coverage.summary import SummaryReporter
 from coverage.types import (
     FilePath, TConfigurable, TConfigSectionIn, TConfigValueIn, TConfigValueOut,
     TFileDisposition, TLineNo, TMorf,
@@ -1298,14 +1300,7 @@ def plugin_info(plugins: List[Any]) -> List[str]:
             ("pid", os.getpid()),
             ("cwd", os.getcwd()),
             ("path", sys.path),
-            ("environment", human_sorted(
-                f"{k} = {v}"
-                for k, v in os.environ.items()
-                if (
-                    any(slug in k for slug in ("COV", "PY")) or
-                    (k in ("HOME", "TEMP", "TMP"))
-                )
-            )),
+            ("environment", [f"{k} = {v}" for k, v in relevant_environment_display(os.environ)]),
             ("command_line", " ".join(getattr(sys, "argv", ["-none-"]))),
         ]
 
diff --git a/coverage/debug.py b/coverage/debug.py
index 3ef6dae8a..3484792e2 100644
--- a/coverage/debug.py
+++ b/coverage/debug.py
@@ -12,16 +12,18 @@
 import itertools
 import os
 import pprint
+import re
 import reprlib
 import sys
 import types
 import _thread
 
 from typing import (
-    Any, Callable, IO, Iterable, Iterator, Optional, List, Tuple, cast,
+    cast,
+    Any, Callable, IO, Iterable, Iterator, Mapping, Optional, List, Tuple,
 )
 
-from coverage.misc import isolate_module
+from coverage.misc import human_sorted_items, isolate_module
 from coverage.types import TWritable
 
 os = isolate_module(os)
@@ -489,3 +491,34 @@ def _clean_stack_line(s: str) -> str:                       # pragma: debugging
     s = s.replace(os.path.dirname(os.__file__) + "/", "")
     s = s.replace(sys.prefix + "/", "")
     return s
+
+
+def relevant_environment_display(env: Mapping[str, str]) -> List[Tuple[str, str]]:
+    """Filter environment variables for a debug display.
+
+    Select variables to display (with COV or PY in the name, or HOME, TEMP, or
+    TMP), and also cloak sensitive values with asterisks.
+
+    Arguments:
+        env: a dict of environment variable names and values.
+
+    Returns:
+        A list of pairs (name, value) to show.
+
+    """
+    slugs = {"COV", "PY"}
+    include = {"HOME", "TEMP", "TMP"}
+    cloak = {"API", "TOKEN", "KEY", "SECRET", "PASS", "SIGNATURE"}
+
+    to_show = []
+    for name, val in env.items():
+        keep = False
+        if name in include:
+            keep = True
+        elif any(slug in name for slug in slugs):
+            keep = True
+        if keep:
+            if any(slug in name for slug in cloak):
+                val = re.sub(r"\w", "*", val)
+            to_show.append((name, val))
+    return human_sorted_items(to_show)
diff --git a/coverage/env.py b/coverage/env.py
index bdc2c7854..3370970e3 100644
--- a/coverage/env.py
+++ b/coverage/env.py
@@ -40,13 +40,10 @@ class PYBEHAVIOR:
 
     # Does Python conform to PEP626, Precise line numbers for debugging and other tools.
     # https://www.python.org/dev/peps/pep-0626
-    pep626 = CPYTHON and (PYVERSION > (3, 10, 0, "alpha", 4))
+    pep626 = (PYVERSION > (3, 10, 0, "alpha", 4))
 
     # Is "if __debug__" optimized away?
-    if PYPY:
-        optimize_if_debug = True
-    else:
-        optimize_if_debug = not pep626
+    optimize_if_debug = not pep626
 
     # Is "if not __debug__" optimized away? The exact details have changed
     # across versions.
@@ -137,6 +134,10 @@ class PYBEHAVIOR:
     # only a 0-number line, which is ignored, giving a truly empty module.
     empty_is_empty = (PYVERSION >= (3, 11, 0, "beta", 4))
 
+    # Are comprehensions inlined (new) or compiled as called functions (old)?
+    # Changed in https://github.com/python/cpython/pull/101441
+    comprehensions_are_functions = (PYVERSION <= (3, 12, 0, "alpha", 7, 0))
+
 # Coverage.py specifics.
 
 # Are we using the C-implemented trace function?
diff --git a/coverage/files.py b/coverage/files.py
index 2a1177340..925d57723 100644
--- a/coverage/files.py
+++ b/coverage/files.py
@@ -209,9 +209,8 @@ def prep_patterns(patterns: Iterable[str]) -> List[str]:
     """
     prepped = []
     for p in patterns or []:
-        if p.startswith(("*", "?")):
-            prepped.append(p)
-        else:
+        prepped.append(p)
+        if not p.startswith(("*", "?")):
             prepped.append(abs_file(p))
     return prepped
 
diff --git a/coverage/html.py b/coverage/html.py
index 570760604..532eb66c2 100644
--- a/coverage/html.py
+++ b/coverage/html.py
@@ -22,8 +22,8 @@
 from coverage.exceptions import NoDataError
 from coverage.files import flat_rootname
 from coverage.misc import ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime
-from coverage.misc import human_sorted, plural
-from coverage.report import get_analysis_to_report
+from coverage.misc import human_sorted, plural, stdout_link
+from coverage.report_core import get_analysis_to_report
 from coverage.results import Analysis, Numbers
 from coverage.templite import Templite
 from coverage.types import TLineNo, TMorf
@@ -493,7 +493,9 @@ def index_file(self, first_html: str, final_html: str) -> None:
 
         index_file = os.path.join(self.directory, "index.html")
         write_html(index_file, html)
-        self.coverage._message(f"Wrote HTML report to {index_file}")
+
+        print_href = stdout_link(index_file, f"file://{os.path.abspath(index_file)}")
+        self.coverage._message(f"Wrote HTML report to {print_href}")
 
         # Write the latest hashes for next time.
         self.incr.write()
diff --git a/coverage/inorout.py b/coverage/inorout.py
index ff46bac0d..d2dbdcdf7 100644
--- a/coverage/inorout.py
+++ b/coverage/inorout.py
@@ -528,7 +528,7 @@ def find_possibly_unexecuted_files(self) -> Iterable[Tuple[str, Optional[str]]]:
         Yields pairs: file path, and responsible plug-in name.
         """
         for pkg in self.source_pkgs:
-            if (not pkg in sys.modules or
+            if (pkg not in sys.modules or
                 not module_has_file(sys.modules[pkg])):
                 continue
             pkg_file = source_for_file(cast(str, sys.modules[pkg].__file__))
diff --git a/coverage/jsonreport.py b/coverage/jsonreport.py
index 24e33585c..9780e261a 100644
--- a/coverage/jsonreport.py
+++ b/coverage/jsonreport.py
@@ -12,7 +12,7 @@
 from typing import Any, Dict, IO, Iterable, List, Optional, Tuple, TYPE_CHECKING
 
 from coverage import __version__
-from coverage.report import get_analysis_to_report
+from coverage.report_core import get_analysis_to_report
 from coverage.results import Analysis, Numbers
 from coverage.types import TMorf, TLineNo
 
diff --git a/coverage/lcovreport.py b/coverage/lcovreport.py
index 7d72e8135..3da164d5d 100644
--- a/coverage/lcovreport.py
+++ b/coverage/lcovreport.py
@@ -5,20 +5,25 @@
 
 from __future__ import annotations
 
-import sys
 import base64
-from hashlib import md5
+import hashlib
+import sys
 
 from typing import IO, Iterable, Optional, TYPE_CHECKING
 
 from coverage.plugin import FileReporter
-from coverage.report import get_analysis_to_report
+from coverage.report_core import get_analysis_to_report
 from coverage.results import Analysis, Numbers
 from coverage.types import TMorf
 
 if TYPE_CHECKING:
     from coverage import Coverage
-    from coverage.data import CoverageData
+
+
+def line_hash(line: str) -> str:
+    """Produce a hash of a source line for use in the LCOV file."""
+    hashed = hashlib.md5(line.encode("utf-8")).digest()
+    return base64.b64encode(hashed).decode("ascii").rstrip("=")
 
 
 class LcovReporter:
@@ -69,17 +74,17 @@ def get_lcov(self, fr: FileReporter, analysis: Analysis, outfile: IO[str]) -> No
             # characters of the encoding ("==") are removed from the hash to
             # allow genhtml to run on the resulting lcov file.
             if source_lines:
-                line = source_lines[covered-1].encode("utf-8")
+                if covered-1 >= len(source_lines):
+                    break
+                line = source_lines[covered-1]
             else:
-                line = b""
-            hashed = base64.b64encode(md5(line).digest()).decode().rstrip("=")
-            outfile.write(f"DA:{covered},1,{hashed}\n")
+                line = ""
+            outfile.write(f"DA:{covered},1,{line_hash(line)}\n")
 
         for missed in sorted(analysis.missing):
             assert source_lines
-            line = source_lines[missed-1].encode("utf-8")
-            hashed = base64.b64encode(md5(line).digest()).decode().rstrip("=")
-            outfile.write(f"DA:{missed},0,{hashed}\n")
+            line = source_lines[missed-1]
+            outfile.write(f"DA:{missed},0,{line_hash(line)}\n")
 
         outfile.write(f"LF:{analysis.numbers.n_statements}\n")
         outfile.write(f"LH:{analysis.numbers.n_executed}\n")
diff --git a/coverage/misc.py b/coverage/misc.py
index 8cefa12e0..908b0dd24 100644
--- a/coverage/misc.py
+++ b/coverage/misc.py
@@ -386,3 +386,15 @@ def plural(n: int, thing: str = "", things: str = "") -> str:
         return thing
     else:
         return things or (thing + "s")
+
+
+def stdout_link(text: str, url: str) -> str:
+    """Format text+url as a clickable link for stdout.
+
+    If attached to a terminal, use escape sequences. Otherwise, just return
+    the text.
+    """
+    if hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
+        return f"\033]8;;{url}\a{text}\033]8;;\a"
+    else:
+        return text
diff --git a/coverage/parser.py b/coverage/parser.py
index e653a9ccd..51a5a52da 100644
--- a/coverage/parser.py
+++ b/coverage/parser.py
@@ -251,7 +251,7 @@ def parse_source(self) -> None:
         """
         try:
             self._raw_parse()
-        except (tokenize.TokenError, IndentationError) as err:
+        except (tokenize.TokenError, IndentationError, SyntaxError) as err:
             if hasattr(err, "lineno"):
                 lineno = err.lineno         # IndentationError
             else:
@@ -1343,9 +1343,10 @@ def _code_object__ClassDef(self, node: ast.ClassDef) -> None:
 
     _code_object__Lambda = _make_expression_code_method("lambda")
     _code_object__GeneratorExp = _make_expression_code_method("generator expression")
-    _code_object__DictComp = _make_expression_code_method("dictionary comprehension")
-    _code_object__SetComp = _make_expression_code_method("set comprehension")
-    _code_object__ListComp = _make_expression_code_method("list comprehension")
+    if env.PYBEHAVIOR.comprehensions_are_functions:
+        _code_object__DictComp = _make_expression_code_method("dictionary comprehension")
+        _code_object__SetComp = _make_expression_code_method("set comprehension")
+        _code_object__ListComp = _make_expression_code_method("list comprehension")
 
 
 # Code only used when dumping the AST for debugging.
diff --git a/coverage/report.py b/coverage/report.py
index 09eed0a82..e1c7a071d 100644
--- a/coverage/report.py
+++ b/coverage/report.py
@@ -1,117 +1,281 @@
 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
 
-"""Reporter foundation for coverage.py."""
+"""Summary reporting"""
 
 from __future__ import annotations
 
 import sys
 
-from typing import Callable, Iterable, Iterator, IO, Optional, Tuple, TYPE_CHECKING
+from typing import Any, IO, Iterable, List, Optional, Tuple, TYPE_CHECKING
 
-from coverage.exceptions import NoDataError, NotPython
-from coverage.files import prep_patterns, GlobMatcher
-from coverage.misc import ensure_dir_for_file, file_be_gone
+from coverage.exceptions import ConfigError, NoDataError
+from coverage.misc import human_sorted_items
 from coverage.plugin import FileReporter
-from coverage.results import Analysis
-from coverage.types import Protocol, TMorf
+from coverage.report_core import get_analysis_to_report
+from coverage.results import Analysis, Numbers
+from coverage.types import TMorf
 
 if TYPE_CHECKING:
     from coverage import Coverage
 
 
-class Reporter(Protocol):
-    """What we expect of reporters."""
-
-    report_type: str
-
-    def report(self, morfs: Optional[Iterable[TMorf]], outfile: IO[str]) -> float:
-        """Generate a report of `morfs`, written to `outfile`."""
-
-
-def render_report(
-    output_path: str,
-    reporter: Reporter,
-    morfs: Optional[Iterable[TMorf]],
-    msgfn: Callable[[str], None],
-) -> float:
-    """Run a one-file report generator, managing the output file.
-
-    This function ensures the output file is ready to be written to. Then writes
-    the report to it. Then closes the file and cleans up.
-
-    """
-    file_to_close = None
-    delete_file = False
-
-    if output_path == "-":
-        outfile = sys.stdout
-    else:
-        # Ensure that the output directory is created; done here because this
-        # report pre-opens the output file.  HtmlReporter does this on its own
-        # because its task is more complex, being multiple files.
-        ensure_dir_for_file(output_path)
-        outfile = open(output_path, "w", encoding="utf-8")
-        file_to_close = outfile
-        delete_file = True
-
-    try:
-        ret = reporter.report(morfs, outfile=outfile)
-        if file_to_close is not None:
-            msgfn(f"Wrote {reporter.report_type} to {output_path}")
-        delete_file = False
-        return ret
-    finally:
-        if file_to_close is not None:
-            file_to_close.close()
-            if delete_file:
-                file_be_gone(output_path)           # pragma: part covered (doesn't return)
-
-
-def get_analysis_to_report(
-    coverage: Coverage,
-    morfs: Optional[Iterable[TMorf]],
-) -> Iterator[Tuple[FileReporter, Analysis]]:
-    """Get the files to report on.
-
-    For each morf in `morfs`, if it should be reported on (based on the omit
-    and include configuration options), yield a pair, the `FileReporter` and
-    `Analysis` for the morf.
-
-    """
-    file_reporters = coverage._get_file_reporters(morfs)
-    config = coverage.config
-
-    if config.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 = 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:
-        raise NoDataError("No data to report.")
-
-    for fr in sorted(file_reporters):
-        try:
-            analysis = coverage._analyze(fr)
-        except NotPython:
-            # Only report errors for .py files, and only if we didn't
-            # explicitly suppress those errors.
-            # NotPython is only raised by PythonFileReporter, which has a
-            # should_be_python() method.
-            if fr.should_be_python():       # type: ignore[attr-defined]
-                if config.ignore_errors:
-                    msg = f"Couldn't parse Python file '{fr.filename}'"
-                    coverage._warn(msg, slug="couldnt-parse")
-                else:
-                    raise
-        except Exception as exc:
-            if config.ignore_errors:
-                msg = f"Couldn't parse '{fr.filename}': {exc}".rstrip()
-                coverage._warn(msg, slug="couldnt-parse")
+class SummaryReporter:
+    """A reporter for writing the summary report."""
+
+    def __init__(self, coverage: Coverage) -> None:
+        self.coverage = coverage
+        self.config = self.coverage.config
+        self.branches = coverage.get_data().has_arcs()
+        self.outfile: Optional[IO[str]] = None
+        self.output_format = self.config.format or "text"
+        if self.output_format not in {"text", "markdown", "total"}:
+            raise ConfigError(f"Unknown report format choice: {self.output_format!r}")
+        self.fr_analysis: List[Tuple[FileReporter, Analysis]] = []
+        self.skipped_count = 0
+        self.empty_count = 0
+        self.total = Numbers(precision=self.config.precision)
+
+    def write(self, line: str) -> None:
+        """Write a line to the output, adding a newline."""
+        assert self.outfile is not None
+        self.outfile.write(line.rstrip())
+        self.outfile.write("\n")
+
+    def write_items(self, items: Iterable[str]) -> None:
+        """Write a list of strings, joined together."""
+        self.write("".join(items))
+
+    def _report_text(
+        self,
+        header: List[str],
+        lines_values: List[List[Any]],
+        total_line: List[Any],
+        end_lines: List[str],
+    ) -> None:
+        """Internal method that prints report data in text format.
+
+        `header` is a list with captions.
+        `lines_values` is list of lists of sortable values.
+        `total_line` is a list with values of the total line.
+        `end_lines` is a list of ending lines with information about skipped files.
+
+        """
+        # Prepare the formatting strings, header, and column sorting.
+        max_name = max([len(line[0]) for line in lines_values] + [5]) + 1
+        max_n = max(len(total_line[header.index("Cover")]) + 2, len(" Cover")) + 1
+        max_n = max([max_n] + [len(line[header.index("Cover")]) + 2 for line in lines_values])
+        formats = dict(
+            Name="{:{name_len}}",
+            Stmts="{:>7}",
+            Miss="{:>7}",
+            Branch="{:>7}",
+            BrPart="{:>7}",
+            Cover="{:>{n}}",
+            Missing="{:>10}",
+        )
+        header_items = [
+            formats[item].format(item, name_len=max_name, n=max_n)
+            for item in header
+        ]
+        header_str = "".join(header_items)
+        rule = "-" * len(header_str)
+
+        # Write the header
+        self.write(header_str)
+        self.write(rule)
+
+        formats.update(dict(Cover="{:>{n}}%"), Missing="   {:9}")
+        for values in lines_values:
+            # build string with line values
+            line_items = [
+                formats[item].format(str(value),
+                name_len=max_name, n=max_n-1) for item, value in zip(header, values)
+            ]
+            self.write_items(line_items)
+
+        # Write a TOTAL line
+        if lines_values:
+            self.write(rule)
+
+        line_items = [
+            formats[item].format(str(value),
+            name_len=max_name, n=max_n-1) for item, value in zip(header, total_line)
+        ]
+        self.write_items(line_items)
+
+        for end_line in end_lines:
+            self.write(end_line)
+
+    def _report_markdown(
+        self,
+        header: List[str],
+        lines_values: List[List[Any]],
+        total_line: List[Any],
+        end_lines: List[str],
+    ) -> None:
+        """Internal method that prints report data in markdown format.
+
+        `header` is a list with captions.
+        `lines_values` is a sorted list of lists containing coverage information.
+        `total_line` is a list with values of the total line.
+        `end_lines` is a list of ending lines with information about skipped files.
+
+        """
+        # Prepare the formatting strings, header, and column sorting.
+        max_name = max((len(line[0].replace("_", "\\_")) for line in lines_values), default=0)
+        max_name = max(max_name, len("**TOTAL**")) + 1
+        formats = dict(
+            Name="| {:{name_len}}|",
+            Stmts="{:>9} |",
+            Miss="{:>9} |",
+            Branch="{:>9} |",
+            BrPart="{:>9} |",
+            Cover="{:>{n}} |",
+            Missing="{:>10} |",
+        )
+        max_n = max(len(total_line[header.index("Cover")]) + 6, len(" Cover "))
+        header_items = [formats[item].format(item, name_len=max_name, n=max_n) for item in header]
+        header_str = "".join(header_items)
+        rule_str = "|" + " ".join(["- |".rjust(len(header_items[0])-1, "-")] +
+            ["-: |".rjust(len(item)-1, "-") for item in header_items[1:]]
+        )
+
+        # Write the header
+        self.write(header_str)
+        self.write(rule_str)
+
+        for values in lines_values:
+            # build string with line values
+            formats.update(dict(Cover="{:>{n}}% |"))
+            line_items = [
+                formats[item].format(str(value).replace("_", "\\_"), name_len=max_name, n=max_n-1)
+                for item, value in zip(header, values)
+            ]
+            self.write_items(line_items)
+
+        # Write the TOTAL line
+        formats.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |"))
+        total_line_items: List[str] = []
+        for item, value in zip(header, total_line):
+            if value == "":
+                insert = value
+            elif item == "Cover":
+                insert = f" **{value}%**"
             else:
-                raise
+                insert = f" **{value}**"
+            total_line_items += formats[item].format(insert, name_len=max_name, n=max_n)
+        self.write_items(total_line_items)
+        for end_line in end_lines:
+            self.write(end_line)
+
+    def report(self, morfs: Optional[Iterable[TMorf]], outfile: Optional[IO[str]] = None) -> float:
+        """Writes a report summarizing coverage statistics per module.
+
+        `outfile` is a text-mode file object to write the summary to.
+
+        """
+        self.outfile = outfile or sys.stdout
+
+        self.coverage.get_data().set_query_contexts(self.config.report_contexts)
+        for fr, analysis in get_analysis_to_report(self.coverage, morfs):
+            self.report_one_file(fr, analysis)
+
+        if not self.total.n_files and not self.skipped_count:
+            raise NoDataError("No data to report.")
+
+        if self.output_format == "total":
+            self.write(self.total.pc_covered_str)
+        else:
+            self.tabular_report()
+
+        return self.total.pc_covered
+
+    def tabular_report(self) -> None:
+        """Writes tabular report formats."""
+        # Prepare the header line and column sorting.
+        header = ["Name", "Stmts", "Miss"]
+        if self.branches:
+            header += ["Branch", "BrPart"]
+        header += ["Cover"]
+        if self.config.show_missing:
+            header += ["Missing"]
+
+        column_order = dict(name=0, stmts=1, miss=2, cover=-1)
+        if self.branches:
+            column_order.update(dict(branch=3, brpart=4))
+
+        # `lines_values` is list of lists of sortable values.
+        lines_values = []
+
+        for (fr, analysis) in self.fr_analysis:
+            nums = analysis.numbers
+
+            args = [fr.relative_filename(), nums.n_statements, nums.n_missing]
+            if self.branches:
+                args += [nums.n_branches, nums.n_partial_branches]
+            args += [nums.pc_covered_str]
+            if self.config.show_missing:
+                args += [analysis.missing_formatted(branches=True)]
+            args += [nums.pc_covered]
+            lines_values.append(args)
+
+        # Line sorting.
+        sort_option = (self.config.sort or "name").lower()
+        reverse = False
+        if sort_option[0] == "-":
+            reverse = True
+            sort_option = sort_option[1:]
+        elif sort_option[0] == "+":
+            sort_option = sort_option[1:]
+        sort_idx = column_order.get(sort_option)
+        if sort_idx is None:
+            raise ConfigError(f"Invalid sorting option: {self.config.sort!r}")
+        if sort_option == "name":
+            lines_values = human_sorted_items(lines_values, reverse=reverse)
+        else:
+            lines_values.sort(
+                key=lambda line: (line[sort_idx], line[0]),     # type: ignore[index]
+                reverse=reverse,
+            )
+
+        # Calculate total if we had at least one file.
+        total_line = ["TOTAL", self.total.n_statements, self.total.n_missing]
+        if self.branches:
+            total_line += [self.total.n_branches, self.total.n_partial_branches]
+        total_line += [self.total.pc_covered_str]
+        if self.config.show_missing:
+            total_line += [""]
+
+        # Create other final lines.
+        end_lines = []
+        if self.config.skip_covered and self.skipped_count:
+            file_suffix = "s" if self.skipped_count>1 else ""
+            end_lines.append(
+                f"\n{self.skipped_count} file{file_suffix} skipped due to complete coverage."
+            )
+        if self.config.skip_empty and self.empty_count:
+            file_suffix = "s" if self.empty_count > 1 else ""
+            end_lines.append(f"\n{self.empty_count} empty file{file_suffix} skipped.")
+
+        if self.output_format == "markdown":
+            formatter = self._report_markdown
+        else:
+            formatter = self._report_text
+        formatter(header, lines_values, total_line, end_lines)
+
+    def report_one_file(self, fr: FileReporter, analysis: Analysis) -> None:
+        """Report on just one file, the callback from report()."""
+        nums = analysis.numbers
+        self.total += nums
+
+        no_missing_lines = (nums.n_missing == 0)
+        no_missing_branches = (nums.n_partial_branches == 0)
+        if self.config.skip_covered and no_missing_lines and no_missing_branches:
+            # Don't report on 100% files.
+            self.skipped_count += 1
+        elif self.config.skip_empty and nums.n_statements == 0:
+            # Don't report on empty files.
+            self.empty_count += 1
         else:
-            yield (fr, analysis)
+            self.fr_analysis.append((fr, analysis))
diff --git a/coverage/report_core.py b/coverage/report_core.py
new file mode 100644
index 000000000..09eed0a82
--- /dev/null
+++ b/coverage/report_core.py
@@ -0,0 +1,117 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
+
+"""Reporter foundation for coverage.py."""
+
+from __future__ import annotations
+
+import sys
+
+from typing import Callable, Iterable, Iterator, IO, Optional, Tuple, TYPE_CHECKING
+
+from coverage.exceptions import NoDataError, NotPython
+from coverage.files import prep_patterns, GlobMatcher
+from coverage.misc import ensure_dir_for_file, file_be_gone
+from coverage.plugin import FileReporter
+from coverage.results import Analysis
+from coverage.types import Protocol, TMorf
+
+if TYPE_CHECKING:
+    from coverage import Coverage
+
+
+class Reporter(Protocol):
+    """What we expect of reporters."""
+
+    report_type: str
+
+    def report(self, morfs: Optional[Iterable[TMorf]], outfile: IO[str]) -> float:
+        """Generate a report of `morfs`, written to `outfile`."""
+
+
+def render_report(
+    output_path: str,
+    reporter: Reporter,
+    morfs: Optional[Iterable[TMorf]],
+    msgfn: Callable[[str], None],
+) -> float:
+    """Run a one-file report generator, managing the output file.
+
+    This function ensures the output file is ready to be written to. Then writes
+    the report to it. Then closes the file and cleans up.
+
+    """
+    file_to_close = None
+    delete_file = False
+
+    if output_path == "-":
+        outfile = sys.stdout
+    else:
+        # Ensure that the output directory is created; done here because this
+        # report pre-opens the output file.  HtmlReporter does this on its own
+        # because its task is more complex, being multiple files.
+        ensure_dir_for_file(output_path)
+        outfile = open(output_path, "w", encoding="utf-8")
+        file_to_close = outfile
+        delete_file = True
+
+    try:
+        ret = reporter.report(morfs, outfile=outfile)
+        if file_to_close is not None:
+            msgfn(f"Wrote {reporter.report_type} to {output_path}")
+        delete_file = False
+        return ret
+    finally:
+        if file_to_close is not None:
+            file_to_close.close()
+            if delete_file:
+                file_be_gone(output_path)           # pragma: part covered (doesn't return)
+
+
+def get_analysis_to_report(
+    coverage: Coverage,
+    morfs: Optional[Iterable[TMorf]],
+) -> Iterator[Tuple[FileReporter, Analysis]]:
+    """Get the files to report on.
+
+    For each morf in `morfs`, if it should be reported on (based on the omit
+    and include configuration options), yield a pair, the `FileReporter` and
+    `Analysis` for the morf.
+
+    """
+    file_reporters = coverage._get_file_reporters(morfs)
+    config = coverage.config
+
+    if config.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 = 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:
+        raise NoDataError("No data to report.")
+
+    for fr in sorted(file_reporters):
+        try:
+            analysis = coverage._analyze(fr)
+        except NotPython:
+            # Only report errors for .py files, and only if we didn't
+            # explicitly suppress those errors.
+            # NotPython is only raised by PythonFileReporter, which has a
+            # should_be_python() method.
+            if fr.should_be_python():       # type: ignore[attr-defined]
+                if config.ignore_errors:
+                    msg = f"Couldn't parse Python file '{fr.filename}'"
+                    coverage._warn(msg, slug="couldnt-parse")
+                else:
+                    raise
+        except Exception as exc:
+            if config.ignore_errors:
+                msg = f"Couldn't parse '{fr.filename}': {exc}".rstrip()
+                coverage._warn(msg, slug="couldnt-parse")
+            else:
+                raise
+        else:
+            yield (fr, analysis)
diff --git a/coverage/summary.py b/coverage/summary.py
deleted file mode 100644
index 5d373ec52..000000000
--- a/coverage/summary.py
+++ /dev/null
@@ -1,281 +0,0 @@
-# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
-# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
-
-"""Summary reporting"""
-
-from __future__ import annotations
-
-import sys
-
-from typing import Any, IO, Iterable, List, Optional, Tuple, TYPE_CHECKING
-
-from coverage.exceptions import ConfigError, NoDataError
-from coverage.misc import human_sorted_items
-from coverage.plugin import FileReporter
-from coverage.report import get_analysis_to_report
-from coverage.results import Analysis, Numbers
-from coverage.types import TMorf
-
-if TYPE_CHECKING:
-    from coverage import Coverage
-
-
-class SummaryReporter:
-    """A reporter for writing the summary report."""
-
-    def __init__(self, coverage: Coverage) -> None:
-        self.coverage = coverage
-        self.config = self.coverage.config
-        self.branches = coverage.get_data().has_arcs()
-        self.outfile: Optional[IO[str]] = None
-        self.output_format = self.config.format or "text"
-        if self.output_format not in {"text", "markdown", "total"}:
-            raise ConfigError(f"Unknown report format choice: {self.output_format!r}")
-        self.fr_analysis: List[Tuple[FileReporter, Analysis]] = []
-        self.skipped_count = 0
-        self.empty_count = 0
-        self.total = Numbers(precision=self.config.precision)
-
-    def write(self, line: str) -> None:
-        """Write a line to the output, adding a newline."""
-        assert self.outfile is not None
-        self.outfile.write(line.rstrip())
-        self.outfile.write("\n")
-
-    def write_items(self, items: Iterable[str]) -> None:
-        """Write a list of strings, joined together."""
-        self.write("".join(items))
-
-    def _report_text(
-        self,
-        header: List[str],
-        lines_values: List[List[Any]],
-        total_line: List[Any],
-        end_lines: List[str],
-    ) -> None:
-        """Internal method that prints report data in text format.
-
-        `header` is a list with captions.
-        `lines_values` is list of lists of sortable values.
-        `total_line` is a list with values of the total line.
-        `end_lines` is a list of ending lines with information about skipped files.
-
-        """
-        # Prepare the formatting strings, header, and column sorting.
-        max_name = max([len(line[0]) for line in lines_values] + [5]) + 1
-        max_n = max(len(total_line[header.index("Cover")]) + 2, len(" Cover")) + 1
-        max_n = max([max_n] + [len(line[header.index("Cover")]) + 2 for line in lines_values])
-        formats = dict(
-            Name="{:{name_len}}",
-            Stmts="{:>7}",
-            Miss="{:>7}",
-            Branch="{:>7}",
-            BrPart="{:>7}",
-            Cover="{:>{n}}",
-            Missing="{:>10}",
-        )
-        header_items = [
-            formats[item].format(item, name_len=max_name, n=max_n)
-            for item in header
-        ]
-        header_str = "".join(header_items)
-        rule = "-" * len(header_str)
-
-        # Write the header
-        self.write(header_str)
-        self.write(rule)
-
-        formats.update(dict(Cover="{:>{n}}%"), Missing="   {:9}")
-        for values in lines_values:
-            # build string with line values
-            line_items = [
-                formats[item].format(str(value),
-                name_len=max_name, n=max_n-1) for item, value in zip(header, values)
-            ]
-            self.write_items(line_items)
-
-        # Write a TOTAL line
-        if lines_values:
-            self.write(rule)
-
-        line_items = [
-            formats[item].format(str(value),
-            name_len=max_name, n=max_n-1) for item, value in zip(header, total_line)
-        ]
-        self.write_items(line_items)
-
-        for end_line in end_lines:
-            self.write(end_line)
-
-    def _report_markdown(
-        self,
-        header: List[str],
-        lines_values: List[List[Any]],
-        total_line: List[Any],
-        end_lines: List[str],
-    ) -> None:
-        """Internal method that prints report data in markdown format.
-
-        `header` is a list with captions.
-        `lines_values` is a sorted list of lists containing coverage information.
-        `total_line` is a list with values of the total line.
-        `end_lines` is a list of ending lines with information about skipped files.
-
-        """
-        # Prepare the formatting strings, header, and column sorting.
-        max_name = max((len(line[0].replace("_", "\\_")) for line in lines_values), default=0)
-        max_name = max(max_name, len("**TOTAL**")) + 1
-        formats = dict(
-            Name="| {:{name_len}}|",
-            Stmts="{:>9} |",
-            Miss="{:>9} |",
-            Branch="{:>9} |",
-            BrPart="{:>9} |",
-            Cover="{:>{n}} |",
-            Missing="{:>10} |",
-        )
-        max_n = max(len(total_line[header.index("Cover")]) + 6, len(" Cover "))
-        header_items = [formats[item].format(item, name_len=max_name, n=max_n) for item in header]
-        header_str = "".join(header_items)
-        rule_str = "|" + " ".join(["- |".rjust(len(header_items[0])-1, "-")] +
-            ["-: |".rjust(len(item)-1, "-") for item in header_items[1:]]
-        )
-
-        # Write the header
-        self.write(header_str)
-        self.write(rule_str)
-
-        for values in lines_values:
-            # build string with line values
-            formats.update(dict(Cover="{:>{n}}% |"))
-            line_items = [
-                formats[item].format(str(value).replace("_", "\\_"), name_len=max_name, n=max_n-1)
-                for item, value in zip(header, values)
-            ]
-            self.write_items(line_items)
-
-        # Write the TOTAL line
-        formats.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |"))
-        total_line_items: List[str] = []
-        for item, value in zip(header, total_line):
-            if value == "":
-                insert = value
-            elif item == "Cover":
-                insert = f" **{value}%**"
-            else:
-                insert = f" **{value}**"
-            total_line_items += formats[item].format(insert, name_len=max_name, n=max_n)
-        self.write_items(total_line_items)
-        for end_line in end_lines:
-            self.write(end_line)
-
-    def report(self, morfs: Optional[Iterable[TMorf]], outfile: Optional[IO[str]] = None) -> float:
-        """Writes a report summarizing coverage statistics per module.
-
-        `outfile` is a text-mode file object to write the summary to.
-
-        """
-        self.outfile = outfile or sys.stdout
-
-        self.coverage.get_data().set_query_contexts(self.config.report_contexts)
-        for fr, analysis in get_analysis_to_report(self.coverage, morfs):
-            self.report_one_file(fr, analysis)
-
-        if not self.total.n_files and not self.skipped_count:
-            raise NoDataError("No data to report.")
-
-        if self.output_format == "total":
-            self.write(self.total.pc_covered_str)
-        else:
-            self.tabular_report()
-
-        return self.total.pc_covered
-
-    def tabular_report(self) -> None:
-        """Writes tabular report formats."""
-        # Prepare the header line and column sorting.
-        header = ["Name", "Stmts", "Miss"]
-        if self.branches:
-            header += ["Branch", "BrPart"]
-        header += ["Cover"]
-        if self.config.show_missing:
-            header += ["Missing"]
-
-        column_order = dict(name=0, stmts=1, miss=2, cover=-1)
-        if self.branches:
-            column_order.update(dict(branch=3, brpart=4))
-
-        # `lines_values` is list of lists of sortable values.
-        lines_values = []
-
-        for (fr, analysis) in self.fr_analysis:
-            nums = analysis.numbers
-
-            args = [fr.relative_filename(), nums.n_statements, nums.n_missing]
-            if self.branches:
-                args += [nums.n_branches, nums.n_partial_branches]
-            args += [nums.pc_covered_str]
-            if self.config.show_missing:
-                args += [analysis.missing_formatted(branches=True)]
-            args += [nums.pc_covered]
-            lines_values.append(args)
-
-        # Line sorting.
-        sort_option = (self.config.sort or "name").lower()
-        reverse = False
-        if sort_option[0] == "-":
-            reverse = True
-            sort_option = sort_option[1:]
-        elif sort_option[0] == "+":
-            sort_option = sort_option[1:]
-        sort_idx = column_order.get(sort_option)
-        if sort_idx is None:
-            raise ConfigError(f"Invalid sorting option: {self.config.sort!r}")
-        if sort_option == "name":
-            lines_values = human_sorted_items(lines_values, reverse=reverse)
-        else:
-            lines_values.sort(
-                key=lambda line: (line[sort_idx], line[0]),     # type: ignore[index]
-                reverse=reverse,
-            )
-
-        # Calculate total if we had at least one file.
-        total_line = ["TOTAL", self.total.n_statements, self.total.n_missing]
-        if self.branches:
-            total_line += [self.total.n_branches, self.total.n_partial_branches]
-        total_line += [self.total.pc_covered_str]
-        if self.config.show_missing:
-            total_line += [""]
-
-        # Create other final lines.
-        end_lines = []
-        if self.config.skip_covered and self.skipped_count:
-            file_suffix = "s" if self.skipped_count>1 else ""
-            end_lines.append(
-                f"\n{self.skipped_count} file{file_suffix} skipped due to complete coverage."
-            )
-        if self.config.skip_empty and self.empty_count:
-            file_suffix = "s" if self.empty_count > 1 else ""
-            end_lines.append(f"\n{self.empty_count} empty file{file_suffix} skipped.")
-
-        if self.output_format == "markdown":
-            formatter = self._report_markdown
-        else:
-            formatter = self._report_text
-        formatter(header, lines_values, total_line, end_lines)
-
-    def report_one_file(self, fr: FileReporter, analysis: Analysis) -> None:
-        """Report on just one file, the callback from report()."""
-        nums = analysis.numbers
-        self.total += nums
-
-        no_missing_lines = (nums.n_missing == 0)
-        no_missing_branches = (nums.n_partial_branches == 0)
-        if self.config.skip_covered and no_missing_lines and no_missing_branches:
-            # Don't report on 100% files.
-            self.skipped_count += 1
-        elif self.config.skip_empty and nums.n_statements == 0:
-            # Don't report on empty files.
-            self.empty_count += 1
-        else:
-            self.fr_analysis.append((fr, analysis))
diff --git a/coverage/version.py b/coverage/version.py
index 9cf7d9d19..c48974967 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, 2, 3, "final", 0)
+version_info = (7, 2, 7, "final", 0)
 _dev = 0
 
 
diff --git a/coverage/xmlreport.py b/coverage/xmlreport.py
index 2c8fd0cc1..819b4c6bc 100644
--- a/coverage/xmlreport.py
+++ b/coverage/xmlreport.py
@@ -12,12 +12,12 @@
 import xml.dom.minidom
 
 from dataclasses import dataclass
-from typing import Any, Dict, IO, Iterable, Optional, TYPE_CHECKING, cast
+from typing import Any, Dict, IO, Iterable, Optional, TYPE_CHECKING
 
 from coverage import __version__, files
 from coverage.misc import isolate_module, human_sorted, human_sorted_items
 from coverage.plugin import FileReporter
-from coverage.report import get_analysis_to_report
+from coverage.report_core import get_analysis_to_report
 from coverage.results import Analysis
 from coverage.types import TMorf
 from coverage.version import __url__
@@ -67,7 +67,9 @@ def __init__(self, coverage: Coverage) -> None:
         if self.config.source:
             for src in self.config.source:
                 if os.path.exists(src):
-                    if not self.config.relative_files:
+                    if self.config.relative_files:
+                        src = src.rstrip(r"\/")
+                    else:
                         src = files.canonical_filename(src)
                     self.source_paths.add(src)
         self.packages: Dict[str, PackageData] = {}
@@ -255,4 +257,4 @@ def xml_file(self, fr: FileReporter, analysis: Analysis, has_arcs: bool) -> None
 
 def serialize_xml(dom: xml.dom.minidom.Document) -> str:
     """Serialize a minidom node to XML."""
-    return cast(str, dom.toprettyxml())
+    return dom.toprettyxml()
diff --git a/doc/cmd.rst b/doc/cmd.rst
index 0704e940a..7db6746a8 100644
--- a/doc/cmd.rst
+++ b/doc/cmd.rst
@@ -624,9 +624,10 @@ Here's a `sample report`__.
 
 __ https://nedbatchelder.com/files/sample_coverage_html/index.html
 
-Lines are highlighted green for executed, red for missing, and gray for
-excluded.  The counts at the top of the file are buttons to turn on and off
-the highlighting.
+Lines are highlighted: green for executed, red for missing, and gray for
+excluded.  If you've used branch coverage, partial branches are yellow.  The
+colored counts at the top of the file are buttons to turn on and off the
+highlighting.
 
 A number of keyboard shortcuts are available for navigating the report.
 Click the keyboard icon in the upper right to see the complete list.
diff --git a/doc/conf.py b/doc/conf.py
index f6310b577..bee8c14b2 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -66,11 +66,11 @@
 # @@@ editable
 copyright = "2009–2023, Ned Batchelder" # pylint: disable=redefined-builtin
 # The short X.Y.Z version.
-version = "7.2.3"
+version = "7.2.7"
 # The full version, including alpha/beta/rc tags.
-release = "7.2.3"
+release = "7.2.7"
 # The date of release, in "monthname day, year" format.
-release_date = "April 6, 2023"
+release_date = "May 29, 2023"
 # @@@ end
 
 rst_epilog = """
diff --git a/doc/config.rst b/doc/config.rst
index 152b3af48..0100d89e1 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -79,10 +79,7 @@ Here's a sample configuration file::
 
     [report]
     # Regexes for lines to exclude from consideration
-    exclude_lines =
-        # Have to re-enable the standard pragma
-        pragma: no cover
-
+    exclude_also =
         # Don't complain about missing debug-only code:
         def __repr__
         if self\.debug
@@ -375,6 +372,19 @@ See :ref:`cmd_combine_remapping` and :ref:`source_glob` for more information.
 Settings common to many kinds of reporting.
 
 
+.. _config_report_exclude_also:
+
+[report] exclude_also
+.....................
+
+(multi-string) A list of regular expressions.  This setting is similar to
+:ref:`config_report_exclude_lines`: it specifies patterns for lines to exclude
+from reporting.  This setting is preferred, because it will preserve the
+default exclude patterns instead of overwriting them.
+
+.. versionadded:: 7.2.0
+
+
 .. _config_report_exclude_lines:
 
 [report] exclude_lines
@@ -384,7 +394,9 @@ Settings common to many kinds of reporting.
 containing a match for one of these regexes is excluded from being reported as
 missing.  More details are in :ref:`excluding`.  If you use this option, you
 are replacing all the exclude regexes, so you'll need to also supply the
-"pragma: no cover" regex if you still want to use it.
+"pragma: no cover" regex if you still want to use it.  The
+:ref:`config_report_exclude_also` setting can be used to specify patterns
+without overwriting the default set.
 
 You can exclude lines introducing blocks, and the entire block is excluded. If
 you exclude a ``def`` line or decorator line, the entire function is excluded.
@@ -395,19 +407,6 @@ you'll exclude any line with three or more of any character. If you write
 ``pass``, you'll also exclude the line ``my_pass="foo"``, and so on.
 
 
-.. _config_report_exclude_also:
-
-[report] exclude_also
-.....................
-
-(multi-string) A list of regular expressions.  This setting is the same as
-:ref:`config_report_exclude_lines`: it adds patterns for lines to exclude from
-reporting.  This setting will preserve the default exclude patterns instead of
-overwriting them.
-
-.. versionadded:: 7.2.0
-
-
 .. _config_report_fail_under:
 
 [report] fail_under
diff --git a/doc/excluding.rst b/doc/excluding.rst
index 4651e6bba..e9d28f156 100644
--- a/doc/excluding.rst
+++ b/doc/excluding.rst
@@ -80,14 +80,13 @@ debugging code, and are uninteresting to test themselves.  You could exclude
 all of them by adding a regex to the exclusion list::
 
     [report]
-    exclude_lines =
+    exclude_also =
         def __repr__
 
 For example, here's a list of exclusions I've used::
 
     [report]
-    exclude_lines =
-        pragma: no cover
+    exclude_also =
         def __repr__
         if self.debug:
         if settings.DEBUG
@@ -99,11 +98,10 @@ For example, here's a list of exclusions I've used::
         class .*\bProtocol\):
         @(abc\.)?abstractmethod
 
-Note that when using the ``exclude_lines`` option in a configuration file, you
-are taking control of the entire list of regexes, so you need to re-specify the
-default "pragma: no cover" match if you still want it to apply.  The
-``exclude_also`` option can be used instead to preserve the default
-exclusions while adding new ones.
+The :ref:`config_report_exclude_also` option adds regexes to the built-in
+default list so that you can add your own exclusions.  The older
+:ref:`config_report_exclude_lines` option completely overwrites the list of
+regexes.
 
 The regexes only have to match part of a line. Be careful not to over-match.  A
 value of ``...`` will match any line with more than three characters in it.
diff --git a/doc/faq.rst b/doc/faq.rst
index b25dce0fd..d4f5a565e 100644
--- a/doc/faq.rst
+++ b/doc/faq.rst
@@ -121,7 +121,7 @@ Make sure you are using the C trace function.  Coverage.py provides two
 implementations of the trace function.  The C implementation runs much faster.
 To see what you are running, use ``coverage debug sys``.  The output contains
 details of the environment, including a line that says either
-``CTrace: available`` or ``CTracer: unavailable``.  If it says unavailable,
+``CTracer: available`` or ``CTracer: unavailable``.  If it says unavailable,
 then you are using the slow Python implementation.
 
 Try re-installing coverage.py to see what happened and if you get the CTracer
diff --git a/doc/index.rst b/doc/index.rst
index b11dc90e9..2475eb402 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -18,7 +18,7 @@ supported on:
 
 .. PYVERSIONS
 
-* Python versions 3.7 through 3.12.0a7.
+* Python versions 3.7 through 3.12.0b1.
 * PyPy3 7.3.11.
 
 .. ifconfig:: prerelease
@@ -234,4 +234,5 @@ More information
     trouble
     faq
     Change history <changes>
+    migrating
     sleepy
diff --git a/doc/migrating.rst b/doc/migrating.rst
new file mode 100644
index 000000000..443afac63
--- /dev/null
+++ b/doc/migrating.rst
@@ -0,0 +1,54 @@
+.. Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+.. For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
+
+.. _migrating:
+
+==========================
+Migrating between versions
+==========================
+
+New versions of coverage.py or Python might require you to adjust your
+settings, options, or other aspects how you use coverage.py.  This page details
+those changes.
+
+.. _migrating_cov7:
+
+Migrating to coverage.py 7.x
+----------------------------
+
+Consider these changes when migrating to coverage.py 7.x:
+
+- The way that wildcards when specifying file paths work in certain cases has
+  changed in 7.x:
+
+  - Previously, ``*`` would incorrectly match directory separators, making
+    precise matching difficult. Patterns such as ``*tests/*``
+    will need to be changed to ``*/tests/*``.
+
+  - ``**`` now matches any number of nested directories. If you wish to retain
+    the behavior of ``**/tests/*`` in previous versions then  ``*/**/tests/*``
+    can be used instead.
+
+- When remapping file paths with ``[paths]``, a path will be remapped only if
+  the resulting path exists. Ensure that remapped ``[paths]`` exist when
+  upgrading as this is now being enforced.
+
+- The :ref:`config_report_exclude_also` setting is new in 7.2.0.  It adds
+  exclusion regexes while keeping the default built-in set. It's better than
+  the older :ref:`config_report_exclude_lines` setting, which overwrote the
+  entire list.  Newer versions of coverage.py will be adding to the default set
+  of exclusions.  Using ``exclude_also`` will let you benefit from those
+  updates.
+
+
+.. _migrating_py312:
+
+Migrating to Python 3.12
+------------------------
+
+Keep these things in mind when running under Python 3.12:
+
+- Python 3.12 now inlines list, dict, and set comprehensions.  Previously, they
+  were compiled as functions that were called internally.  Coverage.py would
+  warn you if comprehensions weren't fully completed, but this no longer
+  happens with Python 3.12.
diff --git a/doc/requirements.pip b/doc/requirements.pip
index b13fedcd8..a1894b64f 100644
--- a/doc/requirements.pip
+++ b/doc/requirements.pip
@@ -6,11 +6,11 @@
 #
 alabaster==0.7.13
     # via sphinx
-attrs==22.2.0
+attrs==23.1.0
     # via scriv
 babel==2.12.1
     # via sphinx
-certifi==2022.12.7
+certifi==2023.5.7
     # via requests
 charset-normalizer==3.1.0
     # via requests
@@ -32,8 +32,9 @@ idna==3.4
     # via requests
 imagesize==1.4.1
     # via sphinx
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
+    #   attrs
     #   click
     #   sphinx
     #   sphinxcontrib-spelling
@@ -45,21 +46,21 @@ livereload==2.6.3
     # via sphinx-autobuild
 markupsafe==2.1.2
     # via jinja2
-packaging==23.0
+packaging==23.1
     # via sphinx
 pyenchant==3.2.2
     # via
     #   -r doc/requirements.in
     #   sphinxcontrib-spelling
-pygments==2.14.0
+pygments==2.15.1
     # via sphinx
 pytz==2023.3
     # via babel
-requests==2.28.2
+requests==2.31.0
     # via
     #   scriv
     #   sphinx
-scriv==1.2.1
+scriv==1.3.1
     # via -r doc/requirements.in
 six==1.16.0
     # via livereload
@@ -75,7 +76,7 @@ sphinx==5.3.0
     #   sphinxcontrib-spelling
 sphinx-autobuild==2021.3.14
     # via -r doc/requirements.in
-sphinx-rtd-theme==1.2.0
+sphinx-rtd-theme==1.2.1
     # via -r doc/requirements.in
 sphinxcontrib-applehelp==1.0.2
     # via sphinx
@@ -97,9 +98,9 @@ sphinxcontrib-spelling==8.0.0
     # via -r doc/requirements.in
 tornado==6.2
     # via livereload
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via importlib-metadata
-urllib3==1.26.15
+urllib3==2.0.2
     # via requests
 zipp==3.15.0
     # via importlib-metadata
diff --git a/doc/sample_html/d_7b071bdc2a35fa80___init___py.html b/doc/sample_html/d_7b071bdc2a35fa80___init___py.html
index a15b8decf..c5ac367ec 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80___init___py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80___init___py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80___main___py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -97,8 +97,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80___main___py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80___main___py.html b/doc/sample_html/d_7b071bdc2a35fa80___main___py.html
index 7ea66a94b..c6883a4c7 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80___main___py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80___main___py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_cogapp_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -97,8 +97,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_cogapp_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80_cogapp_py.html b/doc/sample_html/d_7b071bdc2a35fa80_cogapp_py.html
index 87112dbf9..19c55847b 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80_cogapp_py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80_cogapp_py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_makefiles_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -938,8 +938,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_makefiles_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80_makefiles_py.html b/doc/sample_html/d_7b071bdc2a35fa80_makefiles_py.html
index 16ab2e910..75624055a 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80_makefiles_py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80_makefiles_py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_test_cogapp_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -126,8 +126,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_test_cogapp_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80_test_cogapp_py.html b/doc/sample_html/d_7b071bdc2a35fa80_test_cogapp_py.html
index 3b6684674..057e91225 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80_test_cogapp_py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80_test_cogapp_py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_test_makefiles_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -2713,8 +2713,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_test_makefiles_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80_test_makefiles_py.html b/doc/sample_html/d_7b071bdc2a35fa80_test_makefiles_py.html
index d0e5385ab..36392eff0 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80_test_makefiles_py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80_test_makefiles_py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_test_whiteutils_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -207,8 +207,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_test_whiteutils_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80_test_whiteutils_py.html b/doc/sample_html/d_7b071bdc2a35fa80_test_whiteutils_py.html
index d99b6f77f..2ac062db2 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80_test_whiteutils_py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80_test_whiteutils_py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_whiteutils_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -187,8 +187,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="d_7b071bdc2a35fa80_whiteutils_py.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/d_7b071bdc2a35fa80_whiteutils_py.html b/doc/sample_html/d_7b071bdc2a35fa80_whiteutils_py.html
index 5ca1352ac..db1286290 100644
--- a/doc/sample_html/d_7b071bdc2a35fa80_whiteutils_py.html
+++ b/doc/sample_html/d_7b071bdc2a35fa80_whiteutils_py.html
@@ -66,8 +66,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="index.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
         <aside class="hidden">
             <button type="button" class="button_next_chunk" data-shortcut="j"/>
@@ -157,8 +157,8 @@ <h2>
             <a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
             <a id="nextFileLink" class="nav" href="index.html">&#xbb; next</a>
             &nbsp; &nbsp; &nbsp;
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </footer>
diff --git a/doc/sample_html/index.html b/doc/sample_html/index.html
index c12579608..a609bd62b 100644
--- a/doc/sample_html/index.html
+++ b/doc/sample_html/index.html
@@ -46,8 +46,8 @@ <h1>Cog coverage:
             <input id="filter" type="text" value="" placeholder="filter..." />
         </form>
         <p class="text">
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
 </header>
@@ -157,8 +157,8 @@ <h1>Cog coverage:
 <footer>
     <div class="content">
         <p>
-            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.3">coverage.py v7.2.3</a>,
-            created at 2023-04-06 08:42 -0400
+            <a class="nav" href="https://coverage.readthedocs.io/en/7.2.7">coverage.py v7.2.7</a>,
+            created at 2023-05-29 15:26 -0400
         </p>
     </div>
     <aside class="hidden">
diff --git a/doc/sample_html/status.json b/doc/sample_html/status.json
index 17e633de6..a06dec4db 100644
--- a/doc/sample_html/status.json
+++ b/doc/sample_html/status.json
@@ -1 +1 @@
-{"format":2,"version":"7.2.3","globals":"2b43fc00a84f1e7415bbd5a0e2a010d6","files":{"d_7b071bdc2a35fa80___init___py":{"hash":"70ef41e14b11d599cdbcf53f562ebb16","index":{"nums":[2,1,1,0,0,0,0,0],"html_filename":"d_7b071bdc2a35fa80___init___py.html","relative_filename":"cogapp/__init__.py"}},"d_7b071bdc2a35fa80___main___py":{"hash":"6d9d0d551879aa3e73791f40c5739845","index":{"nums":[2,1,3,0,3,0,0,0],"html_filename":"d_7b071bdc2a35fa80___main___py.html","relative_filename":"cogapp/__main__.py"}},"d_7b071bdc2a35fa80_cogapp_py":{"hash":"7428c811d741c23b10655ff6c20fb85f","index":{"nums":[2,1,500,1,224,210,30,138],"html_filename":"d_7b071bdc2a35fa80_cogapp_py.html","relative_filename":"cogapp/cogapp.py"}},"d_7b071bdc2a35fa80_makefiles_py":{"hash":"4b73eaf76fbb53af575b40165e831aac","index":{"nums":[2,1,22,0,18,14,0,14],"html_filename":"d_7b071bdc2a35fa80_makefiles_py.html","relative_filename":"cogapp/makefiles.py"}},"d_7b071bdc2a35fa80_test_cogapp_py":{"hash":"34099de695d2cac204436597408d33d2","index":{"nums":[2,1,845,2,591,24,1,21],"html_filename":"d_7b071bdc2a35fa80_test_cogapp_py.html","relative_filename":"cogapp/test_cogapp.py"}},"d_7b071bdc2a35fa80_test_makefiles_py":{"hash":"63fd1bdc011935abfd11301da94b383e","index":{"nums":[2,1,70,0,53,6,0,6],"html_filename":"d_7b071bdc2a35fa80_test_makefiles_py.html","relative_filename":"cogapp/test_makefiles.py"}},"d_7b071bdc2a35fa80_test_whiteutils_py":{"hash":"ec69457cbd6dfbc85eefabdfc0931c99","index":{"nums":[2,1,68,0,50,0,0,0],"html_filename":"d_7b071bdc2a35fa80_test_whiteutils_py.html","relative_filename":"cogapp/test_whiteutils.py"}},"d_7b071bdc2a35fa80_whiteutils_py":{"hash":"6dbf59193ab1bdcba86b017c86bb4724","index":{"nums":[2,1,43,0,5,34,4,4],"html_filename":"d_7b071bdc2a35fa80_whiteutils_py.html","relative_filename":"cogapp/whiteutils.py"}}}}
\ No newline at end of file
+{"format":2,"version":"7.2.7","globals":"2b43fc00a84f1e7415bbd5a0e2a010d6","files":{"d_7b071bdc2a35fa80___init___py":{"hash":"70ef41e14b11d599cdbcf53f562ebb16","index":{"nums":[2,1,1,0,0,0,0,0],"html_filename":"d_7b071bdc2a35fa80___init___py.html","relative_filename":"cogapp/__init__.py"}},"d_7b071bdc2a35fa80___main___py":{"hash":"6d9d0d551879aa3e73791f40c5739845","index":{"nums":[2,1,3,0,3,0,0,0],"html_filename":"d_7b071bdc2a35fa80___main___py.html","relative_filename":"cogapp/__main__.py"}},"d_7b071bdc2a35fa80_cogapp_py":{"hash":"7428c811d741c23b10655ff6c20fb85f","index":{"nums":[2,1,500,1,224,210,30,138],"html_filename":"d_7b071bdc2a35fa80_cogapp_py.html","relative_filename":"cogapp/cogapp.py"}},"d_7b071bdc2a35fa80_makefiles_py":{"hash":"4b73eaf76fbb53af575b40165e831aac","index":{"nums":[2,1,22,0,18,14,0,14],"html_filename":"d_7b071bdc2a35fa80_makefiles_py.html","relative_filename":"cogapp/makefiles.py"}},"d_7b071bdc2a35fa80_test_cogapp_py":{"hash":"34099de695d2cac204436597408d33d2","index":{"nums":[2,1,845,2,591,24,1,21],"html_filename":"d_7b071bdc2a35fa80_test_cogapp_py.html","relative_filename":"cogapp/test_cogapp.py"}},"d_7b071bdc2a35fa80_test_makefiles_py":{"hash":"63fd1bdc011935abfd11301da94b383e","index":{"nums":[2,1,70,0,53,6,0,6],"html_filename":"d_7b071bdc2a35fa80_test_makefiles_py.html","relative_filename":"cogapp/test_makefiles.py"}},"d_7b071bdc2a35fa80_test_whiteutils_py":{"hash":"ec69457cbd6dfbc85eefabdfc0931c99","index":{"nums":[2,1,68,0,50,0,0,0],"html_filename":"d_7b071bdc2a35fa80_test_whiteutils_py.html","relative_filename":"cogapp/test_whiteutils.py"}},"d_7b071bdc2a35fa80_whiteutils_py":{"hash":"6dbf59193ab1bdcba86b017c86bb4724","index":{"nums":[2,1,43,0,5,34,4,4],"html_filename":"d_7b071bdc2a35fa80_whiteutils_py.html","relative_filename":"cogapp/whiteutils.py"}}}}
\ No newline at end of file
diff --git a/howto.txt b/howto.txt
index 24f01ecb6..1d5f4f2e1 100644
--- a/howto.txt
+++ b/howto.txt
@@ -10,6 +10,7 @@
         version_info = (4, 0, 2, "final", 0)
     - make sure: _dev = 0
 - Edit supported Python version numbers. Search for "PYVERSIONS".
+    - Especially README.rst and doc/index.rst
 - Update source files with release facts:
     $ make edit_for_release
 - Get useful snippets for next steps, and beyond, in cheats.txt
@@ -17,9 +18,7 @@
 - Look over CHANGES.rst
 - Update README.rst
     - "New in x.y:"
-    - Python versions supported
 - Update docs
-    - Python versions in doc/index.rst
     - IF PRE-RELEASE:
         - Version of latest stable release in doc/index.rst
     - Make sure the docs are cogged:
@@ -49,6 +48,7 @@
         $ make publishbeta
     - ELSE:
         $ make publish
+    - commit and publish nedbatchelder.com
 - Kits:
     - Wait for kits to finish:
         - https://github.com/nedbat/coveragepy/actions/workflows/kit.yml
@@ -83,9 +83,9 @@
             - wait for the new tag build to finish successfully.
         - @ https://readthedocs.org/dashboard/coverage/advanced/
             - change the default version to the new version
+- Once CI passes, merge the bump-version branch to master and push it
+
 - things to automate:
-    - url to link to latest changes in docs
-    - next version.py line
     - readthedocs api to do the readthedocs changes
 
 
diff --git a/igor.py b/igor.py
index ad0dbf8c5..f4c5d22f1 100644
--- a/igor.py
+++ b/igor.py
@@ -12,6 +12,7 @@
 import datetime
 import glob
 import inspect
+import itertools
 import os
 import platform
 import pprint
@@ -77,10 +78,11 @@ def do_remove_extension(*args):
             "-c",
             "import coverage; print(coverage.__file__)"
         ], encoding="utf-8").strip())
+        roots = [root]
     else:
-        root = "coverage"
+        roots = ["coverage", "build/*/coverage"]
 
-    for pattern in so_patterns:
+    for root, pattern in itertools.product(roots, so_patterns):
         pattern = os.path.join(root, pattern.strip())
         if VERBOSITY:
             print(f"Searching for {pattern}")
@@ -219,6 +221,10 @@ def do_combine_html():
     cov.load()
     cov.combine()
     cov.save()
+    # A new Coverage to turn on messages. Better would be to have tighter
+    # control over message verbosity...
+    cov = coverage.Coverage(config_file="metacov.ini", messages=True)
+    cov.load()
     show_contexts = bool(os.environ.get('COVERAGE_DYNCTX') or os.environ.get('COVERAGE_CONTEXT'))
     cov.html_report(show_contexts=show_contexts)
 
diff --git a/pyproject.toml b/pyproject.toml
index 6b02c6a47..cf9c2c0e4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,6 +54,9 @@ balanced_clumps = [
     "GetZipBytesTest",
 ]
 
+[tool.ruff]
+line-length = 100
+
 [tool.scriv]
 # Changelog management: https://pypi.org/project/scriv/
 format = "rst"
diff --git a/requirements/dev.pip b/requirements/dev.pip
index 76304efc0..0cd90a3c4 100644
--- a/requirements/dev.pip
+++ b/requirements/dev.pip
@@ -4,19 +4,17 @@
 #
 #    make upgrade
 #
-astroid==2.15.1
+astroid==2.15.5
     # via pylint
-attrs==22.2.0
-    # via
-    #   hypothesis
-    #   pytest
+attrs==23.1.0
+    # via hypothesis
 bleach==6.0.0
     # via readme-renderer
 build==0.10.0
     # via check-manifest
-cachetools==5.3.0
+cachetools==5.3.1
     # via tox
-certifi==2022.12.7
+certifi==2023.5.7
     # via requests
 chardet==5.1.0
     # via tox
@@ -35,7 +33,7 @@ dill==0.3.6
     # via pylint
 distlib==0.3.6
     # via virtualenv
-docutils==0.19
+docutils==0.20.1
     # via readme-renderer
 exceptiongroup==1.1.1
     # via
@@ -43,7 +41,7 @@ exceptiongroup==1.1.1
     #   pytest
 execnet==1.9.0
     # via pytest-xdist
-filelock==3.10.7
+filelock==3.12.0
     # via
     #   tox
     #   virtualenv
@@ -51,12 +49,13 @@ flaky==3.7.0
     # via -r requirements/pytest.in
 greenlet==2.0.2
     # via -r requirements/dev.in
-hypothesis==6.70.2
+hypothesis==6.75.6
     # via -r requirements/pytest.in
 idna==3.4
     # via requests
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
+    #   attrs
     #   build
     #   keyring
     #   pluggy
@@ -88,7 +87,7 @@ mdurl==0.1.2
     # via markdown-it-py
 more-itertools==9.1.0
     # via jaraco-classes
-packaging==23.0
+packaging==23.1
     # via
     #   build
     #   pudb
@@ -99,7 +98,7 @@ parso==0.8.3
     # via jedi
 pkginfo==1.9.6
     # via twine
-platformdirs==3.2.0
+platformdirs==3.5.1
     # via
     #   pylint
     #   tox
@@ -110,37 +109,37 @@ pluggy==1.0.0
     #   tox
 pudb==2022.1.3
     # via -r requirements/dev.in
-pygments==2.14.0
+pygments==2.15.1
     # via
     #   pudb
     #   readme-renderer
     #   rich
-pylint==2.17.1
+pylint==2.17.4
     # via -r requirements/dev.in
 pyproject-api==1.5.1
     # via tox
 pyproject-hooks==1.0.0
     # via build
-pytest==7.2.2
+pytest==7.3.1
     # via
     #   -r requirements/pytest.in
     #   pytest-xdist
-pytest-xdist==3.2.1
+pytest-xdist==3.3.1
     # via -r requirements/pytest.in
 readme-renderer==37.3
     # via
     #   -r requirements/dev.in
     #   twine
-requests==2.28.2
+requests==2.31.0
     # via
     #   -r requirements/dev.in
     #   requests-toolbelt
     #   twine
-requests-toolbelt==0.10.1
+requests-toolbelt==1.0.0
     # via twine
 rfc3986==2.0.0
     # via twine
-rich==13.3.3
+rich==13.3.5
     # via twine
 six==1.16.0
     # via bleach
@@ -155,9 +154,9 @@ tomli==2.0.1
     #   pyproject-hooks
     #   pytest
     #   tox
-tomlkit==0.11.7
+tomlkit==0.11.8
     # via pylint
-tox==4.4.8
+tox==4.5.2
     # via
     #   -r requirements/tox.in
     #   tox-gh
@@ -167,7 +166,7 @@ twine==4.0.2
     # via -r requirements/dev.in
 typed-ast==1.5.4
     # via astroid
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via
     #   astroid
     #   importlib-metadata
@@ -176,7 +175,7 @@ typing-extensions==4.5.0
     #   pylint
     #   rich
     #   tox
-urllib3==1.26.15
+urllib3==2.0.2
     # via
     #   requests
     #   twine
@@ -186,7 +185,7 @@ urwid==2.1.2
     #   urwid-readline
 urwid-readline==0.13
     # via pudb
-virtualenv==20.21.0
+virtualenv==20.23.0
     # via
     #   -r requirements/pip.in
     #   tox
@@ -200,10 +199,9 @@ zipp==3.15.0
     #   importlib-resources
 
 # The following packages are considered to be unsafe in a requirements file:
-pip==23.0.1
+pip==23.1.2
     # via -r requirements/pip.in
-setuptools==65.7.0
+setuptools==67.8.0
     # via
-    #   -c requirements/pins.pip
     #   -r requirements/pip.in
     #   check-manifest
diff --git a/requirements/kit.pip b/requirements/kit.pip
index a126aa357..608c3f79b 100644
--- a/requirements/kit.pip
+++ b/requirements/kit.pip
@@ -4,7 +4,7 @@
 #
 #    make upgrade
 #
-auditwheel==5.3.0
+auditwheel==5.4.0
     # via -r requirements/kit.in
 bashlex==0.18
     # via cibuildwheel
@@ -12,23 +12,23 @@ bracex==2.3.post1
     # via cibuildwheel
 build==0.10.0
     # via -r requirements/kit.in
-certifi==2022.12.7
+certifi==2023.5.7
     # via cibuildwheel
-cibuildwheel==2.12.1
+cibuildwheel==2.13.0
     # via -r requirements/kit.in
 colorama==0.4.6
     # via -r requirements/kit.in
-filelock==3.10.7
+filelock==3.12.0
     # via cibuildwheel
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
     #   auditwheel
     #   build
-packaging==23.0
+packaging==23.1
     # via
     #   build
     #   cibuildwheel
-platformdirs==3.2.0
+platformdirs==3.5.1
     # via cibuildwheel
 pyelftools==0.29
     # via auditwheel
@@ -39,7 +39,7 @@ tomli==2.0.1
     #   build
     #   cibuildwheel
     #   pyproject-hooks
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via
     #   cibuildwheel
     #   importlib-metadata
@@ -50,5 +50,5 @@ zipp==3.15.0
     # via importlib-metadata
 
 # The following packages are considered to be unsafe in a requirements file:
-setuptools==65.7.0
+setuptools==67.8.0
     # via -r requirements/kit.in
diff --git a/requirements/light-threads.pip b/requirements/light-threads.pip
index e53a4d13f..a633004df 100644
--- a/requirements/light-threads.pip
+++ b/requirements/light-threads.pip
@@ -27,9 +27,8 @@ zope-interface==6.0
     # via gevent
 
 # The following packages are considered to be unsafe in a requirements file:
-setuptools==65.7.0
+setuptools==67.8.0
     # via
-    #   -c requirements/pins.pip
     #   gevent
     #   zope-event
     #   zope-interface
diff --git a/requirements/lint.pip b/requirements/lint.pip
index af91931e1..9ba71d60d 100644
--- a/requirements/lint.pip
+++ b/requirements/lint.pip
@@ -6,12 +6,11 @@
 #
 alabaster==0.7.13
     # via sphinx
-astroid==2.15.1
+astroid==2.15.5
     # via pylint
-attrs==22.2.0
+attrs==23.1.0
     # via
     #   hypothesis
-    #   pytest
     #   scriv
 babel==2.12.1
     # via sphinx
@@ -19,9 +18,9 @@ bleach==6.0.0
     # via readme-renderer
 build==0.10.0
     # via check-manifest
-cachetools==5.3.0
+cachetools==5.3.1
     # via tox
-certifi==2022.12.7
+certifi==2023.5.7
     # via requests
 chardet==5.1.0
     # via tox
@@ -60,7 +59,7 @@ exceptiongroup==1.1.1
     #   pytest
 execnet==1.9.0
     # via pytest-xdist
-filelock==3.10.7
+filelock==3.12.0
     # via
     #   tox
     #   virtualenv
@@ -68,14 +67,15 @@ flaky==3.7.0
     # via -r requirements/pytest.in
 greenlet==2.0.2
     # via -r requirements/dev.in
-hypothesis==6.70.2
+hypothesis==6.75.6
     # via -r requirements/pytest.in
 idna==3.4
     # via requests
 imagesize==1.4.1
     # via sphinx
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
+    #   attrs
     #   build
     #   click
     #   keyring
@@ -118,7 +118,7 @@ mdurl==0.1.2
     # via markdown-it-py
 more-itertools==9.1.0
     # via jaraco-classes
-packaging==23.0
+packaging==23.1
     # via
     #   build
     #   pudb
@@ -130,7 +130,7 @@ parso==0.8.3
     # via jedi
 pkginfo==1.9.6
     # via twine
-platformdirs==3.2.0
+platformdirs==3.5.1
     # via
     #   pylint
     #   tox
@@ -145,23 +145,23 @@ pyenchant==3.2.2
     # via
     #   -r doc/requirements.in
     #   sphinxcontrib-spelling
-pygments==2.14.0
+pygments==2.15.1
     # via
     #   pudb
     #   readme-renderer
     #   rich
     #   sphinx
-pylint==2.17.1
+pylint==2.17.4
     # via -r requirements/dev.in
 pyproject-api==1.5.1
     # via tox
 pyproject-hooks==1.0.0
     # via build
-pytest==7.2.2
+pytest==7.3.1
     # via
     #   -r requirements/pytest.in
     #   pytest-xdist
-pytest-xdist==3.2.1
+pytest-xdist==3.3.1
     # via -r requirements/pytest.in
 pytz==2023.3
     # via babel
@@ -169,20 +169,20 @@ readme-renderer==37.3
     # via
     #   -r requirements/dev.in
     #   twine
-requests==2.28.2
+requests==2.31.0
     # via
     #   -r requirements/dev.in
     #   requests-toolbelt
     #   scriv
     #   sphinx
     #   twine
-requests-toolbelt==0.10.1
+requests-toolbelt==1.0.0
     # via twine
 rfc3986==2.0.0
     # via twine
-rich==13.3.3
+rich==13.3.5
     # via twine
-scriv==1.2.1
+scriv==1.3.1
     # via -r doc/requirements.in
 six==1.16.0
     # via
@@ -202,7 +202,7 @@ sphinx==5.3.0
     #   sphinxcontrib-spelling
 sphinx-autobuild==2021.3.14
     # via -r doc/requirements.in
-sphinx-rtd-theme==1.2.0
+sphinx-rtd-theme==1.2.1
     # via -r doc/requirements.in
 sphinxcontrib-applehelp==1.0.2
     # via sphinx
@@ -231,11 +231,11 @@ tomli==2.0.1
     #   pyproject-hooks
     #   pytest
     #   tox
-tomlkit==0.11.7
+tomlkit==0.11.8
     # via pylint
 tornado==6.2
     # via livereload
-tox==4.4.8
+tox==4.5.2
     # via
     #   -r requirements/tox.in
     #   tox-gh
@@ -245,7 +245,7 @@ twine==4.0.2
     # via -r requirements/dev.in
 typed-ast==1.5.4
     # via astroid
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via
     #   astroid
     #   importlib-metadata
@@ -254,7 +254,7 @@ typing-extensions==4.5.0
     #   pylint
     #   rich
     #   tox
-urllib3==1.26.15
+urllib3==2.0.2
     # via
     #   requests
     #   twine
@@ -264,7 +264,7 @@ urwid==2.1.2
     #   urwid-readline
 urwid-readline==0.13
     # via pudb
-virtualenv==20.21.0
+virtualenv==20.23.0
     # via
     #   -r requirements/pip.in
     #   tox
@@ -278,10 +278,9 @@ zipp==3.15.0
     #   importlib-resources
 
 # The following packages are considered to be unsafe in a requirements file:
-pip==23.0.1
+pip==23.1.2
     # via -r requirements/pip.in
-setuptools==65.7.0
+setuptools==67.8.0
     # via
-    #   -c requirements/pins.pip
     #   -r requirements/pip.in
     #   check-manifest
diff --git a/requirements/mypy.pip b/requirements/mypy.pip
index ae1bbd97b..f3146a23e 100644
--- a/requirements/mypy.pip
+++ b/requirements/mypy.pip
@@ -4,10 +4,8 @@
 #
 #    make upgrade
 #
-attrs==22.2.0
-    # via
-    #   hypothesis
-    #   pytest
+attrs==23.1.0
+    # via hypothesis
 colorama==0.4.6
     # via -r requirements/pytest.in
 exceptiongroup==1.1.1
@@ -18,27 +16,28 @@ execnet==1.9.0
     # via pytest-xdist
 flaky==3.7.0
     # via -r requirements/pytest.in
-hypothesis==6.70.2
+hypothesis==6.75.6
     # via -r requirements/pytest.in
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
+    #   attrs
     #   pluggy
     #   pytest
 iniconfig==2.0.0
     # via pytest
-mypy==1.1.1
+mypy==1.3.0
     # via -r requirements/mypy.in
 mypy-extensions==1.0.0
     # via mypy
-packaging==23.0
+packaging==23.1
     # via pytest
 pluggy==1.0.0
     # via pytest
-pytest==7.2.2
+pytest==7.3.1
     # via
     #   -r requirements/pytest.in
     #   pytest-xdist
-pytest-xdist==3.2.1
+pytest-xdist==3.3.1
     # via -r requirements/pytest.in
 sortedcontainers==2.4.0
     # via hypothesis
@@ -48,7 +47,7 @@ tomli==2.0.1
     #   pytest
 typed-ast==1.5.4
     # via mypy
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via
     #   importlib-metadata
     #   mypy
diff --git a/requirements/pins.pip b/requirements/pins.pip
index b614c3119..3b38dcb3f 100644
--- a/requirements/pins.pip
+++ b/requirements/pins.pip
@@ -11,4 +11,4 @@
 # checking the Python version like that, should it?
 # https://github.com/pypa/packaging/issues/678
 # https://github.com/nedbat/coveragepy/issues/1556
-setuptools<66.0.0
+#setuptools<66.0.0
diff --git a/requirements/pip-tools.pip b/requirements/pip-tools.pip
index 000f707a7..b720cb51f 100644
--- a/requirements/pip-tools.pip
+++ b/requirements/pip-tools.pip
@@ -8,13 +8,13 @@ build==0.10.0
     # via pip-tools
 click==8.1.3
     # via pip-tools
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
     #   build
     #   click
-packaging==23.0
+packaging==23.1
     # via build
-pip-tools==6.12.3
+pip-tools==6.13.0
     # via -r requirements/pip-tools.in
 pyproject-hooks==1.0.0
     # via build
@@ -22,7 +22,7 @@ tomli==2.0.1
     # via
     #   build
     #   pyproject-hooks
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via importlib-metadata
 wheel==0.40.0
     # via pip-tools
@@ -30,9 +30,7 @@ zipp==3.15.0
     # via importlib-metadata
 
 # The following packages are considered to be unsafe in a requirements file:
-pip==23.0.1
+pip==23.1.2
+    # via pip-tools
+setuptools==67.8.0
     # via pip-tools
-setuptools==65.7.0
-    # via
-    #   -c requirements/pins.pip
-    #   pip-tools
diff --git a/requirements/pip.pip b/requirements/pip.pip
index 927943a5e..0cc81df43 100644
--- a/requirements/pip.pip
+++ b/requirements/pip.pip
@@ -6,23 +6,23 @@
 #
 distlib==0.3.6
     # via virtualenv
-filelock==3.10.7
+filelock==3.12.0
     # via virtualenv
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via virtualenv
-platformdirs==3.2.0
+platformdirs==3.5.1
     # via virtualenv
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via
     #   importlib-metadata
     #   platformdirs
-virtualenv==20.21.0
+virtualenv==20.23.0
     # via -r requirements/pip.in
 zipp==3.15.0
     # via importlib-metadata
 
 # The following packages are considered to be unsafe in a requirements file:
-pip==23.0.1
+pip==23.1.2
     # via -r requirements/pip.in
-setuptools==65.7.0
+setuptools==67.8.0
     # via -r requirements/pip.in
diff --git a/requirements/pytest.pip b/requirements/pytest.pip
index 478860a97..d34f3d3d7 100644
--- a/requirements/pytest.pip
+++ b/requirements/pytest.pip
@@ -4,10 +4,8 @@
 #
 #    make upgrade
 #
-attrs==22.2.0
-    # via
-    #   hypothesis
-    #   pytest
+attrs==23.1.0
+    # via hypothesis
 colorama==0.4.6
     # via -r requirements/pytest.in
 exceptiongroup==1.1.1
@@ -18,29 +16,30 @@ execnet==1.9.0
     # via pytest-xdist
 flaky==3.7.0
     # via -r requirements/pytest.in
-hypothesis==6.70.2
+hypothesis==6.75.6
     # via -r requirements/pytest.in
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
+    #   attrs
     #   pluggy
     #   pytest
 iniconfig==2.0.0
     # via pytest
-packaging==23.0
+packaging==23.1
     # via pytest
 pluggy==1.0.0
     # via pytest
-pytest==7.2.2
+pytest==7.3.1
     # via
     #   -r requirements/pytest.in
     #   pytest-xdist
-pytest-xdist==3.2.1
+pytest-xdist==3.3.1
     # via -r requirements/pytest.in
 sortedcontainers==2.4.0
     # via hypothesis
 tomli==2.0.1
     # via pytest
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via importlib-metadata
 zipp==3.15.0
     # via importlib-metadata
diff --git a/requirements/tox.pip b/requirements/tox.pip
index c02835cbb..759f13cef 100644
--- a/requirements/tox.pip
+++ b/requirements/tox.pip
@@ -4,7 +4,7 @@
 #
 #    make upgrade
 #
-cachetools==5.3.0
+cachetools==5.3.1
     # via tox
 chardet==5.1.0
     # via tox
@@ -14,20 +14,20 @@ colorama==0.4.6
     #   tox
 distlib==0.3.6
     # via virtualenv
-filelock==3.10.7
+filelock==3.12.0
     # via
     #   tox
     #   virtualenv
-importlib-metadata==6.1.0
+importlib-metadata==6.6.0
     # via
     #   pluggy
     #   tox
     #   virtualenv
-packaging==23.0
+packaging==23.1
     # via
     #   pyproject-api
     #   tox
-platformdirs==3.2.0
+platformdirs==3.5.1
     # via
     #   tox
     #   virtualenv
@@ -39,18 +39,18 @@ tomli==2.0.1
     # via
     #   pyproject-api
     #   tox
-tox==4.4.8
+tox==4.5.2
     # via
     #   -r requirements/tox.in
     #   tox-gh
 tox-gh==1.0.0
     # via -r requirements/tox.in
-typing-extensions==4.5.0
+typing-extensions==4.6.2
     # via
     #   importlib-metadata
     #   platformdirs
     #   tox
-virtualenv==20.21.0
+virtualenv==20.23.0
     # via tox
 zipp==3.15.0
     # via importlib-metadata
diff --git a/setup.cfg b/setup.cfg
deleted file mode 100644
index db3fdce8e..000000000
--- a/setup.cfg
+++ /dev/null
@@ -1,5 +0,0 @@
-# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
-# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
-
-[metadata]
-license_files = LICENSE.txt
diff --git a/setup.py b/setup.py
index 2c375522d..90763f94c 100644
--- a/setup.py
+++ b/setup.py
@@ -118,6 +118,7 @@
     long_description_content_type='text/x-rst',
     keywords='code coverage testing',
     license='Apache-2.0',
+    license_files=["LICENSE.txt"],
     classifiers=classifier_list,
     url="https://github.com/nedbat/coveragepy",
     project_urls={
diff --git a/tests/conftest.py b/tests/conftest.py
index 41db85b49..51bab8d2b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -40,7 +40,7 @@ def set_warnings() -> None:
     warnings.simplefilter("once", DeprecationWarning)
 
     # Warnings to suppress:
-    # How come these warnings are successfully suppressed here, but not in setup.cfg??
+    # How come these warnings are successfully suppressed here, but not in pyproject.toml??
 
     warnings.filterwarnings(
         "ignore",
diff --git a/tests/test_api.py b/tests/test_api.py
index 596510ebc..5c903d65f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -28,8 +28,7 @@
 from coverage.types import FilePathClasses, FilePathType, Protocol, TCovKwargs
 
 from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
-from tests.goldtest import contains, doesnt_contain
-from tests.helpers import arcz_to_arcs, assert_count_equal, assert_coverage_warnings
+from tests.helpers import assert_count_equal, assert_coverage_warnings
 from tests.helpers import change_dir, nice_file, os_sep
 
 BAD_SQLITE_REGEX = r"file( is encrypted or)? is not a database"
@@ -1489,146 +1488,3 @@ def test_combine_parallel_data_keep(self) -> None:
         # After combining, the .coverage file & the original combined file should still be there.
         self.assert_exists(".coverage")
         self.assert_file_count(".coverage.*", 2)
-
-
-class ReportMapsPathsTest(CoverageTest):
-    """Check that reporting implicitly maps paths."""
-
-    def make_files(self, data: str, settings: bool = False) -> None:
-        """Create the test files we need for line coverage."""
-        src = """\
-            if VER == 1:
-                print("line 2")
-            if VER == 2:
-                print("line 4")
-            if VER == 3:
-                print("line 6")
-            """
-        self.make_file("src/program.py", src)
-        self.make_file("ver1/program.py", src)
-        self.make_file("ver2/program.py", src)
-
-        if data == "line":
-            self.make_data_file(
-                lines={
-                    abs_file("ver1/program.py"): [1, 2, 3, 5],
-                    abs_file("ver2/program.py"): [1, 3, 4, 5],
-                }
-            )
-        else:
-            self.make_data_file(
-                arcs={
-                    abs_file("ver1/program.py"): arcz_to_arcs(".1 12 23 35 5."),
-                    abs_file("ver2/program.py"): arcz_to_arcs(".1 13 34 45 5."),
-                }
-            )
-
-        if settings:
-            self.make_file(".coveragerc", """\
-                [paths]
-                source =
-                    src
-                    ver1
-                    ver2
-                """)
-
-    def test_map_paths_during_line_report_without_setting(self) -> None:
-        self.make_files(data="line")
-        cov = coverage.Coverage()
-        cov.load()
-        cov.report(show_missing=True)
-        expected = textwrap.dedent(os_sep("""\
-            Name              Stmts   Miss  Cover   Missing
-            -----------------------------------------------
-            ver1/program.py       6      2    67%   4, 6
-            ver2/program.py       6      2    67%   2, 6
-            -----------------------------------------------
-            TOTAL                12      4    67%
-            """))
-        assert expected == self.stdout()
-
-    def test_map_paths_during_line_report(self) -> None:
-        self.make_files(data="line", settings=True)
-        cov = coverage.Coverage()
-        cov.load()
-        cov.report(show_missing=True)
-        expected = textwrap.dedent(os_sep("""\
-            Name             Stmts   Miss  Cover   Missing
-            ----------------------------------------------
-            src/program.py       6      1    83%   6
-            ----------------------------------------------
-            TOTAL                6      1    83%
-            """))
-        assert expected == self.stdout()
-
-    def test_map_paths_during_branch_report_without_setting(self) -> None:
-        self.make_files(data="arcs")
-        cov = coverage.Coverage(branch=True)
-        cov.load()
-        cov.report(show_missing=True)
-        expected = textwrap.dedent(os_sep("""\
-            Name              Stmts   Miss Branch BrPart  Cover   Missing
-            -------------------------------------------------------------
-            ver1/program.py       6      2      6      3    58%   1->3, 4, 6
-            ver2/program.py       6      2      6      3    58%   2, 3->5, 6
-            -------------------------------------------------------------
-            TOTAL                12      4     12      6    58%
-            """))
-        assert expected == self.stdout()
-
-    def test_map_paths_during_branch_report(self) -> None:
-        self.make_files(data="arcs", settings=True)
-        cov = coverage.Coverage(branch=True)
-        cov.load()
-        cov.report(show_missing=True)
-        expected = textwrap.dedent(os_sep("""\
-            Name             Stmts   Miss Branch BrPart  Cover   Missing
-            ------------------------------------------------------------
-            src/program.py       6      1      6      1    83%   6
-            ------------------------------------------------------------
-            TOTAL                6      1      6      1    83%
-            """))
-        assert expected == self.stdout()
-
-    def test_map_paths_during_annotate(self) -> None:
-        self.make_files(data="line", settings=True)
-        cov = coverage.Coverage()
-        cov.load()
-        cov.annotate()
-        self.assert_exists(os_sep("src/program.py,cover"))
-        self.assert_doesnt_exist(os_sep("ver1/program.py,cover"))
-        self.assert_doesnt_exist(os_sep("ver2/program.py,cover"))
-
-    def test_map_paths_during_html_report(self) -> None:
-        self.make_files(data="line", settings=True)
-        cov = coverage.Coverage()
-        cov.load()
-        cov.html_report()
-        contains("htmlcov/index.html", os_sep("src/program.py"))
-        doesnt_contain("htmlcov/index.html", os_sep("ver1/program.py"), os_sep("ver2/program.py"))
-
-    def test_map_paths_during_xml_report(self) -> None:
-        self.make_files(data="line", settings=True)
-        cov = coverage.Coverage()
-        cov.load()
-        cov.xml_report()
-        contains("coverage.xml", "src/program.py")
-        doesnt_contain("coverage.xml", "ver1/program.py", "ver2/program.py")
-
-    def test_map_paths_during_json_report(self) -> None:
-        self.make_files(data="line", settings=True)
-        cov = coverage.Coverage()
-        cov.load()
-        cov.json_report()
-        def os_sepj(s: str) -> str:
-            return os_sep(s).replace("\\", r"\\")
-        contains("coverage.json", os_sepj("src/program.py"))
-        doesnt_contain("coverage.json", os_sepj("ver1/program.py"), os_sepj("ver2/program.py"))
-
-    def test_map_paths_during_lcov_report(self) -> None:
-        self.make_files(data="line", settings=True)
-        cov = coverage.Coverage()
-        cov.load()
-        cov.lcov_report()
-        contains("coverage.lcov", os_sep("src/program.py"))
-        doesnt_contain("coverage.lcov", os_sep("ver1/program.py"), os_sep("ver2/program.py"))
diff --git a/tests/test_arcs.py b/tests/test_arcs.py
index d80a46370..1f46064f6 100644
--- a/tests/test_arcs.py
+++ b/tests/test_arcs.py
@@ -24,7 +24,6 @@
     reason="https://foss.heptapod.net/pypy/pypy/-/issues/3882",
 )
 
-
 class SimpleArcTest(CoverageTest):
     """Tests for coverage.py's arc measurement."""
 
@@ -559,6 +558,10 @@ def whileelse(seq):
         )
 
     def test_confusing_for_loop_bug_175(self) -> None:
+        if env.PYBEHAVIOR.comprehensions_are_functions:
+            extra_arcz = " -22 2-2"
+        else:
+            extra_arcz = ""
         self.check_coverage("""\
             o = [(1,2), (3,4)]
             o = [a for a in o]
@@ -566,7 +569,7 @@ def test_confusing_for_loop_bug_175(self) -> None:
                 x = tup[0]
                 y = tup[1]
             """,
-            arcz=".1 -22 2-2 12 23 34 45 53 3.",
+            arcz=".1 12 23 34 45 53 3." + extra_arcz,
         )
         self.check_coverage("""\
             o = [(1,2), (3,4)]
@@ -574,7 +577,7 @@ def test_confusing_for_loop_bug_175(self) -> None:
                 x = tup[0]
                 y = tup[1]
             """,
-            arcz=".1 12 -22 2-2 23 34 42 2.",
+            arcz=".1 12 23 34 42 2." + extra_arcz,
         )
 
     # https://bugs.python.org/issue44672
@@ -639,6 +642,10 @@ def test_generator_expression_another_way(self) -> None:
         )
 
     def test_other_comprehensions(self) -> None:
+        if env.PYBEHAVIOR.comprehensions_are_functions:
+            extra_arcz = " -22 2-2"
+        else:
+            extra_arcz = ""
         # Set comprehension:
         self.check_coverage("""\
             o = ((1,2), (3,4))
@@ -647,7 +654,7 @@ def test_other_comprehensions(self) -> None:
                 x = tup[0]
                 y = tup[1]
             """,
-            arcz=".1 -22 2-2 12 23 34 45 53 3.",
+            arcz=".1 12 23 34 45 53 3." + extra_arcz,
         )
         # Dict comprehension:
         self.check_coverage("""\
@@ -657,10 +664,14 @@ def test_other_comprehensions(self) -> None:
                 x = tup[0]
                 y = tup[1]
             """,
-            arcz=".1 -22 2-2 12 23 34 45 53 3.",
+            arcz=".1 12 23 34 45 53 3." + extra_arcz,
         )
 
     def test_multiline_dict_comp(self) -> None:
+        if env.PYBEHAVIOR.comprehensions_are_functions:
+            extra_arcz = " 2-2"
+        else:
+            extra_arcz = ""
         # Multiline dict comp:
         self.check_coverage("""\
             # comment
@@ -675,7 +686,7 @@ def test_multiline_dict_comp(self) -> None:
             }
             x = 11
             """,
-            arcz="-22 2B B-2   2-2"
+            arcz="-22 2B B-2" + extra_arcz,
         )
         # Multi dict comp:
         self.check_coverage("""\
@@ -695,7 +706,7 @@ def test_multiline_dict_comp(self) -> None:
             }
             x = 15
             """,
-            arcz="-22 2F F-2   2-2"
+            arcz="-22 2F F-2" + extra_arcz,
         )
 
 
diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
index a9b64d158..07c76ce28 100644
--- a/tests/test_concurrency.py
+++ b/tests/test_concurrency.py
@@ -768,10 +768,11 @@ def test_sigterm_threading_saves_data(self) -> None:
             sigterm = true
             """)
         out = self.run_command("coverage run handler.py")
-        if env.LINUX:
-            assert out == "START\nSIGTERM\nTerminated\n"
-        else:
-            assert out == "START\nSIGTERM\n"
+        out_lines = out.splitlines()
+        assert len(out_lines) in [2, 3]
+        assert out_lines[:2] == ["START", "SIGTERM"]
+        if len(out_lines) == 3:
+            assert out_lines[2] == "Terminated"
         out = self.run_command("coverage report -m")
         expected = "handler.py 5 1 80% 6"
         assert self.squeezed_lines(out)[2] == expected
diff --git a/tests/test_debug.py b/tests/test_debug.py
index 60a7b10a4..e611134d0 100644
--- a/tests/test_debug.py
+++ b/tests/test_debug.py
@@ -19,7 +19,8 @@
 from coverage import env
 from coverage.debug import (
     DebugOutputFile,
-    clipped_repr, filter_text, info_formatter, info_header, short_id, short_stack,
+    clipped_repr, filter_text, info_formatter, info_header, relevant_environment_display,
+    short_id, short_stack,
 )
 
 from tests.coveragetest import CoverageTest
@@ -297,3 +298,22 @@ def test_short_stack_limit(self) -> None:
     def test_short_stack_skip(self) -> None:
         stack = f_one(skip=1).splitlines()
         assert "f_two" in stack[-1]
+
+
+def test_relevant_environment_display() -> None:
+    env_vars = {
+        "HOME": "my home",
+        "HOME_DIR": "other place",
+        "XYZ_NEVER_MIND": "doesn't matter",
+        "SOME_PYOTHER": "xyz123",
+        "COVERAGE_THING": "abcd",
+        "MY_PYPI_TOKEN": "secret.something",
+        "TMP": "temporary",
+    }
+    assert relevant_environment_display(env_vars) == [
+        ("COVERAGE_THING", "abcd"),
+        ("HOME", "my home"),
+        ("MY_PYPI_TOKEN", "******.*********"),
+        ("SOME_PYOTHER", "xyz123"),
+        ("TMP", "temporary"),
+    ]
diff --git a/tests/test_html.py b/tests/test_html.py
index 65f0cc763..476e75e80 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -24,7 +24,7 @@
 from coverage.exceptions import NoDataError, NotPython, NoSource
 from coverage.files import abs_file, flat_rootname
 import coverage.html
-from coverage.report import get_analysis_to_report
+from coverage.report_core import get_analysis_to_report
 from coverage.types import TLineNo, TMorf
 
 from tests.coveragetest import CoverageTest, TESTS_DIR
diff --git a/tests/test_lcov.py b/tests/test_lcov.py
index 6d50b62b5..a3332081e 100644
--- a/tests/test_lcov.py
+++ b/tests/test_lcov.py
@@ -23,8 +23,6 @@ def create_initial_files(self) -> None:
         show the consequences of changes in the setup.
         """
         self.make_file("main_file.py", """\
-            #!/usr/bin/env python3
-
             def cuboid_volume(l):
                 return (l*l*l)
 
@@ -33,8 +31,6 @@ def IsItTrue():
             """)
 
         self.make_file("test_file.py", """\
-            #!/usr/bin/env python3
-
             from main_file import cuboid_volume
             import unittest
 
@@ -52,11 +48,9 @@ def get_lcov_report_content(self, filename: str = "coverage.lcov") -> str:
             return file.read()
 
     def test_lone_file(self) -> None:
-        """For a single file with a couple of functions, the lcov should cover
-        the function definitions themselves, but not the returns."""
+        # For a single file with a couple of functions, the lcov should cover
+        # the function definitions themselves, but not the returns.
         self.make_file("main_file.py", """\
-            #!/usr/bin/env python3
-
             def cuboid_volume(l):
                 return (l*l*l)
 
@@ -66,10 +60,10 @@ def IsItTrue():
         expected_result = textwrap.dedent("""\
             TN:
             SF:main_file.py
-            DA:3,1,7URou3io0zReBkk69lEb/Q
-            DA:6,1,ilhb4KUfytxtEuClijZPlQ
-            DA:4,0,Xqj6H1iz/nsARMCAbE90ng
-            DA:7,0,LWILTcvARcydjFFyo9qM0A
+            DA:1,1,7URou3io0zReBkk69lEb/Q
+            DA:4,1,ilhb4KUfytxtEuClijZPlQ
+            DA:2,0,Xqj6H1iz/nsARMCAbE90ng
+            DA:5,0,LWILTcvARcydjFFyo9qM0A
             LF:4
             LH:2
             end_of_record
@@ -83,8 +77,8 @@ def IsItTrue():
         assert expected_result == actual_result
 
     def test_simple_line_coverage_two_files(self) -> None:
-        """Test that line coverage is created when coverage is run,
-        and matches the output of the file below."""
+        # Test that line coverage is created when coverage is run,
+        # and matches the output of the file below.
         self.create_initial_files()
         self.assert_doesnt_exist(".coverage")
         self.make_file(".coveragerc", "[lcov]\noutput = data.lcov\n")
@@ -96,23 +90,23 @@ def test_simple_line_coverage_two_files(self) -> None:
         expected_result = textwrap.dedent("""\
             TN:
             SF:main_file.py
-            DA:3,1,7URou3io0zReBkk69lEb/Q
-            DA:6,1,ilhb4KUfytxtEuClijZPlQ
-            DA:4,0,Xqj6H1iz/nsARMCAbE90ng
-            DA:7,0,LWILTcvARcydjFFyo9qM0A
+            DA:1,1,7URou3io0zReBkk69lEb/Q
+            DA:4,1,ilhb4KUfytxtEuClijZPlQ
+            DA:2,0,Xqj6H1iz/nsARMCAbE90ng
+            DA:5,0,LWILTcvARcydjFFyo9qM0A
             LF:4
             LH:2
             end_of_record
             TN:
             SF:test_file.py
-            DA:3,1,R5Rb4IzmjKRgY/vFFc1TRg
-            DA:4,1,E/tvV9JPVDhEcTCkgrwOFw
-            DA:6,1,GP08LPBYJq8EzYveHJy2qA
-            DA:7,1,MV+jSLi6PFEl+WatEAptog
-            DA:8,0,qyqd1mF289dg6oQAQHA+gQ
-            DA:9,0,nmEYd5F1KrxemgC9iVjlqg
-            DA:10,0,jodMK26WYDizOO1C7ekBbg
-            DA:11,0,LtxfKehkX8o4KvC5GnN52g
+            DA:1,1,R5Rb4IzmjKRgY/vFFc1TRg
+            DA:2,1,E/tvV9JPVDhEcTCkgrwOFw
+            DA:4,1,GP08LPBYJq8EzYveHJy2qA
+            DA:5,1,MV+jSLi6PFEl+WatEAptog
+            DA:6,0,qyqd1mF289dg6oQAQHA+gQ
+            DA:7,0,nmEYd5F1KrxemgC9iVjlqg
+            DA:8,0,jodMK26WYDizOO1C7ekBbg
+            DA:9,0,LtxfKehkX8o4KvC5GnN52g
             LF:8
             LH:4
             end_of_record
@@ -121,10 +115,8 @@ def test_simple_line_coverage_two_files(self) -> None:
         assert expected_result == actual_result
 
     def test_branch_coverage_one_file(self) -> None:
-        """Test that the reporter produces valid branch coverage."""
+        # Test that the reporter produces valid branch coverage.
         self.make_file("main_file.py", """\
-            #!/usr/bin/env python3
-
             def is_it_x(x):
                 if x == 3:
                     return x
@@ -140,14 +132,14 @@ def is_it_x(x):
         expected_result = textwrap.dedent("""\
             TN:
             SF:main_file.py
-            DA:3,1,4MDXMbvwQ3L7va1tsphVzw
-            DA:4,0,MuERA6EYyZNpKPqoJfzwkA
-            DA:5,0,sAyiiE6iAuPMte9kyd0+3g
-            DA:7,0,W/g8GJDAYJkSSurt59Mzfw
+            DA:1,1,4MDXMbvwQ3L7va1tsphVzw
+            DA:2,0,MuERA6EYyZNpKPqoJfzwkA
+            DA:3,0,sAyiiE6iAuPMte9kyd0+3g
+            DA:5,0,W/g8GJDAYJkSSurt59Mzfw
             LF:4
             LH:1
-            BRDA:5,0,0,-
-            BRDA:7,0,1,-
+            BRDA:3,0,0,-
+            BRDA:5,0,1,-
             BRF:2
             BRH:0
             end_of_record
@@ -156,11 +148,9 @@ def is_it_x(x):
         assert expected_result == actual_result
 
     def test_branch_coverage_two_files(self) -> None:
-        """Test that valid branch coverage is generated
-        in the case of two files."""
+        # Test that valid branch coverage is generated
+        # in the case of two files.
         self.make_file("main_file.py", """\
-            #!/usr/bin/env python3
-
             def is_it_x(x):
                 if x == 3:
                     return x
@@ -169,8 +159,6 @@ def is_it_x(x):
             """)
 
         self.make_file("test_file.py", """\
-            #!/usr/bin/env python3
-
             from main_file import *
             import unittest
 
@@ -188,25 +176,25 @@ def test_is_it_x(self):
         expected_result = textwrap.dedent("""\
             TN:
             SF:main_file.py
-            DA:3,1,4MDXMbvwQ3L7va1tsphVzw
-            DA:4,0,MuERA6EYyZNpKPqoJfzwkA
-            DA:5,0,sAyiiE6iAuPMte9kyd0+3g
-            DA:7,0,W/g8GJDAYJkSSurt59Mzfw
+            DA:1,1,4MDXMbvwQ3L7va1tsphVzw
+            DA:2,0,MuERA6EYyZNpKPqoJfzwkA
+            DA:3,0,sAyiiE6iAuPMte9kyd0+3g
+            DA:5,0,W/g8GJDAYJkSSurt59Mzfw
             LF:4
             LH:1
-            BRDA:5,0,0,-
-            BRDA:7,0,1,-
+            BRDA:3,0,0,-
+            BRDA:5,0,1,-
             BRF:2
             BRH:0
             end_of_record
             TN:
             SF:test_file.py
-            DA:3,1,9TxKIyoBtmhopmlbDNa8FQ
-            DA:4,1,E/tvV9JPVDhEcTCkgrwOFw
-            DA:6,1,C3s/c8C1Yd/zoNG1GnGexg
-            DA:7,1,9qPyWexYysgeKtB+YvuzAg
-            DA:8,0,LycuNcdqoUhPXeuXUTf5lA
-            DA:9,0,FPTWzd68bDx76HN7VHu1wA
+            DA:1,1,9TxKIyoBtmhopmlbDNa8FQ
+            DA:2,1,E/tvV9JPVDhEcTCkgrwOFw
+            DA:4,1,C3s/c8C1Yd/zoNG1GnGexg
+            DA:5,1,9qPyWexYysgeKtB+YvuzAg
+            DA:6,0,LycuNcdqoUhPXeuXUTf5lA
+            DA:7,0,FPTWzd68bDx76HN7VHu1wA
             LF:6
             LH:4
             BRF:0
@@ -217,9 +205,8 @@ def test_is_it_x(self):
         assert actual_result == expected_result
 
     def test_half_covered_branch(self) -> None:
-        """Test that for a given branch that is only half covered,
-        the block numbers remain the same, and produces valid lcov.
-        """
+        # Test that for a given branch that is only half covered,
+        # the block numbers remain the same, and produces valid lcov.
         self.make_file("main_file.py", """\
             something = True
 
@@ -253,14 +240,13 @@ def test_half_covered_branch(self) -> None:
         assert actual_result == expected_result
 
     def test_empty_init_files(self) -> None:
-        """Test that in the case of an empty __init__.py file, the lcov
-        reporter will note that the file is there, and will note the empty
-        line. It will also note the lack of branches, and the checksum for
-        the line.
-
-        Although there are no lines found, it will note one line as hit in
-        old Pythons, and no lines hit in newer Pythons.
-        """
+        # Test that in the case of an empty __init__.py file, the lcov
+        # reporter will note that the file is there, and will note the empty
+        # line. It will also note the lack of branches, and the checksum for
+        # the line.
+        #
+        # Although there are no lines found, it will note one line as hit in
+        # old Pythons, and no lines hit in newer Pythons.
 
         self.make_file("__init__.py", "")
         self.assert_doesnt_exist(".coverage")
diff --git a/tests/test_misc.py b/tests/test_misc.py
index ba465cbd1..4b83f8aec 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -6,13 +6,14 @@
 from __future__ import annotations
 
 import sys
+from unittest import mock
 
 import pytest
 
 from coverage.exceptions import CoverageException
 from coverage.misc import file_be_gone
 from coverage.misc import Hasher, substitute_variables, import_third_party
-from coverage.misc import human_sorted, human_sorted_items
+from coverage.misc import human_sorted, human_sorted_items, stdout_link
 
 from tests.coveragetest import CoverageTest
 
@@ -153,3 +154,21 @@ def test_human_sorted_items(words: str, ordered: str) -> None:
     oitems = [(k, v) for k in okeys for v in [1, 2]]
     assert human_sorted_items(items) == oitems
     assert human_sorted_items(items, reverse=True) == oitems[::-1]
+
+
+def test_stdout_link_tty() -> None:
+    with mock.patch.object(sys.stdout, "isatty", lambda:True):
+        link = stdout_link("some text", "some url")
+    assert link == "\033]8;;some url\asome text\033]8;;\a"
+
+
+def test_stdout_link_not_tty() -> None:
+    # Without mocking isatty, it reports False in a pytest suite.
+    assert stdout_link("some text", "some url") == "some text"
+
+
+def test_stdout_link_with_fake_stdout() -> None:
+    # If stdout is another object, we should still be ok.
+    with mock.patch.object(sys, "stdout", object()):
+        link = stdout_link("some text", "some url")
+    assert link == "some text"
diff --git a/tests/test_parser.py b/tests/test_parser.py
index f74420b5d..8ff3226bc 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -128,7 +128,7 @@ def foo():
     def test_indentation_error(self) -> None:
         msg = (
             "Couldn't parse '<code>' as Python source: " +
-            "'unindent does not match any outer indentation level' at line 3"
+            "'unindent does not match any outer indentation level.*' at line 3"
         )
         with pytest.raises(NotPython, match=msg):
             _ = self.parse_source("""\
@@ -138,11 +138,17 @@ def test_indentation_error(self) -> None:
                 """)
 
     def test_token_error(self) -> None:
-        msg = "Couldn't parse '<code>' as Python source: 'EOF in multi-line string' at line 1"
+        submsgs = [
+            r"EOF in multi-line string",                                        # before 3.12.0b1
+            r"unterminated triple-quoted string literal .detected at line 1.",  # after 3.12.0b1
+        ]
+        msg = (
+            r"Couldn't parse '<code>' as Python source: '"
+            + r"(" + "|".join(submsgs) + ")"
+            + r"' at line 1"
+        )
         with pytest.raises(NotPython, match=msg):
-            _ = self.parse_source("""\
-                '''
-                """)
+            _ = self.parse_source("'''")
 
     @xfail_pypy38
     def test_decorator_pragmas(self) -> None:
@@ -254,7 +260,10 @@ def bar(self):
     def test_fuzzed_double_parse(self) -> None:
         # https://bugs.chromium.org/p/oss-fuzz/issues/detail?id=50381
         # The second parse used to raise `TypeError: 'NoneType' object is not iterable`
-        msg = "EOF in multi-line statement"
+        msg = (
+            r"(EOF in multi-line statement)"        # before 3.12.0b1
+            + r"|(unmatched ']')"                   # after 3.12.0b1
+        )
         with pytest.raises(NotPython, match=msg):
             self.parse_source("]")
         with pytest.raises(NotPython, match=msg):
@@ -322,10 +331,11 @@ def test_missing_arc_descriptions_for_small_callables(self) -> None:
         assert expected == parser.missing_arc_description(2, -2)
         expected = "line 3 didn't finish the generator expression on line 3"
         assert expected == parser.missing_arc_description(3, -3)
-        expected = "line 4 didn't finish the dictionary comprehension on line 4"
-        assert expected == parser.missing_arc_description(4, -4)
-        expected = "line 5 didn't finish the set comprehension on line 5"
-        assert expected == parser.missing_arc_description(5, -5)
+        if env.PYBEHAVIOR.comprehensions_are_functions:
+            expected = "line 4 didn't finish the dictionary comprehension on line 4"
+            assert expected == parser.missing_arc_description(4, -4)
+            expected = "line 5 didn't finish the set comprehension on line 5"
+            assert expected == parser.missing_arc_description(5, -5)
 
     def test_missing_arc_descriptions_for_exceptions(self) -> None:
         parser = self.parse_text("""\
diff --git a/tests/test_process.py b/tests/test_process.py
index bdfa33164..e06b86f5d 100644
--- a/tests/test_process.py
+++ b/tests/test_process.py
@@ -8,6 +8,7 @@
 import glob
 import os
 import os.path
+import platform
 import re
 import stat
 import sys
@@ -837,6 +838,12 @@ def test_coverage_custom_script(self) -> None:
         assert "hello-xyzzy" in out
 
     @pytest.mark.skipif(env.WINDOWS, reason="Windows can't make symlinks")
+    @pytest.mark.skipif(
+        platform.python_version().endswith("+"),
+        reason="setuptools barfs on dev versions: https://github.com/pypa/packaging/issues/678"
+        # https://github.com/nedbat/coveragepy/issues/1556
+        # TODO: get rid of pkg_resources
+    )
     def test_bug_862(self) -> None:
         # This simulates how pyenv and pyenv-virtualenv end up creating the
         # coverage executable.
diff --git a/tests/test_report.py b/tests/test_report.py
index c85c6b473..51a4fc683 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -1,68 +1,1081 @@
 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
 
-"""Tests for helpers in report.py"""
+"""Test text-based summary reporting for coverage.py"""
 
 from __future__ import annotations
 
-from typing import IO, Iterable, List, Optional, Type
+import glob
+import io
+import math
+import os
+import os.path
+import py_compile
+import re
+
+from typing import Tuple
 
 import pytest
 
-from coverage.exceptions import CoverageException
-from coverage.report import render_report
-from coverage.types import TMorf
+import coverage
+from coverage import env
+from coverage.control import Coverage
+from coverage.data import CoverageData
+from coverage.exceptions import ConfigError, NoDataError, NotPython
+from coverage.files import abs_file
+from coverage.report import SummaryReporter
+from coverage.types import TConfigValueIn
+
+from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
+from tests.helpers import assert_coverage_warnings
+
+
+class SummaryTest(UsingModulesMixin, CoverageTest):
+    """Tests of the text summary reporting for coverage.py."""
+
+    def make_mycode(self) -> None:
+        """Make the mycode.py file when needed."""
+        self.make_file("mycode.py", """\
+            import covmod1
+            import covmodzip1
+            a = 1
+            print('done')
+            """)
+
+    def test_report(self) -> None:
+        self.make_mycode()
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "mycode")
+        assert self.stdout() == 'done\n'
+        report = self.get_report(cov)
+
+        # Name                                           Stmts   Miss  Cover
+        # ------------------------------------------------------------------
+        # c:/ned/coverage/tests/modules/covmod1.py           2      0   100%
+        # c:/ned/coverage/tests/zipmods.zip/covmodzip1.py    2      0   100%
+        # mycode.py                                          4      0   100%
+        # ------------------------------------------------------------------
+        # TOTAL                                              8      0   100%
+
+        assert "/coverage/__init__/" not in report
+        assert "/tests/modules/covmod1.py " in report
+        assert "/tests/zipmods.zip/covmodzip1.py " in report
+        assert "mycode.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 8 0 100%"
+
+    def test_report_just_one(self) -> None:
+        # Try reporting just one module
+        self.make_mycode()
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "mycode")
+        report = self.get_report(cov, morfs=["mycode.py"])
+
+        # Name        Stmts   Miss  Cover
+        # -------------------------------
+        # mycode.py       4      0   100%
+        # -------------------------------
+        # TOTAL           4      0   100%
+        assert self.line_count(report) == 5
+        assert "/coverage/" not in report
+        assert "/tests/modules/covmod1.py " not in report
+        assert "/tests/zipmods.zip/covmodzip1.py " not in report
+        assert "mycode.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
+
+    def test_report_wildcard(self) -> None:
+        # Try reporting using wildcards to get the modules.
+        self.make_mycode()
+        # Wildcard is handled by shell or cmdline.py, so use real commands
+        self.run_command("coverage run mycode.py")
+        report = self.report_from_command("coverage report my*.py")
+
+        # Name        Stmts   Miss  Cover
+        # -------------------------------
+        # mycode.py       4      0   100%
+        # -------------------------------
+        # TOTAL           4      0   100%
+
+        assert self.line_count(report) == 5
+        assert "/coverage/" not in report
+        assert "/tests/modules/covmod1.py " not in report
+        assert "/tests/zipmods.zip/covmodzip1.py " not in report
+        assert "mycode.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
+
+    def test_report_omitting(self) -> None:
+        # Try reporting while omitting some modules
+        self.make_mycode()
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "mycode")
+        report = self.get_report(cov, omit=[f"{TESTS_DIR}/*", "*/site-packages/*"])
+
+        # Name        Stmts   Miss  Cover
+        # -------------------------------
+        # mycode.py       4      0   100%
+        # -------------------------------
+        # TOTAL           4      0   100%
+
+        assert self.line_count(report) == 5
+        assert "/coverage/" not in report
+        assert "/tests/modules/covmod1.py " not in report
+        assert "/tests/zipmods.zip/covmodzip1.py " not in report
+        assert "mycode.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
+
+    def test_report_including(self) -> None:
+        # Try reporting while including some modules
+        self.make_mycode()
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "mycode")
+        report = self.get_report(cov, include=["mycode*"])
+
+        # Name        Stmts   Miss  Cover
+        # -------------------------------
+        # mycode.py       4      0   100%
+        # -------------------------------
+        # TOTAL           4      0   100%
+
+        assert self.line_count(report) == 5
+        assert "/coverage/" not in report
+        assert "/tests/modules/covmod1.py " not in report
+        assert "/tests/zipmods.zip/covmodzip1.py " not in report
+        assert "mycode.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
+
+    def test_report_include_relative_files_and_path(self) -> None:
+        """
+        Test that when relative_files is True and a relative path to a module
+        is included, coverage is reported for the module.
+
+        Ref: https://github.com/nedbat/coveragepy/issues/1604
+        """
+        self.make_mycode()
+        self.make_file(".coveragerc", """\
+            [run]
+            relative_files = true
+            """)
+        self.make_file("submodule/mycode.py", "import mycode")
+
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "submodule/mycode")
+        report = self.get_report(cov, include="submodule/mycode.py")
+
+        # Name                Stmts   Miss  Cover
+        # ---------------------------------------
+        # submodule/mycode.py 1       0     100%
+        # ---------------------------------------
+        # TOTAL               1       0     100%
+
+        assert "submodule/mycode.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 1 0 100%"
+
+    def test_report_include_relative_files_and_wildcard_path(self) -> None:
+        self.make_mycode()
+        self.make_file(".coveragerc", """\
+            [run]
+            relative_files = true
+            """)
+        self.make_file("submodule/mycode.py", "import nested.submodule.mycode")
+        self.make_file("nested/submodule/mycode.py", "import mycode")
+
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "submodule/mycode")
+        report = self.get_report(cov, include="*/submodule/mycode.py")
+
+        # Name                          Stmts   Miss  Cover
+        # -------------------------------------------------
+        # nested/submodule/mycode.py    1       0     100%
+        # submodule/mycode.py           1       0     100%
+        # -------------------------------------------------
+        # TOTAL                         2       0     100%
+
+        reported_files = [line.split()[0] for line in report.splitlines()[2:4]]
+        assert reported_files == [
+            "nested/submodule/mycode.py",
+            "submodule/mycode.py",
+        ]
+
+    def test_omit_files_here(self) -> None:
+        # 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) -> None:
+        # https://github.com/nedbat/coveragepy/issues/621
+        self.make_file(".coveragerc", """\
+            [run]
+            source = .
+
+            [report]
+            include = mod/*,tests/*
+            """)
+        # It should be OK to use that configuration.
+        cov = coverage.Coverage()
+        with self.assert_warnings(cov, []):
+            cov.start()
+            cov.stop()                                                  # pragma: nested
 
-from tests.coveragetest import CoverageTest
+    def test_run_omit_vs_report_omit(self) -> None:
+        # https://github.com/nedbat/coveragepy/issues/622
+        # report:omit shouldn't clobber run:omit.
+        self.make_mycode()
+        self.make_file(".coveragerc", """\
+            [run]
+            omit = */covmodzip1.py
 
+            [report]
+            omit = */covmod1.py
+            """)
+        self.run_command("coverage run mycode.py")
 
-class FakeReporter:
-    """A fake implementation of a one-file reporter."""
+        # Read the data written, to see that the right files have been omitted from running.
+        covdata = CoverageData()
+        covdata.read()
+        files = [os.path.basename(p) for p in covdata.measured_files()]
+        assert "covmod1.py" in files
+        assert "covmodzip1.py" not in files
 
-    report_type = "fake report file"
+    def test_report_branches(self) -> None:
+        self.make_file("mybranch.py", """\
+            def branch(x):
+                if x:
+                    print("x")
+                return x
+            branch(1)
+            """)
+        cov = coverage.Coverage(source=["."], branch=True)
+        self.start_import_stop(cov, "mybranch")
+        assert self.stdout() == 'x\n'
+        report = self.get_report(cov)
 
-    def __init__(self, output: str = "", error: Optional[Type[Exception]] = None) -> None:
-        self.output = output
-        self.error = error
-        self.morfs: Optional[Iterable[TMorf]] = None
+        # Name          Stmts   Miss Branch BrPart  Cover
+        # -----------------------------------------------
+        # mybranch.py       5      0      2      1    86%
+        # -----------------------------------------------
+        # TOTAL             5      0      2      1    86%
+        assert self.line_count(report) == 5
+        assert "mybranch.py " in report
+        assert self.last_line_squeezed(report) == "TOTAL 5 0 2 1 86%"
 
-    def report(self, morfs: Optional[Iterable[TMorf]], outfile: IO[str]) -> float:
-        """Fake."""
-        self.morfs = morfs
-        outfile.write(self.output)
-        if self.error:
-            raise self.error("You asked for it!")
-        return 17.25
+    def test_report_show_missing(self) -> None:
+        self.make_file("mymissing.py", """\
+            def missing(x, y):
+                if x:
+                    print("x")
+                    return x
+                if y:
+                    print("y")
+                try:
+                    print("z")
+                    1/0
+                    print("Never!")
+                except ZeroDivisionError:
+                    pass
+                return x
+            missing(0, 1)
+            """)
+        cov = coverage.Coverage(source=["."])
+        self.start_import_stop(cov, "mymissing")
+        assert self.stdout() == 'y\nz\n'
+        report = self.get_report(cov, show_missing=True)
 
+        # Name           Stmts   Miss  Cover   Missing
+        # --------------------------------------------
+        # mymissing.py      14      3    79%   3-4, 10
+        # --------------------------------------------
+        # TOTAL             14      3    79%
 
-class RenderReportTest(CoverageTest):
-    """Tests of render_report."""
+        assert self.line_count(report) == 5
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[2] == "mymissing.py 14 3 79% 3-4, 10"
+        assert squeezed[4] == "TOTAL 14 3 79%"
 
-    def test_stdout(self) -> None:
-        fake = FakeReporter(output="Hello!\n")
-        msgs: List[str] = []
-        res = render_report("-", fake, [pytest, "coverage"], msgs.append)
-        assert res == 17.25
-        assert fake.morfs == [pytest, "coverage"]
-        assert self.stdout() == "Hello!\n"
-        assert not msgs
+    def test_report_show_missing_branches(self) -> None:
+        self.make_file("mybranch.py", """\
+            def branch(x, y):
+                if x:
+                    print("x")
+                if y:
+                    print("y")
+            branch(1, 1)
+            """)
+        cov = coverage.Coverage(branch=True)
+        self.start_import_stop(cov, "mybranch")
+        assert self.stdout() == 'x\ny\n'
 
-    def test_file(self) -> None:
-        fake = FakeReporter(output="Gréètings!\n")
-        msgs: List[str] = []
-        res = render_report("output.txt", fake, [], msgs.append)
-        assert res == 17.25
+    def test_report_show_missing_branches_and_lines(self) -> None:
+        self.make_file("main.py", """\
+            import mybranch
+            """)
+        self.make_file("mybranch.py", """\
+            def branch(x, y, z):
+                if x:
+                    print("x")
+                if y:
+                    print("y")
+                if z:
+                    if x and y:
+                        print("z")
+                return x
+            branch(1, 1, 0)
+            """)
+        cov = coverage.Coverage(branch=True)
+        self.start_import_stop(cov, "main")
+        assert self.stdout() == 'x\ny\n'
+
+    def test_report_skip_covered_no_branches(self) -> None:
+        self.make_file("main.py", """
+            import not_covered
+
+            def normal():
+                print("z")
+            normal()
+            """)
+        self.make_file("not_covered.py", """
+            def not_covered():
+                print("n")
+            """)
+        # --fail-under is handled by cmdline.py, use real commands.
+        out = self.run_command("coverage run main.py")
+        assert out == "z\n"
+        report = self.report_from_command("coverage report --skip-covered --fail-under=70")
+
+        # Name             Stmts   Miss  Cover
+        # ------------------------------------
+        # not_covered.py       2      1    50%
+        # ------------------------------------
+        # TOTAL                6      1    83%
+        #
+        # 1 file skipped due to complete coverage.
+
+        assert self.line_count(report) == 7, report
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[2] == "not_covered.py 2 1 50%"
+        assert squeezed[4] == "TOTAL 6 1 83%"
+        assert squeezed[6] == "1 file skipped due to complete coverage."
+        assert self.last_command_status == 0
+
+    def test_report_skip_covered_branches(self) -> None:
+        self.make_file("main.py", """
+            import not_covered, covered
+
+            def normal(z):
+                if z:
+                    print("z")
+            normal(True)
+            normal(False)
+            """)
+        self.make_file("not_covered.py", """
+            def not_covered(n):
+                if n:
+                    print("n")
+            not_covered(True)
+            """)
+        self.make_file("covered.py", """
+            def foo():
+                pass
+            foo()
+            """)
+        cov = coverage.Coverage(branch=True)
+        self.start_import_stop(cov, "main")
+        assert self.stdout() == "n\nz\n"
+        report = self.get_report(cov, skip_covered=True)
+
+        # Name             Stmts   Miss Branch BrPart  Cover
+        # --------------------------------------------------
+        # not_covered.py       4      0      2      1    83%
+        # --------------------------------------------------
+        # TOTAL               13      0      4      1    94%
+        #
+        # 2 files skipped due to complete coverage.
+
+        assert self.line_count(report) == 7, report
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[2] == "not_covered.py 4 0 2 1 83%"
+        assert squeezed[4] == "TOTAL 13 0 4 1 94%"
+        assert squeezed[6] == "2 files skipped due to complete coverage."
+
+    def test_report_skip_covered_branches_with_totals(self) -> None:
+        self.make_file("main.py", """
+            import not_covered
+            import also_not_run
+
+            def normal(z):
+                if z:
+                    print("z")
+            normal(True)
+            normal(False)
+            """)
+        self.make_file("not_covered.py", """
+            def not_covered(n):
+                if n:
+                    print("n")
+            not_covered(True)
+            """)
+        self.make_file("also_not_run.py", """
+            def does_not_appear_in_this_film(ni):
+                print("Ni!")
+            """)
+        cov = coverage.Coverage(branch=True)
+        self.start_import_stop(cov, "main")
+        assert self.stdout() == "n\nz\n"
+        report = self.get_report(cov, skip_covered=True)
+
+        # Name             Stmts   Miss Branch BrPart  Cover
+        # --------------------------------------------------
+        # also_not_run.py      2      1      0      0    50%
+        # not_covered.py       4      0      2      1    83%
+        # --------------------------------------------------
+        # TOTAL                13     1      4      1    88%
+        #
+        # 1 file skipped due to complete coverage.
+
+        assert self.line_count(report) == 8, report
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[2] == "also_not_run.py 2 1 0 0 50%"
+        assert squeezed[3] == "not_covered.py 4 0 2 1 83%"
+        assert squeezed[5] == "TOTAL 13 1 4 1 88%"
+        assert squeezed[7] == "1 file skipped due to complete coverage."
+
+    def test_report_skip_covered_all_files_covered(self) -> None:
+        self.make_file("main.py", """
+            def foo():
+                pass
+            foo()
+            """)
+        cov = coverage.Coverage(source=["."], branch=True)
+        self.start_import_stop(cov, "main")
         assert self.stdout() == ""
-        with open("output.txt", "rb") as f:
-            assert f.read().rstrip() == b"Gr\xc3\xa9\xc3\xa8tings!"
-        assert msgs == ["Wrote fake report file to output.txt"]
-
-    @pytest.mark.parametrize("error", [CoverageException, ZeroDivisionError])
-    def test_exception(self, error: Type[Exception]) -> None:
-        fake = FakeReporter(error=error)
-        msgs: List[str] = []
-        with pytest.raises(error, match="You asked for it!"):
-            render_report("output.txt", fake, [], msgs.append)
+        report = self.get_report(cov, skip_covered=True)
+
+        # Name    Stmts   Miss Branch BrPart  Cover
+        # -----------------------------------------
+        # TOTAL       3      0      0      0   100%
+        #
+        # 1 file skipped due to complete coverage.
+
+        assert self.line_count(report) == 5, report
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[4] == "1 file skipped due to complete coverage."
+
+        report = self.get_report(cov, squeeze=False, skip_covered=True, output_format="markdown")
+
+        # | Name      |    Stmts |     Miss |   Branch |   BrPart |    Cover |
+        # |---------- | -------: | -------: | -------: | -------: | -------: |
+        # | **TOTAL** |    **3** |    **0** |    **0** |    **0** | **100%** |
+        #
+        # 1 file skipped due to complete coverage.
+
+        assert self.line_count(report) == 5, report
+        assert report.split("\n")[0] == (
+            '| Name      |    Stmts |     Miss |   Branch |   BrPart |    Cover |'
+        )
+        assert report.split("\n")[1] == (
+            '|---------- | -------: | -------: | -------: | -------: | -------: |'
+        )
+        assert report.split("\n")[2] == (
+            '| **TOTAL** |    **3** |    **0** |    **0** |    **0** | **100%** |'
+        )
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[4] == "1 file skipped due to complete coverage."
+
+        total = self.get_report(cov, output_format="total", skip_covered=True)
+        assert total == "100\n"
+
+    def test_report_skip_covered_longfilename(self) -> None:
+        self.make_file("long_______________filename.py", """
+            def foo():
+                pass
+            foo()
+            """)
+        cov = coverage.Coverage(source=["."], branch=True)
+        self.start_import_stop(cov, "long_______________filename")
+        assert self.stdout() == ""
+        report = self.get_report(cov, squeeze=False, skip_covered=True)
+
+        # Name    Stmts   Miss Branch BrPart  Cover
+        # -----------------------------------------
+        # TOTAL       3      0      0      0   100%
+        #
+        # 1 file skipped due to complete coverage.
+
+        assert self.line_count(report) == 5, report
+        lines = self.report_lines(report)
+        assert lines[0] == "Name    Stmts   Miss Branch BrPart  Cover"
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[4] == "1 file skipped due to complete coverage."
+
+    def test_report_skip_covered_no_data(self) -> None:
+        cov = coverage.Coverage()
+        cov.load()
+        with pytest.raises(NoDataError, match="No data to report."):
+            self.get_report(cov, skip_covered=True)
+        self.assert_doesnt_exist(".coverage")
+
+    def test_report_skip_empty(self) -> None:
+        self.make_file("main.py", """
+            import submodule
+
+            def normal():
+                print("z")
+            normal()
+            """)
+        self.make_file("submodule/__init__.py", "")
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "main")
+        assert self.stdout() == "z\n"
+        report = self.get_report(cov, skip_empty=True)
+
+        # Name             Stmts   Miss  Cover
+        # ------------------------------------
+        # main.py              4      0   100%
+        # ------------------------------------
+        # TOTAL                4      0   100%
+        #
+        # 1 empty file skipped.
+
+        assert self.line_count(report) == 7, report
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[2] == "main.py 4 0 100%"
+        assert squeezed[4] == "TOTAL 4 0 100%"
+        assert squeezed[6] == "1 empty file skipped."
+
+    def test_report_skip_empty_no_data(self) -> None:
+        self.make_file("__init__.py", "")
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "__init__")
         assert self.stdout() == ""
-        self.assert_doesnt_exist("output.txt")
-        assert not msgs
+        report = self.get_report(cov, skip_empty=True)
+
+        # Name             Stmts   Miss  Cover
+        # ------------------------------------
+        # TOTAL                0      0   100%
+        #
+        # 1 empty file skipped.
+
+        assert self.line_count(report) == 5, report
+        assert report.split("\n")[2] == "TOTAL 0 0 100%"
+        assert report.split("\n")[4] == "1 empty file skipped."
+
+    def test_report_precision(self) -> None:
+        self.make_file(".coveragerc", """\
+            [report]
+            precision = 3
+            omit = */site-packages/*
+            """)
+        self.make_file("main.py", """
+            import not_covered, covered
+
+            def normal(z):
+                if z:
+                    print("z")
+            normal(True)
+            normal(False)
+            """)
+        self.make_file("not_covered.py", """
+            def not_covered(n):
+                if n:
+                    print("n")
+            not_covered(True)
+            """)
+        self.make_file("covered.py", """
+            def foo():
+                pass
+            foo()
+            """)
+        cov = coverage.Coverage(branch=True)
+        self.start_import_stop(cov, "main")
+        assert self.stdout() == "n\nz\n"
+        report = self.get_report(cov, squeeze=False)
+
+        # Name             Stmts   Miss Branch BrPart      Cover
+        # ------------------------------------------------------
+        # covered.py           3      0      0      0   100.000%
+        # main.py              6      0      2      0   100.000%
+        # not_covered.py       4      0      2      1    83.333%
+        # ------------------------------------------------------
+        # TOTAL               13      0      4      1    94.118%
+
+        assert self.line_count(report) == 7, report
+        squeezed = self.squeezed_lines(report)
+        assert squeezed[2] == "covered.py 3 0 0 0 100.000%"
+        assert squeezed[4] == "not_covered.py 4 0 2 1 83.333%"
+        assert squeezed[6] == "TOTAL 13 0 4 1 94.118%"
+
+    def test_report_precision_all_zero(self) -> None:
+        self.make_file("not_covered.py", """
+            def not_covered(n):
+                if n:
+                    print("n")
+            """)
+        self.make_file("empty.py", "")
+        cov = coverage.Coverage(source=["."])
+        self.start_import_stop(cov, "empty")
+        report = self.get_report(cov, precision=6, squeeze=False)
+
+        # Name             Stmts   Miss       Cover
+        # -----------------------------------------
+        # empty.py             0      0 100.000000%
+        # not_covered.py       3      3   0.000000%
+        # -----------------------------------------
+        # TOTAL                3      3   0.000000%
+
+        assert self.line_count(report) == 6, report
+        assert "empty.py             0      0 100.000000%" in report
+        assert "not_covered.py       3      3   0.000000%" in report
+        assert "TOTAL                3      3   0.000000%" in report
+
+    def test_dotpy_not_python(self) -> None:
+        # We run a .py file, and when reporting, we can't parse it as Python.
+        # We should get an error message in the report.
+
+        self.make_data_file(lines={"mycode.py": [1]})
+        self.make_file("mycode.py", "This isn't python at all!")
+        cov = coverage.Coverage()
+        cov.load()
+        msg = r"Couldn't parse '.*[/\\]mycode.py' as Python source: '.*' at line 1"
+        with pytest.raises(NotPython, match=msg):
+            self.get_report(cov, morfs=["mycode.py"])
+
+    def test_accented_directory(self) -> None:
+        # Make a file with a non-ascii character in the directory name.
+        self.make_file("\xe2/accented.py", "print('accented')")
+        self.make_data_file(lines={abs_file("\xe2/accented.py"): [1]})
+        report_expected = (
+            "Name            Stmts   Miss  Cover\n" +
+            "-----------------------------------\n" +
+            "\xe2/accented.py       1      0   100%\n" +
+            "-----------------------------------\n" +
+            "TOTAL               1      0   100%\n"
+        )
+        cov = coverage.Coverage()
+        cov.load()
+        output = self.get_report(cov, squeeze=False)
+        assert output == report_expected
+
+    def test_accenteddotpy_not_python(self) -> None:
+        # We run a .py file with a non-ascii name, and when reporting, we can't
+        # parse it as Python.  We should get an error message in the report.
+
+        self.make_data_file(lines={"accented\xe2.py": [1]})
+        self.make_file("accented\xe2.py", "This isn't python at all!")
+        cov = coverage.Coverage()
+        cov.load()
+        msg = r"Couldn't parse '.*[/\\]accented\xe2.py' as Python source: '.*' at line 1"
+        with pytest.raises(NotPython, match=msg):
+            self.get_report(cov, morfs=["accented\xe2.py"])
+
+    def test_dotpy_not_python_ignored(self) -> None:
+        # We run a .py file, and when reporting, we can't parse it as Python,
+        # but we've said to ignore errors, so there's no error reported,
+        # though we still get a warning.
+        self.make_file("mycode.py", "This isn't python at all!")
+        self.make_data_file(lines={"mycode.py": [1]})
+        cov = coverage.Coverage()
+        cov.load()
+        with pytest.raises(NoDataError, match="No data to report."):
+            with pytest.warns(Warning) as warns:
+                self.get_report(cov, morfs=["mycode.py"], ignore_errors=True)
+        assert_coverage_warnings(
+            warns,
+            re.compile(r"Couldn't parse Python file '.*[/\\]mycode.py' \(couldnt-parse\)"),
+        )
+
+    def test_dothtml_not_python(self) -> None:
+        # We run a .html file, and when reporting, we can't parse it as
+        # Python.  Since it wasn't .py, no error is reported.
+
+        # Pretend to run an html file.
+        self.make_file("mycode.html", "<h1>This isn't python at all!</h1>")
+        self.make_data_file(lines={"mycode.html": [1]})
+        cov = coverage.Coverage()
+        cov.load()
+        with pytest.raises(NoDataError, match="No data to report."):
+            self.get_report(cov, morfs=["mycode.html"])
+
+    def test_report_no_extension(self) -> None:
+        self.make_file("xxx", """\
+            # This is a python file though it doesn't look like it, like a main script.
+            a = b = c = d = 0
+            a = 3
+            b = 4
+            if not b:
+                c = 6
+            d = 7
+            print(f"xxx: {a} {b} {c} {d}")
+            """)
+        self.make_data_file(lines={abs_file("xxx"): [2, 3, 4, 5, 7, 8]})
+        cov = coverage.Coverage()
+        cov.load()
+        report = self.get_report(cov)
+        assert self.last_line_squeezed(report) == "TOTAL 7 1 86%"
+
+    def test_report_with_chdir(self) -> None:
+        self.make_file("chdir.py", """\
+            import os
+            print("Line One")
+            os.chdir("subdir")
+            print("Line Two")
+            print(open("something").read())
+            """)
+        self.make_file("subdir/something", "hello")
+        out = self.run_command("coverage run --source=. chdir.py")
+        assert out == "Line One\nLine Two\nhello\n"
+        report = self.report_from_command("coverage report")
+        assert self.last_line_squeezed(report) == "TOTAL 5 0 100%"
+        report = self.report_from_command("coverage report --format=markdown")
+        assert self.last_line_squeezed(report) == "| **TOTAL** | **5** | **0** | **100%** |"
+
+    def test_bug_156_file_not_run_should_be_zero(self) -> None:
+        # https://github.com/nedbat/coveragepy/issues/156
+        self.make_file("mybranch.py", """\
+            def branch(x):
+                if x:
+                    print("x")
+                return x
+            branch(1)
+            """)
+        self.make_file("main.py", """\
+            print("y")
+            """)
+        cov = coverage.Coverage(branch=True, source=["."])
+        self.start_import_stop(cov, "main")
+        report = self.get_report(cov).splitlines()
+        assert "mybranch.py 5 5 2 0 0%" in report
+
+    def run_TheCode_and_report_it(self) -> str:
+        """A helper for the next few tests."""
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "TheCode")
+        return self.get_report(cov)
+
+    def test_bug_203_mixed_case_listed_twice_with_rc(self) -> None:
+        self.make_file("TheCode.py", "a = 1\n")
+        self.make_file(".coveragerc", "[run]\nsource = .\n")
+
+        report = self.run_TheCode_and_report_it()
+        assert "TheCode" in report
+        assert "thecode" not in report
+
+    def test_bug_203_mixed_case_listed_twice(self) -> None:
+        self.make_file("TheCode.py", "a = 1\n")
+
+        report = self.run_TheCode_and_report_it()
+
+        assert "TheCode" in report
+        assert "thecode" not in report
+
+    @pytest.mark.skipif(not env.WINDOWS, reason=".pyw files are only on Windows.")
+    def test_pyw_files(self) -> None:
+        # https://github.com/nedbat/coveragepy/issues/261
+        self.make_file("start.pyw", """\
+            import mod
+            print("In start.pyw")
+            """)
+        self.make_file("mod.pyw", """\
+            print("In mod.pyw")
+            """)
+        cov = coverage.Coverage()
+        # start_import_stop can't import the .pyw file, so use the long form.
+        cov.start()
+        import start    # pragma: nested # pylint: disable=import-error, unused-import
+        cov.stop()      # pragma: nested
+
+        report = self.get_report(cov)
+        assert "NoSource" not in report
+        report_lines = report.splitlines()
+        assert "start.pyw 2 0 100%" in report_lines
+        assert "mod.pyw 1 0 100%" in report_lines
+
+    def test_tracing_pyc_file(self) -> None:
+        # Create two Python files.
+        self.make_file("mod.py", "a = 1\n")
+        self.make_file("main.py", "import mod\n")
+
+        # Make one into a .pyc.
+        py_compile.compile("mod.py")
+
+        # Run the program.
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "main")
+
+        report_lines = self.get_report(cov).splitlines()
+        assert "mod.py 1 0 100%" in report_lines
+        report = self.get_report(cov, squeeze=False, output_format="markdown")
+        assert report.split("\n")[3] == "| mod.py    |        1 |        0 |     100% |"
+        assert report.split("\n")[4] == "| **TOTAL** |    **2** |    **0** | **100%** |"
+
+    def test_missing_py_file_during_run(self) -> None:
+        # Create two Python files.
+        self.make_file("mod.py", "a = 1\n")
+        self.make_file("main.py", "import mod\n")
+
+        # Make one into a .pyc, and remove the .py.
+        py_compile.compile("mod.py")
+        os.remove("mod.py")
+
+        # Python 3 puts the .pyc files in a __pycache__ directory, and will
+        # not import from there without source.  It will import a .pyc from
+        # the source location though.
+        pycs = glob.glob("__pycache__/mod.*.pyc")
+        assert len(pycs) == 1
+        os.rename(pycs[0], "mod.pyc")
+
+        # Run the program.
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "main")
+
+        # Put back the missing Python file.
+        self.make_file("mod.py", "a = 1\n")
+        report = self.get_report(cov).splitlines()
+        assert "mod.py 1 0 100%" in report
+
+    def test_empty_files(self) -> None:
+        # Shows that empty files like __init__.py are listed as having zero
+        # statements, not one statement.
+        cov = coverage.Coverage(branch=True)
+        cov.start()
+        import usepkgs  # pragma: nested # pylint: disable=import-error, unused-import
+        cov.stop()      # pragma: nested
+        report = self.get_report(cov)
+        assert "tests/modules/pkg1/__init__.py 1 0 0 0 100%" in report
+        assert "tests/modules/pkg2/__init__.py 0 0 0 0 100%" in report
+        report = self.get_report(cov, squeeze=False, output_format="markdown")
+        # get_report() escapes backslash so we expect forward slash escaped
+        # underscore
+        assert "tests/modules/pkg1//_/_init/_/_.py " in report
+        assert "|        1 |        0 |        0 |        0 |     100% |" in report
+        assert "tests/modules/pkg2//_/_init/_/_.py " in report
+        assert "|        0 |        0 |        0 |        0 |     100% |" in report
+
+    def test_markdown_with_missing(self) -> None:
+        self.make_file("mymissing.py", """\
+            def missing(x, y):
+                if x:
+                    print("x")
+                    return x
+                if y:
+                    print("y")
+                try:
+                    print("z")
+                    1/0
+                    print("Never!")
+                except ZeroDivisionError:
+                    pass
+                return x
+            missing(0, 1)
+            """)
+        cov = coverage.Coverage(source=["."])
+        self.start_import_stop(cov, "mymissing")
+        assert self.stdout() == 'y\nz\n'
+        report = self.get_report(cov, squeeze=False, output_format="markdown", show_missing=True)
+
+        # | Name         |    Stmts |     Miss |   Cover |   Missing |
+        # |------------- | -------: | -------: | ------: | --------: |
+        # | mymissing.py |       14 |        3 |     79% |   3-4, 10 |
+        # |    **TOTAL** |   **14** |    **3** | **79%** |           |
+        assert self.line_count(report) == 4
+        report_lines = report.split("\n")
+        assert report_lines[2] == "| mymissing.py |       14 |        3 |     79% |   3-4, 10 |"
+        assert report_lines[3] == "|    **TOTAL** |   **14** |    **3** | **79%** |           |"
+
+        assert self.get_report(cov, output_format="total") == "79\n"
+        assert self.get_report(cov, output_format="total", precision=2) == "78.57\n"
+        assert self.get_report(cov, output_format="total", precision=4) == "78.5714\n"
+
+    def test_bug_1524(self) -> None:
+        self.make_file("bug1524.py", """\
+            class Mine:
+                @property
+                def thing(self) -> int:
+                    return 17
+
+            print(Mine().thing)
+            """)
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "bug1524")
+        assert self.stdout() == "17\n"
+        report = self.get_report(cov)
+        report_lines = report.splitlines()
+        assert report_lines[2] == "bug1524.py 5 0 100%"
+
+
+class ReportingReturnValueTest(CoverageTest):
+    """Tests of reporting functions returning values."""
+
+    def run_coverage(self) -> Coverage:
+        """Run coverage on doit.py and return the coverage object."""
+        self.make_file("doit.py", """\
+            a = 1
+            b = 2
+            c = 3
+            d = 4
+            if a > 10:
+                f = 6
+            g = 7
+            """)
+
+        cov = coverage.Coverage()
+        self.start_import_stop(cov, "doit")
+        return cov
+
+    def test_report(self) -> None:
+        cov = self.run_coverage()
+        val = cov.report(include="*/doit.py")
+        assert math.isclose(val, 6 / 7 * 100)
+
+    def test_html(self) -> None:
+        cov = self.run_coverage()
+        val = cov.html_report(include="*/doit.py")
+        assert math.isclose(val, 6 / 7 * 100)
+
+    def test_xml(self) -> None:
+        cov = self.run_coverage()
+        val = cov.xml_report(include="*/doit.py")
+        assert math.isclose(val, 6 / 7 * 100)
+
+
+class SummaryReporterConfigurationTest(CoverageTest):
+    """Tests of SummaryReporter."""
+
+    def make_rigged_file(self, filename: str, stmts: int, miss: int) -> None:
+        """Create a file that will have specific results.
+
+        `stmts` and `miss` are ints, the number of statements, and
+        missed statements that should result.
+        """
+        run = stmts - miss - 1
+        dont_run = miss
+        source = ""
+        source += "a = 1\n" * run
+        source += "if a == 99:\n"
+        source += "    a = 2\n" * dont_run
+        self.make_file(filename, source)
+
+    def get_summary_text(self, *options: Tuple[str, TConfigValueIn]) -> str:
+        """Get text output from the SummaryReporter.
+
+        The arguments are tuples: (name, value) for Coverage.set_option.
+        """
+        self.make_rigged_file("file1.py", 339, 155)
+        self.make_rigged_file("file2.py", 13, 3)
+        self.make_rigged_file("file10.py", 234, 228)
+        self.make_file("doit.py", "import file1, file2, file10")
+
+        cov = Coverage(source=["."], omit=["doit.py"])
+        self.start_import_stop(cov, "doit")
+        for name, value in options:
+            cov.set_option(name, value)
+        printer = SummaryReporter(cov)
+        destination = io.StringIO()
+        printer.report([], destination)
+        return destination.getvalue()
+
+    def test_test_data(self) -> None:
+        # We use our own test files as test data. Check that our assumptions
+        # about them are still valid.  We want the three columns of numbers to
+        # sort in three different orders.
+        report = self.get_summary_text()
+        # Name       Stmts   Miss  Cover
+        # ------------------------------
+        # file1.py     339    155    54%
+        # file2.py      13      3    77%
+        # file10.py    234    228     3%
+        # ------------------------------
+        # TOTAL        586    386    34%
+        lines = report.splitlines()[2:-2]
+        assert len(lines) == 3
+        nums = [list(map(int, l.replace('%', '').split()[1:])) for l in lines]
+        # [
+        #  [339, 155, 54],
+        #  [ 13,   3, 77],
+        #  [234, 228,  3]
+        # ]
+        assert nums[1][0] < nums[2][0] < nums[0][0]
+        assert nums[1][1] < nums[0][1] < nums[2][1]
+        assert nums[2][2] < nums[0][2] < nums[1][2]
+
+    def test_defaults(self) -> None:
+        """Run the report with no configuration options."""
+        report = self.get_summary_text()
+        assert 'Missing' not in report
+        assert 'Branch' not in report
+
+    def test_print_missing(self) -> None:
+        """Run the report printing the missing lines."""
+        report = self.get_summary_text(('report:show_missing', True))
+        assert 'Missing' in report
+        assert 'Branch' not in report
+
+    def assert_ordering(self, text: str, *words: str) -> None:
+        """Assert that the `words` appear in order in `text`."""
+        indexes = list(map(text.find, words))
+        assert -1 not in indexes
+        msg = f"The words {words!r} don't appear in order in {text!r}"
+        assert indexes == sorted(indexes), msg
+
+    def test_default_sort_report(self) -> None:
+        # Sort the text report by the default (Name) column.
+        report = self.get_summary_text()
+        self.assert_ordering(report, "file1.py", "file2.py", "file10.py")
+
+    def test_sort_report_by_name(self) -> None:
+        # Sort the text report explicitly by the Name column.
+        report = self.get_summary_text(('report:sort', 'Name'))
+        self.assert_ordering(report, "file1.py", "file2.py", "file10.py")
+
+    def test_sort_report_by_stmts(self) -> None:
+        # Sort the text report by the Stmts column.
+        report = self.get_summary_text(('report:sort', 'Stmts'))
+        self.assert_ordering(report, "file2.py", "file10.py", "file1.py")
+
+    def test_sort_report_by_missing(self) -> None:
+        # Sort the text report by the Missing column.
+        report = self.get_summary_text(('report:sort', 'Miss'))
+        self.assert_ordering(report, "file2.py", "file1.py", "file10.py")
+
+    def test_sort_report_by_cover(self) -> None:
+        # Sort the text report by the Cover column.
+        report = self.get_summary_text(('report:sort', 'Cover'))
+        self.assert_ordering(report, "file10.py", "file1.py", "file2.py")
+
+    def test_sort_report_by_cover_plus(self) -> None:
+        # Sort the text report by the Cover column, including the explicit + sign.
+        report = self.get_summary_text(('report:sort', '+Cover'))
+        self.assert_ordering(report, "file10.py", "file1.py", "file2.py")
+
+    def test_sort_report_by_cover_reversed(self) -> None:
+        # Sort the text report by the Cover column reversed.
+        report = self.get_summary_text(('report:sort', '-Cover'))
+        self.assert_ordering(report, "file2.py", "file1.py", "file10.py")
+
+    def test_sort_report_by_invalid_option(self) -> None:
+        # Sort the text report by a nonsense column.
+        msg = "Invalid sorting option: 'Xyzzy'"
+        with pytest.raises(ConfigError, match=msg):
+            self.get_summary_text(('report:sort', 'Xyzzy'))
+
+    def test_report_with_invalid_format(self) -> None:
+        # Ask for an invalid format.
+        msg = "Unknown report format choice: 'xyzzy'"
+        with pytest.raises(ConfigError, match=msg):
+            self.get_summary_text(('report:format', 'xyzzy'))
diff --git a/tests/test_report_common.py b/tests/test_report_common.py
new file mode 100644
index 000000000..685515172
--- /dev/null
+++ b/tests/test_report_common.py
@@ -0,0 +1,283 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
+
+"""Tests of behavior common to all reporting."""
+
+from __future__ import annotations
+
+import textwrap
+
+import coverage
+from coverage import env
+from coverage.files import abs_file
+
+from tests.coveragetest import CoverageTest
+from tests.goldtest import contains, doesnt_contain
+from tests.helpers import arcz_to_arcs, os_sep
+
+
+class ReportMapsPathsTest(CoverageTest):
+    """Check that reporting implicitly maps paths."""
+
+    def make_files(self, data: str, settings: bool = False) -> None:
+        """Create the test files we need for line coverage."""
+        src = """\
+            if VER == 1:
+                print("line 2")
+            if VER == 2:
+                print("line 4")
+            if VER == 3:
+                print("line 6")
+            """
+        self.make_file("src/program.py", src)
+        self.make_file("ver1/program.py", src)
+        self.make_file("ver2/program.py", src)
+
+        if data == "line":
+            self.make_data_file(
+                lines={
+                    abs_file("ver1/program.py"): [1, 2, 3, 5],
+                    abs_file("ver2/program.py"): [1, 3, 4, 5],
+                }
+            )
+        else:
+            self.make_data_file(
+                arcs={
+                    abs_file("ver1/program.py"): arcz_to_arcs(".1 12 23 35 5."),
+                    abs_file("ver2/program.py"): arcz_to_arcs(".1 13 34 45 5."),
+                }
+            )
+
+        if settings:
+            self.make_file(".coveragerc", """\
+                [paths]
+                source =
+                    src
+                    ver1
+                    ver2
+                """)
+
+    def test_map_paths_during_line_report_without_setting(self) -> None:
+        self.make_files(data="line")
+        cov = coverage.Coverage()
+        cov.load()
+        cov.report(show_missing=True)
+        expected = textwrap.dedent(os_sep("""\
+            Name              Stmts   Miss  Cover   Missing
+            -----------------------------------------------
+            ver1/program.py       6      2    67%   4, 6
+            ver2/program.py       6      2    67%   2, 6
+            -----------------------------------------------
+            TOTAL                12      4    67%
+            """))
+        assert expected == self.stdout()
+
+    def test_map_paths_during_line_report(self) -> None:
+        self.make_files(data="line", settings=True)
+        cov = coverage.Coverage()
+        cov.load()
+        cov.report(show_missing=True)
+        expected = textwrap.dedent(os_sep("""\
+            Name             Stmts   Miss  Cover   Missing
+            ----------------------------------------------
+            src/program.py       6      1    83%   6
+            ----------------------------------------------
+            TOTAL                6      1    83%
+            """))
+        assert expected == self.stdout()
+
+    def test_map_paths_during_branch_report_without_setting(self) -> None:
+        self.make_files(data="arcs")
+        cov = coverage.Coverage(branch=True)
+        cov.load()
+        cov.report(show_missing=True)
+        expected = textwrap.dedent(os_sep("""\
+            Name              Stmts   Miss Branch BrPart  Cover   Missing
+            -------------------------------------------------------------
+            ver1/program.py       6      2      6      3    58%   1->3, 4, 6
+            ver2/program.py       6      2      6      3    58%   2, 3->5, 6
+            -------------------------------------------------------------
+            TOTAL                12      4     12      6    58%
+            """))
+        assert expected == self.stdout()
+
+    def test_map_paths_during_branch_report(self) -> None:
+        self.make_files(data="arcs", settings=True)
+        cov = coverage.Coverage(branch=True)
+        cov.load()
+        cov.report(show_missing=True)
+        expected = textwrap.dedent(os_sep("""\
+            Name             Stmts   Miss Branch BrPart  Cover   Missing
+            ------------------------------------------------------------
+            src/program.py       6      1      6      1    83%   6
+            ------------------------------------------------------------
+            TOTAL                6      1      6      1    83%
+            """))
+        assert expected == self.stdout()
+
+    def test_map_paths_during_annotate(self) -> None:
+        self.make_files(data="line", settings=True)
+        cov = coverage.Coverage()
+        cov.load()
+        cov.annotate()
+        self.assert_exists(os_sep("src/program.py,cover"))
+        self.assert_doesnt_exist(os_sep("ver1/program.py,cover"))
+        self.assert_doesnt_exist(os_sep("ver2/program.py,cover"))
+
+    def test_map_paths_during_html_report(self) -> None:
+        self.make_files(data="line", settings=True)
+        cov = coverage.Coverage()
+        cov.load()
+        cov.html_report()
+        contains("htmlcov/index.html", os_sep("src/program.py"))
+        doesnt_contain("htmlcov/index.html", os_sep("ver1/program.py"), os_sep("ver2/program.py"))
+
+    def test_map_paths_during_xml_report(self) -> None:
+        self.make_files(data="line", settings=True)
+        cov = coverage.Coverage()
+        cov.load()
+        cov.xml_report()
+        contains("coverage.xml", "src/program.py")
+        doesnt_contain("coverage.xml", "ver1/program.py", "ver2/program.py")
+
+    def test_map_paths_during_json_report(self) -> None:
+        self.make_files(data="line", settings=True)
+        cov = coverage.Coverage()
+        cov.load()
+        cov.json_report()
+        def os_sepj(s: str) -> str:
+            return os_sep(s).replace("\\", r"\\")
+        contains("coverage.json", os_sepj("src/program.py"))
+        doesnt_contain("coverage.json", os_sepj("ver1/program.py"), os_sepj("ver2/program.py"))
+
+    def test_map_paths_during_lcov_report(self) -> None:
+        self.make_files(data="line", settings=True)
+        cov = coverage.Coverage()
+        cov.load()
+        cov.lcov_report()
+        contains("coverage.lcov", os_sep("src/program.py"))
+        doesnt_contain("coverage.lcov", os_sep("ver1/program.py"), os_sep("ver2/program.py"))
+
+
+class ReportWithJinjaTest(CoverageTest):
+    """Tests of Jinja-like behavior.
+
+    Jinja2 compiles a template into Python code, and then runs the Python code
+    to render the template.  But during rendering, it uses the template name
+    (for example, "template.j2") as the file name, not the Python code file
+    name.  Then during reporting, we will try to parse template.j2 as Python
+    code.
+
+    If the file can be parsed, it's included in the report (as a Python file!).
+    If it can't be parsed, then it's not included in the report.
+
+    These tests confirm that code doesn't raise an exception (as reported in
+    #1553), and that the current (incorrect) behavior remains stable.  Ideally,
+    good.j2 wouldn't be listed at all, since we can't report on it accurately.
+
+    See https://github.com/nedbat/coveragepy/issues/1553 for more detail, and
+    https://github.com/nedbat/coveragepy/issues/1623 for an issue about this
+    behavior.
+
+    """
+
+    def make_files(self) -> None:
+        """Create test files: two Jinja templates, and data from rendering them."""
+        # A Jinja2 file that is syntactically acceptable Python (though it wont run).
+        self.make_file("good.j2", """\
+            {{ data }}
+            line2
+            line3
+            """)
+        # A Jinja2 file that is a Python syntax error.
+        self.make_file("bad.j2", """\
+            This is data: {{ data }}.
+            line 2
+            line 3
+            """)
+        self.make_data_file(
+            lines={
+                abs_file("good.j2"): [1, 3, 5, 7, 9],
+                abs_file("bad.j2"): [1, 3, 5, 7, 9],
+            }
+        )
+
+    def test_report(self) -> None:
+        self.make_files()
+        cov = coverage.Coverage()
+        cov.load()
+        cov.report(show_missing=True)
+        expected = textwrap.dedent("""\
+            Name      Stmts   Miss  Cover   Missing
+            ---------------------------------------
+            good.j2       3      1    67%   2
+            ---------------------------------------
+            TOTAL         3      1    67%
+            """)
+        assert expected == self.stdout()
+
+    def test_html(self) -> None:
+        self.make_files()
+        cov = coverage.Coverage()
+        cov.load()
+        cov.html_report()
+        contains("htmlcov/index.html", """\
+        <tbody>
+            <tr class="file">
+                <td class="name left"><a href="good_j2.html">good.j2</a></td>
+                <td>3</td>
+                <td>1</td>
+                <td>0</td>
+                <td class="right" data-ratio="2 3">67%</td>
+            </tr>
+        </tbody>"""
+        )
+        doesnt_contain("htmlcov/index.html", "bad.j2")
+
+    def test_xml(self) -> None:
+        self.make_files()
+        cov = coverage.Coverage()
+        cov.load()
+        cov.xml_report()
+        contains("coverage.xml", 'filename="good.j2"')
+        if env.PYVERSION >= (3, 8):     # Py3.7 puts attributes in the other order.
+            contains("coverage.xml",
+                '<line number="1" hits="1"/>',
+                '<line number="2" hits="0"/>',
+                '<line number="3" hits="1"/>',
+            )
+        doesnt_contain("coverage.xml", 'filename="bad.j2"')
+        if env.PYVERSION >= (3, 8):     # Py3.7 puts attributes in the other order.
+            doesnt_contain("coverage.xml", '<line number="4"',)
+
+    def test_json(self) -> None:
+        self.make_files()
+        cov = coverage.Coverage()
+        cov.load()
+        cov.json_report()
+        contains("coverage.json",
+            # Notice the .json report claims lines in good.j2 executed that
+            # don't even exist in good.j2...
+            '"files": {"good.j2": {"executed_lines": [1, 3, 5, 7, 9], ' +
+                '"summary": {"covered_lines": 2, "num_statements": 3',
+        )
+        doesnt_contain("coverage.json", "bad.j2")
+
+    def test_lcov(self) -> None:
+        self.make_files()
+        cov = coverage.Coverage()
+        cov.load()
+        cov.lcov_report()
+        with open("coverage.lcov") as lcov:
+            actual = lcov.read()
+        expected = textwrap.dedent("""\
+            TN:
+            SF:good.j2
+            DA:1,1,FHs1rDakj9p/NAzMCu3Kgw
+            DA:3,1,DGOyp8LEgI+3CcdFYw9uKQ
+            DA:2,0,5iUbzxp9w7peeTPjJbvmBQ
+            LF:3
+            LH:2
+            end_of_record
+            """)
+        assert expected == actual
diff --git a/tests/test_report_core.py b/tests/test_report_core.py
new file mode 100644
index 000000000..77e234b66
--- /dev/null
+++ b/tests/test_report_core.py
@@ -0,0 +1,68 @@
+# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
+# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
+
+"""Tests for helpers in report.py"""
+
+from __future__ import annotations
+
+from typing import IO, Iterable, List, Optional, Type
+
+import pytest
+
+from coverage.exceptions import CoverageException
+from coverage.report_core import render_report
+from coverage.types import TMorf
+
+from tests.coveragetest import CoverageTest
+
+
+class FakeReporter:
+    """A fake implementation of a one-file reporter."""
+
+    report_type = "fake report file"
+
+    def __init__(self, output: str = "", error: Optional[Type[Exception]] = None) -> None:
+        self.output = output
+        self.error = error
+        self.morfs: Optional[Iterable[TMorf]] = None
+
+    def report(self, morfs: Optional[Iterable[TMorf]], outfile: IO[str]) -> float:
+        """Fake."""
+        self.morfs = morfs
+        outfile.write(self.output)
+        if self.error:
+            raise self.error("You asked for it!")
+        return 17.25
+
+
+class RenderReportTest(CoverageTest):
+    """Tests of render_report."""
+
+    def test_stdout(self) -> None:
+        fake = FakeReporter(output="Hello!\n")
+        msgs: List[str] = []
+        res = render_report("-", fake, [pytest, "coverage"], msgs.append)
+        assert res == 17.25
+        assert fake.morfs == [pytest, "coverage"]
+        assert self.stdout() == "Hello!\n"
+        assert not msgs
+
+    def test_file(self) -> None:
+        fake = FakeReporter(output="Gréètings!\n")
+        msgs: List[str] = []
+        res = render_report("output.txt", fake, [], msgs.append)
+        assert res == 17.25
+        assert self.stdout() == ""
+        with open("output.txt", "rb") as f:
+            assert f.read().rstrip() == b"Gr\xc3\xa9\xc3\xa8tings!"
+        assert msgs == ["Wrote fake report file to output.txt"]
+
+    @pytest.mark.parametrize("error", [CoverageException, ZeroDivisionError])
+    def test_exception(self, error: Type[Exception]) -> None:
+        fake = FakeReporter(error=error)
+        msgs: List[str] = []
+        with pytest.raises(error, match="You asked for it!"):
+            render_report("output.txt", fake, [], msgs.append)
+        assert self.stdout() == ""
+        self.assert_doesnt_exist("output.txt")
+        assert not msgs
diff --git a/tests/test_summary.py b/tests/test_summary.py
deleted file mode 100644
index f532a7b1f..000000000
--- a/tests/test_summary.py
+++ /dev/null
@@ -1,1028 +0,0 @@
-# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
-# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
-
-"""Test text-based summary reporting for coverage.py"""
-
-from __future__ import annotations
-
-import glob
-import io
-import math
-import os
-import os.path
-import py_compile
-import re
-
-from typing import Tuple
-
-import pytest
-
-import coverage
-from coverage import env
-from coverage.control import Coverage
-from coverage.data import CoverageData
-from coverage.exceptions import ConfigError, NoDataError, NotPython
-from coverage.files import abs_file
-from coverage.summary import SummaryReporter
-from coverage.types import TConfigValueIn
-
-from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
-from tests.helpers import assert_coverage_warnings
-
-
-class SummaryTest(UsingModulesMixin, CoverageTest):
-    """Tests of the text summary reporting for coverage.py."""
-
-    def make_mycode(self) -> None:
-        """Make the mycode.py file when needed."""
-        self.make_file("mycode.py", """\
-            import covmod1
-            import covmodzip1
-            a = 1
-            print('done')
-            """)
-
-    def test_report(self) -> None:
-        self.make_mycode()
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "mycode")
-        assert self.stdout() == 'done\n'
-        report = self.get_report(cov)
-
-        # Name                                           Stmts   Miss  Cover
-        # ------------------------------------------------------------------
-        # c:/ned/coverage/tests/modules/covmod1.py           2      0   100%
-        # c:/ned/coverage/tests/zipmods.zip/covmodzip1.py    2      0   100%
-        # mycode.py                                          4      0   100%
-        # ------------------------------------------------------------------
-        # TOTAL                                              8      0   100%
-
-        assert "/coverage/__init__/" not in report
-        assert "/tests/modules/covmod1.py " in report
-        assert "/tests/zipmods.zip/covmodzip1.py " in report
-        assert "mycode.py " in report
-        assert self.last_line_squeezed(report) == "TOTAL 8 0 100%"
-
-    def test_report_just_one(self) -> None:
-        # Try reporting just one module
-        self.make_mycode()
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "mycode")
-        report = self.get_report(cov, morfs=["mycode.py"])
-
-        # Name        Stmts   Miss  Cover
-        # -------------------------------
-        # mycode.py       4      0   100%
-        # -------------------------------
-        # TOTAL           4      0   100%
-        assert self.line_count(report) == 5
-        assert "/coverage/" not in report
-        assert "/tests/modules/covmod1.py " not in report
-        assert "/tests/zipmods.zip/covmodzip1.py " not in report
-        assert "mycode.py " in report
-        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
-
-    def test_report_wildcard(self) -> None:
-        # Try reporting using wildcards to get the modules.
-        self.make_mycode()
-        # Wildcard is handled by shell or cmdline.py, so use real commands
-        self.run_command("coverage run mycode.py")
-        report = self.report_from_command("coverage report my*.py")
-
-        # Name        Stmts   Miss  Cover
-        # -------------------------------
-        # mycode.py       4      0   100%
-        # -------------------------------
-        # TOTAL           4      0   100%
-
-        assert self.line_count(report) == 5
-        assert "/coverage/" not in report
-        assert "/tests/modules/covmod1.py " not in report
-        assert "/tests/zipmods.zip/covmodzip1.py " not in report
-        assert "mycode.py " in report
-        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
-
-    def test_report_omitting(self) -> None:
-        # Try reporting while omitting some modules
-        self.make_mycode()
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "mycode")
-        report = self.get_report(cov, omit=[f"{TESTS_DIR}/*", "*/site-packages/*"])
-
-        # Name        Stmts   Miss  Cover
-        # -------------------------------
-        # mycode.py       4      0   100%
-        # -------------------------------
-        # TOTAL           4      0   100%
-
-        assert self.line_count(report) == 5
-        assert "/coverage/" not in report
-        assert "/tests/modules/covmod1.py " not in report
-        assert "/tests/zipmods.zip/covmodzip1.py " not in report
-        assert "mycode.py " in report
-        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
-
-    def test_report_including(self) -> None:
-        # Try reporting while including some modules
-        self.make_mycode()
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "mycode")
-        report = self.get_report(cov, include=["mycode*"])
-
-        # Name        Stmts   Miss  Cover
-        # -------------------------------
-        # mycode.py       4      0   100%
-        # -------------------------------
-        # TOTAL           4      0   100%
-
-        assert self.line_count(report) == 5
-        assert "/coverage/" not in report
-        assert "/tests/modules/covmod1.py " not in report
-        assert "/tests/zipmods.zip/covmodzip1.py " not in report
-        assert "mycode.py " in report
-        assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
-
-    def test_omit_files_here(self) -> None:
-        # 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) -> None:
-        # https://github.com/nedbat/coveragepy/issues/621
-        self.make_file(".coveragerc", """\
-            [run]
-            source = .
-
-            [report]
-            include = mod/*,tests/*
-            """)
-        # It should be OK to use that configuration.
-        cov = coverage.Coverage()
-        with self.assert_warnings(cov, []):
-            cov.start()
-            cov.stop()                                                  # pragma: nested
-
-    def test_run_omit_vs_report_omit(self) -> None:
-        # https://github.com/nedbat/coveragepy/issues/622
-        # report:omit shouldn't clobber run:omit.
-        self.make_mycode()
-        self.make_file(".coveragerc", """\
-            [run]
-            omit = */covmodzip1.py
-
-            [report]
-            omit = */covmod1.py
-            """)
-        self.run_command("coverage run mycode.py")
-
-        # Read the data written, to see that the right files have been omitted from running.
-        covdata = CoverageData()
-        covdata.read()
-        files = [os.path.basename(p) for p in covdata.measured_files()]
-        assert "covmod1.py" in files
-        assert "covmodzip1.py" not in files
-
-    def test_report_branches(self) -> None:
-        self.make_file("mybranch.py", """\
-            def branch(x):
-                if x:
-                    print("x")
-                return x
-            branch(1)
-            """)
-        cov = coverage.Coverage(source=["."], branch=True)
-        self.start_import_stop(cov, "mybranch")
-        assert self.stdout() == 'x\n'
-        report = self.get_report(cov)
-
-        # Name          Stmts   Miss Branch BrPart  Cover
-        # -----------------------------------------------
-        # mybranch.py       5      0      2      1    86%
-        # -----------------------------------------------
-        # TOTAL             5      0      2      1    86%
-        assert self.line_count(report) == 5
-        assert "mybranch.py " in report
-        assert self.last_line_squeezed(report) == "TOTAL 5 0 2 1 86%"
-
-    def test_report_show_missing(self) -> None:
-        self.make_file("mymissing.py", """\
-            def missing(x, y):
-                if x:
-                    print("x")
-                    return x
-                if y:
-                    print("y")
-                try:
-                    print("z")
-                    1/0
-                    print("Never!")
-                except ZeroDivisionError:
-                    pass
-                return x
-            missing(0, 1)
-            """)
-        cov = coverage.Coverage(source=["."])
-        self.start_import_stop(cov, "mymissing")
-        assert self.stdout() == 'y\nz\n'
-        report = self.get_report(cov, show_missing=True)
-
-        # Name           Stmts   Miss  Cover   Missing
-        # --------------------------------------------
-        # mymissing.py      14      3    79%   3-4, 10
-        # --------------------------------------------
-        # TOTAL             14      3    79%
-
-        assert self.line_count(report) == 5
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[2] == "mymissing.py 14 3 79% 3-4, 10"
-        assert squeezed[4] == "TOTAL 14 3 79%"
-
-    def test_report_show_missing_branches(self) -> None:
-        self.make_file("mybranch.py", """\
-            def branch(x, y):
-                if x:
-                    print("x")
-                if y:
-                    print("y")
-            branch(1, 1)
-            """)
-        cov = coverage.Coverage(branch=True)
-        self.start_import_stop(cov, "mybranch")
-        assert self.stdout() == 'x\ny\n'
-
-    def test_report_show_missing_branches_and_lines(self) -> None:
-        self.make_file("main.py", """\
-            import mybranch
-            """)
-        self.make_file("mybranch.py", """\
-            def branch(x, y, z):
-                if x:
-                    print("x")
-                if y:
-                    print("y")
-                if z:
-                    if x and y:
-                        print("z")
-                return x
-            branch(1, 1, 0)
-            """)
-        cov = coverage.Coverage(branch=True)
-        self.start_import_stop(cov, "main")
-        assert self.stdout() == 'x\ny\n'
-
-    def test_report_skip_covered_no_branches(self) -> None:
-        self.make_file("main.py", """
-            import not_covered
-
-            def normal():
-                print("z")
-            normal()
-            """)
-        self.make_file("not_covered.py", """
-            def not_covered():
-                print("n")
-            """)
-        # --fail-under is handled by cmdline.py, use real commands.
-        out = self.run_command("coverage run main.py")
-        assert out == "z\n"
-        report = self.report_from_command("coverage report --skip-covered --fail-under=70")
-
-        # Name             Stmts   Miss  Cover
-        # ------------------------------------
-        # not_covered.py       2      1    50%
-        # ------------------------------------
-        # TOTAL                6      1    83%
-        #
-        # 1 file skipped due to complete coverage.
-
-        assert self.line_count(report) == 7, report
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[2] == "not_covered.py 2 1 50%"
-        assert squeezed[4] == "TOTAL 6 1 83%"
-        assert squeezed[6] == "1 file skipped due to complete coverage."
-        assert self.last_command_status == 0
-
-    def test_report_skip_covered_branches(self) -> None:
-        self.make_file("main.py", """
-            import not_covered, covered
-
-            def normal(z):
-                if z:
-                    print("z")
-            normal(True)
-            normal(False)
-            """)
-        self.make_file("not_covered.py", """
-            def not_covered(n):
-                if n:
-                    print("n")
-            not_covered(True)
-            """)
-        self.make_file("covered.py", """
-            def foo():
-                pass
-            foo()
-            """)
-        cov = coverage.Coverage(branch=True)
-        self.start_import_stop(cov, "main")
-        assert self.stdout() == "n\nz\n"
-        report = self.get_report(cov, skip_covered=True)
-
-        # Name             Stmts   Miss Branch BrPart  Cover
-        # --------------------------------------------------
-        # not_covered.py       4      0      2      1    83%
-        # --------------------------------------------------
-        # TOTAL               13      0      4      1    94%
-        #
-        # 2 files skipped due to complete coverage.
-
-        assert self.line_count(report) == 7, report
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[2] == "not_covered.py 4 0 2 1 83%"
-        assert squeezed[4] == "TOTAL 13 0 4 1 94%"
-        assert squeezed[6] == "2 files skipped due to complete coverage."
-
-    def test_report_skip_covered_branches_with_totals(self) -> None:
-        self.make_file("main.py", """
-            import not_covered
-            import also_not_run
-
-            def normal(z):
-                if z:
-                    print("z")
-            normal(True)
-            normal(False)
-            """)
-        self.make_file("not_covered.py", """
-            def not_covered(n):
-                if n:
-                    print("n")
-            not_covered(True)
-            """)
-        self.make_file("also_not_run.py", """
-            def does_not_appear_in_this_film(ni):
-                print("Ni!")
-            """)
-        cov = coverage.Coverage(branch=True)
-        self.start_import_stop(cov, "main")
-        assert self.stdout() == "n\nz\n"
-        report = self.get_report(cov, skip_covered=True)
-
-        # Name             Stmts   Miss Branch BrPart  Cover
-        # --------------------------------------------------
-        # also_not_run.py      2      1      0      0    50%
-        # not_covered.py       4      0      2      1    83%
-        # --------------------------------------------------
-        # TOTAL                13     1      4      1    88%
-        #
-        # 1 file skipped due to complete coverage.
-
-        assert self.line_count(report) == 8, report
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[2] == "also_not_run.py 2 1 0 0 50%"
-        assert squeezed[3] == "not_covered.py 4 0 2 1 83%"
-        assert squeezed[5] == "TOTAL 13 1 4 1 88%"
-        assert squeezed[7] == "1 file skipped due to complete coverage."
-
-    def test_report_skip_covered_all_files_covered(self) -> None:
-        self.make_file("main.py", """
-            def foo():
-                pass
-            foo()
-            """)
-        cov = coverage.Coverage(source=["."], branch=True)
-        self.start_import_stop(cov, "main")
-        assert self.stdout() == ""
-        report = self.get_report(cov, skip_covered=True)
-
-        # Name    Stmts   Miss Branch BrPart  Cover
-        # -----------------------------------------
-        # TOTAL       3      0      0      0   100%
-        #
-        # 1 file skipped due to complete coverage.
-
-        assert self.line_count(report) == 5, report
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[4] == "1 file skipped due to complete coverage."
-
-        report = self.get_report(cov, squeeze=False, skip_covered=True, output_format="markdown")
-
-        # | Name      |    Stmts |     Miss |   Branch |   BrPart |    Cover |
-        # |---------- | -------: | -------: | -------: | -------: | -------: |
-        # | **TOTAL** |    **3** |    **0** |    **0** |    **0** | **100%** |
-        #
-        # 1 file skipped due to complete coverage.
-
-        assert self.line_count(report) == 5, report
-        assert report.split("\n")[0] == (
-            '| Name      |    Stmts |     Miss |   Branch |   BrPart |    Cover |'
-        )
-        assert report.split("\n")[1] == (
-            '|---------- | -------: | -------: | -------: | -------: | -------: |'
-        )
-        assert report.split("\n")[2] == (
-            '| **TOTAL** |    **3** |    **0** |    **0** |    **0** | **100%** |'
-        )
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[4] == "1 file skipped due to complete coverage."
-
-        total = self.get_report(cov, output_format="total", skip_covered=True)
-        assert total == "100\n"
-
-    def test_report_skip_covered_longfilename(self) -> None:
-        self.make_file("long_______________filename.py", """
-            def foo():
-                pass
-            foo()
-            """)
-        cov = coverage.Coverage(source=["."], branch=True)
-        self.start_import_stop(cov, "long_______________filename")
-        assert self.stdout() == ""
-        report = self.get_report(cov, squeeze=False, skip_covered=True)
-
-        # Name    Stmts   Miss Branch BrPart  Cover
-        # -----------------------------------------
-        # TOTAL       3      0      0      0   100%
-        #
-        # 1 file skipped due to complete coverage.
-
-        assert self.line_count(report) == 5, report
-        lines = self.report_lines(report)
-        assert lines[0] == "Name    Stmts   Miss Branch BrPart  Cover"
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[4] == "1 file skipped due to complete coverage."
-
-    def test_report_skip_covered_no_data(self) -> None:
-        cov = coverage.Coverage()
-        cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
-            self.get_report(cov, skip_covered=True)
-        self.assert_doesnt_exist(".coverage")
-
-    def test_report_skip_empty(self) -> None:
-        self.make_file("main.py", """
-            import submodule
-
-            def normal():
-                print("z")
-            normal()
-            """)
-        self.make_file("submodule/__init__.py", "")
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "main")
-        assert self.stdout() == "z\n"
-        report = self.get_report(cov, skip_empty=True)
-
-        # Name             Stmts   Miss  Cover
-        # ------------------------------------
-        # main.py              4      0   100%
-        # ------------------------------------
-        # TOTAL                4      0   100%
-        #
-        # 1 empty file skipped.
-
-        assert self.line_count(report) == 7, report
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[2] == "main.py 4 0 100%"
-        assert squeezed[4] == "TOTAL 4 0 100%"
-        assert squeezed[6] == "1 empty file skipped."
-
-    def test_report_skip_empty_no_data(self) -> None:
-        self.make_file("__init__.py", "")
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "__init__")
-        assert self.stdout() == ""
-        report = self.get_report(cov, skip_empty=True)
-
-        # Name             Stmts   Miss  Cover
-        # ------------------------------------
-        # TOTAL                0      0   100%
-        #
-        # 1 empty file skipped.
-
-        assert self.line_count(report) == 5, report
-        assert report.split("\n")[2] == "TOTAL 0 0 100%"
-        assert report.split("\n")[4] == "1 empty file skipped."
-
-    def test_report_precision(self) -> None:
-        self.make_file(".coveragerc", """\
-            [report]
-            precision = 3
-            omit = */site-packages/*
-            """)
-        self.make_file("main.py", """
-            import not_covered, covered
-
-            def normal(z):
-                if z:
-                    print("z")
-            normal(True)
-            normal(False)
-            """)
-        self.make_file("not_covered.py", """
-            def not_covered(n):
-                if n:
-                    print("n")
-            not_covered(True)
-            """)
-        self.make_file("covered.py", """
-            def foo():
-                pass
-            foo()
-            """)
-        cov = coverage.Coverage(branch=True)
-        self.start_import_stop(cov, "main")
-        assert self.stdout() == "n\nz\n"
-        report = self.get_report(cov, squeeze=False)
-
-        # Name             Stmts   Miss Branch BrPart      Cover
-        # ------------------------------------------------------
-        # covered.py           3      0      0      0   100.000%
-        # main.py              6      0      2      0   100.000%
-        # not_covered.py       4      0      2      1    83.333%
-        # ------------------------------------------------------
-        # TOTAL               13      0      4      1    94.118%
-
-        assert self.line_count(report) == 7, report
-        squeezed = self.squeezed_lines(report)
-        assert squeezed[2] == "covered.py 3 0 0 0 100.000%"
-        assert squeezed[4] == "not_covered.py 4 0 2 1 83.333%"
-        assert squeezed[6] == "TOTAL 13 0 4 1 94.118%"
-
-    def test_report_precision_all_zero(self) -> None:
-        self.make_file("not_covered.py", """
-            def not_covered(n):
-                if n:
-                    print("n")
-            """)
-        self.make_file("empty.py", "")
-        cov = coverage.Coverage(source=["."])
-        self.start_import_stop(cov, "empty")
-        report = self.get_report(cov, precision=6, squeeze=False)
-
-        # Name             Stmts   Miss       Cover
-        # -----------------------------------------
-        # empty.py             0      0 100.000000%
-        # not_covered.py       3      3   0.000000%
-        # -----------------------------------------
-        # TOTAL                3      3   0.000000%
-
-        assert self.line_count(report) == 6, report
-        assert "empty.py             0      0 100.000000%" in report
-        assert "not_covered.py       3      3   0.000000%" in report
-        assert "TOTAL                3      3   0.000000%" in report
-
-    def test_dotpy_not_python(self) -> None:
-        # We run a .py file, and when reporting, we can't parse it as Python.
-        # We should get an error message in the report.
-
-        self.make_data_file(lines={"mycode.py": [1]})
-        self.make_file("mycode.py", "This isn't python at all!")
-        cov = coverage.Coverage()
-        cov.load()
-        msg = r"Couldn't parse '.*[/\\]mycode.py' as Python source: '.*' at line 1"
-        with pytest.raises(NotPython, match=msg):
-            self.get_report(cov, morfs=["mycode.py"])
-
-    def test_accented_directory(self) -> None:
-        # Make a file with a non-ascii character in the directory name.
-        self.make_file("\xe2/accented.py", "print('accented')")
-        self.make_data_file(lines={abs_file("\xe2/accented.py"): [1]})
-        report_expected = (
-            "Name            Stmts   Miss  Cover\n" +
-            "-----------------------------------\n" +
-            "\xe2/accented.py       1      0   100%\n" +
-            "-----------------------------------\n" +
-            "TOTAL               1      0   100%\n"
-        )
-        cov = coverage.Coverage()
-        cov.load()
-        output = self.get_report(cov, squeeze=False)
-        assert output == report_expected
-
-    def test_accenteddotpy_not_python(self) -> None:
-        # We run a .py file with a non-ascii name, and when reporting, we can't
-        # parse it as Python.  We should get an error message in the report.
-
-        self.make_data_file(lines={"accented\xe2.py": [1]})
-        self.make_file("accented\xe2.py", "This isn't python at all!")
-        cov = coverage.Coverage()
-        cov.load()
-        msg = r"Couldn't parse '.*[/\\]accented\xe2.py' as Python source: '.*' at line 1"
-        with pytest.raises(NotPython, match=msg):
-            self.get_report(cov, morfs=["accented\xe2.py"])
-
-    def test_dotpy_not_python_ignored(self) -> None:
-        # We run a .py file, and when reporting, we can't parse it as Python,
-        # but we've said to ignore errors, so there's no error reported,
-        # though we still get a warning.
-        self.make_file("mycode.py", "This isn't python at all!")
-        self.make_data_file(lines={"mycode.py": [1]})
-        cov = coverage.Coverage()
-        cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
-            with pytest.warns(Warning) as warns:
-                self.get_report(cov, morfs=["mycode.py"], ignore_errors=True)
-        assert_coverage_warnings(
-            warns,
-            re.compile(r"Couldn't parse Python file '.*[/\\]mycode.py' \(couldnt-parse\)"),
-        )
-
-    def test_dothtml_not_python(self) -> None:
-        # We run a .html file, and when reporting, we can't parse it as
-        # Python.  Since it wasn't .py, no error is reported.
-
-        # Pretend to run an html file.
-        self.make_file("mycode.html", "<h1>This isn't python at all!</h1>")
-        self.make_data_file(lines={"mycode.html": [1]})
-        cov = coverage.Coverage()
-        cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
-            self.get_report(cov, morfs=["mycode.html"])
-
-    def test_report_no_extension(self) -> None:
-        self.make_file("xxx", """\
-            # This is a python file though it doesn't look like it, like a main script.
-            a = b = c = d = 0
-            a = 3
-            b = 4
-            if not b:
-                c = 6
-            d = 7
-            print(f"xxx: {a} {b} {c} {d}")
-            """)
-        self.make_data_file(lines={abs_file("xxx"): [2, 3, 4, 5, 7, 8]})
-        cov = coverage.Coverage()
-        cov.load()
-        report = self.get_report(cov)
-        assert self.last_line_squeezed(report) == "TOTAL 7 1 86%"
-
-    def test_report_with_chdir(self) -> None:
-        self.make_file("chdir.py", """\
-            import os
-            print("Line One")
-            os.chdir("subdir")
-            print("Line Two")
-            print(open("something").read())
-            """)
-        self.make_file("subdir/something", "hello")
-        out = self.run_command("coverage run --source=. chdir.py")
-        assert out == "Line One\nLine Two\nhello\n"
-        report = self.report_from_command("coverage report")
-        assert self.last_line_squeezed(report) == "TOTAL 5 0 100%"
-        report = self.report_from_command("coverage report --format=markdown")
-        assert self.last_line_squeezed(report) == "| **TOTAL** | **5** | **0** | **100%** |"
-
-    def test_bug_156_file_not_run_should_be_zero(self) -> None:
-        # https://github.com/nedbat/coveragepy/issues/156
-        self.make_file("mybranch.py", """\
-            def branch(x):
-                if x:
-                    print("x")
-                return x
-            branch(1)
-            """)
-        self.make_file("main.py", """\
-            print("y")
-            """)
-        cov = coverage.Coverage(branch=True, source=["."])
-        self.start_import_stop(cov, "main")
-        report = self.get_report(cov).splitlines()
-        assert "mybranch.py 5 5 2 0 0%" in report
-
-    def run_TheCode_and_report_it(self) -> str:
-        """A helper for the next few tests."""
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "TheCode")
-        return self.get_report(cov)
-
-    def test_bug_203_mixed_case_listed_twice_with_rc(self) -> None:
-        self.make_file("TheCode.py", "a = 1\n")
-        self.make_file(".coveragerc", "[run]\nsource = .\n")
-
-        report = self.run_TheCode_and_report_it()
-        assert "TheCode" in report
-        assert "thecode" not in report
-
-    def test_bug_203_mixed_case_listed_twice(self) -> None:
-        self.make_file("TheCode.py", "a = 1\n")
-
-        report = self.run_TheCode_and_report_it()
-
-        assert "TheCode" in report
-        assert "thecode" not in report
-
-    @pytest.mark.skipif(not env.WINDOWS, reason=".pyw files are only on Windows.")
-    def test_pyw_files(self) -> None:
-        # https://github.com/nedbat/coveragepy/issues/261
-        self.make_file("start.pyw", """\
-            import mod
-            print("In start.pyw")
-            """)
-        self.make_file("mod.pyw", """\
-            print("In mod.pyw")
-            """)
-        cov = coverage.Coverage()
-        # start_import_stop can't import the .pyw file, so use the long form.
-        cov.start()
-        import start    # pragma: nested # pylint: disable=import-error, unused-import
-        cov.stop()      # pragma: nested
-
-        report = self.get_report(cov)
-        assert "NoSource" not in report
-        report_lines = report.splitlines()
-        assert "start.pyw 2 0 100%" in report_lines
-        assert "mod.pyw 1 0 100%" in report_lines
-
-    def test_tracing_pyc_file(self) -> None:
-        # Create two Python files.
-        self.make_file("mod.py", "a = 1\n")
-        self.make_file("main.py", "import mod\n")
-
-        # Make one into a .pyc.
-        py_compile.compile("mod.py")
-
-        # Run the program.
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "main")
-
-        report_lines = self.get_report(cov).splitlines()
-        assert "mod.py 1 0 100%" in report_lines
-        report = self.get_report(cov, squeeze=False, output_format="markdown")
-        assert report.split("\n")[3] == "| mod.py    |        1 |        0 |     100% |"
-        assert report.split("\n")[4] == "| **TOTAL** |    **2** |    **0** | **100%** |"
-
-    def test_missing_py_file_during_run(self) -> None:
-        # Create two Python files.
-        self.make_file("mod.py", "a = 1\n")
-        self.make_file("main.py", "import mod\n")
-
-        # Make one into a .pyc, and remove the .py.
-        py_compile.compile("mod.py")
-        os.remove("mod.py")
-
-        # Python 3 puts the .pyc files in a __pycache__ directory, and will
-        # not import from there without source.  It will import a .pyc from
-        # the source location though.
-        pycs = glob.glob("__pycache__/mod.*.pyc")
-        assert len(pycs) == 1
-        os.rename(pycs[0], "mod.pyc")
-
-        # Run the program.
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "main")
-
-        # Put back the missing Python file.
-        self.make_file("mod.py", "a = 1\n")
-        report = self.get_report(cov).splitlines()
-        assert "mod.py 1 0 100%" in report
-
-    def test_empty_files(self) -> None:
-        # Shows that empty files like __init__.py are listed as having zero
-        # statements, not one statement.
-        cov = coverage.Coverage(branch=True)
-        cov.start()
-        import usepkgs  # pragma: nested # pylint: disable=import-error, unused-import
-        cov.stop()      # pragma: nested
-        report = self.get_report(cov)
-        assert "tests/modules/pkg1/__init__.py 1 0 0 0 100%" in report
-        assert "tests/modules/pkg2/__init__.py 0 0 0 0 100%" in report
-        report = self.get_report(cov, squeeze=False, output_format="markdown")
-        # get_report() escapes backslash so we expect forward slash escaped
-        # underscore
-        assert "tests/modules/pkg1//_/_init/_/_.py " in report
-        assert "|        1 |        0 |        0 |        0 |     100% |" in report
-        assert "tests/modules/pkg2//_/_init/_/_.py " in report
-        assert "|        0 |        0 |        0 |        0 |     100% |" in report
-
-    def test_markdown_with_missing(self) -> None:
-        self.make_file("mymissing.py", """\
-            def missing(x, y):
-                if x:
-                    print("x")
-                    return x
-                if y:
-                    print("y")
-                try:
-                    print("z")
-                    1/0
-                    print("Never!")
-                except ZeroDivisionError:
-                    pass
-                return x
-            missing(0, 1)
-            """)
-        cov = coverage.Coverage(source=["."])
-        self.start_import_stop(cov, "mymissing")
-        assert self.stdout() == 'y\nz\n'
-        report = self.get_report(cov, squeeze=False, output_format="markdown", show_missing=True)
-
-        # | Name         |    Stmts |     Miss |   Cover |   Missing |
-        # |------------- | -------: | -------: | ------: | --------: |
-        # | mymissing.py |       14 |        3 |     79% |   3-4, 10 |
-        # |    **TOTAL** |   **14** |    **3** | **79%** |           |
-        assert self.line_count(report) == 4
-        report_lines = report.split("\n")
-        assert report_lines[2] == "| mymissing.py |       14 |        3 |     79% |   3-4, 10 |"
-        assert report_lines[3] == "|    **TOTAL** |   **14** |    **3** | **79%** |           |"
-
-        assert self.get_report(cov, output_format="total") == "79\n"
-        assert self.get_report(cov, output_format="total", precision=2) == "78.57\n"
-        assert self.get_report(cov, output_format="total", precision=4) == "78.5714\n"
-
-    def test_bug_1524(self) -> None:
-        self.make_file("bug1524.py", """\
-            class Mine:
-                @property
-                def thing(self) -> int:
-                    return 17
-
-            print(Mine().thing)
-            """)
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "bug1524")
-        assert self.stdout() == "17\n"
-        report = self.get_report(cov)
-        report_lines = report.splitlines()
-        assert report_lines[2] == "bug1524.py 5 0 100%"
-
-
-class ReportingReturnValueTest(CoverageTest):
-    """Tests of reporting functions returning values."""
-
-    def run_coverage(self) -> Coverage:
-        """Run coverage on doit.py and return the coverage object."""
-        self.make_file("doit.py", """\
-            a = 1
-            b = 2
-            c = 3
-            d = 4
-            if a > 10:
-                f = 6
-            g = 7
-            """)
-
-        cov = coverage.Coverage()
-        self.start_import_stop(cov, "doit")
-        return cov
-
-    def test_report(self) -> None:
-        cov = self.run_coverage()
-        val = cov.report(include="*/doit.py")
-        assert math.isclose(val, 6 / 7 * 100)
-
-    def test_html(self) -> None:
-        cov = self.run_coverage()
-        val = cov.html_report(include="*/doit.py")
-        assert math.isclose(val, 6 / 7 * 100)
-
-    def test_xml(self) -> None:
-        cov = self.run_coverage()
-        val = cov.xml_report(include="*/doit.py")
-        assert math.isclose(val, 6 / 7 * 100)
-
-
-class SummaryReporterConfigurationTest(CoverageTest):
-    """Tests of SummaryReporter."""
-
-    def make_rigged_file(self, filename: str, stmts: int, miss: int) -> None:
-        """Create a file that will have specific results.
-
-        `stmts` and `miss` are ints, the number of statements, and
-        missed statements that should result.
-        """
-        run = stmts - miss - 1
-        dont_run = miss
-        source = ""
-        source += "a = 1\n" * run
-        source += "if a == 99:\n"
-        source += "    a = 2\n" * dont_run
-        self.make_file(filename, source)
-
-    def get_summary_text(self, *options: Tuple[str, TConfigValueIn]) -> str:
-        """Get text output from the SummaryReporter.
-
-        The arguments are tuples: (name, value) for Coverage.set_option.
-        """
-        self.make_rigged_file("file1.py", 339, 155)
-        self.make_rigged_file("file2.py", 13, 3)
-        self.make_rigged_file("file10.py", 234, 228)
-        self.make_file("doit.py", "import file1, file2, file10")
-
-        cov = Coverage(source=["."], omit=["doit.py"])
-        self.start_import_stop(cov, "doit")
-        for name, value in options:
-            cov.set_option(name, value)
-        printer = SummaryReporter(cov)
-        destination = io.StringIO()
-        printer.report([], destination)
-        return destination.getvalue()
-
-    def test_test_data(self) -> None:
-        # We use our own test files as test data. Check that our assumptions
-        # about them are still valid.  We want the three columns of numbers to
-        # sort in three different orders.
-        report = self.get_summary_text()
-        # Name       Stmts   Miss  Cover
-        # ------------------------------
-        # file1.py     339    155    54%
-        # file2.py      13      3    77%
-        # file10.py    234    228     3%
-        # ------------------------------
-        # TOTAL        586    386    34%
-        lines = report.splitlines()[2:-2]
-        assert len(lines) == 3
-        nums = [list(map(int, l.replace('%', '').split()[1:])) for l in lines]
-        # [
-        #  [339, 155, 54],
-        #  [ 13,   3, 77],
-        #  [234, 228,  3]
-        # ]
-        assert nums[1][0] < nums[2][0] < nums[0][0]
-        assert nums[1][1] < nums[0][1] < nums[2][1]
-        assert nums[2][2] < nums[0][2] < nums[1][2]
-
-    def test_defaults(self) -> None:
-        """Run the report with no configuration options."""
-        report = self.get_summary_text()
-        assert 'Missing' not in report
-        assert 'Branch' not in report
-
-    def test_print_missing(self) -> None:
-        """Run the report printing the missing lines."""
-        report = self.get_summary_text(('report:show_missing', True))
-        assert 'Missing' in report
-        assert 'Branch' not in report
-
-    def assert_ordering(self, text: str, *words: str) -> None:
-        """Assert that the `words` appear in order in `text`."""
-        indexes = list(map(text.find, words))
-        assert -1 not in indexes
-        msg = f"The words {words!r} don't appear in order in {text!r}"
-        assert indexes == sorted(indexes), msg
-
-    def test_default_sort_report(self) -> None:
-        # Sort the text report by the default (Name) column.
-        report = self.get_summary_text()
-        self.assert_ordering(report, "file1.py", "file2.py", "file10.py")
-
-    def test_sort_report_by_name(self) -> None:
-        # Sort the text report explicitly by the Name column.
-        report = self.get_summary_text(('report:sort', 'Name'))
-        self.assert_ordering(report, "file1.py", "file2.py", "file10.py")
-
-    def test_sort_report_by_stmts(self) -> None:
-        # Sort the text report by the Stmts column.
-        report = self.get_summary_text(('report:sort', 'Stmts'))
-        self.assert_ordering(report, "file2.py", "file10.py", "file1.py")
-
-    def test_sort_report_by_missing(self) -> None:
-        # Sort the text report by the Missing column.
-        report = self.get_summary_text(('report:sort', 'Miss'))
-        self.assert_ordering(report, "file2.py", "file1.py", "file10.py")
-
-    def test_sort_report_by_cover(self) -> None:
-        # Sort the text report by the Cover column.
-        report = self.get_summary_text(('report:sort', 'Cover'))
-        self.assert_ordering(report, "file10.py", "file1.py", "file2.py")
-
-    def test_sort_report_by_cover_plus(self) -> None:
-        # Sort the text report by the Cover column, including the explicit + sign.
-        report = self.get_summary_text(('report:sort', '+Cover'))
-        self.assert_ordering(report, "file10.py", "file1.py", "file2.py")
-
-    def test_sort_report_by_cover_reversed(self) -> None:
-        # Sort the text report by the Cover column reversed.
-        report = self.get_summary_text(('report:sort', '-Cover'))
-        self.assert_ordering(report, "file2.py", "file1.py", "file10.py")
-
-    def test_sort_report_by_invalid_option(self) -> None:
-        # Sort the text report by a nonsense column.
-        msg = "Invalid sorting option: 'Xyzzy'"
-        with pytest.raises(ConfigError, match=msg):
-            self.get_summary_text(('report:sort', 'Xyzzy'))
-
-    def test_report_with_invalid_format(self) -> None:
-        # Ask for an invalid format.
-        msg = "Unknown report format choice: 'xyzzy'"
-        with pytest.raises(ConfigError, match=msg):
-            self.get_summary_text(('report:format', 'xyzzy'))
diff --git a/tests/test_venv.py b/tests/test_venv.py
index ae5b303f7..a23561921 100644
--- a/tests/test_venv.py
+++ b/tests/test_venv.py
@@ -116,8 +116,12 @@ def sixth(x):
                 __path__ = extend_path(__path__, __name__)
             """)
         make_file("bug888/app/testcov/main.py", """\
-            import pkg_resources
-            for entry_point in pkg_resources.iter_entry_points('plugins'):
+            try:  # pragma: no cover
+                entry_points = __import__("pkg_resources").iter_entry_points('plugins')
+            except ImportError:  # pragma: no cover
+                import importlib.metadata
+                entry_points = importlib.metadata.entry_points(group="plugins")
+            for entry_point in entry_points:
                 entry_point.load()()
             """)
         make_file("bug888/plugin/setup.py", """\
diff --git a/tests/test_xml.py b/tests/test_xml.py
index 94b310e3e..731053207 100644
--- a/tests/test_xml.py
+++ b/tests/test_xml.py
@@ -15,7 +15,7 @@
 import pytest
 
 import coverage
-from coverage import Coverage
+from coverage import Coverage, env
 from coverage.exceptions import NoDataError
 from coverage.files import abs_file
 from coverage.misc import import_local_file
@@ -320,7 +320,7 @@ def test_accented_directory(self) -> None:
 
     def test_no_duplicate_packages(self) -> None:
         self.make_file(
-            "namespace/package/__init__.py", 
+            "namespace/package/__init__.py",
             "from . import sample; from . import test; from .subpackage import test"
         )
         self.make_file("namespace/package/sample.py", "print('package.sample')")
@@ -476,15 +476,16 @@ def test_source_prefix(self) -> None:
         dom = ElementTree.parse("coverage.xml")
         self.assert_source(dom, "src")
 
-    def test_relative_source(self) -> None:
+    @pytest.mark.parametrize("trail", ["", "/", "\\"])
+    def test_relative_source(self, trail: str) -> None:
+        if trail == "\\" and not env.WINDOWS:
+            pytest.skip("trailing backslash is only for Windows")
         self.make_file("src/mod.py", "print(17)")
-        cov = coverage.Coverage(source=["src"])
+        cov = coverage.Coverage(source=[f"src{trail}"])
         cov.set_option("run:relative_files", True)
         self.start_import_stop(cov, "mod", modfile="src/mod.py")
         cov.xml_report()
 
-        with open("coverage.xml") as x:
-            print(x.read())
         dom = ElementTree.parse("coverage.xml")
         elts = dom.findall(".//sources/source")
         assert [elt.text for elt in elts] == ["src"]
diff --git a/tox.ini b/tox.ini
index 0a1fa6f60..80b31897e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,6 +10,7 @@ toxworkdir = {env:TOXWORKDIR:.tox}
 
 [testenv]
 usedevelop = True
+download = True
 extras =
     toml
 
@@ -29,6 +30,7 @@ setenv =
     # For some tests, we need .pyc files written in the current directory,
     # so override any local setting.
     PYTHONPYCACHEPREFIX=
+    PYTHONWARNINGS=ignore:removed in Python 3.14; use ast.Constant:DeprecationWarning
 
 # $set_env.py: COVERAGE_PIP_ARGS - Extra arguments for `pip install`
 # `--no-build-isolation` will let tox work with no network.
@@ -70,6 +72,8 @@ commands =
     - sphinx-build -b html -b linkcheck -aEnQW doc doc/_build/html
 
 [testenv:lint]
+# Minimum of PYVERSIONS
+basepython = python3.7
 deps =
     -r requirements/lint.pip