Skip to content
This repository has been archived by the owner on Nov 3, 2023. It is now read-only.

Enable full toml configuration and pyproject.toml #534

Merged
merged 17 commits into from May 9, 2021
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements/runtime.txt
@@ -1 +1,2 @@
snowballstemmer==1.2.1
toml==0.10.2
1 change: 1 addition & 0 deletions setup.py
Expand Up @@ -6,6 +6,7 @@

requirements = [
'snowballstemmer',
'toml',
]


Expand Down
113 changes: 108 additions & 5 deletions src/pydocstyle/config.py
Expand Up @@ -5,9 +5,11 @@
import os
from collections import namedtuple
from collections.abc import Set
from configparser import RawConfigParser
from configparser import NoOptionError, NoSectionError, RawConfigParser
from re import compile as re

import toml

from .utils import __version__, log
from .violations import ErrorRegistry, conventions

Expand All @@ -23,6 +25,98 @@ def _decorator(self, *args, **kwargs):
return _decorator


class TomlParser:
"""ConfigParser that partially mimics RawConfigParser but for toml files.

See RawConfigParser for more info. Also, please note that not all
RawConfigParser functionality is implemented, but only the subset, that is
RuRo marked this conversation as resolved.
Show resolved Hide resolved
currently used by pydocstyle.
"""

def __init__(self):
"""Create a toml parser."""
self._config = {}

def read(self, filenames, encoding=None):
"""Read and parse a filename or an iterable of filenames.

Files that cannot be opened are silently ignored; this is
designed so that you can specify an iterable of potential
configuration file locations (e.g. current directory, user's
home directory, systemwide directory), and all existing
configuration files in the iterable will be read. A single
filename may also be given.

Return list of successfully read files.
"""
if isinstance(filenames, (str, bytes, os.PathLike)):
filenames = [filenames]
read_ok = []
for filename in filenames:
try:
with open(filename, encoding=encoding) as fp:
self._config.update(toml.load(fp))
except OSError:
continue
if isinstance(filename, os.PathLike):
filename = os.fspath(filename)
read_ok.append(filename)
return read_ok

def _get_section(self, section, allow_none=False):
current = self._config
for p in section.split('.'):
if isinstance(current, dict) and p in current:
current = current[p]
else:
current = None
break
RuRo marked this conversation as resolved.
Show resolved Hide resolved

if isinstance(current, dict):
return current
elif allow_none:
return None
else:
raise NoSectionError(section)

def has_section(self, section):
"""Indicate whether the named section is present in the configuration."""
return self._get_section(section, allow_none=True) is not None

def options(self, section):
"""Return a list of option names for the given section name."""
current = self._get_section(section)
# current = current.copy()
# current.update(self._defaults)
return list(current.keys())

def get(self, section, option, *, _conv=None):
"""Get an option value for a given section."""
d = self._get_section(section)
option = option.lower()
try:
value = d[option]
except KeyError:
raise NoOptionError(option, section)

# toml should convert types automatically
# don't manually convert, just check, that the type is correct
if _conv is not None and not isinstance(value, _conv):
raise TypeError(
f"The type of {section}.{option} should be {_conv}"
)

return value

def getboolean(self, section, option):
"""Get a boolean option value for a given section."""
return self.get(section, option, _conv=bool)

def getint(self, section, option):
"""Get an integer option value for a given section."""
return self.get(section, option, _conv=int)


class ConfigurationParser:
"""Responsible for parsing configuration from files and CLI.

Expand Down Expand Up @@ -85,11 +179,12 @@ class ConfigurationParser:
'.pydocstyle.ini',
'.pydocstylerc',
'.pydocstylerc.ini',
'pyproject.toml',
# The following is deprecated, but remains for backwards compatibility.
'.pep257',
)

POSSIBLE_SECTION_NAMES = ('pydocstyle', 'pep257')
POSSIBLE_SECTION_NAMES = ('pydocstyle', 'pep257', 'tool.pydocstyle')

def __init__(self):
"""Create a configuration parser."""
Expand Down Expand Up @@ -310,7 +405,10 @@ def _read_configuration_file(self, path):
Returns (options, should_inherit).

"""
parser = RawConfigParser(inline_comment_prefixes=('#', ';'))
if path.endswith('.toml'):
parser = TomlParser()
else:
parser = RawConfigParser(inline_comment_prefixes=('#', ';'))
options = None
should_inherit = True

Expand Down Expand Up @@ -433,7 +531,10 @@ def _get_config_file_in_folder(cls, path):
path = os.path.dirname(path)

for fn in cls.PROJECT_CONFIG_FILES:
config = RawConfigParser()
if fn.endswith('.toml'):
config = TomlParser()
else:
config = RawConfigParser(inline_comment_prefixes=('#', ';'))
full_path = os.path.join(path, fn)
if config.read(full_path) and cls._get_section_name(config):
return full_path
Expand Down Expand Up @@ -552,8 +653,10 @@ def _get_set(value_str):
file.

