• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

abravalheri / validate-pyproject / 4900349245390848

pending completion
4900349245390848

Pull #82

cirrus-ci

Anderson Bravalheri
Add test case for extra keys in authors
Pull Request #82: Error-out when extra keys are added to project.authos/maintainers

255 of 261 branches covered (97.7%)

Branch coverage included in aggregate %.

736 of 746 relevant lines covered (98.66%)

10.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.93
/src/validate_pyproject/cli.py
1
# The code in this module is based on a similar code from `ini2toml` (originally
2
# published under the MPL-2.0 license)
3
# https://github.com/abravalheri/ini2toml/blob/49897590a9254646434b7341225932e54f9626a3/LICENSE.txt
4

5
import argparse
11✔
6
import io
11✔
7
import json
11✔
8
import logging
11✔
9
import sys
11✔
10
from contextlib import contextmanager
11✔
11
from itertools import chain
11✔
12
from textwrap import dedent, wrap
11✔
13
from typing import (
11✔
14
    Callable,
15
    Dict,
16
    Iterator,
17
    List,
18
    NamedTuple,
19
    Sequence,
20
    Tuple,
21
    Type,
22
    TypeVar,
23
)
24

25
from . import __version__
11✔
26
from .api import Validator
11✔
27
from .errors import ValidationError
11✔
28
from .plugins import PluginWrapper
11✔
29
from .plugins import list_from_entry_points as list_plugins_from_entry_points
11✔
30

31
_logger = logging.getLogger(__package__)
11✔
32
T = TypeVar("T", bound=NamedTuple)
11✔
33

34

35
try:  # pragma: no cover
36
    if sys.version_info[:2] >= (3, 11):
37
        from tomllib import TOMLDecodeError, loads
38
    else:
39
        from tomli import TOMLDecodeError, loads
40
except ImportError:  # pragma: no cover
41
    try:
42
        from toml import TomlDecodeError as TOMLDecodeError  # type: ignore
43
        from toml import loads  # type: ignore
44
    except ImportError as ex:
45
        raise ImportError("Please install `tomli` (TOML parser)") from ex
46

47
_REGULAR_EXCEPTIONS = (ValidationError, TOMLDecodeError)
11✔
48

49

50
@contextmanager
11✔
51
def critical_logging():
9✔
52
    """Make sure the logging level is set even before parsing the CLI args"""
53
    try:
11✔
54
        yield
11✔
55
    except Exception:  # pragma: no cover
56
        if "-vv" in sys.argv or "--very-verbose" in sys.argv:
57
            setup_logging(logging.DEBUG)
58
        raise
59

60

61
META: Dict[str, dict] = {
11✔
62
    "version": dict(
63
        flags=("-V", "--version"),
64
        action="version",
65
        version=f"{__package__} {__version__}",
66
    ),
67
    "input_file": dict(
68
        dest="input_file",
69
        nargs="*",
70
        default=[argparse.FileType("r")("-")],
71
        type=argparse.FileType("r"),
72
        help="TOML file to be verified (`stdin` by default)",
73
    ),
74
    "enable": dict(
75
        flags=("-E", "--enable-plugins"),
76
        nargs="+",
77
        default=(),
78
        dest="enable",
79
        metavar="PLUGINS",
80
        help="Enable ONLY the given plugins (ALL plugins are enabled by default).",
81
    ),
82
    "disable": dict(
83
        flags=("-D", "--disable-plugins"),
84
        nargs="+",
85
        dest="disable",
86
        default=(),
87
        metavar="PLUGINS",
88
        help="Enable ALL plugins, EXCEPT the ones given.",
89
    ),
90
    "verbose": dict(
91
        flags=("-v", "--verbose"),
92
        dest="loglevel",
93
        action="store_const",
94
        const=logging.INFO,
95
        help="set logging level to INFO",
96
    ),
97
    "very_verbose": dict(
98
        flags=("-vv", "--very-verbose"),
99
        dest="loglevel",
100
        action="store_const",
101
        const=logging.DEBUG,
102
        help="set logging level to DEBUG",
103
    ),
104
    "dump_json": dict(
105
        flags=("--dump-json",),
106
        action="store_true",
107
        help="Print the JSON equivalent to the given TOML",
108
    ),
109
}
110

