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

284 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 22:22 +0000

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

2# _____ _ _ ____ _ _ # 

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

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

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

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

14# Copyright 2021-2025 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:: See :ref:`high-level help <PACKAGING>` for explanations and usage examples. 

35""" 

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

37from collections.abc import Sized 

38from pathlib import Path 

39from re import split as re_split 

40from sys import version_info 

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

42 

43try: 

44 from pyTooling.Decorators import export, readonly 

45 from pyTooling.Exceptions import ToolingException 

46 from pyTooling.MetaClasses import ExtendedType 

47 from pyTooling.Common import getFullyQualifiedName 

48 from pyTooling.Licensing import License, Apache_2_0_License 

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

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

51 

52 try: 

53 from Decorators import export, readonly 

54 from Exceptions import ToolingException 

55 from MetaClasses import ExtendedType 

56 from Common import getFullyQualifiedName 

57 from Licensing import License, Apache_2_0_License 

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

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

60 raise ex 

61 

62 

63__all__ = [ 

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

65 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS", 

66 "DEFAULT_VERSION_FILE" 

67] 

68 

69 

70@export 

71class Readme: 

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

73 

74 _content: str #: Content of the README file 

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

76 

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

78 """ 

79 Initializes a README file wrapper. 

80 

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

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

83 """ 

84 self._content = content 

85 self._mimeType = mimeType 

86 

87 @readonly 

88 def Content(self) -> str: 

89 """ 

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

91 

92 :returns: Raw content of the README file. 

93 """ 

94 return self._content 

95 

96 @readonly 

97 def MimeType(self) -> str: 

98 """ 

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

100 

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

102 """ 

103 return self._mimeType 

104 

105 

106@export 

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

108 """ 

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

110 

111 Supported formats: 

112 

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

114 * Markdown (``*.md``) 

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

116 

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

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

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

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

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

122 """ 

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

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

125 if version_info >= (3, 11): # pragma: no cover 

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

127 raise ex 

128 

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

130 mimeType = "text/plain" 

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

132 mimeType = "text/markdown" 

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

134 mimeType = "text/x-rst" 

135 else: # pragma: no cover 

136 raise ValueError("Unsupported README format.") 

137 

138 try: 

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

140 return Readme( 

141 content=file.read(), 

142 mimeType=mimeType 

143 ) 

144 except FileNotFoundError as ex: 

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

146 

147 

148@export 

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

150 """ 

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

152 

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

154 

155 .. hint:: 

156 

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

158 

159 .. code-block:: Python 

160 

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

162 

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

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

165 :returns: A list of dependencies. 

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

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

168 """ 

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

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

171 if version_info >= (3, 11): # pragma: no cover 

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

173 raise ex 

174 

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

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

177 requirements = [] 

178 try: 

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

180 if debug: 

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

182 for line in file.readlines(): 

183 line = line.strip() 

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

185 continue 

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

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

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

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

190 elif line.startswith("https"): 

191 if debug: 

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

193 

194 # Convert 'URL#NAME' to 'NAME @ URL' 

195 splitItems = line.split("#") 

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

197 else: 

198 if debug: 

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

200 

201 requirements.append(line) 

202 except FileNotFoundError as ex: 

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

204 

205 return requirements 

206 

207 return _loadRequirementsFile(requirementsFile, 0) 

208 

209 

210@export 

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

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

213 

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

215 _copyright: str #: Copyright information. 

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

217 _keywords: List[str] #: Keywords. 

218 _license: str #: License name. 

219 _description: str #: Description of the package. 

220 _version: str #: Version number. 

221 

222 def __init__( 

223 self, 

224 author: str, 

225 email: str, 

226 copyright: str, 

227 license: str, 

228 version: str, 

229 description: str, 

230 keywords: Iterable[str] 

231 ) -> None: 

232 """ 

233 Initializes a Python package (version) information instance. 

234 

235 :param author: Author of the Python package. 

236 :param email: The author's email address 

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

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

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

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

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

