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

repo-helper / pyproject-parser / 14915590452

08 May 2025 08:32PM UTC coverage: 97.789% (-0.2%) from 97.95%
14915590452

push

github

web-flow
Updated files with 'repo_helper'. (#82)

Co-authored-by: repo-helper[bot] <74742576+repo-helper[bot]@users.noreply.github.com>

929 of 950 relevant lines covered (97.79%)

0.98 hits per line

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

95.43
/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.13.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):
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 name: The table name.
134
                :param inside_aot:
135

136
                :rtype:
137

138
                .. versionadded:: 0.11.0
139
                """
140

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

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

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

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

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

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

179
                :param obj:
180
                :param nest_level:
181

182
                :rtype:
183

184
                .. versionadded:: 0.11.0
185
                """
186

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

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

198
                :param obj:
199
                :param nest_level:
200

201
                :rtype:
202

203
                .. versionadded:: 0.11.0
204
                """
205

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

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

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

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

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

230
                :param obj:
231
                """
232

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

235

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

241
        :param build_system:
242

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

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

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

251
        .. latex:clearpage::
252

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

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

258
        """
259

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

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

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

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

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

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

283
        .. versionadded:: 0.13.0
284
        """
285

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

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

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

299
        .. code-block:: python
300

301
                class WheyParser(AbstractConfigParser):
302
                        pass
303

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

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

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

323
                filename = PathPlus(filename)
1✔
324

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

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

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

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

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

348
                        if "project" in config:
1✔
349
                                project_table = cls.project_table_parser.parse(config["project"], set_defaults=set_defaults)
1✔
350
                                keys.remove("project")
1✔
351

352
                        if "tool" in config:
1✔
353
                                tool_table = config["tool"]
1✔
354
                                keys.remove("tool")
1✔
355

356
                                for tool_name, tool_subtable in tool_table.items():
1✔
357
                                        if tool_name in cls.tool_parsers:
1✔
358
                                                tool_table[tool_name] = cls.tool_parsers[tool_name].parse(tool_subtable)
1✔
359

360
                if keys:
1✔
361
                        allowed_top_level = ("build-system", "dependency-groups", "project", "tool")
1✔
362

363
                        for top_level_key in sorted(keys):
1✔
364
                                if top_level_key in allowed_top_level:
1✔
365
                                        continue
×
366

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

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

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

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

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

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

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

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

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

414
                                _project: Dict[str, Any]
415

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

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

423
                return dom_toml.dumps(toml_dict, encoder)
1✔
424

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

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

436
                :returns: A string containing the TOML representation.
437
                """
438

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

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

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

456
                :returns: A string containing the reformatted TOML.
457

458
                .. versionchanged:: 0.2.0
459

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

464
                """
465

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

470
                return config.dump(filename, encoder=encoder)
1✔
471

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

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

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

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

487
                        lic = self.project.get("license", None)
1✔
488

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

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

497
                :param d: The dictionary.
498
                """
499

500
                kwargs = {}
1✔
501

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

508
                        kwargs[key] = value
1✔
509

510
                return cls(**kwargs)
1✔
511

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

516
                .. seealso:: :func:`attr.asdict`
517
                """
518

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