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

311 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-08 23:46 +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 

46try: 

47 from pyTooling.Decorators import export, readonly 

48 from pyTooling.Exceptions import ToolingException 

49 from pyTooling.MetaClasses import ExtendedType 

50 from pyTooling.Common import __version__, getFullyQualifiedName, firstElement 

51 from pyTooling.Licensing import License, Apache_2_0_License 

52except (ImportError, ModuleNotFoundError): # pragma: no cover 

53 print("[pyTooling.Packaging] Could not import from 'pyTooling.*'!") 

54 

55 try: 

56 from Decorators import export, readonly 

57 from Exceptions import ToolingException 

58 from MetaClasses import ExtendedType 

59 from Common import __version__, getFullyQualifiedName, firstElement 

60 from Licensing import License, Apache_2_0_License 

61 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

62 print("[pyTooling.Packaging] Could not import directly!") 

63 raise ex 

64 

65 

66__all__ = [ 

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

68 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS", 

69 "DEFAULT_VERSION_FILE" 

70] 

71 

72 

73@export 

74class Readme: 

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

76 

77 _content: str #: Content of the README file 

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

79 

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

81 """ 

82 Initializes a README file wrapper. 

83 

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

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

86 """ 

87 self._content = content 

88 self._mimeType = mimeType 

89 

90 @readonly 

91 def Content(self) -> str: 

92 """ 

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

94 

95 :returns: Raw content of the README file. 

96 """ 

97 return self._content 

98 

99 @readonly 

100 def MimeType(self) -> str: 

101 """ 

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

103 

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

105 """ 

106 return self._mimeType 

107 

108 

109@export 

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

111 """ 

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

113 

114 Supported formats: 

115 

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

117 * Markdown (``*.md``) 

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

119 

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

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

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

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

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

125 """ 

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

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

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

129 raise ex 

130 

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

132 mimeType = "text/plain" 

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

134 mimeType = "text/markdown" 

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

136 mimeType = "text/x-rst" 

137 else: # pragma: no cover 

138 raise ValueError("Unsupported README format.") 

139 

140 try: 

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

142 return Readme( 

143 content=file.read(), 

144 mimeType=mimeType 

145 ) 

146 except FileNotFoundError as ex: 

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

148 

149 

150@export 

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

152 """ 

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

154 

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

156 

157 .. hint:: 

158 

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

160 

161 .. code-block:: Python 

162 

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

164 

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

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

167 :returns: A list of dependencies. 

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

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

170 """ 

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

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

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

174 raise ex 

175 

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

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

178 requirements = [] 

179 try: 

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

181 if debug: 

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

183 for line in file.readlines(): 

184 line = line.strip() 

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

186 continue 

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

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

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

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

191 elif line.startswith("https"): 

192 if debug: 

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

194 

195 # Convert 'URL#NAME' to 'NAME @ URL' 

196 splitItems = line.split("#") 

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

198 else: 

199 if debug: 

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

201 

202 requirements.append(line) 

203 except FileNotFoundError as ex: 

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

205 

206 return requirements 

207 

208 return _loadRequirementsFile(requirementsFile, 0) 

209 

210 

211@export 

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

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

214 

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

216 _copyright: str #: Copyright information. 

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

218 _keywords: List[str] #: Keywords. 

219 _license: str #: License name. 

220 _description: str #: Description of the package. 

221 _version: str #: Version number. 

222 

223 def __init__( 

224 self, 

225 author: str, 

226 email: str, 

227 copyright: str, 

228 license: str, 

229 version: str, 

230 description: str, 

231 keywords: Iterable[str] 

232 ) -> None: 

233 """ 

234 Initializes a Python package (version) information instance. 

235 

236 :param author: Author of the Python package. 

237 :param email: The author's email address 

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

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

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

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

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

243 """ 

244 self._author = author 

245 self._email = email 

246 self._copyright = copyright 

247 self._license = license 

248 self._version = version 

249 self._description = description 

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

251 

252 @readonly 

253 def Author(self) -> str: 

254 """Name(s) of the package author(s).""" 

255 return self._author 

256 

257 @readonly 

258 def Copyright(self) -> str: 

259 """Copyright information.""" 

260 return self._copyright 

261 

262 @readonly 

263 def Description(self) -> str: 

264 """Package description text.""" 

265 return self._description 

266 

267 @readonly 

268 def Email(self) -> str: 

269 """Email address of the author.""" 

270 return self._email 

271 

272 @readonly 

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

274 """List of keywords.""" 

275 return self._keywords 

276 

277 @readonly 

278 def License(self) -> str: 

279 """License name.""" 

280 return self._license 

281 

282 @readonly 

283 def Version(self) -> str: 

284 """Version number.""" 

285 return self._version 

286 

287 def __str__(self) -> str: 

288 return f"{self._version}" 

289 

290 

291@export 

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

293 """ 

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

295 

296 Supported variables: 

297 

298 * ``__author__`` 

299 * ``__copyright__`` 

300 * ``__email__`` 

301 * ``__keywords__`` 

302 * ``__license__`` 

303 * ``__version__`` 

304 

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

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

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

308 

309 """ 

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

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

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

