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

357 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +0000

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

2# _____ _ _ ____ _ _ # 

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

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

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

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

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

15# # 

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

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

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

19# # 

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

21# # 

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

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

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

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

26# limitations under the License. # 

27# # 

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

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

30# 

31""" 

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

33 

34.. hint:: 

35 

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

37 

38.. seealso:: 

39 

40 :mod:`pyTooling.Versioning` 

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

42 :mod:`pyTooling.Licensing` 

43 |rarr| The license names translated for setuptools. 

44 :mod:`pyTooling.Testing` 

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

46""" 

47from ast import parse as ast_parse, get_docstring as ast_get_docstring, iter_child_nodes, Assign, \ 

48 Constant, Name, List as ast_List 

49from collections.abc import Sized 

50from os import scandir as os_scandir 

51from pathlib import Path 

52from re import compile as re_compile, split as re_split, sub as re_sub 

53from sys import version_info 

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

55from pyTooling.Decorators import export, readonly 

56from pyTooling.Documentation import splitDocString 

57from pyTooling.Exceptions import ToolingException, MissingDependencyError 

58from pyTooling.MetaClasses import ExtendedType 

59from pyTooling.Common import __version__, getFullyQualifiedName, firstElement 

60from pyTooling.Licensing import License, Apache_2_0_License 

61from pyTooling.Warning import CriticalWarning, WarningCollector 

62 

63 

64__all__ = [ 

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

66 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS", 

67 "DEFAULT_VERSION_FILE" 

68] 

69 

70 

71@export 

72class PackagingError(ToolingException): 

73 """Base-exception of all exceptions raised by :mod:`pyTooling.Packaging`.""" 

74 

75 

76@export 

77class DirectReferenceWarning(CriticalWarning): 

78 """ 

79 A requirement of an extra names its distribution by a URL, a direct reference (``name @ URL``). 

80 

81 The Python Package Index rejects a package whose metadata contains a direct reference, therefore 

82 :func:`DescribePythonPackage` leaves such a requirement out of the extra. 

83 

84 .. important:: 

85 

86 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the 

87 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. 

88 """ 

89 

90 _requirement: Nullable[str] #: The requirement left out, as stated. 

91 _extra: Nullable[str] #: The extra the requirement was left out of. 

92 _filePath: Nullable[Path] #: The requirements file stating it; ``None`` for parameter 'additionalRequirements'. 

93 

94 def __init__( 

95 self, 

96 message: str, 

97 /, *, 

98 requirement: Nullable[str] = None, 

99 extra: Nullable[str] = None, 

100 filePath: Nullable[Path] = None 

101 ) -> None: 

102 """ 

103 Initializes the warning with the requirement left out, its extra and where it was stated. 

104 

105 :param message: The warning's message. 

106 :param requirement: Optional, the requirement left out, as stated. 

107 :param extra: Optional, the extra the requirement was left out of. 

108 :param filePath: Optional, the requirements file stating it; ``None`` for parameter 'additionalRequirements'. 

109 """ 

110 super().__init__(message) 

111 self._requirement = requirement 

112 self._extra = extra 

113 self._filePath = filePath 

114 

115 @readonly 

116 def Requirement(self) -> Nullable[str]: 

117 """ 

118 Read-only property to access the requirement left out, as stated (:attr:`_requirement`). 

119 

120 :returns: The requirement, or ``None`` if it wasn't recorded. 

121 """ 

122 return self._requirement 

123 

124 @readonly 

125 def Extra(self) -> Nullable[str]: 

126 """ 

127 Read-only property to access the extra the requirement was left out of (:attr:`_extra`). 

128 

129 :returns: The extra's name, or ``None`` if it wasn't recorded. 

130 """ 

131 return self._extra 

132 

133 @readonly 

134 def FilePath(self) -> Nullable[Path]: 

135 """ 

136 Read-only property to access the requirements file stating the requirement (:attr:`_filePath`). 

137 

138 :returns: The file's path, or ``None`` for a requirement from parameter 'additionalRequirements' or if it wasn't 

139 recorded. 

140 """ 

141 return self._filePath 

142 

143 

144@export 

145class Readme: 

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

147 

148 _content: str #: Content of the README file 

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

150 

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

152 """ 

153 Initializes a README file wrapper. 

154 

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

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

157 """ 

158 self._content = content 

159 self._mimeType = mimeType 

160 

161 @readonly 

162 def Content(self) -> str: 

163 """ 

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

165 

166 :returns: Raw content of the README file. 

167 """ 

168 return self._content 

169 

170 @readonly 

171 def MimeType(self) -> str: 

172 """ 

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

174 

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

176 """ 

177 return self._mimeType 

178 

179 

180@export 

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

182 """ 

183 Read the README file, so it can be used as long description for the package. 

184 

185 Supported formats: 

186 

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

188 * Markdown (``*.md``) 

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

190 

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

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

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

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

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

196 """ 

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

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

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

200 raise ex 

201 

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

203 mimeType = "text/plain" 

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

205 mimeType = "text/markdown" 

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

207 mimeType = "text/x-rst" 

208 else: # pragma: no cover 

209 raise ValueError("Unsupported README format.") 

210 

211 try: 

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

213 return Readme( 

214 content=file.read(), 

215 mimeType=mimeType 

216 ) 

217 except FileNotFoundError as ex: 

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

219 

220 

221@export 

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

223 """ 

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

225 

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

227 

228 .. hint:: 

229 

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

231 

232 .. code-block:: Python 

233 

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

235 

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

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

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

239 :returns: A list of dependencies. 

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

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

242 """ 

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

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

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

246 raise ex 

247 

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

249 """ 

250 Recursive variant of :func:`loadRequirementsFile`. 

251 

252 :param requirementsFile: Path to the requirements file to read. 

253 :param indent: Indentation level used for the debug output of nested requirements files. 

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

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

256 """ 

257 requirements = [] 

258 try: 

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

260 if debug: 

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

262 

263 for line in file.readlines(): 

264 # a comment starts a line or follows whitespace; a '#' inside a word is a URL's fragment ('URL#NAME') 

265 line = re_sub(r"(^|\s)#.*$", "", line).strip() 

266 if line == "": 

267 continue 

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

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

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

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

272 elif line.startswith("https"): 

273 if debug: 

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

275 

276 # Convert 'URL#NAME' to 'NAME @ URL' 

277 splitItems = line.split("#") 

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

279 else: 

280 if debug: 

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

282 

283 requirements.append(line) 

284 except FileNotFoundError as ex: 

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

286 

287 return requirements 

288 

289 return _loadRequirementsFile(requirementsFile, 0) 

290 

291 

292@export 

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

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

295 

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

297 _copyright: str #: Copyright information. 

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

299 _keywords: list[str] #: Keywords. 

300 _license: str #: License name. 

301 _description: str #: Description of the package. 

302 _version: str #: Version number. 

303 

304 def __init__( 

305 self, 

306 author: str, 

307 email: str, 

308 copyright: str, 

309 license: str, 

310 version: str, 

311 description: str, 

312 keywords: Iterable[str] 

313 ) -> None: 

314 """ 

315 Initializes a Python package (version) information instance. 

316 

317 :param author: Author of the Python package. 

318 :param email: The author's email address 

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

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

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

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

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

324 """ 

325 self._author = author 

326 self._email = email 

327 self._copyright = copyright 

328 self._license = license 

329 self._version = version 

330 self._description = description 

331 self._keywords = list(keywords) 

332 

333 @readonly 

334 def Author(self) -> str: 

335 """ 

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

337 

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

339 """ 

340 return self._author 

341 

342 @readonly 

343 def Copyright(self) -> str: 

344 """ 

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

346 

347 :returns: Copyright information. 

348 """ 

349 return self._copyright 

350 

351 @readonly 

352 def Description(self) -> str: 

353 """ 

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

355 

356 :returns: Package description text. 

357 """ 

358 return self._description 

359 

360 @readonly 

361 def Email(self) -> str: 

362 """ 

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

364 

365 :returns: Email address of the author. 

366 """ 

367 return self._email 

368 

369 @readonly 

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

371 """ 

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

373 

374 :returns: List of keywords. 

375 """ 

376 return self._keywords 

377 

378 @readonly 

379 def License(self) -> str: 

380 """ 

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

382 

383 :returns: License name. 

384 """ 

385 return self._license 

386 

387 @readonly 

388 def Version(self) -> str: 

389 """ 

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

391 

392 :returns: Version number. 

393 """ 

394 return self._version 

395 

396 def __str__(self) -> str: 

397 """ 

398 Return a string representation of this version information. 

399 

400 :returns: The version number. 

401 """ 

402 return f"{self._version}" 

403 

404 

405def _extractDescription(docString: Nullable[str]) -> str: 

406 """ 

407 Read a package's short description from the first paragraph of its module doc-string. 

408 

409 A package's short description **is** the summary of its module doc-string - the first paragraph - so the two are 

410 one text in two places, and :func:`~pyTooling.Documentation.splitDocString` reads it. 

411 

412 It is folded into a single line, because a short description is one line of plain text while a doc-string is 

413 wrapped to the source file's line length. Strong emphasis around the whole paragraph is removed for the same 

414 reason: ``**An abstract VHDL language model.**`` is markup for the rendered documentation, and nothing renders it 

415 where a short description is displayed. 

416 

417 :param docString: The module's doc-string, or ``None`` if it has none. 

418 :returns: The description as a single line, or an empty string if there is no doc-string. 

419 :raises DocumentationError: If the first paragraph is too long to be a description. 

420 

421 .. seealso:: 

422 

423 :func:`~pyTooling.Documentation.splitDocString` 

424 |rarr| Reads the first paragraph, and rejects one that is too long to be a summary. 

425 """ 

426 description, _ = splitDocString(docString) 

427 description = " ".join(description.split()) 

428 

429 if description.startswith("**") and description.endswith("**") and "**" not in description[2:-2]: 

430 description = description[2:-2] 

431 

432 return description 

433 

434 

435@export 

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

437 """ 

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

439 

440 Supported variables: 

441 

442 * ``__author__`` 

443 * ``__copyright__`` 

444 * ``__email__`` 

445 * ``__keywords__`` 

446 * ``__license__`` 

447 * ``__version__`` 

448 

449 The package's short description is not a dunder variable - it is the first paragraph of the source file's module 

450 doc-string, which is where a package already describes itself. 

451 

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

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

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

455 :raises PackagingError: If the given file doesn't exist. 

456 :raises PackagingError: If the given file can't be parsed as Python source code. 

457 :raises PackagingError: If a dunder variable has an unexpected format. 

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

459 """ 

460 if not isinstance(sourceFile, Path): 

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

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

463 raise ex 

464 

465 author = None 

466 copyright = None 

467 email = None 

468 keywords = [] 

469 license = None 

470 version = None 

471 

472 try: 

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

474 content = file.read() 

475 except OSError as ex: 

476 raise PackagingError(f"Source file '{sourceFile}' couldn't be read.") from ex 

477 

478 try: 

479 ast = ast_parse(content) 

480 except Exception as ex: # pragma: no cover 

481 raise PackagingError(f"Source file '{sourceFile}' couldn't be parsed as Python source code.") from ex 

482 

483 description = _extractDescription(ast_get_docstring(ast)) 

484 

485 for item in iter_child_nodes(ast): 

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

487 target = item.targets[0] 

488 value = item.value 

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

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

491 author = value.value 

492 

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

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

495 copyright = value.value 

496 

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

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

499 email = value.value 

500 

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

502 if isinstance(value, Constant) and isinstance(value.value, str): 

503 cause = TypeError("Variable '__keywords__' should be a list of strings.") 

504 raise PackagingError(f"Couldn't extract '__keywords__' from '{sourceFile}'.") from cause 

505 elif isinstance(value, ast_List): 

506 for const in value.elts: 

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

508 keywords.append(const.value) 

509 else: 

510 cause = TypeError("List elements in '__keywords__' should be strings.") 

511 raise PackagingError(f"Couldn't extract '__keywords__' from '{sourceFile}'.") from cause 

