Coverage for pyTooling/Packaging/__init__.py: 78%
284 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +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 pathlib import Path
39from re import split as re_split
40from sys import version_info
41from typing import List, Iterable, Dict, Sequence, Any, Optional as Nullable, Union, Tuple
43try:
44 from pyTooling.Decorators import export, readonly
45 from pyTooling.Exceptions import ToolingException
46 from pyTooling.MetaClasses import ExtendedType
47 from pyTooling.Common import getFullyQualifiedName
48 from pyTooling.Licensing import License, Apache_2_0_License
49except (ImportError, ModuleNotFoundError): # pragma: no cover
50 print("[pyTooling.Packaging] Could not import from 'pyTooling.*'!")
52 try:
53 from Decorators import export, readonly
54 from Exceptions import ToolingException
55 from MetaClasses import ExtendedType
56 from Common import getFullyQualifiedName
57 from Licensing import License, Apache_2_0_License
58 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
59 print("[pyTooling.Packaging] Could not import directly!")
60 raise ex
63__all__ = [
64 "STATUS", "DEFAULT_LICENSE", "DEFAULT_PY_VERSIONS", "DEFAULT_CLASSIFIERS", "DEFAULT_README", "DEFAULT_REQUIREMENTS",
65 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS",
66 "DEFAULT_VERSION_FILE"
67]
70@export
71class Readme:
72 """Encapsulates the READMEs file content and MIME type."""
74 _content: str #: Content of the README file
75 _mimeType: str #: MIME type of the README content
77 def __init__(self, content: str, mimeType: str) -> None:
78 """
79 Initializes a README file wrapper.
81 :param content: Raw content of the README file.
82 :param mimeType: MIME type of the README file.
83 """
84 self._content = content
85 self._mimeType = mimeType
87 @readonly
88 def Content(self) -> str:
89 """
90 Read-only property to access the README's content.
92 :returns: Raw content of the README file.
93 """
94 return self._content
96 @readonly
97 def MimeType(self) -> str:
98 """
99 Read-only property to access the README's MIME type.
101 :returns: The MIME type of the README file.
102 """
103 return self._mimeType
106@export
107def loadReadmeFile(readmeFile: Path) -> Readme:
108 """
109 Read the README file (e.g. in Markdown format), so it can be used as long description for the package.
111 Supported formats:
113 * Plain text (``*.txt``)
114 * Markdown (``*.md``)
115 * ReStructured Text (``*.rst``)
117 :param readmeFile: Path to the `README` file as an instance of :class:`Path`.
118 :returns: A tuple containing the file content and the MIME type.
119 :raises TypeError: If parameter 'readmeFile' is not of type 'Path'.
120 :raises ValueError: If README file has an unsupported format.
121 :raises FileNotFoundError: If README file does not exist.
122 """
123 if not isinstance(readmeFile, Path): 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
125 if version_info >= (3, 11): # pragma: no cover
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 if version_info >= (3, 11): # pragma: no cover
172 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
173 raise ex
175 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> List[str]:
176 """Recursive variant of :func:`loadRequirementsFile`."""
177 requirements = []
178 try:
179 with requirementsFile.open("r", encoding="utf-8") as file:
180 if debug:
181 print(f"[pyTooling.Packaging]{' ' * indent} Extracting requirements from '{requirementsFile}'.")
182 for line in file.readlines():
183 line = line.strip()
184 if line.startswith("#") or line == "":
185 continue
186 elif line.startswith("-r"):
187 # Remove the first word/argument (-r)
188 filename = line[2:].lstrip()
189 requirements += _loadRequirementsFile(requirementsFile.parent / filename, indent + 1)
190 elif line.startswith("https"):
191 if debug:
192 print(f"[pyTooling.Packaging]{' ' * indent} Found URL '{line}'.")
194 # Convert 'URL#NAME' to 'NAME @ URL'
195 splitItems = line.split("#")
196 requirements.append(f"{splitItems[1]} @ {splitItems[0]}")
197 else:
198 if debug:
199 print(f"[pyTooling.Packaging]{' ' * indent} - {line}")
201 requirements.append(line)
202 except FileNotFoundError as ex:
203 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex
205 return requirements
207 return _loadRequirementsFile(requirementsFile, 0)
210@export
211class VersionInformation(metaclass=ExtendedType, slots=True):
212 """Encapsulates version information extracted from a Python source file."""
214 _author: str #: Author name(s).
215 _copyright: str #: Copyright information.
216 _email: str #: Author's email address.
217 _keywords: List[str] #: Keywords.
218 _license: str #: License name.
219 _description: str #: Description of the package.
220 _version: str #: Version number.
222 def __init__(
223 self,
224 author: str,
225 email: str,
226 copyright: str,
227 license: str,
228 version: str,
229 description: str,
230 keywords: Iterable[str]
231 ) -> None:
232 """
233 Initializes a Python package (version) information instance.
235 :param author: Author of the Python package.
236 :param email: The author's email address
237 :param copyright: The copyright notice of the Package.
238 :param license: The Python package's license.
239 :param version: The Python package's version.
240 :param description: The Python package's short description.
241 :param keywords: The Python package's list of keywords.
242 """
243 self._author = author
244 self._email = email
245 self._copyright = copyright
246 self._license = license
247 self._version = version
248 self._description = description
249 self._keywords = [k for k in keywords]
251 @readonly
252 def Author(self) -> str:
253 """Name(s) of the package author(s)."""
254 return self._author
256 @readonly
257 def Copyright(self) -> str:
258 """Copyright information."""
259 return self._copyright
261 @readonly
262 def Description(self) -> str:
263 """Package description text."""
264 return self._description
266 @readonly
267 def Email(self) -> str:
268 """Email address of the author."""
269 return self._email
271 @readonly
272 def Keywords(self) -> List[str]:
273 """List of keywords."""
274 return self._keywords
276 @readonly
277 def License(self) -> str:
278 """License name."""
279 return self._license
281 @readonly
282 def Version(self) -> str:
283 """Version number."""
284 return self._version
287@export
288def extractVersionInformation(sourceFile: Path) -> VersionInformation:
289 """
290 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information.
292 Supported variables:
294 * ``__author__``
295 * ``__copyright__``
296 * ``__email__``
297 * ``__keywords__``
298 * ``__license__``
299 * ``__version__``
301 :param sourceFile: Path to a Python source file as an instance of :class:`Path`.
302 :returns: An instance of :class:`VersionInformation` with gathered variable contents.
303 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`.
305 """
306 if not isinstance(sourceFile, Path): 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 ex = TypeError(f"Parameter 'sourceFile' is not of type 'Path'.")
308 if version_info >= (3, 11): # pragma: no cover
309 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.")
310 raise ex
312 _author = None
313 _copyright = None
314 _description = ""
315 _email = None
316 _keywords = []
317 _license = None
318 _version = None
320 try:
321 with sourceFile.open("r", encoding="utf-8") as file:
322 content = file.read()
323 except FileNotFoundError as ex:
324 raise FileNotFoundError
326 try:
327 ast = ast_parse(content)
328 except Exception as ex: # pragma: no cover
329 raise ToolingException(f"Internal error when parsing '{sourceFile}'.") from ex
331 for item in iter_child_nodes(ast):
332 if isinstance(item, Assign) and len(item.targets) == 1:
333 target = item.targets[0]
334 value = item.value
335 if isinstance(target, Name) and target.id == "__author__":
336 if isinstance(value, Constant) and isinstance(value.value, str): 336 ↛ 338line 336 didn't jump to line 338 because the condition on line 336 was always true
337 _author = value.value
338 if isinstance(target, Name) and target.id == "__copyright__":
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 _copyright = value.value
341 if isinstance(target, Name) and target.id == "__email__":
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 _email = value.value
344 if isinstance(target, Name) and target.id == "__keywords__":
345 if isinstance(value, Constant) and isinstance(value.value, str): # pragma: no cover
346 raise TypeError(f"Variable '__keywords__' should be a list of strings.")
347 elif isinstance(value, ast_List):
348 for const in value.elts:
349 if isinstance(const, Constant) and isinstance(const.value, str):
350 _keywords.append(const.value)
351 else: # pragma: no cover
352 raise TypeError(f"List elements in '__keywords__' should be strings.")
353 else: # pragma: no cover
354 raise TypeError(f"Used unsupported type for variable '__keywords__'.")
355 if isinstance(target, Name) and target.id == "__license__":
356 if isinstance(value, Constant) and isinstance(value.value, str): 356 ↛ 358line 356 didn't jump to line 358 because the condition on line 356 was always true
357 _license = value.value
358 if isinstance(target, Name) and target.id == "__version__":
359 if isinstance(value, Constant) and isinstance(value.value, str): 359 ↛ 331line 359 didn't jump to line 331 because the condition on line 359 was always true
360 _version = value.value
362 if _author is None:
363 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover
364 if _copyright is None:
365 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover
366 if _email is None:
367 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover
368 if _license is None:
369 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover
370 if _version is None:
371 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover
373 return VersionInformation(_author, _email, _copyright, _license, _version, _description, _keywords)
376STATUS: Dict[str, str] = {
377 "planning": "1 - Planning",
378 "pre-alpha": "2 - Pre-Alpha",
379 "alpha": "3 - Alpha",
380 "beta": "4 - Beta",
381 "stable": "5 - Production/Stable",
382 "mature": "6 - Mature",
383 "inactive": "7 - Inactive"
384}
385"""
386A dictionary of supported development status values.
388The mapping's value will be appended to ``Development Status :: `` to form a package classifier.
3901. Planning
3912. Pre-Alpha
3923. Alpha
3934. Beta
3945. Production/Stable
3956. Mature
3967. Inactive
398.. seealso::
400 `Python package classifiers <https://pypi.org/classifiers/>`__
401"""
403DEFAULT_LICENSE = Apache_2_0_License
404"""
405Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
406if parameter ``license`` is not assigned.
407"""
409DEFAULT_PY_VERSIONS = ("3.9", "3.10", "3.11", "3.12", "3.13")
410"""
411A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
412if parameter ``pythonVersions`` is not assigned.
414.. seealso::
416 `Status of Python versions <https://devguide.python.org/versions/>`__
417"""
419DEFAULT_CLASSIFIERS = (
420 "Operating System :: OS Independent",
421 "Intended Audience :: Developers",
422 "Topic :: Utilities"
423 )
424"""
425A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
426if parameter ``classifiers`` is not assigned.
428.. seealso::
430 `Python package classifiers <https://pypi.org/classifiers/>`__
431"""
433DEFAULT_README = Path("README.md")
434"""
435Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
436if parameter ``readmeFile`` is not assigned.
437"""
439DEFAULT_REQUIREMENTS = Path("requirements.txt")
440"""
441Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
442if parameter ``requirementsFile`` is not assigned.
443"""
445DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt")
446"""
447Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
448if parameter ``documentationRequirementsFile`` is not assigned.
449"""
451DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt")
452"""
453Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
454if parameter ``unittestRequirementsFile`` is not assigned.
455"""
457DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt")
458"""
459Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
460if parameter ``packagingRequirementsFile`` is not assigned.
461"""
463DEFAULT_VERSION_FILE = Path("__init__.py")
466@export
467def DescribePythonPackage(
468 packageName: str,
469 description: str,
470 projectURL: str,
471 sourceCodeURL: str,
472 documentationURL: str,
473 issueTrackerCodeURL: str,
474 keywords: Iterable[str] = None,
475 license: License = DEFAULT_LICENSE,
476 readmeFile: Path = DEFAULT_README,
477 requirementsFile: Path = DEFAULT_REQUIREMENTS,
478 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
479 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
480 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
481 additionalRequirements: Dict[str, List[str]] = None,
482 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE,
483 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
484 developmentStatus: str = "stable",
485 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
486 consoleScripts: Dict[str, str] = None,
487 dataFiles: Dict[str, List[str]] = None,
488 debug: bool = False
489) -> Dict[str, Any]:
490 """
491 Helper function to describe a Python package.
493 .. hint::
495 Some information will be gathered automatically from well-known files.
497 Examples: ``README.md``, ``requirements.txt``, ``__init__.py``
499 .. topic:: Handling of namespace packages
501 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then
502 :func:`setuptools.find_namespace_packages` is used to discover package files. |br|
503 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used.
505 In both cases, the following packages (directories) are excluded from search:
507 * ``build``, ``build.*``
508 * ``dist``, ``dist.*``
509 * ``doc``, ``doc.*``
510 * ``tests``, ``tests.*``
512 .. topic:: Handling of minimal Python version
514 The minimal required Python version is selected from parameter ``pythonVersions``.
516 .. topic:: Handling of dunder variables
518 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the
519 resulting AST will be searched for the following dunder variables:
521 * ``__author__``: :class:`str`
522 * ``__copyright__``: :class:`str`
523 * ``__email__``: :class:`str`
524 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`]
525 * ``__license__``: :class:`str`
526 * ``__version__``: :class:`str`
528 The gathered information be used to add further mappings in the result dictionary.
530 .. topic:: Handling of package classifiers
532 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional
533 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly
534 converted to package classifiers, so no need to specify them in parameter ``classifiers``.
536 The following classifiers are implicitly handled:
538 license
539 The license specified by parameter ``license`` is translated into a classifier. |br|
540 See also :meth:`pyTooling.Licensing.License.PythonClassifier`
542 Python versions
543 Always add ``Programming Language :: Python :: 3 :: Only``. |br|
544 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added.
546 Development status
547 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added.
549 .. topic:: Handling of extra requirements
551 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra*
552 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called
553 ``all`` is added, so developers can install all dependencies needed for package development.
555 ``doc``
556 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined.
557 ``test``
558 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined.
559 ``build``
560 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined.
561 User-defined
562 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the
563 dictionary will be added.
564 ``all``
565 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all
566 extra requirements.
568 .. topic:: Handling of keywords
570 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion``
571 will be used. Otherwise, the content of the parameter, if not None or empty.
573 :param packageName: Name of the Python package.
574 :param description: Short description of the package. The long description will be read from README file.
575 :param projectURL: URL to the Python project.
576 :param sourceCodeURL: URL to the Python source code.
577 :param documentationURL: URL to the package's documentation.
578 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system).
579 :param keywords: A list of keywords.
580 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
581 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
582 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
583 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
584 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
585 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
586 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
587 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
588 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
589 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
590 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
591 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
592 :param dataFiles: A dictionary mapping package names to lists of additional data files.
593 :param debug: Enable extended outputs for debugging.
594 :returns: A dictionary suitable for :func:`setuptools.setup`.
595 :raises ToolingException: If package 'setuptools' is not available.
596 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
597 :raises FileNotFoundError: If README file doesn't exist.
598 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
599 :raises FileNotFoundError: If requirements file doesn't exist.
600 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
601 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
602 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
603 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
604 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
605 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
606 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
607 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
608 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
609 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
610 """
611 try:
612 from setuptools import find_packages, find_namespace_packages
613 except ImportError as ex:
614 raise ToolingException(f"Optional dependency 'setuptools' is not available.") from ex
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 if version_info >= (3, 11): # pragma: no cover
620 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
621 raise ex
622 elif not readmeFile.exists(): 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.")
624 else:
625 readme = loadReadmeFile(readmeFile)
627 # Read requirements file and add them to package dependency list (remove duplicates)
628 if not isinstance(requirementsFile, Path): 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true
629 ex = TypeError(f"Parameter 'requirementsFile' is not of type 'Path'.")
630 if version_info >= (3, 11): # pragma: no cover
631 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
632 raise ex
633 elif not requirementsFile.exists(): 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true
634 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.")
635 else:
636 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug)))
638 extraRequirements: Dict[str, List[str]] = {}
639 if documentationRequirementsFile is not None: 639 ↛ 653line 639 didn't jump to line 653 because the condition on line 639 was always true
640 if not isinstance(documentationRequirementsFile, Path): 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true
641 ex = TypeError(f"Parameter 'documentationRequirementsFile' is not of type 'Path'.")
642 if version_info >= (3, 11): # pragma: no cover
643 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.")
644 raise ex
645 elif not documentationRequirementsFile.exists(): 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true
646 if debug:
647 print(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
648 print(" No section added to 'extraRequirements'.")
649 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
650 else:
651 extraRequirements["doc"] = list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug)))
653 if unittestRequirementsFile is not None: 653 ↛ 667line 653 didn't jump to line 667 because the condition on line 653 was always true
654 if not isinstance(unittestRequirementsFile, Path): 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true
655 ex = TypeError(f"Parameter 'unittestRequirementsFile' is not of type 'Path'.")
656 if version_info >= (3, 11): # pragma: no cover
657 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.")
658 raise ex
659 elif not unittestRequirementsFile.exists(): 659 ↛ 660line 659 didn't jump to line 660 because the condition on line 659 was never true
660 if debug:
661 print(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
662 print(" No section added to 'extraRequirements'.")
663 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
664 else:
665 extraRequirements["test"] = list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug)))
667 if packagingRequirementsFile is not None: 667 ↛ 681line 667 didn't jump to line 681 because the condition on line 667 was always true
668 if not isinstance(packagingRequirementsFile, Path): 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true
669 ex = TypeError(f"Parameter 'packagingRequirementsFile' is not of type 'Path'.")
670 if version_info >= (3, 11): # pragma: no cover
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"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
676 print(" 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 if version_info >= (3, 11): # pragma: no cover
692 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.")
693 raise ex
694 elif not sourceFileWithVersion.exists(): 694 ↛ 695line 694 didn't jump to line 695 because the condition on line 694 was never true
695 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.")
696 else:
697 versionInformation = extractVersionInformation(sourceFileWithVersion)
699 # Scan for packages and source files
700 exclude = ["build", "build.*", "dist", "dist.*", "doc", "doc.*", "tests", "tests.*"]
701 if "." in packageName:
702 packages = find_namespace_packages(exclude=exclude)
703 if packageName.endswith(".*"): 703 ↛ 704line 703 didn't jump to line 704 because the condition on line 703 was never true
704 packageName = packageName[:-2]
705 else:
706 packages = find_packages(exclude=exclude)
708 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0:
709 keywords = versionInformation.Keywords
711 # Assemble classifiers
712 classifiers = list(classifiers)
714 # Translate license to classifier
715 if not isinstance(license, License): 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true
716 ex = TypeError(f"Parameter 'license' is not of type 'License'.")
717 if version_info >= (3, 11): # pragma: no cover
718 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
719 raise ex
720 classifiers.append(license.PythonClassifier)
722 def _naturalSorting(array: Iterable[str]) -> List[str]:
723 """A simple natural sorting implementation."""
724 # See http://nedbatchelder.com/blog/200712/human_sorting.html
725 def _toInt(text: str) -> Union[str, int]:
726 """Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string."""
727 return int(text) if text.isdigit() else text
729 def _createKey(text: str) -> Tuple[Union[str, float], ...]:
730 """
731 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by
732 their value.
733 """
734 return tuple(_toInt(part) for part in re_split(r"(\d+)", text))
736 sortedArray = list(array)
737 sortedArray.sort(key=_createKey)
738 return sortedArray
740 pythonVersions = _naturalSorting(pythonVersions)
742 # Translate Python versions to classifiers
743 classifiers.append("Programming Language :: Python :: 3 :: Only")
744 for v in pythonVersions:
745 classifiers.append(f"Programming Language :: Python :: {v}")
747 # Translate status to classifier
748 try:
749 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}")
750 except KeyError: # pragma: no cover
751 raise ValueError(f"Unsupported development status '{developmentStatus}'.")
753 # Assemble all package information
754 parameters = {
755 "name": packageName,
756 "version": versionInformation.Version,
757 "author": versionInformation.Author,
758 "author_email": versionInformation.Email,
759 "license": license.SPDXIdentifier,
760 "description": description,
761 "long_description": readme.Content,
762 "long_description_content_type": readme.MimeType,
763 "url": projectURL,
764 "project_urls": {
765 'Documentation': documentationURL,
766 'Source Code': sourceCodeURL,
767 'Issue Tracker': issueTrackerCodeURL
768 },
769 "packages": packages,
770 "classifiers": classifiers,
771 "keywords": keywords,
772 "python_requires": f">={pythonVersions[0]}",
773 "install_requires": requirements,
774 }
776 if len(extraRequirements) > 0: 776 ↛ 779line 776 didn't jump to line 779 because the condition on line 776 was always true
777 parameters["extras_require"] = extraRequirements
779 if consoleScripts is not None: 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true
780 scripts = []
781 for scriptName, entryPoint in consoleScripts.items():
782 scripts.append(f"{scriptName} = {entryPoint}")
784 parameters["entry_points"] = {
785 "console_scripts": scripts
786 }
788 if dataFiles: 788 ↛ 789line 788 didn't jump to line 789 because the condition on line 788 was never true
789 parameters["package_data"] = dataFiles
791 return parameters
794@export
795def DescribePythonPackageHostedOnGitHub(
796 packageName: str,
797 description: str,
798 gitHubNamespace: str,
799 gitHubRepository: str = None,
800 projectURL: str = None,
801 keywords: Iterable[str] = None,
802 license: License = DEFAULT_LICENSE,
803 readmeFile: Path = DEFAULT_README,
804 requirementsFile: Path = DEFAULT_REQUIREMENTS,
805 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
806 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
807 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
808 additionalRequirements: Dict[str, List[str]] = None,
809 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE,
810 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
811 developmentStatus: str = "stable",
812 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
813 consoleScripts: Dict[str, str] = None,
814 dataFiles: Dict[str, List[str]] = None,
815 debug: bool = False
816) -> Dict[str, Any]:
817 """
818 Helper function to describe a Python package when the source code is hosted on GitHub.
820 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub
821 namespace and repository name: issue tracker URL, source code URL, ...
823 :param packageName: Name of the Python package.
824 :param description: Short description of the package. The long description will be read from README file.
825 :param gitHubNamespace: Name of the GitHub namespace (organization or user).
826 :param gitHubRepository: Name of the GitHub repository.
827 :param projectURL: URL to the Python project.
828 :param keywords: A list of keywords.
829 :param license: The package's license. (Default: ``Apache License, 2.0``, see :const:`DEFAULT_LICENSE`)
830 :param readmeFile: The path to the README file. (Default: ``README.md``, see :const:`DEFAULT_README`)
831 :param requirementsFile: The path to the project's requirements file. (Default: ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
832 :param documentationRequirementsFile: The path to the project's requirements file for documentation. (Default: ``doc/requirements.txt``, see :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
833 :param unittestRequirementsFile: The path to the project's requirements file for unit tests. (Default: ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
834 :param packagingRequirementsFile: The path to the project's requirements file for packaging. (Default: ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
835 :param additionalRequirements: A dictionary of a lists with additional requirements. (default: None)
836 :param sourceFileWithVersion: The path to the project's source file containing dunder variables like ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
837 :param classifiers: A list of package classifiers. (Default: 3 classifiers, see :const:`DEFAULT_CLASSIFIERS`)
838 :param developmentStatus: Development status of the package. (Default: stable, see :const:`STATUS` for supported status values)
839 :param pythonVersions: A list of supported Python 3 version. (Default: all currently maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
840 :param consoleScripts: A dictionary mapping command line names to entry points. (Default: None)
841 :param dataFiles: A dictionary mapping package names to lists of additional data files.
842 :param debug: Enable extended outputs for debugging.
843 :returns: A dictionary suitable for :func:`setuptools.setup`.
844 :raises ToolingException: If package 'setuptools' is not available.
845 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
846 :raises FileNotFoundError: If README file doesn't exist.
847 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
848 :raises FileNotFoundError: If requirements file doesn't exist.
849 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
850 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
851 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
852 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
853 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
854 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
855 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
856 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
857 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
858 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
859 """
860 if gitHubRepository is None: 860 ↛ 862line 860 didn't jump to line 862 because the condition on line 860 was never true
861 # Assign GitHub repository name without '.*', if derived from Python package name.
862 if packageName.endswith(".*"):
863 gitHubRepository = packageName[:-2]
864 else:
865 gitHubRepository = packageName
867 # Derive URLs
868 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}"
869 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}"
870 issueTrackerCodeURL = f"{sourceCodeURL}/issues"
872 projectURL = projectURL if projectURL is not None else sourceCodeURL
874 return DescribePythonPackage(
875 packageName=packageName,
876 description=description,
877 keywords=keywords,
878 projectURL=projectURL,
879 sourceCodeURL=sourceCodeURL,
880 documentationURL=documentationURL,
881 issueTrackerCodeURL=issueTrackerCodeURL,
882 license=license,
883 readmeFile=readmeFile,
884 requirementsFile=requirementsFile,
885 documentationRequirementsFile=documentationRequirementsFile,
886 unittestRequirementsFile=unittestRequirementsFile,
887 packagingRequirementsFile=packagingRequirementsFile,
888 additionalRequirements=additionalRequirements,
889 sourceFileWithVersion=sourceFileWithVersion,
890 classifiers=classifiers,
891 developmentStatus=developmentStatus,
892 pythonVersions=pythonVersions,
893 consoleScripts=consoleScripts,
894 dataFiles=dataFiles,
895 debug=debug,
896 )