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

301 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 07:24 +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""" 

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

39from collections.abc import Sized 

40from os import scandir as os_scandir 

41from pathlib import Path 

42from re import split as re_split 

43from sys import version_info 

44from typing import List, Iterable, Dict, Sequence, Any, Optional as Nullable, Union, Tuple 

45 

46from pyTooling.Decorators import export, readonly 

47from pyTooling.Exceptions import ToolingException 

48from pyTooling.MetaClasses import ExtendedType 

49from pyTooling.Common import __version__, getFullyQualifiedName, firstElement 

50from pyTooling.Licensing import License, Apache_2_0_License 

51 

52 

53__all__ = [ 

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

55 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS", 

56 "DEFAULT_VERSION_FILE" 

57] 

58 

59 

60@export 

61class Readme: 

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

63 

64 _content: str #: Content of the README file 

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

66 

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

68 """ 

69 Initializes a README file wrapper. 

70 

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

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

73 """ 

74 self._content = content 

75 self._mimeType = mimeType 

76 

77 @readonly 

78 def Content(self) -> str: 

79 """ 

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

81 

82 :returns: Raw content of the README file. 

83 """ 

84 return self._content 

85 

86 @readonly 

87 def MimeType(self) -> str: 

88 """ 

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

90 

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

92 """ 

93 return self._mimeType 

94 

95 

96@export 

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

98 """ 

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

100 

101 Supported formats: 

102 

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

104 * Markdown (``*.md``) 

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

106 

107 :param readmeFile: Path to the `README` file as an instance of :class:`Path`. 

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

109 :raises TypeError: If parameter 'readmeFile' is not of type 'Path'. 

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

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

112 """ 

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

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

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

116 raise ex 

117 

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

119 mimeType = "text/plain" 

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

121 mimeType = "text/markdown" 

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

123 mimeType = "text/x-rst" 

124 else: # pragma: no cover 

125 raise ValueError("Unsupported README format.") 

126 

127 try: 

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

129 return Readme( 

130 content=file.read(), 

131 mimeType=mimeType 

132 ) 

133 except FileNotFoundError as ex: 

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

135 

136 

137@export 

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

139 """ 

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

141 

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

143 

144 .. hint:: 

145 

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

147 

148 .. code-block:: Python 

149 

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

151 

152 :param requirementsFile: Path to the ``requirements.txt`` file as an instance of :class:`Path`. 

153 :param debug: If ``True``, print found dependencies and recursion. 

154 :returns: A list of dependencies. 

155 :raises TypeError: If parameter 'requirementsFile' is not of type 'Path'. 

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

157 """ 

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

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

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

161 raise ex 

162 

163 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> List[str]: 

164 """Recursive variant of :func:`loadRequirementsFile`.""" 

165 requirements = [] 

166 try: 

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

168 if debug: 

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

170 for line in file.readlines(): 

171 line = line.strip() 

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

173 continue 

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

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

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

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

178 elif line.startswith("https"): 

179 if debug: 

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

181 

182 # Convert 'URL#NAME' to 'NAME @ URL' 

183 splitItems = line.split("#") 

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

185 else: 

186 if debug: 

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

188 

189 requirements.append(line) 

190 except FileNotFoundError as ex: 

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

192 

193 return requirements 

194 

195 return _loadRequirementsFile(requirementsFile, 0) 

196 

197 

198@export 

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

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

201 

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

203 _copyright: str #: Copyright information. 

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

205 _keywords: List[str] #: Keywords. 

206 _license: str #: License name. 

207 _description: str #: Description of the package. 

208 _version: str #: Version number. 

209 

210 def __init__( 

211 self, 

212 author: str, 

213 email: str, 

214 copyright: str, 

215 license: str, 

216 version: str, 

217 description: str, 

218 keywords: Iterable[str] 

219 ) -> None: 

220 """ 

221 Initializes a Python package (version) information instance. 

222 

223 :param author: Author of the Python package. 

224 :param email: The author's email address 

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

226 :param license: The Python package's license. 

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

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

229 :param keywords: The Python package's list of keywords. 

230 """ 

231 self._author = author 

232 self._email = email 

233 self._copyright = copyright 

234 self._license = license 

235 self._version = version 

236 self._description = description 

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

238 

239 @readonly 

240 def Author(self) -> str: 

241 """ 

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

243 

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

245 """ 

246 return self._author 

247 

248 @readonly 

249 def Copyright(self) -> str: 

250 """ 

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