111

112
class CliParams(NamedTuple):
11✔
113
    input_file: List[io.TextIOBase]
11✔
114
    plugins: List[PluginWrapper]
11✔
115
    loglevel: int = logging.WARNING
11✔
116
    dump_json: bool = False
11✔
117

118

119
def __meta__(plugins: Sequence[PluginWrapper]) -> Dict[str, dict]:
11✔
120
    """'Hyper parameters' to instruct :mod:`argparse` how to create the CLI"""
121
    meta = {k: v.copy() for k, v in META.items()}
11✔
122
    meta["enable"]["choices"] = set([p.tool for p in plugins])
11✔
123
    return meta
11✔
124

125

126
@critical_logging()
11✔
127
def parse_args(
11✔
128
    args: Sequence[str],
129
    plugins: Sequence[PluginWrapper],
130
    description: str = "Validate a given TOML file",
131
    get_parser_spec: Callable[[Sequence[PluginWrapper]], Dict[str, dict]] = __meta__,
132
    params_class: Type[T] = CliParams,  # type: ignore[assignment]
133
) -> T:
134
    """Parse command line parameters
135

136
    Args:
137
      args: command line parameters as list of strings (for example  ``["--help"]``).
138

139
    Returns: command line parameters namespace
140
    """
141
    epilog = ""
11✔
142
    if plugins:
11✔
143
        epilog = f"The following plugins are available:\n\n{plugins_help(plugins)}"
11✔
144

145
    parser = argparse.ArgumentParser(
11✔
146
        description=description, epilog=epilog, formatter_class=Formatter
147
    )
148
    for cli_opts in get_parser_spec(plugins).values():
11✔
149
        parser.add_argument(*cli_opts.pop("flags", ()), **cli_opts)
11✔
150

151
    parser.set_defaults(loglevel=logging.WARNING)
11✔
152
    params = vars(parser.parse_args(args))
11✔
153
    enabled = params.pop("enable", ())
11✔
154
    disabled = params.pop("disable", ())
11✔
155
    params["plugins"] = select_plugins(plugins, enabled, disabled)
11✔
156
    return params_class(**params)
11✔
157

158

159
def select_plugins(
11✔
160
    plugins: Sequence[PluginWrapper],
161
    enabled: Sequence[str] = (),
162
    disabled: Sequence[str] = (),
163
) -> List[PluginWrapper]:
164
    available = list(plugins)
11✔
165
    if enabled:
11✔
166
        available = [p for p in available if p.tool in enabled]
11✔
167
    if disabled:
11✔
168
        available = [p for p in available if p.tool not in disabled]
11✔
169
    return available
11✔
170

171

172
def setup_logging(loglevel: int):
11✔
173
    """Setup basic logging
174

175
    Args:
176
      loglevel: minimum loglevel for emitting messages
177
    """
178
    logformat = "[%(levelname)s] %(message)s"
11✔
179
    logging.basicConfig(level=loglevel, stream=sys.stderr, format=logformat)
11✔
180

181

182
@contextmanager
11✔
183
def exceptions2exit():
9✔
184
    try:
11✔
185
        yield
11✔
186
    except _ExceptionGroup as group:
11✔
187
        for prefix, ex in group:
11✔
188
            print(prefix)
11✔
189
            _logger.error(str(ex) + "\n")
11✔
190
        raise SystemExit(1)
11✔
191
    except _REGULAR_EXCEPTIONS as ex:
11✔
192
        _logger.error(str(ex))
11✔
193
        raise SystemExit(1)
11✔
194
    except Exception as ex:  # pragma: no cover
