Coverage for pyTooling/Packaging/__init__.py: 75%

301 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ __ _ ___| | ____ _ __ _(_)_ __ __ _ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_) / _` |/ __| |/ / _` |/ _` | | '_ \ / _` | # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| __/ (_| | (__| < (_| | (_| | | | | | (_| | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| \__,_|\___|_|\_\__,_|\__, |_|_| |_|\__, | # 

7# |_| |___/ |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2021-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32A set of helper functions to describe a Python package for setuptools. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <PACKAGING>` for explanations and usage examples. 

37 

38.. seealso:: 

39 

40 :mod:`pyTooling.Versioning` 

41 |rarr| The version numbers read from a package's dunder variables. 

42 :mod:`pyTooling.Licensing` 

43 |rarr| The license names translated for setuptools. 

44 :mod:`pyTooling.Testing` 

45 |rarr| Testing the console scripts a package installs. 

46""" 

47from ast import parse as ast_parse, iter_child_nodes, Assign, Constant, Name, List as ast_List 

48from collections.abc import Sized 

49from os import scandir as os_scandir 

50from pathlib import Path 

51from re import split as re_split 

52from sys import version_info 

53from typing import Iterable, Sequence, Any, Optional as Nullable, Union 

54from pyTooling.Decorators import export, readonly 

55from pyTooling.Exceptions import ToolingException, MissingDependencyException 

56from pyTooling.MetaClasses import ExtendedType 

57from pyTooling.Common import __version__, getFullyQualifiedName, firstElement 

58from pyTooling.Licensing import License, Apache_2_0_License 

59 

60 

61__all__ = [ 

62 "STATUS", "DEFAULT_LICENSE", "DEFAULT_PY_VERSIONS", "DEFAULT_CLASSIFIERS", "DEFAULT_README", "DEFAULT_REQUIREMENTS", 

63 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS", 

64 "DEFAULT_VERSION_FILE" 

65] 

66 

67 

68@export 

69class Readme: 

70 """Encapsulates the READMEs file content and MIME type.""" 

71 

72 _content: str #: Content of the README file 

73 _mimeType: str #: MIME type of the README content 

74 

75 def __init__(self, content: str, mimeType: str) -> None: 

76 """ 

77 Initializes a README file wrapper. 

78 

79 :param content: Raw content of the README file. 

80 :param mimeType: MIME type of the README file. 

81 """ 

82 self._content = content 

83 self._mimeType = mimeType 

84 

85 @readonly 

86 def Content(self) -> str: 

87 """ 

88 Read-only property to access the README's content. 

89 

90 :returns: Raw content of the README file. 

91 """ 

92 return self._content 

93 

94 @readonly 

95 def MimeType(self) -> str: 

96 """ 

97 Read-only property to access the README's MIME type. 

98 

99 :returns: The MIME type of the README file. 

100 """ 

101 return self._mimeType 

102 

103 

104@export 

105def loadReadmeFile(readmeFile: Path) -> Readme: 

106 """ 

107 Read the README file (e.g. in Markdown format), so it can be used as long description for the package. 

108 

109 Supported formats: 

110 

111 * Plain text (``*.txt``) 

112 * Markdown (``*.md``) 

113 * ReStructured Text (``*.rst``) 

114 

115 :param readmeFile: Optional, path to the `README` file as an instance of :class:`Path`. 

116 :returns: A tuple containing the file content and the MIME type. 

117 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`. 

118 :raises ValueError: If README file has an unsupported format. 

119 :raises FileNotFoundError: If README file does not exist. 

120 """ 

121 if not isinstance(readmeFile, Path): 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.") 

123 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.") 

124 raise ex 

125 

126 if readmeFile.suffix == ".txt": 

127 mimeType = "text/plain" 

128 elif readmeFile.suffix == ".md": 

129 mimeType = "text/markdown" 

130 elif readmeFile.suffix == ".rst": 

131 mimeType = "text/x-rst" 

132 else: # pragma: no cover 

133 raise ValueError("Unsupported README format.") 

134 

135 try: 

136 with readmeFile.open("r", encoding="utf-8") as file: 

137 return Readme( 

138 content=file.read(), 

139 mimeType=mimeType 

140 ) 

141 except FileNotFoundError as ex: 

142 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.") from ex 

143 

144 

145@export 

146def loadRequirementsFile(requirementsFile: Path, indent: int = 0, debug: bool = False) -> list[str]: 

147 """ 

148 Reads a `requirements.txt` file (recursively) and extracts all specified dependencies into an array. 

149 

150 Special dependency entries like Git repository references are translates to match the syntax expected by setuptools. 

151 

152 .. hint:: 

153 

154 Duplicates should be removed by converting the result to a :class:`set` and back to a :class:`list`. 

155 

156 .. code-block:: Python 

157 

158 requirements = list(set(loadRequirementsFile(requirementsFile))) 

159 

160 :param requirementsFile: Optional, path to the ``requirements.txt`` file as an instance of :class:`Path`. 

161 :param indent: Optional, indentation level used for the debug output of nested requirements files. 

162 :param debug: Optional, if ``True``, print found dependencies and recursion. 

163 :returns: A list of dependencies. 

164 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`. 