252 

253 :returns: Copyright information. 

254 """ 

255 return self._copyright 

256 

257 @readonly 

258 def Description(self) -> str: 

259 """ 

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

261 

262 :returns: Package description text. 

263 """ 

264 return self._description 

265 

266 @readonly 

267 def Email(self) -> str: 

268 """ 

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

270 

271 :returns: Email address of the author. 

272 """ 

273 return self._email 

274 

275 @readonly 

276 def Keywords(self) -> List[str]: 

277 """ 

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

279 

280 :returns: List of keywords. 

281 """ 

282 return self._keywords 

283 

284 @readonly 

285 def License(self) -> str: 

286 """ 

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

288 

289 :returns: License name. 

290 """ 

291 return self._license 

292 

293 @readonly 

294 def Version(self) -> str: 

295 """ 

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

297 

298 :returns: Version number. 

299 """ 

300 return self._version 

301 

302 def __str__(self) -> str: 

303 return f"{self._version}" 

304 

305 

306@export 

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

308 """ 

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

310 

311 Supported variables: 

312 

313 * ``__author__`` 

314 * ``__copyright__`` 

315 * ``__email__`` 

316 * ``__keywords__`` 

317 * ``__license__`` 

318 * ``__version__`` 

319 

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

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

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

323 

324 """ 

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

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

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

328 raise ex 

329 

330 _author = None 

331 _copyright = None 

332 _description = "" 

333 _email = None 

334 _keywords = [] 

335 _license = None 

336 _version = None 

337 

338 try: 

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

340 content = file.read() 

341 except FileNotFoundError as ex: 

342 raise FileNotFoundError 

343 

344 try: 

345 ast = ast_parse(content) 

346 except Exception as ex: # pragma: no cover 

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

348 

349 for item in iter_child_nodes(ast): 

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

351 target = item.targets[0] 

352 value = item.value 

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

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

355 _author = value.value 

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

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

358 _copyright = value.value 

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

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

361 _email = value.value 

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

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

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

365 elif isinstance(value, ast_List): 

366 for const in value.elts: 

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

368 _keywords.append(const.value) 

369 else: # pragma: no cover 

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

371 else: # pragma: no cover 

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

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

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

375 _license = value.value 

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

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

378 _version = value.value 

379 

380 if _author is None: 

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

382 if _copyright is None: 

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

384 if _email is None: 

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

386 if _license is None: 

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

388 if _version is None: 

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

390 

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

392 

393 

394STATUS: Dict[str, str] = { 

395 "planning": "1 - Planning", 

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

397 "alpha": "3 - Alpha", 

398 "beta": "4 - Beta", 

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

400 "mature": "6 - Mature", 

401 "inactive": "7 - Inactive" 

402} 

403""" 

404A dictionary of supported development status values. 

405 

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

407 

4081. Planning 

4092. Pre-Alpha 

4103. Alpha 

4114. Beta 

4125. Production/Stable 

4136. Mature 

4147. Inactive 

415 

416.. seealso:: 

417 

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

419""" 

420 

421DEFAULT_LICENSE = Apache_2_0_License 

422""" 

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

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

425""" 

426 

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

428""" 

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

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

431 

432.. seealso:: 

433 

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

435""" 

436 

437DEFAULT_CLASSIFIERS = ( 

438 "Operating System :: OS Independent", 

439 "Intended Audience :: Developers", 

440 "Topic :: Utilities" 

441 ) 

442""" 

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

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

445 

446.. seealso:: 

447 

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

449""" 

450 

451DEFAULT_README = Path("README.md") 

452""" 

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

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

455""" 

456 

457DEFAULT_REQUIREMENTS = Path("requirements.txt") 

458""" 

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

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

461""" 

462 

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

464""" 

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

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

467""" 

468 

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

470""" 

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

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

473""" 

474 

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

476""" 

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

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

479""" 

480 

481DEFAULT_VERSION_FILE = Path("__init__.py") 

482 

483 

484@export 

485def DescribePythonPackage( 

486 packageName: str, 

487 description: str, 

488 projectURL: str, 

489 sourceCodeURL: str, 

490 documentationURL: str, 

491 issueTrackerCodeURL: str, 

492 keywords: Iterable[str] = None, 

493 license: License = DEFAULT_LICENSE, 

494 readmeFile: Path = DEFAULT_README, 

495 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

496 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

497 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

498 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

499 additionalRequirements: Dict[str, List[str]] = None, 

500 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

501 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

502 developmentStatus: str = "stable", 

503 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

504 consoleScripts: Dict[str, str] = None, 

505 dataFiles: Dict[str, List[str]] = None, 

506 debug: bool = False 

507) -> Dict[str, Any]: 

508 """ 

