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

repo-helper / pyproject-parser / 20722754374

05 Jan 2026 04:58PM UTC coverage: 97.52% (-0.08%) from 97.595%
20722754374

push

github

domdfcoding
Increase test coverage and remove dead code.

983 of 1008 relevant lines covered (97.52%)

0.98 hits per line

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

95.98
/pyproject_parser/__init__.py
1
#!/usr/bin/env python3
2
#
3
#  __init__.py
4
"""
5
Parser for ``pyproject.toml``.
6
"""
7
#
8
#  Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
9
#
10
#  PyProjectTomlEncoder.dumps based on https://github.com/hukkin/tomli-w
11
#  MIT Licensed
12
#  Copyright (c) 2021 Taneli Hukkinen
13
#
14
#  Permission is hereby granted, free of charge, to any person obtaining a copy
15
#  of this software and associated documentation files (the "Software"), to deal
16
#  in the Software without restriction, including without limitation the rights
17
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
#  copies of the Software, and to permit persons to whom the Software is
19
#  furnished to do so, subject to the following conditions:
20
#
21
#  The above copyright notice and this permission notice shall be included in all
22
#  copies or substantial portions of the Software.
23
#
24
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
25
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
26
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
27
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
28
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
29
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
30
#  OR OTHER DEALINGS IN THE SOFTWARE.
31
#
32

33
# stdlib
34
from typing import (
1✔
35
                Any,
36
                ClassVar,
37
                Dict,
38
                Iterator,
39
                List,
40
                Mapping,
41
                MutableMapping,
42
                Optional,
43
                Tuple,
44
                Type,
45
                TypeVar,
46
                Union
47
                )
48

49
# 3rd party
50
import attr
1✔
51
import dom_toml
1✔
52
from dom_toml.decoder import InlineTableDict
1✔
53
from dom_toml.encoder import TomlEncoder
1✔
54
from dom_toml.parser import AbstractConfigParser, BadConfigError
1✔
55
from domdf_python_tools.paths import PathPlus, in_directory
1✔
56
from domdf_python_tools.typing import PathLike
1✔
57
from domdf_python_tools.words import word_join
1✔
58
from packaging.markers import Marker
1✔
59
from packaging.requirements import Requirement
1✔
60
from packaging.specifiers import SpecifierSet
1✔
61
from packaging.version import Version
1✔
62
from shippinglabel import normalize
1✔
63

64
# this package
65
from pyproject_parser.classes import License, Readme, _NormalisedName
1✔
66
from pyproject_parser.parsers import BuildSystemParser, DependencyGroupsParser, PEP621Parser
1✔
67
from pyproject_parser.type_hints import (  # noqa: F401
1✔
68
                Author,
69
                BuildSystemDict,
70
                ContentTypes,
71
                DependencyGroupsDict,
72
                ProjectDict,
73
                _PyProjectAsTomlDict
74
                )
75
from pyproject_parser.utils import _load_toml
1✔
76

77
__author__: str = "Dominic Davis-Foster"
1✔
78
__copyright__: str = "2021 Dominic Davis-Foster"
1✔
79
__license__: str = "MIT License"
1✔
80
__version__: str = "0.14.0"
1✔
81
__email__: str = "dominic@davis-foster.co.uk"
1✔
82

83
__all__ = ["PyProject", "PyProjectTomlEncoder", "_PP"]
1✔
84

85
_PP = TypeVar("_PP", bound="PyProject")
1✔
86

87
_translation_table = {
1✔
88
                8: "\\b",
89
                9: "\\t",
90
                10: "\\n",
91
                12: "\\f",
92
                13: "\\r",
93
                92: "\\\\",
94
                }
95

96

97
def _dump_str(v: str) -> str:
1✔
98
        v = str(v).translate(_translation_table)
1✔
99

100
        if "'" in v and '"' not in v:
1✔
101
                quote_char = '"'
×
102
        elif '"' in v and "'" not in v:
1✔
103
                quote_char = "'"
1✔
104
        else:
105
                quote_char = '"'
1✔
106
                v = v.replace('"', '\\"')
1✔
107

108
        return f"{quote_char}{v}{quote_char}"