165 :raises FileNotFoundError: If requirements file does not exist. 

166 """ 

167 if not isinstance(requirementsFile, Path): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 ex = TypeError(f"Parameter '{requirementsFile}' is not of type 'Path'.") 

169 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.") 

170 raise ex 

171 

172 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> list[str]: 

173 """ 

174 Recursive variant of :func:`loadRequirementsFile`. 

175 

176 :param requirementsFile: Optional, path to the requirements file to read. 

177 :param indent: Optional, indentation level used for the debug output of nested requirements files. 

178 :returns: List of requirements read from that file and every file it includes. 

179 :raises FileNotFoundError: If the requirements file doesn't exist. 

180 """ 

181 requirements = [] 

182 try: 

183 with requirementsFile.open("r", encoding="utf-8") as file: 

184 if debug: 

185 print(f"[pyTooling.Packaging]{' ' * indent} Extracting requirements from '{requirementsFile}'.") 

186 for line in file.readlines(): 

187 line = line.strip() 

188 if line.startswith("#") or line == "": 

189 continue 

190 elif line.startswith("-r"): 

191 # Remove the first word/argument (-r) 

192 filename = line[2:].lstrip() 

193 requirements += _loadRequirementsFile(requirementsFile.parent / filename, indent + 1) 

194 elif line.startswith("https"): 

195 if debug: 

196 print(f"[pyTooling.Packaging]{' ' * indent} Found URL '{line}'.") 

197 

198 # Convert 'URL#NAME' to 'NAME @ URL' 

199 splitItems = line.split("#") 

200 requirements.append(f"{splitItems[1]} @ {splitItems[0]}") 

201 else: 

202 if debug: 

203 print(f"[pyTooling.Packaging]{' ' * indent} - {line}") 

204 

205 requirements.append(line) 

206 except FileNotFoundError as ex: 

207 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex 

208 

209 return requirements 

210 

211 return _loadRequirementsFile(requirementsFile, 0) 

212 

213 

214@export 

215class VersionInformation(metaclass=ExtendedType, slots=True): 

216 """Encapsulates version information extracted from a Python source file.""" 

217 

218 _author: str #: Author name(s). 

219 _copyright: str #: Copyright information. 

220 _email: str #: Author's email address. 

221 _keywords: list[str] #: Keywords. 

222 _license: str #: License name. 

223 _description: str #: Description of the package. 

224 _version: str #: Version number. 

225 

226 def __init__( 

227 self, 

228 author: str, 

229 email: str, 

230 copyright: str, 

231 license: str, 

232 version: str, 

233 description: str, 

234 keywords: Iterable[str] 

235 ) -> None: 

236 """ 

237 Initializes a Python package (version) information instance. 

238 

239 :param author: Author of the Python package. 

240 :param email: The author's email address 

241 :param copyright: The copyright notice of the Package. 

242 :param license: Optional, the Python package's license. 

243 :param version: The Python package's version. 

244 :param description: The Python package's short description. 

245 :param keywords: Optional, the Python package's list of keywords. 

246 """ 

247 self._author = author 

248 self._email = email 

249 self._copyright = copyright 

250 self._license = license 

251 self._version = version 

252 self._description = description 

253 self._keywords = [k for k in keywords] 

254 

255 @readonly 

256 def Author(self) -> str: 

257 """ 

258 Read-only property to access the name(s) of the package author(s) (:attr:`_author`). 

259 

260 :returns: Name(s) of the package author(s). 

261 """ 

262 return self._author 

263 

264 @readonly 

265 def Copyright(self) -> str: 

266 """ 

267 Read-only property to access the package's copyright information (:attr:`_copyright`). 

268 

269 :returns: Copyright information. 

270 """ 

271 return self._copyright 

272 

273 @readonly 

274 def Description(self) -> str: 

275 """ 

276 Read-only property to access the package description (:attr:`_description`). 

277 

278 :returns: Package description text. 

279 """ 

280 return self._description 

281 

282 @readonly 

283 def Email(self) -> str: 

284 """ 

285 Read-only property to access the author's email address (:attr:`_email`). 

286 

287 :returns: Email address of the author. 

288 """ 

289 return self._email 

290 

291 @readonly 

292 def Keywords(self) -> list[str]: 

293 """ 

294 Read-only property to access the package's keywords (:attr:`_keywords`). 

295 

296 :returns: List of keywords. 

297 """ 

298 return self._keywords 

299 

300 @readonly 

301 def License(self) -> str: 

302 """ 

303 Read-only property to access the package's license (:attr:`_license`). 

304 

305 :returns: License name. 

306 """ 

307 return self._license 

308 

309 @readonly 

310 def Version(self) -> str: 

311 """ 

312 Read-only property to access the package's version number (:attr:`_version`). 

313 

314 :returns: Version number. 

315 """ 

316 return self._version 

317 

318 def __str__(self) -> str: 

319 """ 

320 Return a string representation of this version information. 

321 

322 :returns: The version number. 

323 """ 

324 return f"{self._version}" 

325 

326 

327@export 

328def extractVersionInformation(sourceFile: Path) -> VersionInformation: 

329 """ 

330 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information. 

331 

332 Supported variables: 

333 

334 * ``__author__`` 

335 * ``__copyright__`` 

336 * ``__email__`` 

337 * ``__keywords__`` 

338 * ``__license__`` 

339 * ``__version__`` 

340 

341 :param sourceFile: Path to a Python source file as an instance of :class:`Path`. 

342 :returns: An instance of :class:`VersionInformation` with gathered variable contents. 

343 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`. 

344 :raises FileNotFoundError: If the given file doesn't exist. 

345 :raises AssertionError: If a dunder variable is missing in the given file. 

346 :raises ToolingException: If a dunder variable has an unexpected format. 

347 """ 

348 if not isinstance(sourceFile, Path): 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true

349 ex = TypeError(f"Parameter 'sourceFile' is not of type 'Path'.") 

350 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.") 

351 raise ex 

352 

353 _author = None 

354 _copyright = None 

355 _description = "" 

356 _email = None 

357 _keywords = [] 

358 _license = None 

359 _version = None 

360 

361 try: 

362 with sourceFile.open("r", encoding="utf-8") as file: 

363 content = file.read() 

364 except FileNotFoundError as ex: 

365 raise FileNotFoundError 

366 

367 try: 

368 ast = ast_parse(content) 

369 except Exception as ex: # pragma: no cover 

370 raise ToolingException(f"Internal error when parsing '{sourceFile}'.") from ex 

371 

372 for item in iter_child_nodes(ast): 

373 if isinstance(item, Assign) and len(item.targets) == 1: 

374 target = item.targets[0] 

375 value = item.value 

376 if isinstance(target, Name) and target.id == "__author__": 

377 if isinstance(value, Constant) and isinstance(value.value, str): 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was always true

378 _author = value.value 

379 if isinstance(target, Name) and target.id == "__copyright__": 

380 if isinstance(value, Constant) and isinstance(value.value, str): 380 ↛ 382line 380 didn't jump to line 382 because the condition on line 380 was always true

381 _copyright = value.value 

382 if isinstance(target, Name) and target.id == "__email__": 

383 if isinstance(value, Constant) and isinstance(value.value, str): 383 ↛ 385line 383 didn't jump to line 385 because the condition on line 383 was always true

384 _email = value.value 

385 if isinstance(target, Name) and target.id == "__keywords__": 

386 if isinstance(value, Constant) and isinstance(value.value, str): # pragma: no cover 

387 raise TypeError(f"Variable '__keywords__' should be a list of strings.") 

388 elif isinstance(value, ast_List): 

389 for const in value.elts: 

390 if isinstance(const, Constant) and isinstance(const.value, str): 

391 _keywords.append(const.value) 

392 else: # pragma: no cover 

393 raise TypeError(f"List elements in '__keywords__' should be strings.") 

394 else: # pragma: no cover 

395 raise TypeError(f"Used unsupported type for variable '__keywords__'.") 

396 if isinstance(target, Name) and target.id == "__license__": 

397 if isinstance(value, Constant) and isinstance(value.value, str): 397 ↛ 399line 397 didn't jump to line 399 because the condition on line 397 was always true

398 _license = value.value 

399 if isinstance(target, Name) and target.id == "__version__": 

400 if isinstance(value, Constant) and isinstance(value.value, str): 400 ↛ 372line 400 didn't jump to line 372 because the condition on line 400 was always true

401 _version = value.value 

402 

403 if _author is None: 

404 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover 

405 if _copyright is None: 

406 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover 

407 if _email is None: 

408 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover 

409 if _license is None: 

410 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover 

411 if _version is None: 

412 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover 

413 

414 return VersionInformation(_author, _email, _copyright, _license, _version, _description, _keywords) 

415 

416 

417STATUS: dict[str, str] = { 

418 "planning": "1 - Planning", 

419 "pre-alpha": "2 - Pre-Alpha", 

420 "alpha": "3 - Alpha", 

421 "beta": "4 - Beta", 

422 "stable": "5 - Production/Stable", 

423 "mature": "6 - Mature", 

424 "inactive": "7 - Inactive" 

425} 

426""" 

427A dictionary of supported development status values. 

428 

429The mapping's value will be appended to ``Development Status :: `` to form a package classifier. 

430 

4311. Planning 

4322. Pre-Alpha 

4333. Alpha 

4344. Beta 

4355. Production/Stable 

4366. Mature 

4377. Inactive 

438 

439.. seealso:: 

440 

441 `Python package classifiers <https://pypi.org/classifiers/>`__ 

442""" 

443 

444DEFAULT_LICENSE = Apache_2_0_License 

445""" 

446Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

447if parameter ``license`` is not assigned. 

448""" 

449 

450DEFAULT_PY_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") 

451""" 

452A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

453if parameter ``pythonVersions`` is not assigned. 

454 

455.. seealso:: 

456 

457 `Status of Python versions <https://devguide.python.org/versions/>`__ 

458""" 

459 

460DEFAULT_CLASSIFIERS = ( 

461 "Operating System :: OS Independent", 

462 "Intended Audience :: Developers", 

463 "Topic :: Utilities" 

464 ) 

465""" 

466A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

467if parameter ``classifiers`` is not assigned. 

468 

469.. seealso:: 

470 

471 `Python package classifiers <https://pypi.org/classifiers/>`__ 

472""" 

473 

474DEFAULT_README = Path("README.md") 

475""" 

476Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

477if parameter ``readmeFile`` is not assigned. 

478""" 

479 

480DEFAULT_REQUIREMENTS = Path("requirements.txt") 

481""" 

482Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

483if parameter ``requirementsFile`` is not assigned. 

484""" 

485 

486DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt") 

487""" 

488Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

489if parameter ``documentationRequirementsFile`` is not assigned. 

490""" 

491 

492DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt") 

493""" 

494Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

495if parameter ``unittestRequirementsFile`` is not assigned. 

496""" 

497 

498DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt") 

499""" 

500Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub` 

501if parameter ``packagingRequirementsFile`` is not assigned. 

502""" 

503 

504DEFAULT_VERSION_FILE = Path("__init__.py") 

505 

506 

507@export 

508def DescribePythonPackage( 

509 packageName: str, 

510 description: str, 

511 projectURL: str, 

512 sourceCodeURL: str, 

513 documentationURL: str, 

514 issueTrackerCodeURL: str, 

515 keywords: Iterable[str] = None, 

516 license: License = DEFAULT_LICENSE, 

517 readmeFile: Path = DEFAULT_README, 

518 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

519 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

520 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

521 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

522 additionalRequirements: dict[str, list[str]] = None, 

523 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

524 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

525 developmentStatus: str = "stable", 

526 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

527 consoleScripts: dict[str, str] = None, 

528 dataFiles: dict[str, list[str]] = None, 

529 debug: bool = False 

530) -> dict[str, Any]: 

531 """ 

532 Helper function to describe a Python package. 

533 

534 .. hint:: 

535 

536 Some information will be gathered automatically from well-known files. 

537 

538 Examples: ``README.md``, ``requirements.txt``, ``__init__.py`` 

539 

540 .. topic:: Handling of namespace packages 

541 

542 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then 

543 :func:`setuptools.find_namespace_packages` is used to discover package files. |br| 

544 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used. 

545 

546 In both cases, the following packages (directories) are excluded from search: 

547 

548 * ``build``, ``build.*`` 

549 * ``dist``, ``dist.*`` 

550 * ``doc``, ``doc.*`` 

551 * ``tests``, ``tests.*`` 

552 

553 .. topic:: Handling of minimal Python version 

554 

555 The minimal required Python version is selected from parameter ``pythonVersions``. 

556 

557 .. topic:: Handling of dunder variables 

558 

559 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the 

560 resulting AST will be searched for the following dunder variables: 

561 

562 * ``__author__``: :class:`str` 

563 * ``__copyright__``: :class:`str` 

564 * ``__email__``: :class:`str` 

565 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`] 

566 * ``__license__``: :class:`str` 

567 * ``__version__``: :class:`str` 

568 

569 The gathered information be used to add further mappings in the result dictionary. 

570 

571 .. topic:: Handling of package classifiers 

572 

573 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional 

574 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly 

575 converted to package classifiers, so no need to specify them in parameter ``classifiers``. 

576 

577 The following classifiers are implicitly handled: 

578 

579 license 

580 The license specified by parameter ``license`` is translated into a classifier. |br| 

581 See also :meth:`pyTooling.Licensing.License.PythonClassifier` 

582 

583 Python versions 

584 Always add ``Programming Language :: Python :: 3 :: Only``. |br| 

585 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added. 

586 

587 Development status 

588 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added. 

589 

590 .. topic:: Handling of extra requirements 

591 

592 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra* 

593 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called 

594 ``all`` is added, so developers can install all dependencies needed for package development. 

595 

596 ``doc`` 

597 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined. 

598 ``test`` 

599 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined. 

600 ``build`` 

601 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined. 

602 User-defined 

603 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the 

604 dictionary will be added. 

605 ``all`` 

606 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all 

607 extra requirements. 

608 

609 .. topic:: Handling of keywords 

610 

611 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion`` 

612 will be used. Otherwise, the content of the parameter, if not None or empty. 

613 

614 :param packageName: Name of the Python package. 

615 :param description: Short description of the package. The long description will be read from README file. 

616 :param projectURL: Optional, URL to the Python project. 

617 :param sourceCodeURL: URL to the Python source code. 

618 :param documentationURL: URL to the package's documentation. 

619 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system). 

620 :param keywords: Optional, a list of keywords. 

621 :param license: Optional, the package's license. (Default: ``Apache License, 2.0``, see 

622 :const:`DEFAULT_LICENSE`) 

623 :param readmeFile: Optional, the path to the README file. (Default: ``README.md``, see 

624 :const:`DEFAULT_README`) 

625 :param requirementsFile: Optional, the path to the project's requirements file. (Default: 

626 ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`) 

627 :param documentationRequirementsFile: Optional, the path to the project's requirements file for documentation. 

628 (Default: ``doc/requirements.txt``, see 

629 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`) 

630 :param unittestRequirementsFile: Optional, the path to the project's requirements file for unit tests. (Default: 

631 ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`) 

632 :param packagingRequirementsFile: Optional, the path to the project's requirements file for packaging. (Default: 

633 ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`) 

634 :param additionalRequirements: Optional, a dictionary of a lists with additional requirements. (default: None) 

635 :param sourceFileWithVersion: Optional, the path to the project's source file containing dunder variables like 

636 ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`) 

637 :param classifiers: Optional, a list of package classifiers. (Default: 3 classifiers, see 

638 :const:`DEFAULT_CLASSIFIERS`) 

639 :param developmentStatus: Optional, development status of the package. (Default: stable, see 

640 :const:`STATUS` for supported status values) 

641 :param pythonVersions: Optional, a list of supported Python 3 version. (Default: all currently 

642 maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`) 

643 :param consoleScripts: Optional, a dictionary mapping command line names to entry points. (Default: 

644 None) 

645 :param dataFiles: Optional, a dictionary mapping package names to lists of additional data files. 

646 :param debug: Optional, if ``True``, enable extended outputs for debugging. 

647 :returns: A dictionary suitable for :func:`setuptools.setup`. 

648 :raises MissingDependencyException: If package 'setuptools' is not available. 

649 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`. 

650 :raises FileNotFoundError: If README file doesn't exist. 

651 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`. 

652 :raises FileNotFoundError: If requirements file doesn't exist. 

653 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`. 

654 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`. 

655 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`. 

656 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`. 

657 :raises FileNotFoundError: If package file with dunder variables doesn't exist. 

658 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`. 

659 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`) 

660 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`) 

661 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`) 