509 Helper function to describe a Python package. 

510 

511 .. hint:: 

512 

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

514 

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

516 

517 .. topic:: Handling of namespace packages 

518 

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

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

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

522 

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

524 

525 * ``build``, ``build.*`` 

526 * ``dist``, ``dist.*`` 

527 * ``doc``, ``doc.*`` 

528 * ``tests``, ``tests.*`` 

529 

530 .. topic:: Handling of minimal Python version 

531 

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

533 

534 .. topic:: Handling of dunder variables 

535 

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

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

538 

539 * ``__author__``: :class:`str` 

540 * ``__copyright__``: :class:`str` 

541 * ``__email__``: :class:`str` 

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

543 * ``__license__``: :class:`str` 

544 * ``__version__``: :class:`str` 

545 

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

547 

548 .. topic:: Handling of package classifiers 

549 

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

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

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

553 

554 The following classifiers are implicitly handled: 

555 

556 license 

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

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

559 

560 Python versions 

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

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

563 

564 Development status 

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

566 

567 .. topic:: Handling of extra requirements 

568 

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

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

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

572 

573 ``doc`` 

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

575 ``test`` 

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

577 ``build`` 

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

579 User-defined 

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

581 dictionary will be added. 

582 ``all`` 

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

584 extra requirements. 

585 

586 .. topic:: Handling of keywords 

587 

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

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

590 

591 :param packageName: Name of the Python package. 

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

593 :param projectURL: URL to the Python project. 

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

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

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

597 :param keywords: A list of keywords. 

598 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`) 

599 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`) 

600 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`) 

601 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`) 

602 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`) 

603 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`) 

604 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None) 

605 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`) 

606 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`) 

607 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values) 

