Coverage for pyTooling/Packaging/__init__.py: 82%
357 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +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, get_docstring as ast_get_docstring, iter_child_nodes, Assign, \
48 Constant, Name, List as ast_List
49from collections.abc import Sized
50from os import scandir as os_scandir
51from pathlib import Path
52from re import compile as re_compile, split as re_split, sub as re_sub
53from sys import version_info
54from typing import Iterable, Sequence, Any, Optional as Nullable, Union
55from pyTooling.Decorators import export, readonly
56from pyTooling.Documentation import splitDocString
57from pyTooling.Exceptions import ToolingException, MissingDependencyError
58from pyTooling.MetaClasses import ExtendedType
59from pyTooling.Common import __version__, getFullyQualifiedName, firstElement
60from pyTooling.Licensing import License, Apache_2_0_License
61from pyTooling.Warning import CriticalWarning, WarningCollector
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 PackagingError(ToolingException):
73 """Base-exception of all exceptions raised by :mod:`pyTooling.Packaging`."""
76@export
77class DirectReferenceWarning(CriticalWarning):
78 """
79 A requirement of an extra names its distribution by a URL, a direct reference (``name @ URL``).
81 The Python Package Index rejects a package whose metadata contains a direct reference, therefore
82 :func:`DescribePythonPackage` leaves such a requirement out of the extra.
84 .. important::
86 As a critical warning, it must be collected by a :class:`~pyTooling.Warning.WarningCollector` up the
87 call-hierarchy, otherwise :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised.
88 """
90 _requirement: Nullable[str] #: The requirement left out, as stated.
91 _extra: Nullable[str] #: The extra the requirement was left out of.
92 _filePath: Nullable[Path] #: The requirements file stating it; ``None`` for parameter 'additionalRequirements'.
94 def __init__(
95 self,
96 message: str,
97 /, *,
98 requirement: Nullable[str] = None,
99 extra: Nullable[str] = None,
100 filePath: Nullable[Path] = None
101 ) -> None:
102 """
103 Initializes the warning with the requirement left out, its extra and where it was stated.
105 :param message: The warning's message.
106 :param requirement: Optional, the requirement left out, as stated.
107 :param extra: Optional, the extra the requirement was left out of.
108 :param filePath: Optional, the requirements file stating it; ``None`` for parameter 'additionalRequirements'.
109 """
110 super().__init__(message)
111 self._requirement = requirement
112 self._extra = extra
113 self._filePath = filePath
115 @readonly
116 def Requirement(self) -> Nullable[str]:
117 """
118 Read-only property to access the requirement left out, as stated (:attr:`_requirement`).
120 :returns: The requirement, or ``None`` if it wasn't recorded.
121 """
122 return self._requirement
124 @readonly
125 def Extra(self) -> Nullable[str]:
126 """
127 Read-only property to access the extra the requirement was left out of (:attr:`_extra`).
129 :returns: The extra's name, or ``None`` if it wasn't recorded.
130 """
131 return self._extra
133 @readonly
134 def FilePath(self) -> Nullable[Path]:
135 """
136 Read-only property to access the requirements file stating the requirement (:attr:`_filePath`).
138 :returns: The file's path, or ``None`` for a requirement from parameter 'additionalRequirements' or if it wasn't
139 recorded.
140 """
141 return self._filePath
144@export
145class Readme:
146 """Encapsulates the READMEs file content and MIME type."""
148 _content: str #: Content of the README file
149 _mimeType: str #: MIME type of the README content
151 def __init__(self, content: str, mimeType: str) -> None:
152 """
153 Initializes a README file wrapper.
155 :param content: Raw content of the README file.
156 :param mimeType: MIME type of the README file.
157 """
158 self._content = content
159 self._mimeType = mimeType
161 @readonly
162 def Content(self) -> str:
163 """
164 Read-only property to access the README's content.
166 :returns: Raw content of the README file.
167 """
168 return self._content
170 @readonly
171 def MimeType(self) -> str:
172 """
173 Read-only property to access the README's MIME type.
175 :returns: The MIME type of the README file.
176 """
177 return self._mimeType
180@export
181def loadReadmeFile(readmeFile: Path) -> Readme:
182 """
183 Read the README file, so it can be used as long description for the package.
185 Supported formats:
187 * Plain text (``*.txt``)
188 * Markdown (``*.md``)
189 * ReStructured Text (``*.rst``)
191 :param readmeFile: Path to the `README` file as an instance of :class:`Path`.
192 :returns: A tuple containing the file content and the MIME type.
193 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
194 :raises ValueError: If README file has an unsupported format.
195 :raises FileNotFoundError: If README file does not exist.
196 """
197 if not isinstance(readmeFile, Path): 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 ex = TypeError("Parameter 'readmeFile' is not of type 'Path'.")
199 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
200 raise ex
202 if readmeFile.suffix == ".txt":
203 mimeType = "text/plain"
204 elif readmeFile.suffix == ".md":
205 mimeType = "text/markdown"
206 elif readmeFile.suffix == ".rst":
207 mimeType = "text/x-rst"
208 else: # pragma: no cover
209 raise ValueError("Unsupported README format.")
211 try:
212 with readmeFile.open("r", encoding="utf-8") as file:
213 return Readme(
214 content=file.read(),
215 mimeType=mimeType
216 )
217 except FileNotFoundError as ex:
218 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.") from ex
221@export
222def loadRequirementsFile(requirementsFile: Path, indent: int = 0, debug: bool = False) -> list[str]:
223 """
224 Reads a `requirements.txt` file (recursively) and extracts all specified dependencies into an array.
226 Special dependency entries like Git repository references are translates to match the syntax expected by setuptools.
228 .. hint::
230 Duplicates should be removed by converting the result to a :class:`set` and back to a :class:`list`.
232 .. code-block:: Python
234 requirements = list(set(loadRequirementsFile(requirementsFile)))
236 :param requirementsFile: Path to the ``requirements.txt`` file as an instance of :class:`Path`.
237 :param indent: Optional, indentation level used for the debug output of nested requirements files.
238 :param debug: Optional, if ``True``, print found dependencies and recursion.
239 :returns: A list of dependencies.
240 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
241 :raises FileNotFoundError: If requirements file does not exist.
242 """
243 if not isinstance(requirementsFile, Path): 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 ex = TypeError(f"Parameter '{requirementsFile}' is not of type 'Path'.")
245 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
246 raise ex
248 def _loadRequirementsFile(requirementsFile: Path, indent: int) -> list[str]:
249 """
250 Recursive variant of :func:`loadRequirementsFile`.
252 :param requirementsFile: Path to the requirements file to read.
253 :param indent: Indentation level used for the debug output of nested requirements files.
254 :returns: List of requirements read from that file and every file it includes.
255 :raises FileNotFoundError: If the requirements file doesn't exist.
256 """
257 requirements = []
258 try:
259 with requirementsFile.open("r", encoding="utf-8") as file:
260 if debug:
261 print(f"[pyTooling.Packaging]{' ' * indent} Extracting requirements from '{requirementsFile}'.")
263 for line in file.readlines():
264 # a comment starts a line or follows whitespace; a '#' inside a word is a URL's fragment ('URL#NAME')
265 line = re_sub(r"(^|\s)#.*$", "", line).strip()
266 if line == "":
267 continue
268 elif line.startswith("-r"):
269 # Remove the first word/argument (-r)
270 filename = line[2:].lstrip()
271 requirements += _loadRequirementsFile(requirementsFile.parent / filename, indent + 1)
272 elif line.startswith("https"):
273 if debug:
274 print(f"[pyTooling.Packaging]{' ' * indent} Found URL '{line}'.")
276 # Convert 'URL#NAME' to 'NAME @ URL'
277 splitItems = line.split("#")
278 requirements.append(f"{splitItems[1]} @ {splitItems[0]}")
279 else:
280 if debug:
281 print(f"[pyTooling.Packaging]{' ' * indent} - {line}")
283 requirements.append(line)
284 except FileNotFoundError as ex:
285 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.") from ex
287 return requirements
289 return _loadRequirementsFile(requirementsFile, 0)
292@export
293class VersionInformation(metaclass=ExtendedType, slots=True):
294 """Encapsulates version information extracted from a Python source file."""
296 _author: str #: Author name(s).
297 _copyright: str #: Copyright information.
298 _email: str #: Author's email address.
299 _keywords: list[str] #: Keywords.
300 _license: str #: License name.
301 _description: str #: Description of the package.
302 _version: str #: Version number.
304 def __init__(
305 self,
306 author: str,
307 email: str,
308 copyright: str,
309 license: str,
310 version: str,
311 description: str,
312 keywords: Iterable[str]
313 ) -> None:
314 """
315 Initializes a Python package (version) information instance.
317 :param author: Author of the Python package.
318 :param email: The author's email address
319 :param copyright: The copyright notice of the Package.
320 :param license: The Python package's license.
321 :param version: The Python package's version.
322 :param description: The Python package's short description.
323 :param keywords: The Python package's list of keywords.
324 """
325 self._author = author
326 self._email = email
327 self._copyright = copyright
328 self._license = license
329 self._version = version
330 self._description = description
331 self._keywords = list(keywords)
333 @readonly
334 def Author(self) -> str:
335 """
336 Read-only property to access the name(s) of the package author(s) (:attr:`_author`).
338 :returns: Name(s) of the package author(s).
339 """
340 return self._author
342 @readonly
343 def Copyright(self) -> str:
344 """
345 Read-only property to access the package's copyright information (:attr:`_copyright`).
347 :returns: Copyright information.
348 """
349 return self._copyright
351 @readonly
352 def Description(self) -> str:
353 """
354 Read-only property to access the package description (:attr:`_description`).
356 :returns: Package description text.
357 """
358 return self._description
360 @readonly
361 def Email(self) -> str:
362 """
363 Read-only property to access the author's email address (:attr:`_email`).
365 :returns: Email address of the author.
366 """
367 return self._email
369 @readonly
370 def Keywords(self) -> list[str]:
371 """
372 Read-only property to access the package's keywords (:attr:`_keywords`).
374 :returns: List of keywords.
375 """
376 return self._keywords
378 @readonly
379 def License(self) -> str:
380 """
381 Read-only property to access the package's license (:attr:`_license`).
383 :returns: License name.
384 """
385 return self._license
387 @readonly
388 def Version(self) -> str:
389 """
390 Read-only property to access the package's version number (:attr:`_version`).
392 :returns: Version number.
393 """
394 return self._version
396 def __str__(self) -> str:
397 """
398 Return a string representation of this version information.
400 :returns: The version number.
401 """
402 return f"{self._version}"
405def _extractDescription(docString: Nullable[str]) -> str:
406 """
407 Read a package's short description from the first paragraph of its module doc-string.
409 A package's short description **is** the summary of its module doc-string - the first paragraph - so the two are
410 one text in two places, and :func:`~pyTooling.Documentation.splitDocString` reads it.
412 It is folded into a single line, because a short description is one line of plain text while a doc-string is
413 wrapped to the source file's line length. Strong emphasis around the whole paragraph is removed for the same
414 reason: ``**An abstract VHDL language model.**`` is markup for the rendered documentation, and nothing renders it
415 where a short description is displayed.
417 :param docString: The module's doc-string, or ``None`` if it has none.
418 :returns: The description as a single line, or an empty string if there is no doc-string.
419 :raises DocumentationError: If the first paragraph is too long to be a description.
421 .. seealso::
423 :func:`~pyTooling.Documentation.splitDocString`
424 |rarr| Reads the first paragraph, and rejects one that is too long to be a summary.
425 """
426 description, _ = splitDocString(docString)
427 description = " ".join(description.split())
429 if description.startswith("**") and description.endswith("**") and "**" not in description[2:-2]:
430 description = description[2:-2]
432 return description
435@export
436def extractVersionInformation(sourceFile: Path) -> VersionInformation:
437 """
438 Extract double underscored variables from a Python source file, so these can be used for single-sourcing information.
440 Supported variables:
442 * ``__author__``
443 * ``__copyright__``
444 * ``__email__``
445 * ``__keywords__``
446 * ``__license__``
447 * ``__version__``
449 The package's short description is not a dunder variable - it is the first paragraph of the source file's module
450 doc-string, which is where a package already describes itself.
452 :param sourceFile: Path to a Python source file as an instance of :class:`Path`.
453 :returns: An instance of :class:`VersionInformation` with gathered variable contents.
454 :raises TypeError: If parameter 'sourceFile' is not of type :class:`~pathlib.Path`.
455 :raises PackagingError: If the given file doesn't exist.
456 :raises PackagingError: If the given file can't be parsed as Python source code.
457 :raises PackagingError: If a dunder variable has an unexpected format.
458 :raises AssertionError: If a dunder variable is missing in the given file.
459 """
460 if not isinstance(sourceFile, Path):
461 ex = TypeError("Parameter 'sourceFile' is not of type 'Path'.")
462 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.")
463 raise ex
465 author = None
466 copyright = None
467 email = None
468 keywords = []
469 license = None
470 version = None
472 try:
473 with sourceFile.open("r", encoding="utf-8") as file:
474 content = file.read()
475 except OSError as ex:
476 raise PackagingError(f"Source file '{sourceFile}' couldn't be read.") from ex
478 try:
479 ast = ast_parse(content)
480 except Exception as ex: # pragma: no cover
481 raise PackagingError(f"Source file '{sourceFile}' couldn't be parsed as Python source code.") from ex
483 description = _extractDescription(ast_get_docstring(ast))
485 for item in iter_child_nodes(ast):
486 if isinstance(item, Assign) and len(item.targets) == 1:
487 target = item.targets[0]
488 value = item.value
489 if isinstance(target, Name) and target.id == "__author__":
490 if isinstance(value, Constant) and isinstance(value.value, str): 490 ↛ 493line 490 didn't jump to line 493 because the condition on line 490 was always true
491 author = value.value
493 if isinstance(target, Name) and target.id == "__copyright__":
494 if isinstance(value, Constant) and isinstance(value.value, str): 494 ↛ 497line 494 didn't jump to line 497 because the condition on line 494 was always true
495 copyright = value.value
497 if isinstance(target, Name) and target.id == "__email__":
498 if isinstance(value, Constant) and isinstance(value.value, str): 498 ↛ 501line 498 didn't jump to line 501 because the condition on line 498 was always true
499 email = value.value
501 if isinstance(target, Name) and target.id == "__keywords__":
502 if isinstance(value, Constant) and isinstance(value.value, str):
503 cause = TypeError("Variable '__keywords__' should be a list of strings.")
504 raise PackagingError(f"Couldn't extract '__keywords__' from '{sourceFile}'.") from cause
505 elif isinstance(value, ast_List):
506 for const in value.elts:
507 if isinstance(const, Constant) and isinstance(const.value, str):
508 keywords.append(const.value)
509 else:
510 cause = TypeError("List elements in '__keywords__' should be strings.")
511 raise PackagingError(f"Couldn't extract '__keywords__' from '{sourceFile}'.") from cause
512 else:
513 cause = TypeError(f"Used unsupported type '{getFullyQualifiedName(value)}' for variable '__keywords__'.")
514 raise PackagingError(f"Couldn't extract '__keywords__' from '{sourceFile}'.") from cause
516 if isinstance(target, Name) and target.id == "__license__":
517 if isinstance(value, Constant) and isinstance(value.value, str): 517 ↛ 520line 517 didn't jump to line 520 because the condition on line 517 was always true
518 license = value.value
520 if isinstance(target, Name) and target.id == "__version__":
521 if isinstance(value, Constant) and isinstance(value.value, str): 521 ↛ 485line 521 didn't jump to line 485 because the condition on line 521 was always true
522 version = value.value
524 if author is None:
525 raise AssertionError(f"Could not extract '__author__' from '{sourceFile}'.") # pragma: no cover
527 if copyright is None:
528 raise AssertionError(f"Could not extract '__copyright__' from '{sourceFile}'.") # pragma: no cover
530 if email is None:
531 raise AssertionError(f"Could not extract '__email__' from '{sourceFile}'.") # pragma: no cover
533 if license is None:
534 raise AssertionError(f"Could not extract '__license__' from '{sourceFile}'.") # pragma: no cover
536 if version is None:
537 raise AssertionError(f"Could not extract '__version__' from '{sourceFile}'.") # pragma: no cover
539 return VersionInformation(author, email, copyright, license, version, description, keywords)
542STATUS: dict[str, str] = {
543 "planning": "1 - Planning",
544 "pre-alpha": "2 - Pre-Alpha",
545 "alpha": "3 - Alpha",
546 "beta": "4 - Beta",
547 "stable": "5 - Production/Stable",
548 "mature": "6 - Mature",
549 "inactive": "7 - Inactive"
550}
551"""
552A dictionary of supported development status values.
554The mapping's value will be appended to ``Development Status :: `` to form a package classifier.
5561. Planning
5572. Pre-Alpha
5583. Alpha
5594. Beta
5605. Production/Stable
5616. Mature
5627. Inactive
564.. seealso::
566 `Python package classifiers <https://pypi.org/classifiers/>`__
567"""
569DEFAULT_LICENSE = Apache_2_0_License
570"""
571Default license (Apache License, 2.0) used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
572if parameter ``license`` is not assigned.
573"""
575DEFAULT_PY_VERSIONS = ("3.11", "3.12", "3.13", "3.14", "3.15")
576"""
577A tuple of supported CPython versions used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
578if parameter ``pythonVersions`` is not assigned.
580.. seealso::
582 `Status of Python versions <https://devguide.python.org/versions/>`__
583"""
585DEFAULT_CLASSIFIERS = (
586 "Operating System :: OS Independent",
587 "Intended Audience :: Developers",
588 "Topic :: Utilities"
589 )
590"""
591A list of Python package classifiers used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
592if parameter ``classifiers`` is not assigned.
594.. seealso::
596 `Python package classifiers <https://pypi.org/classifiers/>`__
597"""
599DEFAULT_README = Path("README.md")
600"""
601Path to the README file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
602if parameter ``readmeFile`` is not assigned.
603"""
605DEFAULT_REQUIREMENTS = Path("requirements.txt")
606"""
607Path to the requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
608if parameter ``requirementsFile`` is not assigned.
609"""
611DEFAULT_DOCUMENTATION_REQUIREMENTS = Path("doc/requirements.txt")
612"""
613Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
614if parameter ``documentationRequirementsFile`` is not assigned.
615"""
617DEFAULT_TEST_REQUIREMENTS = Path("tests/requirements.txt")
618"""
619Path to the README requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
620if parameter ``unittestRequirementsFile`` is not assigned.
621"""
623DEFAULT_PACKAGING_REQUIREMENTS = Path("build/requirements.txt")
624"""
625Path to the package requirements file used by :func:`DescribePythonPackage` and :func:`DescribePythonPackageHostedOnGitHub`
626if parameter ``packagingRequirementsFile`` is not assigned.
627"""
629DEFAULT_VERSION_FILE = Path("__init__.py")
631_DIRECT_REFERENCE_PATTERN = re_compile(r"^\s*[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\s*(?:\[[^\]]*\])?\s*@")
634def _removeDirectReferences(requirements: Iterable[str], extra: str, filePath: Nullable[Path]) -> list[str]:
635 """
636 Remove direct references (``name @ URL``) from the requirements of an extra.
638 The Python Package Index rejects a package whose metadata contains a direct reference. Each removed requirement is
639 reported as a :exc:`DirectReferenceWarning` to a :class:`~pyTooling.Warning.WarningCollector`.
641 :param requirements: Requirements of the extra.
642 :param extra: Name of the extra.
643 :param filePath: The requirements file the requirements were read from, or ``None`` for
644 parameter 'additionalRequirements'.
645 :returns: The requirements, which aren't direct references.
646 :raises UnhandledCriticalWarningError: If a requirement is a direct reference and no
647 :class:`~pyTooling.Warning.WarningCollector` collects the warning.
648 """
649 source = "parameter 'additionalRequirements'" if filePath is None else f"'{filePath}'"
651 result = []
652 for requirement in requirements:
653 if _DIRECT_REFERENCE_PATTERN.match(requirement) is None:
654 result.append(requirement)
655 continue
657 WarningCollector.Raise(DirectReferenceWarning(
658 f"Requirement '{requirement}' (extra '{extra}', from {source}) is a direct reference, which PyPI rejects; "
659 f"it's left out of the extra.",
660 requirement=requirement,
661 extra=extra,
662 filePath=filePath
663 ))
665 return result
668def _collectEntryPoints(
669 consoleScripts: Nullable[dict[str, str]],
670 guiScripts: Nullable[dict[str, str]],
671 pytestPlugins: Nullable[dict[str, str]]
672) -> Nullable[dict[str, list[str]]]:
673 """
674 Collect what a package advertises into setuptools' entry point groups.
676 Each parameter names one kind of thing a package can offer, so a caller says *what* it provides and this
677 function knows *how* it is declared - which group it belongs to, and setuptools' ``"name = entry.point"``
678 spelling.
680 :param consoleScripts: Command line programs, declared as ``console_scripts``.
681 :param guiScripts: Windowed programs, declared as ``gui_scripts``.
682 :param pytestPlugins: Pytest plugins, declared as ``pytest11``.
683 :returns: A dictionary mapping a group to its entry point lines, or ``None`` if a package
684 advertises nothing.
685 """
686 groups = {
687 "console_scripts": consoleScripts,
688 "gui_scripts": guiScripts,
689 "pytest11": pytestPlugins,
690 }
692 entryPoints = {
693 groupName: [f"{name} = {entryPoint}" for name, entryPoint in group.items()]
694 for groupName, group in groups.items()
695 if group is not None and len(group) > 0
696 }
698 return entryPoints if len(entryPoints) > 0 else None
701@export
702def DescribePythonPackage(
703 packageName: str,
704 projectURL: str,
705 sourceCodeURL: str,
706 documentationURL: str,
707 issueTrackerCodeURL: str,
708 description: Nullable[str] = None,
709 keywords: Nullable[Iterable[str]] = None,
710 license: License = DEFAULT_LICENSE,
711 readmeFile: Path = DEFAULT_README,
712 requirementsFile: Path = DEFAULT_REQUIREMENTS,
713 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
714 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
715 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
716 additionalRequirements: Nullable[dict[str, list[str]]] = None,
717 sourceFileWithVersion: Nullable[Path] = DEFAULT_VERSION_FILE,
718 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
719 developmentStatus: str = "stable",
720 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
721 consoleScripts: Nullable[dict[str, str]] = None,
722 guiScripts: Nullable[dict[str, str]] = None,
723 pytestPlugins: Nullable[dict[str, str]] = None,
724 dataFiles: Nullable[dict[str, list[str]]] = None,
725 debug: bool = False
726) -> dict[str, Any]:
727 """
728 Helper function to describe a Python package.
730 .. hint::
732 Some information will be gathered automatically from well-known files.
734 Examples: ``README.md``, ``requirements.txt``, ``__init__.py``
736 .. topic:: Handling of namespace packages
738 If parameter ``packageName`` contains a dot, a namespace package is assumed. Then
739 :func:`setuptools.find_namespace_packages` is used to discover package files. |br|
740 Otherwise, the package is considered a normal package and :func:`setuptools.find_packages` is used.
742 In both cases, the following packages (directories) are excluded from search:
744 * ``build``, ``build.*``
745 * ``dist``, ``dist.*``
746 * ``doc``, ``doc.*``
747 * ``tests``, ``tests.*``
749 .. topic:: Handling of minimal Python version
751 The minimal required Python version is selected from parameter ``pythonVersions``.
753 .. topic:: Handling of dunder variables
755 A Python source file specified by parameter ``sourceFileWithVersion`` will be analyzed with Pythons parser and the
756 resulting AST will be searched for the following dunder variables:
758 * ``__author__``: :class:`str`
759 * ``__copyright__``: :class:`str`
760 * ``__email__``: :class:`str`
761 * ``__keywords__``: :class:`typing.Iterable`[:class:`str`]
762 * ``__license__``: :class:`str`
763 * ``__version__``: :class:`str`
765 The gathered information be used to add further mappings in the result dictionary.
767 .. topic:: Handling of package classifiers
769 To reduce redundantly provided parameters to this function (e.g. supported ``pythonVersions``), only additional
770 classifiers should be provided via parameter ``classifiers``. The supported Python versions will be implicitly
771 converted to package classifiers, so no need to specify them in parameter ``classifiers``.
773 The following classifiers are implicitly handled:
775 license
776 The license specified by parameter ``license`` is translated into a classifier. |br|
777 See also :meth:`pyTooling.Licensing.License.PythonClassifier`
779 Python versions
780 Always add ``Programming Language :: Python :: 3 :: Only``. |br|
781 For each value in ``pythonVersions``, one ``Programming Language :: Python :: Major.Minor`` is added.
783 Development status
784 The development status specified by parameter ``developmentStatus`` is translated to a classifier and added.
786 .. topic:: Handling of extra requirements
788 If additional requirement files are provided, e.g. requirements to build the documentation, then *extra*
789 requirements are defined. These can be installed via ``pip install packageName[extraName]``. If so, an extra called
790 ``all`` is added, so developers can install all dependencies needed for package development.
792 ``doc``
793 If parameter ``documentationRequirementsFile`` is present, an extra requirements called ``doc`` will be defined.
794 ``test``
795 If parameter ``unittestRequirementsFile`` is present, an extra requirements called ``test`` will be defined.
796 ``build``
797 If parameter ``packagingRequirementsFile`` is present, an extra requirements called ``build`` will be defined.
798 User-defined
799 If parameter ``additionalRequirements`` is present, an extra requirements for every mapping entry in the
800 dictionary will be added.
801 ``all``
802 If any of the above was added, an additional extra requirement called ``all`` will be added, summarizing all
803 extra requirements.
805 A requirement with a direct reference (``name @ URL``, e.g. ``pyTooling.Sphinx @ git+https://...@dev``) is left out
806 of every extra, because the Python Package Index rejects a package whose metadata contains one. Each one is
807 reported as a critical :exc:`DirectReferenceWarning` to a :class:`~pyTooling.Warning.WarningCollector`; without
808 a collector up the call-hierarchy, :exc:`~pyTooling.Warning.UnhandledCriticalWarningError` is raised. So a project
809 can name a dependency, which isn't on PyPI yet, by its repository in e.g. ``doc/requirements.txt`` - the
810 documentation build installs it - and still be released, if its :file:`setup.py` collects the warnings. The base
811 requirements (``requirementsFile``) are kept as they are: without one of them, the package wouldn't work.
813 .. topic:: Handling of keywords
815 If parameter ``keywords`` is not specified, the dunder variable ``__keywords__`` from ``sourceFileWithVersion``
816 will be used. Otherwise, the content of the parameter, if not None or empty.
818 .. topic:: Handling of the description
820 If parameter ``description`` is not specified, the first paragraph of the module doc-string in
821 ``sourceFileWithVersion`` is used, so a package describes itself in one place. An explicitly passed description
822 always wins - including an empty one.
824 :param packageName: Name of the Python package.
825 :param projectURL: URL to the Python project.
826 :param sourceCodeURL: URL to the Python source code.
827 :param documentationURL: URL to the package's documentation.
828 :param issueTrackerCodeURL: URL to the projects issue tracker (ticket system).
829 :param description: Optional, short description of the package. (Default: the first paragraph
830 of the module doc-string in ``sourceFileWithVersion``.) The long description
831 is read from the README file.
832 :param keywords: Optional, a list of keywords.
833 :param license: Optional, the package's license. (Default: ``Apache License, 2.0``, see
834 :const:`DEFAULT_LICENSE`)
835 :param readmeFile: Optional, the path to the README file. (Default: ``README.md``, see
836 :const:`DEFAULT_README`)
837 :param requirementsFile: Optional, the path to the project's requirements file. (Default:
838 ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
839 :param documentationRequirementsFile: Optional, the path to the project's requirements file for documentation.
840 (Default: ``doc/requirements.txt``, see
841 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
842 :param unittestRequirementsFile: Optional, the path to the project's requirements file for unit tests. (Default:
843 ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
844 :param packagingRequirementsFile: Optional, the path to the project's requirements file for packaging. (Default:
845 ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
846 :param additionalRequirements: Optional, a dictionary of a lists with additional requirements. (default: None)
847 :param sourceFileWithVersion: Optional, the path to the project's source file containing dunder variables
848 like ``__version__``. (Default: ``__init__.py``, see
849 :const:`DEFAULT_VERSION_FILE`)
850 :param classifiers: Optional, a list of package classifiers. (Default: 3 classifiers, see
851 :const:`DEFAULT_CLASSIFIERS`)
852 :param developmentStatus: Optional, development status of the package. (Default: stable, see
853 :const:`STATUS` for supported status values)
854 :param pythonVersions: Optional, a list of supported Python 3 version. (Default: all currently
855 maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
856 :param consoleScripts: Optional, a dictionary mapping command line names to entry points, declared
857 as ``console_scripts``. (Default: None)
858 :param guiScripts: Optional, like ``consoleScripts``, but declared as ``gui_scripts`` - on
859 Windows such a program starts without a console window. (Default: None)
860 :param pytestPlugins: Optional, a dictionary mapping plugin names to entry points, declared as
861 ``pytest11``. The classifier ``Framework :: Pytest`` is added with it.
862 (Default: None)
863 :param dataFiles: Optional, a dictionary mapping package names to lists of additional data files.
864 :param debug: Optional, if ``True``, enable extended outputs for debugging.
865 :returns: A dictionary suitable for :func:`setuptools.setup`.
866 :raises MissingDependencyError: If package 'setuptools' is not available.
867 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
868 :raises FileNotFoundError: If README file doesn't exist.
869 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
870 :raises FileNotFoundError: If requirements file doesn't exist.
871 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type
872 :class:`~pathlib.Path`.
873 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
874 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
875 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
876 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
877 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
878 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
879 :raises ValueError: If the content type of the README file is not supported. (See
880 :func:`loadReadmeFile`)
881 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
882 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
883 :raises Exception: If the package's directory doesn't exist, or if a requirements file is
884 malformed.
885 :raises PackagingError: If no description was given and the package file has no module doc-string.
886 :raises UnhandledCriticalWarningError: If an extra's requirement is a direct reference and no
887 :class:`~pyTooling.Warning.WarningCollector` collects the
888 :exc:`DirectReferenceWarning`.
889 """
890 try:
891 from setuptools import find_packages, find_namespace_packages
892 except ImportError as ex:
893 raise MissingDependencyError(dependency="setuptools", extra="packaging") from ex
895 print(f"[pyTooling.Packaging] Python: {version_info.major}.{version_info.minor}.{version_info.micro}, pyTooling: {__version__}")
897 # Read README for upload to PyPI
898 if not isinstance(readmeFile, Path): 898 ↛ 899line 898 didn't jump to line 899 because the condition on line 898 was never true
899 ex = TypeError("Parameter 'readmeFile' is not of type 'Path'.")
900 ex.add_note(f"Got type '{getFullyQualifiedName(readmeFile)}'.")
901 raise ex
902 elif not readmeFile.exists(): 902 ↛ 903line 902 didn't jump to line 903 because the condition on line 902 was never true
903 raise FileNotFoundError(f"README file '{readmeFile}' not found in '{Path.cwd()}'.")
904 else:
905 readme = loadReadmeFile(readmeFile)
907 # Read requirements file and add them to package dependency list (remove duplicates)
908 if not isinstance(requirementsFile, Path): 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true
909 ex = TypeError("Parameter 'requirementsFile' is not of type 'Path'.")
910 ex.add_note(f"Got type '{getFullyQualifiedName(requirementsFile)}'.")
911 raise ex
912 elif not requirementsFile.exists(): 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 raise FileNotFoundError(f"Requirements file '{requirementsFile}' not found in '{Path.cwd()}'.")
914 else:
915 requirements = list(set(loadRequirementsFile(requirementsFile, debug=debug)))
917 extraRequirements: dict[str, list[str]] = {}
918 if documentationRequirementsFile is not None: 918 ↛ 935line 918 didn't jump to line 935 because the condition on line 918 was always true
919 if not isinstance(documentationRequirementsFile, Path): 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true
920 ex = TypeError("Parameter 'documentationRequirementsFile' is not of type 'Path'.")
921 ex.add_note(f"Got type '{getFullyQualifiedName(documentationRequirementsFile)}'.")
922 raise ex
923 elif not documentationRequirementsFile.exists(): 923 ↛ 924line 923 didn't jump to line 924 because the condition on line 923 was never true
924 if debug:
925 print(f"[pyTooling.Packaging] Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
926 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
927 # raise FileNotFoundError(f"Documentation requirements file '{documentationRequirementsFile}' not found in '{Path.cwd()}'.")
928 else:
929 extraRequirements["doc"] = _removeDirectReferences(
930 list(set(loadRequirementsFile(documentationRequirementsFile, debug=debug))),
931 "doc",
932 documentationRequirementsFile
933 )
935 if unittestRequirementsFile is not None: 935 ↛ 952line 935 didn't jump to line 952 because the condition on line 935 was always true
936 if not isinstance(unittestRequirementsFile, Path): 936 ↛ 937line 936 didn't jump to line 937 because the condition on line 936 was never true
937 ex = TypeError("Parameter 'unittestRequirementsFile' is not of type 'Path'.")
938 ex.add_note(f"Got type '{getFullyQualifiedName(unittestRequirementsFile)}'.")
939 raise ex
940 elif not unittestRequirementsFile.exists(): 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true
941 if debug:
942 print(f"[pyTooling.Packaging] Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
943 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
944 # raise FileNotFoundError(f"Unit testing requirements file '{unittestRequirementsFile}' not found in '{Path.cwd()}'.")
945 else:
946 extraRequirements["test"] = _removeDirectReferences(
947 list(set(loadRequirementsFile(unittestRequirementsFile, debug=debug))),
948 "test",
949 unittestRequirementsFile
950 )
952 if packagingRequirementsFile is not None:
953 if not isinstance(packagingRequirementsFile, Path): 953 ↛ 954line 953 didn't jump to line 954 because the condition on line 953 was never true
954 ex = TypeError("Parameter 'packagingRequirementsFile' is not of type 'Path'.")
955 ex.add_note(f"Got type '{getFullyQualifiedName(packagingRequirementsFile)}'.")
956 raise ex
957 elif not packagingRequirementsFile.exists():
958 if debug: 958 ↛ 959line 958 didn't jump to line 959 because the condition on line 958 was never true
959 print(f"[pyTooling.Packaging] Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
960 print( "[pyTooling.Packaging] No section added to 'extraRequirements'.")
961 # raise FileNotFoundError(f"Packaging requirements file '{packagingRequirementsFile}' not found in '{Path.cwd()}'.")
962 else:
963 extraRequirements["build"] = _removeDirectReferences(
964 list(set(loadRequirementsFile(packagingRequirementsFile, debug=debug))),
965 "build",
966 packagingRequirementsFile
967 )
969 if additionalRequirements is not None:
970 for key, value in additionalRequirements.items():
971 extraRequirements[key] = _removeDirectReferences(value, key, None)
973 if len(extraRequirements) > 0: 973 ↛ 977line 973 didn't jump to line 977 because the condition on line 973 was always true
974 extraRequirements["all"] = list(set([dep for deps in extraRequirements.values() for dep in deps]))
976 # Read __author__, __email__, __version__ from source file
977 if not isinstance(sourceFileWithVersion, Path): 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 ex = TypeError("Parameter 'sourceFileWithVersion' is not of type 'Path'.")
979 ex.add_note(f"Got type '{getFullyQualifiedName(sourceFileWithVersion)}'.")
980 raise ex
981 elif not sourceFileWithVersion.exists(): 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true
982 raise FileNotFoundError(f"Package file '{sourceFileWithVersion}' with dunder variables not found in '{Path.cwd()}'.")
983 else:
984 versionInformation = extractVersionInformation(sourceFileWithVersion)
986 # Scan for packages and source files
987 if debug: 987 ↛ 988line 987 didn't jump to line 988 because the condition on line 987 was never true
988 print("[pyTooling.Packaging] Exclude list for find_(namespace_)packages:")
989 exclude = []
990 rootNamespace = firstElement(packageName.split("."))
991 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):
992 exclude.append(f"{dirName}")
993 exclude.append(f"{dirName}.*")
994 if debug: 994 ↛ 995line 994 didn't jump to line 995 because the condition on line 994 was never true
995 print(f"[pyTooling.Packaging] - {dirName}, {dirName}.*")
997 if "." in packageName:
998 exclude.append(rootNamespace)
999 packages = find_namespace_packages(exclude=exclude)
1000 if packageName.endswith(".*"): 1000 ↛ 1001line 1000 didn't jump to line 1001 because the condition on line 1000 was never true
1001 packageName = packageName[:-2]
1002 else:
1003 packages = find_packages(exclude=exclude)
1005 if debug: 1005 ↛ 1006line 1005 didn't jump to line 1006 because the condition on line 1005 was never true
1006 print(f"[pyTooling.Packaging] Found packages: ({getFullyQualifiedName(packages)})")
1007 for package in packages:
1008 print(f"[pyTooling.Packaging] - {package}")
1010 if keywords is None or isinstance(keywords, Sized) and len(keywords) == 0:
1011 keywords = versionInformation.Keywords
1013 if description is None:
1014 description = versionInformation.Description
1016 if len(description) == 0:
1017 ex = PackagingError(f"Package '{packageName}' has no description.")
1018 ex.add_note(f"Neither was parameter 'description' given, nor has '{sourceFileWithVersion}' a module doc-string.")
1019 ex.add_note("Describe the package in the first paragraph of that doc-string, or pass parameter 'description'.")
1020 raise ex
1022 # Assemble classifiers
1023 classifiers = list(classifiers)
1025 # Check the license; it reaches setuptools as an SPDX expression, not as a classifier
1026 if not isinstance(license, License):
1027 ex = TypeError("Parameter 'license' is not of type 'License'.")
1028 ex.add_note(f"Got type '{getFullyQualifiedName(license)}'.")
1029 raise ex
1031 if pytestPlugins is not None and "Framework :: Pytest" not in classifiers:
1032 classifiers.append("Framework :: Pytest")
1034 if (deprecated := [classifier for classifier in classifiers if classifier.startswith("License ::")]) != []:
1035 names = "', '".join(deprecated)
1036 print(f"[pyTooling.Packaging] License classifiers are deprecated: '{names}'.")
1037 print( "[pyTooling.Packaging] Remove them; the 'license' parameter becomes the SPDX expression setuptools wants.")
1039 def _naturalSorting(array: Iterable[str]) -> list[str]:
1040 """
1041 A simple natural sorting implementation.
1043 :param array: The strings to sort.
1044 :returns: The strings, sorted with embedded numbers compared numerically.
1045 """
1046 # See http://nedbatchelder.com/blog/200712/human_sorting.html
1047 def _toInt(text: str) -> Union[str, int]:
1048 """
1049 Try to convert a :class:`str` to :class:`int` if possible, otherwise preserve the string.
1051 :param text: The text to convert.
1052 :returns: The converted integer, or the unchanged string.
1053 """
1054 return int(text) if text.isdigit() else text
1056 def _createKey(text: str) -> tuple[Union[str, float], ...]:
1057 """
1058 Split the text into a tuple of multiple :class:`str` and :class:`int` fields, so embedded numbers can be sorted by
1059 their value.
1061 :param text: The text to split.
1062 :returns: Tuple of string and integer fields, usable as a sort key.
1063 """
1064 return tuple(_toInt(part) for part in re_split(r"(\d+)", text))
1066 sortedArray = list(array)
1067 sortedArray.sort(key=_createKey)
1068 return sortedArray
1070 pythonVersions = _naturalSorting(pythonVersions)
1072 # Translate Python versions to classifiers
1073 classifiers.append("Programming Language :: Python :: 3 :: Only")
1074 for v in pythonVersions:
1075 classifiers.append(f"Programming Language :: Python :: {v}")
1077 # Translate status to classifier
1078 try:
1079 classifiers.append(f"Development Status :: {STATUS[developmentStatus.lower()]}")
1080 except KeyError: # pragma: no cover
1081 raise ValueError(f"Unsupported development status '{developmentStatus}'.")
1083 # Assemble all package information
1084 parameters = {
1085 "name": packageName,
1086 "version": versionInformation.Version,
1087 "author": versionInformation.Author,
1088 "author_email": versionInformation.Email,
1089 "license": license.SPDXIdentifier,
1090 "description": description,
1091 "long_description": readme.Content,
1092 "long_description_content_type": readme.MimeType,
1093 "url": projectURL,
1094 "project_urls": {
1095 'Documentation': documentationURL,
1096 'Source Code': sourceCodeURL,
1097 'Issue Tracker': issueTrackerCodeURL
1098 },
1099 "packages": packages,
1100 "classifiers": classifiers,
1101 "keywords": keywords,
1102 "python_requires": f">={pythonVersions[0]}",
1103 "install_requires": requirements,
1104 }
1106 if len(extraRequirements) > 0: 1106 ↛ 1109line 1106 didn't jump to line 1109 because the condition on line 1106 was always true
1107 parameters["extras_require"] = extraRequirements
1109 if (groups := _collectEntryPoints(consoleScripts, guiScripts, pytestPlugins)) is not None:
1110 parameters["entry_points"] = groups
1112 if dataFiles: 1112 ↛ 1113line 1112 didn't jump to line 1113 because the condition on line 1112 was never true
1113 parameters["package_data"] = dataFiles
1115 return parameters
1118@export
1119def DescribePythonPackageHostedOnGitHub(
1120 packageName: str,
1121 gitHubNamespace: str,
1122 gitHubRepository: Nullable[str] = None,
1123 projectURL: Nullable[str] = None,
1124 description: Nullable[str] = None,
1125 keywords: Nullable[Iterable[str]] = None,
1126 license: License = DEFAULT_LICENSE,
1127 readmeFile: Path = DEFAULT_README,
1128 requirementsFile: Path = DEFAULT_REQUIREMENTS,
1129 documentationRequirementsFile: Path = DEFAULT_DOCUMENTATION_REQUIREMENTS,
1130 unittestRequirementsFile: Path = DEFAULT_TEST_REQUIREMENTS,
1131 packagingRequirementsFile: Path = DEFAULT_PACKAGING_REQUIREMENTS,
1132 additionalRequirements: Nullable[dict[str, list[str]]] = None,
1133 sourceFileWithVersion: Path = DEFAULT_VERSION_FILE,
1134 classifiers: Iterable[str] = DEFAULT_CLASSIFIERS,
1135 developmentStatus: str = "stable",
1136 pythonVersions: Sequence[str] = DEFAULT_PY_VERSIONS,
1137 consoleScripts: Nullable[dict[str, str]] = None,
1138 guiScripts: Nullable[dict[str, str]] = None,
1139 pytestPlugins: Nullable[dict[str, str]] = None,
1140 dataFiles: Nullable[dict[str, list[str]]] = None,
1141 debug: bool = False
1142) -> dict[str, Any]:
1143 """
1144 Helper function to describe a Python package when the source code is hosted on GitHub.
1146 This is a wrapper for :func:`DescribePythonPackage`, because some parameters can be simplified by knowing the GitHub
1147 namespace and repository name: issue tracker URL, source code URL, ...
1149 :param packageName: Name of the Python package.
1150 :param gitHubNamespace: Name of the GitHub namespace (organization or user).
1151 :param gitHubRepository: Optional, name of the GitHub repository.
1152 :param projectURL: Optional, URL to the Python project.
1153 :param description: Optional, short description of the package. (Default: the first paragraph
1154 of the module doc-string in ``sourceFileWithVersion``.) The long description
1155 is read from the README file.
1156 :param keywords: Optional, a list of keywords.
1157 :param license: Optional, the package's license. (Default: ``Apache License, 2.0``, see
1158 :const:`DEFAULT_LICENSE`)
1159 :param readmeFile: Optional, the path to the README file. (Default: ``README.md``, see
1160 :const:`DEFAULT_README`)
1161 :param requirementsFile: Optional, the path to the project's requirements file. (Default:
1162 ``requirements.txt``, see :const:`DEFAULT_REQUIREMENTS`)
1163 :param documentationRequirementsFile: Optional, the path to the project's requirements file for documentation.
1164 (Default: ``doc/requirements.txt``, see
1165 :const:`DEFAULT_DOCUMENTATION_REQUIREMENTS`)
1166 :param unittestRequirementsFile: Optional, the path to the project's requirements file for unit tests. (Default:
1167 ``tests/requirements.txt``, see :const:`DEFAULT_TEST_REQUIREMENTS`)
1168 :param packagingRequirementsFile: Optional, the path to the project's requirements file for packaging. (Default:
1169 ``build/requirements.txt``, see :const:`DEFAULT_PACKAGING_REQUIREMENTS`)
1170 :param additionalRequirements: Optional, a dictionary of a lists with additional requirements. (default: None)
1171 :param sourceFileWithVersion: Optional, the path to the project's source file containing dunder variables
1172 like ``__version__``. (Default: ``__init__.py``, see
1173 :const:`DEFAULT_VERSION_FILE`)
1174 :param classifiers: Optional, a list of package classifiers. (Default: 3 classifiers, see
1175 :const:`DEFAULT_CLASSIFIERS`)
1176 :param developmentStatus: Optional, development status of the package. (Default: stable, see
1177 :const:`STATUS` for supported status values)
1178 :param pythonVersions: Optional, a list of supported Python 3 version. (Default: all currently
1179 maintained CPython versions, see :const:`DEFAULT_PY_VERSIONS`)
1180 :param consoleScripts: Optional, a dictionary mapping command line names to entry points, declared
1181 as ``console_scripts``. (Default: None)
1182 :param guiScripts: Optional, like ``consoleScripts``, but declared as ``gui_scripts`` - on
1183 Windows such a program starts without a console window. (Default: None)
1184 :param pytestPlugins: Optional, a dictionary mapping plugin names to entry points, declared as
1185 ``pytest11``. The classifier ``Framework :: Pytest`` is added with it.
1186 (Default: None)
1187 :param dataFiles: Optional, a dictionary mapping package names to lists of additional data files.
1188 :param debug: Optional, if ``True``, enable extended outputs for debugging.
1189 :returns: A dictionary suitable for :func:`setuptools.setup`.
1190 :raises MissingDependencyError: If package 'setuptools' is not available.
1191 :raises TypeError: If parameter 'readmeFile' is not of type :class:`~pathlib.Path`.
1192 :raises FileNotFoundError: If README file doesn't exist.
1193 :raises TypeError: If parameter 'requirementsFile' is not of type :class:`~pathlib.Path`.
1194 :raises FileNotFoundError: If requirements file doesn't exist.
1195 :raises TypeError: If parameter 'documentationRequirementsFile' is not of type
1196 :class:`~pathlib.Path`.
1197 :raises TypeError: If parameter 'unittestRequirementsFile' is not of type :class:`~pathlib.Path`.
1198 :raises TypeError: If parameter 'packagingRequirementsFile' is not of type :class:`~pathlib.Path`.
1199 :raises TypeError: If parameter 'sourceFileWithVersion' is not of type :class:`~pathlib.Path`.
1200 :raises FileNotFoundError: If package file with dunder variables doesn't exist.
1201 :raises TypeError: If parameter 'license' is not of type :class:`~pyTooling.Licensing.License`.
1202 :raises ValueError: If developmentStatus uses an unsupported value. (See :const:`STATUS`)
1203 :raises ValueError: If the content type of the README file is not supported. (See
1204 :func:`loadReadmeFile`)
1205 :raises FileNotFoundError: If the README file doesn't exist. (See :func:`loadReadmeFile`)
1206 :raises FileNotFoundError: If the requirements file doesn't exist. (See :func:`loadRequirementsFile`)
1207 :raises PackagingError: If no description was given and the package file has no module doc-string.
1208 :raises UnhandledCriticalWarningError: If an extra's requirement is a direct reference and no
1209 :class:`~pyTooling.Warning.WarningCollector` collects the
1210 :exc:`DirectReferenceWarning`.
1211 """
1212 if gitHubRepository is None: 1212 ↛ 1214line 1212 didn't jump to line 1214 because the condition on line 1212 was never true
1213 # Assign GitHub repository name without '.*', if derived from Python package name.
1214 if packageName.endswith(".*"):
1215 gitHubRepository = packageName[:-2]
1216 else:
1217 gitHubRepository = packageName
1219 # Derive URLs
1220 sourceCodeURL = f"https://GitHub.com/{gitHubNamespace}/{gitHubRepository}"
1221 documentationURL = f"https://{gitHubNamespace}.GitHub.io/{gitHubRepository}"
1222 issueTrackerCodeURL = f"{sourceCodeURL}/issues"
1224 projectURL = projectURL if projectURL is not None else sourceCodeURL
1226 return DescribePythonPackage(
1227 packageName=packageName,
1228 description=description,
1229 keywords=keywords,
1230 projectURL=projectURL,
1231 sourceCodeURL=sourceCodeURL,
1232 documentationURL=documentationURL,
1233 issueTrackerCodeURL=issueTrackerCodeURL,
1234 license=license,
1235 readmeFile=readmeFile,
1236 requirementsFile=requirementsFile,
1237 documentationRequirementsFile=documentationRequirementsFile,
1238 unittestRequirementsFile=unittestRequirementsFile,
1239 packagingRequirementsFile=packagingRequirementsFile,
1240 additionalRequirements=additionalRequirements,
1241 sourceFileWithVersion=sourceFileWithVersion,
1242 classifiers=classifiers,
1243 developmentStatus=developmentStatus,
1244 pythonVersions=pythonVersions,
1245 consoleScripts=consoleScripts,
1246 guiScripts=guiScripts,
1247 pytestPlugins=pytestPlugins,
1248 dataFiles=dataFiles,
1249 debug=debug,
1250 )