1✔
109

110

111
class PyProjectTomlEncoder(dom_toml.TomlEncoder):  # noqa: PRM002
1✔
112
        """
113
        Custom TOML encoder supporting types in :mod:`pyproject_parser.classes` and packaging_.
114

115
        .. _packaging: https://packaging.pypa.io/en/latest/
116

117
        .. autosummary-widths:: 23/64
118
        """
119

120
        def __init__(self, preserve: bool = False) -> None:
1✔
121
                super().__init__(preserve=preserve)
1✔
122

123
        def dumps(
1✔
124
                        self,
125
                        table: Mapping[str, Any],
126
                        *,
127
                        name: str,
128
                        inside_aot: bool = False,
129
                        ) -> Iterator[str]:
130
                """
131
                Serialise the given table.
132

133
                :param table:
134
                :param name: The table name.
135
                :param inside_aot:
136

137
                :rtype:
138

139
                .. versionadded:: 0.11.0
140
                """
141

142
                yielded = False
1✔
143
                literals = []
1✔
144
                tables: List[Tuple[str, Any, bool]] = []
1✔
145
                for k, v in table.items():
1✔
146
                        if v is None:
1✔
147
                                continue
1✔
148
                        if self.preserve and isinstance(v, InlineTableDict):
1✔
149
                                literals.append((k, v))
×
150
                        elif isinstance(v, dict):
1✔
151
                                tables.append((k, v, False))
1✔
152
                        elif self._is_aot(v):
1✔
153
                                tables.extend((k, t, True) for t in v)
1✔
154
                        else:
155
                                literals.append((k, v))
1✔
156

157
                if inside_aot or name and (literals or not tables):
1✔
158
                        yielded = True
1✔
159
                        yield f"[[{name}]]\n" if inside_aot else f"[{name}]\n"
1✔
160

161
                if literals:
1✔
162
                        yielded = True
1✔
163
                        for k, v in literals:
1✔
164
                                yield f"{self.format_key_part(k)} = {self.format_literal(v)}\n"
1✔
165

166
                for k, v, in_aot in tables:
1✔
167
                        if yielded:
1✔
168
                                yield '\n'
1✔
169
                        else:
170
                                yielded = True
1✔
171
                        key_part = self.format_key_part(k)
1✔
172
                        display_name = f"{name}.{key_part}" if name else key_part
1✔
173

174
                        yield from self.dumps(v, name=display_name, inside_aot=in_aot)
1✔
175

176
        def format_literal(self, obj: object, *, nest_level: int = 0) -> str:
1✔
177
                """
178
                Format a literal value.
179

180
                :param obj:
181
                :param nest_level:
182

183
                :rtype:
184

185
                .. versionadded:: 0.11.0
186
                """
187

188
                if isinstance(obj, (str, _NormalisedName)):
1✔
189
                        return _dump_str(obj)
1✔
190
                elif isinstance(obj, (Version, Requirement, Marker, SpecifierSet)):
1✔
191
                        return self.dump_packaging_types(obj)
1✔
192
                else:
193
                        return super().format_literal(obj, nest_level=nest_level)
1✔
194

195
        def format_inline_array(self, obj: Union[Tuple, List], nest_level: int) -> str:
1✔
196
                """
197
                Format an inline array.
198

199
                :param obj:
200
                :param nest_level:
201

202
                :rtype:
203

204
                .. versionadded:: 0.11.0
205
                """
206

207
                if not len(obj):
1✔
208
                        return "[]"
1✔
209

210
                item_indent = "    " * (1 + nest_level)
1✔
211
                closing_bracket_indent = "    " * nest_level
1✔
212
                single_line = "[ " + ", ".join(
1✔
213
                                self.format_literal(item, nest_level=nest_level + 1) for item in obj
214
                                ) + f",]"
215

216
                if len(single_line) <= self.max_width:
1✔
217
                        return single_line
1✔
218
                else:
219
                        start = "[\n"
×
220
                        body = ",\n".join(item_indent + self.format_literal(item, nest_level=nest_level + 1) for item in obj)
×
221
                        end = f",\n{closing_bracket_indent}]"
