Skip to content

Finish the TOML support started in #699 #865

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ Unreleased
- The HTML and textual reports now have a ``--skip-empty`` option that skips
files with no statements (notably, ``__init__.py`` files). Thanks, Reya B.

- Configuration can now be read from `TOML`_ files. This requires installing
coverage.py with the ``[toml]`` extra. The standard "pyproject.toml" file
will be read automatically if no other configuration file is found, with
settings in the ``[tool.coverage.]`` namespace. Thanks to Frazer McLean for
implementation and persistence. Finishes `issue 664`_.

- The HTML report has been reimplemented (no more table around the source
code). This allowed for a better presentation of the context information,
hopefully resolving `issue 855`_.
Expand All @@ -33,6 +39,8 @@ Unreleased
``coverage html --show-contexts``) will issue a warning if there were no
contexts measured (`issue 851`_).

.. _TOML: https://github.com/toml-lang/toml#toml
.. _issue 664: https://github.com/nedbat/coveragepy/issues/664
.. _issue 851: https://github.com/nedbat/coveragepy/issues/851
.. _issue 855: https://github.com/nedbat/coveragepy/issues/855

Expand Down Expand Up @@ -79,7 +87,6 @@ Version 5.0a7 --- 2019-09-21
- ``debug=plugin`` didn't properly support configuration or dynamic context
plugins, but now it does, closing `issue 834`_.


.. _issue 720: https://github.com/nedbat/coveragepy/issues/720
.. _issue 822: https://github.com/nedbat/coveragepy/issues/822
.. _issue 834: https://github.com/nedbat/coveragepy/issues/834
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Eduardo Schettino
Emil Madsen
Edward Loper
Federico Bond
Frazer McLean
Geoff Bache
George Paci
George Song
Expand Down
12 changes: 11 additions & 1 deletion coverage/backward.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

"""Add things to old Pythons so I can pretend they are newer."""

# This file does tricky stuff, so disable a pylint warning.
# This file's purpose is to provide modules to be imported from here.
# pylint: disable=unused-import

import os
import sys

from coverage import env
Expand Down Expand Up @@ -55,6 +56,15 @@
except ImportError:
from threading import get_ident as get_thread_id

try:
os.PathLike
except AttributeError:
# This is Python 2 and 3
path_types = (bytes, string_class, unicode_class)
else:
# 3.6+
path_types = (bytes, str, os.PathLike)