662 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`) 

663 :raises Exception: If the package's directory doesn't exist, or if a requirements file is 

664 malformed. 

665 """ 

666 try: 

667 from setuptools import find_packages, find_namespace_packages 

668 except ImportError as ex: 

669 raise MissingDependencyException(dependency="setuptools", extra="packaging") from ex 

670 

671 print(f"[pyTooling.Packaging] Python: {version_info.major}.{version_info.minor}.{version_info.micro}, pyTooling: {__version__}") 

672 

673 # Read README for upload to PyPI 

674 if not isinstance(readmeFile, Path): 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true

675 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.") 

676 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.") 

677 raise ex 

678 elif not readmeFile.exists(): 678 ↛ 679line 678 didn't jump to line 679 because the condition on line 678 was never true

679 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.") 

680 else: 

681 readme = loadReadmeFile(readmeFile) 

682 

683 # Read requirements file and add them to package dependency list (remove duplicates) 

684 if not isinstance(requirementsFile, Path): 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true

685 ex = TypeError(f"Parameter 'requirementsFile' is not of type 'Path'.") 

686 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.") 

687 raise ex 

688 elif not requirementsFile.exists(): 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true

689 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") 

690 else: 

691 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug))) 

692 

693 extraRequirements: dict[str, list[str]] = {} 

694 if documentationRequirementsFile is not None: 694 ↛ 707line 694 didn't jump to line 707 because the condition on line 694 was always true

695 if not isinstance(documentationRequirementsFile, Path): 695 ↛ 696line 695 didn't jump to line 696 because the condition on line 695 was never true

696 ex = TypeError(f"Parameter 'documentationRequirementsFile' is not of type 'Path'.") 

697 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.") 

698 raise ex 

699 elif not documentationRequirementsFile.exists(): 699 ↛ 700line 699 didn't jump to line 700 because the condition on line 699 was never true

700 if debug: 

701 print(f"[pyTooling.Packaging] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.") 

702 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.") 

703 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.") 

704 else: 

705 extraRequirements["doc"] = list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug))) 

706 

707 if unittestRequirementsFile is not None: 707 ↛ 720line 707 didn't jump to line 720 because the condition on line 707 was always true

708 if not isinstance(unittestRequirementsFile, Path): 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true

709 ex = TypeError(f"Parameter 'unittestRequirementsFile' is not of type 'Path'.") 

710 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.") 

711 raise ex 

712 elif not unittestRequirementsFile.exists(): 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true

713 if debug: 

714 print(f"[pyTooling.Packaging] Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.") 

715 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.") 

716 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.") 

717 else: 

718 extraRequirements["test"] = list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug))) 

719 

720 if packagingRequirementsFile is not None: 720 ↛ 733line 720 didn't jump to line 733 because the condition on line 720 was always true

721 if not isinstance(packagingRequirementsFile, Path): 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true

722 ex = TypeError(f"Parameter 'packagingRequirementsFile' is not of type 'Path'.") 

723 ex.add_note(f"Got type '{getFullyQualifiedName(packagingRequirementsFile)}'.") 

724 raise ex 

725 elif not packagingRequirementsFile.exists(): 

726 if debug: 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true

727 print(f"[pyTooling.Packaging] Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.") 

728 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.") 

729 # raise FileNotFoundError(f"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.") 

730 else: 

731 extraRequirements["build"] = list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug))) 

732 

733 if additionalRequirements is not None: 

734 for key, value in additionalRequirements.items(): 

735 extraRequirements[key] = value 

736 

737 if len(extraRequirements) > 0: 737 ↛ 741line 737 didn't jump to line 741 because the condition on line 737 was always true

738 extraRequirements["all"] = list(set([dep for deps in extraRequirements.values() for dep in deps])) 

739 

740 # Read __author__, __email__, __version__ from source file 

741 if not isinstance(sourceFileWithVersion, Path): 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true

742 ex = TypeError(f"Parameter 'sourceFileWithVersion' is not of type 'Path'.") 

743 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.") 

744 raise ex 

745 elif not sourceFileWithVersion.exists(): 745 ↛ 746line 745 didn't jump to line 746 because the condition on line 745 was never true

746 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.") 

747 else: 

748 versionInformation = extractVersionInformation(sourceFileWithVersion) 

749 

750 # Scan for packages and source files 

751 if debug: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true

752 print(f"[pyTooling.Packaging] Exclude list for find_(namespace_)packages:") 

753 exclude = [] 

754 rootNamespace = firstElement(packageName.split(".")) 

755 for dirName in (dirItem.name for dirItem in os_scandir(Path.cwd()) if dirItem.is_dir() and "." not in dirItem.name and dirItem.name != rootNamespace): 

756 exclude.append(f"{dirName}") 

757 exclude.append(f"{dirName}.*") 

758 if debug: 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true

759 print(f"[pyTooling.Packaging] - {dirName}, {dirName}.*") 

760 

761 if "." in packageName: 

762 exclude.append(rootNamespace) 

763 packages = find_namespace_packages(exclude=exclude) 

764 if packageName.endswith(".*"): 764 ↛ 765line 764 didn't jump to line 765 because the condition on line 764 was never true

765 packageName = packageName[:-2] 

766 else: 

767 packages = find_packages(exclude=exclude) 

768 

769 if debug: 769 ↛ 770line 769 didn't jump to line 770 because the condition on line 769 was never true

770 print(f"[pyTooling.Packaging] Found packages: ({getFullyQualifiedName(packages)})") 

771 for package in packages: 

772 print(f"[pyTooling.Packaging] - {package}") 

773 

774 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0: 

775 keywords = versionInformation.Keywords 

776 

777 # Assemble classifiers 

778 classifiers = list(classifiers) 

779 

780 # Translate license to classifier 

781 if not isinstance(license, License): 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true

782 ex = TypeError(f"Parameter 'license' is not of type 'License'.") 

783 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.") 

784 raise ex 

785 classifiers.append(license.PythonClassifier) 

786 

787 def _naturalSorting(array: Iterable[str]) -> list[str]: 

788 """ 

789 A simple natural sorting implementation. 

790 

791 :param array: The strings to sort. 

792 :returns: The strings, sorted with embedded numbers compared numerically. 

793 """ 

794 # See http://nedbatchelder.com/blog/200712/human_sorting.html 

795 def _toInt(text: str) -> Union[str, int]: 

796 """ 

797 Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string. 

798 

799 :param text: The text to convert. 

800 :returns: The converted integer, or the unchanged string. 

801 """ 

802 return int(text) if text.isdigit() else text 

803 

804 def _createKey(text: str) -> tuple[Union[str, float], ...]: 

805 """ 

806 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by 

807 their value. 

808 

809 :param text: The text to split. 

810 :returns: Tuple of string and integer fields, usable as a sort key. 

811 """ 

812 return tuple(_toInt(part) for part in re_split(r"(\d+)", text)) 

813 

814 sortedArray = list(array) 

815 sortedArray.sort(key=_createKey) 

816 return sortedArray 

817 

818 pythonVersions = _naturalSorting(pythonVersions) 

819 

820 # Translate Python versions to classifiers 

821 classifiers.append("Programming Language :: Python :: 3 :: Only") 

822 for v in pythonVersions: 

823 classifiers.append(f"Programming Language :: Python :: {v}") 

824 

825 # Translate status to classifier 

826 try: 

827 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}") 

828 except KeyError: # pragma: no cover 

829 raise ValueError(f"Unsupported development status '{developmentStatus}'.") 

830 

831 # Assemble all package information 

832 parameters = { 

833 "name": packageName, 

834 "version": versionInformation.Version, 

835 "author": versionInformation.Author, 

836 "author_email": versionInformation.Email, 

837 "license": license.SPDXIdentifier, 

838 "description": description, 

839 "long_description": readme.Content, 

840 "long_description_content_type": readme.MimeType, 

841 "url": projectURL, 

842 "project_urls": { 

843 'Documentation': documentationURL, 

844 'Source Code': sourceCodeURL, 

845 'Issue Tracker': issueTrackerCodeURL 

846 }, 

847 "packages": packages, 

848 "classifiers": classifiers, 

849 "keywords": keywords, 

850 "python_requires": f">={pythonVersions[0]}", 

851 "install_requires": requirements, 

852 } 

853 

854 if len(extraRequirements) > 0: 854 ↛ 857line 854 didn't jump to line 857 because the condition on line 854 was always true

855 parameters["extras_require"] = extraRequirements 

856 

857 if consoleScripts is not None: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true

858 scripts = [] 

859 for scriptName, entryPoint in consoleScripts.items(): 

860 scripts.append(f"{scriptName} = {entryPoint}") 

861 

862 parameters["entry_points"] = { 

863 "console_scripts": scripts 

864 } 

865 

866 if dataFiles: 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true

867 parameters["package_data"] = dataFiles 

868 

869 return parameters 

870 

871 

872@export 

873def DescribePythonPackageHostedOnGitHub( 

874 packageName: str, 

875 description: str, 

876 gitHubNamespace: str, 

877 gitHubRepository: str = None, 

878 projectURL: str = None, 

879 keywords: Iterable[str] = None, 

880 license: License = DEFAULT_LICENSE, 

881 readmeFile: Path = DEFAULT_README, 

882 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

883 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

884 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

885 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

886 additionalRequirements: dict[str, list[str]] = None, 

887 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

888 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

889 developmentStatus: str = "stable", 

890 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

891 consoleScripts: dict[str, str] = None, 

892 dataFiles: dict[str, list[str]] = None, 

893 debug: bool = False 

894) -> dict[str, Any]: 

895 """ 

896 Helper function to describe a Python package when the source code is hosted on GitHub. 

897 

898 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub 

899 namespace and repository name: issue tracker URL, source code URL, ... 

900 

901 :param packageName: Name of the Python package. 

902 :param description: Short description of the package. The long description will be read from README file. 

903 :param gitHubNamespace: Name of the GitHub namespace (organization or user). 

904 :param gitHubRepository: Optional, name of the GitHub repository. 

905 :param projectURL: Optional, URL to the Python project. 

906 :param keywords: Optional, a list of keywords. 

907 :param license: Optional, the package's license. (Default: ``Apache License, 2.0``, see 

908 :const:`DEFAULT_LICENSE`) 

909 :param readmeFile: Optional, the path to the README file. (Default: ``README.md``, see 

910 :const:`DEFAULT_README`) 

911 :param requirementsFile: Optional, the path to the project's requirements file. (Default: 

912 ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`) 