×
222
                        return start + body + end
×
223

224
        @staticmethod
1✔
225
        def dump_packaging_types(obj: Union[Version, Requirement, Marker, SpecifierSet]) -> str:
1✔
226
                """
227
                Convert types in packaging_ to TOML.
228

229
                .. _packaging: https://packaging.pypa.io/en/latest/
230

231
                :param obj:
232
                """
233

234
                return _dump_str(str(obj))
1✔
235

236

237
@attr.s
1✔
238
class PyProject:
1✔
239
        """
240
        Represents a ``pyproject.toml`` file.
241

242
        :param build_system:
243

244
        .. versionchanged:: 0.13.0  Added ``dependency_groups`` and ``dependency_groups_table_parser`` properties.
245

246
        .. autosummary-widths:: 4/10
247

248
        .. autoclasssumm:: PyProject
249
                :autosummary-sections: Methods
250
                :autosummary-exclude-members: __ge__,__gt__,__le__,__lt__,__ne__,__init__,__repr__,__eq__
251

252
        .. latex:clearpage::
253

254
        .. autosummary-widths:: 1/2
255

256
        .. autoclasssumm:: PyProject
257
                :autosummary-sections: Attributes
258

259
        """
260

261
        #: Represents the :pep:`build-system table <518#build-system-table>` defined in :pep:`517` and :pep:`518`.
262
        build_system: Optional[BuildSystemDict] = attr.ib(default=None)
1✔
263

264
        #: Represents the :pep:`dependency groups table <735#specification>` defined in :pep:`735`.
265
        dependency_groups: Optional[DependencyGroupsDict] = attr.ib(default=None)
1✔
266

267
        #: Represents the :pep621:`project table <table-name>` defined in :pep:`621`.
268
        project: Optional[ProjectDict] = attr.ib(default=None)
1✔
269

270
        #: Represents the :pep:`tool table <518#tool-table>` defined in :pep:`518`.
271
        tool: Dict[str, Dict[str, Any]] = attr.ib(factory=dict)
1✔
272

273
        build_system_table_parser: ClassVar[BuildSystemParser] = BuildSystemParser()
1✔
274
        """
275
        The :class:`~dom_toml.parser.AbstractConfigParser`
276
        to parse the :pep:`build-system table <518#build-system-table>` with.
277
        """
278

279
        dependency_groups_table_parser: ClassVar[DependencyGroupsParser] = DependencyGroupsParser()
1✔
280
        """
281
        The :class:`~dom_toml.parser.AbstractConfigParser`
282
        to parse the :pep:`dependency groups table <735#specification>` with.
283

284
        .. versionadded:: 0.13.0
285
        """
286

287
        project_table_parser: ClassVar[PEP621Parser] = PEP621Parser()
1✔
288
        """
289
        The :class:`~dom_toml.parser.AbstractConfigParser`
290
        to parse the :pep621:`project table <table-name>` with.
291
        """
292

293
        tool_parsers: ClassVar[Mapping[str, AbstractConfigParser]] = {}
1✔
294
        """
295
        A mapping of subtable names to :class:`~dom_toml.parser.AbstractConfigParser` objects
296
        to parse the :pep:`tool table <518#tool-table>` with.
297

298
        For example, to parse ``[tool.whey]``:
299

300
        .. code-block:: python
301

302
                class WheyParser(AbstractConfigParser):
303
                        pass
304

305
                class CustomPyProject(PyProject):
306
                        tool_parsers = {"whey": WheyParser()}
307
        """
308

309
        @classmethod
1✔
310
        def load(
1✔
311
                        cls: Type[_PP],
312
                        filename: PathLike,
313
                        set_defaults: bool = False,
314
                        ) -> _PP:
315
                """
316
                Load the ``pyproject.toml`` configuration mapping from the given file.
317

318
                :param filename:
319
                :param set_defaults: If :py:obj:`True`, passes ``set_defaults=True``
320
                        the :meth:`parse() <dom_toml.parser.AbstractConfigParser.parse>` method on
321
                        :attr:`~.build_system_table_parser` and :attr:`~.project_table_parser`.
322
                """