# shlex.quote is new, but there's an undocumented implementation in "pipes",
# who knew!?
try:
Expand Down
4 changes: 2 additions & 2 deletions coverage/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ class Opts(object):
'', '--rcfile', action='store',
help=(
"Specify configuration file. "
"By default '.coveragerc', 'setup.cfg' and 'tox.ini' are tried. "
"[env: COVERAGE_RCFILE]"
"By default '.coveragerc', 'pyproject.toml', 'setup.cfg' and "
"'tox.ini' are tried. [env: COVERAGE_RCFILE]"
),
)
source = optparse.make_option(
Expand Down
12 changes: 10 additions & 2 deletions coverage/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from coverage.misc import contract, CoverageException, isolate_module
from coverage.misc import substitute_variables

from coverage.tomlconfig import TomlConfigParser, TomlDecodeError

os = isolate_module(os)


Expand Down Expand Up @@ -256,12 +258,17 @@ def from_file(self, filename, our_file):
coverage.py settings in it.

"""
_, ext = os.path.splitext(filename)
if ext == '.toml':
cp = TomlConfigParser(our_file)
else:
cp = HandyConfigParser(our_file)

self.attempted_config_files.append(filename)

cp = HandyConfigParser(our_file)
try:
files_read = cp.read(filename)
except configparser.Error as err:
except (configparser.Error, TomlDecodeError) as err:
raise CoverageException("Couldn't read config file %s: %s" % (filename, err))
if not files_read:
return False
Expand Down Expand Up @@ -475,6 +482,7 @@ def config_files_to_try(config_file):
(config_file, True, specified_file),
("setup.cfg", False, False),
("tox.ini", False, False),
("pyproject.toml", False, False),
]
return files_to_try

Expand Down
68 changes: 68 additions & 0 deletions coverage/optional.py
Original file line number Diff line number Diff line change
@@ -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

"""
Imports that we need at runtime, but might not be present.

When importing one of these modules, always do it in the function where you
need the module. Some tests will need to remove the module. If you import
it at the top level of your module, then the test won't be able to simulate
the module being unimportable.

The import will always succeed, but the value will be None if the module is
unavailable.

Bad::

# MyModule.py
from coverage.optional import unsure

def use_unsure():
unsure.something()

Good::

# MyModule.py

def use_unsure():
from coverage.optional import unsure
if unsure is None:
raise Exception("Module unsure isn't available!")

unsure.something()

"""

import contextlib

# This file's purpose is to provide modules to be imported from here.
# pylint: disable=unused-import

# TOML support is an install-time extra option.
try:
import toml
except ImportError: # pragma: not covered
toml = None


@contextlib.contextmanager
def without(modname):
"""Hide a module for testing.

Use this in a test function to make an optional module unavailable during
the test::

with coverage.optional.without('toml'):
use_toml_somehow()

Arguments:
modname (str): the name of a module importable from
`coverage.optional`.

"""
real_module = globals()[modname]
try:
globals()[modname] = None
yield
finally:
globals()[modname] = real_module
169 changes: 169 additions & 0 deletions coverage/tomlconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt

"""TOML configuration support for coverage.py"""

import io
import os
import re

from coverage import env
from coverage.backward import configparser, path_types
from coverage.misc import CoverageException, substitute_variables


class TomlDecodeError(Exception):
"""An exception class that exists even when toml isn't installed."""
pass


class TomlConfigParser:
"""TOML file reading with the interface of HandyConfigParser."""

# This class has the same interface as config.HandyConfigParser, no
# need for docstrings.
# pylint: disable=missing-function-docstring

def __init__(self, our_file):
self.our_file = our_file
self.getters = [lambda obj: obj['tool']['coverage']]
if self.our_file:
self.getters.append(lambda obj: obj)

self._data = []

def read(self, filenames):
# RawConfigParser takes a filename or list of filenames, but we only
# ever call this with a single filename.
assert isinstance(filenames, path_types)
filename = filenames
if env.PYVERSION >= (3, 6):
filename = os.fspath(filename)

from coverage.optional import toml
if toml is None:
if self.our_file:
raise CoverageException("Can't read {!r} without TOML support".format(filename))

try:
with io.open(filename, encoding='utf-8') as fp:
toml_data = fp.read()
toml_data = substitute_variables(toml_data, os.environ)
if toml:
try:
self._data.append(toml.loads(toml_data))
except toml.TomlDecodeError as err:
raise TomlDecodeError(*err.args)
elif re.search(r"^\[tool\.coverage\.", toml_data, flags=re.MULTILINE):
# Looks like they meant to read TOML, but we can't.
raise CoverageException("Can't read {!r} without TOML support".format(filename))
else:
return []
except IOError:
return []
return [filename]

def has_option(self, section, option):
for data in self._data:
for getter in self.getters:
try:
getter(data)[section][option]
except KeyError:
continue
return True
return False

def has_section(self, section):
for data in self._data:
for getter in self.getters:
try:
getter(data)[section]
except KeyError:
continue
return section
return False

def options(self, section):
for data in self._data:
for getter in self.getters:
try:
section = getter(data)[section]
except KeyError:
continue
return list(section.keys())
raise configparser.NoSectionError(section)

def get_section(self, section):
d = {}
for opt in self.options(section):
d[opt] = self.get(section, opt)
return d

def get(self, section, option):
found_section = False
for data in self._data:
for getter in self.getters:
try:
section = getter(data)[section]
except KeyError:
continue

found_section = True
try:
value = section[option]
except KeyError:
continue
return value
if not found_section:
raise configparser.NoSectionError(section)
raise configparser.NoOptionError(option, section)

def getboolean(self, section, option):
value = self.get(section, option)
if not isinstance(value, bool):
raise ValueError(
'Option {!r} in section {!r} is not a boolean: {!r}'
.format(option, section, value)
)
return value

def getlist(self, section, option):
values = self.get(section, option)
if not isinstance(values, list):
raise ValueError(
'Option {!r} in section {!r} is not a list: {!r}'
.format(option, section, values)
)
return values

def getregexlist(self, section, option):
values = self.getlist(section, option)
for value in values:
value = value.strip()
try:
re.compile(value)
except re.error as e:
raise CoverageException(
"Invalid [%s].%s value %r: %s" % (section, option, value, e)
)
return values

def getint(self, section, option):
value = self.get(section, option)
if not isinstance(value, int):
raise ValueError(
'Option {!r} in section {!r} is not an integer: {!r}'
.format(option, section, value)
)
return value

def getfloat(self, section, option):
value = self.get(section, option)
if isinstance(value, int):
value = float(value)
if not isinstance(value, float):
raise ValueError(
'Option {!r} in section {!r} is not a float: {!r}'
.format(option, section, value)
)
return value
5 changes: 4 additions & 1 deletion doc/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ Coverage.py will read settings from other usual configuration files if no other
configuration file is used. It will automatically read from "setup.cfg" or
"tox.ini" if they exist. In this case, the section names have "coverage:"
prefixed, so the ``[run]`` options described below will be found in the
``[coverage:run]`` section of the file.
``[coverage:run]`` section of the file. If coverage.py is installed with the
``toml`` extra (``pip install coverage[toml]``), it will automatically read
from "pyproject.toml". Configuration must be within the ``[tool.coverage]``
section, for example, ``[tool.coverage.run]``.


Syntax
Expand Down
5 changes: 5 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@
],
},

extras_require={
# Enable pyproject.toml support.
'toml': ['toml'],
},

# We need to get HTML assets from our htmlfiles directory.
zip_safe=False,

Expand Down
Loading