512 else: 

513 cause = TypeError(f"Used unsupported type '{getFullyQualifiedName(value)}' for variable '__keywords__'.") 

514 raise PackagingError(f"Couldn't extract '__keywords__' from '{sourceFile}'.") from cause 

515 

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

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

518 license = value.value 

519 

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

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

522 version = value.value 

523 

524 if author is None: 

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

526 

527 if copyright is None: 

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

529 

530 if email is None: 

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

532 

533 if license is None: 

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

535 

536 if version is None: 

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

538 

539 return VersionInformation(author, email, copyright, license, version, description, keywords) 

540 

541 

542STATUS: dict[str, str] = { 

543 "planning": "1 - Planning", 

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

545 "alpha": "3 - Alpha", 

546 "beta": "4 - Beta", 

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

548 "mature": "6 - Mature", 

549 "inactive": "7 - Inactive" 

550} 

551""" 

552A dictionary of supported development status values. 

553 

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

555 

5561. Planning 

5572. Pre-Alpha 

5583. Alpha 

5594. Beta 

5605. Production/Stable 

5616. Mature 

5627. Inactive 

563 

564.. seealso:: 

565 

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

567""" 

568 

569DEFAULT_LICENSE = Apache_2_0_License 

570""" 

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

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

573""" 

574 

575DEFAULT_PY_VERSIONS = ("3.11", "3.12", "3.13", "3.14", "3.15") 

576""" 

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

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

579 

580.. seealso:: 

581 

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

583""" 

584 

585DEFAULT_CLASSIFIERS = ( 

586 "Operating System :: OS Independent", 

587 "Intended Audience :: Developers", 

588 "Topic :: Utilities" 

589 ) 

590""" 

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

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

593 

594.. seealso:: 

595 

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

597""" 

598 

599DEFAULT_README = Path("README.md") 

600""" 

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

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

603""" 

604 

605DEFAULT_REQUIREMENTS = Path("requirements.txt") 

606""" 

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

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

609""" 

610 

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

612""" 

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

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

615""" 

616 

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

618""" 

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

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

621""" 

622 

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

624""" 

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

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

627""" 

628 

629DEFAULT_VERSION_FILE = Path("__init__.py") 

630 

631_DIRECT_REFERENCE_PATTERN = re_compile(r"^\s*[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\s*(?:\[[^\]]*\])?\s*@") 

632 

633 

634def _removeDirectReferences(requirements: Iterable[str], extra: str, filePath: Nullable[Path]) -> list[str]: 

635 """ 

636 Remove direct references (``name @ URL``) from the requirements of an extra. 

637 

638 The Python Package Index rejects a package whose metadata contains a direct reference. Each removed requirement is 

639 reported as a :exc:`DirectReferenceWarning` to a :class:`~pyTooling.Warning.WarningCollector`. 

640 

641 :param requirements: Requirements of the extra. 

642 :param extra: Name of the extra. 

643 :param filePath: The requirements file the requirements were read from, or ``None`` for 

644 parameter 'additionalRequirements'. 

645 :returns: The requirements, which aren't direct references. 

646 :raises UnhandledCriticalWarningError: If a requirement is a direct reference and no 

647 :class:`~pyTooling.Warning.WarningCollector` collects the warning. 

648 """ 

649 source = "parameter 'additionalRequirements'" if filePath is None else f"'{filePath}'" 

650 

651 result = [] 

652 for requirement in requirements: 

653 if _DIRECT_REFERENCE_PATTERN.match(requirement) is None: 

654 result.append(requirement) 

655 continue 

656 

657 WarningCollector.Raise(DirectReferenceWarning( 

658 f"Requirement '{requirement}' (extra '{extra}', from {source}) is a direct reference, which PyPI rejects; " 

659 f"it's left out of the extra.", 

660 requirement=requirement, 

661 extra=extra, 

662 filePath=filePath 

663 )) 

664 

665 return result 

666 

667 