"""
if not isinstance(value_str, list):
RuRo marked this conversation as resolved.
Show resolved Hide resolved
value_str = value_str.split(",")
return cls._expand_error_codes(
{x.strip() for x in value_str.split(",")} - {""}
{x.strip() for x in value_str} - {""}
)

for opt in optional_set_options:
Expand Down
92 changes: 86 additions & 6 deletions src/tests/test_integration.py
Expand Up @@ -32,12 +32,19 @@ class SandboxEnv:

Result = namedtuple('Result', ('out', 'err', 'code'))

def __init__(self, script_name='pydocstyle'):
def __init__(
self,
script_name='pydocstyle',
section_name='pydocstyle',
config_name='tox.ini',
):
"""Initialize the object."""
self.tempdir = None
self.script_name = script_name
self.section_name = section_name
self.config_name = config_name

def write_config(self, prefix='', name='tox.ini', **kwargs):
def write_config(self, prefix='', name=None, **kwargs):
"""Change an environment config file.

Applies changes to `tox.ini` relative to `tempdir/prefix`.
Expand All @@ -48,10 +55,23 @@ def write_config(self, prefix='', name='tox.ini', **kwargs):
if not os.path.isdir(base):
self.makedirs(base)

name = self.config_name if name is None else name
if name.endswith('.toml'):
def convert_value(val):
if isinstance(val, bool):
return {True: 'true', False: 'false'}[val]
else:
return repr(val)
RuRo marked this conversation as resolved.
Show resolved Hide resolved
else:
def convert_value(val):
return val

with open(os.path.join(base, name), 'wt') as conf:
conf.write(f"[{self.script_name}]\n")
conf.write(f"[{self.section_name}]\n")
for k, v in kwargs.items():
conf.write("{} = {}\n".format(k.replace('_', '-'), v))
conf.write("{} = {}\n".format(
k.replace('_', '-'), convert_value(v)
))

def open(self, path, *args, **kwargs):
"""Open a file in the environment.
Expand Down Expand Up @@ -117,10 +137,20 @@ def install_package(request):
)


@pytest.yield_fixture(scope="function")
@pytest.yield_fixture(scope="function", params=['ini', 'toml'])
def env(request):
"""Add a testing environment to a test method."""
with SandboxEnv() as test_env:
sandbox_settings = {
'ini': {
'section_name': 'pydocstyle',
'config_name': 'tox.ini',
},
'toml': {
'section_name': 'tool.pydocstyle',
'config_name': 'pyproject.toml',
},
}[request.param]
with SandboxEnv(**sandbox_settings) as test_env:
yield test_env


Expand Down Expand Up @@ -305,6 +335,11 @@ def foo():
assert 'file does not contain a pydocstyle section' not in err


@pytest.mark.parametrize(
# Don't parametrize over 'pyproject.toml'
# since this test applies only to '.ini' files
'env', ['ini'], indirect=True
)
def test_multiple_lined_config_file(env):
"""Test that .ini files with multi-lined entries are parsed correctly."""
with env.open('example.py', 'wt') as example:
Expand All @@ -328,6 +363,31 @@ def foo():
assert 'D103' not in out


@pytest.mark.parametrize(
# Don't parametrize over 'tox.ini' since
# this test applies only to '.toml' files
'env', ['toml'], indirect=True
)
def test_accepts_select_error_code_list(env):
"""Test that .ini files with multi-lined entries are parsed correctly."""
with env.open('example.py', 'wt') as example:
example.write(textwrap.dedent("""\
class Foo(object):
"Doc string"
def foo():
pass
"""))

env.write_config(select=['D100', 'D204', 'D300'])

out, err, code = env.invoke()
assert code == 1
assert 'D100' in out
assert 'D204' in out
assert 'D300' in out
assert 'D103' not in out


def test_config_path(env):
"""Test that options are correctly loaded from a specific config file.

Expand Down Expand Up @@ -476,6 +536,11 @@ def foo():
assert 'D300' not in out


@pytest.mark.parametrize(
# Don't parametrize over 'pyproject.toml'
# since this test applies only to '.ini' files
'env', ['ini'], indirect=True
)
def test_ignores_whitespace_in_fixed_option_set(env):
with env.open('example.py', 'wt') as example:
example.write("class Foo(object):\n 'Doc string'")
Expand All @@ -486,6 +551,21 @@ def test_ignores_whitespace_in_fixed_option_set(env):
assert err == ''


@pytest.mark.parametrize(
# Don't parametrize over 'tox.ini' since
# this test applies only to '.toml' files
'env', ['toml'], indirect=True
)
def test_accepts_ignore_error_code_list(env):
with env.open('example.py', 'wt') as example:
example.write("class Foo(object):\n 'Doc string'")
env.write_config(ignore=['D100', 'D300'])
out, err, code = env.invoke()
assert code == 1
assert 'D300' not in out
assert err == ''


def test_bad_wildcard_add_ignore_cli(env):
"""Test adding a non-existent error codes with --add-ignore."""
with env.open('example.py', 'wt') as example:
Expand Down