323

324
                filename = PathPlus(filename)
1✔
325

326
                project_dir = filename.parent
1✔
327
                config = _load_toml(filename)
1✔
328

329
                keys = set(config.keys())
1✔
330

331
                build_system_table: Optional[BuildSystemDict] = None
1✔
332
                dependency_groups_table: Optional[DependencyGroupsDict] = None
1✔
333
                project_table: Optional[ProjectDict] = None
1✔
334
                tool_table: Dict[str, Dict[str, Any]] = {}
1✔
335

336
                with in_directory(project_dir):
1✔
337
                        if "build-system" in config:
1✔
338
                                build_system_table = cls.build_system_table_parser.parse(
1✔
339
                                                config["build-system"],
340
                                                set_defaults=set_defaults,
341
                                                )
342
                                keys.remove("build-system")
1✔
343

344
                        if "dependency-groups" in config:
1✔
345
                                dependency_groups_table = cls.dependency_groups_table_parser.parse(
1✔
346
                                                config["dependency-groups"],
347
                                                set_defaults=set_defaults,
348
                                                )
349
                                keys.remove("dependency-groups")
1✔
350

351
                        if "project" in config:
1✔
352
                                project_table = cls.project_table_parser.parse(config["project"], set_defaults=set_defaults)
1✔
353
                                keys.remove("project")
1✔
354

355
                        if "tool" in config:
1✔
356
                                tool_table = config["tool"]
1✔
357
                                keys.remove("tool")
1✔
358

359
                                for tool_name, tool_subtable in tool_table.items():
1✔
360
                                        if tool_name in cls.tool_parsers:
1✔
361
                                                tool_table[tool_name] = cls.tool_parsers[tool_name].parse(tool_subtable)
1✔
362

363
                if keys:
1✔
364
                        allowed_top_level = ("build-system", "dependency-groups", "project", "tool")
1✔
365

366
                        for top_level_key in sorted(keys):
1✔
367

368
                                if normalize(top_level_key) in allowed_top_level:
1✔
369
                                        raise BadConfigError(
1✔
370
                                                        f"Unexpected top-level key {top_level_key!r}. "
371
                                                        f"Did you mean {normalize(top_level_key)!r}?",
372
                                                        )
373

374
                                raise BadConfigError(
1✔
375
                                                f"Unexpected top-level key {top_level_key!r}. "
376
                                                f"Only {word_join(allowed_top_level, use_repr=True)} are allowed.",
377
                                                )
378

379
                return cls(
1✔
380
                                build_system=build_system_table,
381
                                dependency_groups=dependency_groups_table,
382
                                project=project_table,
383
                                tool=tool_table,
384
                                )
385

386
        def dumps(
1✔
387
                        self,
388
                        encoder: Union[Type[TomlEncoder], TomlEncoder] = PyProjectTomlEncoder,
389
                        ) -> str:
390
                """
391
                Serialise to TOML.
392

393
                :param encoder: The :class:`~dom_toml.encoder.TomlEncoder` to use for constructing the output string.
394
                """
395

396
                # TODO: filter out default values (lists and dicts)
397

398
                toml_dict: _PyProjectAsTomlDict = {
1✔
399
                                "build-system": self.build_system,
400
                                "project": self.project,
401
                                "tool": self.tool,
402
                                "dependency-groups": self.dependency_groups,
403
                                }
404

405
                if toml_dict["project"] is not None:
1✔
406
                        if "license" in toml_dict["project"] and toml_dict["project"]["license"] is not None:
1✔
407
                                _license = toml_dict["project"]["license"].to_pep639()
1✔
408
                                toml_dict["project"] = {
1✔
409
                                                **toml_dict["project"],
410
                                                "license": _license,  # type: ignore[typeddict-item]
411
                                                }
412

413
                        if "readme" in toml_dict["project"] and toml_dict["project"]["readme"] is not None:
1✔
414
                                readme_dict = toml_dict["project"]["readme"].to_pep621_dict()
1✔
415

416
                                _project: Dict[str, Any]
417

418
                                if set(readme_dict.keys()) == {"file"}:
