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

309 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 06:41 +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 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.") 

127 raise ex 

128 

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

130 mimeType = "text/plain" 

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

132 mimeType = "text/markdown" 

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

134 mimeType = "text/x-rst" 

135 else: # pragma: no cover 

136 raise ValueError("Unsupported README format.") 

137 

138 try: 

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

140 return Readme( 

141 content=file.read(), 

142 mimeType=mimeType 

143 ) 

144 except FileNotFoundError as ex: 

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

146 

147 

148@export 

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

150 """ 

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

152 

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

154 

155 .. hint:: 

156 

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

158 

159 .. code-block:: Python 

160 

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

162 

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

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

165 :returns: A list of dependencies. 

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

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

168 """ 

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

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

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

172 raise ex 

173 

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

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

176 requirements = [] 

177 try: 

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

179 if debug: 

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

181 for line in file.readlines(): 

182 line = line.strip() 

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

184 continue 

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

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

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

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

189 elif line.startswith("https"): 

190 if debug: 

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

192 

193 # Convert 'URL#NAME' to 'NAME @ URL' 

194 splitItems = line.split("#") 

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

196 else: 

197 if debug: 

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

199 

200 requirements.append(line) 

201 except FileNotFoundError as ex: 

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

203 

204 return requirements 

205 

206 return _loadRequirementsFile(requirementsFile, 0) 

207 

208 

209@export 

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

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

212 

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

214 _copyright: str #: Copyright information. 

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

216 _keywords: List[str] #: Keywords. 

217 _license: str #: License name. 

218 _description: str #: Description of the package. 

219 _version: str #: Version number. 

220 

221 def __init__( 

222 self, 

223 author: str, 

224 email: str, 

225 copyright: str, 

226 license: str, 

227 version: str, 

228 description: str, 

229 keywords: Iterable[str] 

230 ) -> None: 

231 """ 

232 Initializes a Python package (version) information instance. 

233 

234 :param author: Author of the Python package. 

235 :param email: The author's email address 

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

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

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

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

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

241 """ 

242 self._author = author 

243 self._email = email 

244 self._copyright = copyright 

245 self._license = license 

246 self._version = version 

247 self._description = description 

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

249 

250 @readonly 

251 def Author(self) -> str: 

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

253 return self._author 

254 

255 @readonly 

256 def Copyright(self) -> str: 

257 """Copyright information.""" 

258 return self._copyright 

259 

260 @readonly 

261 def Description(self) -> str: 

262 """Package description text.""" 

263 return self._description 

264 

265 @readonly 

266 def Email(self) -> str: 

267 """Email address of the author.""" 

268 return self._email 

269 

270 @readonly 

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

272 """List of keywords.""" 

273 return self._keywords 

274 

275 @readonly 

276 def License(self) -> str: 

277 """License name.""" 

278 return self._license 

279 

280 @readonly 

281 def Version(self) -> str: 

282 """Version number.""" 

283 return self._version 

284 

285 

286@export 

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

288 """ 

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

290 

291 Supported variables: 

292 

293 * ``__author__`` 

294 * ``__copyright__`` 

295 * ``__email__`` 

296 * ``__keywords__`` 

297 * ``__license__`` 

298 * ``__version__`` 

299 

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

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

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

303 

304 """ 

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

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

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

308 raise ex 

309 

310 _author = None 

311 _copyright = None 

312 _description = "" 

313 _email = None 

314 _keywords = [] 

315 _license = None 

316 _version = None 

317 

318 try: 

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

320 content = file.read() 

321 except FileNotFoundError as ex: 

322 raise FileNotFoundError 

323 

324 try: 

325 ast = ast_parse(content) 

326 except Exception as ex: # pragma: no cover 

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

328 

329 for item in iter_child_nodes(ast): 

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

331 target = item.targets[0] 

332 value = item.value 

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

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

335 _author = value.value 

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

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

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

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

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

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

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

345 elif isinstance(value, ast_List): 

346 for const in value.elts: 

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

348 _keywords.append(const.value) 

349 else: # pragma: no cover 

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

351 else: # pragma: no cover 

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

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

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

355 _license = value.value 

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

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

358 _version = value.value 

359 

360 if _author is None: 

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

362 if _copyright is None: 

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

364 if _email is None: 

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

366 if _license is None: 

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

368 if _version is None: 

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

370 

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

372 

373 

