summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNed Batchelder <ned@nedbatchelder.com>2023-01-04 07:03:10 -0500
committerNed Batchelder <ned@nedbatchelder.com>2023-01-04 07:03:10 -0500
commitdbb94d570a2042409400c28ba3069dcb32a45159 (patch)
tree41caedd28b1d5cb99a76d8104566d309724eeeb7
parentfaa62d3f3d90c15e72040479a9b237ca86bbeae1 (diff)
downloadpython-coveragepy-git-dbb94d570a2042409400c28ba3069dcb32a45159.tar.gz
mypy: summary.py, test_summary.py, tests/coveragetest.py
-rw-r--r--coverage/config.py2
-rw-r--r--coverage/control.py28
-rw-r--r--coverage/debug.py5
-rw-r--r--coverage/misc.py8
-rw-r--r--coverage/parser.py2
-rw-r--r--coverage/plugin.py2
-rw-r--r--coverage/python.py2
-rw-r--r--coverage/summary.py51
-rw-r--r--coverage/types.py7
-rw-r--r--tests/coveragetest.py122
-rw-r--r--tests/helpers.py6
-rw-r--r--tests/test_summary.py129
-rw-r--r--tox.ini9
13 files changed, 231 insertions, 142 deletions
diff --git a/coverage/config.py b/coverage/config.py
index 3e535949..04bde26f 100644
--- a/coverage/config.py
+++ b/coverage/config.py
@@ -518,7 +518,7 @@ class CoverageConfig(TConfigurable):
for k, v in self.paths.items()
)
- def debug_info(self) -> Iterable[Tuple[str, Any]]:
+ def debug_info(self) -> List[Tuple[str, Any]]:
"""Make a list of (name, value) pairs for writing debug info."""
return human_sorted_items(
(k, v) for k, v in self.__dict__.items() if not k.startswith("_")
diff --git a/coverage/control.py b/coverage/control.py
index 8ac6781e..acd89b94 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -122,8 +122,8 @@ class Coverage(TConfigurable):
config_file: Union[str, bool]=True,
source: Optional[List[str]]=None,
source_pkgs: Optional[List[str]]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
debug: Optional[List[str]]=None,
concurrency: Optional[Union[str, List[str]]]=None,
check_preimported: bool=False,
@@ -959,8 +959,8 @@ class Coverage(TConfigurable):
show_missing: Optional[bool]=None,
ignore_errors: Optional[bool]=None,
file: Optional[IO[str]]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
skip_covered: Optional[bool]=None,
contexts: Optional[List[str]]=None,
skip_empty: Optional[bool]=None,
@@ -1040,8 +1040,8 @@ class Coverage(TConfigurable):
morfs: Optional[Iterable[TMorf]]=None,
directory: Optional[str]=None,
ignore_errors: Optional[bool]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
contexts: Optional[List[str]]=None,
) -> None:
"""Annotate a list of modules.
@@ -1079,8 +1079,8 @@ class Coverage(TConfigurable):
morfs: Optional[Iterable[TMorf]]=None,
directory: Optional[str]=None,
ignore_errors: Optional[bool]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
extra_css: Optional[str]=None,
title: Optional[str]=None,
skip_covered: Optional[bool]=None,
@@ -1137,8 +1137,8 @@ class Coverage(TConfigurable):
morfs: Optional[Iterable[TMorf]]=None,
outfile: Optional[str]=None,
ignore_errors: Optional[bool]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
contexts: Optional[List[str]]=None,
skip_empty: Optional[bool]=None,
) -> float:
@@ -1171,8 +1171,8 @@ class Coverage(TConfigurable):
morfs: Optional[Iterable[TMorf]]=None,
outfile: Optional[str]=None,
ignore_errors: Optional[bool]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
contexts: Optional[List[str]]=None,
pretty_print: Optional[bool]=None,
show_contexts: Optional[bool]=None,
@@ -1209,8 +1209,8 @@ class Coverage(TConfigurable):
morfs: Optional[Iterable[TMorf]]=None,
outfile: Optional[str]=None,
ignore_errors: Optional[bool]=None,
- omit: Optional[List[str]]=None,
- include: Optional[List[str]]=None,
+ omit: Optional[Union[str, List[str]]]=None,
+ include: Optional[Union[str, List[str]]]=None,
contexts: Optional[List[str]]=None,
) -> float:
"""Generate an LCOV report of coverage results.
diff --git a/coverage/debug.py b/coverage/debug.py
index 82de3c29..a3d1dfa8 100644
--- a/coverage/debug.py
+++ b/coverage/debug.py
@@ -23,6 +23,7 @@ from typing import (
)
from coverage.misc import isolate_module
+from coverage.types import TWritable
os = isolate_module(os)
@@ -184,7 +185,7 @@ def short_stack(limit: Optional[int]=None, skip: int=0) -> str:
def dump_stack_frames(
limit: Optional[int]=None,
- out: Optional[IO[str]]=None,
+ out: Optional[TWritable]=None,
skip: int=0
) -> None:
"""Print a summary of the stack to stdout, or someplace else."""
@@ -371,7 +372,7 @@ class DebugOutputFile: # pragma: debugging
self.outfile.flush()
-def log(msg, stack=False): # pragma: debugging
+def log(msg: str, stack: bool=False) -> None: # pragma: debugging
"""Write a log message as forcefully as possible."""
out = DebugOutputFile.get_one(interim=True)
out.write(msg+"\n")
diff --git a/coverage/misc.py b/coverage/misc.py
index a2ac2fed..9b42d841 100644
--- a/coverage/misc.py
+++ b/coverage/misc.py
@@ -22,7 +22,7 @@ import types
from types import ModuleType
from typing import (
Any, Callable, Dict, Generator, IO, Iterable, List, Mapping, Optional,
- Tuple, TypeVar, Union,
+ Sequence, Tuple, TypeVar, Union,
)
from coverage import env
@@ -361,10 +361,12 @@ def human_sorted(strings: Iterable[str]) -> List[str]:
"""
return sorted(strings, key=_human_key)
+SortableItem = TypeVar("SortableItem", bound=Sequence[Any])
+
def human_sorted_items(
- items: Iterable[Tuple[str, Any]],
+ items: Iterable[SortableItem],
reverse: bool=False,
-) -> List[Tuple[str, Any]]:
+) -> List[SortableItem]:
"""Sort (string, ...) items the way humans expect.
The elements of `items` can be any tuple/list. They'll be sorted by the
diff --git a/coverage/parser.py b/coverage/parser.py
index 2a8d0a50..cb4e6474 100644
--- a/coverage/parser.py
+++ b/coverage/parser.py
@@ -328,7 +328,7 @@ class PythonParser:
self,
start: TLineNo,
end: TLineNo,
- executed_arcs: Optional[Set[TArc]]=None,
+ executed_arcs: Optional[Iterable[TArc]]=None,
) -> str:
"""Provide an English sentence describing a missing arc."""
if self._missing_arc_fragments is None:
diff --git a/coverage/plugin.py b/coverage/plugin.py
index ccc33337..da91aac4 100644
--- a/coverage/plugin.py
+++ b/coverage/plugin.py
@@ -496,7 +496,7 @@ class FileReporter(CoveragePluginBase):
self,
start: TLineNo,
end: TLineNo,
- executed_arcs: Optional[Set[TArc]]=None, # pylint: disable=unused-argument
+ executed_arcs: Optional[Iterable[TArc]]=None, # pylint: disable=unused-argument
) -> str:
"""Provide an English sentence describing a missing arc.
diff --git a/coverage/python.py b/coverage/python.py
index 2d2faa14..c25a03fd 100644
--- a/coverage/python.py
+++ b/coverage/python.py
@@ -224,7 +224,7 @@ class PythonFileReporter(FileReporter):
self,
start: TLineNo,
end: TLineNo,
- executed_arcs: Optional[Set[TArc]]=None,
+ executed_arcs: Optional[Iterable[TArc]]=None,
) -> str:
return self.parser.missing_arc_description(start, end, executed_arcs)
diff --git a/coverage/summary.py b/coverage/summary.py
index 3f3fd688..287e7593 100644
--- a/coverage/summary.py
+++ b/coverage/summary.py
@@ -3,40 +3,56 @@
"""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 Numbers
+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):
+ def __init__(self, coverage: Coverage) -> None:
self.coverage = coverage
self.config = self.coverage.config
self.branches = coverage.get_data().has_arcs()
- self.outfile = None
+ 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 = []
+ 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):
+ 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):
+ def write_items(self, items: Iterable[str]) -> None:
"""Write a list of strings, joined together."""
self.write("".join(items))
- def _report_text(self, header, lines_values, total_line, end_lines):
+ 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.
@@ -91,7 +107,13 @@ class SummaryReporter:
for end_line in end_lines:
self.write(end_line)
- def _report_markdown(self, header, lines_values, total_line, end_lines):
+ 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.
@@ -134,7 +156,7 @@ class SummaryReporter:
# Write the TOTAL line
formats.update(dict(Name="|{:>{name_len}} |", Cover="{:>{n}} |"))
- total_line_items = []
+ total_line_items: List[str] = []
for item, value in zip(header, total_line):
if value == "":
insert = value
@@ -147,7 +169,7 @@ class SummaryReporter:
for end_line in end_lines:
self.write(end_line)
- def report(self, morfs, outfile=None) -> float:
+ 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.
@@ -169,7 +191,7 @@ class SummaryReporter:
return self.total.pc_covered
- def tabular_report(self):
+ def tabular_report(self) -> None:
"""Writes tabular report formats."""
# Prepare the header line and column sorting.
header = ["Name", "Stmts", "Miss"]
@@ -212,7 +234,10 @@ class SummaryReporter:
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]), reverse=reverse)
+ 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]
@@ -239,7 +264,7 @@ class SummaryReporter:
formatter = self._report_text
formatter(header, lines_values, total_line, end_lines)
- def report_one_file(self, fr, analysis):
+ 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
diff --git a/coverage/types.py b/coverage/types.py
index 54c1dfba..ed22e699 100644
--- a/coverage/types.py
+++ b/coverage/types.py
@@ -166,3 +166,10 @@ class TDebugCtl(Protocol):
def write(self, msg: str) -> None:
"""Write a line of debug output."""
+
+
+class TWritable(Protocol):
+ """Anything that can be written to."""
+
+ def write(self, msg: str) -> None:
+ """Write a message."""
diff --git a/tests/coveragetest.py b/tests/coveragetest.py
index 47a124c1..c55fffd5 100644
--- a/tests/coveragetest.py
+++ b/tests/coveragetest.py
@@ -15,9 +15,18 @@ import re
import shlex
import sys
+from types import ModuleType
+from typing import (
+ Any, Collection, Dict, Generator, Iterable, List, Mapping, Optional,
+ Tuple, Union,
+)
+
import coverage
+from coverage import Coverage
from coverage.cmdline import CoverageScript
+from coverage.data import CoverageData
from coverage.misc import import_local_file
+from coverage.types import TArc, TLineNo
from tests.helpers import arcs_to_arcz_repr, arcz_to_arcs, assert_count_equal
from tests.helpers import nice_file, run_command
@@ -53,15 +62,20 @@ class CoverageTest(
# Let stderr go to stderr, pytest will capture it for us.
show_stderr = True
- def setUp(self):
+ def setUp(self) -> None:
super().setUp()
# Attributes for getting info about what happened.
- self.last_command_status = None
- self.last_command_output = None
- self.last_module_name = None
-
- def start_import_stop(self, cov, modname, modfile=None):
+ self.last_command_status: Optional[int] = None
+ self.last_command_output: Optional[str] = None
+ self.last_module_name: Optional[str] = None
+
+ def start_import_stop(
+ self,
+ cov: Coverage,
+ modname: str,
+ modfile: Optional[str]=None
+ ) -> ModuleType:
"""Start coverage, import a file, then stop coverage.
`cov` is started and stopped, with an `import_local_file` of
@@ -80,7 +94,7 @@ class CoverageTest(
cov.stop()
return mod
- def get_report(self, cov, squeeze=True, **kwargs):
+ def get_report(self, cov: Coverage, squeeze: bool=True, **kwargs: Any) -> str:
"""Get the report from `cov`, and canonicalize it."""
repout = io.StringIO()
kwargs.setdefault("show_missing", False)
@@ -91,12 +105,17 @@ class CoverageTest(
report = re.sub(r" +", " ", report)
return report
- def get_module_name(self):
+ def get_module_name(self) -> str:
"""Return a random module name to use for this test run."""
self.last_module_name = 'coverage_test_' + str(random.random())[2:]
return self.last_module_name
- def _check_arcs(self, a1, a2, arc_type):
+ def _check_arcs(
+ self,
+ a1: Optional[Iterable[TArc]],
+ a2: Optional[Iterable[TArc]],
+ arc_type: str,
+ ) -> str:
"""Check that the arc lists `a1` and `a2` are equal.
If they are equal, return empty string. If they are unequal, return
@@ -114,11 +133,20 @@ class CoverageTest(
return ""
def check_coverage(
- self, text, lines=None, missing="", report="",
- excludes=None, partials="",
- arcz=None, arcz_missing=None, arcz_unpredicted=None,
- arcs=None, arcs_missing=None, arcs_unpredicted=None,
- ):
+ self,
+ text: str,
+ lines: Optional[Union[List[TLineNo], List[List[TLineNo]]]]=None,
+ missing: Union[str, List[str]]="",
+ report: str="",
+ excludes: Optional[Iterable[str]]=None,
+ partials: Iterable[str]=(),
+ arcz: Optional[str]=None,
+ arcz_missing: Optional[str]=None,
+ arcz_unpredicted: Optional[str]=None,
+ arcs: Optional[Iterable[TArc]]=None,
+ arcs_missing: Optional[Iterable[TArc]]=None,
+ arcs_unpredicted: Optional[Iterable[TArc]]=None,
+ ) -> Coverage:
"""Check the coverage measurement of `text`.
The source `text` is run and measured. `lines` are the line numbers
@@ -216,7 +244,14 @@ class CoverageTest(
return cov
- def make_data_file(self, basename=None, suffix=None, lines=None, arcs=None, file_tracers=None):
+ def make_data_file(
+ self,
+ basename: Optional[str]=None,
+ suffix: Optional[str]=None,
+ lines: Optional[Mapping[str, Collection[TLineNo]]]=None,
+ arcs: Optional[Mapping[str, Collection[TArc]]]=None,
+ file_tracers: Optional[Mapping[str, str]]=None,
+ ) -> CoverageData:
"""Write some data into a coverage data file."""
data = coverage.CoverageData(basename=basename, suffix=suffix)
assert lines is None or arcs is None
@@ -230,7 +265,12 @@ class CoverageTest(
return data
@contextlib.contextmanager
- def assert_warnings(self, cov, warnings, not_warnings=()):
+ def assert_warnings(
+ self,
+ cov: Coverage,
+ warnings: Iterable[str],
+ not_warnings: Iterable[str]=(),
+ ) -> Generator[None, None, None]:
"""A context manager to check that particular warnings happened in `cov`.
`cov` is a Coverage instance. `warnings` is a list of regexes. Every
@@ -248,7 +288,11 @@ class CoverageTest(
"""
__tracebackhide__ = True
saved_warnings = []
- def capture_warning(msg, slug=None, once=False): # pylint: disable=unused-argument
+ def capture_warning(
+ msg: str,
+ slug: Optional[str]=None,
+ once: bool=False, # pylint: disable=unused-argument
+ ) -> None:
"""A fake implementation of Coverage._warn, to capture warnings."""
# NOTE: we don't implement `once`.
if slug:
@@ -256,7 +300,7 @@ class CoverageTest(
saved_warnings.append(msg)
original_warn = cov._warn
- cov._warn = capture_warning
+ cov._warn = capture_warning # type: ignore[assignment]
try:
yield
@@ -281,30 +325,35 @@ class CoverageTest(
if saved_warnings:
assert False, f"Unexpected warnings: {saved_warnings!r}"
finally:
- cov._warn = original_warn
+ cov._warn = original_warn # type: ignore[assignment]
- def assert_same_files(self, flist1, flist2):
+ def assert_same_files(self, flist1: Iterable[str], flist2: Iterable[str]) -> None:
"""Assert that `flist1` and `flist2` are the same set of file names."""
flist1_nice = [nice_file(f) for f in flist1]
flist2_nice = [nice_file(f) for f in flist2]
assert_count_equal(flist1_nice, flist2_nice)
- def assert_exists(self, fname):
+ def assert_exists(self, fname: str) -> None:
"""Assert that `fname` is a file that exists."""
assert os.path.exists(fname), f"File {fname!r} should exist"
- def assert_doesnt_exist(self, fname):
+ def assert_doesnt_exist(self, fname: str) -> None:
"""Assert that `fname` is a file that doesn't exist."""
assert not os.path.exists(fname), f"File {fname!r} shouldn't exist"
- def assert_file_count(self, pattern, count):
+ def assert_file_count(self, pattern: str, count: int) -> None:
"""Assert that there are `count` files matching `pattern`."""
files = sorted(glob.glob(pattern))
msg = "There should be {} files matching {!r}, but there are these: {}"
msg = msg.format(count, pattern, files)
assert len(files) == count, msg
- def assert_recent_datetime(self, dt, seconds=10, msg=None):
+ def assert_recent_datetime(
+ self,
+ dt: datetime.datetime,
+ seconds: int=10,
+ msg: Optional[str]=None,
+ ) -> None:
"""Assert that `dt` marks a time at most `seconds` seconds ago."""
age = datetime.datetime.now() - dt
assert age.total_seconds() >= 0, msg
@@ -331,7 +380,7 @@ class CoverageTest(
# https://salsa.debian.org/debian/pkg-python-coverage/-/blob/master/debian/patches/02.rename-public-programs.patch
coverage_command = "coverage"
- def run_command(self, cmd):
+ def run_command(self, cmd: str) -> str:
"""Run the command-line `cmd` in a sub-process.
`cmd` is the command line to invoke in a sub-process. Returns the
@@ -348,7 +397,7 @@ class CoverageTest(
_, output = self.run_command_status(cmd)
return output
- def run_command_status(self, cmd):
+ def run_command_status(self, cmd: str) -> Tuple[int, str]:
"""Run the command-line `cmd` in a sub-process, and print its output.
Use this when you need to test the process behavior of coverage.
@@ -408,36 +457,36 @@ class CoverageTest(
print(self.last_command_output)
return self.last_command_status, self.last_command_output
- def working_root(self):
+ def working_root(self) -> str:
"""Where is the root of the coverage.py working tree?"""
return os.path.dirname(nice_file(__file__, ".."))
- def report_from_command(self, cmd):
+ def report_from_command(self, cmd: str) -> str:
"""Return the report from the `cmd`, with some convenience added."""
report = self.run_command(cmd).replace('\\', '/')
assert "error" not in report.lower()
return report
- def report_lines(self, report):
+ def report_lines(self, report: str) -> List[str]:
"""Return the lines of the report, as a list."""
lines = report.split('\n')
assert lines[-1] == ""
return lines[:-1]
- def line_count(self, report):
+ def line_count(self, report: str) -> int:
"""How many lines are in `report`?"""
return len(self.report_lines(report))
- def squeezed_lines(self, report):
+ def squeezed_lines(self, report: str) -> List[str]:
"""Return a list of the lines in report, with the spaces squeezed."""
lines = self.report_lines(report)
return [re.sub(r"\s+", " ", l.strip()) for l in lines]
- def last_line_squeezed(self, report):
+ def last_line_squeezed(self, report: str) -> str:
"""Return the last line of `report` with the spaces squeezed down."""
return self.squeezed_lines(report)[-1]
- def get_measured_filenames(self, coverage_data):
+ def get_measured_filenames(self, coverage_data: CoverageData) -> Dict[str, str]:
"""Get paths to measured files.
Returns a dict of {filename: absolute path to file}
@@ -446,9 +495,10 @@ class CoverageTest(
return {os.path.basename(filename): filename
for filename in coverage_data.measured_files()}
- def get_missing_arc_description(self, cov, start, end):
+ def get_missing_arc_description(self, cov: Coverage, start: TLineNo, end: TLineNo) -> str:
"""Get the missing-arc description for a line arc in a coverage run."""
# ugh, unexposed methods??
+ assert self.last_module_name is not None
filename = self.last_module_name + ".py"
fr = cov._get_file_reporter(filename)
arcs_executed = cov._analyze(filename).arcs_executed()
@@ -458,8 +508,8 @@ class CoverageTest(
class UsingModulesMixin:
"""A mixin for importing modules from tests/modules and tests/moremodules."""
- def setUp(self):
- super().setUp()
+ def setUp(self) -> None:
+ super().setUp() # type: ignore[misc]
# Parent class saves and restores sys.path, we can just modify it.
sys.path.append(nice_file(TESTS_DIR, "modules"))
diff --git a/tests/helpers.py b/tests/helpers.py
index 0503097e..bffd800c 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -25,7 +25,7 @@ import pytest
from coverage import env
from coverage.exceptions import CoverageWarning
from coverage.misc import output_encoding
-from coverage.types import TLineNo
+from coverage.types import TArc, TLineNo
def run_command(cmd: str) -> Tuple[int, str]:
@@ -198,7 +198,7 @@ _arcz_map.update({c: ord(c) - ord('0') for c in '123456789'})
_arcz_map.update({c: 10 + ord(c) - ord('A') for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'})
-def arcz_to_arcs(arcz: str) -> List[Tuple[TLineNo, TLineNo]]:
+def arcz_to_arcs(arcz: str) -> List[TArc]:
"""Convert a compact textual representation of arcs to a list of pairs.
The text has space-separated pairs of letters. Period is -1, 1-9 are
@@ -249,7 +249,7 @@ def _arcs_to_arcz_repr_one(num: TLineNo) -> str:
return z
-def arcs_to_arcz_repr(arcs: Iterable[Tuple[TLineNo, TLineNo]]) -> str:
+def arcs_to_arcz_repr(arcs: Optional[Iterable[TArc]]) -> str:
"""Convert a list of arcs to a readable multi-line form for asserting.
Each pair is on its own line, with a comment showing the arcz form,
diff --git a/tests/test_summary.py b/tests/test_summary.py
index 45427079..f3c7ed3f 100644
--- a/tests/test_summary.py
+++ b/tests/test_summary.py
@@ -11,6 +11,8 @@ import os.path
import py_compile
import re
+from typing import Tuple
+
import pytest
import coverage
@@ -20,6 +22,7 @@ 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 TConfigValue
from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin
from tests.helpers import assert_coverage_warnings
@@ -28,7 +31,7 @@ from tests.helpers import assert_coverage_warnings
class SummaryTest(UsingModulesMixin, CoverageTest):
"""Tests of the text summary reporting for coverage.py."""
- def make_mycode(self):
+ def make_mycode(self) -> None:
"""Make the mycode.py file when needed."""
self.make_file("mycode.py", """\
import covmod1
@@ -37,7 +40,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
print('done')
""")
- def test_report(self):
+ def test_report(self) -> None:
self.make_mycode()
cov = coverage.Coverage()
self.start_import_stop(cov, "mycode")
@@ -58,7 +61,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "mycode.py " in report
assert self.last_line_squeezed(report) == "TOTAL 8 0 100%"
- def test_report_just_one(self):
+ def test_report_just_one(self) -> None:
# Try reporting just one module
self.make_mycode()
cov = coverage.Coverage()
@@ -77,7 +80,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "mycode.py " in report
assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
- def test_report_wildcard(self):
+ 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
@@ -97,7 +100,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "mycode.py " in report
assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
- def test_report_omitting(self):
+ def test_report_omitting(self) -> None:
# Try reporting while omitting some modules
self.make_mycode()
cov = coverage.Coverage()
@@ -117,7 +120,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "mycode.py " in report
assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
- def test_report_including(self):
+ def test_report_including(self) -> None:
# Try reporting while including some modules
self.make_mycode()
cov = coverage.Coverage()
@@ -137,7 +140,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "mycode.py " in report
assert self.last_line_squeezed(report) == "TOTAL 4 0 100%"
- def test_omit_files_here(self):
+ 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", "")
@@ -161,7 +164,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
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):
+ def test_run_source_vs_report_include(self) -> None:
# https://github.com/nedbat/coveragepy/issues/621
self.make_file(".coveragerc", """\
[run]
@@ -176,7 +179,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
cov.start()
cov.stop() # pragma: nested
- def test_run_omit_vs_report_omit(self):
+ 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()
@@ -196,7 +199,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "covmod1.py" in files
assert "covmodzip1.py" not in files
- def test_report_branches(self):
+ def test_report_branches(self) -> None:
self.make_file("mybranch.py", """\
def branch(x):
if x:
@@ -218,7 +221,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "mybranch.py " in report
assert self.last_line_squeezed(report) == "TOTAL 5 0 2 1 86%"
- def test_report_show_missing(self):
+ def test_report_show_missing(self) -> None:
self.make_file("mymissing.py", """\
def missing(x, y):
if x:
@@ -251,7 +254,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
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):
+ def test_report_show_missing_branches(self) -> None:
self.make_file("mybranch.py", """\
def branch(x, y):
if x:
@@ -264,7 +267,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
self.start_import_stop(cov, "mybranch")
assert self.stdout() == 'x\ny\n'
- def test_report_show_missing_branches_and_lines(self):
+ def test_report_show_missing_branches_and_lines(self) -> None:
self.make_file("main.py", """\
import mybranch
""")
@@ -284,7 +287,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
self.start_import_stop(cov, "main")
assert self.stdout() == 'x\ny\n'
- def test_report_skip_covered_no_branches(self):
+ def test_report_skip_covered_no_branches(self) -> None:
self.make_file("main.py", """
import not_covered
@@ -316,7 +319,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert squeezed[6] == "1 file skipped due to complete coverage."
assert self.last_command_status == 0
- def test_report_skip_covered_branches(self):
+ def test_report_skip_covered_branches(self) -> None:
self.make_file("main.py", """
import not_covered, covered
@@ -356,7 +359,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
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):
+ def test_report_skip_covered_branches_with_totals(self) -> None:
self.make_file("main.py", """
import not_covered
import also_not_run
@@ -398,7 +401,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
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):
+ def test_report_skip_covered_all_files_covered(self) -> None:
self.make_file("main.py", """
def foo():
pass
@@ -443,7 +446,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
total = self.get_report(cov, output_format="total", skip_covered=True)
assert total == "100\n"
- def test_report_skip_covered_longfilename(self):
+ def test_report_skip_covered_longfilename(self) -> None:
self.make_file("long_______________filename.py", """
def foo():
pass
@@ -466,14 +469,14 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
squeezed = self.squeezed_lines(report)
assert squeezed[4] == "1 file skipped due to complete coverage."
- def test_report_skip_covered_no_data(self):
+ 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):
+ def test_report_skip_empty(self) -> None:
self.make_file("main.py", """
import submodule
@@ -501,7 +504,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert squeezed[4] == "TOTAL 4 0 100%"
assert squeezed[6] == "1 empty file skipped."
- def test_report_skip_empty_no_data(self):
+ def test_report_skip_empty_no_data(self) -> None:
self.make_file("__init__.py", "")
cov = coverage.Coverage()
self.start_import_stop(cov, "__init__")
@@ -518,7 +521,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert report.split("\n")[2] == "TOTAL 0 0 100%"
assert report.split("\n")[4] == "1 empty file skipped."
- def test_report_precision(self):
+ def test_report_precision(self) -> None:
self.make_file(".coveragerc", """\
[report]
precision = 3
@@ -563,7 +566,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
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):
+ def test_report_precision_all_zero(self) -> None:
self.make_file("not_covered.py", """
def not_covered(n):
if n:
@@ -586,7 +589,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "not_covered.py 3 3 0.000000%" in report
assert "TOTAL 3 3 0.000000%" in report
- def test_dotpy_not_python(self):
+ 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.
@@ -598,7 +601,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
with pytest.raises(NotPython, match=msg):
self.get_report(cov, morfs=["mycode.py"])
- def test_accented_directory(self):
+ 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]})
@@ -614,7 +617,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
output = self.get_report(cov, squeeze=False)
assert output == report_expected
- def test_accenteddotpy_not_python(self):
+ 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.
@@ -626,7 +629,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
with pytest.raises(NotPython, match=msg):
self.get_report(cov, morfs=["accented\xe2.py"])
- def test_dotpy_not_python_ignored(self):
+ 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.
@@ -642,7 +645,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
re.compile(r"Couldn't parse Python file '.*[/\\]mycode.py' \(couldnt-parse\)"),
)
- def test_dothtml_not_python(self):
+ 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.
@@ -654,7 +657,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
with pytest.raises(NoDataError, match="No data to report."):
self.get_report(cov, morfs=["mycode.html"])
- def test_report_no_extension(self):
+ 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
@@ -671,7 +674,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
report = self.get_report(cov)
assert self.last_line_squeezed(report) == "TOTAL 7 1 86%"
- def test_report_with_chdir(self):
+ def test_report_with_chdir(self) -> None:
self.make_file("chdir.py", """\
import os
print("Line One")
@@ -687,7 +690,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
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):
+ 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):
@@ -704,13 +707,13 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
report = self.get_report(cov).splitlines()
assert "mybranch.py 5 5 2 0 0%" in report
- def run_TheCode_and_report_it(self):
+ 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):
+ 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")
@@ -718,7 +721,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "TheCode" in report
assert "thecode" not in report
- def test_bug_203_mixed_case_listed_twice(self):
+ 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()
@@ -727,7 +730,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "thecode" not in report
@pytest.mark.skipif(not env.WINDOWS, reason=".pyw files are only on Windows.")
- def test_pyw_files(self):
+ def test_pyw_files(self) -> None:
# https://github.com/nedbat/coveragepy/issues/261
self.make_file("start.pyw", """\
import mod
@@ -744,11 +747,11 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
report = self.get_report(cov)
assert "NoSource" not in report
- report = report.splitlines()
- assert "start.pyw 2 0 100%" in report
- assert "mod.pyw 1 0 100%" 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):
+ 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")
@@ -760,13 +763,13 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
cov = coverage.Coverage()
self.start_import_stop(cov, "main")
- report = self.get_report(cov).splitlines()
- assert "mod.py 1 0 100%" in report
+ 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):
+ 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")
@@ -791,7 +794,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
report = self.get_report(cov).splitlines()
assert "mod.py 1 0 100%" in report
- def test_empty_files(self):
+ 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)
@@ -809,7 +812,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
assert "tests/modules/pkg2//_/_init/_/_.py " in report
assert "| 0 | 0 | 0 | 0 | 100% |" in report
- def test_markdown_with_missing(self):
+ def test_markdown_with_missing(self) -> None:
self.make_file("mymissing.py", """\
def missing(x, y):
if x:
@@ -848,7 +851,7 @@ class SummaryTest(UsingModulesMixin, CoverageTest):
class ReportingReturnValueTest(CoverageTest):
"""Tests of reporting functions returning values."""
- def run_coverage(self):
+ def run_coverage(self) -> Coverage:
"""Run coverage on doit.py and return the coverage object."""
self.make_file("doit.py", """\
a = 1
@@ -864,17 +867,17 @@ class ReportingReturnValueTest(CoverageTest):
self.start_import_stop(cov, "doit")
return cov
- def test_report(self):
+ 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):
+ 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):
+ def test_xml(self) -> None:
cov = self.run_coverage()
val = cov.xml_report(include="*/doit.py")
assert math.isclose(val, 6 / 7 * 100)
@@ -883,7 +886,7 @@ class ReportingReturnValueTest(CoverageTest):
class SummaryReporterConfigurationTest(CoverageTest):
"""Tests of SummaryReporter."""
- def make_rigged_file(self, filename, stmts, miss):
+ 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
@@ -897,7 +900,7 @@ class SummaryReporterConfigurationTest(CoverageTest):
source += " a = 2\n" * dont_run
self.make_file(filename, source)
- def get_summary_text(self, *options):
+ def get_summary_text(self, *options: Tuple[str, TConfigValue]) -> str:
"""Get text output from the SummaryReporter.
The arguments are tuples: (name, value) for Coverage.set_option.
@@ -916,7 +919,7 @@ class SummaryReporterConfigurationTest(CoverageTest):
printer.report([], destination)
return destination.getvalue()
- def test_test_data(self):
+ 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.
@@ -940,67 +943,67 @@ class SummaryReporterConfigurationTest(CoverageTest):
assert nums[1][1] < nums[0][1] < nums[2][1]
assert nums[2][2] < nums[0][2] < nums[1][2]
- def test_defaults(self):
+ 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):
+ 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, *words):
+ 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):
+ 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):
+ 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):
+ 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):
+ 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):
+ 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):
+ 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):
+ 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):
+ 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):
+ 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):
diff --git a/tox.ini b/tox.ini
index 43f6bce6..24fb2ea0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -100,10 +100,11 @@ setenv =
C3=coverage/data.py coverage/disposition.py coverage/env.py coverage/exceptions.py
C4=coverage/files.py coverage/inorout.py coverage/jsonreport.py coverage/lcovreport.py coverage/misc.py coverage/multiproc.py coverage/numbits.py
C5=coverage/parser.py coverage/phystokens.py coverage/plugin.py coverage/plugin_support.py coverage/python.py
- C6=coverage/report.py coverage/results.py coverage/sqldata.py coverage/tomlconfig.py coverage/types.py coverage/version.py coverage/xmlreport.py
- T1=tests/test_annotate.py tests/test_api.py tests/test_arcs.py tests/test_cmdline.py tests/test_collector.py tests/test_concurrency.py tests/test_config.py tests/test_context.py
- T2=tests/goldtest.py tests/helpers.py tests/test_html.py tests/test_misc.py tests/test_python.py tests/test_xml.py
- TYPEABLE={env:C1} {env:C2} {env:C3} {env:C4} {env:C5} {env:C6} {env:T1} {env:T2}
+ C6=coverage/report.py coverage/results.py coverage/sqldata.py coverage/summary.py coverage/tomlconfig.py coverage/types.py coverage/version.py coverage/xmlreport.py
+ T1=tests/coveragetest.py tests/goldtest.py tests/helpers.py
+ T2=tests/test_annotate.py tests/test_api.py tests/test_arcs.py tests/test_cmdline.py tests/test_collector.py tests/test_concurrency.py
+ T3=tests/test_config.py tests/test_context.py tests/test_html.py tests/test_misc.py tests/test_python.py tests/test_summary.py tests/test_xml.py
+ TYPEABLE={env:C1} {env:C2} {env:C3} {env:C4} {env:C5} {env:C6} {env:T1} {env:T2} {env:T3}
commands =
# PYVERSIONS