• 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

97.81
/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, Union
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
                        msg = "'content_type' cannot be provided on its own; please provide either 'text' or 'file' or use the 'from_file' method."
1✔
99
                        raise ValueError(msg)
1✔
100

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

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

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

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

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

122
                filename = PathPlus(file)
1✔
123

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

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

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

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

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

146
                if inplace:
1✔
147
                        self.text = text
1✔
148
                        return self
1✔
149

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

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

162
                .. seealso:: :meth:`~.Readme.to_pep621_dict` and :meth:`~.Readme.from_dict`
163
                """
164

165
                as_dict: "ReadmeDict" = {}
1✔
166

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

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

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

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

179
                return as_dict
1✔
180

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

186
                In addition, ``content_type`` may instead be given as ``content-type``.
187

188
                :param data:
189

190
                :rtype:
191

192
                .. seealso:: :meth:`~.Readme.to_dict` and :meth:`~.Readme.to_pep621_dict`
193
                """
194

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

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

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

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

210
                .. seealso:: :meth:`~.Readme.from_dict`
211
                """  # noqa: D400
212

213
                as_dict = {}
1✔
214

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

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

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

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

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

230
                return as_dict
1✔
231

232

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

238
        :param file:
239

240
        .. latex:vspace:: 20px
241

242
        .. autosummary-widths:: 6/16
243

244
        .. versionchanged:: 0.14.0  Add ``expression`` option.
245
        """
246

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

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

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

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

266
                # if self.text is not None and self.file is not None:
267
                #         raise TypeError("'text' and 'filename' are mutually exclusive.")
268

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

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

275
                :param inplace: Modifies and returns the current object rather than creating a new one.
276
                """
277

278
                text = self.text
1✔
279
                if text is None and self.file:
1✔
280
                        text = self.file.read_text(encoding="UTF-8")
1✔
281

282
                if inplace:
1✔
283
                        self.text = text
1✔
284
                        return self
1✔
285

286
                else:
287
                        return self.__class__(
1✔
288
                                        file=self.file,
289
                                        text=text,
290
                                        )
291

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

296
                :rtype:
297

298
                .. seealso:: :meth:`~.License.to_pep621_dict` and :meth:`~.License.from_dict`
299
                .. latex:clearpage::
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
                """  # noqa: D400
341

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

346
                return as_dict
1✔
347

348
        def to_pep639(self) -> Union[Dict[str, str], str]:
1✔
349
                """
350
                Construct a dictionary or string representing the :class:`~.License` object,
351
                suitable for use in :pep:`639`-compatible ``pyproject.toml`` configuration.
352

353
                :rtype:
354

355
                .. versionadded:: 0.14.0
356
                .. seealso:: :meth:`~.Readme.from_dict`, :meth:`~.License.to_pep621_dict`
357
                .. latex:clearpage::
358
                """  # noqa: D400
359

360
                if self.expression is not None:
1✔
361
                        return self.expression
1✔
362

363
                return self.to_pep621_dict()
1✔
364

365

366
class _NormalisedName(str):
1✔
367
        """
368
        Represents a name normalized per :pep:`503`,
369
        and allows the original name to be stored as an attribute.
370
        """  # noqa: D400
371

372
        __slots__ = ("_unnormalized", )
1✔
373

374
        _unnormalized: Optional[str]
1✔
375

376
        def __new__(cls, o, **kwargs):  # noqa: MAN001
1✔
377
                self = super().__new__(cls, o, **kwargs)
1✔
378
                self._unnormalized = None
1✔
379
                return self
1✔
380

381
        @property
1✔
382
        def unnormalized(self) -> str:
1✔
383
                if self._unnormalized is None:
1✔
384
                        return str(self)
×
385
                else:
386
                        return self._unnormalized
1✔
387

388
        @unnormalized.setter
1✔
389
        def unnormalized(self, value: str) -> None:
1✔
390
                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

© 2026 Coveralls, Inc