668def _collectEntryPoints( 

669 consoleScripts: Nullable[dict[str, str]], 

670 guiScripts: Nullable[dict[str, str]], 

671 pytestPlugins: Nullable[dict[str, str]] 

672) -> Nullable[dict[str, list[str]]]: 

673 """ 

674 Collect what a package advertises into setuptools' entry point groups. 

675 

676 Each parameter names one kind of thing a package can offer, so a caller says *what* it provides and this 

677 function knows *how* it is declared - which group it belongs to, and setuptools' ``"name = entry.point"`` 

678 spelling. 

679 

680 :param consoleScripts: Command line programs, declared as ``console_scripts``. 

681 :param guiScripts: Windowed programs, declared as ``gui_scripts``. 

682 :param pytestPlugins: Pytest plugins, declared as ``pytest11``. 

683 :returns: A dictionary mapping a group to its entry point lines, or ``None`` if a package 

684 advertises nothing. 

685 """ 

686 groups = { 

687 "console_scripts": consoleScripts, 

688 "gui_scripts": guiScripts, 

689 "pytest11": pytestPlugins, 

690 } 

691 

692 entryPoints = { 

693 groupName: [f"{name} = {entryPoint}" for name, entryPoint in group.items()] 

694 for groupName, group in groups.items() 

695 if group is not None and len(group) > 0 

696 } 

697 

698 return entryPoints if len(entryPoints) > 0 else None 

699 

700 

701@export 

702def DescribePythonPackage( 

703 packageName: str, 

704 projectURL: str, 

705 sourceCodeURL: str, 

706 documentationURL: str, 

707 issueTrackerCodeURL: str, 

708 description: Nullable[str] = None, 

709 keywords: Nullable[Iterable[str]] = None, 

710 license: License = DEFAULT_LICENSE, 

711 readmeFile: Path = DEFAULT_README, 

712 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

713 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

714 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

715 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

716 additionalRequirements: Nullable[dict[str, list[str]]] = None, 

717 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

718 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

719 developmentStatus: str = "stable", 

720 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

721 consoleScripts: Nullable[dict[str, str]] = None, 

722 guiScripts: Nullable[dict[str, str]] = None, 

723 pytestPlugins: Nullable[dict[str, str]] = None, 

724 dataFiles: Nullable[dict[str, list[str]]] = None, 

725 debug: bool = False 

726) -> dict[str, Any]: 