242 """ 

243 self._author = author 

244 self._email = email 

245 self._copyright = copyright 

246 self._license = license 

247 self._version = version 

248 self._description = description 

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

250 

251 @readonly 

252 def Author(self) -> str: 

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

254 return self._author 

255 

256 @readonly 

257 def Copyright(self) -> str: 

258 """Copyright information.""" 

259 return self._copyright 

260 

261 @readonly 

262 def Description(self) -> str: 

263 """Package description text.""" 

264 return self._description 

265 

266 @readonly 

267 def Email(self) -> str: 

268 """Email address of the author.""" 

269 return self._email 

270 

271 @readonly 

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

273 """List of keywords.""" 

274 return self._keywords 

275 

276 @readonly 

277 def License(self) -> str: 

278 """License name.""" 

279 return self._license 

280 

281 @readonly 

282 def Version(self) -> str: 

283 """Version number.""" 

284 return self._version 

285 

286 

287@export 

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

289 """ 

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

291 

292 Supported variables: 

293 

294 * ``__author__`` 

295 * ``__copyright__`` 

296 * ``__email__`` 

297 * ``__keywords__`` 

298 * ``__license__`` 

299 * ``__version__`` 

300 

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

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

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

304 

305 """ 

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

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

308 if version_info >= (3, 11): # pragma: no cover 

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

310 raise ex 

311 

312 _author = None 

313 _copyright = None 

314 _description = "" 

315 _email = None 

316 _keywords = [] 

317 _license = None 

318 _version = None 

319 

320 try: 

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

322 content = file.read() 

323 except FileNotFoundError as ex: 

324 raise FileNotFoundError 

325 

326 try: 

327 ast = ast_parse(content) 

328 except Exception as ex: # pragma: no cover 

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

330 

331 for item in iter_child_nodes(ast): 

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

333 target = item.targets[0] 

334 value = item.value 

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

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

337 _author = value.value 

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

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 _copyright = value.value 

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

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 _email = value.value 

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

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

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

347 elif isinstance(value, ast_List): 

348 for const in value.elts: 

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

350 _keywords.append(const.value) 

351 else: # pragma: no cover 

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

353 else: # pragma: no cover 

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

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

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

357 _license = value.value 

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

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

360 _version = value.value 

361 

362 if _author is None: 

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

364 if _copyright is None: 

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

366 if _email is None: 

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

368 if _license is None: 

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

370 if _version is None: 

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

372 

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

374 

375 

376STATUS: Dict[str, str] = { 

377 "planning": "1 - Planning", 

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

379 "alpha": "3 - Alpha", 

380 "beta": "4 - Beta", 

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

382 "mature": "6 - Mature", 

383 "inactive": "7 - Inactive" 

384} 

385""" 

386A dictionary of supported development status values. 

387 

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

389 

3901. Planning 

3912. Pre-Alpha 

3923. Alpha 

3934. Beta 

3945. Production/Stable 

3956. Mature 

3967. Inactive 

397 

398.. seealso:: 

399 

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

401""" 

402 

403DEFAULT_LICENSE = Apache_2_0_License 

404""" 

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

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

407""" 

408 

409DEFAULT_PY_VERSIONS = ("3.9", "3.10", "3.11", "3.12", "3.13") 

410""" 

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

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

413 

414.. seealso:: 

415 

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

417""" 

418 

419DEFAULT_CLASSIFIERS = ( 

420 "Operating System :: OS Independent", 

421 "Intended Audience :: Developers", 

422 "Topic :: Utilities" 

423 ) 

424""" 

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

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

427 

428.. seealso:: 

429 

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

431""" 

432 

433DEFAULT_README = Path("README.md") 

434""" 

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

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

437""" 

438 

439DEFAULT_REQUIREMENTS = Path("requirements.txt") 

440""" 

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

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

443""" 

444 

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

446""" 

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

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

449""" 

450 

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

452""" 

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

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

455""" 

456 

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

458""" 

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

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