195
        _logger.error(f"{ex.__class__.__name__}: {ex}\n")
196
        _logger.debug("Please check the following information:", exc_info=True)
197
        raise SystemExit(1)
198

199

200
def run(args: Sequence[str] = ()):
11✔
201
    """Wrapper allowing :obj:`Translator` to be called in a CLI fashion.
202

203
    Instead of returning the value from :func:`Translator.translate`, it prints the
204
    result to the given ``output_file`` or ``stdout``.
205

206
    Args:
207
      args (List[str]): command line parameters as list of strings
208
          (for example  ``["--verbose", "setup.cfg"]``).
209
    """
210
    args = args or sys.argv[1:]
11✔
211
    plugins: List[PluginWrapper] = list_plugins_from_entry_points()
11✔
212
    params: CliParams = parse_args(args, plugins)
11✔
213
    setup_logging(params.loglevel)
11✔
214
    validator = Validator(plugins=params.plugins)
11✔
215

216
    exceptions = _ExceptionGroup()
11✔
217
    for file in params.input_file:
11✔
218
        try:
11✔
219
            toml_equivalent = loads(file.read())
11✔
220
            validator(toml_equivalent)
11✔
221
            if params.dump_json:
11✔
222
                print(json.dumps(toml_equivalent, indent=2))
11✔
223
            else:
224
                print(f"Valid {_format_file(file)}")
11✔
225
        except _REGULAR_EXCEPTIONS as ex:
11✔
226
            exceptions.add(f"Invalid {_format_file(file)}", ex)
11✔
227

228
    exceptions.raise_if_any()
11✔
229

230
    return 0
11✔
231

232

233
main = exceptions2exit()(run)
11✔
234

235

236
class Formatter(argparse.RawTextHelpFormatter):
11✔
237
    # Since the stdlib does not specify what is the signature we need to implement in
238
    # order to create our own formatter, we are left no choice other then overwrite a
239
    # "private" method considered to be an implementation detail.
240

241
    def _split_lines(self, text, width):
11✔
242
        return list(chain.from_iterable(wrap(x, width) for x in text.splitlines()))
11✔
243

244

245
def plugins_help(plugins: Sequence[PluginWrapper]) -> str:
11✔
246
    return "\n".join(_format_plugin_help(p) for p in plugins)
11✔
247

248

249
def _flatten_str(text: str) -> str:
11✔
250
    text = " ".join(x.strip() for x in dedent(text).splitlines()).strip()
×
251
    text = text.rstrip(".,;").strip()
×
252
    return (text[0].lower() + text[1:]).strip()
×
253

254

255
def _format_plugin_help(plugin: PluginWrapper) -> str:
11✔
256
    help_text = plugin.help_text
11✔
257
    help_text = f": {_flatten_str(help_text)}" if help_text else ""
11✔
258
    return f"* {plugin.tool!r}{help_text}"
11✔
259

260

261
def _format_file(file: io.TextIOBase) -> str:
11✔
262
    if hasattr(file, "name") and file.name:
11✔
263
        return f"file: {file.name}"
11✔
264
    return "file"  # pragma: no cover
265

266

267
class _ExceptionGroup(Exception):
11✔
268
    _members: List[Tuple[str, Exception]]
11✔
269

270
    def __init__(self):
11✔
271
        self._members = []
11✔
272
        super().__init__()
11✔
273

274
    def add(self, prefix: str, ex: Exception):
11✔
275
        self._members.append((prefix, ex))
11✔
276

277
    def __iter__(self) -> Iterator[Tuple[str, Exception]]:
11✔
278
        return iter(self._members)
11✔
279

280
    def raise_if_any(self):
11✔
281
        number = len(self._members)
11✔
282
        if number == 1:
11✔
283
            print(self._members[0][0])
11✔
284
            raise self._members[0][1]
11✔
285
        if number > 0:
11✔
286
            raise self
11✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc