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

299 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-06-06 22:21 +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 os import scandir as os_scandir 

39from pathlib import Path 

40from re import split as re_split 

41from sys import version_info 

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

43 

44try: 

45 from pyTooling.Decorators import export, readonly 

46 from pyTooling.Exceptions import ToolingException 

47 from pyTooling.MetaClasses import ExtendedType 

48 from pyTooling.Common import __version__, getFullyQualifiedName, firstElement 

49 from pyTooling.Licensing import License, Apache_2_0_License 

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

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

52 

53 try: 

54 from Decorators import export, readonly 

55 from Exceptions import ToolingException 

56 from MetaClasses import ExtendedType 

57 from Common import __version__, getFullyQualifiedName, firstElement 

58 from Licensing import License, Apache_2_0_License 

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

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

61 raise ex 

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 Readme: 

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

74 

75 _content: str #: Content of the README file 

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

77 

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

79 """ 

80 Initializes a README file wrapper. 

81 

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

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

84 """ 

85 self._content = content 

86 self._mimeType = mimeType 

87 

88 @readonly 

89 def Content(self) -> str: 

90 """ 

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

92 

93 :returns: Raw content of the README file. 

94 """ 

95 return self._content 

96 

97 @readonly 

98 def MimeType(self) -> str: 

99 """ 

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

101 

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

103 """ 

104 return self._mimeType 

105 

106 

107@export 

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

109 """ 

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

111 

112 Supported formats: 

113 

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

115 * Markdown (``*.md``) 

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

117 

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

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

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

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

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

123 """ 

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

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

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

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

128 raise ex 

129 

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

131 mimeType = "text/plain" 

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

133 mimeType = "text/markdown" 

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

135 mimeType = "text/x-rst" 

136 else: # pragma: no cover 

137 raise ValueError("Unsupported README format.") 

138 

139 try: 

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

141 return Readme( 

142 content=file.read(), 

143 mimeType=mimeType 

144 ) 

145 except FileNotFoundError as ex: 

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

147 

148 

149@export 

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

151 """ 

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

153 

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

155 

156 .. hint:: 

157 

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

159 

160 .. code-block:: Python 

161 

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

163 

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

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

166 :returns: A list of dependencies. 

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

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

169 """ 

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

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

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

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

174 raise ex 

175 

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

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

178 requirements = [] 

179 try: 

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

181 if debug: 

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

183 for line in file.readlines(): 

184 line = line.strip() 

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

186 continue 

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

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

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

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

191 elif line.startswith("https"): 

192 if debug: 

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

194 

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

196 splitItems = line.split("#") 

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

198 else: 

199 if debug: 

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

201 

202 requirements.append(line) 

203 except FileNotFoundError as ex: 

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

205 

206 return requirements 

207 

208 return _loadRequirementsFile(requirementsFile, 0) 

209 

210 

211@export 

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

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

214 

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

216 _copyright: str #: Copyright information. 

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

218 _keywords: List[str] #: Keywords. 

219 _license: str #: License name. 

220 _description: str #: Description of the package. 

221 _version: str #: Version number. 

222 

223 def __init__( 

224 self, 

225 author: str, 

226 email: str, 

227 copyright: str, 

228 license: str, 

229 version: str, 

230 description: str, 

231 keywords: Iterable[str] 

232 ) -> None: 

233 """ 

234 Initializes a Python package (version) information instance. 

235 

236 :param author: Author of the Python package. 

237 :param email: The author's email address 

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

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

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

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

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

243 """ 

244 self._author = author 

245 self._email = email 

246 self._copyright = copyright 

247 self._license = license 

248 self._version = version 

249 self._description = description 

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

251 

252 @readonly 

253 def Author(self) -> str: 

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

255 return self._author 

256 

257 @readonly 

258 def Copyright(self) -> str: 

259 """Copyright information.""" 

260 return self._copyright 

261 

262 @readonly 

263 def Description(self) -> str: 

264 """Package description text.""" 

265 return self._description 

266 

267 @readonly 

268 def Email(self) -> str: 

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

270 return self._email 

271 

272 @readonly 

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

274 """List of keywords.""" 

275 return self._keywords 

276 

277 @readonly 

278 def License(self) -> str: 

279 """License name.""" 

280 return self._license 

281 

282 @readonly 

283 def Version(self) -> str: 

284 """Version number.""" 

285 return self._version 

286 

287 

288@export 

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

290 """ 

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

292 

293 Supported variables: 

294 

295 * ``__author__`` 

296 * ``__copyright__`` 

297 * ``__email__`` 

298 * ``__keywords__`` 

299 * ``__license__`` 

300 * ``__version__`` 

301 

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

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

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

305 

306 """ 

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

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

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

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

311 raise ex 

312 

313 _author = None 

314 _copyright = None 

315 _description = "" 

316 _email = None 

317 _keywords = [] 

318 _license = None 

319 _version = None 

320 

321 try: 

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

323 content = file.read() 