913 :param documentationRequirementsFile: Optional, the path to the project's requirements file for documentation. 

914 (Default: ``doc/requirements.txt``, see 

915 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`) 

916 :param unittestRequirementsFile: Optional, the path to the project's requirements file for unit tests. (Default: 

917 ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`) 

918 :param packagingRequirementsFile: Optional, the path to the project's requirements file for packaging. (Default: 

919 ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`) 

920 :param additionalRequirements: Optional, a dictionary of a lists with additional requirements. (default: None) 

921 :param sourceFileWithVersion: Optional, the path to the project's source file containing dunder variables like 

922 ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`) 

923 :param classifiers: Optional, a list of package classifiers. (Default: 3 classifiers, see 

924 :const:`DEFAULT_CLASSIFIERS`) 

925 :param developmentStatus: Optional, development status of the package. (Default: stable, see 

926 :const:`STATUS` for supported status values) 

927 :param pythonVersions: Optional, a list of supported Python 3 version. (Default: all currently 

928 maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`) 

929 :param consoleScripts: Optional, a dictionary mapping command line names to entry points. (Default: 

930 None) 

931 :param dataFiles: Optional, a dictionary mapping package names to lists of additional data files. 

932 :param debug: Optional, if ``True``, enable extended outputs for debugging. 

933 :returns: A dictionary suitable for :func:`setuptools.setup`. 

934 :raises MissingDependencyException: If package 'setuptools' is not available. 

935 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`. 

936 :raises FileNotFoundError: If README file doesn't exist. 

937 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`. 

938 :raises FileNotFoundError: If requirements file doesn't exist. 

939 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`. 

940 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`. 

941 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`. 

942 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`. 

943 :raises FileNotFoundError: If package file with dunder variables doesn't exist. 

944 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`. 

945 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`) 

946 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`) 