374STATUS: Dict[str, str] = { 

375 "planning": "1 - Planning", 

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

377 "alpha": "3 - Alpha", 

378 "beta": "4 - Beta", 

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

380 "mature": "6 - Mature", 

381 "inactive": "7 - Inactive" 

382} 

383""" 

384A dictionary of supported development status values. 

385 

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

387 

3881. Planning 

3892. Pre-Alpha 

3903. Alpha 

3914. Beta 

3925. Production/Stable 

3936. Mature 

3947. Inactive 

395 

396.. seealso:: 

397 

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

399""" 

400 

401DEFAULT_LICENSE = Apache_2_0_License 

402""" 

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

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

405""" 

406 

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

408""" 

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

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

411 

412.. seealso:: 

413 

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

415""" 

416 

417DEFAULT_CLASSIFIERS = ( 

418 "Operating System :: OS Independent", 

419 "Intended Audience :: Developers", 

420 "Topic :: Utilities" 

421 ) 

422""" 

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

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

425 

426.. seealso:: 

427 

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

429""" 

430 

431DEFAULT_README = Path("README.md") 

432""" 

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

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

435""" 

436 

437DEFAULT_REQUIREMENTS = Path("requirements.txt") 

438""" 

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

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

441""" 

442 

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

444""" 

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

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

447""" 

448 

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

450""" 

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

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

453""" 

454 

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

456""" 

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

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

459""" 

460 

461DEFAULT_VERSION_FILE = Path("__init__.py") 

462 

463 

464@export 

465def DescribePythonPackage( 

466 packageName: str, 

467 description: str, 

468 projectURL: str, 

469 sourceCodeURL: str, 

470 documentationURL: str, 

471 issueTrackerCodeURL: str, 

472 keywords: Iterable[str] = None, 

473 license: License = DEFAULT_LICENSE, 

474 readmeFile: Path = DEFAULT_README, 

475 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

476 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

477 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

478 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

480 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE, 

481 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

482 developmentStatus: str = "stable", 

483 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

486 debug: bool = False 

487) -> Dict[str, Any]: 

488 """ 

489 Helper function to describe a Python package. 

490 

491 .. hint:: 

492 

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

494 

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

496 

497 .. topic:: Handling of namespace packages 

498 

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

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

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

502 

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

504 

505 * ``build``, ``build.*`` 

506 * ``dist``, ``dist.*`` 

507 * ``doc``, ``doc.*`` 

508 * ``tests``, ``tests.*`` 

509 

510 .. topic:: Handling of minimal Python version 

511 

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

513 

514 .. topic:: Handling of dunder variables 

515 

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

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

518 

519 * ``__author__``: :class:`str` 

520 * ``__copyright__``: :class:`str` 

521 * ``__email__``: :class:`str` 

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

523 * ``__license__``: :class:`str` 

524 * ``__version__``: :class:`str` 

525 

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

527 

528 .. topic:: Handling of package classifiers 

529 

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

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

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

533 

534 The following classifiers are implicitly handled: 

535 

536 license 

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

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

539 

540 Python versions 

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

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

543 

544 Development status 

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

546 

547 .. topic:: Handling of extra requirements 

548 

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

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

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

552 

553 ``doc`` 

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

555 ``test`` 

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

557 ``build`` 

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

559 User-defined 

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

561 dictionary will be added. 

562 ``all`` 

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

564 extra requirements. 

565 

566 .. topic:: Handling of keywords 

567 

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

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

570 

571 :param packageName: Name of the Python package. 

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

573 :param projectURL: URL to the Python project. 

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

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

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

577 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

591 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

608 """ 

609 try: 

610 from setuptools import find_packages, find_namespace_packages 

611 except ImportError as ex: 

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

613 

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

615 

616 # Read README for upload to PyPI 

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

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

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

620 raise ex 

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

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

623 else: 

624 readme = loadReadmeFile(readmeFile) 

625 

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

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

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

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

630 raise ex 

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

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

633 else: 

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

635 

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

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

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

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

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

641 raise ex 

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

643 if debug: 

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

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

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

647 else: 

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

649 

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

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

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

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

654 raise ex 

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

656 if debug: 

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

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

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

660 else: 

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

662 

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

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

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

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

667 raise ex 

668 elif not packagingRequirementsFile.exists(): 

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

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

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

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

673 else: 

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

675 

676 if additionalRequirements is not None: 

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

678 extraRequirements[key] = value 

679 

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

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

682 

683 # Read __author__, __email__, __version__ from source file 

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

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

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

