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

repo-helper / pyproject-parser / 17280459051

27 Aug 2025 10:38PM UTC coverage: 97.541%. First build
17280459051

push

github

domdfcoding
Add support for PEP 639 license expressions.

29 of 32 new or added lines in 3 files covered. (90.63%)

952 of 976 relevant lines covered (97.54%)

0.98 hits per line

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

98.47
/pyproject_parser/classes.py
1
#!/usr/bin/env python3
2
#
3
#  classes.py
4
"""
5
Classes to represent readme and license files.
6

7
.. automodulesumm:: pyproject_parser.classes
8
        :autosummary-sections: Classes
9

10
.. autosummary-widths:: 4/16
11

12
.. automodulesumm:: pyproject_parser.classes
13
        :autosummary-sections: Data
14
"""
15
#
16
#  Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
17
#
18
#  Permission is hereby granted, free of charge, to any person obtaining a copy
19
#  of this software and associated documentation files (the "Software"), to deal
20
#  in the Software without restriction, including without limitation the rights
21
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
22
#  copies of the Software, and to permit persons to whom the Software is
23
#  furnished to do so, subject to the following conditions:
24
#
25
#  The above copyright notice and this permission notice shall be included in all
26
#  copies or substantial portions of the Software.
27
#
28
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
29
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
30
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
31
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
32
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
33
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
34
#  OR OTHER DEALINGS IN THE SOFTWARE.
35
#
36

37
# stdlib
38
import pathlib
1✔
39
from contextlib import suppress
1✔
40
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Type, TypeVar
1✔
41

42
# 3rd party
43
import attr
1✔
44
from domdf_python_tools.paths import PathPlus
1✔
45
from domdf_python_tools.typing import PathLike
1✔
46

47
# this package
48
from pyproject_parser.utils import content_type_from_filename
1✔
49

50
if TYPE_CHECKING:
51
        # this package
52
        from pyproject_parser.type_hints import ContentTypes, ReadmeDict
53

54
__all__ = ["License", "Readme", "_R", "_L"]
1✔
55

56
_R = TypeVar("_R", bound="Readme")
1✔
57
_L = TypeVar("_L", bound="License")
1✔
58

59
# @overload
60
# def _convert_filename(filename: None) -> None: ...
61
#
62
#
63
# @overload
64
# def _convert_filename(filename: PathLike) -> pathlib.Path: ...
65

66

67
def _convert_filename(filename: Optional[PathLike]) -> Optional[pathlib.Path]:
1✔
68
        if filename is None:
1✔
69
                return filename
1✔
70
        return pathlib.Path(filename)
1✔
71

72

73
# TODO: overloads for __init__
74

75

76
@attr.s
1✔
77
class Readme:
1✔
78
        """
79
        Represents a readme in :pep:`621` configuration.
80
        """
81

82
        #: The content type of the readme.
83
        content_type: Optional["ContentTypes"] = attr.ib(default=None)
1✔
84

85
        #: The charset / encoding of the readme.
86
        charset: str = attr.ib(default="UTF-8")
1✔
87

88
        #: The path to the readme file.
89
        file: Optional[pathlib.Path] = attr.ib(default=None, converter=_convert_filename)
1✔
90

91
        #: The content of the readme.
92
        text: Optional[str] = attr.ib(default=None)
1✔
93

94
        def __attrs_post_init__(self) -> None:
1✔
95
                # Sanity checks the supplied arguments
96

97
                if self.content_type and not (self.text or self.file):
1✔
98
                        raise ValueError(
1✔
99
                                        "'content_type' cannot be provided on its own; "
100
                                        "please provide either 'text' or 'file' or use the 'from_file' method."
101
                                        )
102

103
                if self.text is None and self.file is None:
1✔
104
                        raise TypeError(f"At least one of 'text' and 'file' must be supplied to {self.__class__!r}")
1✔
105

106
                if self.file is not None and self.content_type is None:
1✔
107
                        with suppress(ValueError):
1✔
108
                                self.content_type = content_type_from_filename(self.file)
1✔
109

110
        @content_type.validator
1✔
111
        def _check_content_type(self, attribute: Any, value: str) -> None:
1✔
112
                if value not in {"text/markdown", "text/x-rst", "text/plain", None}:
1✔
113
                        raise ValueError(f"Unsupported readme content-type {value!r}")
1✔
114

115
        @classmethod
1✔
116
        def from_file(cls: Type[_R], file: PathLike, charset: str = "UTF-8") -> _R:
1✔
117
                """
118
                Create a :class:`~.Readme` from a filename.
119

120
                :param file: The path to the readme file.
121
                :param charset: The charset / encoding of the readme file.
122
                """
123

124
                filename = PathPlus(file)
1✔
125