1✔
419
                                        _project = {**toml_dict["project"], "readme": readme_dict["file"]}
1✔
420
                                else:
421
                                        _project = {**toml_dict["project"], "readme": readme_dict}
1✔
422

423
                                toml_dict["project"] = _project  # type: ignore[typeddict-item]
1✔
424

425
                return dom_toml.dumps(toml_dict, encoder)
1✔
426

427
        def dump(
1✔
428
                        self,
429
                        filename: PathLike,
430
                        encoder: Union[Type[TomlEncoder], TomlEncoder] = PyProjectTomlEncoder,
431
                        ) -> str:
432
                """
433
                Write as TOML to the given file.
434

435
                :param filename: The filename to write to.
436
                :param encoder: The :class:`~dom_toml.encoder.TomlEncoder` to use for constructing the output string.
437

438
                :returns: A string containing the TOML representation.
439
                """
440

441
                filename = PathPlus(filename)
1✔
442
                as_toml = self.dumps(encoder=encoder)
1✔
443
                filename.write_clean(as_toml)
1✔
444
                return as_toml
1✔
445

446
        @classmethod
1✔
447
        def reformat(
1✔
448
                        cls: Type[_PP],
449
                        filename: PathLike,
450
                        encoder: Union[Type[TomlEncoder], TomlEncoder] = PyProjectTomlEncoder,
451
                        ) -> str:
452
                """
453
                Reformat the given ``pyproject.toml`` file.
454

455
                :param filename: The file to reformat.
456
                :param encoder: The :class:`~dom_toml.encoder.TomlEncoder` to use for constructing the output string.
457

458
                :returns: A string containing the reformatted TOML.
459

460
                .. versionchanged:: 0.2.0
461

462
                        * Added the ``encoder`` argument.
463
                        * The parser configured as :attr:`~.project_table_parser` is now used to parse
464
                          the :pep621:`project table <table-name>`, rather than always using :class:`~.PEP621Parser`.
465

466
                """
467

468
                config = cls.load(filename, set_defaults=False)
1✔
469
                if config.project is not None and isinstance(config.project["name"], _NormalisedName):
1✔
470
                        config.project["name"] = config.project["name"].unnormalized
1✔
471

472
                return config.dump(filename, encoder=encoder)
1✔
473

474
        def resolve_files(self) -> None:
1✔
475
                """
476
                Resolve the ``file`` key in :pep621:`readme` and :pep621:`license`
477
                (if present) to retrieve the content of the file.
478

479
                Calling this method may mean it is no longer possible to recreate
480
                the original ``TOML`` file from this object.
481
                """  # noqa: D400
482

483
                if self.project is not None:
1✔
484
                        readme = self.project.get("readme", None)
1✔
485

486
                        if readme is not None and isinstance(readme, Readme):
1✔
487
                                readme.resolve(inplace=True)
1✔
488

489
                        lic = self.project.get("license", None)
1✔
490

491
                        if lic is not None and isinstance(lic, License):
1✔
492
                                lic.resolve(inplace=True)
1✔
493

494
        @classmethod
1✔
495
        def from_dict(cls: Type[_PP], d: Mapping[str, Any]) -> _PP:
1✔
496
                """
497
                Construct an instance of :class:`~.PyProject` from a dictionary.
498

499
                :param d: The dictionary.
500
                """
501

502
                kwargs = {}
1✔
503

504
                for key, value in d.items():
1✔
505
                        if key == "build-system":
1✔
506
                                key = "build_system"
1✔
507
                        elif key == "dependency-groups":
1✔
508
                                key = "dependency_groups"
×
509

510
                        kwargs[key] = value
1✔
511

512
                return cls(**kwargs)
1✔
513

514
        def to_dict(self) -> MutableMapping[str, Any]:
1✔
515
                """
516
                Returns a dictionary containing the contents of the class.
517

518
                .. seealso:: :func:`attr.asdict`
519
                """
520

521
                return {
1✔
522
                                "build_system": self.build_system,
523
                                "project": self.project,
524
                                "tool": self.tool,
525
                                "dependency_groups": self.dependency_groups,
526
                                }
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

© 2026 Coveralls, Inc