687 raise ex 

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

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

690 else: 

691 versionInformation = extractVersionInformation(sourceFileWithVersion) 

692 

693 # Scan for packages and source files 

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

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

696 exclude = [] 

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

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

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

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

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

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

703 

704 if "." in packageName: 

705 exclude.append(rootNamespace) 

706 packages = find_namespace_packages(exclude=exclude) 

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

708 packageName = packageName[:-2] 

709 else: 

710 packages = find_packages(exclude=exclude) 

711 

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

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

714 for package in packages: 

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

716 

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

718 keywords = versionInformation.Keywords 

719 

720 # Assemble classifiers 

721 classifiers = list(classifiers) 

722 

723 # Translate license to classifier 

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

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

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

727 raise ex 

728 classifiers.append(license.PythonClassifier) 

729 

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

731 """A simple natural sorting implementation.""" 

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

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

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

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

736 

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

738 """ 

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

740 their value. 

741 """ 

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

743 

744 sortedArray = list(array) 

745 sortedArray.sort(key=_createKey) 

746 return sortedArray 

747 

748 pythonVersions = _naturalSorting(pythonVersions) 

749 

750 # Translate Python versions to classifiers 

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

752 for v in pythonVersions: 

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

754 

755 # Translate status to classifier 

756 try: 

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

758 except KeyError: # pragma: no cover 

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

760 

761 # Assemble all package information 

762 parameters = { 

763 "name": packageName, 

764 "version": versionInformation.Version, 

765 "author": versionInformation.Author, 

766 "author_email": versionInformation.Email, 

767 "license": license.SPDXIdentifier, 

768 "description": description, 

769 "long_description": readme.Content, 

770 "long_description_content_type": readme.MimeType, 

771 "url": projectURL, 

772 "project_urls": { 

773 'Documentation': documentationURL, 

774 'Source Code': sourceCodeURL, 

775 'Issue Tracker': issueTrackerCodeURL 

776 }, 

777 "packages": packages, 

778 "classifiers": classifiers, 

779 "keywords": keywords, 

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

781 "install_requires": requirements, 

782 } 

783 

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

785 parameters["extras_require"] = extraRequirements 

786 

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

788 scripts = [] 

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

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

791 

792 parameters["entry_points"] = { 

793 "console_scripts": scripts 

794 } 

795 

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

797 parameters["package_data"] = dataFiles 

798 

799 return parameters 

800 

801 

802@export 

803def DescribePythonPackageHostedOnGitHub( 

804 packageName: str, 

805 description: str, 

806 gitHubNamespace: str, 

807 gitHubRepository: str = None, 

808 projectURL: str = None, 

809 keywords: Iterable[str] = None, 

810 license: License = DEFAULT_LICENSE, 

811 readmeFile: Path = DEFAULT_README, 

812 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

813 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

814 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

815 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

817 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

818 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

819 developmentStatus: str = "stable", 

820 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

823 debug: bool = False 

824) -> Dict[str, Any]: 

825 """ 

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

827 

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

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

830 

831 :param packageName: Name of the Python package. 

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

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

834 :param gitHubRepository: Name of the GitHub repository. 

835 :param projectURL: URL to the Python project. 

836 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

850 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

867 """ 

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

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

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

871 gitHubRepository = packageName[:-2] 

872 else: 

873 gitHubRepository = packageName 

874 

875 # Derive URLs 

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

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

878 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

879 

880 projectURL = projectURL if projectURL is not None else sourceCodeURL 

881 

882 return DescribePythonPackage( 

883 packageName=packageName, 

884 description=description, 

885 keywords=keywords, 

886 projectURL=projectURL, 

887 sourceCodeURL=sourceCodeURL, 

888 documentationURL=documentationURL, 

889 issueTrackerCodeURL=issueTrackerCodeURL, 

890 license=license, 

891 readmeFile=readmeFile, 

892 requirementsFile=requirementsFile, 

893 documentationRequirementsFile=documentationRequirementsFile, 

894 unittestRequirementsFile=unittestRequirementsFile, 

895 packagingRequirementsFile=packagingRequirementsFile, 

896 additionalRequirements=additionalRequirements, 

897 sourceFileWithVersion=sourceFileWithVersion, 

898 classifiers=classifiers, 

899 developmentStatus=developmentStatus, 

900 pythonVersions=pythonVersions, 

901 consoleScripts=consoleScripts, 

902 dataFiles=dataFiles, 

903 debug=debug, 

904 )