Coverage for pyTooling / Packaging / __init__.py: 75%
311 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +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
46try:
47 from pyTooling.Decorators import export, readonly
48 from pyTooling.Exceptions import ToolingException
49 from pyTooling.MetaClasses import ExtendedType
50 from pyTooling.Common import __version__, getFullyQualifiedName, firstElement
51 from pyTooling.Licensing import License, Apache_2_0_License
52except (ImportError, ModuleNotFoundError): # pragma: no cover
53 print("[pyTooling.Packaging] Could not import from 'pyTooling.*'!")
55 try:
56 from Decorators import export, readonly
57 from Exceptions import ToolingException
58 from MetaClasses import ExtendedType
59 from Common import __version__, getFullyQualifiedName, firstElement
60 from Licensing import License, Apache_2_0_License
61 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
62 print("[pyTooling.Packaging] Could not import directly!")
63 raise ex
66__all__ = [
67 "STATUS", "DEFAULT_LICENSE", "DEFAULT_PY_VERSIONS", "DEFAULT_CLASSIFIERS", "DEFAULT_README", "DEFAULT_REQUIREMENTS",
68 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS",
69 "DEFAULT_VERSION_FILE"
70]
73@export
74class Readme:
75 """Encapsulates the READMEs file content and MIME type."""
77 _content: str #: Content of the README file
78 _mimeType: str #: MIME type of the README content
80 def __init__(self, content: str, mimeType: str) -> None:
81 """
82 Initializes a README file wrapper.
84 :param content: Raw content of the README file.
85 :param mimeType: MIME type of the README file.
86 """
87 self._content = content
88 self._mimeType = mimeType
90 @readonly
91 def Content(self) -> str:
92 """
93 Read-only property to access the README's content.
95 :returns: Raw content of the README file.
96 """
97 return self._content
99 @readonly
100 def MimeType(self) -> str:
101 """
102 Read-only property to access the README's MIME type.
104 :returns: The MIME type of the README file.
105 """
106 return self._mimeType
109@export
110def loadReadmeFile(readmeFile: Path) -> Readme:
111 """
112 Read the README file (e.g. in Markdown format), so it can be used as long description for the package.
114 Supported formats:
116 * Plain text (``*.txt``)
117 * Markdown (``*.md``)
118 * ReStructured Text (``*.rst``)
120 :param readmeFile: Path to the `README` file as an instance of :class:`Path`.
121 :returns: A tuple containing the file content and the MIME type.
122 :raises TypeError: If parameter 'readmeFile' is not of type 'Path'.
123 :raises ValueError: If README file has an unsupported format.
124 :raises FileNotFoundError: If README file does not exist.
125 """
126 if not isinstance(readmeFile, Path): 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
128 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
129 raise ex
131 if readmeFile.suffix == ".txt":
132 mimeType = "text/plain"
133 elif readmeFile.suffix == ".md":
134 mimeType = "text/markdown"
135 elif readmeFile.suffix == ".rst":
136 mimeType = "text/x-rst"
137 else: # pragma: no cover
138 raise ValueError("Unsupported README format.")
140 try:
141 with readmeFile.open("r", encoding="utf-8") as file:
142 return Readme(
143 content=file.read(),
144 mimeType=mimeType
145 )
146 except FileNotFoundError as ex:
147 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.") from ex
150@export
151def loadRequirementsFile(requirementsFile: Path, indent: int = 0, debug: bool = False) -> List[str]:
152 """
153 Reads a `requirements.txt` file (recursively) and extracts all specified dependencies into an array.
155 Special dependency entries like Git repository references are translates to match the syntax expected by setuptools.
157 .. hint::
159 Duplicates should be removed by converting the result to a :class:`set` and back to a :class:`list`.
161 .. code-block:: Python
163 requirements = list(set(loadRequirementsFile(requirementsFile)))
165 :param requirementsFile: Path to the ``requirements.txt`` file as an instance of :class:`Path`.
166 :param debug: If ``True``, print found dependencies and recursion.
167 :returns: A list of dependencies.
168 :raises TypeError: If parameter 'requirementsFile' is not of type 'Path'.
169 :raises FileNotFoundError: If requirements file does not exist.
170 """
171 if not isinstance(requirementsFile, Path): 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 ex = TypeError(f"Parameter '{requirementsFile}' is not of type 'Path'.")
173 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
174 raise ex
176 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> List[str]:
177 """Recursive variant of :func:`loadRequirementsFile`."""
178 requirements = []
179 try:
180 with requirementsFile.open("r", encoding="utf-8") as file:
181 if debug:
182 print(f"[pyTooling.Packaging]{' ' * indent} Extracting requirements from '{requirementsFile}'.")
183 for line in file.readlines():
184 line = line.strip()
185 if line.startswith("#") or line == "":
186 continue
187 elif line.startswith("-r"):
188 # Remove the first word/argument (-r)
189 filename = line[2:].lstrip()
190 requirements += _loadRequirementsFile(requirementsFile.parent / filename, indent + 1)
191 elif line.startswith("https"):
192 if debug:
193 print(f"[pyTooling.Packaging]{' ' * indent} Found URL '{line}'.")
195 # Convert 'URL#NAME' to 'NAME @ URL'
196 splitItems = line.split("#")
197 requirements.append(f"{splitItems[1]} @ {splitItems[0]}")
198 else:
199 if debug:
200 print(f"[pyTooling.Packaging]{' ' * indent} - {line}")
202 requirements.append(line)
203 except FileNotFoundError as ex:
204 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex
206 return requirements
208 return _loadRequirementsFile(requirementsFile, 0)
211@export
212class VersionInformation(metaclass=ExtendedType, slots=True):
213 """Encapsulates version information extracted from a Python source file."""
215 _author: str #: Author name(s).
216 _copyright: str #: Copyright information.
217 _email: str #: Author's email address.
218 _keywords: List[str] #: Keywords.
219 _license: str #: License name.
220 _description: str #: Description of the package.
221 _version: str #: Version number.
223 def __init__(
224 self,
225 author: str,
226 email: str,
227 copyright: str,
228 license: str,
229 version: str,
230 description: str,
231 keywords: Iterable[str]
232 ) -> None:
233 """
234 Initializes a Python package (version) information instance.
236 :param author: Author of the Python package.
237 :param email: The author's email address
238 :param copyright: The copyright notice of the Package.
239 :param license: The Python package's license.
240 :param version: The Python package's version.
241 :param description: The Python package's short description.
242 :param keywords: The Python package's list of keywords.
243 """
244 self._author = author
245 self._email = email
246 self._copyright = copyright
247 self._license = license
248 self._version = version
249 self._description = description
250 self._keywords = [k for k in keywords]
252 @readonly
253 def Author(self) -> str:
254 """Name(s) of the package author(s)."""
255 return self._author
257 @readonly
258 def Copyright(self) -> str:
259 """Copyright information."""
260 return self._copyright
262 @readonly
263 def Description(self) -> str:
264 """Package description text."""
265 return self._description
267 @readonly
268 def Email(self) -> str:
269 """Email address of the author."""
270 return self._email
272 @readonly
273 def Keywords(self) -> List[str]:
274 """List of keywords."""
275 return self._keywords
277 @readonly
278 def License(self) -> str:
279 """License name."""
280 return self._license
282 @readonly
283 def Version(self) -> str:
284 """Version number."""
285 return self._version
287 def __str__(self) -> str:
288 return f"{self._version}"
291@export
292def extractVersionInformation(sourceFile: Path) -> VersionInformation:
293 """
294 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information.
296 Supported variables:
298 * ``__author__``
299 * ``__copyright__``
300 * ``__email__``
301 * ``__keywords__``
302 * ``__license__``
303 * ``__version__``
305 :param sourceFile: Path to a Python source file as an instance of :class:`Path`.
306 :returns: An instance of :class:`VersionInformation` with gathered variable contents.
307 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`.
309 """
310 if not isinstance(sourceFile, Path): 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true
311 ex = TypeError(f"Parameter 'sourceFile' is not of type 'Path'.")
312 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.")
313 raise ex
315 _author = None
316 _copyright = None
317 _description = ""
318 _email = None
319 _keywords = []
320 _license = None
321 _version = None
323 try:
324 with sourceFile.open("r", encoding="utf-8") as file:
325 content = file.read()
326 except FileNotFoundError as ex:
327 raise FileNotFoundError
329 try:
330 ast = ast_parse(content)
331 except Exception as ex: # pragma: no cover
332 raise ToolingException(f"Internal error when parsing '{sourceFile}'.") from ex
334 for item in iter_child_nodes(ast):
335 if isinstance(item, Assign) and len(item.targets) == 1:
336 target = item.targets[0]
337 value = item.value
338 if isinstance(target, Name) and target.id == "__author__":
339 if isinstance(value, Constant) and isinstance(value.value, str): 339 ↛ 341line 339 didn't jump to line 341 because the condition on line 339 was always true
340 _author = value.value
341 if isinstance(target, Name) and target.id == "__copyright__":
342 if isinstance(value, Constant) and isinstance(value.value, str): 342 ↛ 344line 342 didn't jump to line 344 because the condition on line 342 was always true
343 _copyright = value.value
344 if isinstance(target, Name) and target.id == "__email__":
345 if isinstance(value, Constant) and isinstance(value.value, str): 345 ↛ 347line 345 didn't jump to line 347 because the condition on line 345 was always true
346 _email = value.value
347 if isinstance(target, Name) and target.id == "__keywords__":
348 if isinstance(value, Constant) and isinstance(value.value, str): # pragma: no cover
349 raise TypeError(f"Variable '__keywords__' should be a list of strings.")
350 elif isinstance(value, ast_List):
351 for const in value.elts:
352 if isinstance(const, Constant) and isinstance(const.value, str):
353 _keywords.append(const.value)
354 else: # pragma: no cover
355 raise TypeError(f"List elements in '__keywords__' should be strings.")
356 else: # pragma: no cover
357 raise TypeError(f"Used unsupported type for variable '__keywords__'.")
358 if isinstance(target, Name) and target.id == "__license__":
359 if isinstance(value, Constant) and isinstance(value.value, str): 359 ↛ 361line 359 didn't jump to line 361 because the condition on line 359 was always true
360 _license = value.value
361 if isinstance(target, Name) and target.id == "__version__":
362 if isinstance(value, Constant) and isinstance(value.value, str): 362 ↛ 334line 362 didn't jump to line 334 because the condition on line 362 was always true
363 _version = value.value
365 if _author is None:
366 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover
367 if _copyright is None:
368 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover
369 if _email is None:
370 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover
371 if _license is None:
372 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover
373 if _version is None:
374 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover
376 return VersionInformation(_author, _email, _copyright, _license, _version, _description, _keywords)
379STATUS: Dict[str, str] = {
380 "planning": "1 - Planning",
381 "pre-alpha": "2 - Pre-Alpha",
382 "alpha": "3 - Alpha",
383 "beta": "4 - Beta",
384 "stable": "5 - Production/Stable",
385 "mature": "6 - Mature",
386 "inactive": "7 - Inactive"
387}
388"""
389A dictionary of supported development status values.
391The mapping's value will be appended to ``Development Status :: `` to form a package classifier.
3931. Planning
3942. Pre-Alpha
3953. Alpha
3964. Beta
3975. Production/Stable
3986. Mature
3997. Inactive
401.. seealso::
403 `Python package classifiers <https://pypi.org/classifiers/>`__
404"""
406DEFAULT_LICENSE = Apache_2_0_License
407"""
408Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
409if parameter ``license`` is not assigned.
410"""
412DEFAULT_PY_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14")
413"""
414A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
415if parameter ``pythonVersions`` is not assigned.
417.. seealso::
419 `Status of Python versions <https://devguide.python.org/versions/>`__
420"""
422DEFAULT_CLASSIFIERS = (
423 "Operating System :: OS Independent",
424 "Intended Audience :: Developers",
425 "Topic :: Utilities"
426 )
427"""
428A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
429if parameter ``classifiers`` is not assigned.
431.. seealso::
433 `Python package classifiers <https://pypi.org/classifiers/>`__
434"""
436DEFAULT_README = Path("README.md")
437"""
438Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
439if parameter ``readmeFile`` is not assigned.
440"""
442DEFAULT_REQUIREMENTS = Path("requirements.txt")
443"""
444Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
445if parameter ``requirementsFile`` is not assigned.
446"""
448DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt")
449"""
450Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
451if parameter ``documentationRequirementsFile`` is not assigned.
452"""
454DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt")
455"""
456Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
457if parameter ``unittestRequirementsFile`` is not assigned.
458"""
460DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt")
461"""
462Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
463if parameter ``packagingRequirementsFile`` is not assigned.
464"""
466DEFAULT_VERSION_FILE = Path("__init__.py")
469@export
470def DescribePythonPackage(
471 packageName: str,
472 description: str,
473 projectURL: str,
474 sourceCodeURL: str,
475 documentationURL: str,
476 issueTrackerCodeURL: str,
477 keywords: Iterable[str] = None,
478 license: License = DEFAULT_LICENSE,
479 readmeFile: Path = DEFAULT_README,
480 requirementsFile: Path = DEFAULT_REQUIREMENTS,
481 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
482 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
483 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
484 additionalRequirements: Dict[str, List[str]] = None,
485 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE,
486 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
487 developmentStatus: str = "stable",
488 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
489 consoleScripts: Dict[str, str] = None,
490 dataFiles: Dict[str, List[str]] = None,
491 debug: bool = False
492) -> Dict[str, Any]:
493 """
494 Helper function to describe a Python package.
496 .. hint::
498 Some information will be gathered automatically from well-known files.
500 Examples: ``README.md``, ``requirements.txt``, ``__init__.py``
502 .. topic:: Handling of namespace packages
504 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then
505 :func:`setuptools.find_namespace_packages` is used to discover package files. |br|
506 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used.
508 In both cases, the following packages (directories) are excluded from search:
510 * ``build``, ``build.*``
511 * ``dist``, ``dist.*``
512 * ``doc``, ``doc.*``
513 * ``tests``, ``tests.*``
515 .. topic:: Handling of minimal Python version
517 The minimal required Python version is selected from parameter ``pythonVersions``.
519 .. topic:: Handling of dunder variables
521 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the
522 resulting AST will be searched for the following dunder variables:
524 * ``__author__``: :class:`str`
525 * ``__copyright__``: :class:`str`
526 * ``__email__``: :class:`str`
527 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`]
528 * ``__license__``: :class:`str`
529 * ``__version__``: :class:`str`
531 The gathered information be used to add further mappings in the result dictionary.
533 .. topic:: Handling of package classifiers
535 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional
536 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly
537 converted to package classifiers, so no need to specify them in parameter ``classifiers``.
539 The following classifiers are implicitly handled:
541 license
542 The license specified by parameter ``license`` is translated into a classifier. |br|
543 See also :meth:`pyTooling.Licensing.License.PythonClassifier`
545 Python versions
546 Always add ``Programming Language :: Python :: 3 :: Only``. |br|
547 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added.
549 Development status
550 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added.
552 .. topic:: Handling of extra requirements
554 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra*
555 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called
556 ``all`` is added, so developers can install all dependencies needed for package development.
558 ``doc``
559 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined.
560 ``test``
561 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined.
562 ``build``
563 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined.
564 User-defined
565 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the
566 dictionary will be added.
567 ``all``
568 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all
569 extra requirements.
571 .. topic:: Handling of keywords
573 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion``
574 will be used. Otherwise, the content of the parameter, if not None or empty.
576 :param packageName: Name of the Python package.
577 :param description: Short description of the package. The long description will be read from README file.
578 :param projectURL: URL to the Python project.
579 :param sourceCodeURL: URL to the Python source code.
580 :param documentationURL: URL to the package's documentation.
581 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system).
582 :param keywords: A list of keywords.
583 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
584 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
585 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
586 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
587 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
588 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
589 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
590 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
591 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
592 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
593 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
594 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
595 :param dataFiles: A dictionary mapping package names to lists of additional data files.
596 :param debug: Enable extended outputs for debugging.
597 :returns: A dictionary suitable for :func:`setuptools.setup`.
598 :raises ToolingException: If package 'setuptools' is not available.
599 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
600 :raises FileNotFoundError: If README file doesn't exist.
601 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
602 :raises FileNotFoundError: If requirements file doesn't exist.
603 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
604 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
605 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
606 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
607 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
608 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
609 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
610 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
611 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
612 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
613 """
614 try:
615 from setuptools import find_packages, find_namespace_packages
616 except ImportError as ex:
617 raise Exception(f"Optional dependency 'setuptools' not installed. Either install pyTooling with extra dependencies 'pyTooling[packaging]' or install 'setuptools' directly.") from ex
619 print(f"[pyTooling.Packaging] Python: {version_info.major}.{version_info.minor}.{version_info.micro}, pyTooling: {__version__}")
621 # Read README for upload to PyPI
622 if not isinstance(readmeFile, Path): 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
624 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
625 raise ex
626 elif not readmeFile.exists(): 626 ↛ 627line 626 didn't jump to line 627 because the condition on line 626 was never true
627 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.")
628 else:
629 readme = loadReadmeFile(readmeFile)
631 # Read requirements file and add them to package dependency list (remove duplicates)
632 if not isinstance(requirementsFile, Path): 632 ↛ 633line 632 didn't jump to line 633 because the condition on line 632 was never true
633 ex = TypeError(f"Parameter 'requirementsFile' is not of type 'Path'.")
634 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
635 raise ex
636 elif not requirementsFile.exists(): 636 ↛ 637line 636 didn't jump to line 637 because the condition on line 636 was never true
637 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.")
638 else:
639 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug)))
641 extraRequirements: Dict[str, List[str]] = {}
642 if documentationRequirementsFile is not None: 642 ↛ 655line 642 didn't jump to line 655 because the condition on line 642 was always true
643 if not isinstance(documentationRequirementsFile, Path): 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true
644 ex = TypeError(f"Parameter 'documentationRequirementsFile' is not of type 'Path'.")
645 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.")
646 raise ex
647 elif not documentationRequirementsFile.exists(): 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true
648 if debug:
649 print(f"[pyTooling.Packaging] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
650 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
651 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
652 else:
653 extraRequirements["doc"] = list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug)))
655 if unittestRequirementsFile is not None: 655 ↛ 668line 655 didn't jump to line 668 because the condition on line 655 was always true
656 if not isinstance(unittestRequirementsFile, Path): 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 ex = TypeError(f"Parameter 'unittestRequirementsFile' is not of type 'Path'.")
658 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.")
659 raise ex
660 elif not unittestRequirementsFile.exists(): 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true
661 if debug:
662 print(f"[pyTooling.Packaging] Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
663 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
664 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
665 else:
666 extraRequirements["test"] = list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug)))
668 if packagingRequirementsFile is not None: 668 ↛ 681line 668 didn't jump to line 681 because the condition on line 668 was always true
669 if not isinstance(packagingRequirementsFile, Path): 669 ↛ 670line 669 didn't jump to line 670 because the condition on line 669 was never true
670 ex = TypeError(f"Parameter 'packagingRequirementsFile' is not of type 'Path'.")
671 ex.add_note(f"Got type '{getFullyQualifiedName(packagingRequirementsFile)}'.")
672 raise ex
673 elif not packagingRequirementsFile.exists():
674 if debug: 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true
675 print(f"[pyTooling.Packaging] Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
676 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
677 # raise FileNotFoundError(f"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
678 else:
679 extraRequirements["build"] = list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug)))
681 if additionalRequirements is not None:
682 for key, value in additionalRequirements.items():
683 extraRequirements[key] = value
685 if len(extraRequirements) > 0: 685 ↛ 689line 685 didn't jump to line 689 because the condition on line 685 was always true
686 extraRequirements["all"] = list(set([dep for deps in extraRequirements.values() for dep in deps]))
688 # Read __author__, __email__, __version__ from source file
689 if not isinstance(sourceFileWithVersion, Path): 689 ↛ 690line 689 didn't jump to line 690 because the condition on line 689 was never true
690 ex = TypeError(f"Parameter 'sourceFileWithVersion' is not of type 'Path'.")
691 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.")
692 raise ex
693 elif not sourceFileWithVersion.exists(): 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true
694 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.")
695 else:
696 versionInformation = extractVersionInformation(sourceFileWithVersion)
698 # Scan for packages and source files
699 if debug: 699 ↛ 700line 699 didn't jump to line 700 because the condition on line 699 was never true
700 print(f"[pyTooling.Packaging] Exclude list for find_(namespace_)packages:")
701 exclude = []
702 rootNamespace = firstElement(packageName.split("."))
703 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):
704 exclude.append(f"{dirName}")
705 exclude.append(f"{dirName}.*")
706 if debug: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true
707 print(f"[pyTooling.Packaging] - {dirName}, {dirName}.*")
709 if "." in packageName:
710 exclude.append(rootNamespace)
711 packages = find_namespace_packages(exclude=exclude)
712 if packageName.endswith(".*"): 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true
713 packageName = packageName[:-2]
714 else:
715 packages = find_packages(exclude=exclude)
717 if debug: 717 ↛ 718line 717 didn't jump to line 718 because the condition on line 717 was never true
718 print(f"[pyTooling.Packaging] Found packages: ({packages.__class__.__name__})")
719 for package in packages:
720 print(f"[pyTooling.Packaging] - {package}")
722 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0:
723 keywords = versionInformation.Keywords
725 # Assemble classifiers
726 classifiers = list(classifiers)
728 # Translate license to classifier
729 if not isinstance(license, License): 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true
730 ex = TypeError(f"Parameter 'license' is not of type 'License'.")
731 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
732 raise ex
733 classifiers.append(license.PythonClassifier)
735 def _naturalSorting(array: Iterable[str]) -> List[str]:
736 """A simple natural sorting implementation."""
737 # See http://nedbatchelder.com/blog/200712/human_sorting.html
738 def _toInt(text: str) -> Union[str, int]:
739 """Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string."""
740 return int(text) if text.isdigit() else text
742 def _createKey(text: str) -> Tuple[Union[str, float], ...]:
743 """
744 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by
745 their value.
746 """
747 return tuple(_toInt(part) for part in re_split(r"(\d+)", text))
749 sortedArray = list(array)
750 sortedArray.sort(key=_createKey)
751 return sortedArray
753 pythonVersions = _naturalSorting(pythonVersions)
755 # Translate Python versions to classifiers
756 classifiers.append("Programming Language :: Python :: 3 :: Only")
757 for v in pythonVersions:
758 classifiers.append(f"Programming Language :: Python :: {v}")
760 # Translate status to classifier
761 try:
762 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}")
763 except KeyError: # pragma: no cover
764 raise ValueError(f"Unsupported development status '{developmentStatus}'.")
766 # Assemble all package information
767 parameters = {
768 "name": packageName,
769 "version": versionInformation.Version,
770 "author": versionInformation.Author,
771 "author_email": versionInformation.Email,
772 "license": license.SPDXIdentifier,
773 "description": description,
774 "long_description": readme.Content,
775 "long_description_content_type": readme.MimeType,
776 "url": projectURL,
777 "project_urls": {
778 'Documentation': documentationURL,
779 'Source Code': sourceCodeURL,
780 'Issue Tracker': issueTrackerCodeURL
781 },
782 "packages": packages,
783 "classifiers": classifiers,
784 "keywords": keywords,
785 "python_requires": f">={pythonVersions[0]}",
786 "install_requires": requirements,
787 }
789 if len(extraRequirements) > 0: 789 ↛ 792line 789 didn't jump to line 792 because the condition on line 789 was always true
790 parameters["extras_require"] = extraRequirements
792 if consoleScripts is not None: 792 ↛ 793line 792 didn't jump to line 793 because the condition on line 792 was never true
793 scripts = []
794 for scriptName, entryPoint in consoleScripts.items():
795 scripts.append(f"{scriptName} = {entryPoint}")
797 parameters["entry_points"] = {
798 "console_scripts": scripts
799 }
801 if dataFiles: 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true
802 parameters["package_data"] = dataFiles
804 return parameters
807@export
808def DescribePythonPackageHostedOnGitHub(
809 packageName: str,
810 description: str,
811 gitHubNamespace: str,
812 gitHubRepository: str = None,
813 projectURL: str = None,
814 keywords: Iterable[str] = None,
815 license: License = DEFAULT_LICENSE,
816 readmeFile: Path = DEFAULT_README,
817 requirementsFile: Path = DEFAULT_REQUIREMENTS,
818 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
819 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
820 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
821 additionalRequirements: Dict[str, List[str]] = None,
822 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE,
823 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
824 developmentStatus: str = "stable",
825 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
826 consoleScripts: Dict[str, str] = None,
827 dataFiles: Dict[str, List[str]] = None,
828 debug: bool = False
829) -> Dict[str, Any]:
830 """
831 Helper function to describe a Python package when the source code is hosted on GitHub.
833 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub
834 namespace and repository name: issue tracker URL, source code URL, ...
836 :param packageName: Name of the Python package.
837 :param description: Short description of the package. The long description will be read from README file.
838 :param gitHubNamespace: Name of the GitHub namespace (organization or user).
839 :param gitHubRepository: Name of the GitHub repository.
840 :param projectURL: URL to the Python project.
841 :param keywords: A list of keywords.
842 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
843 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
844 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
845 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
846 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
847 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
848 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
849 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
850 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
851 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
852 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
853 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
854 :param dataFiles: A dictionary mapping package names to lists of additional data files.
855 :param debug: Enable extended outputs for debugging.
856 :returns: A dictionary suitable for :func:`setuptools.setup`.
857 :raises ToolingException: If package 'setuptools' is not available.
858 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
859 :raises FileNotFoundError: If README file doesn't exist.
860 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
861 :raises FileNotFoundError: If requirements file doesn't exist.
862 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
863 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
864 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
865 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
866 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
867 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
868 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
869 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
870 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
871 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
872 """
873 if gitHubRepository is None: 873 ↛ 875line 873 didn't jump to line 875 because the condition on line 873 was never true
874 # Assign GitHub repository name without '.*', if derived from Python package name.
875 if packageName.endswith(".*"):
876 gitHubRepository = packageName[:-2]
877 else:
878 gitHubRepository = packageName
880 # Derive URLs
881 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}"
882 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}"
883 issueTrackerCodeURL = f"{sourceCodeURL}/issues"
885 projectURL = projectURL if projectURL is not None else sourceCodeURL
887 return DescribePythonPackage(
888 packageName=packageName,
889 description=description,
890 keywords=keywords,
891 projectURL=projectURL,
892 sourceCodeURL=sourceCodeURL,
893 documentationURL=documentationURL,
894 issueTrackerCodeURL=issueTrackerCodeURL,
895 license=license,
896 readmeFile=readmeFile,
897 requirementsFile=requirementsFile,
898 documentationRequirementsFile=documentationRequirementsFile,
899 unittestRequirementsFile=unittestRequirementsFile,
900 packagingRequirementsFile=packagingRequirementsFile,
901 additionalRequirements=additionalRequirements,
902 sourceFileWithVersion=sourceFileWithVersion,
903 classifiers=classifiers,
904 developmentStatus=developmentStatus,
905 pythonVersions=pythonVersions,
906 consoleScripts=consoleScripts,
907 dataFiles=dataFiles,
908 debug=debug,
909 )