324 except FileNotFoundError as ex: 

325 raise FileNotFoundError 

326 

327 try: 

328 ast = ast_parse(content) 

329 except Exception as ex: # pragma: no cover 

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

331 

332 for item in iter_child_nodes(ast): 

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

334 target = item.targets[0] 

335 value = item.value 

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

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

338 _author = value.value 

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

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

341 _copyright = value.value 

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

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

344 _email = value.value 

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

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

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

348 elif isinstance(value, ast_List): 

349 for const in value.elts: 

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

351 _keywords.append(const.value) 

352 else: # pragma: no cover 

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

354 else: # pragma: no cover 

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

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

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

358 _license = value.value 

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

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

361 _version = value.value 

362 

363 if _author is None: 

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

365 if _copyright is None: 

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

367 if _email is None: 

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

369 if _license is None: 

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

371 if _version is None: 

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

373 

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

375 

376 

377STATUS: Dict[str, str] = { 

378 "planning": "1 - Planning", 

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

380 "alpha": "3 - Alpha", 

381 "beta": "4 - Beta", 

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

383 "mature": "6 - Mature", 

384 "inactive": "7 - Inactive" 

385} 

386""" 

387A dictionary of supported development status values. 

388 

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

390 

3911. Planning 

3922. Pre-Alpha 

3933. Alpha 

3944. Beta 

3955. Production/Stable 

3966. Mature 

3977. Inactive 

398 

399.. seealso:: 

400 

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

402""" 

403 

404DEFAULT_LICENSE = Apache_2_0_License 

405""" 

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

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

408""" 

409 

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

411""" 

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

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

414 

415.. seealso:: 

416 

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

418""" 

419 

420DEFAULT_CLASSIFIERS = ( 

421 "Operating System :: OS Independent", 

422 "Intended Audience :: Developers", 

423 "Topic :: Utilities" 

424 ) 

425""" 

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

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

428 

429.. seealso:: 

430 

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

432""" 

433 

434DEFAULT_README = Path("README.md") 

435""" 

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

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

438""" 

439 

440DEFAULT_REQUIREMENTS = Path("requirements.txt") 

441""" 

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

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

444""" 

445 

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

447""" 

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

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

450""" 

451 

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

453""" 

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

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

456""" 

457 

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

459""" 

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

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

462""" 

463 

464DEFAULT_VERSION_FILE = Path("__init__.py") 

465 

466 

467@export 

468def DescribePythonPackage( 

469 packageName: str, 

470 description: str, 

471 projectURL: str, 

472 sourceCodeURL: str, 

473 documentationURL: str, 

474 issueTrackerCodeURL: str, 

475 keywords: Iterable[str] = None, 

476 license: License = DEFAULT_LICENSE, 

477 readmeFile: Path = DEFAULT_README, 

478 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

479 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

480 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

481 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

483 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

484 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

485 developmentStatus: str = "stable", 

486 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

489 debug: bool = False 

490) -> Dict[str, Any]: 

491 """ 

492 Helper function to describe a Python package. 

493 

494 .. hint:: 

495 

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

497 

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

499 

500 .. topic:: Handling of namespace packages 

501 

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

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

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

505 

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

507 

508 * ``build``, ``build.*`` 

509 * ``dist``, ``dist.*`` 

510 * ``doc``, ``doc.*`` 

511 * ``tests``, ``tests.*`` 

512 

513 .. topic:: Handling of minimal Python version 

514 

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

516 

517 .. topic:: Handling of dunder variables 

518 

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

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

521 

522 * ``__author__``: :class:`str` 

523 * ``__copyright__``: :class:`str` 

524 * ``__email__``: :class:`str` 

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

526 * ``__license__``: :class:`str` 

527 * ``__version__``: :class:`str` 

528 

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

530 

531 .. topic:: Handling of package classifiers 

532 

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

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

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

536 

537 The following classifiers are implicitly handled: 

538 

539 license 

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

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

542 

543 Python versions 

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

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

546 

547 Development status 

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

549 

550 .. topic:: Handling of extra requirements 

551 

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

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

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

555 

556 ``doc`` 

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

558 ``test`` 

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

560 ``build`` 

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

562 User-defined 

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

564 dictionary will be added. 

565 ``all`` 

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

567 extra requirements. 

568 

569 .. topic:: Handling of keywords 

570 

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

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

573 

574 :param packageName: Name of the Python package. 

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

576 :param projectURL: URL to the Python project. 

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

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

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

580 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

594 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

611 """ 

612 try: 

613 from setuptools import find_packages, find_namespace_packages 

614 except ImportError as ex: 

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

616 

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

618 

619 # Read README for upload to PyPI 

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

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

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

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

624 raise ex 

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

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

627 else: 

628 readme = loadReadmeFile(readmeFile) 

629 

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

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

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

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

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

635 raise ex 

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

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

638 else: 

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

640 

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

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

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

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

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

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

647 raise ex 

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

649 if debug: 

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

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

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

653 else: 

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

