Coverage for pyTooling/Packaging/__init__.py: 76%
309 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 06:41 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 06:41 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ __ _ ___| | ____ _ __ _(_)_ __ __ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_) / _` |/ __| |/ / _` |/ _` | | '_ \ / _` | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| __/ (_| | (__| < (_| | (_| | | | | | (_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| \__,_|\___|_|\_\__,_|\__, |_|_| |_|\__, | #
7# |_| |___/ |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2021-2025 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A set of helper functions to describe a Python package for setuptools.
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
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.*'!")
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
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]
71@export
72class Readme:
73 """Encapsulates the READMEs file content and MIME type."""
75 _content: str #: Content of the README file
76 _mimeType: str #: MIME type of the README content
78 def __init__(self, content: str, mimeType: str) -> None:
79 """
80 Initializes a README file wrapper.
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
88 @readonly
89 def Content(self) -> str:
90 """
91 Read-only property to access the README's content.
93 :returns: Raw content of the README file.
94 """
95 return self._content
97 @readonly
98 def MimeType(self) -> str:
99 """
100 Read-only property to access the README's MIME type.
102 :returns: The MIME type of the README file.
103 """
104 return self._mimeType
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.
112 Supported formats:
114 * Plain text (``*.txt``)
115 * Markdown (``*.md``)
116 * ReStructured Text (``*.rst``)
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
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.")
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
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.
153 Special dependency entries like Git repository references are translates to match the syntax expected by setuptools.
155 .. hint::
157 Duplicates should be removed by converting the result to a :class:`set` and back to a :class:`list`.
159 .. code-block:: Python
161 requirements = list(set(loadRequirementsFile(requirementsFile)))
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
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}'.")
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}")
200 requirements.append(line)
201 except FileNotFoundError as ex:
202 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex
204 return requirements
206 return _loadRequirementsFile(requirementsFile, 0)
209@export
210class VersionInformation(metaclass=ExtendedType, slots=True):
211 """Encapsulates version information extracted from a Python source file."""
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.
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.
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]
250 @readonly
251 def Author(self) -> str:
252 """Name(s) of the package author(s)."""
253 return self._author
255 @readonly
256 def Copyright(self) -> str:
257 """Copyright information."""
258 return self._copyright
260 @readonly
261 def Description(self) -> str:
262 """Package description text."""
263 return self._description
265 @readonly
266 def Email(self) -> str:
267 """Email address of the author."""
268 return self._email
270 @readonly
271 def Keywords(self) -> List[str]:
272 """List of keywords."""
273 return self._keywords
275 @readonly
276 def License(self) -> str:
277 """License name."""
278 return self._license
280 @readonly
281 def Version(self) -> str:
282 """Version number."""
283 return self._version
286@export
287def extractVersionInformation(sourceFile: Path) -> VersionInformation:
288 """
289 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information.
291 Supported variables:
293 * ``__author__``
294 * ``__copyright__``
295 * ``__email__``
296 * ``__keywords__``
297 * ``__license__``
298 * ``__version__``
300 :param sourceFile: Path to a Python source file as an instance of :class:`Path`.
301 :returns: An instance of :class:`VersionInformation` with gathered variable contents.
302 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`.
304 """
305 if not isinstance(sourceFile, Path): 305 ↛ 306line 305 didn't jump to line 306 because the condition on line 305 was never true
306 ex = TypeError(f"Parameter 'sourceFile' is not of type 'Path'.")
307 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.")
308 raise ex
310 _author = None
311 _copyright = None
312 _description = ""
313 _email = None
314 _keywords = []
315 _license = None
316 _version = None
318 try:
319 with sourceFile.open("r", encoding="utf-8") as file:
320 content = file.read()
321 except FileNotFoundError as ex:
322 raise FileNotFoundError
324 try:
325 ast = ast_parse(content)
326 except Exception as ex: # pragma: no cover
327 raise ToolingException(f"Internal error when parsing '{sourceFile}'.") from ex
329 for item in iter_child_nodes(ast):
330 if isinstance(item, Assign) and len(item.targets) == 1:
331 target = item.targets[0]
332 value = item.value
333 if isinstance(target, Name) and target.id == "__author__":
334 if isinstance(value, Constant) and isinstance(value.value, str): 334 ↛ 336line 334 didn't jump to line 336 because the condition on line 334 was always true
335 _author = value.value
336 if isinstance(target, Name) and target.id == "__copyright__":
337 if isinstance(value, Constant) and isinstance(value.value, str): 337 ↛ 339line 337 didn't jump to line 339 because the condition on line 337 was always true
338 _copyright = value.value
339 if isinstance(target, Name) and target.id == "__email__":
340 if isinstance(value, Constant) and isinstance(value.value, str): 340 ↛ 342line 340 didn't jump to line 342 because the condition on line 340 was always true
341 _email = value.value
342 if isinstance(target, Name) and target.id == "__keywords__":
343 if isinstance(value, Constant) and isinstance(value.value, str): # pragma: no cover
344 raise TypeError(f"Variable '__keywords__' should be a list of strings.")
345 elif isinstance(value, ast_List):
346 for const in value.elts:
347 if isinstance(const, Constant) and isinstance(const.value, str):
348 _keywords.append(const.value)
349 else: # pragma: no cover
350 raise TypeError(f"List elements in '__keywords__' should be strings.")
351 else: # pragma: no cover
352 raise TypeError(f"Used unsupported type for variable '__keywords__'.")
353 if isinstance(target, Name) and target.id == "__license__":
354 if isinstance(value, Constant) and isinstance(value.value, str): 354 ↛ 356line 354 didn't jump to line 356 because the condition on line 354 was always true
355 _license = value.value
356 if isinstance(target, Name) and target.id == "__version__":
357 if isinstance(value, Constant) and isinstance(value.value, str): 357 ↛ 329line 357 didn't jump to line 329 because the condition on line 357 was always true
358 _version = value.value
360 if _author is None:
361 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover
362 if _copyright is None:
363 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover
364 if _email is None:
365 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover
366 if _license is None:
367 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover
368 if _version is None:
369 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover
371 return VersionInformation(_author, _email, _copyright, _license, _version, _description, _keywords)
374STATUS: Dict[str, str] = {
375 "planning": "1 - Planning",
376 "pre-alpha": "2 - Pre-Alpha",
377 "alpha": "3 - Alpha",
378 "beta": "4 - Beta",
379 "stable": "5 - Production/Stable",
380 "mature": "6 - Mature",
381 "inactive": "7 - Inactive"
382}
383"""
384A dictionary of supported development status values.
386The mapping's value will be appended to ``Development Status :: `` to form a package classifier.
3881. Planning
3892. Pre-Alpha
3903. Alpha
3914. Beta
3925. Production/Stable
3936. Mature
3947. Inactive
396.. seealso::
398 `Python package classifiers <https://pypi.org/classifiers/>`__
399"""
401DEFAULT_LICENSE = Apache_2_0_License
402"""
403Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
404if parameter ``license`` is not assigned.
405"""
407DEFAULT_PY_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14")
408"""
409A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
410if parameter ``pythonVersions`` is not assigned.
412.. seealso::
414 `Status of Python versions <https://devguide.python.org/versions/>`__
415"""
417DEFAULT_CLASSIFIERS = (
418 "Operating System :: OS Independent",
419 "Intended Audience :: Developers",
420 "Topic :: Utilities"
421 )
422"""
423A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
424if parameter ``classifiers`` is not assigned.
426.. seealso::
428 `Python package classifiers <https://pypi.org/classifiers/>`__
429"""
431DEFAULT_README = Path("README.md")
432"""
433Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
434if parameter ``readmeFile`` is not assigned.
435"""
437DEFAULT_REQUIREMENTS = Path("requirements.txt")
438"""
439Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
440if parameter ``requirementsFile`` is not assigned.
441"""
443DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt")
444"""
445Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
446if parameter ``documentationRequirementsFile`` is not assigned.
447"""
449DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt")
450"""
451Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
452if parameter ``unittestRequirementsFile`` is not assigned.
453"""
455DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt")
456"""
457Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
458if parameter ``packagingRequirementsFile`` is not assigned.
459"""
461DEFAULT_VERSION_FILE = Path("__init__.py")
464@export
465def DescribePythonPackage(
466 packageName: str,
467 description: str,
468 projectURL: str,
469 sourceCodeURL: str,
470 documentationURL: str,
471 issueTrackerCodeURL: str,
472 keywords: Iterable[str] = None,
473 license: License = DEFAULT_LICENSE,
474 readmeFile: Path = DEFAULT_README,
475 requirementsFile: Path = DEFAULT_REQUIREMENTS,
476 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
477 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
478 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
479 additionalRequirements: Dict[str, List[str]] = None,
480 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE,
481 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
482 developmentStatus: str = "stable",
483 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
484 consoleScripts: Dict[str, str] = None,
485 dataFiles: Dict[str, List[str]] = None,
486 debug: bool = False
487) -> Dict[str, Any]:
488 """
489 Helper function to describe a Python package.
491 .. hint::
493 Some information will be gathered automatically from well-known files.
495 Examples: ``README.md``, ``requirements.txt``, ``__init__.py``
497 .. topic:: Handling of namespace packages
499 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then
500 :func:`setuptools.find_namespace_packages` is used to discover package files. |br|
501 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used.
503 In both cases, the following packages (directories) are excluded from search:
505 * ``build``, ``build.*``
506 * ``dist``, ``dist.*``
507 * ``doc``, ``doc.*``
508 * ``tests``, ``tests.*``
510 .. topic:: Handling of minimal Python version
512 The minimal required Python version is selected from parameter ``pythonVersions``.
514 .. topic:: Handling of dunder variables
516 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the
517 resulting AST will be searched for the following dunder variables:
519 * ``__author__``: :class:`str`
520 * ``__copyright__``: :class:`str`
521 * ``__email__``: :class:`str`
522 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`]
523 * ``__license__``: :class:`str`
524 * ``__version__``: :class:`str`
526 The gathered information be used to add further mappings in the result dictionary.
528 .. topic:: Handling of package classifiers
530 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional
531 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly
532 converted to package classifiers, so no need to specify them in parameter ``classifiers``.
534 The following classifiers are implicitly handled:
536 license
537 The license specified by parameter ``license`` is translated into a classifier. |br|
538 See also :meth:`pyTooling.Licensing.License.PythonClassifier`
540 Python versions
541 Always add ``Programming Language :: Python :: 3 :: Only``. |br|
542 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added.
544 Development status
545 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added.
547 .. topic:: Handling of extra requirements
549 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra*
550 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called
551 ``all`` is added, so developers can install all dependencies needed for package development.
553 ``doc``
554 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined.
555 ``test``
556 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined.
557 ``build``
558 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined.
559 User-defined
560 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the
561 dictionary will be added.
562 ``all``
563 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all
564 extra requirements.
566 .. topic:: Handling of keywords
568 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion``
569 will be used. Otherwise, the content of the parameter, if not None or empty.
571 :param packageName: Name of the Python package.
572 :param description: Short description of the package. The long description will be read from README file.
573 :param projectURL: URL to the Python project.
574 :param sourceCodeURL: URL to the Python source code.
575 :param documentationURL: URL to the package's documentation.
576 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system).
577 :param keywords: A list of keywords.
578 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
579 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
580 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
581 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
582 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
583 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
584 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
585 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
586 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
587 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
588 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
589 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
590 :param dataFiles: A dictionary mapping package names to lists of additional data files.
591 :param debug: Enable extended outputs for debugging.
592 :returns: A dictionary suitable for :func:`setuptools.setup`.
593 :raises ToolingException: If package 'setuptools' is not available.
594 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
595 :raises FileNotFoundError: If README file doesn't exist.
596 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
597 :raises FileNotFoundError: If requirements file doesn't exist.
598 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
599 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
600 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
601 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
602 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
603 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
604 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
605 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
606 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
607 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
608 """
609 try:
610 from setuptools import find_packages, find_namespace_packages
611 except ImportError as ex:
612 raise ToolingException(f"Optional dependency 'setuptools' is not available.") from ex
614 print(f"[pyTooling.Packaging] Python: {version_info.major}.{version_info.minor}.{version_info.micro}, pyTooling: {__version__}")
616 # Read README for upload to PyPI
617 if not isinstance(readmeFile, Path): 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true
618 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
619 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
620 raise ex
621 elif not readmeFile.exists(): 621 ↛ 622line 621 didn't jump to line 622 because the condition on line 621 was never true
622 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.")
623 else:
624 readme = loadReadmeFile(readmeFile)
626 # Read requirements file and add them to package dependency list (remove duplicates)
627 if not isinstance(requirementsFile, Path): 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true
628 ex = TypeError(f"Parameter 'requirementsFile' is not of type 'Path'.")
629 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
630 raise ex
631 elif not requirementsFile.exists(): 631 ↛ 632line 631 didn't jump to line 632 because the condition on line 631 was never true
632 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.")
633 else:
634 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug)))
636 extraRequirements: Dict[str, List[str]] = {}
637 if documentationRequirementsFile is not None: 637 ↛ 650line 637 didn't jump to line 650 because the condition on line 637 was always true
638 if not isinstance(documentationRequirementsFile, Path): 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true
639 ex = TypeError(f"Parameter 'documentationRequirementsFile' is not of type 'Path'.")
640 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.")
641 raise ex
642 elif not documentationRequirementsFile.exists(): 642 ↛ 643line 642 didn't jump to line 643 because the condition on line 642 was never true
643 if debug:
644 print(f"[pyTooling.Packaging] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
645 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
646 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
647 else:
648 extraRequirements["doc"] = list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug)))
650 if unittestRequirementsFile is not None: 650 ↛ 663line 650 didn't jump to line 663 because the condition on line 650 was always true
651 if not isinstance(unittestRequirementsFile, Path): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 ex = TypeError(f"Parameter 'unittestRequirementsFile' is not of type 'Path'.")
653 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.")
654 raise ex
655 elif not unittestRequirementsFile.exists(): 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true
656 if debug:
657 print(f"[pyTooling.Packaging] Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
658 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
659 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
660 else:
661 extraRequirements["test"] = list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug)))
663 if packagingRequirementsFile is not None: 663 ↛ 676line 663 didn't jump to line 676 because the condition on line 663 was always true
664 if not isinstance(packagingRequirementsFile, Path): 664 ↛ 665line 664 didn't jump to line 665 because the condition on line 664 was never true
665 ex = TypeError(f"Parameter 'packagingRequirementsFile' is not of type 'Path'.")
666 ex.add_note(f"Got type '{getFullyQualifiedName(packagingRequirementsFile)}'.")
667 raise ex
668 elif not packagingRequirementsFile.exists():
669 if debug: 669 ↛ 670line 669 didn't jump to line 670 because the condition on line 669 was never true
670 print(f"[pyTooling.Packaging] Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
671 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
672 # raise FileNotFoundError(f"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
673 else:
674 extraRequirements["build"] = list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug)))
676 if additionalRequirements is not None:
677 for key, value in additionalRequirements.items():
678 extraRequirements[key] = value
680 if len(extraRequirements) > 0: 680 ↛ 684line 680 didn't jump to line 684 because the condition on line 680 was always true
681 extraRequirements["all"] = list(set([dep for deps in extraRequirements.values() for dep in deps]))
683 # Read __author__, __email__, __version__ from source file
684 if not isinstance(sourceFileWithVersion, Path): 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true
685 ex = TypeError(f"Parameter 'sourceFileWithVersion' is not of type 'Path'.")
686 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.")
687 raise ex
688 elif not sourceFileWithVersion.exists(): 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true
689 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.")
690 else:
691 versionInformation = extractVersionInformation(sourceFileWithVersion)
693 # Scan for packages and source files
694 if debug: 694 ↛ 695line 694 didn't jump to line 695 because the condition on line 694 was never true
695 print(f"[pyTooling.Packaging] Exclude list for find_(namespace_)packages:")
696 exclude = []
697 rootNamespace = firstElement(packageName.split("."))
698 for dirName in (dirItem.name for dirItem in os_scandir(Path.cwd()) if dirItem.is_dir() and "." not in dirItem.name and dirItem.name != rootNamespace):
699 exclude.append(f"{dirName}")
700 exclude.append(f"{dirName}.*")
701 if debug: 701 ↛ 702line 701 didn't jump to line 702 because the condition on line 701 was never true
702 print(f"[pyTooling.Packaging] - {dirName}, {dirName}.*")
704 if "." in packageName:
705 exclude.append(rootNamespace)
706 packages = find_namespace_packages(exclude=exclude)
707 if packageName.endswith(".*"): 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true
708 packageName = packageName[:-2]
709 else:
710 packages = find_packages(exclude=exclude)
712 if debug: 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true
713 print(f"[pyTooling.Packaging] Found packages: ({packages.__class__.__name__})")
714 for package in packages:
715 print(f"[pyTooling.Packaging] - {package}")
717 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0:
718 keywords = versionInformation.Keywords
720 # Assemble classifiers
721 classifiers = list(classifiers)
723 # Translate license to classifier
724 if not isinstance(license, License): 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true
725 ex = TypeError(f"Parameter 'license' is not of type 'License'.")
726 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
727 raise ex
728 classifiers.append(license.PythonClassifier)
730 def _naturalSorting(array: Iterable[str]) -> List[str]:
731 """A simple natural sorting implementation."""
732 # See http://nedbatchelder.com/blog/200712/human_sorting.html
733 def _toInt(text: str) -> Union[str, int]:
734 """Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string."""
735 return int(text) if text.isdigit() else text
737 def _createKey(text: str) -> Tuple[Union[str, float], ...]:
738 """
739 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by
740 their value.
741 """
742 return tuple(_toInt(part) for part in re_split(r"(\d+)", text))
744 sortedArray = list(array)
745 sortedArray.sort(key=_createKey)
746 return sortedArray
748 pythonVersions = _naturalSorting(pythonVersions)
750 # Translate Python versions to classifiers
751 classifiers.append("Programming Language :: Python :: 3 :: Only")
752 for v in pythonVersions:
753 classifiers.append(f"Programming Language :: Python :: {v}")
755 # Translate status to classifier
756 try:
757 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}")
758 except KeyError: # pragma: no cover
759 raise ValueError(f"Unsupported development status '{developmentStatus}'.")
761 # Assemble all package information
762 parameters = {
763 "name": packageName,
764 "version": versionInformation.Version,
765 "author": versionInformation.Author,
766 "author_email": versionInformation.Email,
767 "license": license.SPDXIdentifier,
768 "description": description,
769 "long_description": readme.Content,
770 "long_description_content_type": readme.MimeType,
771 "url": projectURL,
772 "project_urls": {
773 'Documentation': documentationURL,
774 'Source Code': sourceCodeURL,
775 'Issue Tracker': issueTrackerCodeURL
776 },
777 "packages": packages,
778 "classifiers": classifiers,
779 "keywords": keywords,
780 "python_requires": f">={pythonVersions[0]}",
781 "install_requires": requirements,
782 }
784 if len(extraRequirements) > 0: 784 ↛ 787line 784 didn't jump to line 787 because the condition on line 784 was always true
785 parameters["extras_require"] = extraRequirements
787 if consoleScripts is not None: 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true
788 scripts = []
789 for scriptName, entryPoint in consoleScripts.items():
790 scripts.append(f"{scriptName} = {entryPoint}")
792 parameters["entry_points"] = {
793 "console_scripts": scripts
794 }
796 if dataFiles: 796 ↛ 797line 796 didn't jump to line 797 because the condition on line 796 was never true
797 parameters["package_data"] = dataFiles
799 return parameters
802@export
803def DescribePythonPackageHostedOnGitHub(
804 packageName: str,
805 description: str,
806 gitHubNamespace: str,
807 gitHubRepository: str = None,
808 projectURL: str = None,
809 keywords: Iterable[str] = None,
810 license: License = DEFAULT_LICENSE,
811 readmeFile: Path = DEFAULT_README,
812 requirementsFile: Path = DEFAULT_REQUIREMENTS,
813 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
814 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
815 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
816 additionalRequirements: Dict[str, List[str]] = None,
817 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE,
818 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
819 developmentStatus: str = "stable",
820 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
821 consoleScripts: Dict[str, str] = None,
822 dataFiles: Dict[str, List[str]] = None,
823 debug: bool = False
824) -> Dict[str, Any]:
825 """
826 Helper function to describe a Python package when the source code is hosted on GitHub.
828 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub
829 namespace and repository name: issue tracker URL, source code URL, ...
831 :param packageName: Name of the Python package.
832 :param description: Short description of the package. The long description will be read from README file.
833 :param gitHubNamespace: Name of the GitHub namespace (organization or user).
834 :param gitHubRepository: Name of the GitHub repository.
835 :param projectURL: URL to the Python project.
836 :param keywords: A list of keywords.
837 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
838 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
839 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
840 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
841 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
842 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
843 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
844 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
845 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
846 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
847 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
848 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
849 :param dataFiles: A dictionary mapping package names to lists of additional data files.
850 :param debug: Enable extended outputs for debugging.
851 :returns: A dictionary suitable for :func:`setuptools.setup`.
852 :raises ToolingException: If package 'setuptools' is not available.
853 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
854 :raises FileNotFoundError: If README file doesn't exist.
855 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
856 :raises FileNotFoundError: If requirements file doesn't exist.
857 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
858 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
859 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
860 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
861 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
862 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
863 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
864 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
865 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
866 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
867 """
868 if gitHubRepository is None: 868 ↛ 870line 868 didn't jump to line 870 because the condition on line 868 was never true
869 # Assign GitHub repository name without '.*', if derived from Python package name.
870 if packageName.endswith(".*"):
871 gitHubRepository = packageName[:-2]
872 else:
873 gitHubRepository = packageName
875 # Derive URLs
876 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}"
877 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}"
878 issueTrackerCodeURL = f"{sourceCodeURL}/issues"
880 projectURL = projectURL if projectURL is not None else sourceCodeURL
882 return DescribePythonPackage(
883 packageName=packageName,
884 description=description,
885 keywords=keywords,
886 projectURL=projectURL,
887 sourceCodeURL=sourceCodeURL,
888 documentationURL=documentationURL,
889 issueTrackerCodeURL=issueTrackerCodeURL,
890 license=license,
891 readmeFile=readmeFile,
892 requirementsFile=requirementsFile,
893 documentationRequirementsFile=documentationRequirementsFile,
894 unittestRequirementsFile=unittestRequirementsFile,
895 packagingRequirementsFile=packagingRequirementsFile,
896 additionalRequirements=additionalRequirements,
897 sourceFileWithVersion=sourceFileWithVersion,
898 classifiers=classifiers,
899 developmentStatus=developmentStatus,
900 pythonVersions=pythonVersions,
901 consoleScripts=consoleScripts,
902 dataFiles=dataFiles,
903 debug=debug,
904 )