727 """ 

728 Helper function to describe a Python package. 

729 

730 .. hint:: 

731 

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

733 

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

735 

736 .. topic:: Handling of namespace packages 

737 

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

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

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

741 

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

743 

744 * ``build``, ``build.*`` 

745 * ``dist``, ``dist.*`` 

746 * ``doc``, ``doc.*`` 

747 * ``tests``, ``tests.*`` 

748 

749 .. topic:: Handling of minimal Python version 

750 

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

752 

753 .. topic:: Handling of dunder variables 

754 

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

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

757 

758 * ``__author__``: :class:`str` 

759 * ``__copyright__``: :class:`str` 

760 * ``__email__``: :class:`str` 

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

762 * ``__license__``: :class:`str` 

763 * ``__version__``: :class:`str` 

764 

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

766 

767 .. topic:: Handling of package classifiers 

768 

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

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

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

772 

773 The following classifiers are implicitly handled: 

774 

775 license 

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

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

778 

779 Python versions 

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

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

782 

783 Development status 

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

785 

786 .. topic:: Handling of extra requirements 

787 

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

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

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

791 

792 ``doc`` 

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

794 ``test`` 

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

796 ``build`` 

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

798 User-defined 

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

800 dictionary will be added. 

801 ``all`` 

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

803 extra requirements. 

804 

805 A requirement with a direct reference (``name @ URL``, e.g. ``pyTooling.Sphinx @ git+https://...@dev``) is left out 

806 of every extra, because the Python Package Index rejects a package whose metadata contains one. Each one is 

807 reported as a critical :exc:`DirectReferenceWarning` to a :class:`~pyTooling.Warning.WarningCollector`; without 

808 a collector up the call-hierarchy, :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. So a project 

809 can name a dependency, which isn't on PyPI yet, by its repository in e.g. ``doc/requirements.txt`` - the 

810 documentation build installs it - and still be released, if its :file:`setup.py` collects the warnings. The base 

811 requirements (``requirementsFile``) are kept as they are: without one of them, the package wouldn't work. 

812 

813 .. topic:: Handling of keywords 

814 

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

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

817 

818 .. topic:: Handling of the description 

819 

820 If parameter ``description`` is not specified, the first paragraph of the module doc-string in 

821 ``sourceFileWithVersion`` is used, so a package describes itself in one place. An explicitly passed description 

822 always wins - including an empty one. 

823 

824 :param packageName: Name of the Python package. 

825 :param projectURL: URL to the Python project. 

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

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

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

829 :param description: Optional, short description of the package. (Default: the first paragraph 

830 of the module doc-string in ``sourceFileWithVersion``.) The long description 

831 is read from the README file. 

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

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

834 :const:`DEFAULT_LICENSE`) 

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

836 :const:`DEFAULT_README`) 

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

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

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

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

841 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`) 

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

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

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

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

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

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

848 like ``__version__``. (Default: ``__init__.py``, see 

849 :const:`DEFAULT_VERSION_FILE`) 

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

851 :const:`DEFAULT_CLASSIFIERS`) 

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

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

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

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

856 :param consoleScripts: Optional, a dictionary mapping command line names to entry points, declared 

857 as ``console_scripts``. (Default: None) 

858 :param guiScripts: Optional, like ``consoleScripts``, but declared as ``gui_scripts`` - on 

859 Windows such a program starts without a console window. (Default: None) 

860 :param pytestPlugins: Optional, a dictionary mapping plugin names to entry points, declared as 

861 ``pytest11``. The classifier ``Framework :: Pytest`` is added with it. 

862 (Default: None) 

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

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

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

866 :raises MissingDependencyError: If package 'setuptools' is not available. 

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

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

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

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

871 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type 

872 :class:`~pathlib.Path`. 

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

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

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

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

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

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

879 :raises ValueError: If the content type of the README file is not supported. (See 

880 :func:`loadReadmeFile`) 

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

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

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

884 malformed. 

885 :raises PackagingError: If no description was given and the package file has no module doc-string. 

886 :raises UnhandledCriticalWarningError: If an extra's requirement is a direct reference and no 

887 :class:`~pyTooling.Warning.WarningCollector` collects the 

888 :exc:`DirectReferenceWarning`. 

889 """ 

890 try: 

891 from setuptools import find_packages, find_namespace_packages 

892 except ImportError as ex: 

893 raise MissingDependencyError(dependency="setuptools", extra="packaging") from ex 

894 

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

896 

897 # Read README for upload to PyPI 

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

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

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

901 raise ex 

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

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

904 else: 

905 readme = loadReadmeFile(readmeFile) 

906 

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

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

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

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

911 raise ex 

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

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

914 else: 

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

916 

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

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

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

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

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

922 raise ex 

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

924 if debug: 

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

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

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

928 else: 

929 extraRequirements["doc"] = _removeDirectReferences( 

930 list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug))), 

931 "doc", 

932 documentationRequirementsFile 

933 ) 

934 

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

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

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

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

939 raise ex 

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

941 if debug: 

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

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

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

945 else: 

946 extraRequirements["test"] = _removeDirectReferences( 

947 list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug))), 

948 "test", 

949 unittestRequirementsFile 

950 ) 

951 

952 if packagingRequirementsFile is not None: 

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

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

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

956 raise ex 

957 elif not packagingRequirementsFile.exists(): 

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

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

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

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

962 else: 

963 extraRequirements["build"] = _removeDirectReferences( 

964 list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug))), 

965 "build", 

966 packagingRequirementsFile 

967 ) 

968 

969 if additionalRequirements is not None: 

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

971 extraRequirements[key] = _removeDirectReferences(value, key, None) 

972 

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

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

975 

976 # Read __author__, __email__, __version__ from source file 

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

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

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

980 raise ex 

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

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

983 else: 

984 versionInformation = extractVersionInformation(sourceFileWithVersion) 

985 