313 raise ex 

314 

315 _author = None 

316 _copyright = None 

317 _description = "" 

318 _email = None 

319 _keywords = [] 

320 _license = None 

321 _version = None 

322 

323 try: 

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

325 content = file.read() 

326 except FileNotFoundError as ex: 

327 raise FileNotFoundError 

328 

329 try: 

330 ast = ast_parse(content) 

331 except Exception as ex: # pragma: no cover 

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

333 

334 for item in iter_child_nodes(ast): 

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

336 target = item.targets[0] 

337 value = item.value 

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

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

340 _author = value.value 

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

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

343 _copyright = value.value 

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

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

346 _email = value.value 

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

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

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

350 elif isinstance(value, ast_List): 

351 for const in value.elts: 

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

353 _keywords.append(const.value) 

354 else: # pragma: no cover 

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

356 else: # pragma: no cover 

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

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

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

360 _license = value.value 

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

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

363 _version = value.value 

364 

365 if _author is None: 

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

367 if _copyright is None: 

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

369 if _email is None: 

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

371 if _license is None: 

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

373 if _version is None: 

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

375 

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

377 

378 

379STATUS: Dict[str, str] = { 

380 "planning": "1 - Planning", 

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

382 "alpha": "3 - Alpha", 

383 "beta": "4 - Beta", 

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

385 "mature": "6 - Mature", 

386 "inactive": "7 - Inactive" 

387} 

388""" 

389A dictionary of supported development status values. 

390 

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

392 

3931. Planning 

3942. Pre-Alpha 

3953. Alpha 

3964. Beta 

3975. Production/Stable 

3986. Mature 

3997. Inactive 

400 

401.. seealso:: 

402 

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

404""" 

405 

406DEFAULT_LICENSE = Apache_2_0_License 

407""" 

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

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

410""" 

411 

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

413""" 

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

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

416 

417.. seealso:: 

418 

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

420""" 

421 

422DEFAULT_CLASSIFIERS = ( 

423 "Operating System :: OS Independent", 

424 "Intended Audience :: Developers", 

425 "Topic :: Utilities" 

426 ) 

427""" 

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

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

430 

431.. seealso:: 

432 

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

434""" 

435 

436DEFAULT_README = Path("README.md") 

437""" 

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

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

440""" 

441 

442DEFAULT_REQUIREMENTS = Path("requirements.txt") 

443""" 

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

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

446""" 

447 

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

449""" 

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

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

452""" 

453 

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

455""" 

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

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

458""" 

459 

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

461""" 

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

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

464""" 

465 

466DEFAULT_VERSION_FILE = Path("__init__.py") 

467 

468 

469@export 

470def DescribePythonPackage( 

471 packageName: str, 

472 description: str, 

473 projectURL: str, 

474 sourceCodeURL: str, 

475 documentationURL: str, 

476 issueTrackerCodeURL: str, 

477 keywords: Iterable[str] = None, 

478 license: License = DEFAULT_LICENSE, 

479 readmeFile: Path = DEFAULT_README, 

480 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

481 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

482 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

483 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

485 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

486 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

487 developmentStatus: str = "stable", 

488 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

491 debug: bool = False 

492) -> Dict[str, Any]: 

493 """ 

494 Helper function to describe a Python package. 

495 

496 .. hint:: 

497 

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

499 

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

501 

502 .. topic:: Handling of namespace packages 

503 

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

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

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

507 

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

509 

510 * ``build``, ``build.*`` 

511 * ``dist``, ``dist.*`` 

512 * ``doc``, ``doc.*`` 

513 * ``tests``, ``tests.*`` 

514 

515 .. topic:: Handling of minimal Python version 

516 

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

518 

519 .. topic:: Handling of dunder variables 

520 

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

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

523 

524 * ``__author__``: :class:`str` 

525 * ``__copyright__``: :class:`str` 

526 * ``__email__``: :class:`str` 

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

528 * ``__license__``: :class:`str` 

529 * ``__version__``: :class:`str` 

530 

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

532 

533 .. topic:: Handling of package classifiers 

534 

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

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

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

538 

539 The following classifiers are implicitly handled: 

540 

541 license 

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

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

544 

545 Python versions 

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

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

548 

549 Development status 

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

551 

552 .. topic:: Handling of extra requirements 

553 

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

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

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

557 

558 ``doc`` 

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

560 ``test`` 

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

562 ``build`` 

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

564 User-defined 

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

566 dictionary will be added. 

567 ``all`` 

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

569 extra requirements. 

570 

571 .. topic:: Handling of keywords 

572 

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

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

575 

576 :param packageName: Name of the Python package. 

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

578 :param projectURL: URL to the Python project. 

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

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

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

582 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

596 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

613 """ 

614 try: 

615 from setuptools import find_packages, find_namespace_packages 

616 except ImportError as ex: 

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

618 

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

620 

621 # Read README for upload to PyPI 

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

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

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

625 raise ex 

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

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

628 else: 

629 readme = loadReadmeFile(readmeFile) 

630 

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

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

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

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

635 raise ex 

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

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

638 else: 

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

640 

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

