Coverage for pyTooling/Packaging/__init__.py: 75%
301 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +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.
38.. seealso::
40 :mod:`pyTooling.Versioning`
41 |rarr| The version numbers read from a package's dunder variables.
42 :mod:`pyTooling.Licensing`
43 |rarr| The license names translated for setuptools.
44 :mod:`pyTooling.Testing`
45 |rarr| Testing the console scripts a package installs.
46"""
47from ast import parse as ast_parse, iter_child_nodes, Assign, Constant, Name, List as ast_List
48from collections.abc import Sized
49from os import scandir as os_scandir
50from pathlib import Path
51from re import split as re_split
52from sys import version_info
53from typing import Iterable, Sequence, Any, Optional as Nullable, Union
54from pyTooling.Decorators import export, readonly
55from pyTooling.Exceptions import ToolingException, MissingDependencyException
56from pyTooling.MetaClasses import ExtendedType
57from pyTooling.Common import __version__, getFullyQualifiedName, firstElement
58from pyTooling.Licensing import License, Apache_2_0_License
61__all__ = [
62 "STATUS", "DEFAULT_LICENSE", "DEFAULT_PY_VERSIONS", "DEFAULT_CLASSIFIERS", "DEFAULT_README", "DEFAULT_REQUIREMENTS",
63 "DEFAULT_DOCUMENTATION_REQUIREMENTS", "DEFAULT_TEST_REQUIREMENTS", "DEFAULT_PACKAGING_REQUIREMENTS",
64 "DEFAULT_VERSION_FILE"
65]
68@export
69class Readme:
70 """Encapsulates the READMEs file content and MIME type."""
72 _content: str #: Content of the README file
73 _mimeType: str #: MIME type of the README content
75 def __init__(self, content: str, mimeType: str) -> None:
76 """
77 Initializes a README file wrapper.
79 :param content: Raw content of the README file.
80 :param mimeType: MIME type of the README file.
81 """
82 self._content = content
83 self._mimeType = mimeType
85 @readonly
86 def Content(self) -> str:
87 """
88 Read-only property to access the README's content.
90 :returns: Raw content of the README file.
91 """
92 return self._content
94 @readonly
95 def MimeType(self) -> str:
96 """
97 Read-only property to access the README's MIME type.
99 :returns: The MIME type of the README file.
100 """
101 return self._mimeType
104@export
105def loadReadmeFile(readmeFile: Path) -> Readme:
106 """
107 Read the README file (e.g. in Markdown format), so it can be used as long description for the package.
109 Supported formats:
111 * Plain text (``*.txt``)
112 * Markdown (``*.md``)
113 * ReStructured Text (``*.rst``)
115 :param readmeFile: Optional, path to the `README` file as an instance of :class:`Path`.
116 :returns: A tuple containing the file content and the MIME type.
117 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
118 :raises ValueError: If README file has an unsupported format.
119 :raises FileNotFoundError: If README file does not exist.
120 """
121 if not isinstance(readmeFile, Path): 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
123 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
124 raise ex
126 if readmeFile.suffix == ".txt":
127 mimeType = "text/plain"
128 elif readmeFile.suffix == ".md":
129 mimeType = "text/markdown"
130 elif readmeFile.suffix == ".rst":
131 mimeType = "text/x-rst"
132 else: # pragma: no cover
133 raise ValueError("Unsupported README format.")
135 try:
136 with readmeFile.open("r", encoding="utf-8") as file:
137 return Readme(
138 content=file.read(),
139 mimeType=mimeType
140 )
141 except FileNotFoundError as ex:
142 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.") from ex
145@export
146def loadRequirementsFile(requirementsFile: Path, indent: int = 0, debug: bool = False) -> list[str]:
147 """
148 Reads a `requirements.txt` file (recursively) and extracts all specified dependencies into an array.
150 Special dependency entries like Git repository references are translates to match the syntax expected by setuptools.
152 .. hint::
154 Duplicates should be removed by converting the result to a :class:`set` and back to a :class:`list`.
156 .. code-block:: Python
158 requirements = list(set(loadRequirementsFile(requirementsFile)))
160 :param requirementsFile: Optional, path to the ``requirements.txt`` file as an instance of :class:`Path`.
161 :param indent: Optional, indentation level used for the debug output of nested requirements files.
162 :param debug: Optional, if ``True``, print found dependencies and recursion.
163 :returns: A list of dependencies.
164 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
165 :raises FileNotFoundError: If requirements file does not exist.
166 """
167 if not isinstance(requirementsFile, Path): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 ex = TypeError(f"Parameter '{requirementsFile}' is not of type 'Path'.")
169 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
170 raise ex
172 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> list[str]:
173 """
174 Recursive variant of :func:`loadRequirementsFile`.
176 :param requirementsFile: Optional, path to the requirements file to read.
177 :param indent: Optional, indentation level used for the debug output of nested requirements files.
178 :returns: List of requirements read from that file and every file it includes.
179 :raises FileNotFoundError: If the requirements file doesn't exist.
180 """
181 requirements = []
182 try:
183 with requirementsFile.open("r", encoding="utf-8") as file:
184 if debug:
185 print(f"[pyTooling.Packaging]{' ' * indent} Extracting requirements from '{requirementsFile}'.")
186 for line in file.readlines():
187 line = line.strip()
188 if line.startswith("#") or line == "":
189 continue
190 elif line.startswith("-r"):
191 # Remove the first word/argument (-r)
192 filename = line[2:].lstrip()
193 requirements += _loadRequirementsFile(requirementsFile.parent / filename, indent + 1)
194 elif line.startswith("https"):
195 if debug:
196 print(f"[pyTooling.Packaging]{' ' * indent} Found URL '{line}'.")
198 # Convert 'URL#NAME' to 'NAME @ URL'
199 splitItems = line.split("#")
200 requirements.append(f"{splitItems[1]} @ {splitItems[0]}")
201 else:
202 if debug:
203 print(f"[pyTooling.Packaging]{' ' * indent} - {line}")
205 requirements.append(line)
206 except FileNotFoundError as ex:
207 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex
209 return requirements
211 return _loadRequirementsFile(requirementsFile, 0)
214@export
215class VersionInformation(metaclass=ExtendedType, slots=True):
216 """Encapsulates version information extracted from a Python source file."""
218 _author: str #: Author name(s).
219 _copyright: str #: Copyright information.
220 _email: str #: Author's email address.
221 _keywords: list[str] #: Keywords.
222 _license: str #: License name.
223 _description: str #: Description of the package.
224 _version: str #: Version number.
226 def __init__(
227 self,
228 author: str,
229 email: str,
230 copyright: str,
231 license: str,
232 version: str,
233 description: str,
234 keywords: Iterable[str]
235 ) -> None:
236 """
237 Initializes a Python package (version) information instance.
239 :param author: Author of the Python package.
240 :param email: The author's email address
241 :param copyright: The copyright notice of the Package.
242 :param license: Optional, the Python package's license.
243 :param version: The Python package's version.
244 :param description: The Python package's short description.
245 :param keywords: Optional, the Python package's list of keywords.
246 """
247 self._author = author
248 self._email = email
249 self._copyright = copyright
250 self._license = license
251 self._version = version
252 self._description = description
253 self._keywords = [k for k in keywords]
255 @readonly
256 def Author(self) -> str:
257 """
258 Read-only property to access the name(s) of the package author(s) (:attr:`_author`).
260 :returns: Name(s) of the package author(s).
261 """
262 return self._author
264 @readonly
265 def Copyright(self) -> str:
266 """
267 Read-only property to access the package's copyright information (:attr:`_copyright`).
269 :returns: Copyright information.
270 """
271 return self._copyright
273 @readonly
274 def Description(self) -> str:
275 """
276 Read-only property to access the package description (:attr:`_description`).
278 :returns: Package description text.
279 """
280 return self._description
282 @readonly
283 def Email(self) -> str:
284 """
285 Read-only property to access the author's email address (:attr:`_email`).
287 :returns: Email address of the author.
288 """
289 return self._email
291 @readonly
292 def Keywords(self) -> list[str]:
293 """
294 Read-only property to access the package's keywords (:attr:`_keywords`).
296 :returns: List of keywords.
297 """
298 return self._keywords
300 @readonly
301 def License(self) -> str:
302 """
303 Read-only property to access the package's license (:attr:`_license`).
305 :returns: License name.
306 """
307 return self._license
309 @readonly
310 def Version(self) -> str:
311 """
312 Read-only property to access the package's version number (:attr:`_version`).
314 :returns: Version number.
315 """
316 return self._version
318 def __str__(self) -> str:
319 """
320 Return a string representation of this version information.
322 :returns: The version number.
323 """
324 return f"{self._version}"
327@export
328def extractVersionInformation(sourceFile: Path) -> VersionInformation:
329 """
330 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information.
332 Supported variables:
334 * ``__author__``
335 * ``__copyright__``
336 * ``__email__``
337 * ``__keywords__``
338 * ``__license__``
339 * ``__version__``
341 :param sourceFile: Path to a Python source file as an instance of :class:`Path`.
342 :returns: An instance of :class:`VersionInformation` with gathered variable contents.
343 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`.
344 :raises FileNotFoundError: If the given file doesn't exist.
345 :raises AssertionError: If a dunder variable is missing in the given file.
346 :raises ToolingException: If a dunder variable has an unexpected format.
347 """
348 if not isinstance(sourceFile, Path): 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true
349 ex = TypeError(f"Parameter 'sourceFile' is not of type 'Path'.")
350 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.")
351 raise ex
353 _author = None
354 _copyright = None
355 _description = ""
356 _email = None
357 _keywords = []
358 _license = None
359 _version = None
361 try:
362 with sourceFile.open("r", encoding="utf-8") as file:
363 content = file.read()
364 except FileNotFoundError as ex:
365 raise FileNotFoundError
367 try:
368 ast = ast_parse(content)
369 except Exception as ex: # pragma: no cover
370 raise ToolingException(f"Internal error when parsing '{sourceFile}'.") from ex
372 for item in iter_child_nodes(ast):
373 if isinstance(item, Assign) and len(item.targets) == 1:
374 target = item.targets[0]
375 value = item.value
376 if isinstance(target, Name) and target.id == "__author__":
377 if isinstance(value, Constant) and isinstance(value.value, str): 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was always true
378 _author = value.value
379 if isinstance(target, Name) and target.id == "__copyright__":
380 if isinstance(value, Constant) and isinstance(value.value, str): 380 ↛ 382line 380 didn't jump to line 382 because the condition on line 380 was always true
381 _copyright = value.value
382 if isinstance(target, Name) and target.id == "__email__":
383 if isinstance(value, Constant) and isinstance(value.value, str): 383 ↛ 385line 383 didn't jump to line 385 because the condition on line 383 was always true
384 _email = value.value
385 if isinstance(target, Name) and target.id == "__keywords__":
386 if isinstance(value, Constant) and isinstance(value.value, str): # pragma: no cover
387 raise TypeError(f"Variable '__keywords__' should be a list of strings.")
388 elif isinstance(value, ast_List):
389 for const in value.elts:
390 if isinstance(const, Constant) and isinstance(const.value, str):
391 _keywords.append(const.value)
392 else: # pragma: no cover
393 raise TypeError(f"List elements in '__keywords__' should be strings.")
394 else: # pragma: no cover
395 raise TypeError(f"Used unsupported type for variable '__keywords__'.")
396 if isinstance(target, Name) and target.id == "__license__":
397 if isinstance(value, Constant) and isinstance(value.value, str): 397 ↛ 399line 397 didn't jump to line 399 because the condition on line 397 was always true
398 _license = value.value
399 if isinstance(target, Name) and target.id == "__version__":
400 if isinstance(value, Constant) and isinstance(value.value, str): 400 ↛ 372line 400 didn't jump to line 372 because the condition on line 400 was always true
401 _version = value.value
403 if _author is None:
404 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover
405 if _copyright is None:
406 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover
407 if _email is None:
408 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover
409 if _license is None:
410 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover
411 if _version is None:
412 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover
414 return VersionInformation(_author, _email, _copyright, _license, _version, _description, _keywords)
417STATUS: dict[str, str] = {
418 "planning": "1 - Planning",
419 "pre-alpha": "2 - Pre-Alpha",
420 "alpha": "3 - Alpha",
421 "beta": "4 - Beta",
422 "stable": "5 - Production/Stable",
423 "mature": "6 - Mature",
424 "inactive": "7 - Inactive"
425}
426"""
427A dictionary of supported development status values.
429The mapping's value will be appended to ``Development Status :: `` to form a package classifier.
4311. Planning
4322. Pre-Alpha
4333. Alpha
4344. Beta
4355. Production/Stable
4366. Mature
4377. Inactive
439.. seealso::
441 `Python package classifiers <https://pypi.org/classifiers/>`__
442"""
444DEFAULT_LICENSE = Apache_2_0_License
445"""
446Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
447if parameter ``license`` is not assigned.
448"""
450DEFAULT_PY_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14")
451"""
452A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
453if parameter ``pythonVersions`` is not assigned.
455.. seealso::
457 `Status of Python versions <https://devguide.python.org/versions/>`__
458"""
460DEFAULT_CLASSIFIERS = (
461 "Operating System :: OS Independent",
462 "Intended Audience :: Developers",
463 "Topic :: Utilities"
464 )
465"""
466A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
467if parameter ``classifiers`` is not assigned.
469.. seealso::
471 `Python package classifiers <https://pypi.org/classifiers/>`__
472"""
474DEFAULT_README = Path("README.md")
475"""
476Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
477if parameter ``readmeFile`` is not assigned.
478"""
480DEFAULT_REQUIREMENTS = Path("requirements.txt")
481"""
482Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
483if parameter ``requirementsFile`` is not assigned.
484"""
486DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt")
487"""
488Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
489if parameter ``documentationRequirementsFile`` is not assigned.
490"""
492DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt")
493"""
494Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
495if parameter ``unittestRequirementsFile`` is not assigned.
496"""
498DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt")
499"""
500Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
501if parameter ``packagingRequirementsFile`` is not assigned.
502"""
504DEFAULT_VERSION_FILE = Path("__init__.py")
507@export
508def DescribePythonPackage(
509 packageName: str,
510 description: str,
511 projectURL: str,
512 sourceCodeURL: str,
513 documentationURL: str,
514 issueTrackerCodeURL: str,
515 keywords: Iterable[str] = None,
516 license: License = DEFAULT_LICENSE,
517 readmeFile: Path = DEFAULT_README,
518 requirementsFile: Path = DEFAULT_REQUIREMENTS,
519 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
520 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
521 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
522 additionalRequirements: dict[str, list[str]] = None,
523 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE,
524 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
525 developmentStatus: str = "stable",
526 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
527 consoleScripts: dict[str, str] = None,
528 dataFiles: dict[str, list[str]] = None,
529 debug: bool = False
530) -> dict[str, Any]:
531 """
532 Helper function to describe a Python package.
534 .. hint::
536 Some information will be gathered automatically from well-known files.
538 Examples: ``README.md``, ``requirements.txt``, ``__init__.py``
540 .. topic:: Handling of namespace packages
542 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then
543 :func:`setuptools.find_namespace_packages` is used to discover package files. |br|
544 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used.
546 In both cases, the following packages (directories) are excluded from search:
548 * ``build``, ``build.*``
549 * ``dist``, ``dist.*``
550 * ``doc``, ``doc.*``
551 * ``tests``, ``tests.*``
553 .. topic:: Handling of minimal Python version
555 The minimal required Python version is selected from parameter ``pythonVersions``.
557 .. topic:: Handling of dunder variables
559 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the
560 resulting AST will be searched for the following dunder variables:
562 * ``__author__``: :class:`str`
563 * ``__copyright__``: :class:`str`
564 * ``__email__``: :class:`str`
565 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`]
566 * ``__license__``: :class:`str`
567 * ``__version__``: :class:`str`
569 The gathered information be used to add further mappings in the result dictionary.
571 .. topic:: Handling of package classifiers
573 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional
574 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly
575 converted to package classifiers, so no need to specify them in parameter ``classifiers``.
577 The following classifiers are implicitly handled:
579 license
580 The license specified by parameter ``license`` is translated into a classifier. |br|
581 See also :meth:`pyTooling.Licensing.License.PythonClassifier`
583 Python versions
584 Always add ``Programming Language :: Python :: 3 :: Only``. |br|
585 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added.
587 Development status
588 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added.
590 .. topic:: Handling of extra requirements
592 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra*
593 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called
594 ``all`` is added, so developers can install all dependencies needed for package development.
596 ``doc``
597 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined.
598 ``test``
599 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined.
600 ``build``
601 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined.
602 User-defined
603 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the
604 dictionary will be added.
605 ``all``
606 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all
607 extra requirements.
609 .. topic:: Handling of keywords
611 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion``
612 will be used. Otherwise, the content of the parameter, if not None or empty.
614 :param packageName: Name of the Python package.
615 :param description: Short description of the package. The long description will be read from README file.
616 :param projectURL: Optional, URL to the Python project.
617 :param sourceCodeURL: URL to the Python source code.
618 :param documentationURL: URL to the package's documentation.
619 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system).
620 :param keywords: Optional, a list of keywords.
621 :param license: Optional, the package's license. (Default: ``Apache License, 2.0``, see
622 :const:`DEFAULT_LICENSE`)
623 :param readmeFile: Optional, the path to the README file. (Default: ``README.md``, see
624 :const:`DEFAULT_README`)
625 :param requirementsFile: Optional, the path to the project's requirements file. (Default:
626 ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
627 :param documentationRequirementsFile: Optional, the path to the project's requirements file for documentation.
628 (Default: ``doc/requirements.txt``, see
629 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
630 :param unittestRequirementsFile: Optional, the path to the project's requirements file for unit tests. (Default:
631 ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
632 :param packagingRequirementsFile: Optional, the path to the project's requirements file for packaging. (Default:
633 ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
634 :param additionalRequirements: Optional, a dictionary of a lists with additional requirements. (default: None)
635 :param sourceFileWithVersion: Optional, the path to the project's source file containing dunder variables like
636 ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
637 :param classifiers: Optional, a list of package classifiers. (Default: 3 classifiers, see
638 :const:`DEFAULT_CLASSIFIERS`)
639 :param developmentStatus: Optional, development status of the package. (Default: stable, see
640 :const:`STATUS` for supported status values)
641 :param pythonVersions: Optional, a list of supported Python 3 version. (Default: all currently
642 maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
643 :param consoleScripts: Optional, a dictionary mapping command line names to entry points. (Default:
644 None)
645 :param dataFiles: Optional, a dictionary mapping package names to lists of additional data files.
646 :param debug: Optional, if ``True``, enable extended outputs for debugging.
647 :returns: A dictionary suitable for :func:`setuptools.setup`.
648 :raises MissingDependencyException: If package 'setuptools' is not available.
649 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
650 :raises FileNotFoundError: If README file doesn't exist.
651 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
652 :raises FileNotFoundError: If requirements file doesn't exist.
653 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
654 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
655 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
656 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
657 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
658 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
659 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
660 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
661 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
662 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
663 :raises Exception: If the package's directory doesn't exist, or if a requirements file is
664 malformed.
665 """
666 try:
667 from setuptools import find_packages, find_namespace_packages
668 except ImportError as ex:
669 raise MissingDependencyException(dependency="setuptools", extra="packaging") from ex
671 print(f"[pyTooling.Packaging] Python: {version_info.major}.{version_info.minor}.{version_info.micro}, pyTooling: {__version__}")
673 # Read README for upload to PyPI
674 if not isinstance(readmeFile, Path): 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true
675 ex = TypeError(f"Parameter 'readmeFile' is not of type 'Path'.")
676 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
677 raise ex
678 elif not readmeFile.exists(): 678 ↛ 679line 678 didn't jump to line 679 because the condition on line 678 was never true
679 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.")
680 else:
681 readme = loadReadmeFile(readmeFile)
683 # Read requirements file and add them to package dependency list (remove duplicates)
684 if not isinstance(requirementsFile, Path): 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true
685 ex = TypeError(f"Parameter 'requirementsFile' is not of type 'Path'.")
686 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
687 raise ex
688 elif not requirementsFile.exists(): 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true
689 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.")
690 else:
691 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug)))
693 extraRequirements: dict[str, list[str]] = {}
694 if documentationRequirementsFile is not None: 694 ↛ 707line 694 didn't jump to line 707 because the condition on line 694 was always true
695 if not isinstance(documentationRequirementsFile, Path): 695 ↛ 696line 695 didn't jump to line 696 because the condition on line 695 was never true
696 ex = TypeError(f"Parameter 'documentationRequirementsFile' is not of type 'Path'.")
697 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.")
698 raise ex
699 elif not documentationRequirementsFile.exists(): 699 ↛ 700line 699 didn't jump to line 700 because the condition on line 699 was never true
700 if debug:
701 print(f"[pyTooling.Packaging] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
702 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
703 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
704 else:
705 extraRequirements["doc"] = list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug)))
707 if unittestRequirementsFile is not None: 707 ↛ 720line 707 didn't jump to line 720 because the condition on line 707 was always true
708 if not isinstance(unittestRequirementsFile, Path): 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true
709 ex = TypeError(f"Parameter 'unittestRequirementsFile' is not of type 'Path'.")
710 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.")
711 raise ex
712 elif not unittestRequirementsFile.exists(): 712 ↛ 713line 712 didn't jump to line 713 because the condition on line 712 was never true
713 if debug:
714 print(f"[pyTooling.Packaging] Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
715 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
716 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
717 else:
718 extraRequirements["test"] = list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug)))
720 if packagingRequirementsFile is not None: 720 ↛ 733line 720 didn't jump to line 733 because the condition on line 720 was always true
721 if not isinstance(packagingRequirementsFile, Path): 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true
722 ex = TypeError(f"Parameter 'packagingRequirementsFile' is not of type 'Path'.")
723 ex.add_note(f"Got type '{getFullyQualifiedName(packagingRequirementsFile)}'.")
724 raise ex
725 elif not packagingRequirementsFile.exists():
726 if debug: 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true
727 print(f"[pyTooling.Packaging] Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
728 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
729 # raise FileNotFoundError(f"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
730 else:
731 extraRequirements["build"] = list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug)))
733 if additionalRequirements is not None:
734 for key, value in additionalRequirements.items():
735 extraRequirements[key] = value
737 if len(extraRequirements) > 0: 737 ↛ 741line 737 didn't jump to line 741 because the condition on line 737 was always true
738 extraRequirements["all"] = list(set([dep for deps in extraRequirements.values() for dep in deps]))
740 # Read __author__, __email__, __version__ from source file
741 if not isinstance(sourceFileWithVersion, Path): 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true
742 ex = TypeError(f"Parameter 'sourceFileWithVersion' is not of type 'Path'.")
743 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.")
744 raise ex
745 elif not sourceFileWithVersion.exists(): 745 ↛ 746line 745 didn't jump to line 746 because the condition on line 745 was never true
746 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.")
747 else:
748 versionInformation = extractVersionInformation(sourceFileWithVersion)
750 # Scan for packages and source files
751 if debug: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true
752 print(f"[pyTooling.Packaging] Exclude list for find_(namespace_)packages:")
753 exclude = []
754 rootNamespace = firstElement(packageName.split("."))
755 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):
756 exclude.append(f"{dirName}")
757 exclude.append(f"{dirName}.*")
758 if debug: 758 ↛ 759line 758 didn't jump to line 759 because the condition on line 758 was never true
759 print(f"[pyTooling.Packaging] - {dirName}, {dirName}.*")
761 if "." in packageName:
762 exclude.append(rootNamespace)
763 packages = find_namespace_packages(exclude=exclude)
764 if packageName.endswith(".*"): 764 ↛ 765line 764 didn't jump to line 765 because the condition on line 764 was never true
765 packageName = packageName[:-2]
766 else:
767 packages = find_packages(exclude=exclude)
769 if debug: 769 ↛ 770line 769 didn't jump to line 770 because the condition on line 769 was never true
770 print(f"[pyTooling.Packaging] Found packages: ({getFullyQualifiedName(packages)})")
771 for package in packages:
772 print(f"[pyTooling.Packaging] - {package}")
774 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0:
775 keywords = versionInformation.Keywords
777 # Assemble classifiers
778 classifiers = list(classifiers)
780 # Translate license to classifier
781 if not isinstance(license, License): 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true
782 ex = TypeError(f"Parameter 'license' is not of type 'License'.")
783 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
784 raise ex
785 classifiers.append(license.PythonClassifier)
787 def _naturalSorting(array: Iterable[str]) -> list[str]:
788 """
789 A simple natural sorting implementation.
791 :param array: The strings to sort.
792 :returns: The strings, sorted with embedded numbers compared numerically.
793 """
794 # See http://nedbatchelder.com/blog/200712/human_sorting.html
795 def _toInt(text: str) -> Union[str, int]:
796 """
797 Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string.
799 :param text: The text to convert.
800 :returns: The converted integer, or the unchanged string.
801 """
802 return int(text) if text.isdigit() else text
804 def _createKey(text: str) -> tuple[Union[str, float], ...]:
805 """
806 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by
807 their value.
809 :param text: The text to split.
810 :returns: Tuple of string and integer fields, usable as a sort key.
811 """
812 return tuple(_toInt(part) for part in re_split(r"(\d+)", text))
814 sortedArray = list(array)
815 sortedArray.sort(key=_createKey)
816 return sortedArray
818 pythonVersions = _naturalSorting(pythonVersions)
820 # Translate Python versions to classifiers
821 classifiers.append("Programming Language :: Python :: 3 :: Only")
822 for v in pythonVersions:
823 classifiers.append(f"Programming Language :: Python :: {v}")
825 # Translate status to classifier
826 try:
827 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}")
828 except KeyError: # pragma: no cover
829 raise ValueError(f"Unsupported development status '{developmentStatus}'.")
831 # Assemble all package information
832 parameters = {
833 "name": packageName,
834 "version": versionInformation.Version,
835 "author": versionInformation.Author,
836 "author_email": versionInformation.Email,
837 "license": license.SPDXIdentifier,
838 "description": description,
839 "long_description": readme.Content,
840 "long_description_content_type": readme.MimeType,
841 "url": projectURL,
842 "project_urls": {
843 'Documentation': documentationURL,
844 'Source Code': sourceCodeURL,
845 'Issue Tracker': issueTrackerCodeURL
846 },
847 "packages": packages,
848 "classifiers": classifiers,
849 "keywords": keywords,
850 "python_requires": f">={pythonVersions[0]}",
851 "install_requires": requirements,
852 }
854 if len(extraRequirements) > 0: 854 ↛ 857line 854 didn't jump to line 857 because the condition on line 854 was always true
855 parameters["extras_require"] = extraRequirements
857 if consoleScripts is not None: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true
858 scripts = []
859 for scriptName, entryPoint in consoleScripts.items():
860 scripts.append(f"{scriptName} = {entryPoint}")
862 parameters["entry_points"] = {
863 "console_scripts": scripts
864 }
866 if dataFiles: 866 ↛ 867line 866 didn't jump to line 867 because the condition on line 866 was never true
867 parameters["package_data"] = dataFiles
869 return parameters
872@export
873def DescribePythonPackageHostedOnGitHub(
874 packageName: str,
875 description: str,
876 gitHubNamespace: str,
877 gitHubRepository: str = None,
878 projectURL: str = None,
879 keywords: Iterable[str] = None,
880 license: License = DEFAULT_LICENSE,
881 readmeFile: Path = DEFAULT_README,
882 requirementsFile: Path = DEFAULT_REQUIREMENTS,
883 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
884 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
885 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
886 additionalRequirements: dict[str, list[str]] = None,
887 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE,
888 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
889 developmentStatus: str = "stable",
890 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
891 consoleScripts: dict[str, str] = None,
892 dataFiles: dict[str, list[str]] = None,
893 debug: bool = False
894) -> dict[str, Any]:
895 """
896 Helper function to describe a Python package when the source code is hosted on GitHub.
898 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub
899 namespace and repository name: issue tracker URL, source code URL, ...
901 :param packageName: Name of the Python package.
902 :param description: Short description of the package. The long description will be read from README file.
903 :param gitHubNamespace: Name of the GitHub namespace (organization or user).
904 :param gitHubRepository: Optional, name of the GitHub repository.
905 :param projectURL: Optional, URL to the Python project.
906 :param keywords: Optional, a list of keywords.
907 :param license: Optional, the package's license. (Default: ``Apache License, 2.0``, see
908 :const:`DEFAULT_LICENSE`)
909 :param readmeFile: Optional, the path to the README file. (Default: ``README.md``, see
910 :const:`DEFAULT_README`)
911 :param requirementsFile: Optional, the path to the project's requirements file. (Default:
912 ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
913 :param documentationRequirementsFile: Optional, the path to the project's requirements file for documentation.
914 (Default: ``doc/requirements.txt``, see
915 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
916 :param unittestRequirementsFile: Optional, the path to the project's requirements file for unit tests. (Default:
917 ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
918 :param packagingRequirementsFile: Optional, the path to the project's requirements file for packaging. (Default:
919 ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
920 :param additionalRequirements: Optional, a dictionary of a lists with additional requirements. (default: None)
921 :param sourceFileWithVersion: Optional, the path to the project's source file containing dunder variables like
922 ``__version__``. (Default: ``__init__.py``, see :const:`DEFAULT_VERSION_FILE`)
923 :param classifiers: Optional, a list of package classifiers. (Default: 3 classifiers, see
924 :const:`DEFAULT_CLASSIFIERS`)
925 :param developmentStatus: Optional, development status of the package. (Default: stable, see
926 :const:`STATUS` for supported status values)
927 :param pythonVersions: Optional, a list of supported Python 3 version. (Default: all currently
928 maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
929 :param consoleScripts: Optional, a dictionary mapping command line names to entry points. (Default:
930 None)
931 :param dataFiles: Optional, a dictionary mapping package names to lists of additional data files.
932 :param debug: Optional, if ``True``, enable extended outputs for debugging.
933 :returns: A dictionary suitable for :func:`setuptools.setup`.
934 :raises MissingDependencyException: If package 'setuptools' is not available.
935 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
936 :raises FileNotFoundError: If README file doesn't exist.
937 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
938 :raises FileNotFoundError: If requirements file doesn't exist.
939 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type :class:`~pathlib.Path`.
940 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
941 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
942 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
943 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
944 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
945 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
946 :raises ValueError: If the content type of the README file is not supported. (See :func:`loadReadmeFile`)
947 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
948 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
949 """
950 if gitHubRepository is None: 950 ↛ 952line 950 didn't jump to line 952 because the condition on line 950 was never true
951 # Assign GitHub repository name without '.*', if derived from Python package name.
952 if packageName.endswith(".*"):
953 gitHubRepository = packageName[:-2]
954 else:
955 gitHubRepository = packageName
957 # Derive URLs
958 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}"
959 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}"
960 issueTrackerCodeURL = f"{sourceCodeURL}/issues"
962 projectURL = projectURL if projectURL is not None else sourceCodeURL
964 return DescribePythonPackage(
965 packageName=packageName,
966 description=description,
967 keywords=keywords,
968 projectURL=projectURL,
969 sourceCodeURL=sourceCodeURL,
970 documentationURL=documentationURL,
971 issueTrackerCodeURL=issueTrackerCodeURL,
972 license=license,
973 readmeFile=readmeFile,
974 requirementsFile=requirementsFile,
975 documentationRequirementsFile=documentationRequirementsFile,
976 unittestRequirementsFile=unittestRequirementsFile,
977 packagingRequirementsFile=packagingRequirementsFile,
978 additionalRequirements=additionalRequirements,
979 sourceFileWithVersion=sourceFileWithVersion,
980 classifiers=classifiers,
981 developmentStatus=developmentStatus,
982 pythonVersions=pythonVersions,
983 consoleScripts=consoleScripts,
984 dataFiles=dataFiles,
985 debug=debug,
986 )