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

310 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-13 22:36 +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 """Name(s) of the package author(s).""" 

242 return self._author 

243 

244 @readonly 

245 def Copyright(self) -> str: 

246 """Copyright information.""" 

247 return self._copyright 

248 

249 @readonly 

250 def Description(self) -> str: 

251 """Package description text.""" 

252 return self._description 

253 

254 @readonly 

255 def Email(self) -> str: 

256 """Email address of the author.""" 

257 return self._email 

258 

259 @readonly 

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

261 """List of keywords.""" 

262 return self._keywords 

263 

264 @readonly 

265 def License(self) -> str: 

266 """License name.""" 

267 return self._license 

268 

269 @readonly 

270 def Version(self) -> str: 

271 """Version number.""" 

272 return self._version 

273 

274 def __str__(self) -> str: 

275 return f"{self._version}" 

276 

277 

278@export 

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

280 """ 

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

282 

283 Supported variables: 

284 

285 * ``__author__`` 

286 * ``__copyright__`` 

287 * ``__email__`` 

288 * ``__keywords__`` 

289 * ``__license__`` 

290 * ``__version__`` 

291 

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

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

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

295 

296 """ 

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

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

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

300 raise ex 

301 

302 _author = None 

303 _copyright = None 

304 _description = "" 

305 _email = None 

306 _keywords = [] 

307 _license = None 

308 _version = None 

309 

310 try: 

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

312 content = file.read() 

313 except FileNotFoundError as ex: 

314 raise FileNotFoundError 

315 

316 try: 

317 ast = ast_parse(content) 

318 except Exception as ex: # pragma: no cover 

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

320 

321 for item in iter_child_nodes(ast): 

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

323 target = item.targets[0] 

324 value = item.value 

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

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

327 _author = value.value 

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

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

330 _copyright = value.value 

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

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

333 _email = value.value 

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

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

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

337 elif isinstance(value, ast_List): 

338 for const in value.elts: 

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

340 _keywords.append(const.value) 

341 else: # pragma: no cover 

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

343 else: # pragma: no cover 

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

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

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

347 _license = value.value 

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

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

350 _version = value.value 

351 

352 if _author is None: 

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

354 if _copyright is None: 

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

356 if _email is None: 

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

358 if _license is None: 

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

360 if _version is None: 

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

362 

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

364 

365 

366STATUS: Dict[str, str] = { 

367 "planning": "1 - Planning", 

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

369 "alpha": "3 - Alpha", 

370 "beta": "4 - Beta", 

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

372 "mature": "6 - Mature", 

373 "inactive": "7 - Inactive" 

374} 

375""" 

376A dictionary of supported development status values. 

377 

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

379 

3801. Planning 

3812. Pre-Alpha 

3823. Alpha 

3834. Beta 

3845. Production/Stable 

3856. Mature 

3867. Inactive 

387 

388.. seealso:: 

389 

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

391""" 

392 

393DEFAULT_LICENSE = Apache_2_0_License 

394""" 

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

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

397""" 

398 

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

400""" 

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

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

403 

404.. seealso:: 

405 

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

407""" 

408 

409DEFAULT_CLASSIFIERS = ( 

410 "Operating System :: OS Independent", 

411 "Intended Audience :: Developers", 

412 "Topic :: Utilities" 

413 ) 

414""" 

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

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

417 

418.. seealso:: 

419 

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

421""" 

422 

423DEFAULT_README = Path("README.md") 

424""" 

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

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

427""" 

428 

429DEFAULT_REQUIREMENTS = Path("requirements.txt") 

430""" 

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

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

433""" 

434 

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

436""" 

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

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

439""" 

440 

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

442""" 

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

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

445""" 

446 

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

448""" 

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

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

451""" 

452 

453DEFAULT_VERSION_FILE = Path("__init__.py") 

454 

455 

456@export 

457def DescribePythonPackage( 

458 packageName: str, 

459 description: str, 

460 projectURL: str, 

461 sourceCodeURL: str, 

462 documentationURL: str, 

463 issueTrackerCodeURL: str, 

464 keywords: Iterable[str] = None, 

465 license: License = DEFAULT_LICENSE, 

466 readmeFile: Path = DEFAULT_README, 

467 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

468 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

469 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

470 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

472 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

473 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

474 developmentStatus: str = "stable", 

475 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

478 debug: bool = False 

479) -> Dict[str, Any]: 

480 """ 

481 Helper function to describe a Python package. 

482 

483 .. hint:: 

484 

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

486 

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

488 

489 .. topic:: Handling of namespace packages 

490 

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

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

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

494 

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

496 

497 * ``build``, ``build.*`` 

498 * ``dist``, ``dist.*`` 

499 * ``doc``, ``doc.*`` 

500 * ``tests``, ``tests.*`` 

501 

502 .. topic:: Handling of minimal Python version 

503 

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

505 

506 .. topic:: Handling of dunder variables 

507 

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

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

510 

511 * ``__author__``: :class:`str` 

512 * ``__copyright__``: :class:`str` 

513 * ``__email__``: :class:`str` 

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

515 * ``__license__``: :class:`str` 

516 * ``__version__``: :class:`str` 

517 

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

519 

520 .. topic:: Handling of package classifiers 

521 

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

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

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

525 

526 The following classifiers are implicitly handled: 

527 

528 license 

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

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

531 

532 Python versions 

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

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

535 

536 Development status 

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

538 

539 .. topic:: Handling of extra requirements 

540 

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

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

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

544 

545 ``doc`` 

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

547 ``test`` 

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

549 ``build`` 

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

551 User-defined 

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

553 dictionary will be added. 

554 ``all`` 

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

556 extra requirements. 

557 

558 .. topic:: Handling of keywords 

559 

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

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

562 

563 :param packageName: Name of the Python package. 

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

565 :param projectURL: URL to the Python project. 

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

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

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

569 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

583 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

600 """ 