461""" 

462 

463DEFAULT_VERSION_FILE = Path("__init__.py") 

464 

465 

466@export 

467def DescribePythonPackage( 

468 packageName: str, 

469 description: str, 

470 projectURL: str, 

471 sourceCodeURL: str, 

472 documentationURL: str, 

473 issueTrackerCodeURL: str, 

474 keywords: Iterable[str] = None, 

475 license: License = DEFAULT_LICENSE, 

476 readmeFile: Path = DEFAULT_README, 

477 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

478 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

479 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

480 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

482 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

483 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

484 developmentStatus: str = "stable", 

485 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

488 debug: bool = False 

489) -> Dict[str, Any]: 

490 """ 

491 Helper function to describe a Python package. 

492 

493 .. hint:: 

494 

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

496 

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

498 

499 .. topic:: Handling of namespace packages 

500 

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

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

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

504 

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

506 

507 * ``build``, ``build.*`` 

508 * ``dist``, ``dist.*`` 

509 * ``doc``, ``doc.*`` 

510 * ``tests``, ``tests.*`` 

511 

512 .. topic:: Handling of minimal Python version 

513 

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

515 

516 .. topic:: Handling of dunder variables 

517 

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

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

520 

521 * ``__author__``: :class:`str` 

522 * ``__copyright__``: :class:`str` 

523 * ``__email__``: :class:`str` 

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

525 * ``__license__``: :class:`str` 

526 * ``__version__``: :class:`str` 

527 

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

529 

530 .. topic:: Handling of package classifiers 

531 

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

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

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

535 

536 The following classifiers are implicitly handled: 

537 

538 license 

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

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

541 

542 Python versions 

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

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

545 

546 Development status 

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

548 

549 .. topic:: Handling of extra requirements 

550 

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

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

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

554 

555 ``doc`` 

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

557 ``test`` 

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

559 ``build`` 

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

561 User-defined 

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

563 dictionary will be added. 

564 ``all`` 

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

566 extra requirements. 

567 

568 .. topic:: Handling of keywords 

569 

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

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

572 

573 :param packageName: Name of the Python package. 

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

575 :param projectURL: URL to the Python project. 

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

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

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

579 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

593 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

610 """ 

611 try: 

612 from setuptools import find_packages, find_namespace_packages 

613 except ImportError as ex: 

614 raise ToolingException(f"Optional dependency 'setuptools' is not available.") from ex 

615 

616 # Read README for upload to PyPI 

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

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

619 if version_info >= (3, 11): # pragma: no cover 

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

621 raise ex 

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

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

624 else: 

625 readme = loadReadmeFile(readmeFile) 

626 

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

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

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

630 if version_info >= (3, 11): # pragma: no cover 

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

632 raise ex 

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

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

635 else: 

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

637 

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

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

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

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

642 if version_info >= (3, 11): # pragma: no cover 

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

644 raise ex 

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

646 if debug: 

647 print(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.") 

648 print(" No section added to 'extraRequirements'.") 

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

650 else: 

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

652 

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

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

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

656 if version_info >= (3, 11): # pragma: no cover 

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

658 raise ex 

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

660 if debug: 

661 print(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.") 

662 print(" No section added to 'extraRequirements'.") 

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

664 else: 

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

666 

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

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

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

670 if version_info >= (3, 11): # pragma: no cover 

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"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.") 

676 print(" 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 if version_info >= (3, 11): # pragma: no cover 

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

693 raise ex 

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

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

696 else: 

697 versionInformation = extractVersionInformation(sourceFileWithVersion) 

698 

699 # Scan for packages and source files 

700 exclude = ["build", "build.*", "dist", "dist.*", "doc", "doc.*", "tests", "tests.*"] 

701 if "." in packageName: 

702 packages = find_namespace_packages(exclude=exclude) 

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

704 packageName = packageName[:-2] 

705 else: 

706 packages = find_packages(exclude=exclude) 

707 

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

709 keywords = versionInformation.Keywords 

710 

711 # Assemble classifiers 

712 classifiers = list(classifiers) 

713 

714 # Translate license to classifier 

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

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

717 if version_info >= (3, 11): # pragma: no cover 

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 )