986 # Scan for packages and source files 

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

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

989 exclude = [] 

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

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

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

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

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

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

996 

997 if "." in packageName: 

998 exclude.append(rootNamespace) 

999 packages = find_namespace_packages(exclude=exclude) 

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

1001 packageName = packageName[:-2] 

1002 else: 

1003 packages = find_packages(exclude=exclude) 

1004 

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

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

1007 for package in packages: 

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

1009 

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

1011 keywords = versionInformation.Keywords 

1012 

1013 if description is None: 

1014 description = versionInformation.Description 

1015 

1016 if len(description) == 0: 

1017 ex = PackagingError(f"Package '{packageName}' has no description.") 

1018 ex.add_note(f"Neither was parameter 'description' given, nor has '{sourceFileWithVersion}' a module doc-string.") 

1019 ex.add_note("Describe the package in the first paragraph of that doc-string, or pass parameter 'description'.") 

1020 raise ex 

1021 

1022 # Assemble classifiers 

1023 classifiers = list(classifiers) 

1024 

1025 # Check the license; it reaches setuptools as an SPDX expression, not as a classifier 

1026 if not isinstance(license, License): 

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

1028 ex.add_note(f"Got type '{getFullyQualifiedName(license)}'.") 

1029 raise ex 

1030 

1031 if pytestPlugins is not None and "Framework :: Pytest" not in classifiers: 

1032 classifiers.append("Framework :: Pytest") 

1033 

1034 if (deprecated := [classifier for classifier in classifiers if classifier.startswith("License ::")]) != []: 

1035 names = "', '".join(deprecated) 

1036 print(f"[pyTooling.Packaging] License classifiers are deprecated: '{names}'.") 

1037 print( "[pyTooling.Packaging] Remove them; the 'license' parameter becomes the SPDX expression setuptools wants.") 

1038 

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

1040 """ 

1041 A simple natural sorting implementation. 

1042 

1043 :param array: The strings to sort. 

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

1045 """ 

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

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

1048 """ 

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

1050 

1051 :param text: The text to convert. 

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

1053 """ 

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

1055 

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

1057 """ 

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

1059 their value. 

1060 

1061 :param text: The text to split. 

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

1063 """ 

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

1065 

1066 sortedArray = list(array) 

1067 sortedArray.sort(key=_createKey) 

1068 return sortedArray 

1069 

1070 pythonVersions = _naturalSorting(pythonVersions) 

1071 

1072 # Translate Python versions to classifiers 

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

1074 for v in pythonVersions: 

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

1076 

1077 # Translate status to classifier 

1078 try: 

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

1080 except KeyError: # pragma: no cover 

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

1082 

1083 # Assemble all package information 

1084 parameters = { 

1085 "name": packageName, 

1086 "version": versionInformation.Version, 

1087 "author": versionInformation.Author, 

1088 "author_email": versionInformation.Email, 

1089 "license": license.SPDXIdentifier, 

1090 "description": description, 

1091 "long_description": readme.Content, 

1092 "long_description_content_type": readme.MimeType, 

1093 "url": projectURL, 

1094 "project_urls": { 

1095 'Documentation': documentationURL, 

1096 'Source Code': sourceCodeURL, 

1097 'Issue Tracker': issueTrackerCodeURL 

1098 }, 

1099 "packages": packages, 

1100 "classifiers": classifiers, 

1101 "keywords": keywords, 

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

1103 "install_requires": requirements, 

1104 } 

1105 

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

1107 parameters["extras_require"] = extraRequirements 

1108 

1109 if (groups := _collectEntryPoints(consoleScripts, guiScripts, pytestPlugins)) is not None: 

1110 parameters["entry_points"] = groups 

1111 

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

1113 parameters["package_data"] = dataFiles 

1114 

1115 return parameters 

1116 

1117 

1118@export 