601 try: 

602 from setuptools import find_packages, find_namespace_packages 

603 except ImportError as ex: 

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

605 

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

607 

608 # Read README for upload to PyPI 

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

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

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

612 raise ex 

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

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

615 else: 

616 readme = loadReadmeFile(readmeFile) 

617 

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

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

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

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

622 raise ex 

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

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

625 else: 

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

627 

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

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

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

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

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

633 raise ex 

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

635 if debug: 

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

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

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

639 else: 

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

641 

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

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

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

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

646 raise ex 

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

648 if debug: 

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

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

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

652 else: 

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

654 

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

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

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

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

659 raise ex 

660 elif not packagingRequirementsFile.exists(): 

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

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

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

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

665 else: 

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

667 

668 if additionalRequirements is not None: 

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

670 extraRequirements[key] = value 

671 

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

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

674 

675 # Read __author__, __email__, __version__ from source file 

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

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

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

679 raise ex 

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

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

682 else: 

683 versionInformation = extractVersionInformation(sourceFileWithVersion) 

684 

685 # Scan for packages and source files 

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

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

688 exclude = [] 

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

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

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

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

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

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

695 

696 if "." in packageName: 

697 exclude.append(rootNamespace) 

698 packages = find_namespace_packages(exclude=exclude) 

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

700 packageName = packageName[:-2] 

701 else: 

702 packages = find_packages(exclude=exclude) 

703 

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

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

706 for package in packages: 

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

708 

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

710 keywords = versionInformation.Keywords 

711 

712 # Assemble classifiers 

713 classifiers = list(classifiers) 

714 

715 # Translate license to classifier 

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

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

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

719 raise ex 

720 classifiers.append(license.PythonClassifier) 

721 

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

723 """A simple natural sorting implementation.""" 

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

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

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

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

728 

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

730 """ 

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

732 their value. 

733 """ 

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

735 

736 sortedArray = list(array) 

737 sortedArray.sort(key=_createKey) 

738 return sortedArray 

739 

740 pythonVersions = _naturalSorting(pythonVersions) 

741 

742 # Translate Python versions to classifiers 

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

744 for v in pythonVersions: 

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

746 

747 # Translate status to classifier 

748 try: 

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

750 except KeyError: # pragma: no cover 

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

752 

753 # Assemble all package information 

754 parameters = { 

755 "name": packageName, 

756 "version": versionInformation.Version, 

757 "author": versionInformation.Author, 

758 "author_email": versionInformation.Email, 

759 "license": license.SPDXIdentifier, 

760 "description": description, 

761 "long_description": readme.Content, 

762 "long_description_content_type": readme.MimeType, 

763 "url": projectURL, 

764 "project_urls": { 

765 'Documentation': documentationURL, 

766 'Source Code': sourceCodeURL, 

767 'Issue Tracker': issueTrackerCodeURL 

768 }, 

769 "packages": packages, 

770 "classifiers": classifiers, 

771 "keywords": keywords, 

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

773 "install_requires": requirements, 

774 } 

775 

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

777 parameters["extras_require"] = extraRequirements 

778 

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

780 scripts = [] 

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

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

783 

784 parameters["entry_points"] = { 

785 "console_scripts": scripts 

786 } 

787 

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

789 parameters["package_data"] = dataFiles 

790 

791 return parameters 

792 

793 

794@export 

795def DescribePythonPackageHostedOnGitHub( 

796 packageName: str, 

797 description: str, 

798 gitHubNamespace: str, 

799 gitHubRepository: str = None, 

800 projectURL: str = None, 

801 keywords: Iterable[str] = None, 

802 license: License = DEFAULT_LICENSE, 

803 readmeFile: Path = DEFAULT_README, 

804 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

805 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

806 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

807 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

809 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

810 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

811 developmentStatus: str = "stable", 

812 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

815 debug: bool = False 

816) -> Dict[str, Any]: 

817 """ 

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

819 

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

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

822 

823 :param packageName: Name of the Python package. 

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

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

826 :param gitHubRepository: Name of the GitHub repository. 

827 :param projectURL: URL to the Python project. 

828 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

842 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

859 """ 

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

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

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

863 gitHubRepository = packageName[:-2] 

864 else: 

865 gitHubRepository = packageName 

866 

867 # Derive URLs 

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

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

870 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

871 

872 projectURL = projectURL if projectURL is not None else sourceCodeURL 

873 

874 return DescribePythonPackage( 

875 packageName=packageName, 

876 description=description, 

877 keywords=keywords, 

878 projectURL=projectURL, 

879 sourceCodeURL=sourceCodeURL, 

880 documentationURL=documentationURL, 

881 issueTrackerCodeURL=issueTrackerCodeURL, 

882 license=license, 

883 readmeFile=readmeFile, 

884 requirementsFile=requirementsFile, 

885 documentationRequirementsFile=documentationRequirementsFile, 

886 unittestRequirementsFile=unittestRequirementsFile, 

887 packagingRequirementsFile=packagingRequirementsFile, 

888 additionalRequirements=additionalRequirements, 

889 sourceFileWithVersion=sourceFileWithVersion, 

890 classifiers=classifiers, 

891 developmentStatus=developmentStatus, 

892 pythonVersions=pythonVersions, 

893 consoleScripts=consoleScripts, 

894 dataFiles=dataFiles, 

895 debug=debug, 

896 )