126
                if filename.suffix.lower() == ".md":
1✔
127
                        return cls(file=filename, charset=str(charset), content_type="text/markdown")
1✔
128
                elif filename.suffix.lower() == ".rst":
1✔
129
                        return cls(file=filename, charset=str(charset), content_type="text/x-rst")
1✔
130
                elif filename.suffix.lower() == ".txt":
1✔
131
                        return cls(file=filename, charset=str(charset), content_type="text/plain")
1✔
132
                else:
133
                        raise ValueError(f"Unrecognised filetype for '{filename!s}'")
1✔
134

135
        def resolve(self: _R, inplace: bool = False) -> _R:
1✔
136
                """
137
                Retrieve the contents of the readme file if the :attr:`self.file <.Readme.file>` is set.
138

139
                Returns a new :class:`~.Readme` object with :attr:`~.Readme.text` set to the content of the file.
140

141
                :param inplace: Modifies and returns the current object rather than creating a new one.
142
                """
143

144
                text = self.text
1✔
145
                if text is None and self.file:
1✔
146
                        text = self.file.read_text(encoding=self.charset)
1✔
147

148
                if inplace:
1✔
149
                        self.text = text
1✔
150
                        return self
1✔
151

152
                else:
153
                        return self.__class__(
1✔
154
                                        content_type=self.content_type,
155
                                        charset=self.charset,
156
                                        file=self.file,
157
                                        text=text,
158
                                        )
159

160
        def to_dict(self) -> "ReadmeDict":
1✔
161
                """
162
                Construct a dictionary containing the keys of the :class:`~.Readme` object.
163

164
                .. seealso:: :meth:`~.Readme.to_pep621_dict` and :meth:`~.Readme.from_dict`
165
                """
166

167
                as_dict: "ReadmeDict" = {}
1✔
168

169
                if self.content_type is not None:
1✔
170
                        as_dict["content_type"] = self.content_type
1✔
171

172
                if self.charset != "UTF-8":
1✔
173
                        as_dict["charset"] = self.charset
1✔
174

175
                if self.file is not None:
1✔
176
                        as_dict["file"] = self.file.as_posix()
1✔
177

178
                if self.text is not None:
1✔
179
                        as_dict["text"] = self.text
1✔
180

181
                return as_dict
1✔
182

183
        @classmethod
1✔
184
        def from_dict(cls: Type[_R], data: "ReadmeDict") -> _R:
1✔
185
                """
186
                Construct a :class:`~.Readme` from a dictionary containing the same keys as the class constructor.
187

188
                In addition, ``content_type`` may instead be given as ``content-type``.
189

190
                :param data:
191

192
                :rtype:
193

194
                .. seealso:: :meth:`~.Readme.to_dict` and :meth:`~.Readme.to_pep621_dict`
195
                """
196

197
                data_dict = dict(data)
1✔
198
                if "content-type" in data_dict:
1✔
199
                        data_dict["content_type"] = data_dict.pop("content-type")
1✔
200

201
                return cls(**data_dict)  # type: ignore[arg-type]
1✔
202

203
        def to_pep621_dict(self) -> Dict[str, str]:
1✔
204
                """
205
                Construct a dictionary containing the keys of the :class:`~.Readme` object,
206
                suitable for use in :pep:`621` ``pyproject.toml`` configuration.
207

208
                Unlike :meth:`~.Readme.to_dict` this ignores the ``text`` key if  :attr:`self.file <.Readme.file>` is set,
209
                and ignores :attr:`self.content_type <.Readme.content_type>` if it matches the content-type inferred
210
                from the file extension.
211

212
                .. seealso:: :meth:`~.Readme.from_dict`
213
                """  # noqa: D400
214

215
                as_dict = {}
1✔
216

217
                if self.content_type is not None:
1✔
218
                        as_dict["content-type"] = str(self.content_type)
1✔
219

220
                if self.charset != "UTF-8":
1✔
221
                        as_dict["charset"] = self.charset
1✔
222

223
                if self.file is not None:
1✔
224
                        as_dict["file"] = self.file.as_posix()
1✔
225

226
                        if content_type_from_filename(self.file) == self.content_type:
1✔
227
                                as_dict.pop("content-type")
1✔
228

229
                elif self.text is not None:
1✔
230
                        as_dict["text"] = self.text
1✔
231

232
                return as_dict
1✔
233

234

235
@attr.s
1✔
236
class License:
1✔
237
        """
238
        Represents a license in :pep:`621` configuration.
239

240
        :param file:
241

242
        .. latex:vspace:: 20px
243

244
        .. autosummary-widths:: 6/16
245

246
        .. versionchanged:: 0.14.0  Add ``expression`` option.
247
        """
248

249
        #: The path to the license file.