1119def DescribePythonPackageHostedOnGitHub( 

1120 packageName: str, 

1121 gitHubNamespace: str, 

1122 gitHubRepository: Nullable[str] = None, 

1123 projectURL: Nullable[str] = None, 

1124 description: Nullable[str] = None, 

1125 keywords: Nullable[Iterable[str]] = None, 

1126 license: License = DEFAULT_LICENSE, 

1127 readmeFile: Path = DEFAULT_README, 

1128 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

1129 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

1130 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

1131 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

1132 additionalRequirements: Nullable[dict[str, list[str]]] = None, 

1133 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

1134 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

1135 developmentStatus: str = "stable", 

1136 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

1137 consoleScripts: Nullable[dict[str, str]] = None, 

1138 guiScripts: Nullable[dict[str, str]] = None, 

1139 pytestPlugins: Nullable[dict[str, str]] = None, 

1140 dataFiles: Nullable[dict[str, list[str]]] = None, 

1141 debug: bool = False 

1142) -> dict[str, Any]: 

1143 """ 

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

1145 

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

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

1148 

1149 :param packageName: Name of the Python package. 

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

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

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

1153 :param description: Optional, short description of the package. (Default: the first paragraph 

1154 of the module doc-string in ``sourceFileWithVersion``.) The long description 

1155 is read from the README file. 

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

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

1158 :const:`DEFAULT_LICENSE`) 

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

1160 :const:`DEFAULT_README`) 

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

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

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

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

1165 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`) 

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

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

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

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

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

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

1172 like ``__version__``. (Default: ``__init__.py``, see 

1173 :const:`DEFAULT_VERSION_FILE`) 

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

1175 :const:`DEFAULT_CLASSIFIERS`) 

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

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

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

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

1180 :param consoleScripts: Optional, a dictionary mapping command line names to entry points, declared 

1181 as ``console_scripts``. (Default: None) 

1182 :param guiScripts: Optional, like ``consoleScripts``, but declared as ``gui_scripts`` - on 

1183 Windows such a program starts without a console window. (Default: None) 

1184 :param pytestPlugins: Optional, a dictionary mapping plugin names to entry points, declared as 

1185 ``pytest11``. The classifier ``Framework :: Pytest`` is added with it. 

1186 (Default: None) 

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

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

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

1190 :raises MissingDependencyError: If package 'setuptools' is not available. 

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

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

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

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

1195 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type 

1196 :class:`~pathlib.Path`. 

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

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

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

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

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

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

1203 :raises ValueError: If the content type of the README file is not supported. (See 

1204 :func:`loadReadmeFile`) 

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

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

1207 :raises PackagingError: If no description was given and the package file has no module doc-string. 

1208 :raises UnhandledCriticalWarningError: If an extra's requirement is a direct reference and no 

1209 :class:`~pyTooling.Warning.WarningCollector` collects the 

1210 :exc:`DirectReferenceWarning`. 

1211 """ 

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

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

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

1215 gitHubRepository = packageName[:-2] 

1216 else: 

1217 gitHubRepository = packageName 

1218 

1219 # Derive URLs 

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

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

1222 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

1223 

1224 projectURL = projectURL if projectURL is not None else sourceCodeURL 

1225 

1226 return DescribePythonPackage( 

1227 packageName=packageName, 

1228 description=description, 

1229 keywords=keywords, 

1230 projectURL=projectURL, 

1231 sourceCodeURL=sourceCodeURL, 

1232 documentationURL=documentationURL, 

1233 issueTrackerCodeURL=issueTrackerCodeURL, 

1234 license=license, 

1235 readmeFile=readmeFile, 

1236 requirementsFile=requirementsFile, 

1237 documentationRequirementsFile=documentationRequirementsFile, 

1238 unittestRequirementsFile=unittestRequirementsFile, 

1239 packagingRequirementsFile=packagingRequirementsFile, 

1240 additionalRequirements=additionalRequirements, 

1241 sourceFileWithVersion=sourceFileWithVersion, 

1242 classifiers=classifiers, 

1243 developmentStatus=developmentStatus, 

1244 pythonVersions=pythonVersions, 

1245 consoleScripts=consoleScripts, 

1246 guiScripts=guiScripts, 

1247 pytestPlugins=pytestPlugins, 

1248 dataFiles=dataFiles, 

1249 debug=debug, 

1250 )