642 if documentationRequirementsFile 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(documentationRequirementsFile, Path): 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true

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

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

646 raise ex 

647 elif not documentationRequirementsFile.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] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.") 

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

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

652 else: 

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

654 

655 if unittestRequirementsFile 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(unittestRequirementsFile, Path): 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true

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

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

659 raise ex 

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

661 if debug: 

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

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

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

665 else: 

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

667 

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

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

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

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

672 raise ex 

673 elif not packagingRequirementsFile.exists(): 

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

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

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

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

678 else: 

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

680 

681 if additionalRequirements is not None: 

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

683 extraRequirements[key] = value 

684 

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

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

687 

688 # Read __author__, __email__, __version__ from source file 

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

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

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

692 raise ex 

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

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

695 else: 

696 versionInformation = extractVersionInformation(sourceFileWithVersion) 

697 

698 # Scan for packages and source files 

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

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

701 exclude = [] 

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

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

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

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

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

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

708 

709 if "." in packageName: 

710 exclude.append(rootNamespace) 

711 packages = find_namespace_packages(exclude=exclude) 

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

713 packageName = packageName[:-2] 

714 else: 

715 packages = find_packages(exclude=exclude) 

716 

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

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

719 for package in packages: 

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

721 

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

723 keywords = versionInformation.Keywords 

724 

725 # Assemble classifiers 

726 classifiers = list(classifiers) 

727 

728 # Translate license to classifier 

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

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

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

732 raise ex 

733 classifiers.append(license.PythonClassifier) 

734 

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

736 """A simple natural sorting implementation.""" 

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

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

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

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

741 

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

743 """ 

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

745 their value. 

746 """ 

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

748 

749 sortedArray = list(array) 

750 sortedArray.sort(key=_createKey) 

751 return sortedArray 

752 

753 pythonVersions = _naturalSorting(pythonVersions) 

754 

755 # Translate Python versions to classifiers 

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

757 for v in pythonVersions: 

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

759 

760 # Translate status to classifier 

761 try: 

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

763 except KeyError: # pragma: no cover 

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

765 

766 # Assemble all package information 

767 parameters = { 

768 "name": packageName, 

769 "version": versionInformation.Version, 

770 "author": versionInformation.Author, 

771 "author_email": versionInformation.Email, 

772 "license": license.SPDXIdentifier, 

773 "description": description, 

774 "long_description": readme.Content, 

775 "long_description_content_type": readme.MimeType, 

776 "url": projectURL, 

777 "project_urls": { 

778 'Documentation': documentationURL, 

779 'Source Code': sourceCodeURL, 

780 'Issue Tracker': issueTrackerCodeURL 

781 }, 

782 "packages": packages, 

783 "classifiers": classifiers, 

784 "keywords": keywords, 

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

786 "install_requires": requirements, 

787 } 

788 

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

790 parameters["extras_require"] = extraRequirements 

791 

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

793 scripts = [] 

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

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

796 

797 parameters["entry_points"] = { 

798 "console_scripts": scripts 

799 } 

800 

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

802 parameters["package_data"] = dataFiles 

803 

804 return parameters 

805 

806 

807@export 

808def DescribePythonPackageHostedOnGitHub( 

809 packageName: str, 

810 description: str, 

811 gitHubNamespace: str, 

812 gitHubRepository: str = None, 

813 projectURL: str = None, 

814 keywords: Iterable[str] = None, 

815 license: License = DEFAULT_LICENSE, 

816 readmeFile: Path = DEFAULT_README, 

817 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

818 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

819 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

820 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

822 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

823 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

824 developmentStatus: str = "stable", 

825 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

828 debug: bool = False 

829) -> Dict[str, Any]: 

830 """ 

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

832 

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

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

835 

836 :param packageName: Name of the Python package. 

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

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

839 :param gitHubRepository: Name of the GitHub repository. 

840 :param projectURL: URL to the Python project. 

841 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

855 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

872 """ 

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

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

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

876 gitHubRepository = packageName[:-2] 

877 else: 

878 gitHubRepository = packageName 

879 

880 # Derive URLs 

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

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

883 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

884 

885 projectURL = projectURL if projectURL is not None else sourceCodeURL 

886 

887 return DescribePythonPackage( 

888 packageName=packageName, 

889 description=description, 

890 keywords=keywords, 

891 projectURL=projectURL, 

892 sourceCodeURL=sourceCodeURL, 

893 documentationURL=documentationURL, 

894 issueTrackerCodeURL=issueTrackerCodeURL, 

895 license=license, 

896 readmeFile=readmeFile, 

897 requirementsFile=requirementsFile, 

898 documentationRequirementsFile=documentationRequirementsFile, 

899 unittestRequirementsFile=unittestRequirementsFile, 

900 packagingRequirementsFile=packagingRequirementsFile, 

901 additionalRequirements=additionalRequirements, 

902 sourceFileWithVersion=sourceFileWithVersion, 

903 classifiers=classifiers, 

904 developmentStatus=developmentStatus, 

905 pythonVersions=pythonVersions, 

906 consoleScripts=consoleScripts, 

907 dataFiles=dataFiles, 

908 debug=debug, 

909 )