Coverage for pyTooling/Packaging/__init__.py: 75%
301 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 07:24 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 07:24 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ __ _ ___| | ____ _ __ _(_)_ __ __ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_) / _` |/ __| |/ / _` |/ _` | | '_ \ / _` | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| __/ (_| | (__| < (_| | (_| | | | | | (_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| \__,_|\___|_|\_\__,_|\__, |_|_| |_|\__, | #
7# |_| |___/ |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2021-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A set of helper functions to describe a Python package for setuptools.
34.. hint::
36 See :ref:`high-level help <PACKAGING>` for explanations and usage examples.
37"""
38from ast import parse as ast_parse, iter_child_nodes, Assign, Constant, Name, List as ast_List
39from collections.abc import Sized
40from os import scandir as os_scandir
41from pathlib import Path
42from re import split as re_split
43from sys import version_info
44from typing import List, Iterable, Dict, Sequence, Any, Optional as Nullable, Union, Tuple
46from pyTooling.Decorators import export, readonly
47from pyTooling.Exceptions import ToolingException
48from pyTooling.MetaClasses import ExtendedType
49from pyTooling.Common import __version__, getFullyQualifiedName, firstElement
50from pyTooling.Licensing import License, Apache_2_0_License
53__all__ = [
54 "STATUS", "DEFAULT_LICENSE", "DEFAULT_PY_VERSIONS", "DEFAULT_CLASSIFIERS", "DEFAULT_README", "DEFAULT_REQUIREMENTS",
55 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS",
56 "DEFAULT_VERSION_FILE"
57]
60@export
61class Readme:
62 """Encapsulates the READMEs file content and MIME type."""
64 _content: str #: Content of the README file
65 _mimeType: str #: MIME type of the README content
67 def __init__(self, content: str, mimeType: str) -> None:
68 """
69 Initializes a README file wrapper.
71 :param content: Raw content of the README file.
72 :param mimeType: MIME type of the README file.
73 """
74 self._content = content
75 self._mimeType = mimeType
77 @readonly
78 def Content(self) -> str:
79 """
80 Read-only property to access the README's content.
82 :returns: Raw content of the README file.
83 """
84 return self._content
86 @readonly
87 def MimeType(self) -> str:
88 """
89 Read-only property to access the README's MIME type.
91 :returns: The MIME type of the README file.
92 """
93 return self._mimeType
96@export
97def loadReadmeFile(readmeFile: Path) -> Readme:
98 """
99 Read the README file (e.g. in Markdown format), so it can be used as long description for the package.
101 Supported formats:
103 * Plain text (``*.txt``)
104 * Markdown (``*.md``)
105 * ReStructured Text (``*.rst``)
107 :param readmeFile: Path to the `README` file as an instance of :class:`Path`.
108 :returns: A tuple containing the file content and the MIME type.
109 :raises TypeError: If parameter 'readmeFile' is not of type 'Path'.
110 :raises ValueError: If README file has an unsupported format.
111 :raises FileNotFoundError: If README file does not exist.
112 """
113 if not isinstance(readmeFile, Path): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
115 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
116 raise ex
118 if readmeFile.suffix == ".txt":
119 mimeType = "text/plain"
120 elif readmeFile.suffix == ".md":
121 mimeType = "text/markdown"
122 elif readmeFile.suffix == ".rst":
123 mimeType = "text/x-rst"
124 else: # pragma: no cover
125 raise ValueError("Unsupported README format.")
127 try:
128 with readmeFile.open("r", encoding="utf-8") as file:
129 return Readme(
130 content=file.read(),
131 mimeType=mimeType
132 )
133 except FileNotFoundError as ex:
134 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.") from ex
137@export
138def loadRequirementsFile(requirementsFile: Path, indent: int = 0, debug: bool = False) -> List[str]:
139 """
140 Reads a `requirements.txt` file (recursively) and extracts all specified dependencies into an array.
142 Special dependency entries like Git repository references are translates to match the syntax expected by setuptools.
144 .. hint::
146 Duplicates should be removed by converting the result to a :class:`set` and back to a :class:`list`.
148 .. code-block:: Python
150 requirements = list(set(loadRequirementsFile(requirementsFile)))
152 :param requirementsFile: Path to the ``requirements.txt`` file as an instance of :class:`Path`.
153 :param debug: If ``True``, print found dependencies and recursion.
154 :returns: A list of dependencies.
155 :raises TypeError: If parameter 'requirementsFile' is not of type 'Path'.
156 :raises FileNotFoundError: If requirements file does not exist.
157 """
158 if not isinstance(requirementsFile, Path): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 ex = TypeError(f"Parameter '{requirementsFile}' is not of type 'Path'.")
160 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
161 raise ex
163 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> List[str]:
164 """Recursive variant of :func:`loadRequirementsFile`."""
165 requirements = []
166 try:
167 with requirementsFile.open("r", encoding="utf-8") as file:
168 if debug:
169 print(f"[pyTooling.Packaging]{' ' * indent} Extracting requirements from '{requirementsFile}'.")
170 for line in file.readlines():
171 line = line.strip()
172 if line.startswith("#") or line == "":
173 continue
174 elif line.startswith("-r"):
175 # Remove the first word/argument (-r)
176 filename = line[2:].lstrip()
177 requirements += _loadRequirementsFile(requirementsFile.parent / filename, indent + 1)
178 elif line.startswith("https"):
179 if debug:
180 print(f"[pyTooling.Packaging]{' ' * indent} Found URL '{line}'.")
182 # Convert 'URL#NAME' to 'NAME @ URL'
183 splitItems = line.split("#")
184 requirements.append(f"{splitItems[1]} @ {splitItems[0]}")
185 else:
186 if debug:
187 print(f"[pyTooling.Packaging]{' ' * indent} - {line}")
189 requirements.append(line)
190 except FileNotFoundError as ex:
191 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex
193 return requirements
195 return _loadRequirementsFile(requirementsFile, 0)
198@export
199class VersionInformation(metaclass=ExtendedType, slots=True):
200 """Encapsulates version information extracted from a Python source file."""
202 _author: str #: Author name(s).
203 _copyright: str #: Copyright information.
204 _email: str #: Author's email address.
205 _keywords: List[str] #: Keywords.
206 _license: str #: License name.
207 _description: str #: Description of the package.
208 _version: str #: Version number.
210 def __init__(
211 self,
212 author: str,
213 email: str,
214 copyright: str,
215 license: str,
216 version: str,
217 description: str,
218 keywords: Iterable[str]
219 ) -> None:
220 """
221 Initializes a Python package (version) information instance.
223 :param author: Author of the Python package.
224 :param email: The author's email address
225 :param copyright: The copyright notice of the Package.
226 :param license: The Python package's license.
227 :param version: The Python package's version.
228 :param description: The Python package's short description.
229 :param keywords: The Python package's list of keywords.
230 """
231 self._author = author
232 self._email = email
233 self._copyright = copyright
234 self._license = license
235 self._version = version
236 self._description = description
237 self._keywords = [k for k in keywords]
239 @readonly
240 def Author(self) -> str:
241 """
242 Read-only property to access the name(s) of the package author(s) (:attr:`_author`).
244 :returns: Name(s) of the package author(s).
245 """
246 return self._author
248 @readonly
249 def Copyright(self) -> str:
250 """
251 Read-only property to access the package's copyright information (:attr:`_copyright`).
253 :returns: Copyright information.
254 """
255 return self._copyright
257 @readonly
258 def Description(self) -> str:
259 """
260 Read-only property to access the package description (:attr:`_description`).
262 :returns: Package description text.
263 """
264 return self._description
266 @readonly
267 def Email(self) -> str:
268 """
269 Read-only property to access the author's email address (:attr:`_email`).
271 :returns: Email address of the author.
272 """
273 return self._email
275 @readonly
276 def Keywords(self) -> List[str]:
277 """
278 Read-only property to access the package's keywords (:attr:`_keywords`).
280 :returns: List of keywords.
281 """
282 return self._keywords
284 @readonly
285 def License(self) -> str:
286 """
287 Read-only property to access the package's license (:attr:`_license`).
289 :returns: License name.
290 """
291 return self._license
293 @readonly
294 def Version(self) -> str:
295 """
296 Read-only property to access the package's version number (:attr:`_version`).
298 :returns: Version number.
299 """
300 return self._version
302 def __str__(self) -> str:
303 return f"{self._version}"
306@export
307def extractVersionInformation(sourceFile: Path) -> VersionInformation:
308 """
309 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information.
311 Supported variables:
313 * ``__author__``
314 * ``__copyright__``
315 * ``__email__``
316 * ``__keywords__``
317 * ``__license__``
318 * ``__version__``
320 :param sourceFile: Path to a Python source file as an instance of :class:`Path`.
321 :returns: An instance of :class:`VersionInformation` with gathered variable contents.
322 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`.
324 """
325 if not isinstance(sourceFile, Path): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 ex = TypeError(f"Parameter 'sourceFile' is not of type 'Path'.")
327 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.")
328 raise ex
330 _author = None
331 _copyright = None
332 _description = ""
333 _email = None
334 _keywords = []
335 _license = None
336 _version = None
338 try:
339 with sourceFile.open("r", encoding="utf-8") as file:
340 content = file.read()
341 except FileNotFoundError as ex:
342 raise FileNotFoundError
344 try:
345 ast = ast_parse(content)
346 except Exception as ex: # pragma: no cover
347 raise ToolingException(f"Internal error when parsing '{sourceFile}'.") from ex
349 for item in iter_child_nodes(ast):
350 if isinstance(item, Assign) and len(item.targets) == 1:
351 target = item.targets[0]
352 value = item.value
353 if isinstance(target, Name) and target.id == "__author__":
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 _author = value.value
356 if isinstance(target, Name) and target.id == "__copyright__":
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 _copyright = value.value
359 if isinstance(target, Name) and target.id == "__email__":
360 if isinstance(value, Constant) and isinstance(value.value, str): 360 ↛ 362line 360 didn't jump to line 362 because the condition on line 360 was always true
361 _email = value.value
362 if isinstance(target, Name) and target.id == "__keywords__":
363 if isinstance(value, Constant) and isinstance(value.value, str): # pragma: no cover
364 raise TypeError(f"Variable '__keywords__' should be a list of strings.")
365 elif isinstance(value, ast_List):
366 for const in value.elts:
367 if isinstance(const, Constant) and isinstance(const.value, str):
368 _keywords.append(const.value)
369 else: # pragma: no cover
370 raise TypeError(f"List elements in '__keywords__' should be strings.")
371 else: # pragma: no cover
372 raise TypeError(f"Used unsupported type for variable '__keywords__'.")
373 if isinstance(target, Name) and target.id == "__license__":
374 if isinstance(value, Constant) and isinstance(value.value, str): 374 ↛ 376line 374 didn't jump to line 376 because the condition on line 374 was always true
375 _license = value.value
376 if isinstance(target, Name) and target.id == "__version__":
377 if isinstance(value, Constant) and isinstance(value.value, str): 377 ↛ 349line 377 didn't jump to line 349 because the condition on line 377 was always true
378 _version = value.value
380 if _author is None:
381 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover
382 if _copyright is None:
383 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover
384 if _email is None:
385 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover
386 if _license is None:
387 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover
388 if _version is None:
389 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover
391 return VersionInformation(_author, _email, _copyright, _license, _version, _description, _keywords)
394STATUS: Dict[str, str] = {
395 "planning": "1 - Planning",
396 "pre-alpha": "2 - Pre-Alpha",
397 "alpha": "3 - Alpha",
398 "beta": "4 - Beta",
399 "stable": "5 - Production/Stable",
400 "mature": "6 - Mature",
401 "inactive": "7 - Inactive"
402}
403"""
404A dictionary of supported development status values.
406The mapping's value will be appended to ``Development Status :: `` to form a package classifier.
4081. Planning
4092. Pre-Alpha
4103. Alpha
4114. Beta
4125. Production/Stable
4136. Mature
4147. Inactive
416.. seealso::
418 `Python package classifiers <https://pypi.org/classifiers/>`__
419"""
421DEFAULT_LICENSE = Apache_2_0_License
422"""
423Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
424if parameter ``license`` is not assigned.
425"""
427DEFAULT_PY_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14")
428"""
429A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
430if parameter ``pythonVersions`` is not assigned.
432.. seealso::
434 `Status of Python versions <https://devguide.python.org/versions/>`__
435"""
437DEFAULT_CLASSIFIERS = (
438 "Operating System :: OS Independent",
439 "Intended Audience :: Developers",
440 "Topic :: Utilities"
441 )
442"""
443A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
444if parameter ``classifiers`` is not assigned.
446.. seealso::
448 `Python package classifiers <https://pypi.org/classifiers/>`__
449"""
451DEFAULT_README = Path("README.md")
452"""
453Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
454if parameter ``readmeFile`` is not assigned.
455"""
457DEFAULT_REQUIREMENTS = Path("requirements.txt")
458"""
459Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
460if parameter ``requirementsFile`` is not assigned.
461"""
463DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt")
464"""
465Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
466if parameter ``documentationRequirementsFile`` is not assigned.
467"""
469DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt")
470"""
471Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
472if parameter ``unittestRequirementsFile`` is not assigned.
473"""
475DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt")
476"""
477Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
478if parameter ``packagingRequirementsFile`` is not assigned.
479"""
481DEFAULT_VERSION_FILE = Path("__init__.py")
484@export
485def DescribePythonPackage(
486 packageName: str,
487 description: str,
488 projectURL: str,
489 sourceCodeURL: str,
490 documentationURL: str,
491 issueTrackerCodeURL: str,
492 keywords: Iterable[str] = None,
493 license: License = DEFAULT_LICENSE,
494 readmeFile: Path = DEFAULT_README,
495 requirementsFile: Path = DEFAULT_REQUIREMENTS,
496 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
497 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
498 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
499 additionalRequirements: Dict[str, List[str]] = None,
500 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE,
501 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
502 developmentStatus: str = "stable",
503 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
504 consoleScripts: Dict[str, str] = None,
505 dataFiles: Dict[str, List[str]] = None,
506 debug: bool = False
507) -> Dict[str, Any]:
508 """
509 Helper function to describe a Python package.
511 .. hint::
513 Some information will be gathered automatically from well-known files.
515 Examples: ``README.md``, ``requirements.txt``, ``__init__.py``
517 .. topic:: Handling of namespace packages
519 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then
520 :func:`setuptools.find_namespace_packages` is used to discover package files. |br|
521 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used.
523 In both cases, the following packages (directories) are excluded from search:
525 * ``build``, ``build.*``
526 * ``dist``, ``dist.*``
527 * ``doc``, ``doc.*``
528 * ``tests``, ``tests.*``
530 .. topic:: Handling of minimal Python version
532 The minimal required Python version is selected from parameter ``pythonVersions``.
534 .. topic:: Handling of dunder variables
536 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the
537 resulting AST will be searched for the following dunder variables:
539 * ``__author__``: :class:`str`
540 * ``__copyright__``: :class:`str`
541 * ``__email__``: :class:`str`
542 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`]
543 * ``__license__``: :class:`str`
544 * ``__version__``: :class:`str`
546 The gathered information be used to add further mappings in the result dictionary.
548 .. topic:: Handling of package classifiers
550 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional
551 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly
552 converted to package classifiers, so no need to specify them in parameter ``classifiers``.
554 The following classifiers are implicitly handled:
556 license
557 The license specified by parameter ``license`` is translated into a classifier. |br|
558 See also :meth:`pyTooling.Licensing.License.PythonClassifier`
560 Python versions
561 Always add ``Programming Language :: Python :: 3 :: Only``. |br|
562 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added.
564 Development status
565 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added.
567 .. topic:: Handling of extra requirements
569 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra*
570 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called
571 ``all`` is added, so developers can install all dependencies needed for package development.
573 ``doc``
574 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined.
575 ``test``
576 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined.
577 ``build``
578 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined.
579 User-defined
580 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the
581 dictionary will be added.
582 ``all``
583 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all
584 extra requirements.
586 .. topic:: Handling of keywords
588 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion``
589 will be used. Otherwise, the content of the parameter, if not None or empty.
591 :param packageName: Name of the Python package.
592 :param description: Short description of the package. The long description will be read from README file.
593 :param projectURL: URL to the Python project.
594 :param sourceCodeURL: URL to the Python source code.
595 :param documentationURL: URL to the package's documentation.
596 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system).
597 :param keywords: A list of keywords.
598 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
599 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
600 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
601 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
602 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
603 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
604 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
605 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
606 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
607 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
608 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
609 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
610 :param dataFiles: A dictionary mapping package names to lists of additional data files.
611 :param debug: Enable extended outputs for debugging.
612 :returns: A dictionary suitable for :func:`setuptools.setup`.
613 :raises ToolingException: If package 'setuptools' is not available.
614 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
615 :raises FileNotFoundError: If README file doesn't exist.
616 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
617 :raises FileNotFoundError: If requirements file doesn't exist.
618 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
619 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
620 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
621 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
622 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
623 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
624 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
625 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
626 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
627 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
628 """
629 try:
630 from setuptools import find_packages, find_namespace_packages
631 except ImportError as ex:
632 raise Exception(f"Optional dependency 'setuptools' not installed. Either install pyTooling with extra dependencies 'pyTooling[packaging]' or install 'setuptools' directly.") from ex
634 print(f"[pyTooling.Packaging] Python: {version_info.major}.{version_info.minor}.{version_info.micro}, pyTooling: {__version__}")
636 # Read README for upload to PyPI
637 if not isinstance(readmeFile, Path): 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true
638 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
639 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
640 raise ex
641 elif not readmeFile.exists(): 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true
642 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.")
643 else:
644 readme = loadReadmeFile(readmeFile)
646 # Read requirements file and add them to package dependency list (remove duplicates)
647 if not isinstance(requirementsFile, Path): 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true
648 ex = TypeError(f"Parameter 'requirementsFile' is not of type 'Path'.")
649 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
650 raise ex
651 elif not requirementsFile.exists(): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.")
653 else:
654 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug)))
656 extraRequirements: Dict[str, List[str]] = {}
657 if documentationRequirementsFile is not None: 657 ↛ 670line 657 didn't jump to line 670 because the condition on line 657 was always true
658 if not isinstance(documentationRequirementsFile, Path): 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true
659 ex = TypeError(f"Parameter 'documentationRequirementsFile' is not of type 'Path'.")
660 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.")
661 raise ex
662 elif not documentationRequirementsFile.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] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
665 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
666 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
667 else:
668 extraRequirements["doc"] = list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug)))
670 if unittestRequirementsFile is not None: 670 ↛ 683line 670 didn't jump to line 683 because the condition on line 670 was always true
671 if not isinstance(unittestRequirementsFile, Path): 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true
672 ex = TypeError(f"Parameter 'unittestRequirementsFile' is not of type 'Path'.")
673 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.")
674 raise ex
675 elif not unittestRequirementsFile.exists(): 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true
676 if debug:
677 print(f"[pyTooling.Packaging] Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
678 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
679 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
680 else:
681 extraRequirements["test"] = list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug)))
683 if packagingRequirementsFile is not None: 683 ↛ 696line 683 didn't jump to line 696 because the condition on line 683 was always true
684 if not isinstance(packagingRequirementsFile, Path): 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true
685 ex = TypeError(f"Parameter 'packagingRequirementsFile' is not of type 'Path'.")
686 ex.add_note(f"Got type '{getFullyQualifiedName(packagingRequirementsFile)}'.")
687 raise ex
688 elif not packagingRequirementsFile.exists():
689 if debug: 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true
690 print(f"[pyTooling.Packaging] Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
691 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
692 # raise FileNotFoundError(f"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
693 else:
694 extraRequirements["build"] = list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug)))
696 if additionalRequirements is not None:
697 for key, value in additionalRequirements.items():
698 extraRequirements[key] = value
700 if len(extraRequirements) > 0: 700 ↛ 704line 700 didn't jump to line 704 because the condition on line 700 was always true
701 extraRequirements["all"] = list(set([dep for deps in extraRequirements.values() for dep in deps]))
703 # Read __author__, __email__, __version__ from source file
704 if not isinstance(sourceFileWithVersion, Path): 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true
705 ex = TypeError(f"Parameter 'sourceFileWithVersion' is not of type 'Path'.")
706 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.")
707 raise ex
708 elif not sourceFileWithVersion.exists(): 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true
709 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.")
710 else:
711 versionInformation = extractVersionInformation(sourceFileWithVersion)
713 # Scan for packages and source files
714 if debug: 714 ↛ 715line 714 didn't jump to line 715 because the condition on line 714 was never true
715 print(f"[pyTooling.Packaging] Exclude list for find_(namespace_)packages:")
716 exclude = []
717 rootNamespace = firstElement(packageName.split("."))
718 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):
719 exclude.append(f"{dirName}")
720 exclude.append(f"{dirName}.*")
721 if debug: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true
722 print(f"[pyTooling.Packaging] - {dirName}, {dirName}.*")
724 if "." in packageName:
725 exclude.append(rootNamespace)
726 packages = find_namespace_packages(exclude=exclude)
727 if packageName.endswith(".*"): 727 ↛ 728line 727 didn't jump to line 728 because the condition on line 727 was never true
728 packageName = packageName[:-2]
729 else:
730 packages = find_packages(exclude=exclude)
732 if debug: 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true
733 print(f"[pyTooling.Packaging] Found packages: ({packages.__class__.__name__})")
734 for package in packages:
735 print(f"[pyTooling.Packaging] - {package}")
737 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0:
738 keywords = versionInformation.Keywords
740 # Assemble classifiers
741 classifiers = list(classifiers)
743 # Translate license to classifier
744 if not isinstance(license, License): 744 ↛ 745line 744 didn't jump to line 745 because the condition on line 744 was never true
745 ex = TypeError(f"Parameter 'license' is not of type 'License'.")
746 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
747 raise ex
748 classifiers.append(license.PythonClassifier)
750 def _naturalSorting(array: Iterable[str]) -> List[str]:
751 """A simple natural sorting implementation."""
752 # See http://nedbatchelder.com/blog/200712/human_sorting.html
753 def _toInt(text: str) -> Union[str, int]:
754 """Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string."""
755 return int(text) if text.isdigit() else text
757 def _createKey(text: str) -> Tuple[Union[str, float], ...]:
758 """
759 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by
760 their value.
761 """
762 return tuple(_toInt(part) for part in re_split(r"(\d+)", text))
764 sortedArray = list(array)
765 sortedArray.sort(key=_createKey)
766 return sortedArray
768 pythonVersions = _naturalSorting(pythonVersions)
770 # Translate Python versions to classifiers
771 classifiers.append("Programming Language :: Python :: 3 :: Only")
772 for v in pythonVersions:
773 classifiers.append(f"Programming Language :: Python :: {v}")
775 # Translate status to classifier
776 try:
777 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}")
778 except KeyError: # pragma: no cover
779 raise ValueError(f"Unsupported development status '{developmentStatus}'.")
781 # Assemble all package information
782 parameters = {
783 "name": packageName,
784 "version": versionInformation.Version,
785 "author": versionInformation.Author,
786 "author_email": versionInformation.Email,
787 "license": license.SPDXIdentifier,
788 "description": description,
789 "long_description": readme.Content,
790 "long_description_content_type": readme.MimeType,
791 "url": projectURL,
792 "project_urls": {
793 'Documentation': documentationURL,
794 'Source Code': sourceCodeURL,
795 'Issue Tracker': issueTrackerCodeURL
796 },
797 "packages": packages,
798 "classifiers": classifiers,
799 "keywords": keywords,
800 "python_requires": f">={pythonVersions[0]}",
801 "install_requires": requirements,
802 }
804 if len(extraRequirements) > 0: 804 ↛ 807line 804 didn't jump to line 807 because the condition on line 804 was always true
805 parameters["extras_require"] = extraRequirements
807 if consoleScripts is not None: 807 ↛ 808line 807 didn't jump to line 808 because the condition on line 807 was never true
808 scripts = []
809 for scriptName, entryPoint in consoleScripts.items():
810 scripts.append(f"{scriptName} = {entryPoint}")
812 parameters["entry_points"] = {
813 "console_scripts": scripts
814 }
816 if dataFiles: 816 ↛ 817line 816 didn't jump to line 817 because the condition on line 816 was never true
817 parameters["package_data"] = dataFiles
819 return parameters
822@export
823def DescribePythonPackageHostedOnGitHub(
824 packageName: str,
825 description: str,
826 gitHubNamespace: str,
827 gitHubRepository: str = None,
828 projectURL: str = None,
829 keywords: Iterable[str] = None,
830 license: License = DEFAULT_LICENSE,
831 readmeFile: Path = DEFAULT_README,
832 requirementsFile: Path = DEFAULT_REQUIREMENTS,
833 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
834 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
835 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
836 additionalRequirements: Dict[str, List[str]] = None,
837 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE,
838 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
839 developmentStatus: str = "stable",
840 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
841 consoleScripts: Dict[str, str] = None,
842 dataFiles: Dict[str, List[str]] = None,
843 debug: bool = False
844) -> Dict[str, Any]:
845 """
846 Helper function to describe a Python package when the source code is hosted on GitHub.
848 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub
849 namespace and repository name: issue tracker URL, source code URL, ...
851 :param packageName: Name of the Python package.
852 :param description: Short description of the package. The long description will be read from README file.
853 :param gitHubNamespace: Name of the GitHub namespace (organization or user).
854 :param gitHubRepository: Name of the GitHub repository.
855 :param projectURL: URL to the Python project.
856 :param keywords: A list of keywords.
857 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
858 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
859 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
860 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
861 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
862 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
863 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
864 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
865 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
866 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
867 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
868 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
869 :param dataFiles: A dictionary mapping package names to lists of additional data files.
870 :param debug: Enable extended outputs for debugging.
871 :returns: A dictionary suitable for :func:`setuptools.setup`.
872 :raises ToolingException: If package 'setuptools' is not available.
873 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
874 :raises FileNotFoundError: If README file doesn't exist.
875 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
876 :raises FileNotFoundError: If requirements file doesn't exist.
877 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
878 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
879 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
880 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
881 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
882 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
883 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
884 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
885 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
886 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
887 """
888 if gitHubRepository is None: 888 ↛ 890line 888 didn't jump to line 890 because the condition on line 888 was never true
889 # Assign GitHub repository name without '.*', if derived from Python package name.
890 if packageName.endswith(".*"):
891 gitHubRepository = packageName[:-2]
892 else:
893 gitHubRepository = packageName
895 # Derive URLs
896 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}"
897 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}"
898 issueTrackerCodeURL = f"{sourceCodeURL}/issues"
900 projectURL = projectURL if projectURL is not None else sourceCodeURL
902 return DescribePythonPackage(
903 packageName=packageName,
904 description=description,
905 keywords=keywords,
906 projectURL=projectURL,
907 sourceCodeURL=sourceCodeURL,
908 documentationURL=documentationURL,
909 issueTrackerCodeURL=issueTrackerCodeURL,
910 license=license,
911 readmeFile=readmeFile,
912 requirementsFile=requirementsFile,
913 documentationRequirementsFile=documentationRequirementsFile,
914 unittestRequirementsFile=unittestRequirementsFile,
915 packagingRequirementsFile=packagingRequirementsFile,
916 additionalRequirements=additionalRequirements,
917 sourceFileWithVersion=sourceFileWithVersion,
918 classifiers=classifiers,
919 developmentStatus=developmentStatus,
920 pythonVersions=pythonVersions,
921 consoleScripts=consoleScripts,
922 dataFiles=dataFiles,
923 debug=debug,
924 )