947 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`) 

948 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`) 

949 """ 

950 if gitHubRepository is None: 950 ↛ 952line 950 didn't jump to line 952 because the condition on line 950 was never true

951 # Assign GitHub repository name without '.*', if derived from Python package name. 

952 if packageName.endswith(".*"): 

953 gitHubRepository = packageName[:-2] 

954 else: 

955 gitHubRepository = packageName 

956 

957 # Derive URLs 

958 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}" 

959 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}" 

960 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

961 

962 projectURL = projectURL if projectURL is not None else sourceCodeURL 

963 

964 return DescribePythonPackage( 

965 packageName=packageName, 

966 description=description, 

967 keywords=keywords, 

968 projectURL=projectURL, 

969 sourceCodeURL=sourceCodeURL, 

970 documentationURL=documentationURL, 

971 issueTrackerCodeURL=issueTrackerCodeURL, 

972 license=license, 

973 readmeFile=readmeFile, 

974 requirementsFile=requirementsFile, 

975 documentationRequirementsFile=documentationRequirementsFile, 

976 unittestRequirementsFile=unittestRequirementsFile, 

977 packagingRequirementsFile=packagingRequirementsFile, 

978 additionalRequirements=additionalRequirements, 

979 sourceFileWithVersion=sourceFileWithVersion, 

980 classifiers=classifiers, 

981 developmentStatus=developmentStatus, 

982 pythonVersions=pythonVersions, 

983 consoleScripts=consoleScripts, 

984 dataFiles=dataFiles, 

985 debug=debug, 

986 )