655 

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

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

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

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

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

661 raise ex 

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

663 if debug: 

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

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

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

667 else: 

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

669 

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

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

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

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

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

675 raise ex 

676 elif not packagingRequirementsFile.exists(): 

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

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

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

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

681 else: 

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

683 

684 if additionalRequirements is not None: 

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

686 extraRequirements[key] = value 

687 

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

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

690 

691 # Read __author__, __email__, __version__ from source file 

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

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

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

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

696 raise ex 

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

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

699 else: 

700 versionInformation = extractVersionInformation(sourceFileWithVersion) 

701 

702 # Scan for packages and source files 

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

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

705 exclude = [] 

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

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

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

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

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

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

712 

713 if "." in packageName: 

714 exclude.append(rootNamespace) 

715 packages = find_namespace_packages(exclude=exclude) 

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

717 packageName = packageName[:-2] 

718 else: 

719 packages = find_packages(exclude=exclude) 

720 

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

722 print("[pyTooling.Packaging] Found packages:") 

723 for package in packages: 

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

725 

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

727 keywords = versionInformation.Keywords 

728 

729 # Assemble classifiers 

730 classifiers = list(classifiers) 

731 

732 # Translate license to classifier 

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

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

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

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

737 raise ex 

738 classifiers.append(license.PythonClassifier) 

739 

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

741 """A simple natural sorting implementation.""" 

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

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

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

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

746 

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

748 """ 

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

750 their value. 

751 """ 

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

753 

754 sortedArray = list(array) 

755 sortedArray.sort(key=_createKey) 

756 return sortedArray 

757 

758 pythonVersions = _naturalSorting(pythonVersions) 

759 

760 # Translate Python versions to classifiers 

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

762 for v in pythonVersions: 

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

764 

765 # Translate status to classifier 

766 try: 

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

768 except KeyError: # pragma: no cover 

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

770 

771 # Assemble all package information 

772 parameters = { 

773 "name": packageName, 

774 "version": versionInformation.Version, 

775 "author": versionInformation.Author, 

776 "author_email": versionInformation.Email, 

777 "license": license.SPDXIdentifier, 

778 "description": description, 

779 "long_description": readme.Content, 

780 "long_description_content_type": readme.MimeType, 

781 "url": projectURL, 

782 "project_urls": { 

783 'Documentation': documentationURL, 

784 'Source Code': sourceCodeURL, 

785 'Issue Tracker': issueTrackerCodeURL 

786 }, 

787 "packages": packages, 

788 "classifiers": classifiers, 

789 "keywords": keywords, 

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

791 "install_requires": requirements, 

792 } 

793 

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

795 parameters["extras_require"] = extraRequirements 

796 

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

798 scripts = [] 

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

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

801 

802 parameters["entry_points"] = { 

803 "console_scripts": scripts 

804 } 

805 

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

807 parameters["package_data"] = dataFiles 

808 

809 return parameters 

810 

811 

812@export 

813def DescribePythonPackageHostedOnGitHub( 

814 packageName: str, 

815 description: str, 

816 gitHubNamespace: str, 

817 gitHubRepository: str = None, 

818 projectURL: str = None, 

819 keywords: Iterable[str] = None, 

820 license: License = DEFAULT_LICENSE, 

821 readmeFile: Path = DEFAULT_README, 

822 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

823 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

824 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

825 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

827 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

828 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

829 developmentStatus: str = "stable", 

830 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

833 debug: bool = False 

834) -> Dict[str, Any]: 

835 """ 

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

837 

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

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

840 

841 :param packageName: Name of the Python package. 

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

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

844 :param gitHubRepository: Name of the GitHub repository. 

845 :param projectURL: URL to the Python project. 

846 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

860 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

877 """ 

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

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

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

881 gitHubRepository = packageName[:-2] 

882 else: 

883 gitHubRepository = packageName 

884 

885 # Derive URLs 

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

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

888 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

889 

890 projectURL = projectURL if projectURL is not None else sourceCodeURL 

891 

892 return DescribePythonPackage( 

893 packageName=packageName, 

894 description=description, 

895 keywords=keywords, 

896 projectURL=projectURL, 

897 sourceCodeURL=sourceCodeURL, 

898 documentationURL=documentationURL, 

899 issueTrackerCodeURL=issueTrackerCodeURL, 

900 license=license, 

901 readmeFile=readmeFile, 

902 requirementsFile=requirementsFile, 

903 documentationRequirementsFile=documentationRequirementsFile, 

904 unittestRequirementsFile=unittestRequirementsFile, 

905 packagingRequirementsFile=packagingRequirementsFile, 

906 additionalRequirements=additionalRequirements, 

907 sourceFileWithVersion=sourceFileWithVersion, 

908 classifiers=classifiers, 

909 developmentStatus=developmentStatus, 

910 pythonVersions=pythonVersions, 

911 consoleScripts=consoleScripts, 

912 dataFiles=dataFiles, 

913 debug=debug, 

914 )