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

311 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-12 11:14 +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 def __str__(self) -> str: 

286 return f"{self._version}" 

287 

288 

289@export 

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

291 """ 

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

293 

294 Supported variables: 

295 

296 * ``__author__`` 

297 * ``__copyright__`` 

298 * ``__email__`` 

299 * ``__keywords__`` 

300 * ``__license__`` 

301 * ``__version__`` 

302 

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

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

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

306 

307 """ 

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

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

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.10", "3.11", "3.12", "3.13", "3.14") 

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 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.") 

623 raise ex 

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

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

626 else: 

627 readme = loadReadmeFile(readmeFile) 

628 

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

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

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

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

633 raise ex 

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

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

636 else: 

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

638 

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

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

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

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

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

644 raise ex 

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

646 if debug: 

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

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

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

650 else: 

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

652 

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

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

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

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

657 raise ex 

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

659 if debug: 

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

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

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

663 else: 

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

665 

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

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

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

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

670 raise ex 

671 elif not packagingRequirementsFile.exists(): 

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

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

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

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

676 else: 

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

678 

679 if additionalRequirements is not None: 

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

681 extraRequirements[key] = value 

682 

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

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

685 

686 # Read __author__, __email__, __version__ from source file 

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

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

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

690 raise ex 

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

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

693 else: 

694 versionInformation = extractVersionInformation(sourceFileWithVersion) 

695 

696 # Scan for packages and source files 

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

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

699 exclude = [] 

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

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

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

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

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

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

706 

707 if "." in packageName: 

708 exclude.append(rootNamespace) 

709 packages = find_namespace_packages(exclude=exclude) 

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

711 packageName = packageName[:-2] 

712 else: 

713 packages = find_packages(exclude=exclude) 

714 

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

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

717 for package in packages: 

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

719 

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

721 keywords = versionInformation.Keywords 

722 

723 # Assemble classifiers 

724 classifiers = list(classifiers) 

725 

726 # Translate license to classifier 

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

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

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

730 raise ex 

731 classifiers.append(license.PythonClassifier) 

732 

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

734 """A simple natural sorting implementation.""" 

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

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

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

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

739 

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

741 """ 

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

743 their value. 

744 """ 

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

746 

747 sortedArray = list(array) 

748 sortedArray.sort(key=_createKey) 

749 return sortedArray 

750 

751 pythonVersions = _naturalSorting(pythonVersions) 

752 

753 # Translate Python versions to classifiers 

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

755 for v in pythonVersions: 

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

757 

758 # Translate status to classifier 

759 try: 

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

761 except KeyError: # pragma: no cover 

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

763 

764 # Assemble all package information 

765 parameters = { 

766 "name": packageName, 

767 "version": versionInformation.Version, 

768 "author": versionInformation.Author, 

769 "author_email": versionInformation.Email, 

770 "license": license.SPDXIdentifier, 

771 "description": description, 

772 "long_description": readme.Content, 

773 "long_description_content_type": readme.MimeType, 

774 "url": projectURL, 

775 "project_urls": { 

776 'Documentation': documentationURL, 

777 'Source Code': sourceCodeURL, 

778 'Issue Tracker': issueTrackerCodeURL 

779 }, 

780 "packages": packages, 

781 "classifiers": classifiers, 

782 "keywords": keywords, 

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

784 "install_requires": requirements, 

785 } 

786 

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

788 parameters["extras_require"] = extraRequirements 

789 

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

791 scripts = [] 

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

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

794 

795 parameters["entry_points"] = { 

796 "console_scripts": scripts 

797 } 

798 

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

800 parameters["package_data"] = dataFiles 

801 

802 return parameters 

803 

804 

805@export 

806def DescribePythonPackageHostedOnGitHub( 

807 packageName: str, 

808 description: str, 

809 gitHubNamespace: str, 

810 gitHubRepository: str = None, 

811 projectURL: str = None, 

812 keywords: Iterable[str] = None, 

813 license: License = DEFAULT_LICENSE, 

814 readmeFile: Path = DEFAULT_README, 

815 requirementsFile: Path = DEFAULT_REQUIREMENTS, 

816 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS, 

817 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS, 

818 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS, 

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

820 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE, 

821 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS, 

822 developmentStatus: str = "stable", 

823 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS, 

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

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

826 debug: bool = False 

827) -> Dict[str, Any]: 

828 """ 

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

830 

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

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

833 

834 :param packageName: Name of the Python package. 

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

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

837 :param gitHubRepository: Name of the GitHub repository. 

838 :param projectURL: URL to the Python project. 

839 :param keywords: A list of keywords. 

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

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

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

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

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

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

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

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

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

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

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

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

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

853 :param debug: Enable extended outputs for debugging. 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

870 """ 

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

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

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

874 gitHubRepository = packageName[:-2] 

875 else: 

876 gitHubRepository = packageName 

877 

878 # Derive URLs 

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

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

881 issueTrackerCodeURL = f"{sourceCodeURL}/issues" 

882 

883 projectURL = projectURL if projectURL is not None else sourceCodeURL 

884 

885 return DescribePythonPackage( 

886 packageName=packageName, 

887 description=description, 

888 keywords=keywords, 

889 projectURL=projectURL, 

890 sourceCodeURL=sourceCodeURL, 

891 documentationURL=documentationURL, 

892 issueTrackerCodeURL=issueTrackerCodeURL, 

893 license=license, 

894 readmeFile=readmeFile, 

895 requirementsFile=requirementsFile, 

896 documentationRequirementsFile=documentationRequirementsFile, 

897 unittestRequirementsFile=unittestRequirementsFile, 

898 packagingRequirementsFile=packagingRequirementsFile, 

899 additionalRequirements=additionalRequirements, 

900 sourceFileWithVersion=sourceFileWithVersion, 

901 classifiers=classifiers, 

902 developmentStatus=developmentStatus, 

903 pythonVersions=pythonVersions, 

904 consoleScripts=consoleScripts, 

905 dataFiles=dataFiles, 

906 debug=debug, 

907 )