608 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`) 

609 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None) 

610 :param dataFiles: A dictionary mapping package names to lists of additional data files. 

611 :param debug: Enable extended outputs for debugging. 

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

613 :raises ToolingException: If package 'setuptools' is not available. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

628 """ 

629 try: 

630 from setuptools import find_packages, find_namespace_packages 

631 except ImportError as ex: 

632 raise Exception(f"Optional dependency 'setuptools' not installed. Either install pyTooling with extra dependencies 'pyTooling[packaging]' or install 'setuptools' directly.") from ex 

633 

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

635 

636 # Read README for upload to PyPI 

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

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

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

640 raise ex 

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

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

643 else: 

644 readme = loadReadmeFile(readmeFile) 

645 

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

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

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

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

650 raise ex 

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

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

653 else: 

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

655 

656 extraRequirements: Dict[str, List[str]] = {} 

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

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

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

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

661 raise ex 

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

663 if debug: 

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

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

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

667 else: 

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

669 

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

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

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

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

674 raise ex 

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

676 if debug: 

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

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

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

680 else: 

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

682 

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

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

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

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

687 raise ex 

688 elif not packagingRequirementsFile.exists(): 

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

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

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

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

693 else: 

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

695 

696 if additionalRequirements is not None: 

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

698 extraRequirements[key] = value 

699 

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

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

702 

703 # Read __author__, __email__, __version__ from source file 

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

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

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

707 raise ex 

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

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

710 else: 

711 versionInformation = extractVersionInformation(sourceFileWithVersion) 

712 

713 # Scan for packages and source files 

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

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

716 exclude = [] 

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

718 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): 

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

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

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

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

723 

724 if "." in packageName: 

725 exclude.append(rootNamespace) 

726 packages = find_namespace_packages(exclude=exclude) 

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

728 packageName = packageName[:-2] 

729 else: 

730 packages = find_packages(exclude=exclude) 

731 

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

733 print(f"[pyTooling.Packaging] Found packages: ({packages.__class__.__name__})") 

734 for package in packages: 

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

736 

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

738 keywords = versionInformation.Keywords 

739 

740 # Assemble classifiers 

741 classifiers = list(classifiers) 

742 

743 # Translate license to classifier 

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

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

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

747 raise ex 

748 classifiers.append(license.PythonClassifier) 

749 

750 def _naturalSorting(array: Iterable[str]) -> List[str]: 

751 """A simple natural sorting implementation.""" 

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

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

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

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

756 

757 def _createKey(text: str) -> Tuple[Union[str, float], ...]: 

758 """ 

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

760 their value. 

761 """ 

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

763 

764 sortedArray = list(array) 

765 sortedArray.sort(key=_createKey) 

766 return sortedArray 

767 

768 pythonVersions = _naturalSorting(pythonVersions) 

769 

770 # Translate Python versions to classifiers 

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

772 for v in pythonVersions: 

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

774 

775 # Translate status to classifier 

776 try: 

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

778 except KeyError: # pragma: no cover 

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

780 

781 # Assemble all package information 

782 parameters = { 

783 "name": packageName, 

784 "version": versionInformation.Version, 

785 "author": versionInformation.Author, 

786 "author_email": versionInformation.Email, 

787 "license": license.SPDXIdentifier, 

788 "description": description, 

789 "long_description": readme.Content, 

790 "long_description_content_type": readme.MimeType, 

791 "url": projectURL, 

792 "project_urls": { 

793 'Documentation': documentationURL, 

794 'Source Code': sourceCodeURL, 

795 'Issue Tracker': issueTrackerCodeURL 

796 }, 

797 "packages": packages, 

798 "classifiers": classifiers, 

799 "keywords": keywords, 

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

801 "install_requires": requirements, 

802 } 

803 

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

805 parameters["extras_require"] = extraRequirements 

806 

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

808 scripts = [] 

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

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

811 

812 parameters["entry_points"] = { 

813 "console_scripts": scripts 

814 } 

815 

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

817 parameters["package_data"] = dataFiles 

818 

819 return parameters 

820 

821 

822@export 

823def DescribePythonPackageHostedOnGitHub( 

824 packageName: str, 

825 description: str, 

826 gitHubNamespace: str, 

827 gitHubRepository: str = None, 

828 projectURL: str = None, 

829 keywords: Iterable[str] = None, 

830 license: License = DEFAULT_LICENSE, 

831 readmeFile: Path = DEFAULT_README, 

832 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

833 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

834 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

835 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

836 additionalRequirements: Dict[str, List[str]] = None, 

837 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

838 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

839 developmentStatus: str = "stable", 

840 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

841 consoleScripts: Dict[str, str] = None, 

842 dataFiles: Dict[str, List[str]] = None, 

843 debug: bool = False 

844) -> Dict[str, Any]: 

845 """ 

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

847 

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

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

850 

851 :param packageName: Name of the Python package. 

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

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

854 :param gitHubRepository: Name of the GitHub repository. 

855 :param projectURL: URL to the Python project. 

856 :param keywords: A list of keywords. 

857 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`) 

858 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`) 

859 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`) 

860 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`) 

861 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`) 

862 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`) 

863 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None) 

864 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`) 

865 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`) 

866 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values) 

867 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`) 

868 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None) 

869 :param dataFiles: A dictionary mapping package names to lists of additional data files. 

870 :param debug: Enable extended outputs for debugging. 

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

872 :raises ToolingException: If package 'setuptools' is not available. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

887 """ 

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

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

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

891 gitHubRepository = packageName[:-2] 

892 else: 

893 gitHubRepository = packageName 

894 

895 # Derive URLs 

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

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

898 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

899 

900 projectURL = projectURL if projectURL is not None else sourceCodeURL 

901 

902 return DescribePythonPackage( 

903 packageName=packageName, 

904 description=description, 

905 keywords=keywords, 

906 projectURL=projectURL, 

907 sourceCodeURL=sourceCodeURL, 

908 documentationURL=documentationURL, 

909 issueTrackerCodeURL=issueTrackerCodeURL, 

910 license=license, 

911 readmeFile=readmeFile, 

912 requirementsFile=requirementsFile, 

913 documentationRequirementsFile=documentationRequirementsFile, 

914 unittestRequirementsFile=unittestRequirementsFile, 

915 packagingRequirementsFile=packagingRequirementsFile, 

916 additionalRequirements=additionalRequirements, 

917 sourceFileWithVersion=sourceFileWithVersion, 

918 classifiers=classifiers, 

919 developmentStatus=developmentStatus, 

920 pythonVersions=pythonVersions, 

921 consoleScripts=consoleScripts, 

922 dataFiles=dataFiles, 

923 debug=debug, 

924 )