250
        file: Optional[pathlib.Path] = attr.ib(default=None, converter=_convert_filename)
1✔
251

252
        #: The content of the license.
253
        text: Optional[str] = attr.ib(default=None)
1✔
254

255
        #: An SPDX License Expression (for :pep:`639`).
256
        expression: Optional[str] = attr.ib(default=None)
1✔
257

258
        def __attrs_post_init__(self) -> None:
1✔
259
                # Sanity checks the supplied arguments
260
                if self.expression:
1✔
261
                        if self.text is not None or self.file is not None:
1✔
NEW
262
                                raise TypeError(
×
263
                                                f"Cannot supply a licence expression alongside 'text' and/or 'file' to {self.__class__!r}"
264
                                                )
265
                else:
266
                        if self.text is None and self.file is None:
1✔
267
                                raise TypeError(f"At least one of 'text' and 'file' must be supplied to {self.__class__!r}")
1✔
268

269
                # if self.text is not None and self.file is not None:
270
                #         raise TypeError("'text' and 'filename' are mutually exclusive.")
271

272
        def resolve(self: _L, inplace: bool = False) -> _L:
1✔
273
                """
274
                Retrieve the contents of the license file if the :attr:`~.License.file` is set.
275

276
                Returns a new :class:`~.License` object with :attr:`~.License.text` set to the content of the file.
277

278
                :param inplace: Modifies and returns the current object rather than creating a new one.
279
                """
280

281
                text = self.text
1✔
282
                if text is None and self.file:
1✔
283
                        text = self.file.read_text(encoding="UTF-8")
1✔
284

285
                if inplace:
1✔
286
                        self.text = text
1✔
287
                        return self
1✔
288

289
                else:
290
                        return self.__class__(
1✔
291
                                        file=self.file,
292
                                        text=text,
293
                                        )
294

295
        def to_dict(self) -> Dict[str, str]:
1✔
296
                """
297
                Construct a dictionary containing the keys of the :class:`~.License` object.
298

299
                .. seealso:: :meth:`~.License.to_pep621_dict` and :meth:`~.License.from_dict`
300
                """
301

302
                as_dict = {}
1✔
303

304
                if self.file is not None:
1✔
305
                        as_dict["file"] = self.file.as_posix()
1✔
306
                if self.text is not None:
1✔
307
                        as_dict["text"] = self.text
1✔
308
                if self.expression is not None:
1✔
309
                        as_dict["expression"] = self.expression
1✔
310

311
                return as_dict
1✔
312

313
        @classmethod
1✔
314
        def from_dict(cls: Type[_L], data: Mapping[str, str]) -> _L:
1✔
315
                """
316
                Construct a :class:`~.License` from a dictionary containing the same keys as the class constructor.
317

318
                Functionally identical to ``License(**data)``
319
                but provided to give an identical API to :class:`~.Readme`.
320

321
                :param data:
322

323
                :rtype:
324

325
                .. seealso:: :meth:`~.License.to_dict` and :meth:`~.License.to_pep621_dict`
326
                """
327

328
                return cls(**data)
1✔
329

330
        def to_pep621_dict(self) -> Dict[str, str]:
1✔
331
                """
332
                Construct a dictionary containing the keys of the :class:`~.License` object,
333
                suitable for use in :pep:`621` ``pyproject.toml`` configuration.
334

335
                Unlike :meth:`~.License.to_dict` this ignores the ``text`` key if :attr:`self.file <.License.file>` is set.
336

337
                :rtype:
338

339
                .. seealso:: :meth:`~.Readme.from_dict`
340
                .. latex:clearpage::
341
                """  # noqa: D400
342

343
                as_dict = self.to_dict()
1✔
344
                if "file" in as_dict and "text" in as_dict:
1✔
345
                        as_dict.pop("text")
1✔
346

347
                return as_dict
1✔
348

349

350
class _NormalisedName(str):
1✔
351
        """
352
        Represents a name normalized per :pep:`503`,
353
        and allows the original name to be stored as an attribute.
354
        """  # noqa: D400
355

356
        __slots__ = ("_unnormalized", )
1✔
357

358
        _unnormalized: Optional[str]
1✔
359

360
        def __new__(cls, o, **kwargs):  # noqa: MAN001
1✔
361
                self = super().__new__(cls, o, **kwargs)
1✔
362
                self._unnormalized = None
1✔
363
                return self
1✔
364

365
        @property
1✔
366
        def unnormalized(self) -> str:
1✔
367
                if self._unnormalized is None:
1✔
368
                        return str(self)
×
369
                else:
370
                        return self._unnormalized
1✔
371

372
        @unnormalized.setter
1✔
373
        def unnormalized(self, value: str) -> None:
1✔
374
                self._unnormalized = str(value)
1✔
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