diff options
| author | robot-piglet <[email protected]> | 2026-03-09 15:01:52 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-03-09 15:19:56 +0300 |
| commit | f6fa894e435f87435fb7e9d169d27be34919d02e (patch) | |
| tree | 9133a7ed545f54d5497f48e35e940a72f108eb2e /contrib/python | |
| parent | 5e4443cb23a723cb03b9a208a3b481e4c03d393e (diff) | |
Intermediate changes
commit_hash:cc43b6f20066a000a87a4b56d73a1234249e0e8a
Diffstat (limited to 'contrib/python')
62 files changed, 8038 insertions, 60 deletions
diff --git a/contrib/python/annotated-doc/.dist-info/METADATA b/contrib/python/annotated-doc/.dist-info/METADATA new file mode 100644 index 00000000000..9bf7a9e8007 --- /dev/null +++ b/contrib/python/annotated-doc/.dist-info/METADATA @@ -0,0 +1,145 @@ +Metadata-Version: 2.4 +Name: annotated-doc +Version: 0.0.4 +Summary: Document parameters, class attributes, return types, and variables inline, with Annotated. +Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= <[email protected]> +License-Expression: MIT +License-File: LICENSE +Classifier: Intended Audience :: Information Technology +Classifier: Intended Audience :: System Administrators +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python +Classifier: Topic :: Internet +Classifier: Topic :: Software Development :: Libraries :: Application Frameworks +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Software Development :: Libraries +Classifier: Topic :: Software Development +Classifier: Typing :: Typed +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Project-URL: Homepage, https://github.com/fastapi/annotated-doc +Project-URL: Documentation, https://github.com/fastapi/annotated-doc +Project-URL: Repository, https://github.com/fastapi/annotated-doc +Project-URL: Issues, https://github.com/fastapi/annotated-doc/issues +Project-URL: Changelog, https://github.com/fastapi/annotated-doc/release-notes.md +Requires-Python: >=3.8 +Description-Content-Type: text/markdown + +# Annotated Doc + +Document parameters, class attributes, return types, and variables inline, with `Annotated`. + +<a href="https://github.com/fastapi/annotated-doc/actions?query=workflow%3ATest+event%3Apush+branch%3Amain" target="_blank"> + <img src="https://github.com/fastapi/annotated-doc/actions/workflows/test.yml/badge.svg?event=push&branch=main" alt="Test"> +</a> +<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/annotated-doc" target="_blank"> + <img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/annotated-doc.svg" alt="Coverage"> +</a> +<a href="https://pypi.org/project/annotated-doc" target="_blank"> + <img src="https://img.shields.io/pypi/v/annotated-doc?color=%2334D058&label=pypi%20package" alt="Package version"> +</a> +<a href="https://pypi.org/project/annotated-doc" target="_blank"> + <img src="https://img.shields.io/pypi/pyversions/annotated-doc.svg?color=%2334D058" alt="Supported Python versions"> +</a> + +## Installation + +```bash +pip install annotated-doc +``` + +Or with `uv`: + +```Python +uv add annotated-doc +``` + +## Usage + +Import `Doc` and pass a single literal string with the documentation for the specific parameter, class attribute, return type, or variable. + +For example, to document a parameter `name` in a function `hi` you could do: + +```Python +from typing import Annotated + +from annotated_doc import Doc + +def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None: + print(f"Hi, {name}!") +``` + +You can also use it to document class attributes: + +```Python +from typing import Annotated + +from annotated_doc import Doc + +class User: + name: Annotated[str, Doc("The user's name")] + age: Annotated[int, Doc("The user's age")] +``` + +The same way, you could document return types and variables, or anything that could have a type annotation with `Annotated`. + +## Who Uses This + +`annotated-doc` was made for: + +* [FastAPI](https://fastapi.tiangolo.com/) +* [Typer](https://typer.tiangolo.com/) +* [SQLModel](https://sqlmodel.tiangolo.com/) +* [Asyncer](https://asyncer.tiangolo.com/) + +`annotated-doc` is supported by [griffe-typingdoc](https://github.com/mkdocstrings/griffe-typingdoc), which powers reference documentation like the one in the [FastAPI Reference](https://fastapi.tiangolo.com/reference/). + +## Reasons not to use `annotated-doc` + +You are already comfortable with one of the existing docstring formats, like: + +* Sphinx +* numpydoc +* Google +* Keras + +Your team is already comfortable using them. + +You prefer having the documentation about parameters all together in a docstring, separated from the code defining them. + +You care about a specific set of users, using one specific editor, and that editor already has support for the specific docstring format you use. + +## Reasons to use `annotated-doc` + +* No micro-syntax to learn for newcomers, itโs **just Python** syntax. +* **Editing** would be already fully supported by default by any editor (current or future) supporting Python syntax, including syntax errors, syntax highlighting, etc. +* **Rendering** would be relatively straightforward to implement by static tools (tools that don't need runtime execution), as the information can be extracted from the AST they normally already create. +* **Deduplication of information**: the name of a parameter would be defined in a single place, not duplicated inside of a docstring. +* **Elimination** of the possibility of having **inconsistencies** when removing a parameter or class variable and **forgetting to remove** its documentation. +* **Minimization** of the probability of adding a new parameter or class variable and **forgetting to add its documentation**. +* **Elimination** of the possibility of having **inconsistencies** between the **name** of a parameter in the **signature** and the name in the docstring when it is renamed. +* **Access** to the documentation string for each symbol at **runtime**, including existing (older) Python versions. +* A more formalized way to document other symbols, like type aliases, that could use Annotated. +* **Support** for apps using FastAPI, Typer and others. +* **AI Accessibility**: AI tools will have an easier way understanding each parameter as the distance from documentation to parameter is much closer. + +## History + +I ([@tiangolo](https://github.com/tiangolo)) originally wanted for this to be part of the Python standard library (in [PEP 727](https://peps.python.org/pep-0727/)), but the proposal was withdrawn as there was a fair amount of negative feedback and opposition. + +The conclusion was that this was better done as an external effort, in a third-party library. + +So, here it is, with a simpler approach, as a third-party library, in a way that can be used by others, starting with FastAPI and friends. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/contrib/python/annotated-doc/.dist-info/entry_points.txt b/contrib/python/annotated-doc/.dist-info/entry_points.txt new file mode 100644 index 00000000000..c3ad4726d43 --- /dev/null +++ b/contrib/python/annotated-doc/.dist-info/entry_points.txt @@ -0,0 +1,4 @@ +[console_scripts] + +[gui_scripts] + diff --git a/contrib/python/annotated-doc/LICENSE b/contrib/python/annotated-doc/LICENSE new file mode 100644 index 00000000000..7a254464cc7 --- /dev/null +++ b/contrib/python/annotated-doc/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Sebastiรกn Ramรญrez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/contrib/python/annotated-doc/README.md b/contrib/python/annotated-doc/README.md new file mode 100644 index 00000000000..0698b1e15ee --- /dev/null +++ b/contrib/python/annotated-doc/README.md @@ -0,0 +1,109 @@ +# Annotated Doc + +Document parameters, class attributes, return types, and variables inline, with `Annotated`. + +<a href="https://github.com/fastapi/annotated-doc/actions?query=workflow%3ATest+event%3Apush+branch%3Amain" target="_blank"> + <img src="https://github.com/fastapi/annotated-doc/actions/workflows/test.yml/badge.svg?event=push&branch=main" alt="Test"> +</a> +<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/annotated-doc" target="_blank"> + <img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/annotated-doc.svg" alt="Coverage"> +</a> +<a href="https://pypi.org/project/annotated-doc" target="_blank"> + <img src="https://img.shields.io/pypi/v/annotated-doc?color=%2334D058&label=pypi%20package" alt="Package version"> +</a> +<a href="https://pypi.org/project/annotated-doc" target="_blank"> + <img src="https://img.shields.io/pypi/pyversions/annotated-doc.svg?color=%2334D058" alt="Supported Python versions"> +</a> + +## Installation + +```bash +pip install annotated-doc +``` + +Or with `uv`: + +```Python +uv add annotated-doc +``` + +## Usage + +Import `Doc` and pass a single literal string with the documentation for the specific parameter, class attribute, return type, or variable. + +For example, to document a parameter `name` in a function `hi` you could do: + +```Python +from typing import Annotated + +from annotated_doc import Doc + +def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None: + print(f"Hi, {name}!") +``` + +You can also use it to document class attributes: + +```Python +from typing import Annotated + +from annotated_doc import Doc + +class User: + name: Annotated[str, Doc("The user's name")] + age: Annotated[int, Doc("The user's age")] +``` + +The same way, you could document return types and variables, or anything that could have a type annotation with `Annotated`. + +## Who Uses This + +`annotated-doc` was made for: + +* [FastAPI](https://fastapi.tiangolo.com/) +* [Typer](https://typer.tiangolo.com/) +* [SQLModel](https://sqlmodel.tiangolo.com/) +* [Asyncer](https://asyncer.tiangolo.com/) + +`annotated-doc` is supported by [griffe-typingdoc](https://github.com/mkdocstrings/griffe-typingdoc), which powers reference documentation like the one in the [FastAPI Reference](https://fastapi.tiangolo.com/reference/). + +## Reasons not to use `annotated-doc` + +You are already comfortable with one of the existing docstring formats, like: + +* Sphinx +* numpydoc +* Google +* Keras + +Your team is already comfortable using them. + +You prefer having the documentation about parameters all together in a docstring, separated from the code defining them. + +You care about a specific set of users, using one specific editor, and that editor already has support for the specific docstring format you use. + +## Reasons to use `annotated-doc` + +* No micro-syntax to learn for newcomers, itโs **just Python** syntax. +* **Editing** would be already fully supported by default by any editor (current or future) supporting Python syntax, including syntax errors, syntax highlighting, etc. +* **Rendering** would be relatively straightforward to implement by static tools (tools that don't need runtime execution), as the information can be extracted from the AST they normally already create. +* **Deduplication of information**: the name of a parameter would be defined in a single place, not duplicated inside of a docstring. +* **Elimination** of the possibility of having **inconsistencies** when removing a parameter or class variable and **forgetting to remove** its documentation. +* **Minimization** of the probability of adding a new parameter or class variable and **forgetting to add its documentation**. +* **Elimination** of the possibility of having **inconsistencies** between the **name** of a parameter in the **signature** and the name in the docstring when it is renamed. +* **Access** to the documentation string for each symbol at **runtime**, including existing (older) Python versions. +* A more formalized way to document other symbols, like type aliases, that could use Annotated. +* **Support** for apps using FastAPI, Typer and others. +* **AI Accessibility**: AI tools will have an easier way understanding each parameter as the distance from documentation to parameter is much closer. + +## History + +I ([@tiangolo](https://github.com/tiangolo)) originally wanted for this to be part of the Python standard library (in [PEP 727](https://peps.python.org/pep-0727/)), but the proposal was withdrawn as there was a fair amount of negative feedback and opposition. + +The conclusion was that this was better done as an external effort, in a third-party library. + +So, here it is, with a simpler approach, as a third-party library, in a way that can be used by others, starting with FastAPI and friends. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/contrib/python/annotated-doc/annotated_doc/__init__.py b/contrib/python/annotated-doc/annotated_doc/__init__.py new file mode 100644 index 00000000000..a0152a7d12a --- /dev/null +++ b/contrib/python/annotated-doc/annotated_doc/__init__.py @@ -0,0 +1,3 @@ +from .main import Doc as Doc + +__version__ = "0.0.4" diff --git a/contrib/python/annotated-doc/annotated_doc/main.py b/contrib/python/annotated-doc/annotated_doc/main.py new file mode 100644 index 00000000000..7063c59e450 --- /dev/null +++ b/contrib/python/annotated-doc/annotated_doc/main.py @@ -0,0 +1,36 @@ +class Doc: + """Define the documentation of a type annotation using `Annotated`, to be + used in class attributes, function and method parameters, return values, + and variables. + + The value should be a positional-only string literal to allow static tools + like editors and documentation generators to use it. + + This complements docstrings. + + The string value passed is available in the attribute `documentation`. + + Example: + + ```Python + from typing import Annotated + from annotated_doc import Doc + + def hi(name: Annotated[str, Doc("Who to say hi to")]) -> None: + print(f"Hi, {name}!") + ``` + """ + + def __init__(self, documentation: str, /) -> None: + self.documentation = documentation + + def __repr__(self) -> str: + return f"Doc({self.documentation!r})" + + def __hash__(self) -> int: + return hash(self.documentation) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Doc): + return NotImplemented + return self.documentation == other.documentation diff --git a/contrib/python/annotated-doc/annotated_doc/py.typed b/contrib/python/annotated-doc/annotated_doc/py.typed new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/contrib/python/annotated-doc/annotated_doc/py.typed diff --git a/contrib/python/annotated-doc/ya.make b/contrib/python/annotated-doc/ya.make new file mode 100644 index 00000000000..ad7982ce521 --- /dev/null +++ b/contrib/python/annotated-doc/ya.make @@ -0,0 +1,24 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(0.0.4) + +LICENSE(MIT) + +NO_LINT() + +PY_SRCS( + TOP_LEVEL + annotated_doc/__init__.py + annotated_doc/main.py +) + +RESOURCE_FILES( + PREFIX contrib/python/annotated-doc/ + .dist-info/METADATA + .dist-info/entry_points.txt + annotated_doc/py.typed +) + +END() diff --git a/contrib/python/jaraco.text/.dist-info/METADATA b/contrib/python/jaraco.text/.dist-info/METADATA index 7fc6760373f..1d21bc04f99 100644 --- a/contrib/python/jaraco.text/.dist-info/METADATA +++ b/contrib/python/jaraco.text/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: jaraco.text -Version: 4.1.0 +Version: 4.2.0 Summary: Module for text manipulation Author-email: "Jason R. Coombs" <[email protected]> License-Expression: MIT @@ -12,10 +12,10 @@ Classifier: Programming Language :: Python :: 3 :: Only Requires-Python: >=3.9 Description-Content-Type: text/x-rst License-File: LICENSE -Requires-Dist: jaraco.functools Requires-Dist: jaraco.context>=4.1 -Requires-Dist: autocommand +Requires-Dist: jaraco.functools Requires-Dist: more_itertools +Requires-Dist: typer-slim Provides-Extra: test Requires-Dist: pytest!=8.1.*,>=6; extra == "test" Requires-Dist: pathlib2; python_version < "3.10" and extra == "test" diff --git a/contrib/python/jaraco.text/.yandex_meta/yamaker.yaml b/contrib/python/jaraco.text/.yandex_meta/yamaker.yaml index bc39e7ce986..ae460b6b4f0 100644 --- a/contrib/python/jaraco.text/.yandex_meta/yamaker.yaml +++ b/contrib/python/jaraco.text/.yandex_meta/yamaker.yaml @@ -1,5 +1,3 @@ -requirements: - - autocommand: null exclude: - "jaraco/text/Lorem ipsum.txt" keep: diff --git a/contrib/python/jaraco.text/jaraco/text/__init__.py b/contrib/python/jaraco.text/jaraco/text/__init__.py index 1c215b68983..9ae1f6e0600 100644 --- a/contrib/python/jaraco.text/jaraco/text/__init__.py +++ b/contrib/python/jaraco.text/jaraco/text/__init__.py @@ -1,7 +1,9 @@ from __future__ import annotations import functools +import io import itertools +import os import re import sys import textwrap @@ -13,6 +15,8 @@ from typing import ( Protocol, SupportsIndex, TypeVar, + Union, + cast, overload, ) @@ -25,12 +29,16 @@ else: # pragma: no cover from importlib.abc import Traversable if TYPE_CHECKING: - from _typeshed import FileDescriptorOrPath, SupportsGetItem + from _typeshed import ( + FileDescriptorOrPath, + SupportsIter, + SupportsNext, + ) from typing_extensions import Self, TypeAlias, TypeGuard, Unpack - _T_co = TypeVar("_T_co", covariant=True) - # Same as builtins._GetItemIterable from typeshed - _GetItemIterable: TypeAlias = SupportsGetItem[int, _T_co] + Openable: TypeAlias = FileDescriptorOrPath +else: + Openable = Union[str, bytes, os.PathLike, int] _T = TypeVar("_T") @@ -655,7 +663,7 @@ def drop_comment(line: str) -> str: return line.partition(' #')[0] -def join_continuation(lines: _GetItemIterable[str]) -> Generator[str]: +def join_continuation(lines: SupportsIter[SupportsNext[str]]) -> Generator[str]: r""" Join lines continued by a trailing backslash. @@ -679,7 +687,7 @@ def join_continuation(lines: _GetItemIterable[str]) -> Generator[str]: ['foo'] """ lines_ = iter(lines) - for item in lines_: + for item in lines_: # type: ignore[attr-defined] # A bit of a false positive with iteration dunder fallback while item.endswith('\\'): try: item = item[:-2].strip() + next(lines_) @@ -688,9 +696,15 @@ def join_continuation(lines: _GetItemIterable[str]) -> Generator[str]: yield item +# https://docs.python.org/3/library/io.html#io.TextIOBase.newlines +NewlineSpec: TypeAlias = Union[str, tuple[str, ...], None] + + def read_newlines( - filename: FileDescriptorOrPath, limit: int | None = 1024 -) -> str | tuple[str, ...] | None: + filename: Union[Openable, io.TextIOWrapper], # noqa: UP007 # singledispatch uses the annotation at runtime (python 3.9) + limit: int | None = 1024, +) -> NewlineSpec: r""" >>> tmp_path = getfixture('tmp_path') >>> filename = tmp_path / 'out.txt' @@ -704,9 +718,21 @@ def read_newlines( >>> read_newlines(filename) ('\r', '\n', '\r\n') """ + if sys.version_info >= (3, 10): + assert isinstance(filename, Openable) + else: # pragma: no cover + filename = cast(Openable, filename) with open(filename, encoding='utf-8') as fp: - fp.read(limit) - return fp.newlines + return read_newlines(fp, limit=limit) + + +@read_newlines.register +def _( + filename: io.TextIOWrapper, + limit: Union[int, None] = 1024, # noqa: UP007 # singledispatch uses the annotation at runtime (python 3.9) +) -> NewlineSpec: + filename.read(limit) + return filename.newlines def lines_from(input: Traversable) -> Generator[str]: diff --git a/contrib/python/jaraco.text/jaraco/text/show-newlines.py b/contrib/python/jaraco.text/jaraco/text/show-newlines.py index 22811e010c6..2a91f7bc291 100644 --- a/contrib/python/jaraco.text/jaraco/text/show-newlines.py +++ b/contrib/python/jaraco.text/jaraco/text/show-newlines.py @@ -1,17 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING - import inflect +import typer from more_itertools import always_iterable import jaraco.text -if TYPE_CHECKING: - from _typeshed import FileDescriptorOrPath - -def report_newlines(filename: FileDescriptorOrPath) -> None: +def report_newlines(input: typer.FileText) -> None: r""" Report the newlines in the indicated file. @@ -25,7 +21,7 @@ def report_newlines(filename: FileDescriptorOrPath) -> None: >>> report_newlines(filename) newlines are ('\n', '\r\n') """ - newlines = jaraco.text.read_newlines(filename) + newlines = jaraco.text.read_newlines(input) count = len(tuple(always_iterable(newlines))) engine = inflect.engine() print( @@ -34,3 +30,6 @@ def report_newlines(filename: FileDescriptorOrPath) -> None: engine.plural_verb("is", count), repr(newlines), ) + + +__name__ == '__main__' and typer.run(report_newlines) # type: ignore[func-returns-value] diff --git a/contrib/python/jaraco.text/jaraco/text/strip-prefix.py b/contrib/python/jaraco.text/jaraco/text/strip-prefix.py index b683f8d72c5..e43cdd1abee 100644 --- a/contrib/python/jaraco.text/jaraco/text/strip-prefix.py +++ b/contrib/python/jaraco.text/jaraco/text/strip-prefix.py @@ -1,5 +1,7 @@ import sys +import typer + from jaraco.text import Stripper @@ -14,3 +16,6 @@ def strip_prefix() -> None: 123 """ sys.stdout.writelines(Stripper.strip_prefix(sys.stdin).lines) + + +__name__ == '__main__' and typer.run(strip_prefix) # type: ignore[func-returns-value] diff --git a/contrib/python/jaraco.text/patches/01-remove-autocommand.patch b/contrib/python/jaraco.text/patches/01-remove-autocommand.patch deleted file mode 100644 index 1b6d9d463c1..00000000000 --- a/contrib/python/jaraco.text/patches/01-remove-autocommand.patch +++ /dev/null @@ -1,20 +0,0 @@ -ะฃ autocommand ะปะธัะตะฝะทะธั LGPL-3 ะธ ััะฐะฝะทะธัะธะฒะฝะพ ัะตัะตะท jaraco.text ะฟะพะดะถะธะณะฐะตั ะฟัะพะฒะตัะบั ั Catboost - -ะ ะัะบะธะดะธะธ ััะธะผ ะฝะธ ะบัะพ ะฝะต ะฟะพะปัะทัะตััั, ะฟะพัะพะผั ะฟัะพััะพ ัะดะฐะปัั ---- contrib/python/jaraco.text/jaraco/text/show-newlines.py (index) -+++ contrib/python/jaraco.text/jaraco/text/show-newlines.py (working tree) -@@ -1 +0,0 @@ --import autocommand -@@ -30,3 +28,0 @@ def report_newlines(filename): -- -- --autocommand.autocommand(__name__)(report_newlines) ---- contrib/python/jaraco.text/jaraco/text/strip-prefix.py (index) -+++ contrib/python/jaraco.text/jaraco/text/strip-prefix.py (working tree) -@@ -3,2 +2,0 @@ import sys --import autocommand -- -@@ -19,3 +16,0 @@ def strip_prefix(): -- -- --autocommand.autocommand(__name__)(strip_prefix) diff --git a/contrib/python/jaraco.text/ya.make b/contrib/python/jaraco.text/ya.make index f4dea16d4c7..ab74fb5b55b 100644 --- a/contrib/python/jaraco.text/ya.make +++ b/contrib/python/jaraco.text/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(4.1.0) +VERSION(4.2.0) LICENSE(MIT) @@ -10,6 +10,7 @@ PEERDIR( contrib/python/jaraco.context contrib/python/jaraco.functools contrib/python/more-itertools + contrib/python/typer ) NO_LINT() diff --git a/contrib/python/shellingham/.dist-info/METADATA b/contrib/python/shellingham/.dist-info/METADATA new file mode 100644 index 00000000000..52118f1e5c8 --- /dev/null +++ b/contrib/python/shellingham/.dist-info/METADATA @@ -0,0 +1,106 @@ +Metadata-Version: 2.1 +Name: shellingham +Version: 1.5.4 +Summary: Tool to Detect Surrounding Shell +Home-page: https://github.com/sarugaku/shellingham +Author: Tzu-ping Chung +Author-email: [email protected] +License: ISC License +Keywords: shell +Classifier: Development Status :: 3 - Alpha +Classifier: Environment :: Console +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: ISC License (ISCL) +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.7 +Description-Content-Type: text/x-rst +License-File: LICENSE + +============================================= +Shellingham: Tool to Detect Surrounding Shell +============================================= + +.. image:: https://img.shields.io/pypi/v/shellingham.svg + :target: https://pypi.org/project/shellingham/ + +Shellingham detects what shell the current Python executable is running in. + + +Usage +===== + +.. code-block:: python + + >>> import shellingham + >>> shellingham.detect_shell() + ('bash', '/bin/bash') + +``detect_shell`` pokes around the process's running environment to determine +what shell it is run in. It returns a 2-tuple: + +* The shell name, always lowercased. +* The command used to run the shell. + +``ShellDetectionFailure`` is raised if ``detect_shell`` fails to detect the +surrounding shell. + + +Notes +===== + +* The shell name is always lowercased. +* On Windows, the shell name is the name of the executable, minus the file + extension. + + +Notes for Application Developers +================================ + +Remember, your application's user is not necessarily using a shell. +Shellingham raises ``ShellDetectionFailure`` if there is no shell to detect, +but *your application should almost never do this to your user*. + +A practical approach to this is to wrap ``detect_shell`` in a try block, and +provide a sane default on failure + +.. code-block:: python + + try: + shell = shellingham.detect_shell() + except shellingham.ShellDetectionFailure: + shell = provide_default() + + +There are a few choices for you to choose from. + +* The POSIX standard mandates the environment variable ``SHELL`` to refer to + "the user's preferred command language interpreter". This is always available + (even if the user is not in an interactive session), and likely the correct + choice to launch an interactive sub-shell with. +* A command ``sh`` is almost guaranteed to exist, likely at ``/bin/sh``, since + several POSIX tools rely on it. This should be suitable if you want to run a + (possibly non-interactive) script. +* All versions of DOS and Windows have an environment variable ``COMSPEC``. + This can always be used to launch a usable command prompt (e.g. `cmd.exe` on + Windows). + +Here's a simple implementation to provide a default shell + +.. code-block:: python + + import os + + def provide_default(): + if os.name == 'posix': + return os.environ['SHELL'] + elif os.name == 'nt': + return os.environ['COMSPEC'] + raise NotImplementedError(f'OS {os.name!r} support not available') diff --git a/contrib/python/shellingham/.dist-info/top_level.txt b/contrib/python/shellingham/.dist-info/top_level.txt new file mode 100644 index 00000000000..d4e44ce0299 --- /dev/null +++ b/contrib/python/shellingham/.dist-info/top_level.txt @@ -0,0 +1 @@ +shellingham diff --git a/contrib/python/shellingham/LICENSE b/contrib/python/shellingham/LICENSE new file mode 100644 index 00000000000..b9077766e9b --- /dev/null +++ b/contrib/python/shellingham/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2018, Tzu-ping Chung <[email protected]> + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/contrib/python/shellingham/README.rst b/contrib/python/shellingham/README.rst new file mode 100644 index 00000000000..81b19463591 --- /dev/null +++ b/contrib/python/shellingham/README.rst @@ -0,0 +1,80 @@ +============================================= +Shellingham: Tool to Detect Surrounding Shell +============================================= + +.. image:: https://img.shields.io/pypi/v/shellingham.svg + :target: https://pypi.org/project/shellingham/ + +Shellingham detects what shell the current Python executable is running in. + + +Usage +===== + +.. code-block:: python + + >>> import shellingham + >>> shellingham.detect_shell() + ('bash', '/bin/bash') + +``detect_shell`` pokes around the process's running environment to determine +what shell it is run in. It returns a 2-tuple: + +* The shell name, always lowercased. +* The command used to run the shell. + +``ShellDetectionFailure`` is raised if ``detect_shell`` fails to detect the +surrounding shell. + + +Notes +===== + +* The shell name is always lowercased. +* On Windows, the shell name is the name of the executable, minus the file + extension. + + +Notes for Application Developers +================================ + +Remember, your application's user is not necessarily using a shell. +Shellingham raises ``ShellDetectionFailure`` if there is no shell to detect, +but *your application should almost never do this to your user*. + +A practical approach to this is to wrap ``detect_shell`` in a try block, and +provide a sane default on failure + +.. code-block:: python + + try: + shell = shellingham.detect_shell() + except shellingham.ShellDetectionFailure: + shell = provide_default() + + +There are a few choices for you to choose from. + +* The POSIX standard mandates the environment variable ``SHELL`` to refer to + "the user's preferred command language interpreter". This is always available + (even if the user is not in an interactive session), and likely the correct + choice to launch an interactive sub-shell with. +* A command ``sh`` is almost guaranteed to exist, likely at ``/bin/sh``, since + several POSIX tools rely on it. This should be suitable if you want to run a + (possibly non-interactive) script. +* All versions of DOS and Windows have an environment variable ``COMSPEC``. + This can always be used to launch a usable command prompt (e.g. `cmd.exe` on + Windows). + +Here's a simple implementation to provide a default shell + +.. code-block:: python + + import os + + def provide_default(): + if os.name == 'posix': + return os.environ['SHELL'] + elif os.name == 'nt': + return os.environ['COMSPEC'] + raise NotImplementedError(f'OS {os.name!r} support not available') diff --git a/contrib/python/shellingham/shellingham/__init__.py b/contrib/python/shellingham/shellingham/__init__.py new file mode 100644 index 00000000000..15f7a90cbd0 --- /dev/null +++ b/contrib/python/shellingham/shellingham/__init__.py @@ -0,0 +1,23 @@ +import importlib +import os + +from ._core import ShellDetectionFailure + +__version__ = "1.5.4" + + +def detect_shell(pid=None, max_depth=10): + name = os.name + try: + impl = importlib.import_module(".{}".format(name), __name__) + except ImportError: + message = "Shell detection not implemented for {0!r}".format(name) + raise RuntimeError(message) + try: + get_shell = impl.get_shell + except AttributeError: + raise RuntimeError("get_shell not implemented for {0!r}".format(name)) + shell = get_shell(pid, max_depth=max_depth) + if shell: + return shell + raise ShellDetectionFailure() diff --git a/contrib/python/shellingham/shellingham/_core.py b/contrib/python/shellingham/shellingham/_core.py new file mode 100644 index 00000000000..13b65417c73 --- /dev/null +++ b/contrib/python/shellingham/shellingham/_core.py @@ -0,0 +1,11 @@ +SHELL_NAMES = ( + {"sh", "bash", "dash", "ash"} # Bourne. + | {"csh", "tcsh"} # C. + | {"ksh", "zsh", "fish"} # Common alternatives. + | {"cmd", "powershell", "pwsh"} # Microsoft. + | {"elvish", "xonsh", "nu"} # More exotic. +) + + +class ShellDetectionFailure(EnvironmentError): + pass diff --git a/contrib/python/shellingham/shellingham/nt.py b/contrib/python/shellingham/shellingham/nt.py new file mode 100644 index 00000000000..389551b223a --- /dev/null +++ b/contrib/python/shellingham/shellingham/nt.py @@ -0,0 +1,163 @@ +import contextlib +import ctypes +import os + +from ctypes.wintypes import ( + BOOL, + CHAR, + DWORD, + HANDLE, + LONG, + LPWSTR, + MAX_PATH, + PDWORD, + ULONG, +) + +from shellingham._core import SHELL_NAMES + + +INVALID_HANDLE_VALUE = HANDLE(-1).value +ERROR_NO_MORE_FILES = 18 +ERROR_INSUFFICIENT_BUFFER = 122 +TH32CS_SNAPPROCESS = 2 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + +kernel32 = ctypes.windll.kernel32 + + +def _check_handle(error_val=0): + def check(ret, func, args): + if ret == error_val: + raise ctypes.WinError() + return ret + + return check + + +def _check_expected(expected): + def check(ret, func, args): + if ret: + return True + code = ctypes.GetLastError() + if code == expected: + return False + raise ctypes.WinError(code) + + return check + + +class ProcessEntry32(ctypes.Structure): + _fields_ = ( + ("dwSize", DWORD), + ("cntUsage", DWORD), + ("th32ProcessID", DWORD), + ("th32DefaultHeapID", ctypes.POINTER(ULONG)), + ("th32ModuleID", DWORD), + ("cntThreads", DWORD), + ("th32ParentProcessID", DWORD), + ("pcPriClassBase", LONG), + ("dwFlags", DWORD), + ("szExeFile", CHAR * MAX_PATH), + ) + + +kernel32.CloseHandle.argtypes = [HANDLE] +kernel32.CloseHandle.restype = BOOL + +kernel32.CreateToolhelp32Snapshot.argtypes = [DWORD, DWORD] +kernel32.CreateToolhelp32Snapshot.restype = HANDLE +kernel32.CreateToolhelp32Snapshot.errcheck = _check_handle( # type: ignore + INVALID_HANDLE_VALUE, +) + +kernel32.Process32First.argtypes = [HANDLE, ctypes.POINTER(ProcessEntry32)] +kernel32.Process32First.restype = BOOL +kernel32.Process32First.errcheck = _check_expected( # type: ignore + ERROR_NO_MORE_FILES, +) + +kernel32.Process32Next.argtypes = [HANDLE, ctypes.POINTER(ProcessEntry32)] +kernel32.Process32Next.restype = BOOL +kernel32.Process32Next.errcheck = _check_expected( # type: ignore + ERROR_NO_MORE_FILES, +) + +kernel32.GetCurrentProcessId.argtypes = [] +kernel32.GetCurrentProcessId.restype = DWORD + +kernel32.OpenProcess.argtypes = [DWORD, BOOL, DWORD] +kernel32.OpenProcess.restype = HANDLE +kernel32.OpenProcess.errcheck = _check_handle( # type: ignore + INVALID_HANDLE_VALUE, +) + +kernel32.QueryFullProcessImageNameW.argtypes = [HANDLE, DWORD, LPWSTR, PDWORD] +kernel32.QueryFullProcessImageNameW.restype = BOOL +kernel32.QueryFullProcessImageNameW.errcheck = _check_expected( # type: ignore + ERROR_INSUFFICIENT_BUFFER, +) + + +def _handle(f, *args, **kwargs): + handle = f(*args, **kwargs) + try: + yield handle + finally: + kernel32.CloseHandle(handle) + + +def _iter_processes(): + f = kernel32.CreateToolhelp32Snapshot + with _handle(f, TH32CS_SNAPPROCESS, 0) as snap: + entry = ProcessEntry32() + entry.dwSize = ctypes.sizeof(entry) + ret = kernel32.Process32First(snap, entry) + while ret: + yield entry + ret = kernel32.Process32Next(snap, entry) + + +def _get_full_path(proch): + size = DWORD(MAX_PATH) + while True: + path_buff = ctypes.create_unicode_buffer("", size.value) + if kernel32.QueryFullProcessImageNameW(proch, 0, path_buff, size): + return path_buff.value + size.value *= 2 + + +def get_shell(pid=None, max_depth=10): + proc_map = { + proc.th32ProcessID: (proc.th32ParentProcessID, proc.szExeFile) + for proc in _iter_processes() + } + pid = pid or os.getpid() + + for _ in range(0, max_depth + 1): + try: + ppid, executable = proc_map[pid] + except KeyError: # No such process? Give up. + break + + # The executable name would be encoded with the current code page if + # we're in ANSI mode (usually). Try to decode it into str/unicode, + # replacing invalid characters to be safe (not thoeratically necessary, + # I think). Note that we need to use 'mbcs' instead of encoding + # settings from sys because this is from the Windows API, not Python + # internals (which those settings reflect). (pypa/pipenv#3382) + if isinstance(executable, bytes): + executable = executable.decode("mbcs", "replace") + + name = executable.rpartition(".")[0].lower() + if name not in SHELL_NAMES: + pid = ppid + continue + + key = PROCESS_QUERY_LIMITED_INFORMATION + with _handle(kernel32.OpenProcess, key, 0, pid) as proch: + return (name, _get_full_path(proch)) + + return None diff --git a/contrib/python/shellingham/shellingham/posix/__init__.py b/contrib/python/shellingham/shellingham/posix/__init__.py new file mode 100644 index 00000000000..5bd2070db27 --- /dev/null +++ b/contrib/python/shellingham/shellingham/posix/__init__.py @@ -0,0 +1,112 @@ +import os +import re + +from .._core import SHELL_NAMES, ShellDetectionFailure +from . import proc, ps + +# Based on QEMU docs: https://www.qemu.org/docs/master/user/main.html +QEMU_BIN_REGEX = re.compile( + r"""qemu- + (alpha + |armeb + |arm + |m68k + |cris + |i386 + |x86_64 + |microblaze + |mips + |mipsel + |mips64 + |mips64el + |mipsn32 + |mipsn32el + |nios2 + |ppc64 + |ppc + |sh4eb + |sh4 + |sparc + |sparc32plus + |sparc64 + )""", + re.VERBOSE, +) + + +def _iter_process_parents(pid, max_depth=10): + """Select a way to obtain process information from the system. + + * `/proc` is used if supported. + * The system `ps` utility is used as a fallback option. + """ + for impl in (proc, ps): + try: + iterator = impl.iter_process_parents(pid, max_depth) + except EnvironmentError: + continue + return iterator + raise ShellDetectionFailure("compatible proc fs or ps utility is required") + + +def _get_login_shell(proc_cmd): + """Form shell information from SHELL environ if possible.""" + login_shell = os.environ.get("SHELL", "") + if login_shell: + proc_cmd = login_shell + else: + proc_cmd = proc_cmd[1:] + return (os.path.basename(proc_cmd).lower(), proc_cmd) + + +_INTERPRETER_SHELL_NAMES = [ + (re.compile(r"^python(\d+(\.\d+)?)?$"), {"xonsh"}), +] + + +def _get_interpreter_shell(proc_name, proc_args): + """Get shell invoked via an interpreter. + + Some shells are implemented on, and invoked with an interpreter, e.g. xonsh + is commonly executed with an executable Python script. This detects what + script the interpreter is actually running, and check whether that looks + like a shell. + + See sarugaku/shellingham#26 for rational. + """ + for pattern, shell_names in _INTERPRETER_SHELL_NAMES: + if not pattern.match(proc_name): + continue + for arg in proc_args: + name = os.path.basename(arg).lower() + if os.path.isfile(arg) and name in shell_names: + return (name, arg) + return None + + +def _get_shell(cmd, *args): + if cmd.startswith("-"): # Login shell! Let's use this. + return _get_login_shell(cmd) + name = os.path.basename(cmd).lower() + if name == "rosetta" or QEMU_BIN_REGEX.fullmatch(name): + # If the current process is Rosetta or QEMU, this likely is a + # containerized process. Parse out the actual command instead. + cmd = args[0] + args = args[1:] + name = os.path.basename(cmd).lower() + if name in SHELL_NAMES: # Command looks like a shell. + return (name, cmd) + shell = _get_interpreter_shell(name, args) + if shell: + return shell + return None + + +def get_shell(pid=None, max_depth=10): + """Get the shell that the supplied pid or os.getpid() is running in.""" + pid = str(pid or os.getpid()) + for proc_args, _, _ in _iter_process_parents(pid, max_depth): + shell = _get_shell(*proc_args) + if shell: + return shell + return None diff --git a/contrib/python/shellingham/shellingham/posix/_core.py b/contrib/python/shellingham/shellingham/posix/_core.py new file mode 100644 index 00000000000..adc49e6e7a9 --- /dev/null +++ b/contrib/python/shellingham/shellingham/posix/_core.py @@ -0,0 +1,3 @@ +import collections + +Process = collections.namedtuple("Process", "args pid ppid") diff --git a/contrib/python/shellingham/shellingham/posix/proc.py b/contrib/python/shellingham/shellingham/posix/proc.py new file mode 100644 index 00000000000..950f63228e5 --- /dev/null +++ b/contrib/python/shellingham/shellingham/posix/proc.py @@ -0,0 +1,83 @@ +import io +import os +import re +import sys + +from ._core import Process + +# FreeBSD: https://www.freebsd.org/cgi/man.cgi?query=procfs +# NetBSD: https://man.netbsd.org/NetBSD-9.3-STABLE/mount_procfs.8 +# DragonFlyBSD: https://www.dragonflybsd.org/cgi/web-man?command=procfs +BSD_STAT_PPID = 2 + +# See https://docs.kernel.org/filesystems/proc.html +LINUX_STAT_PPID = 3 + +STAT_PATTERN = re.compile(r"\(.+\)|\S+") + + +def detect_proc(): + """Detect /proc filesystem style. + + This checks the /proc/{pid} directory for possible formats. Returns one of + the following as str: + + * `stat`: Linux-style, i.e. ``/proc/{pid}/stat``. + * `status`: BSD-style, i.e. ``/proc/{pid}/status``. + """ + pid = os.getpid() + for name in ("stat", "status"): + if os.path.exists(os.path.join("/proc", str(pid), name)): + return name + raise ProcFormatError("unsupported proc format") + + +def _use_bsd_stat_format(): + try: + return os.uname().sysname.lower() in ("freebsd", "netbsd", "dragonfly") + except Exception: + return False + + +def _get_ppid(pid, name): + path = os.path.join("/proc", str(pid), name) + with io.open(path, encoding="ascii", errors="replace") as f: + parts = STAT_PATTERN.findall(f.read()) + # We only care about TTY and PPID -- both are numbers. + if _use_bsd_stat_format(): + return parts[BSD_STAT_PPID] + return parts[LINUX_STAT_PPID] + + +def _get_cmdline(pid): + path = os.path.join("/proc", str(pid), "cmdline") + encoding = sys.getfilesystemencoding() or "utf-8" + with io.open(path, encoding=encoding, errors="replace") as f: + # XXX: Command line arguments can be arbitrary byte sequences, not + # necessarily decodable. For Shellingham's purpose, however, we don't + # care. (pypa/pipenv#2820) + # cmdline appends an extra NULL at the end, hence the [:-1]. + return tuple(f.read().split("\0")[:-1]) + + +class ProcFormatError(EnvironmentError): + pass + + +def iter_process_parents(pid, max_depth=10): + """Try to look up the process tree via the /proc interface.""" + stat_name = detect_proc() + + # Inner generator function so we correctly throw an error eagerly if proc + # is not supported, rather than on the first call to the iterator. This + # allows the call site detects the correct implementation. + def _iter_process_parents(pid, max_depth): + for _ in range(max_depth): + ppid = _get_ppid(pid, stat_name) + args = _get_cmdline(pid) + yield Process(args=args, pid=pid, ppid=ppid) + if ppid == "0": + break + pid = ppid + + return _iter_process_parents(pid, max_depth) diff --git a/contrib/python/shellingham/shellingham/posix/ps.py b/contrib/python/shellingham/shellingham/posix/ps.py new file mode 100644 index 00000000000..3bc39a74a56 --- /dev/null +++ b/contrib/python/shellingham/shellingham/posix/ps.py @@ -0,0 +1,51 @@ +import errno +import subprocess +import sys + +from ._core import Process + + +class PsNotAvailable(EnvironmentError): + pass + + +def iter_process_parents(pid, max_depth=10): + """Try to look up the process tree via the output of `ps`.""" + try: + cmd = ["ps", "-ww", "-o", "pid=", "-o", "ppid=", "-o", "args="] + output = subprocess.check_output(cmd) + except OSError as e: # Python 2-compatible FileNotFoundError. + if e.errno != errno.ENOENT: + raise + raise PsNotAvailable("ps not found") + except subprocess.CalledProcessError as e: + # `ps` can return 1 if the process list is completely empty. + # (sarugaku/shellingham#15) + if not e.output.strip(): + return + raise + if not isinstance(output, str): + encoding = sys.getfilesystemencoding() or sys.getdefaultencoding() + output = output.decode(encoding) + + processes_mapping = {} + for line in output.split("\n"): + try: + _pid, ppid, args = line.strip().split(None, 2) + # XXX: This is not right, but we are really out of options. + # ps does not offer a sane way to decode the argument display, + # and this is "Good Enough" for obtaining shell names. Hopefully + # people don't name their shell with a space, or have something + # like "/usr/bin/xonsh is uber". (sarugaku/shellingham#14) + args = tuple(a.strip() for a in args.split(" ")) + except ValueError: + continue + processes_mapping[_pid] = Process(args=args, pid=_pid, ppid=ppid) + + for _ in range(max_depth): + try: + process = processes_mapping[pid] + except KeyError: + return + yield process + pid = process.ppid diff --git a/contrib/python/shellingham/ya.make b/contrib/python/shellingham/ya.make new file mode 100644 index 00000000000..0497140cbc2 --- /dev/null +++ b/contrib/python/shellingham/ya.make @@ -0,0 +1,32 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(1.5.4) + +LICENSE(ISC) + +NO_LINT() + +NO_CHECK_IMPORTS( + shellingham.nt +) + +PY_SRCS( + TOP_LEVEL + shellingham/__init__.py + shellingham/_core.py + shellingham/nt.py + shellingham/posix/__init__.py + shellingham/posix/_core.py + shellingham/posix/proc.py + shellingham/posix/ps.py +) + +RESOURCE_FILES( + PREFIX contrib/python/shellingham/ + .dist-info/METADATA + .dist-info/top_level.txt +) + +END() diff --git a/contrib/python/typer-slim/.dist-info/METADATA b/contrib/python/typer-slim/.dist-info/METADATA new file mode 100644 index 00000000000..8d9cbe6c372 --- /dev/null +++ b/contrib/python/typer-slim/.dist-info/METADATA @@ -0,0 +1,425 @@ +Metadata-Version: 2.4 +Name: typer-slim +Version: 0.21.1 +Summary: Typer, build great CLIs. Easy to code. Based on Python type hints. +Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= <[email protected]> +License-Expression: MIT +License-File: LICENSE +Classifier: Intended Audience :: Information Technology +Classifier: Intended Audience :: System Administrators +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python +Classifier: Topic :: Software Development :: Libraries :: Application Frameworks +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Software Development :: Libraries +Classifier: Topic :: Software Development +Classifier: Typing :: Typed +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Project-URL: Homepage, https://github.com/fastapi/typer +Project-URL: Documentation, https://typer.tiangolo.com +Project-URL: Repository, https://github.com/fastapi/typer +Project-URL: Issues, https://github.com/fastapi/typer/issues +Project-URL: Changelog, https://typer.tiangolo.com/release-notes/ +Requires-Python: >=3.9 +Requires-Dist: click>=8.0.0 +Requires-Dist: typing-extensions>=3.7.4.3 +Provides-Extra: standard +Requires-Dist: shellingham>=1.3.0; extra == "standard" +Requires-Dist: rich>=10.11.0; extra == "standard" +Description-Content-Type: text/markdown + +<p align="center"> + <a href="https://typer.tiangolo.com"><img src="https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg#only-light" alt="Typer"></a> + +</p> +<p align="center"> + <em>Typer, build great CLIs. Easy to code. Based on Python type hints.</em> +</p> +<p align="center"> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3ATest+event%3Apush+branch%3Amaster" target="_blank"> + <img src="https://github.com/fastapi/typer/actions/workflows/test.yml/badge.svg?event=push&branch=master" alt="Test"> +</a> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3APublish" target="_blank"> + <img src="https://github.com/fastapi/typer/workflows/Publish/badge.svg" alt="Publish"> +</a> +<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/typer" target="_blank"> + <img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/typer.svg" alt="Coverage"> +<a href="https://pypi.org/project/typer" target="_blank"> + <img src="https://img.shields.io/pypi/v/typer?color=%2334D058&label=pypi%20package" alt="Package version"> +</a> +</p> + +--- + +**Documentation**: <a href="https://typer.tiangolo.com" target="_blank">https://typer.tiangolo.com</a> + +**Source Code**: <a href="https://github.com/fastapi/typer" target="_blank">https://github.com/fastapi/typer</a> + +--- + +Typer is a library for building <abbr title="command line interface, programs executed from a terminal">CLI</abbr> applications that users will **love using** and developers will **love creating**. Based on Python type hints. + +It's also a command line tool to run scripts, automatically converting them to CLI applications. + +The key features are: + +* **Intuitive to write**: Great editor support. <abbr title="also known as auto-complete, autocompletion, IntelliSense">Completion</abbr> everywhere. Less time debugging. Designed to be easy to use and learn. Less time reading docs. +* **Easy to use**: It's easy to use for the final users. Automatic help, and automatic completion for all shells. +* **Short**: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs. +* **Start simple**: The simplest example adds only 2 lines of code to your app: **1 import, 1 function call**. +* **Grow large**: Grow in complexity as much as you want, create arbitrarily complex trees of commands and groups of subcommands, with options and arguments. +* **Run scripts**: Typer includes a `typer` command/program that you can use to run scripts, automatically converting them to CLIs, even if they don't use Typer internally. + +## FastAPI of CLIs + +**Typer** is <a href="https://fastapi.tiangolo.com" class="external-link" target="_blank">FastAPI</a>'s little sibling, it's the FastAPI of CLIs. + +## Installation + +Create and activate a <a href="https://typer.tiangolo.com/virtual-environments/" class="external-link" target="_blank">virtual environment</a> and then install **Typer**: + +<div class="termy"> + +```console +$ pip install typer +---> 100% +Successfully installed typer rich shellingham +``` + +</div> + +## Example + +### The absolute minimum + +* Create a file `main.py` with: + +```Python +def main(name: str): + print(f"Hello {name}") +``` + +This script doesn't even use Typer internally. But you can use the `typer` command to run it as a CLI application. + +### Run it + +Run your application with the `typer` command: + +<div class="termy"> + +```console +// Run your application +$ typer main.py run + +// You get a nice error, you are missing NAME +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME +Try 'typer [PATH_OR_MODULE] run --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ typer main.py run --help + +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME + +Run the provided Typer app. + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ typer main.py run Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +This is the simplest use case, not even using Typer internally, but it can already be quite useful for simple scripts. + +**Note**: auto-completion works when you create a Python package and run it with `--install-completion` or when you use the `typer` command. + +## Use Typer in your code + +Now let's start using Typer in your own code, update `main.py` with: + +```Python +import typer + + +def main(name: str): + print(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) +``` + +Now you could run it with Python directly: + +<div class="termy"> + +```console +// Run your application +$ python main.py + +// You get a nice error, you are missing NAME +Usage: main.py [OPTIONS] NAME +Try 'main.py --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ python main.py --help + +Usage: main.py [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ python main.py Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +**Note**: you can also call this same script with the `typer` command, but you don't need to. + +## Example upgrade + +This was the simplest example possible. + +Now let's see one a bit more complex. + +### An example with two subcommands + +Modify the file `main.py`. + +Create a `typer.Typer()` app, and create two subcommands with their parameters. + +```Python hl_lines="3 6 11 20" +import typer + +app = typer.Typer() + + +def hello(name: str): + print(f"Hello {name}") + + +def goodbye(name: str, formal: bool = False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + + +if __name__ == "__main__": + app() +``` + +And that will: + +* Explicitly create a `typer.Typer` app. + * The previous `typer.run` actually creates one implicitly for you. +* Add two subcommands with `@app.command()`. +* Execute the `app()` itself, as if it was a function (instead of `typer.run`). + +### Run the upgraded example + +Check the new help: + +<div class="termy"> + +```console +$ python main.py --help + + Usage: main.py [OPTIONS] COMMAND [ARGS]... + +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --install-completion Install completion โ +โ for the current โ +โ shell. โ +โ --show-completion Show completion for โ +โ the current shell, โ +โ to copy it or โ +โ customize the โ +โ installation. โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ goodbye โ +โ hello โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// When you create a package you get โจ auto-completion โจ for free, installed with --install-completion + +// You have 2 subcommands (the 2 functions): goodbye and hello +``` + +</div> + +Now check the help for the `hello` command: + +<div class="termy"> + +```console +$ python main.py hello --help + + Usage: main.py hello [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +``` + +</div> + +And now check the help for the `goodbye` command: + +<div class="termy"> + +```console +$ python main.py goodbye --help + + Usage: main.py goodbye [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --formal --no-formal [default: no-formal] โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Automatic --formal and --no-formal for the bool option ๐ +``` + +</div> + +Now you can try out the new command line application: + +<div class="termy"> + +```console +// Use it with the hello command + +$ python main.py hello Camila + +Hello Camila + +// And with the goodbye command + +$ python main.py goodbye Camila + +Bye Camila! + +// And with --formal + +$ python main.py goodbye --formal Camila + +Goodbye Ms. Camila. Have a good day. +``` + +</div> + +**Note**: If your app only has one command, by default the command name is **omitted** in usage: `python main.py Camila`. However, when there are multiple commands, you must **explicitly include the command name**: `python main.py hello Camila`. See [One or Multiple Commands](https://typer.tiangolo.com/tutorial/commands/one-or-multiple/) for more details. + +### Recap + +In summary, you declare **once** the types of parameters (*CLI arguments* and *CLI options*) as function parameters. + +You do that with standard modern Python types. + +You don't have to learn a new syntax, the methods or classes of a specific library, etc. + +Just standard **Python**. + +For example, for an `int`: + +```Python +total: int +``` + +or for a `bool` flag: + +```Python +force: bool +``` + +And similarly for **files**, **paths**, **enums** (choices), etc. And there are tools to create **groups of subcommands**, add metadata, extra **validation**, etc. + +**You get**: great editor support, including **completion** and **type checks** everywhere. + +**Your users get**: automatic **`--help`**, **auto-completion** in their terminal (Bash, Zsh, Fish, PowerShell) when they install your package or when using the `typer` command. + +For a more complete example including more features, see the <a href="https://typer.tiangolo.com/tutorial/">Tutorial - User Guide</a>. + +## Dependencies + +**Typer** stands on the shoulders of a giant. Its only internal required dependency is <a href="https://click.palletsprojects.com/" class="external-link" target="_blank">Click</a>. + +By default it also comes with extra standard dependencies: + +* <a href="https://rich.readthedocs.io/en/stable/index.html" class="external-link" target="_blank"><code>rich</code></a>: to show nicely formatted errors automatically. +* <a href="https://github.com/sarugaku/shellingham" class="external-link" target="_blank"><code>shellingham</code></a>: to automatically detect the current shell when installing completion. + * With `shellingham` you can just use `--install-completion`. + * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. + +### `typer-slim` + +If you don't want the extra standard optional dependencies, install `typer-slim` instead. + +When you install with: + +```bash +pip install typer +``` + +...it includes the same code and dependencies as: + +```bash +pip install "typer-slim[standard]" +``` + +The `standard` extra dependencies are `rich` and `shellingham`. + +**Note**: The `typer` command is only included in the `typer` package. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/contrib/python/typer-slim/.dist-info/entry_points.txt b/contrib/python/typer-slim/.dist-info/entry_points.txt new file mode 100644 index 00000000000..c3ad4726d43 --- /dev/null +++ b/contrib/python/typer-slim/.dist-info/entry_points.txt @@ -0,0 +1,4 @@ +[console_scripts] + +[gui_scripts] + diff --git a/contrib/python/typer-slim/LICENSE b/contrib/python/typer-slim/LICENSE new file mode 100644 index 00000000000..a7694736cf3 --- /dev/null +++ b/contrib/python/typer-slim/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Sebastiรกn Ramรญrez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/contrib/python/typer-slim/README.md b/contrib/python/typer-slim/README.md new file mode 100644 index 00000000000..32ead017462 --- /dev/null +++ b/contrib/python/typer-slim/README.md @@ -0,0 +1,386 @@ +<p align="center"> + <a href="https://typer.tiangolo.com"><img src="https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg#only-light" alt="Typer"></a> + +</p> +<p align="center"> + <em>Typer, build great CLIs. Easy to code. Based on Python type hints.</em> +</p> +<p align="center"> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3ATest+event%3Apush+branch%3Amaster" target="_blank"> + <img src="https://github.com/fastapi/typer/actions/workflows/test.yml/badge.svg?event=push&branch=master" alt="Test"> +</a> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3APublish" target="_blank"> + <img src="https://github.com/fastapi/typer/workflows/Publish/badge.svg" alt="Publish"> +</a> +<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/typer" target="_blank"> + <img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/typer.svg" alt="Coverage"> +<a href="https://pypi.org/project/typer" target="_blank"> + <img src="https://img.shields.io/pypi/v/typer?color=%2334D058&label=pypi%20package" alt="Package version"> +</a> +</p> + +--- + +**Documentation**: <a href="https://typer.tiangolo.com" target="_blank">https://typer.tiangolo.com</a> + +**Source Code**: <a href="https://github.com/fastapi/typer" target="_blank">https://github.com/fastapi/typer</a> + +--- + +Typer is a library for building <abbr title="command line interface, programs executed from a terminal">CLI</abbr> applications that users will **love using** and developers will **love creating**. Based on Python type hints. + +It's also a command line tool to run scripts, automatically converting them to CLI applications. + +The key features are: + +* **Intuitive to write**: Great editor support. <abbr title="also known as auto-complete, autocompletion, IntelliSense">Completion</abbr> everywhere. Less time debugging. Designed to be easy to use and learn. Less time reading docs. +* **Easy to use**: It's easy to use for the final users. Automatic help, and automatic completion for all shells. +* **Short**: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs. +* **Start simple**: The simplest example adds only 2 lines of code to your app: **1 import, 1 function call**. +* **Grow large**: Grow in complexity as much as you want, create arbitrarily complex trees of commands and groups of subcommands, with options and arguments. +* **Run scripts**: Typer includes a `typer` command/program that you can use to run scripts, automatically converting them to CLIs, even if they don't use Typer internally. + +## FastAPI of CLIs + +**Typer** is <a href="https://fastapi.tiangolo.com" class="external-link" target="_blank">FastAPI</a>'s little sibling, it's the FastAPI of CLIs. + +## Installation + +Create and activate a <a href="https://typer.tiangolo.com/virtual-environments/" class="external-link" target="_blank">virtual environment</a> and then install **Typer**: + +<div class="termy"> + +```console +$ pip install typer +---> 100% +Successfully installed typer rich shellingham +``` + +</div> + +## Example + +### The absolute minimum + +* Create a file `main.py` with: + +```Python +def main(name: str): + print(f"Hello {name}") +``` + +This script doesn't even use Typer internally. But you can use the `typer` command to run it as a CLI application. + +### Run it + +Run your application with the `typer` command: + +<div class="termy"> + +```console +// Run your application +$ typer main.py run + +// You get a nice error, you are missing NAME +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME +Try 'typer [PATH_OR_MODULE] run --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ typer main.py run --help + +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME + +Run the provided Typer app. + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ typer main.py run Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +This is the simplest use case, not even using Typer internally, but it can already be quite useful for simple scripts. + +**Note**: auto-completion works when you create a Python package and run it with `--install-completion` or when you use the `typer` command. + +## Use Typer in your code + +Now let's start using Typer in your own code, update `main.py` with: + +```Python +import typer + + +def main(name: str): + print(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) +``` + +Now you could run it with Python directly: + +<div class="termy"> + +```console +// Run your application +$ python main.py + +// You get a nice error, you are missing NAME +Usage: main.py [OPTIONS] NAME +Try 'main.py --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ python main.py --help + +Usage: main.py [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ python main.py Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +**Note**: you can also call this same script with the `typer` command, but you don't need to. + +## Example upgrade + +This was the simplest example possible. + +Now let's see one a bit more complex. + +### An example with two subcommands + +Modify the file `main.py`. + +Create a `typer.Typer()` app, and create two subcommands with their parameters. + +```Python hl_lines="3 6 11 20" +import typer + +app = typer.Typer() + + +def hello(name: str): + print(f"Hello {name}") + + +def goodbye(name: str, formal: bool = False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + + +if __name__ == "__main__": + app() +``` + +And that will: + +* Explicitly create a `typer.Typer` app. + * The previous `typer.run` actually creates one implicitly for you. +* Add two subcommands with `@app.command()`. +* Execute the `app()` itself, as if it was a function (instead of `typer.run`). + +### Run the upgraded example + +Check the new help: + +<div class="termy"> + +```console +$ python main.py --help + + Usage: main.py [OPTIONS] COMMAND [ARGS]... + +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --install-completion Install completion โ +โ for the current โ +โ shell. โ +โ --show-completion Show completion for โ +โ the current shell, โ +โ to copy it or โ +โ customize the โ +โ installation. โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ goodbye โ +โ hello โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// When you create a package you get โจ auto-completion โจ for free, installed with --install-completion + +// You have 2 subcommands (the 2 functions): goodbye and hello +``` + +</div> + +Now check the help for the `hello` command: + +<div class="termy"> + +```console +$ python main.py hello --help + + Usage: main.py hello [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +``` + +</div> + +And now check the help for the `goodbye` command: + +<div class="termy"> + +```console +$ python main.py goodbye --help + + Usage: main.py goodbye [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --formal --no-formal [default: no-formal] โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Automatic --formal and --no-formal for the bool option ๐ +``` + +</div> + +Now you can try out the new command line application: + +<div class="termy"> + +```console +// Use it with the hello command + +$ python main.py hello Camila + +Hello Camila + +// And with the goodbye command + +$ python main.py goodbye Camila + +Bye Camila! + +// And with --formal + +$ python main.py goodbye --formal Camila + +Goodbye Ms. Camila. Have a good day. +``` + +</div> + +**Note**: If your app only has one command, by default the command name is **omitted** in usage: `python main.py Camila`. However, when there are multiple commands, you must **explicitly include the command name**: `python main.py hello Camila`. See [One or Multiple Commands](https://typer.tiangolo.com/tutorial/commands/one-or-multiple/) for more details. + +### Recap + +In summary, you declare **once** the types of parameters (*CLI arguments* and *CLI options*) as function parameters. + +You do that with standard modern Python types. + +You don't have to learn a new syntax, the methods or classes of a specific library, etc. + +Just standard **Python**. + +For example, for an `int`: + +```Python +total: int +``` + +or for a `bool` flag: + +```Python +force: bool +``` + +And similarly for **files**, **paths**, **enums** (choices), etc. And there are tools to create **groups of subcommands**, add metadata, extra **validation**, etc. + +**You get**: great editor support, including **completion** and **type checks** everywhere. + +**Your users get**: automatic **`--help`**, **auto-completion** in their terminal (Bash, Zsh, Fish, PowerShell) when they install your package or when using the `typer` command. + +For a more complete example including more features, see the <a href="https://typer.tiangolo.com/tutorial/">Tutorial - User Guide</a>. + +## Dependencies + +**Typer** stands on the shoulders of a giant. Its only internal required dependency is <a href="https://click.palletsprojects.com/" class="external-link" target="_blank">Click</a>. + +By default it also comes with extra standard dependencies: + +* <a href="https://rich.readthedocs.io/en/stable/index.html" class="external-link" target="_blank"><code>rich</code></a>: to show nicely formatted errors automatically. +* <a href="https://github.com/sarugaku/shellingham" class="external-link" target="_blank"><code>shellingham</code></a>: to automatically detect the current shell when installing completion. + * With `shellingham` you can just use `--install-completion`. + * Without `shellingham`, you have to pass the name of the shell to install completion for, e.g. `--install-completion bash`. + +### `typer-slim` + +If you don't want the extra standard optional dependencies, install `typer-slim` instead. + +When you install with: + +```bash +pip install typer +``` + +...it includes the same code and dependencies as: + +```bash +pip install "typer-slim[standard]" +``` + +The `standard` extra dependencies are `rich` and `shellingham`. + +**Note**: The `typer` command is only included in the `typer` package. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/contrib/python/typer-slim/typer/__init__.py b/contrib/python/typer-slim/typer/__init__.py new file mode 100644 index 00000000000..603725b28cf --- /dev/null +++ b/contrib/python/typer-slim/typer/__init__.py @@ -0,0 +1,39 @@ +"""Typer, build great CLIs. Easy to code. Based on Python type hints.""" + +__version__ = "0.21.1" + +from shutil import get_terminal_size as get_terminal_size + +from click.exceptions import Abort as Abort +from click.exceptions import BadParameter as BadParameter +from click.exceptions import Exit as Exit +from click.termui import clear as clear +from click.termui import confirm as confirm +from click.termui import echo_via_pager as echo_via_pager +from click.termui import edit as edit +from click.termui import getchar as getchar +from click.termui import pause as pause +from click.termui import progressbar as progressbar +from click.termui import prompt as prompt +from click.termui import secho as secho +from click.termui import style as style +from click.termui import unstyle as unstyle +from click.utils import echo as echo +from click.utils import format_filename as format_filename +from click.utils import get_app_dir as get_app_dir +from click.utils import get_binary_stream as get_binary_stream +from click.utils import get_text_stream as get_text_stream +from click.utils import open_file as open_file + +from . import colors as colors +from .main import Typer as Typer +from .main import launch as launch +from .main import run as run +from .models import CallbackParam as CallbackParam +from .models import Context as Context +from .models import FileBinaryRead as FileBinaryRead +from .models import FileBinaryWrite as FileBinaryWrite +from .models import FileText as FileText +from .models import FileTextWrite as FileTextWrite +from .params import Argument as Argument +from .params import Option as Option diff --git a/contrib/python/typer-slim/typer/__main__.py b/contrib/python/typer-slim/typer/__main__.py new file mode 100644 index 00000000000..4e28416e104 --- /dev/null +++ b/contrib/python/typer-slim/typer/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/contrib/python/typer-slim/typer/_completion_classes.py b/contrib/python/typer-slim/typer/_completion_classes.py new file mode 100644 index 00000000000..d08c76ac5ef --- /dev/null +++ b/contrib/python/typer-slim/typer/_completion_classes.py @@ -0,0 +1,206 @@ +import importlib.util +import os +import re +import sys +from typing import Any + +import click +import click.parser +import click.shell_completion + +from ._completion_shared import ( + COMPLETION_SCRIPT_BASH, + COMPLETION_SCRIPT_FISH, + COMPLETION_SCRIPT_POWER_SHELL, + COMPLETION_SCRIPT_ZSH, + Shells, +) + +try: + from click.shell_completion import split_arg_string as click_split_arg_string +except ImportError: # pragma: no cover + # TODO: when removing support for Click < 8.2, remove this import + from click.parser import ( # type: ignore[no-redef] + split_arg_string as click_split_arg_string, + ) + + +def _sanitize_help_text(text: str) -> str: + """Sanitizes the help text by removing rich tags""" + if not importlib.util.find_spec("rich"): + return text + from . import rich_utils + + return rich_utils.rich_render_text(text) + + +class BashComplete(click.shell_completion.BashComplete): + name = Shells.bash.value + source_template = COMPLETION_SCRIPT_BASH + + def source_vars(self) -> dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> tuple[list[str], str]: + cwords = click_split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + # TODO: Explore replicating the new behavior from Click, with item types and + # triggering completion for files and directories + # return f"{item.type},{item.value}" + return f"{item.value}" + + def complete(self) -> str: + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + + +class ZshComplete(click.shell_completion.ZshComplete): + name = Shells.zsh.value + source_template = COMPLETION_SCRIPT_ZSH + + def source_vars(self) -> dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> tuple[list[str], str]: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + cwords = click_split_arg_string(completion_args) + args = cwords[1:] + if args and not completion_args.endswith(" "): + incomplete = args[-1] + args = args[:-1] + else: + incomplete = "" + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + def escape(s: str) -> str: + return ( + s.replace('"', '""') + .replace("'", "''") + .replace("$", "\\$") + .replace("`", "\\`") + .replace(":", r"\\:") + ) + + # TODO: Explore replicating the new behavior from Click, pay attention to + # the difference with and without escape + # return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" + if item.help: + return f'"{escape(item.value)}":"{_sanitize_help_text(escape(item.help))}"' + else: + return f'"{escape(item.value)}"' + + def complete(self) -> str: + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + res = [self.format_completion(item) for item in completions] + if res: + args_str = "\n".join(res) + return f"_arguments '*: :(({args_str}))'" + else: + return "_files" + + +class FishComplete(click.shell_completion.FishComplete): + name = Shells.fish.value + source_template = COMPLETION_SCRIPT_FISH + + def source_vars(self) -> dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> tuple[list[str], str]: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + cwords = click_split_arg_string(completion_args) + args = cwords[1:] + if args and not completion_args.endswith(" "): + incomplete = args[-1] + args = args[:-1] + else: + incomplete = "" + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + # TODO: Explore replicating the new behavior from Click, pay attention to + # the difference with and without formatted help + # if item.help: + # return f"{item.type},{item.value}\t{item.help}" + + # return f"{item.type},{item.value} + if item.help: + formatted_help = re.sub(r"\s", " ", item.help) + return f"{item.value}\t{_sanitize_help_text(formatted_help)}" + else: + return f"{item.value}" + + def complete(self) -> str: + complete_action = os.getenv("_TYPER_COMPLETE_FISH_ACTION", "") + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + show_args = [self.format_completion(item) for item in completions] + if complete_action == "get-args": + if show_args: + return "\n".join(show_args) + elif complete_action == "is-args": + if show_args: + # Activate complete args (no files) + sys.exit(0) + else: + # Deactivate complete args (allow files) + sys.exit(1) + return "" # pragma: no cover + + +class PowerShellComplete(click.shell_completion.ShellComplete): + name = Shells.powershell.value + source_template = COMPLETION_SCRIPT_POWER_SHELL + + def source_vars(self) -> dict[str, Any]: + return { + "complete_func": self.func_name, + "autocomplete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def get_completion_args(self) -> tuple[list[str], str]: + completion_args = os.getenv("_TYPER_COMPLETE_ARGS", "") + incomplete = os.getenv("_TYPER_COMPLETE_WORD_TO_COMPLETE", "") + cwords = click_split_arg_string(completion_args) + args = cwords[1:-1] if incomplete else cwords[1:] + return args, incomplete + + def format_completion(self, item: click.shell_completion.CompletionItem) -> str: + return f"{item.value}:::{_sanitize_help_text(item.help) if item.help else ' '}" + + +def completion_init() -> None: + click.shell_completion.add_completion_class(BashComplete, Shells.bash.value) + click.shell_completion.add_completion_class(ZshComplete, Shells.zsh.value) + click.shell_completion.add_completion_class(FishComplete, Shells.fish.value) + click.shell_completion.add_completion_class( + PowerShellComplete, Shells.powershell.value + ) + click.shell_completion.add_completion_class(PowerShellComplete, Shells.pwsh.value) diff --git a/contrib/python/typer-slim/typer/_completion_shared.py b/contrib/python/typer-slim/typer/_completion_shared.py new file mode 100644 index 00000000000..4c4d79dda7a --- /dev/null +++ b/contrib/python/typer-slim/typer/_completion_shared.py @@ -0,0 +1,259 @@ +import os +import re +import subprocess +from enum import Enum +from pathlib import Path +from typing import Optional, Union + +import click +from typer.core import HAS_SHELLINGHAM + +if HAS_SHELLINGHAM: + import shellingham + + +class Shells(str, Enum): + bash = "bash" + zsh = "zsh" + fish = "fish" + powershell = "powershell" + pwsh = "pwsh" + + +COMPLETION_SCRIPT_BASH = """ +%(complete_func)s() { + local IFS=$'\n' + COMPREPLY=( $( env COMP_WORDS="${COMP_WORDS[*]}" \\ + COMP_CWORD=$COMP_CWORD \\ + %(autocomplete_var)s=complete_bash $1 ) ) + return 0 +} + +complete -o default -F %(complete_func)s %(prog_name)s +""" + +COMPLETION_SCRIPT_ZSH = """ +#compdef %(prog_name)s + +%(complete_func)s() { + eval $(env _TYPER_COMPLETE_ARGS="${words[1,$CURRENT]}" %(autocomplete_var)s=complete_zsh %(prog_name)s) +} + +compdef %(complete_func)s %(prog_name)s +""" + +COMPLETION_SCRIPT_FISH = 'complete --command %(prog_name)s --no-files --arguments "(env %(autocomplete_var)s=complete_fish _TYPER_COMPLETE_FISH_ACTION=get-args _TYPER_COMPLETE_ARGS=(commandline -cp) %(prog_name)s)" --condition "env %(autocomplete_var)s=complete_fish _TYPER_COMPLETE_FISH_ACTION=is-args _TYPER_COMPLETE_ARGS=(commandline -cp) %(prog_name)s"' + +COMPLETION_SCRIPT_POWER_SHELL = """ +Import-Module PSReadLine +Set-PSReadLineKeyHandler -Chord Tab -Function MenuComplete +$scriptblock = { + param($wordToComplete, $commandAst, $cursorPosition) + $Env:%(autocomplete_var)s = "complete_powershell" + $Env:_TYPER_COMPLETE_ARGS = $commandAst.ToString() + $Env:_TYPER_COMPLETE_WORD_TO_COMPLETE = $wordToComplete + %(prog_name)s | ForEach-Object { + $commandArray = $_ -Split ":::" + $command = $commandArray[0] + $helpString = $commandArray[1] + [System.Management.Automation.CompletionResult]::new( + $command, $command, 'ParameterValue', $helpString) + } + $Env:%(autocomplete_var)s = "" + $Env:_TYPER_COMPLETE_ARGS = "" + $Env:_TYPER_COMPLETE_WORD_TO_COMPLETE = "" +} +Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $scriptblock +""" + +_completion_scripts = { + "bash": COMPLETION_SCRIPT_BASH, + "zsh": COMPLETION_SCRIPT_ZSH, + "fish": COMPLETION_SCRIPT_FISH, + "powershell": COMPLETION_SCRIPT_POWER_SHELL, + "pwsh": COMPLETION_SCRIPT_POWER_SHELL, +} + +# TODO: Probably refactor this, copied from Click 7.x +_invalid_ident_char_re = re.compile(r"[^a-zA-Z0-9_]") + + +def get_completion_script(*, prog_name: str, complete_var: str, shell: str) -> str: + cf_name = _invalid_ident_char_re.sub("", prog_name.replace("-", "_")) + script = _completion_scripts.get(shell) + if script is None: + click.echo(f"Shell {shell} not supported.", err=True) + raise click.exceptions.Exit(1) + return ( + script + % { + "complete_func": f"_{cf_name}_completion", + "prog_name": prog_name, + "autocomplete_var": complete_var, + } + ).strip() + + +def install_bash(*, prog_name: str, complete_var: str, shell: str) -> Path: + # Ref: https://github.com/scop/bash-completion#faq + # It seems bash-completion is the official completion system for bash: + # Ref: https://www.gnu.org/software/bash/manual/html_node/A-Programmable-Completion-Example.html + # But installing in the locations from the docs doesn't seem to have effect + completion_path = Path.home() / ".bash_completions" / f"{prog_name}.sh" + rc_path = Path.home() / ".bashrc" + rc_path.parent.mkdir(parents=True, exist_ok=True) + rc_content = "" + if rc_path.is_file(): + rc_content = rc_path.read_text() + completion_init_lines = [f"source '{completion_path}'"] + for line in completion_init_lines: + if line not in rc_content: # pragma: no cover + rc_content += f"\n{line}" + rc_content += "\n" + rc_path.write_text(rc_content) + # Install completion + completion_path.parent.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + completion_path.write_text(script_content) + return completion_path + + +def install_zsh(*, prog_name: str, complete_var: str, shell: str) -> Path: + # Setup Zsh and load ~/.zfunc + zshrc_path = Path.home() / ".zshrc" + zshrc_path.parent.mkdir(parents=True, exist_ok=True) + zshrc_content = "" + if zshrc_path.is_file(): + zshrc_content = zshrc_path.read_text() + completion_line = "fpath+=~/.zfunc; autoload -Uz compinit; compinit" + if completion_line not in zshrc_content: + zshrc_content += f"\n{completion_line}\n" + style_line = "zstyle ':completion:*' menu select" + # TODO: consider setting the style only for the current program + # style_line = f"zstyle ':completion:*:*:{prog_name}:*' menu select" + # Install zstyle completion config only if the user doesn't have a customization + if "zstyle" not in zshrc_content: + zshrc_content += f"\n{style_line}\n" + zshrc_content = f"{zshrc_content.strip()}\n" + zshrc_path.write_text(zshrc_content) + # Install completion under ~/.zfunc/ + path_obj = Path.home() / f".zfunc/_{prog_name}" + path_obj.parent.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + path_obj.write_text(script_content) + return path_obj + + +def install_fish(*, prog_name: str, complete_var: str, shell: str) -> Path: + path_obj = Path.home() / f".config/fish/completions/{prog_name}.fish" + parent_dir: Path = path_obj.parent + parent_dir.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + path_obj.write_text(f"{script_content}\n") + return path_obj + + +def install_powershell(*, prog_name: str, complete_var: str, shell: str) -> Path: + subprocess.run( + [ + shell, + "-Command", + "Set-ExecutionPolicy", + "Unrestricted", + "-Scope", + "CurrentUser", + ] + ) + result = subprocess.run( + [shell, "-NoProfile", "-Command", "echo", "$profile"], + check=True, + stdout=subprocess.PIPE, + ) + if result.returncode != 0: # pragma: no cover + click.echo("Couldn't get PowerShell user profile", err=True) + raise click.exceptions.Exit(result.returncode) + path_str = "" + if isinstance(result.stdout, str): # pragma: no cover + path_str = result.stdout + if isinstance(result.stdout, bytes): + for encoding in ["windows-1252", "utf8", "cp850"]: + try: + path_str = result.stdout.decode(encoding) + break + except UnicodeDecodeError: # pragma: no cover + pass + if not path_str: # pragma: no cover + click.echo("Couldn't decode the path automatically", err=True) + raise click.exceptions.Exit(1) + path_obj = Path(path_str.strip()) + parent_dir: Path = path_obj.parent + parent_dir.mkdir(parents=True, exist_ok=True) + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + with path_obj.open(mode="a") as f: + f.write(f"{script_content}\n") + return path_obj + + +def install( + shell: Optional[str] = None, + prog_name: Optional[str] = None, + complete_var: Optional[str] = None, +) -> tuple[str, Path]: + prog_name = prog_name or click.get_current_context().find_root().info_name + assert prog_name + if complete_var is None: + complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + test_disable_detection = os.getenv("_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION") + if shell is None and not test_disable_detection: + shell = _get_shell_name() + if shell == "bash": + installed_path = install_bash( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell == "zsh": + installed_path = install_zsh( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell == "fish": + installed_path = install_fish( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + elif shell in {"powershell", "pwsh"}: + installed_path = install_powershell( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + return shell, installed_path + else: + click.echo(f"Shell {shell} is not supported.") + raise click.exceptions.Exit(1) + + +def _get_shell_name() -> Union[str, None]: + """Get the current shell name, if available. + + The name will always be lowercase. If the shell cannot be detected, None is + returned. + """ + name: Union[str, None] # N.B. shellingham is untyped + if HAS_SHELLINGHAM: + try: + # N.B. detect_shell returns a tuple of (shell name, shell command). + # We only need the name. + name, _cmd = shellingham.detect_shell() # noqa: TID251 + except shellingham.ShellDetectionFailure: # pragma: no cover + name = None + else: + name = None # pragma: no cover + + return name diff --git a/contrib/python/typer-slim/typer/_types.py b/contrib/python/typer-slim/typer/_types.py new file mode 100644 index 00000000000..045e36b8156 --- /dev/null +++ b/contrib/python/typer-slim/typer/_types.py @@ -0,0 +1,27 @@ +from enum import Enum +from typing import Generic, TypeVar, Union + +import click + +ParamTypeValue = TypeVar("ParamTypeValue") + + +class TyperChoice(click.Choice, Generic[ParamTypeValue]): # type: ignore[type-arg] + def normalize_choice( + self, choice: ParamTypeValue, ctx: Union[click.Context, None] + ) -> str: + # Click 8.2.0 added a new method `normalize_choice` to the `Choice` class + # to support enums, but it uses the enum names, while Typer has always used the + # enum values. + # This class overrides that method to maintain the previous behavior. + # In Click: + # normed_value = choice.name if isinstance(choice, Enum) else str(choice) + normed_value = str(choice.value) if isinstance(choice, Enum) else str(choice) + + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(normed_value) + + if not self.case_sensitive: + normed_value = normed_value.casefold() + + return normed_value diff --git a/contrib/python/typer-slim/typer/_typing.py b/contrib/python/typer-slim/typer/_typing.py new file mode 100644 index 00000000000..15d0e91b656 --- /dev/null +++ b/contrib/python/typer-slim/typer/_typing.py @@ -0,0 +1,83 @@ +# Copied from pydantic 1.9.2 (the latest version to support python 3.6.) +# https://github.com/pydantic/pydantic/blob/v1.9.2/pydantic/typing.py +# Reduced drastically to only include Typer-specific 3.9+ functionality +# mypy: ignore-errors + +import sys +from typing import ( + Annotated, + Any, + Callable, + Literal, + Optional, + Union, + get_args, + get_origin, + get_type_hints, +) + +if sys.version_info < (3, 10): + + def is_union(tp: Optional[type[Any]]) -> bool: + return tp is Union + +else: + import types + + def is_union(tp: Optional[type[Any]]) -> bool: + return tp is Union or tp is types.UnionType # noqa: E721 + + +__all__ = ( + "NoneType", + "is_none_type", + "is_callable_type", + "is_literal_type", + "all_literal_values", + "is_union", + "Annotated", + "Literal", + "get_args", + "get_origin", + "get_type_hints", +) + + +NoneType = None.__class__ + + +NONE_TYPES: tuple[Any, Any, Any] = (None, NoneType, Literal[None]) + + +def is_none_type(type_: Any) -> bool: + for none_type in NONE_TYPES: + if type_ is none_type: + return True + return False + + +def is_callable_type(type_: type[Any]) -> bool: + return type_ is Callable or get_origin(type_) is Callable + + +def is_literal_type(type_: type[Any]) -> bool: + import typing_extensions + + return get_origin(type_) in (Literal, typing_extensions.Literal) + + +def literal_values(type_: type[Any]) -> tuple[Any, ...]: + return get_args(type_) + + +def all_literal_values(type_: type[Any]) -> tuple[Any, ...]: + """ + This method is used to retrieve all Literal values as + Literal can be used recursively (see https://www.python.org/dev/peps/pep-0586) + e.g. `Literal[Literal[Literal[1, 2, 3], "foo"], 5, None]` + """ + if not is_literal_type(type_): + return (type_,) + + values = literal_values(type_) + return tuple(x for value in values for x in all_literal_values(value)) diff --git a/contrib/python/typer-slim/typer/cli.py b/contrib/python/typer-slim/typer/cli.py new file mode 100644 index 00000000000..ef83bf87a02 --- /dev/null +++ b/contrib/python/typer-slim/typer/cli.py @@ -0,0 +1,317 @@ +import importlib.util +import re +import sys +from pathlib import Path +from typing import Any, Optional + +import click +import typer +import typer.core +from click import Command, Group, Option + +from . import __version__ +from .core import HAS_RICH, MARKUP_MODE_KEY + +default_app_names = ("app", "cli", "main") +default_func_names = ("main", "cli", "app") + +app = typer.Typer() +utils_app = typer.Typer(help="Extra utility commands for Typer apps.") +app.add_typer(utils_app, name="utils") + + +class State: + def __init__(self) -> None: + self.app: Optional[str] = None + self.func: Optional[str] = None + self.file: Optional[Path] = None + self.module: Optional[str] = None + + +state = State() + + +def maybe_update_state(ctx: click.Context) -> None: + path_or_module = ctx.params.get("path_or_module") + if path_or_module: + file_path = Path(path_or_module) + if file_path.exists() and file_path.is_file(): + state.file = file_path + else: + if not re.fullmatch(r"[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*", path_or_module): + typer.echo( + f"Not a valid file or Python module: {path_or_module}", err=True + ) + sys.exit(1) + state.module = path_or_module + app_name = ctx.params.get("app") + if app_name: + state.app = app_name + func_name = ctx.params.get("func") + if func_name: + state.func = func_name + + +class TyperCLIGroup(typer.core.TyperGroup): + def list_commands(self, ctx: click.Context) -> list[str]: + self.maybe_add_run(ctx) + return super().list_commands(ctx) + + def get_command(self, ctx: click.Context, name: str) -> Optional[Command]: + self.maybe_add_run(ctx) + return super().get_command(ctx, name) + + def invoke(self, ctx: click.Context) -> Any: + self.maybe_add_run(ctx) + return super().invoke(ctx) + + def maybe_add_run(self, ctx: click.Context) -> None: + maybe_update_state(ctx) + maybe_add_run_to_cli(self) + + +def get_typer_from_module(module: Any) -> Optional[typer.Typer]: + # Try to get defined app + if state.app: + obj = getattr(module, state.app, None) + if not isinstance(obj, typer.Typer): + typer.echo(f"Not a Typer object: --app {state.app}", err=True) + sys.exit(1) + return obj + # Try to get defined function + if state.func: + func_obj = getattr(module, state.func, None) + if not callable(func_obj): + typer.echo(f"Not a function: --func {state.func}", err=True) + sys.exit(1) + sub_app = typer.Typer() + sub_app.command()(func_obj) + return sub_app + # Iterate and get a default object to use as CLI + local_names = dir(module) + local_names_set = set(local_names) + # Try to get a default Typer app + for name in default_app_names: + if name in local_names_set: + obj = getattr(module, name, None) + if isinstance(obj, typer.Typer): + return obj + # Try to get any Typer app + for name in local_names_set - set(default_app_names): + obj = getattr(module, name) + if isinstance(obj, typer.Typer): + return obj + # Try to get a default function + for func_name in default_func_names: + func_obj = getattr(module, func_name, None) + if callable(func_obj): + sub_app = typer.Typer() + sub_app.command()(func_obj) + return sub_app + # Try to get any func app + for func_name in local_names_set - set(default_func_names): + func_obj = getattr(module, func_name) + if callable(func_obj): + sub_app = typer.Typer() + sub_app.command()(func_obj) + return sub_app + return None + + +def get_typer_from_state() -> Optional[typer.Typer]: + spec = None + if state.file: + module_name = state.file.name + spec = importlib.util.spec_from_file_location(module_name, str(state.file)) + elif state.module: + spec = importlib.util.find_spec(state.module) + if spec is None: + if state.file: + typer.echo(f"Could not import as Python file: {state.file}", err=True) + else: + typer.echo(f"Could not import as Python module: {state.module}", err=True) + sys.exit(1) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore + obj = get_typer_from_module(module) + return obj + + +def maybe_add_run_to_cli(cli: click.Group) -> None: + if "run" not in cli.commands: + if state.file or state.module: + obj = get_typer_from_state() + if obj: + obj._add_completion = False + click_obj = typer.main.get_command(obj) + click_obj.name = "run" + if not click_obj.help: + click_obj.help = "Run the provided Typer app." + cli.add_command(click_obj) + + +def print_version(ctx: click.Context, param: Option, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + typer.echo(f"Typer version: {__version__}") + raise typer.Exit() + + [email protected](cls=TyperCLIGroup, no_args_is_help=True) +def callback( + ctx: typer.Context, + *, + path_or_module: str = typer.Argument(None), + app: str = typer.Option(None, help="The typer app object/variable to use."), + func: str = typer.Option(None, help="The function to convert to Typer."), + version: bool = typer.Option( + False, + "--version", + help="Print version and exit.", + callback=print_version, + ), +) -> None: + """ + Run Typer scripts with completion, without having to create a package. + + You probably want to install completion for the typer command: + + $ typer --install-completion + + https://typer.tiangolo.com/ + """ + maybe_update_state(ctx) + + +def get_docs_for_click( + *, + obj: Command, + ctx: typer.Context, + indent: int = 0, + name: str = "", + call_prefix: str = "", + title: Optional[str] = None, +) -> str: + docs = "#" * (1 + indent) + command_name = name or obj.name + if call_prefix: + command_name = f"{call_prefix} {command_name}" + if not title: + title = f"`{command_name}`" if command_name else "CLI" + docs += f" {title}\n\n" + rich_markup_mode = None + if hasattr(ctx, "obj") and isinstance(ctx.obj, dict): + rich_markup_mode = ctx.obj.get(MARKUP_MODE_KEY, None) + to_parse: bool = bool(HAS_RICH and (rich_markup_mode == "rich")) + if obj.help: + docs += f"{_parse_html(to_parse, obj.help)}\n\n" + usage_pieces = obj.collect_usage_pieces(ctx) + if usage_pieces: + docs += "**Usage**:\n\n" + docs += "```console\n" + docs += "$ " + if command_name: + docs += f"{command_name} " + docs += f"{' '.join(usage_pieces)}\n" + docs += "```\n\n" + args = [] + opts = [] + for param in obj.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + if param.param_type_name == "argument": + args.append(rv) + elif param.param_type_name == "option": + opts.append(rv) + if args: + docs += "**Arguments**:\n\n" + for arg_name, arg_help in args: + docs += f"* `{arg_name}`" + if arg_help: + docs += f": {_parse_html(to_parse, arg_help)}" + docs += "\n" + docs += "\n" + if opts: + docs += "**Options**:\n\n" + for opt_name, opt_help in opts: + docs += f"* `{opt_name}`" + if opt_help: + docs += f": {_parse_html(to_parse, opt_help)}" + docs += "\n" + docs += "\n" + if obj.epilog: + docs += f"{obj.epilog}\n\n" + if isinstance(obj, Group): + group = obj + commands = group.list_commands(ctx) + if commands: + docs += "**Commands**:\n\n" + for command in commands: + command_obj = group.get_command(ctx, command) + assert command_obj + docs += f"* `{command_obj.name}`" + command_help = command_obj.get_short_help_str() + if command_help: + docs += f": {_parse_html(to_parse, command_help)}" + docs += "\n" + docs += "\n" + for command in commands: + command_obj = group.get_command(ctx, command) + assert command_obj + use_prefix = "" + if command_name: + use_prefix += f"{command_name}" + docs += get_docs_for_click( + obj=command_obj, ctx=ctx, indent=indent + 1, call_prefix=use_prefix + ) + return docs + + +def _parse_html(to_parse: bool, input_text: str) -> str: + if not to_parse: + return input_text + from . import rich_utils + + return rich_utils.rich_to_html(input_text) + + +@utils_app.command() +def docs( + ctx: typer.Context, + name: str = typer.Option("", help="The name of the CLI program to use in docs."), + output: Optional[Path] = typer.Option( + None, + help="An output file to write docs to, like README.md.", + file_okay=True, + dir_okay=False, + ), + title: Optional[str] = typer.Option( + None, + help="The title for the documentation page. If not provided, the name of " + "the program is used.", + ), +) -> None: + """ + Generate Markdown docs for a Typer app. + """ + typer_obj = get_typer_from_state() + if not typer_obj: + typer.echo("No Typer app found", err=True) + raise typer.Abort() + if hasattr(typer_obj, "rich_markup_mode"): + if not hasattr(ctx, "obj") or ctx.obj is None: + ctx.ensure_object(dict) + if isinstance(ctx.obj, dict): + ctx.obj[MARKUP_MODE_KEY] = typer_obj.rich_markup_mode + click_obj = typer.main.get_command(typer_obj) + docs = get_docs_for_click(obj=click_obj, ctx=ctx, name=name, title=title) + clean_docs = f"{docs.strip()}\n" + if output: + output.write_text(clean_docs) + typer.echo(f"Docs saved to: {output}") + else: + typer.echo(clean_docs) + + +def main() -> Any: + return app() diff --git a/contrib/python/typer-slim/typer/colors.py b/contrib/python/typer-slim/typer/colors.py new file mode 100644 index 00000000000..54e7b166cb1 --- /dev/null +++ b/contrib/python/typer-slim/typer/colors.py @@ -0,0 +1,20 @@ +# Variable names to colors, just for completion +BLACK = "black" +RED = "red" +GREEN = "green" +YELLOW = "yellow" +BLUE = "blue" +MAGENTA = "magenta" +CYAN = "cyan" +WHITE = "white" + +RESET = "reset" + +BRIGHT_BLACK = "bright_black" +BRIGHT_RED = "bright_red" +BRIGHT_GREEN = "bright_green" +BRIGHT_YELLOW = "bright_yellow" +BRIGHT_BLUE = "bright_blue" +BRIGHT_MAGENTA = "bright_magenta" +BRIGHT_CYAN = "bright_cyan" +BRIGHT_WHITE = "bright_white" diff --git a/contrib/python/typer-slim/typer/completion.py b/contrib/python/typer-slim/typer/completion.py new file mode 100644 index 00000000000..db87f83e3f3 --- /dev/null +++ b/contrib/python/typer-slim/typer/completion.py @@ -0,0 +1,147 @@ +import os +import sys +from collections.abc import MutableMapping +from typing import Any + +import click + +from ._completion_classes import completion_init +from ._completion_shared import Shells, _get_shell_name, get_completion_script, install +from .core import HAS_SHELLINGHAM +from .models import ParamMeta +from .params import Option +from .utils import get_params_from_function + +_click_patched = False + + +def get_completion_inspect_parameters() -> tuple[ParamMeta, ParamMeta]: + completion_init() + test_disable_detection = os.getenv("_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION") + if HAS_SHELLINGHAM and not test_disable_detection: + parameters = get_params_from_function(_install_completion_placeholder_function) + else: + parameters = get_params_from_function( + _install_completion_no_auto_placeholder_function + ) + install_param, show_param = parameters.values() + return install_param, show_param + + +def install_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any: + if not value or ctx.resilient_parsing: + return value # pragma: no cover + if isinstance(value, str): + shell, path = install(shell=value) + else: + shell, path = install() + click.secho(f"{shell} completion installed in {path}", fg="green") + click.echo("Completion will take effect once you restart the terminal") + sys.exit(0) + + +def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any: + if not value or ctx.resilient_parsing: + return value # pragma: no cover + prog_name = ctx.find_root().info_name + assert prog_name + complete_var = "_{}_COMPLETE".format(prog_name.replace("-", "_").upper()) + shell = "" + test_disable_detection = os.getenv("_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION") + if isinstance(value, str): + shell = value + elif not test_disable_detection: + detected_shell = _get_shell_name() + if detected_shell is not None: + shell = detected_shell + script_content = get_completion_script( + prog_name=prog_name, complete_var=complete_var, shell=shell + ) + click.echo(script_content) + sys.exit(0) + + +# Create a fake command function to extract the completion parameters +def _install_completion_placeholder_function( + install_completion: bool = Option( + None, + "--install-completion", + callback=install_callback, + expose_value=False, + help="Install completion for the current shell.", + ), + show_completion: bool = Option( + None, + "--show-completion", + callback=show_callback, + expose_value=False, + help="Show completion for the current shell, to copy it or customize the installation.", + ), +) -> Any: + pass # pragma: no cover + + +def _install_completion_no_auto_placeholder_function( + install_completion: Shells = Option( + None, + callback=install_callback, + expose_value=False, + help="Install completion for the specified shell.", + ), + show_completion: Shells = Option( + None, + callback=show_callback, + expose_value=False, + help="Show completion for the specified shell, to copy it or customize the installation.", + ), +) -> Any: + pass # pragma: no cover + + +# Re-implement Click's shell_complete to add error message with: +# Invalid completion instruction +# To use 7.x instruction style for compatibility +# And to add extra error messages, for compatibility with Typer in previous versions +# This is only called in new Command method, only used by Click 8.x+ +def shell_complete( + cli: click.Command, + ctx_args: MutableMapping[str, Any], + prog_name: str, + complete_var: str, + instruction: str, +) -> int: + import click + import click.shell_completion + + if "_" not in instruction: + click.echo("Invalid completion instruction.", err=True) + return 1 + + # Click 8 changed the order/style of shell instructions from e.g. + # source_bash to bash_source + # Typer override to preserve the old style for compatibility + # Original in Click 8.x commented: + # shell, _, instruction = instruction.partition("_") + instruction, _, shell = instruction.partition("_") + # Typer override end + + comp_cls = click.shell_completion.get_completion_class(shell) + + if comp_cls is None: + click.echo(f"Shell {shell} not supported.", err=True) + return 1 + + comp = comp_cls(cli, ctx_args, prog_name, complete_var) + + if instruction == "source": + click.echo(comp.source()) + return 0 + + # Typer override to print the completion help msg with Rich + if instruction == "complete": + click.echo(comp.complete()) + return 0 + # Typer override end + + click.echo(f'Completion instruction "{instruction}" not supported.', err=True) + return 1 diff --git a/contrib/python/typer-slim/typer/core.py b/contrib/python/typer-slim/typer/core.py new file mode 100644 index 00000000000..d0d888ccf0d --- /dev/null +++ b/contrib/python/typer-slim/typer/core.py @@ -0,0 +1,840 @@ +import errno +import importlib.util +import inspect +import os +import sys +from collections.abc import MutableMapping, Sequence +from difflib import get_close_matches +from enum import Enum +from gettext import gettext as _ +from typing import ( + Any, + Callable, + Optional, + TextIO, + Union, + cast, +) + +import click +import click.core +import click.formatting +import click.shell_completion +import click.types +import click.utils + +from ._typing import Literal + +MarkupMode = Literal["markdown", "rich", None] +MARKUP_MODE_KEY = "TYPER_RICH_MARKUP_MODE" + +HAS_RICH = importlib.util.find_spec("rich") is not None +HAS_SHELLINGHAM = importlib.util.find_spec("shellingham") is not None + +if HAS_RICH: + DEFAULT_MARKUP_MODE: MarkupMode = "rich" +else: # pragma: no cover + DEFAULT_MARKUP_MODE = None + + +# Copy from click.parser._split_opt +def _split_opt(opt: str) -> tuple[str, str]: + first = opt[:1] + if first.isalnum(): + return "", opt + if opt[1:2] == first: + return opt[:2], opt[2:] + return first, opt[1:] + + +def _typer_param_setup_autocompletion_compat( + self: click.Parameter, + *, + autocompletion: Optional[ + Callable[[click.Context, list[str], str], list[Union[tuple[str, str], str]]] + ] = None, +) -> None: + if self._custom_shell_complete is not None: + import warnings + + warnings.warn( + "In Typer, only the parameter 'autocompletion' is supported. " + "The support for 'shell_complete' is deprecated and will be removed in upcoming versions. ", + DeprecationWarning, + stacklevel=2, + ) + + if autocompletion is not None: + + def compat_autocompletion( + ctx: click.Context, param: click.core.Parameter, incomplete: str + ) -> list["click.shell_completion.CompletionItem"]: + from click.shell_completion import CompletionItem + + out = [] + + for c in autocompletion(ctx, [], incomplete): + if isinstance(c, tuple): + use_completion = CompletionItem(c[0], help=c[1]) + else: + assert isinstance(c, str) + use_completion = CompletionItem(c) + + if use_completion.value.startswith(incomplete): + out.append(use_completion) + + return out + + self._custom_shell_complete = compat_autocompletion + + +def _get_default_string( + obj: Union["TyperArgument", "TyperOption"], + *, + ctx: click.Context, + show_default_is_str: bool, + default_value: Union[list[Any], tuple[Any, ...], str, Callable[..., Any], Any], +) -> str: + # Extracted from click.core.Option.get_help_record() to be reused by + # rich_utils avoiding RegEx hacks + if show_default_is_str: + default_string = f"({obj.show_default})" + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join( + _get_default_string( + obj, ctx=ctx, show_default_is_str=show_default_is_str, default_value=d + ) + for d in default_value + ) + elif isinstance(default_value, Enum): + default_string = str(default_value.value) + elif inspect.isfunction(default_value): + default_string = _("(dynamic)") + elif isinstance(obj, TyperOption) and obj.is_bool_flag and obj.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + # Typer override, original commented + # default_string = click.parser.split_opt( + # (self.opts if self.default else self.secondary_opts)[0] + # )[1] + if obj.default: + if obj.opts: + default_string = _split_opt(obj.opts[0])[1] + else: + default_string = str(default_value) + else: + default_string = _split_opt(obj.secondary_opts[0])[1] + # Typer override end + elif ( + isinstance(obj, TyperOption) + and obj.is_bool_flag + and not obj.secondary_opts + and not default_value + ): + default_string = "" + else: + default_string = str(default_value) + return default_string + + +def _extract_default_help_str( + obj: Union["TyperArgument", "TyperOption"], *, ctx: click.Context +) -> Optional[Union[Any, Callable[[], Any]]]: + # Extracted from click.core.Option.get_help_record() to be reused by + # rich_utils avoiding RegEx hacks + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = obj.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + return default_value + + +def _main( + self: click.Command, + *, + args: Optional[Sequence[str]] = None, + prog_name: Optional[str] = None, + complete_var: Optional[str] = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, + **extra: Any, +) -> Any: + # Typer override, duplicated from click.main() to handle custom rich exceptions + # Verify that the environment is configured correctly, or reject + # further execution to avoid a broken script. + if args is None: + args = sys.argv[1:] + + # Covered in Click tests + if os.name == "nt" and windows_expand_args: # pragma: no cover + args = click.utils._expand_args(args) + else: + args = list(args) + + if prog_name is None: + prog_name = click.utils._detect_program_name() + + # Process shell completion requests and exit early. + self._main_shell_completion(extra, prog_name, complete_var) + + try: + try: + with self.make_context(prog_name, args, **extra) as ctx: + rv = self.invoke(ctx) + if not standalone_mode: + return rv + # it's not safe to `ctx.exit(rv)` here! + # note that `rv` may actually contain data like "1" which + # has obvious effects + # more subtle case: `rv=[None, None]` can come out of + # chained commands which all returned `None` -- so it's not + # even always obvious that `rv` indicates success/failure + # by its truthiness/falsiness + ctx.exit() + except EOFError as e: + click.echo(file=sys.stderr) + raise click.Abort() from e + except KeyboardInterrupt as e: + raise click.exceptions.Exit(130) from e + except click.ClickException as e: + if not standalone_mode: + raise + # Typer override + if HAS_RICH and rich_markup_mode is not None: + from . import rich_utils + + rich_utils.rich_format_error(e) + else: + e.show() + # Typer override end + sys.exit(e.exit_code) + except OSError as e: + if e.errno == errno.EPIPE: + sys.stdout = cast(TextIO, click.utils.PacifyFlushWrapper(sys.stdout)) + sys.stderr = cast(TextIO, click.utils.PacifyFlushWrapper(sys.stderr)) + sys.exit(1) + else: + raise + except click.exceptions.Exit as e: + if standalone_mode: + sys.exit(e.exit_code) + else: + # in non-standalone mode, return the exit code + # note that this is only reached if `self.invoke` above raises + # an Exit explicitly -- thus bypassing the check there which + # would return its result + # the results of non-standalone execution may therefore be + # somewhat ambiguous: if there are codepaths which lead to + # `ctx.exit(1)` and to `return 1`, the caller won't be able to + # tell the difference between the two + return e.exit_code + except click.Abort: + if not standalone_mode: + raise + # Typer override + if HAS_RICH and rich_markup_mode is not None: + from . import rich_utils + + rich_utils.rich_abort_error() + else: + click.echo(_("Aborted!"), file=sys.stderr) + # Typer override end + sys.exit(1) + + +class TyperArgument(click.core.Argument): + def __init__( + self, + *, + # Parameter + param_decls: list[str], + type: Optional[Any] = None, + required: Optional[bool] = None, + default: Optional[Any] = None, + callback: Optional[Callable[..., Any]] = None, + nargs: Optional[int] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + # TyperArgument + show_default: Union[bool, str] = True, + show_choices: bool = True, + show_envvar: bool = True, + help: Optional[str] = None, + hidden: bool = False, + # Rich settings + rich_help_panel: Union[str, None] = None, + ): + self.help = help + self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar + self.hidden = hidden + self.rich_help_panel = rich_help_panel + + super().__init__( + param_decls=param_decls, + type=type, + required=required, + default=default, + callback=callback, + nargs=nargs, + metavar=metavar, + expose_value=expose_value, + is_eager=is_eager, + envvar=envvar, + shell_complete=shell_complete, + ) + _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) + + def _get_default_string( + self, + *, + ctx: click.Context, + show_default_is_str: bool, + default_value: Union[list[Any], tuple[Any, ...], str, Callable[..., Any], Any], + ) -> str: + return _get_default_string( + self, + ctx=ctx, + show_default_is_str=show_default_is_str, + default_value=default_value, + ) + + def _extract_default_help_str( + self, *, ctx: click.Context + ) -> Optional[Union[Any, Callable[[], Any]]]: + return _extract_default_help_str(self, ctx=ctx) + + def get_help_record(self, ctx: click.Context) -> Optional[tuple[str, str]]: + # Modified version of click.core.Option.get_help_record() + # to support Arguments + if self.hidden: + return None + name = self.make_metavar(ctx=ctx) + help = self.help or "" + extra = [] + if self.show_envvar: + envvar = self.envvar + # allow_from_autoenv is currently not supported in Typer for CLI Arguments + if envvar is not None: + var_str = ( + ", ".join(str(d) for d in envvar) + if isinstance(envvar, (list, tuple)) + else envvar + ) + extra.append(f"env var: {var_str}") + + # Typer override: + # Extracted to _extract_default_help_str() to allow re-using it in rich_utils + default_value = self._extract_default_help_str(ctx=ctx) + # Typer override end + + show_default_is_str = isinstance(self.show_default, str) + + if show_default_is_str or ( + default_value is not None and (self.show_default or ctx.show_default) + ): + # Typer override: + # Extracted to _get_default_string() to allow re-using it in rich_utils + default_string = self._get_default_string( + ctx=ctx, + show_default_is_str=show_default_is_str, + default_value=default_value, + ) + # Typer override end + if default_string: + extra.append(_("default: {default}").format(default=default_string)) + if self.required: + extra.append(_("required")) + if extra: + extra_str = "; ".join(extra) + extra_str = f"[{extra_str}]" + rich_markup_mode = None + if hasattr(ctx, "obj") and isinstance(ctx.obj, dict): + rich_markup_mode = ctx.obj.get(MARKUP_MODE_KEY, None) + if HAS_RICH and rich_markup_mode == "rich": + # This is needed for when we want to export to HTML + from . import rich_utils + + extra_str = rich_utils.escape_before_html_export(extra_str) + + help = f"{help} {extra_str}" if help else f"{extra_str}" + return name, help + + def make_metavar(self, ctx: Union[click.Context, None] = None) -> str: + # Modified version of click.core.Argument.make_metavar() + # to include Argument name + if self.metavar is not None: + var = self.metavar + if not self.required and not var.startswith("["): + var = f"[{var}]" + return var + var = (self.name or "").upper() + if not self.required: + var = f"[{var}]" + # TODO: When deprecating Click < 8.2, remove this + signature = inspect.signature(self.type.get_metavar) + if "ctx" in signature.parameters: + # Click >= 8.2 + type_var = self.type.get_metavar(self, ctx=ctx) # type: ignore[arg-type] + else: + # Click < 8.2 + type_var = self.type.get_metavar(self) # type: ignore[call-arg] + # TODO: /When deprecating Click < 8.2, remove this, uncomment the line below + # type_var = self.type.get_metavar(self, ctx=ctx) + if type_var: + var += f":{type_var}" + if self.nargs != 1: + var += "..." + return var + + def value_is_missing(self, value: Any) -> bool: + return _value_is_missing(self, value) + + +class TyperOption(click.core.Option): + def __init__( + self, + *, + # Parameter + param_decls: list[str], + type: Optional[Union[click.types.ParamType, Any]] = None, + required: Optional[bool] = None, + default: Optional[Any] = None, + callback: Optional[Callable[..., Any]] = None, + nargs: Optional[int] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + # Option + show_default: Union[bool, str] = False, + prompt: Union[bool, str] = False, + confirmation_prompt: Union[bool, str] = False, + prompt_required: bool = True, + hide_input: bool = False, + is_flag: Optional[bool] = None, + multiple: bool = False, + count: bool = False, + allow_from_autoenv: bool = True, + help: Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = False, + # Rich settings + rich_help_panel: Union[str, None] = None, + ): + super().__init__( + param_decls=param_decls, + type=type, + required=required, + default=default, + callback=callback, + nargs=nargs, + metavar=metavar, + expose_value=expose_value, + is_eager=is_eager, + envvar=envvar, + show_default=show_default, + prompt=prompt, + confirmation_prompt=confirmation_prompt, + hide_input=hide_input, + is_flag=is_flag, + multiple=multiple, + count=count, + allow_from_autoenv=allow_from_autoenv, + help=help, + hidden=hidden, + show_choices=show_choices, + show_envvar=show_envvar, + prompt_required=prompt_required, + shell_complete=shell_complete, + ) + _typer_param_setup_autocompletion_compat(self, autocompletion=autocompletion) + self.rich_help_panel = rich_help_panel + + def _get_default_string( + self, + *, + ctx: click.Context, + show_default_is_str: bool, + default_value: Union[list[Any], tuple[Any, ...], str, Callable[..., Any], Any], + ) -> str: + return _get_default_string( + self, + ctx=ctx, + show_default_is_str=show_default_is_str, + default_value=default_value, + ) + + def _extract_default_help_str( + self, *, ctx: click.Context + ) -> Optional[Union[Any, Callable[[], Any]]]: + return _extract_default_help_str(self, ctx=ctx) + + def make_metavar(self, ctx: Union[click.Context, None] = None) -> str: + signature = inspect.signature(super().make_metavar) + if "ctx" in signature.parameters: + # Click >= 8.2 + return super().make_metavar(ctx=ctx) # type: ignore[arg-type] + # Click < 8.2 + return super().make_metavar() # type: ignore[call-arg] + + def get_help_record(self, ctx: click.Context) -> Optional[tuple[str, str]]: + # Duplicate all of Click's logic only to modify a single line, to allow boolean + # flags with only names for False values as it's currently supported by Typer + # Ref: https://typer.tiangolo.com/tutorial/parameter-types/bool/#only-names-for-false + if self.hidden: + return None + + any_prefix_is_slash = False + + def _write_opts(opts: Sequence[str]) -> str: + nonlocal any_prefix_is_slash + + rv, any_slashes = click.formatting.join_options(opts) + + if any_slashes: + any_prefix_is_slash = True + + if not self.is_flag and not self.count: + rv += f" {self.make_metavar(ctx=ctx)}" + + return rv + + rv = [_write_opts(self.opts)] + + if self.secondary_opts: + rv.append(_write_opts(self.secondary_opts)) + + help = self.help or "" + extra = [] + + if self.show_envvar: + envvar = self.envvar + + if envvar is None: + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + + if envvar is not None: + var_str = ( + envvar + if isinstance(envvar, str) + else ", ".join(str(d) for d in envvar) + ) + extra.append(_("env var: {var}").format(var=var_str)) + + # Typer override: + # Extracted to _extract_default() to allow re-using it in rich_utils + default_value = self._extract_default_help_str(ctx=ctx) + # Typer override end + + show_default_is_str = isinstance(self.show_default, str) + + if show_default_is_str or ( + default_value is not None and (self.show_default or ctx.show_default) + ): + # Typer override: + # Extracted to _get_default_string() to allow re-using it in rich_utils + default_string = self._get_default_string( + ctx=ctx, + show_default_is_str=show_default_is_str, + default_value=default_value, + ) + # Typer override end + if default_string: + extra.append(_("default: {default}").format(default=default_string)) + + if isinstance(self.type, click.types._NumberRangeBase): + range_str = self.type._describe_range() + + if range_str: + extra.append(range_str) + + if self.required: + extra.append(_("required")) + + if extra: + extra_str = "; ".join(extra) + extra_str = f"[{extra_str}]" + rich_markup_mode = None + if hasattr(ctx, "obj") and isinstance(ctx.obj, dict): + rich_markup_mode = ctx.obj.get(MARKUP_MODE_KEY, None) + if HAS_RICH and rich_markup_mode == "rich": + # This is needed for when we want to export to HTML + from . import rich_utils + + extra_str = rich_utils.escape_before_html_export(extra_str) + + help = f"{help} {extra_str}" if help else f"{extra_str}" + + return ("; " if any_prefix_is_slash else " / ").join(rv), help + + def value_is_missing(self, value: Any) -> bool: + return _value_is_missing(self, value) + + +def _value_is_missing(param: click.Parameter, value: Any) -> bool: + if value is None: + return True + + # Click 8.3 and beyond + # if value is UNSET: + # return True + + if (param.nargs != 1 or param.multiple) and value == (): + return True # pragma: no cover + + return False + + +def _typer_format_options( + self: click.core.Command, *, ctx: click.Context, formatter: click.HelpFormatter +) -> None: + args = [] + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + if param.param_type_name == "argument": + args.append(rv) + elif param.param_type_name == "option": + opts.append(rv) + + if args: + with formatter.section(_("Arguments")): + formatter.write_dl(args) + if opts: + with formatter.section(_("Options")): + formatter.write_dl(opts) + + +def _typer_main_shell_completion( + self: click.core.Command, + *, + ctx_args: MutableMapping[str, Any], + prog_name: str, + complete_var: Optional[str] = None, +) -> None: + if complete_var is None: + complete_var = f"_{prog_name}_COMPLETE".replace("-", "_").upper() + + instruction = os.environ.get(complete_var) + + if not instruction: + return + + from .completion import shell_complete + + rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) + sys.exit(rv) + + +class TyperCommand(click.core.Command): + def __init__( + self, + name: Optional[str], + *, + context_settings: Optional[dict[str, Any]] = None, + callback: Optional[Callable[..., Any]] = None, + params: Optional[list[click.Parameter]] = None, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: Optional[str] = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + # Rich settings + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, + rich_help_panel: Union[str, None] = None, + ) -> None: + super().__init__( + name=name, + context_settings=context_settings, + callback=callback, + params=params, + help=help, + epilog=epilog, + short_help=short_help, + options_metavar=options_metavar, + add_help_option=add_help_option, + no_args_is_help=no_args_is_help, + hidden=hidden, + deprecated=deprecated, + ) + self.rich_markup_mode: MarkupMode = rich_markup_mode + self.rich_help_panel = rich_help_panel + + def format_options( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + _typer_format_options(self, ctx=ctx, formatter=formatter) + + def _main_shell_completion( + self, + ctx_args: MutableMapping[str, Any], + prog_name: str, + complete_var: Optional[str] = None, + ) -> None: + _typer_main_shell_completion( + self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var + ) + + def main( + self, + args: Optional[Sequence[str]] = None, + prog_name: Optional[str] = None, + complete_var: Optional[str] = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + **extra: Any, + ) -> Any: + return _main( + self, + args=args, + prog_name=prog_name, + complete_var=complete_var, + standalone_mode=standalone_mode, + windows_expand_args=windows_expand_args, + rich_markup_mode=self.rich_markup_mode, + **extra, + ) + + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + if not HAS_RICH or self.rich_markup_mode is None: + if not hasattr(ctx, "obj") or ctx.obj is None: + ctx.ensure_object(dict) + if isinstance(ctx.obj, dict): + ctx.obj[MARKUP_MODE_KEY] = self.rich_markup_mode + return super().format_help(ctx, formatter) + from . import rich_utils + + return rich_utils.rich_format_help( + obj=self, + ctx=ctx, + markup_mode=self.rich_markup_mode, + ) + + +class TyperGroup(click.core.Group): + def __init__( + self, + *, + name: Optional[str] = None, + commands: Optional[ + Union[dict[str, click.Command], Sequence[click.Command]] + ] = None, + # Rich settings + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, + rich_help_panel: Union[str, None] = None, + suggest_commands: bool = True, + **attrs: Any, + ) -> None: + super().__init__(name=name, commands=commands, **attrs) + self.rich_markup_mode: MarkupMode = rich_markup_mode + self.rich_help_panel = rich_help_panel + self.suggest_commands = suggest_commands + + def format_options( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + _typer_format_options(self, ctx=ctx, formatter=formatter) + self.format_commands(ctx, formatter) + + def _main_shell_completion( + self, + ctx_args: MutableMapping[str, Any], + prog_name: str, + complete_var: Optional[str] = None, + ) -> None: + _typer_main_shell_completion( + self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var + ) + + def resolve_command( + self, ctx: click.Context, args: list[str] + ) -> tuple[Optional[str], Optional[click.Command], list[str]]: + try: + return super().resolve_command(ctx, args) + except click.UsageError as e: + if self.suggest_commands: + available_commands = list(self.commands.keys()) + if available_commands and args: + typo = args[0] + matches = get_close_matches(typo, available_commands) + if matches: + suggestions = ", ".join(f"{m!r}" for m in matches) + message = e.message.rstrip(".") + e.message = f"{message}. Did you mean {suggestions}?" + raise + + def main( + self, + args: Optional[Sequence[str]] = None, + prog_name: Optional[str] = None, + complete_var: Optional[str] = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + **extra: Any, + ) -> Any: + return _main( + self, + args=args, + prog_name=prog_name, + complete_var=complete_var, + standalone_mode=standalone_mode, + windows_expand_args=windows_expand_args, + rich_markup_mode=self.rich_markup_mode, + **extra, + ) + + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + if not HAS_RICH or self.rich_markup_mode is None: + return super().format_help(ctx, formatter) + from . import rich_utils + + return rich_utils.rich_format_help( + obj=self, + ctx=ctx, + markup_mode=self.rich_markup_mode, + ) + + def list_commands(self, ctx: click.Context) -> list[str]: + """Returns a list of subcommand names. + Note that in Click's Group class, these are sorted. + In Typer, we wish to maintain the original order of creation (cf Issue #933)""" + return [n for n, c in self.commands.items()] diff --git a/contrib/python/typer-slim/typer/main.py b/contrib/python/typer-slim/typer/main.py new file mode 100644 index 00000000000..e8c6b9e4297 --- /dev/null +++ b/contrib/python/typer-slim/typer/main.py @@ -0,0 +1,1157 @@ +import inspect +import os +import platform +import shutil +import subprocess +import sys +import traceback +from collections.abc import Sequence +from datetime import datetime +from enum import Enum +from functools import update_wrapper +from pathlib import Path +from traceback import FrameSummary, StackSummary +from types import TracebackType +from typing import Any, Callable, Optional, Union +from uuid import UUID + +import click +from typer._types import TyperChoice + +from ._typing import get_args, get_origin, is_literal_type, is_union, literal_values +from .completion import get_completion_inspect_parameters +from .core import ( + DEFAULT_MARKUP_MODE, + HAS_RICH, + MarkupMode, + TyperArgument, + TyperCommand, + TyperGroup, + TyperOption, +) +from .models import ( + AnyType, + ArgumentInfo, + CommandFunctionType, + CommandInfo, + Default, + DefaultPlaceholder, + DeveloperExceptionConfig, + FileBinaryRead, + FileBinaryWrite, + FileText, + FileTextWrite, + NoneType, + OptionInfo, + ParameterInfo, + ParamMeta, + Required, + TyperInfo, + TyperPath, +) +from .utils import get_params_from_function + +_original_except_hook = sys.excepthook +_typer_developer_exception_attr_name = "__typer_developer_exception__" + + +def except_hook( + exc_type: type[BaseException], exc_value: BaseException, tb: Optional[TracebackType] +) -> None: + exception_config: Union[DeveloperExceptionConfig, None] = getattr( + exc_value, _typer_developer_exception_attr_name, None + ) + standard_traceback = os.getenv( + "TYPER_STANDARD_TRACEBACK", os.getenv("_TYPER_STANDARD_TRACEBACK") + ) + if ( + standard_traceback + or not exception_config + or not exception_config.pretty_exceptions_enable + ): + _original_except_hook(exc_type, exc_value, tb) + return + typer_path = os.path.dirname(__file__) + click_path = os.path.dirname(click.__file__) + internal_dir_names = [typer_path, click_path] + exc = exc_value + if HAS_RICH: + from . import rich_utils + + rich_tb = rich_utils.get_traceback(exc, exception_config, internal_dir_names) + console_stderr = rich_utils._get_rich_console(stderr=True) + console_stderr.print(rich_tb) + return + tb_exc = traceback.TracebackException.from_exception(exc) + stack: list[FrameSummary] = [] + for frame in tb_exc.stack: + if any(frame.filename.startswith(path) for path in internal_dir_names): + if not exception_config.pretty_exceptions_short: + # Hide the line for internal libraries, Typer and Click + stack.append( + traceback.FrameSummary( + filename=frame.filename, + lineno=frame.lineno, + name=frame.name, + line="", + ) + ) + else: + stack.append(frame) + # Type ignore ref: https://github.com/python/typeshed/pull/8244 + final_stack_summary = StackSummary.from_list(stack) + tb_exc.stack = final_stack_summary + for line in tb_exc.format(): + print(line, file=sys.stderr) + return + + +def get_install_completion_arguments() -> tuple[click.Parameter, click.Parameter]: + install_param, show_param = get_completion_inspect_parameters() + click_install_param, _ = get_click_param(install_param) + click_show_param, _ = get_click_param(show_param) + return click_install_param, click_show_param + + +class Typer: + def __init__( + self, + *, + name: Optional[str] = Default(None), + cls: Optional[type[TyperGroup]] = Default(None), + invoke_without_command: bool = Default(False), + no_args_is_help: bool = Default(False), + subcommand_metavar: Optional[str] = Default(None), + chain: bool = Default(False), + result_callback: Optional[Callable[..., Any]] = Default(None), + # Command + context_settings: Optional[dict[Any, Any]] = Default(None), + callback: Optional[Callable[..., Any]] = Default(None), + help: Optional[str] = Default(None), + epilog: Optional[str] = Default(None), + short_help: Optional[str] = Default(None), + options_metavar: str = Default("[OPTIONS]"), + add_help_option: bool = Default(True), + hidden: bool = Default(False), + deprecated: bool = Default(False), + add_completion: bool = True, + # Rich settings + rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, + rich_help_panel: Union[str, None] = Default(None), + suggest_commands: bool = True, + pretty_exceptions_enable: bool = True, + pretty_exceptions_show_locals: bool = True, + pretty_exceptions_short: bool = True, + ): + self._add_completion = add_completion + self.rich_markup_mode: MarkupMode = rich_markup_mode + self.rich_help_panel = rich_help_panel + self.suggest_commands = suggest_commands + self.pretty_exceptions_enable = pretty_exceptions_enable + self.pretty_exceptions_show_locals = pretty_exceptions_show_locals + self.pretty_exceptions_short = pretty_exceptions_short + self.info = TyperInfo( + name=name, + cls=cls, + invoke_without_command=invoke_without_command, + no_args_is_help=no_args_is_help, + subcommand_metavar=subcommand_metavar, + chain=chain, + result_callback=result_callback, + context_settings=context_settings, + callback=callback, + help=help, + epilog=epilog, + short_help=short_help, + options_metavar=options_metavar, + add_help_option=add_help_option, + hidden=hidden, + deprecated=deprecated, + ) + self.registered_groups: list[TyperInfo] = [] + self.registered_commands: list[CommandInfo] = [] + self.registered_callback: Optional[TyperInfo] = None + + def callback( + self, + *, + cls: Optional[type[TyperGroup]] = Default(None), + invoke_without_command: bool = Default(False), + no_args_is_help: bool = Default(False), + subcommand_metavar: Optional[str] = Default(None), + chain: bool = Default(False), + result_callback: Optional[Callable[..., Any]] = Default(None), + # Command + context_settings: Optional[dict[Any, Any]] = Default(None), + help: Optional[str] = Default(None), + epilog: Optional[str] = Default(None), + short_help: Optional[str] = Default(None), + options_metavar: Optional[str] = Default(None), + add_help_option: bool = Default(True), + hidden: bool = Default(False), + deprecated: bool = Default(False), + # Rich settings + rich_help_panel: Union[str, None] = Default(None), + ) -> Callable[[CommandFunctionType], CommandFunctionType]: + def decorator(f: CommandFunctionType) -> CommandFunctionType: + self.registered_callback = TyperInfo( + cls=cls, + invoke_without_command=invoke_without_command, + no_args_is_help=no_args_is_help, + subcommand_metavar=subcommand_metavar, + chain=chain, + result_callback=result_callback, + context_settings=context_settings, + callback=f, + help=help, + epilog=epilog, + short_help=short_help, + options_metavar=( + options_metavar or self._info_val_str("options_metavar") + ), + add_help_option=add_help_option, + hidden=hidden, + deprecated=deprecated, + rich_help_panel=rich_help_panel, + ) + return f + + return decorator + + def command( + self, + name: Optional[str] = None, + *, + cls: Optional[type[TyperCommand]] = None, + context_settings: Optional[dict[Any, Any]] = None, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: Optional[str] = None, + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + # Rich settings + rich_help_panel: Union[str, None] = Default(None), + ) -> Callable[[CommandFunctionType], CommandFunctionType]: + if cls is None: + cls = TyperCommand + + def decorator(f: CommandFunctionType) -> CommandFunctionType: + self.registered_commands.append( + CommandInfo( + name=name, + cls=cls, + context_settings=context_settings, + callback=f, + help=help, + epilog=epilog, + short_help=short_help, + options_metavar=( + options_metavar or self._info_val_str("options_metavar") + ), + add_help_option=add_help_option, + no_args_is_help=no_args_is_help, + hidden=hidden, + deprecated=deprecated, + # Rich settings + rich_help_panel=rich_help_panel, + ) + ) + return f + + return decorator + + def add_typer( + self, + typer_instance: "Typer", + *, + name: Optional[str] = Default(None), + cls: Optional[type[TyperGroup]] = Default(None), + invoke_without_command: bool = Default(False), + no_args_is_help: bool = Default(False), + subcommand_metavar: Optional[str] = Default(None), + chain: bool = Default(False), + result_callback: Optional[Callable[..., Any]] = Default(None), + # Command + context_settings: Optional[dict[Any, Any]] = Default(None), + callback: Optional[Callable[..., Any]] = Default(None), + help: Optional[str] = Default(None), + epilog: Optional[str] = Default(None), + short_help: Optional[str] = Default(None), + options_metavar: Optional[str] = Default(None), + add_help_option: bool = Default(True), + hidden: bool = Default(False), + deprecated: bool = Default(False), + # Rich settings + rich_help_panel: Union[str, None] = Default(None), + ) -> None: + self.registered_groups.append( + TyperInfo( + typer_instance, + name=name, + cls=cls, + invoke_without_command=invoke_without_command, + no_args_is_help=no_args_is_help, + subcommand_metavar=subcommand_metavar, + chain=chain, + result_callback=result_callback, + context_settings=context_settings, + callback=callback, + help=help, + epilog=epilog, + short_help=short_help, + options_metavar=( + options_metavar or self._info_val_str("options_metavar") + ), + add_help_option=add_help_option, + hidden=hidden, + deprecated=deprecated, + rich_help_panel=rich_help_panel, + ) + ) + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + if sys.excepthook != except_hook: + sys.excepthook = except_hook + try: + return get_command(self)(*args, **kwargs) + except Exception as e: + # Set a custom attribute to tell the hook to show nice exceptions for user + # code. An alternative/first implementation was a custom exception with + # raise custom_exc from e + # but that means the last error shown is the custom exception, not the + # actual error. This trick improves developer experience by showing the + # actual error last. + setattr( + e, + _typer_developer_exception_attr_name, + DeveloperExceptionConfig( + pretty_exceptions_enable=self.pretty_exceptions_enable, + pretty_exceptions_show_locals=self.pretty_exceptions_show_locals, + pretty_exceptions_short=self.pretty_exceptions_short, + ), + ) + raise e + + def _info_val_str(self, name: str) -> str: + val = getattr(self.info, name) + val_str = val.value if isinstance(val, DefaultPlaceholder) else val + assert isinstance(val_str, str) + return val_str + + +def get_group(typer_instance: Typer) -> TyperGroup: + group = get_group_from_info( + TyperInfo(typer_instance), + pretty_exceptions_short=typer_instance.pretty_exceptions_short, + rich_markup_mode=typer_instance.rich_markup_mode, + suggest_commands=typer_instance.suggest_commands, + ) + return group + + +def get_command(typer_instance: Typer) -> click.Command: + if typer_instance._add_completion: + click_install_param, click_show_param = get_install_completion_arguments() + if ( + typer_instance.registered_callback + or typer_instance.info.callback + or typer_instance.registered_groups + or len(typer_instance.registered_commands) > 1 + ): + # Create a Group + click_command: click.Command = get_group(typer_instance) + if typer_instance._add_completion: + click_command.params.append(click_install_param) + click_command.params.append(click_show_param) + return click_command + elif len(typer_instance.registered_commands) == 1: + # Create a single Command + single_command = typer_instance.registered_commands[0] + + if not single_command.context_settings and not isinstance( + typer_instance.info.context_settings, DefaultPlaceholder + ): + single_command.context_settings = typer_instance.info.context_settings + + click_command = get_command_from_info( + single_command, + pretty_exceptions_short=typer_instance.pretty_exceptions_short, + rich_markup_mode=typer_instance.rich_markup_mode, + ) + if typer_instance._add_completion: + click_command.params.append(click_install_param) + click_command.params.append(click_show_param) + return click_command + raise RuntimeError( + "Could not get a command for this Typer instance" + ) # pragma: no cover + + +def solve_typer_info_help(typer_info: TyperInfo) -> str: + # Priority 1: Explicit value was set in app.add_typer() + if not isinstance(typer_info.help, DefaultPlaceholder): + return inspect.cleandoc(typer_info.help or "") + # Priority 2: Explicit value was set in sub_app.callback() + try: + callback_help = typer_info.typer_instance.registered_callback.help + if not isinstance(callback_help, DefaultPlaceholder): + return inspect.cleandoc(callback_help or "") + except AttributeError: + pass + # Priority 3: Explicit value was set in sub_app = typer.Typer() + try: + instance_help = typer_info.typer_instance.info.help + if not isinstance(instance_help, DefaultPlaceholder): + return inspect.cleandoc(instance_help or "") + except AttributeError: + pass + # Priority 4: Implicit inference from callback docstring in app.add_typer() + if typer_info.callback: + doc = inspect.getdoc(typer_info.callback) + if doc: + return doc + # Priority 5: Implicit inference from callback docstring in @app.callback() + try: + callback = typer_info.typer_instance.registered_callback.callback + if not isinstance(callback, DefaultPlaceholder): + doc = inspect.getdoc(callback or "") + if doc: + return doc + except AttributeError: + pass + # Priority 6: Implicit inference from callback docstring in typer.Typer() + try: + instance_callback = typer_info.typer_instance.info.callback + if not isinstance(instance_callback, DefaultPlaceholder): + doc = inspect.getdoc(instance_callback) + if doc: + return doc + except AttributeError: + pass + # Value not set, use the default + return typer_info.help.value + + +def solve_typer_info_defaults(typer_info: TyperInfo) -> TyperInfo: + values: dict[str, Any] = {} + for name, value in typer_info.__dict__.items(): + # Priority 1: Value was set in app.add_typer() + if not isinstance(value, DefaultPlaceholder): + values[name] = value + continue + # Priority 2: Value was set in @subapp.callback() + try: + callback_value = getattr( + typer_info.typer_instance.registered_callback, # type: ignore + name, + ) + if not isinstance(callback_value, DefaultPlaceholder): + values[name] = callback_value + continue + except AttributeError: + pass + # Priority 3: Value set in subapp = typer.Typer() + try: + instance_value = getattr( + typer_info.typer_instance.info, # type: ignore + name, + ) + if not isinstance(instance_value, DefaultPlaceholder): + values[name] = instance_value + continue + except AttributeError: + pass + # Value not set, use the default + values[name] = value.value + values["help"] = solve_typer_info_help(typer_info) + return TyperInfo(**values) + + +def get_group_from_info( + group_info: TyperInfo, + *, + pretty_exceptions_short: bool, + suggest_commands: bool, + rich_markup_mode: MarkupMode, +) -> TyperGroup: + assert group_info.typer_instance, ( + "A Typer instance is needed to generate a Click Group" + ) + commands: dict[str, click.Command] = {} + for command_info in group_info.typer_instance.registered_commands: + command = get_command_from_info( + command_info=command_info, + pretty_exceptions_short=pretty_exceptions_short, + rich_markup_mode=rich_markup_mode, + ) + if command.name: + commands[command.name] = command + for sub_group_info in group_info.typer_instance.registered_groups: + sub_group = get_group_from_info( + sub_group_info, + pretty_exceptions_short=pretty_exceptions_short, + rich_markup_mode=rich_markup_mode, + suggest_commands=suggest_commands, + ) + if sub_group.name: + commands[sub_group.name] = sub_group + else: + if sub_group.callback: + import warnings + + warnings.warn( + "The 'callback' parameter is not supported by Typer when using `add_typer` without a name", + stacklevel=5, + ) + for sub_command_name, sub_command in sub_group.commands.items(): + commands[sub_command_name] = sub_command + solved_info = solve_typer_info_defaults(group_info) + ( + params, + convertors, + context_param_name, + ) = get_params_convertors_ctx_param_name_from_function(solved_info.callback) + cls = solved_info.cls or TyperGroup + assert issubclass(cls, TyperGroup), f"{cls} should be a subclass of {TyperGroup}" + group = cls( + name=solved_info.name or "", + commands=commands, + invoke_without_command=solved_info.invoke_without_command, + no_args_is_help=solved_info.no_args_is_help, + subcommand_metavar=solved_info.subcommand_metavar, + chain=solved_info.chain, + result_callback=solved_info.result_callback, + context_settings=solved_info.context_settings, + callback=get_callback( + callback=solved_info.callback, + params=params, + convertors=convertors, + context_param_name=context_param_name, + pretty_exceptions_short=pretty_exceptions_short, + ), + params=params, + help=solved_info.help, + epilog=solved_info.epilog, + short_help=solved_info.short_help, + options_metavar=solved_info.options_metavar, + add_help_option=solved_info.add_help_option, + hidden=solved_info.hidden, + deprecated=solved_info.deprecated, + rich_markup_mode=rich_markup_mode, + # Rich settings + rich_help_panel=solved_info.rich_help_panel, + suggest_commands=suggest_commands, + ) + return group + + +def get_command_name(name: str) -> str: + return name.lower().replace("_", "-") + + +def get_params_convertors_ctx_param_name_from_function( + callback: Optional[Callable[..., Any]], +) -> tuple[list[Union[click.Argument, click.Option]], dict[str, Any], Optional[str]]: + params = [] + convertors = {} + context_param_name = None + if callback: + parameters = get_params_from_function(callback) + for param_name, param in parameters.items(): + if lenient_issubclass(param.annotation, click.Context): + context_param_name = param_name + continue + click_param, convertor = get_click_param(param) + if convertor: + convertors[param_name] = convertor + params.append(click_param) + return params, convertors, context_param_name + + +def get_command_from_info( + command_info: CommandInfo, + *, + pretty_exceptions_short: bool, + rich_markup_mode: MarkupMode, +) -> click.Command: + assert command_info.callback, "A command must have a callback function" + name = command_info.name or get_command_name(command_info.callback.__name__) + use_help = command_info.help + if use_help is None: + use_help = inspect.getdoc(command_info.callback) + else: + use_help = inspect.cleandoc(use_help) + ( + params, + convertors, + context_param_name, + ) = get_params_convertors_ctx_param_name_from_function(command_info.callback) + cls = command_info.cls or TyperCommand + command = cls( + name=name, + context_settings=command_info.context_settings, + callback=get_callback( + callback=command_info.callback, + params=params, + convertors=convertors, + context_param_name=context_param_name, + pretty_exceptions_short=pretty_exceptions_short, + ), + params=params, # type: ignore + help=use_help, + epilog=command_info.epilog, + short_help=command_info.short_help, + options_metavar=command_info.options_metavar, + add_help_option=command_info.add_help_option, + no_args_is_help=command_info.no_args_is_help, + hidden=command_info.hidden, + deprecated=command_info.deprecated, + rich_markup_mode=rich_markup_mode, + # Rich settings + rich_help_panel=command_info.rich_help_panel, + ) + return command + + +def determine_type_convertor(type_: Any) -> Optional[Callable[[Any], Any]]: + convertor: Optional[Callable[[Any], Any]] = None + if lenient_issubclass(type_, Path): + convertor = param_path_convertor + if lenient_issubclass(type_, Enum): + convertor = generate_enum_convertor(type_) + return convertor + + +def param_path_convertor(value: Optional[str] = None) -> Optional[Path]: + if value is not None: + # allow returning any subclass of Path created by an annotated parser without converting + # it back to a Path + return value if isinstance(value, Path) else Path(value) + return None + + +def generate_enum_convertor(enum: type[Enum]) -> Callable[[Any], Any]: + val_map = {str(val.value): val for val in enum} + + def convertor(value: Any) -> Any: + if value is not None: + val = str(value) + if val in val_map: + key = val_map[val] + return enum(key) + + return convertor + + +def generate_list_convertor( + convertor: Optional[Callable[[Any], Any]], default_value: Optional[Any] +) -> Callable[[Optional[Sequence[Any]]], Optional[list[Any]]]: + def internal_convertor(value: Optional[Sequence[Any]]) -> Optional[list[Any]]: + if (value is None) or (default_value is None and len(value) == 0): + return None + return [convertor(v) if convertor else v for v in value] + + return internal_convertor + + +def generate_tuple_convertor( + types: Sequence[Any], +) -> Callable[[Optional[tuple[Any, ...]]], Optional[tuple[Any, ...]]]: + convertors = [determine_type_convertor(type_) for type_ in types] + + def internal_convertor( + param_args: Optional[tuple[Any, ...]], + ) -> Optional[tuple[Any, ...]]: + if param_args is None: + return None + return tuple( + convertor(arg) if convertor else arg + for (convertor, arg) in zip(convertors, param_args) + ) + + return internal_convertor + + +def get_callback( + *, + callback: Optional[Callable[..., Any]] = None, + params: Sequence[click.Parameter] = [], + convertors: Optional[dict[str, Callable[[str], Any]]] = None, + context_param_name: Optional[str] = None, + pretty_exceptions_short: bool, +) -> Optional[Callable[..., Any]]: + use_convertors = convertors or {} + if not callback: + return None + parameters = get_params_from_function(callback) + use_params: dict[str, Any] = {} + for param_name in parameters: + use_params[param_name] = None + for param in params: + if param.name: + use_params[param.name] = param.default + + def wrapper(**kwargs: Any) -> Any: + _rich_traceback_guard = pretty_exceptions_short # noqa: F841 + for k, v in kwargs.items(): + if k in use_convertors: + use_params[k] = use_convertors[k](v) + else: + use_params[k] = v + if context_param_name: + use_params[context_param_name] = click.get_current_context() + return callback(**use_params) + + update_wrapper(wrapper, callback) + return wrapper + + +def get_click_type( + *, annotation: Any, parameter_info: ParameterInfo +) -> click.ParamType: + if parameter_info.click_type is not None: + return parameter_info.click_type + + elif parameter_info.parser is not None: + return click.types.FuncParamType(parameter_info.parser) + + elif annotation is str: + return click.STRING + elif annotation is int: + if parameter_info.min is not None or parameter_info.max is not None: + min_ = None + max_ = None + if parameter_info.min is not None: + min_ = int(parameter_info.min) + if parameter_info.max is not None: + max_ = int(parameter_info.max) + return click.IntRange(min=min_, max=max_, clamp=parameter_info.clamp) + else: + return click.INT + elif annotation is float: + if parameter_info.min is not None or parameter_info.max is not None: + return click.FloatRange( + min=parameter_info.min, + max=parameter_info.max, + clamp=parameter_info.clamp, + ) + else: + return click.FLOAT + elif annotation is bool: + return click.BOOL + elif annotation == UUID: + return click.UUID + elif annotation == datetime: + return click.DateTime(formats=parameter_info.formats) + elif ( + annotation == Path + or parameter_info.allow_dash + or parameter_info.path_type + or parameter_info.resolve_path + ): + return TyperPath( + exists=parameter_info.exists, + file_okay=parameter_info.file_okay, + dir_okay=parameter_info.dir_okay, + writable=parameter_info.writable, + readable=parameter_info.readable, + resolve_path=parameter_info.resolve_path, + allow_dash=parameter_info.allow_dash, + path_type=parameter_info.path_type, + ) + elif lenient_issubclass(annotation, FileTextWrite): + return click.File( + mode=parameter_info.mode or "w", + encoding=parameter_info.encoding, + errors=parameter_info.errors, + lazy=parameter_info.lazy, + atomic=parameter_info.atomic, + ) + elif lenient_issubclass(annotation, FileText): + return click.File( + mode=parameter_info.mode or "r", + encoding=parameter_info.encoding, + errors=parameter_info.errors, + lazy=parameter_info.lazy, + atomic=parameter_info.atomic, + ) + elif lenient_issubclass(annotation, FileBinaryRead): + return click.File( + mode=parameter_info.mode or "rb", + encoding=parameter_info.encoding, + errors=parameter_info.errors, + lazy=parameter_info.lazy, + atomic=parameter_info.atomic, + ) + elif lenient_issubclass(annotation, FileBinaryWrite): + return click.File( + mode=parameter_info.mode or "wb", + encoding=parameter_info.encoding, + errors=parameter_info.errors, + lazy=parameter_info.lazy, + atomic=parameter_info.atomic, + ) + elif lenient_issubclass(annotation, Enum): + # The custom TyperChoice is only needed for Click < 8.2.0, to parse the + # command line values matching them to the enum values. Click 8.2.0 added + # support for enum values but reading enum names. + # Passing here the list of enum values (instead of just the enum) accounts for + # Click < 8.2.0. + return TyperChoice( + [item.value for item in annotation], + case_sensitive=parameter_info.case_sensitive, + ) + elif is_literal_type(annotation): + return click.Choice( + literal_values(annotation), + case_sensitive=parameter_info.case_sensitive, + ) + raise RuntimeError(f"Type not yet supported: {annotation}") # pragma: no cover + + +def lenient_issubclass( + cls: Any, class_or_tuple: Union[AnyType, tuple[AnyType, ...]] +) -> bool: + return isinstance(cls, type) and issubclass(cls, class_or_tuple) + + +def get_click_param( + param: ParamMeta, +) -> tuple[Union[click.Argument, click.Option], Any]: + # First, find out what will be: + # * ParamInfo (ArgumentInfo or OptionInfo) + # * default_value + # * required + default_value = None + required = False + if isinstance(param.default, ParameterInfo): + parameter_info = param.default + if parameter_info.default == Required: + required = True + else: + default_value = parameter_info.default + elif param.default == Required or param.default is param.empty: + required = True + parameter_info = ArgumentInfo() + else: + default_value = param.default + parameter_info = OptionInfo() + annotation: Any + if param.annotation is not param.empty: + annotation = param.annotation + else: + annotation = str + main_type = annotation + is_list = False + is_tuple = False + parameter_type: Any = None + is_flag = None + origin = get_origin(main_type) + + if origin is not None: + # Handle SomeType | None and Optional[SomeType] + if is_union(origin): + types = [] + for type_ in get_args(main_type): + if type_ is NoneType: + continue + types.append(type_) + assert len(types) == 1, "Typer Currently doesn't support Union types" + main_type = types[0] + origin = get_origin(main_type) + # Handle Tuples and Lists + if lenient_issubclass(origin, list): + main_type = get_args(main_type)[0] + assert not get_origin(main_type), ( + "List types with complex sub-types are not currently supported" + ) + is_list = True + elif lenient_issubclass(origin, tuple): + types = [] + for type_ in get_args(main_type): + assert not get_origin(type_), ( + "Tuple types with complex sub-types are not currently supported" + ) + types.append( + get_click_type(annotation=type_, parameter_info=parameter_info) + ) + parameter_type = tuple(types) + is_tuple = True + if parameter_type is None: + parameter_type = get_click_type( + annotation=main_type, parameter_info=parameter_info + ) + convertor = determine_type_convertor(main_type) + if is_list: + convertor = generate_list_convertor( + convertor=convertor, default_value=default_value + ) + if is_tuple: + convertor = generate_tuple_convertor(get_args(main_type)) + if isinstance(parameter_info, OptionInfo): + if main_type is bool: + is_flag = True + # Click doesn't accept a flag of type bool, only None, and then it sets it + # to bool internally + parameter_type = None + default_option_name = get_command_name(param.name) + if is_flag: + default_option_declaration = ( + f"--{default_option_name}/--no-{default_option_name}" + ) + else: + default_option_declaration = f"--{default_option_name}" + param_decls = [param.name] + if parameter_info.param_decls: + param_decls.extend(parameter_info.param_decls) + else: + param_decls.append(default_option_declaration) + return ( + TyperOption( + # Option + param_decls=param_decls, + show_default=parameter_info.show_default, + prompt=parameter_info.prompt, + confirmation_prompt=parameter_info.confirmation_prompt, + prompt_required=parameter_info.prompt_required, + hide_input=parameter_info.hide_input, + is_flag=is_flag, + multiple=is_list, + count=parameter_info.count, + allow_from_autoenv=parameter_info.allow_from_autoenv, + type=parameter_type, + help=parameter_info.help, + hidden=parameter_info.hidden, + show_choices=parameter_info.show_choices, + show_envvar=parameter_info.show_envvar, + # Parameter + required=required, + default=default_value, + callback=get_param_callback( + callback=parameter_info.callback, convertor=convertor + ), + metavar=parameter_info.metavar, + expose_value=parameter_info.expose_value, + is_eager=parameter_info.is_eager, + envvar=parameter_info.envvar, + shell_complete=parameter_info.shell_complete, + autocompletion=get_param_completion(parameter_info.autocompletion), + # Rich settings + rich_help_panel=parameter_info.rich_help_panel, + ), + convertor, + ) + elif isinstance(parameter_info, ArgumentInfo): + param_decls = [param.name] + nargs = None + if is_list: + nargs = -1 + return ( + TyperArgument( + # Argument + param_decls=param_decls, + type=parameter_type, + required=required, + nargs=nargs, + # TyperArgument + show_default=parameter_info.show_default, + show_choices=parameter_info.show_choices, + show_envvar=parameter_info.show_envvar, + help=parameter_info.help, + hidden=parameter_info.hidden, + # Parameter + default=default_value, + callback=get_param_callback( + callback=parameter_info.callback, convertor=convertor + ), + metavar=parameter_info.metavar, + expose_value=parameter_info.expose_value, + is_eager=parameter_info.is_eager, + envvar=parameter_info.envvar, + shell_complete=parameter_info.shell_complete, + autocompletion=get_param_completion(parameter_info.autocompletion), + # Rich settings + rich_help_panel=parameter_info.rich_help_panel, + ), + convertor, + ) + raise AssertionError("A click.Parameter should be returned") # pragma: no cover + + +def get_param_callback( + *, + callback: Optional[Callable[..., Any]] = None, + convertor: Optional[Callable[..., Any]] = None, +) -> Optional[Callable[..., Any]]: + if not callback: + return None + parameters = get_params_from_function(callback) + ctx_name = None + click_param_name = None + value_name = None + untyped_names: list[str] = [] + for param_name, param_sig in parameters.items(): + if lenient_issubclass(param_sig.annotation, click.Context): + ctx_name = param_name + elif lenient_issubclass(param_sig.annotation, click.Parameter): + click_param_name = param_name + else: + untyped_names.append(param_name) + # Extract value param name first + if untyped_names: + value_name = untyped_names.pop() + # If context and Click param were not typed (old/Click callback style) extract them + if untyped_names: + if ctx_name is None: + ctx_name = untyped_names.pop(0) + if click_param_name is None: + if untyped_names: + click_param_name = untyped_names.pop(0) + if untyped_names: + raise click.ClickException( + "Too many CLI parameter callback function parameters" + ) + + def wrapper(ctx: click.Context, param: click.Parameter, value: Any) -> Any: + use_params: dict[str, Any] = {} + if ctx_name: + use_params[ctx_name] = ctx + if click_param_name: + use_params[click_param_name] = param + if value_name: + if convertor: + use_value = convertor(value) + else: + use_value = value + use_params[value_name] = use_value + return callback(**use_params) + + update_wrapper(wrapper, callback) + return wrapper + + +def get_param_completion( + callback: Optional[Callable[..., Any]] = None, +) -> Optional[Callable[..., Any]]: + if not callback: + return None + parameters = get_params_from_function(callback) + ctx_name = None + args_name = None + incomplete_name = None + unassigned_params = list(parameters.values()) + for param_sig in unassigned_params[:]: + origin = get_origin(param_sig.annotation) + if lenient_issubclass(param_sig.annotation, click.Context): + ctx_name = param_sig.name + unassigned_params.remove(param_sig) + elif lenient_issubclass(origin, list): + args_name = param_sig.name + unassigned_params.remove(param_sig) + elif lenient_issubclass(param_sig.annotation, str): + incomplete_name = param_sig.name + unassigned_params.remove(param_sig) + # If there are still unassigned parameters (not typed), extract by name + for param_sig in unassigned_params[:]: + if ctx_name is None and param_sig.name == "ctx": + ctx_name = param_sig.name + unassigned_params.remove(param_sig) + elif args_name is None and param_sig.name == "args": + args_name = param_sig.name + unassigned_params.remove(param_sig) + elif incomplete_name is None and param_sig.name == "incomplete": + incomplete_name = param_sig.name + unassigned_params.remove(param_sig) + # Extract value param name first + if unassigned_params: + show_params = " ".join([param.name for param in unassigned_params]) + raise click.ClickException( + f"Invalid autocompletion callback parameters: {show_params}" + ) + + def wrapper(ctx: click.Context, args: list[str], incomplete: Optional[str]) -> Any: + use_params: dict[str, Any] = {} + if ctx_name: + use_params[ctx_name] = ctx + if args_name: + use_params[args_name] = args + if incomplete_name: + use_params[incomplete_name] = incomplete + return callback(**use_params) + + update_wrapper(wrapper, callback) + return wrapper + + +def run(function: Callable[..., Any]) -> None: + app = Typer(add_completion=False) + app.command()(function) + app() + + +def _is_macos() -> bool: + return platform.system() == "Darwin" + + +def _is_linux_or_bsd() -> bool: + if platform.system() == "Linux": + return True + + return "BSD" in platform.system() + + +def launch(url: str, wait: bool = False, locate: bool = False) -> int: + """This function launches the given URL (or filename) in the default + viewer application for this file type. If this is an executable, it + might launch the executable in a new session. The return value is + the exit code of the launched application. Usually, ``0`` indicates + success. + + This function handles url in different operating systems separately: + - On macOS (Darwin), it uses the 'open' command. + - On Linux and BSD, it uses 'xdg-open' if available. + - On Windows (and other OSes), it uses the standard webbrowser module. + + The function avoids, when possible, using the webbrowser module on Linux and macOS + to prevent spammy terminal messages from some browsers (e.g., Chrome). + + Examples:: + + typer.launch("https://typer.tiangolo.com/") + typer.launch("/my/downloaded/file", locate=True) + + :param url: URL or filename of the thing to launch. + :param wait: Wait for the program to exit before returning. This + only works if the launched program blocks. In particular, + ``xdg-open`` on Linux does not block. + :param locate: if this is set to `True` then instead of launching the + application associated with the URL it will attempt to + launch a file manager with the file located. This + might have weird effects if the URL does not point to + the filesystem. + """ + + if url.startswith("http://") or url.startswith("https://"): + if _is_macos(): + return subprocess.Popen( + ["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ).wait() + + has_xdg_open = _is_linux_or_bsd() and shutil.which("xdg-open") is not None + + if has_xdg_open: + return subprocess.Popen( + ["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ).wait() + + import webbrowser + + webbrowser.open(url) + + return 0 + + else: + return click.launch(url) diff --git a/contrib/python/typer-slim/typer/models.py b/contrib/python/typer-slim/typer/models.py new file mode 100644 index 00000000000..78d1a5354d5 --- /dev/null +++ b/contrib/python/typer-slim/typer/models.py @@ -0,0 +1,541 @@ +import inspect +import io +from collections.abc import Sequence +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Optional, + TypeVar, + Union, +) + +import click +import click.shell_completion + +if TYPE_CHECKING: # pragma: no cover + from .core import TyperCommand, TyperGroup + from .main import Typer + + +NoneType = type(None) + +AnyType = type[Any] + +Required = ... + + +class Context(click.Context): + pass + + +class FileText(io.TextIOWrapper): + pass + + +class FileTextWrite(FileText): + pass + + +class FileBinaryRead(io.BufferedReader): + pass + + +class FileBinaryWrite(io.BufferedWriter): + pass + + +class CallbackParam(click.Parameter): + pass + + +class DefaultPlaceholder: + """ + You shouldn't use this class directly. + + It's used internally to recognize when a default value has been overwritten, even + if the new value is `None`. + """ + + def __init__(self, value: Any): + self.value = value + + def __bool__(self) -> bool: + return bool(self.value) + + +DefaultType = TypeVar("DefaultType") + +CommandFunctionType = TypeVar("CommandFunctionType", bound=Callable[..., Any]) + + +def Default(value: DefaultType) -> DefaultType: + """ + You shouldn't use this function directly. + + It's used internally to recognize when a default value has been overwritten, even + if the new value is `None`. + """ + return DefaultPlaceholder(value) # type: ignore + + +class CommandInfo: + def __init__( + self, + name: Optional[str] = None, + *, + cls: Optional[type["TyperCommand"]] = None, + context_settings: Optional[dict[Any, Any]] = None, + callback: Optional[Callable[..., Any]] = None, + help: Optional[str] = None, + epilog: Optional[str] = None, + short_help: Optional[str] = None, + options_metavar: str = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool = False, + # Rich settings + rich_help_panel: Union[str, None] = None, + ): + self.name = name + self.cls = cls + self.context_settings = context_settings + self.callback = callback + self.help = help + self.epilog = epilog + self.short_help = short_help + self.options_metavar = options_metavar + self.add_help_option = add_help_option + self.no_args_is_help = no_args_is_help + self.hidden = hidden + self.deprecated = deprecated + # Rich settings + self.rich_help_panel = rich_help_panel + + +class TyperInfo: + def __init__( + self, + typer_instance: Optional["Typer"] = Default(None), + *, + name: Optional[str] = Default(None), + cls: Optional[type["TyperGroup"]] = Default(None), + invoke_without_command: bool = Default(False), + no_args_is_help: bool = Default(False), + subcommand_metavar: Optional[str] = Default(None), + chain: bool = Default(False), + result_callback: Optional[Callable[..., Any]] = Default(None), + # Command + context_settings: Optional[dict[Any, Any]] = Default(None), + callback: Optional[Callable[..., Any]] = Default(None), + help: Optional[str] = Default(None), + epilog: Optional[str] = Default(None), + short_help: Optional[str] = Default(None), + options_metavar: str = Default("[OPTIONS]"), + add_help_option: bool = Default(True), + hidden: bool = Default(False), + deprecated: bool = Default(False), + # Rich settings + rich_help_panel: Union[str, None] = Default(None), + ): + self.typer_instance = typer_instance + self.name = name + self.cls = cls + self.invoke_without_command = invoke_without_command + self.no_args_is_help = no_args_is_help + self.subcommand_metavar = subcommand_metavar + self.chain = chain + self.result_callback = result_callback + self.context_settings = context_settings + self.callback = callback + self.help = help + self.epilog = epilog + self.short_help = short_help + self.options_metavar = options_metavar + self.add_help_option = add_help_option + self.hidden = hidden + self.deprecated = deprecated + self.rich_help_panel = rich_help_panel + + +class ParameterInfo: + def __init__( + self, + *, + default: Optional[Any] = None, + param_decls: Optional[Sequence[str]] = None, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + click_type: Optional[click.ParamType] = None, + # TyperArgument + show_default: Union[bool, str] = True, + show_choices: bool = True, + show_envvar: bool = True, + help: Optional[str] = None, + hidden: bool = False, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, + ): + # Check if user has provided multiple custom parsers + if parser and click_type: + raise ValueError( + "Multiple custom type parsers provided. " + "`parser` and `click_type` may not both be provided." + ) + + self.default = default + self.param_decls = param_decls + self.callback = callback + self.metavar = metavar + self.expose_value = expose_value + self.is_eager = is_eager + self.envvar = envvar + self.shell_complete = shell_complete + self.autocompletion = autocompletion + self.default_factory = default_factory + # Custom type + self.parser = parser + self.click_type = click_type + # TyperArgument + self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar + self.help = help + self.hidden = hidden + # Choice + self.case_sensitive = case_sensitive + # Numbers + self.min = min + self.max = max + self.clamp = clamp + # DateTime + self.formats = formats + # File + self.mode = mode + self.encoding = encoding + self.errors = errors + self.lazy = lazy + self.atomic = atomic + # Path + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.writable = writable + self.readable = readable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.path_type = path_type + # Rich settings + self.rich_help_panel = rich_help_panel + + +class OptionInfo(ParameterInfo): + def __init__( + self, + *, + # ParameterInfo + default: Optional[Any] = None, + param_decls: Optional[Sequence[str]] = None, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + click_type: Optional[click.ParamType] = None, + # Option + show_default: Union[bool, str] = True, + prompt: Union[bool, str] = False, + confirmation_prompt: bool = False, + prompt_required: bool = True, + hide_input: bool = False, + # TODO: remove is_flag and flag_value in a future release + is_flag: Optional[bool] = None, + flag_value: Optional[Any] = None, + count: bool = False, + allow_from_autoenv: bool = True, + help: Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = True, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, + ): + super().__init__( + default=default, + param_decls=param_decls, + callback=callback, + metavar=metavar, + expose_value=expose_value, + is_eager=is_eager, + envvar=envvar, + shell_complete=shell_complete, + autocompletion=autocompletion, + default_factory=default_factory, + # Custom type + parser=parser, + click_type=click_type, + # TyperArgument + show_default=show_default, + show_choices=show_choices, + show_envvar=show_envvar, + help=help, + hidden=hidden, + # Choice + case_sensitive=case_sensitive, + # Numbers + min=min, + max=max, + clamp=clamp, + # DateTime + formats=formats, + # File + mode=mode, + encoding=encoding, + errors=errors, + lazy=lazy, + atomic=atomic, + # Path + exists=exists, + file_okay=file_okay, + dir_okay=dir_okay, + writable=writable, + readable=readable, + resolve_path=resolve_path, + allow_dash=allow_dash, + path_type=path_type, + # Rich settings + rich_help_panel=rich_help_panel, + ) + if is_flag is not None or flag_value is not None: + import warnings + + warnings.warn( + "The 'is_flag' and 'flag_value' parameters are not supported by Typer " + "and will be removed entirely in a future release.", + DeprecationWarning, + stacklevel=2, + ) + self.prompt = prompt + self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required + self.hide_input = hide_input + self.count = count + self.allow_from_autoenv = allow_from_autoenv + + +class ArgumentInfo(ParameterInfo): + def __init__( + self, + *, + # ParameterInfo + default: Optional[Any] = None, + param_decls: Optional[Sequence[str]] = None, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + click_type: Optional[click.ParamType] = None, + # TyperArgument + show_default: Union[bool, str] = True, + show_choices: bool = True, + show_envvar: bool = True, + help: Optional[str] = None, + hidden: bool = False, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, + ): + super().__init__( + default=default, + param_decls=param_decls, + callback=callback, + metavar=metavar, + expose_value=expose_value, + is_eager=is_eager, + envvar=envvar, + shell_complete=shell_complete, + autocompletion=autocompletion, + default_factory=default_factory, + # Custom type + parser=parser, + click_type=click_type, + # TyperArgument + show_default=show_default, + show_choices=show_choices, + show_envvar=show_envvar, + help=help, + hidden=hidden, + # Choice + case_sensitive=case_sensitive, + # Numbers + min=min, + max=max, + clamp=clamp, + # DateTime + formats=formats, + # File + mode=mode, + encoding=encoding, + errors=errors, + lazy=lazy, + atomic=atomic, + # Path + exists=exists, + file_okay=file_okay, + dir_okay=dir_okay, + writable=writable, + readable=readable, + resolve_path=resolve_path, + allow_dash=allow_dash, + path_type=path_type, + # Rich settings + rich_help_panel=rich_help_panel, + ) + + +class ParamMeta: + empty = inspect.Parameter.empty + + def __init__( + self, + *, + name: str, + default: Any = inspect.Parameter.empty, + annotation: Any = inspect.Parameter.empty, + ) -> None: + self.name = name + self.default = default + self.annotation = annotation + + +class DeveloperExceptionConfig: + def __init__( + self, + *, + pretty_exceptions_enable: bool = True, + pretty_exceptions_show_locals: bool = True, + pretty_exceptions_short: bool = True, + ) -> None: + self.pretty_exceptions_enable = pretty_exceptions_enable + self.pretty_exceptions_show_locals = pretty_exceptions_show_locals + self.pretty_exceptions_short = pretty_exceptions_short + + +class TyperPath(click.Path): + # Overwrite Click's behaviour to be compatible with Typer's autocompletion system + def shell_complete( + self, ctx: click.Context, param: click.Parameter, incomplete: str + ) -> list[click.shell_completion.CompletionItem]: + """Return an empty list so that the autocompletion functionality + will work properly from the commandline. + """ + return [] diff --git a/contrib/python/typer-slim/typer/params.py b/contrib/python/typer-slim/typer/params.py new file mode 100644 index 00000000000..2a03c03e71b --- /dev/null +++ b/contrib/python/typer-slim/typer/params.py @@ -0,0 +1,479 @@ +from typing import TYPE_CHECKING, Any, Callable, Optional, Union, overload + +import click + +from .models import ArgumentInfo, OptionInfo + +if TYPE_CHECKING: # pragma: no cover + import click.shell_completion + + +# Overload for Option created with custom type 'parser' +@overload +def Option( + # Parameter + default: Optional[Any] = ..., + *param_decls: str, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + # Option + show_default: Union[bool, str] = True, + prompt: Union[bool, str] = False, + confirmation_prompt: bool = False, + prompt_required: bool = True, + hide_input: bool = False, + # TODO: remove is_flag and flag_value in a future release + is_flag: Optional[bool] = None, + flag_value: Optional[Any] = None, + count: bool = False, + allow_from_autoenv: bool = True, + help: Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = True, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, +) -> Any: ... + + +# Overload for Option created with custom type 'click_type' +@overload +def Option( + # Parameter + default: Optional[Any] = ..., + *param_decls: str, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + click_type: Optional[click.ParamType] = None, + # Option + show_default: Union[bool, str] = True, + prompt: Union[bool, str] = False, + confirmation_prompt: bool = False, + prompt_required: bool = True, + hide_input: bool = False, + # TODO: remove is_flag and flag_value in a future release + is_flag: Optional[bool] = None, + flag_value: Optional[Any] = None, + count: bool = False, + allow_from_autoenv: bool = True, + help: Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = True, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, +) -> Any: ... + + +def Option( + # Parameter + default: Optional[Any] = ..., + *param_decls: str, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + click_type: Optional[click.ParamType] = None, + # Option + show_default: Union[bool, str] = True, + prompt: Union[bool, str] = False, + confirmation_prompt: bool = False, + prompt_required: bool = True, + hide_input: bool = False, + # TODO: remove is_flag and flag_value in a future release + is_flag: Optional[bool] = None, + flag_value: Optional[Any] = None, + count: bool = False, + allow_from_autoenv: bool = True, + help: Optional[str] = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = True, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, +) -> Any: + return OptionInfo( + # Parameter + default=default, + param_decls=param_decls, + callback=callback, + metavar=metavar, + expose_value=expose_value, + is_eager=is_eager, + envvar=envvar, + shell_complete=shell_complete, + autocompletion=autocompletion, + default_factory=default_factory, + # Custom type + parser=parser, + click_type=click_type, + # Option + show_default=show_default, + prompt=prompt, + confirmation_prompt=confirmation_prompt, + prompt_required=prompt_required, + hide_input=hide_input, + is_flag=is_flag, + flag_value=flag_value, + count=count, + allow_from_autoenv=allow_from_autoenv, + help=help, + hidden=hidden, + show_choices=show_choices, + show_envvar=show_envvar, + # Choice + case_sensitive=case_sensitive, + # Numbers + min=min, + max=max, + clamp=clamp, + # DateTime + formats=formats, + # File + mode=mode, + encoding=encoding, + errors=errors, + lazy=lazy, + atomic=atomic, + # Path + exists=exists, + file_okay=file_okay, + dir_okay=dir_okay, + writable=writable, + readable=readable, + resolve_path=resolve_path, + allow_dash=allow_dash, + path_type=path_type, + # Rich settings + rich_help_panel=rich_help_panel, + ) + + +# Overload for Argument created with custom type 'parser' +@overload +def Argument( + # Parameter + default: Optional[Any] = ..., + *, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + # TyperArgument + show_default: Union[bool, str] = True, + show_choices: bool = True, + show_envvar: bool = True, + help: Optional[str] = None, + hidden: bool = False, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, +) -> Any: ... + + +# Overload for Argument created with custom type 'click_type' +@overload +def Argument( + # Parameter + default: Optional[Any] = ..., + *, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + click_type: Optional[click.ParamType] = None, + # TyperArgument + show_default: Union[bool, str] = True, + show_choices: bool = True, + show_envvar: bool = True, + help: Optional[str] = None, + hidden: bool = False, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, +) -> Any: ... + + +def Argument( + # Parameter + default: Optional[Any] = ..., + *, + callback: Optional[Callable[..., Any]] = None, + metavar: Optional[str] = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: Optional[Union[str, list[str]]] = None, + # Note that shell_complete is not fully supported and will be removed in future versions + # TODO: Remove shell_complete in a future version (after 0.16.0) + shell_complete: Optional[ + Callable[ + [click.Context, click.Parameter, str], + Union[list["click.shell_completion.CompletionItem"], list[str]], + ] + ] = None, + autocompletion: Optional[Callable[..., Any]] = None, + default_factory: Optional[Callable[[], Any]] = None, + # Custom type + parser: Optional[Callable[[str], Any]] = None, + click_type: Optional[click.ParamType] = None, + # TyperArgument + show_default: Union[bool, str] = True, + show_choices: bool = True, + show_envvar: bool = True, + help: Optional[str] = None, + hidden: bool = False, + # Choice + case_sensitive: bool = True, + # Numbers + min: Optional[Union[int, float]] = None, + max: Optional[Union[int, float]] = None, + clamp: bool = False, + # DateTime + formats: Optional[list[str]] = None, + # File + mode: Optional[str] = None, + encoding: Optional[str] = None, + errors: Optional[str] = "strict", + lazy: Optional[bool] = None, + atomic: bool = False, + # Path + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: Union[None, type[str], type[bytes]] = None, + # Rich settings + rich_help_panel: Union[str, None] = None, +) -> Any: + return ArgumentInfo( + # Parameter + default=default, + # Arguments can only have one param declaration + # it will be generated from the param name + param_decls=None, + callback=callback, + metavar=metavar, + expose_value=expose_value, + is_eager=is_eager, + envvar=envvar, + shell_complete=shell_complete, + autocompletion=autocompletion, + default_factory=default_factory, + # Custom type + parser=parser, + click_type=click_type, + # TyperArgument + show_default=show_default, + show_choices=show_choices, + show_envvar=show_envvar, + help=help, + hidden=hidden, + # Choice + case_sensitive=case_sensitive, + # Numbers + min=min, + max=max, + clamp=clamp, + # DateTime + formats=formats, + # File + mode=mode, + encoding=encoding, + errors=errors, + lazy=lazy, + atomic=atomic, + # Path + exists=exists, + file_okay=file_okay, + dir_okay=dir_okay, + writable=writable, + readable=readable, + resolve_path=resolve_path, + allow_dash=allow_dash, + path_type=path_type, + # Rich settings + rich_help_panel=rich_help_panel, + ) diff --git a/contrib/python/typer-slim/typer/py.typed b/contrib/python/typer-slim/typer/py.typed new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/contrib/python/typer-slim/typer/py.typed diff --git a/contrib/python/typer-slim/typer/rich_utils.py b/contrib/python/typer-slim/typer/rich_utils.py new file mode 100644 index 00000000000..ad110cb8d69 --- /dev/null +++ b/contrib/python/typer-slim/typer/rich_utils.py @@ -0,0 +1,752 @@ +# Extracted and modified from https://github.com/ewels/rich-click + +import inspect +import io +from collections import defaultdict +from collections.abc import Iterable +from gettext import gettext as _ +from os import getenv +from typing import Any, Literal, Optional, Union + +import click +from rich import box +from rich.align import Align +from rich.columns import Columns +from rich.console import Console, RenderableType, group +from rich.emoji import Emoji +from rich.highlighter import RegexHighlighter +from rich.markdown import Markdown +from rich.markup import escape +from rich.padding import Padding +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.theme import Theme +from rich.traceback import Traceback +from typer.models import DeveloperExceptionConfig + +# Default styles +STYLE_OPTION = "bold cyan" +STYLE_SWITCH = "bold green" +STYLE_NEGATIVE_OPTION = "bold magenta" +STYLE_NEGATIVE_SWITCH = "bold red" +STYLE_METAVAR = "bold yellow" +STYLE_METAVAR_SEPARATOR = "dim" +STYLE_USAGE = "yellow" +STYLE_USAGE_COMMAND = "bold" +STYLE_DEPRECATED = "red" +STYLE_DEPRECATED_COMMAND = "dim" +STYLE_HELPTEXT_FIRST_LINE = "" +STYLE_HELPTEXT = "dim" +STYLE_OPTION_HELP = "" +STYLE_OPTION_DEFAULT = "dim" +STYLE_OPTION_ENVVAR = "dim yellow" +STYLE_REQUIRED_SHORT = "red" +STYLE_REQUIRED_LONG = "dim red" +STYLE_OPTIONS_PANEL_BORDER = "dim" +ALIGN_OPTIONS_PANEL: Literal["left", "center", "right"] = "left" +STYLE_OPTIONS_TABLE_SHOW_LINES = False +STYLE_OPTIONS_TABLE_LEADING = 0 +STYLE_OPTIONS_TABLE_PAD_EDGE = False +STYLE_OPTIONS_TABLE_PADDING = (0, 1) +STYLE_OPTIONS_TABLE_BOX = "" +STYLE_OPTIONS_TABLE_ROW_STYLES = None +STYLE_OPTIONS_TABLE_BORDER_STYLE = None +STYLE_COMMANDS_PANEL_BORDER = "dim" +ALIGN_COMMANDS_PANEL: Literal["left", "center", "right"] = "left" +STYLE_COMMANDS_TABLE_SHOW_LINES = False +STYLE_COMMANDS_TABLE_LEADING = 0 +STYLE_COMMANDS_TABLE_PAD_EDGE = False +STYLE_COMMANDS_TABLE_PADDING = (0, 1) +STYLE_COMMANDS_TABLE_BOX = "" +STYLE_COMMANDS_TABLE_ROW_STYLES = None +STYLE_COMMANDS_TABLE_BORDER_STYLE = None +STYLE_COMMANDS_TABLE_FIRST_COLUMN = "bold cyan" +STYLE_ERRORS_PANEL_BORDER = "red" +ALIGN_ERRORS_PANEL: Literal["left", "center", "right"] = "left" +STYLE_ERRORS_SUGGESTION = "dim" +STYLE_ABORTED = "red" +_TERMINAL_WIDTH = getenv("TERMINAL_WIDTH") +MAX_WIDTH = int(_TERMINAL_WIDTH) if _TERMINAL_WIDTH else None +COLOR_SYSTEM: Optional[Literal["auto", "standard", "256", "truecolor", "windows"]] = ( + "auto" # Set to None to disable colors +) +_TYPER_FORCE_DISABLE_TERMINAL = getenv("_TYPER_FORCE_DISABLE_TERMINAL") +FORCE_TERMINAL = ( + True + if getenv("GITHUB_ACTIONS") or getenv("FORCE_COLOR") or getenv("PY_COLORS") + else None +) +if _TYPER_FORCE_DISABLE_TERMINAL: + FORCE_TERMINAL = False + +# Fixed strings +DEPRECATED_STRING = _("(deprecated) ") +DEFAULT_STRING = _("[default: {}]") +ENVVAR_STRING = _("[env var: {}]") +REQUIRED_SHORT_STRING = "*" +REQUIRED_LONG_STRING = _("[required]") +RANGE_STRING = " [{}]" +ARGUMENTS_PANEL_TITLE = _("Arguments") +OPTIONS_PANEL_TITLE = _("Options") +COMMANDS_PANEL_TITLE = _("Commands") +ERRORS_PANEL_TITLE = _("Error") +ABORTED_TEXT = _("Aborted.") +RICH_HELP = _("Try [blue]'{command_path} {help_option}'[/] for help.") + +MARKUP_MODE_MARKDOWN = "markdown" +MARKUP_MODE_RICH = "rich" +_RICH_HELP_PANEL_NAME = "rich_help_panel" + +MarkupModeStrict = Literal["markdown", "rich"] + + +# Rich regex highlighter +class OptionHighlighter(RegexHighlighter): + """Highlights our special options.""" + + highlights = [ + r"(^|\W)(?P<switch>\-\w+)(?![a-zA-Z0-9])", + r"(^|\W)(?P<option>\-\-[\w\-]+)(?![a-zA-Z0-9])", + r"(?P<metavar>\<[^\>]+\>)", + r"(?P<usage>Usage: )", + ] + + +class NegativeOptionHighlighter(RegexHighlighter): + highlights = [ + r"(^|\W)(?P<negative_switch>\-\w+)(?![a-zA-Z0-9])", + r"(^|\W)(?P<negative_option>\-\-[\w\-]+)(?![a-zA-Z0-9])", + ] + + +highlighter = OptionHighlighter() +negative_highlighter = NegativeOptionHighlighter() + + +def _get_rich_console(stderr: bool = False) -> Console: + return Console( + theme=Theme( + { + "option": STYLE_OPTION, + "switch": STYLE_SWITCH, + "negative_option": STYLE_NEGATIVE_OPTION, + "negative_switch": STYLE_NEGATIVE_SWITCH, + "metavar": STYLE_METAVAR, + "metavar_sep": STYLE_METAVAR_SEPARATOR, + "usage": STYLE_USAGE, + }, + ), + highlighter=highlighter, + color_system=COLOR_SYSTEM, + force_terminal=FORCE_TERMINAL, + width=MAX_WIDTH, + stderr=stderr, + ) + + +def _make_rich_text( + *, text: str, style: str = "", markup_mode: MarkupModeStrict +) -> Union[Markdown, Text]: + """Take a string, remove indentations, and return styled text. + + If `markup_mode` is `"rich"`, the text is parsed for Rich markup strings. + If `markup_mode` is `"markdown"`, parse as Markdown. + """ + # Remove indentations from input text + text = inspect.cleandoc(text) + if markup_mode == MARKUP_MODE_MARKDOWN: + text = Emoji.replace(text) + return Markdown(text, style=style) + else: + assert markup_mode == MARKUP_MODE_RICH + return highlighter(Text.from_markup(text, style=style)) + + +@group() +def _get_help_text( + *, + obj: Union[click.Command, click.Group], + markup_mode: MarkupModeStrict, +) -> Iterable[Union[Markdown, Text]]: + """Build primary help text for a click command or group. + + Returns the prose help text for a command or group, rendered either as a + Rich Text object or as Markdown. + If the command is marked as deprecated, the deprecated string will be prepended. + """ + # Prepend deprecated status + if obj.deprecated: + yield Text(DEPRECATED_STRING, style=STYLE_DEPRECATED) + + # Fetch and dedent the help text + help_text = inspect.cleandoc(obj.help or "") + + # Trim off anything that comes after \f on its own line + help_text = help_text.partition("\f")[0] + + # Get the first paragraph + first_line, *remaining_paragraphs = help_text.split("\n\n") + + # Remove single linebreaks + if markup_mode != MARKUP_MODE_MARKDOWN and not first_line.startswith("\b"): + first_line = first_line.replace("\n", " ") + yield _make_rich_text( + text=first_line.strip(), + style=STYLE_HELPTEXT_FIRST_LINE, + markup_mode=markup_mode, + ) + + # Get remaining lines, remove single line breaks and format as dim + if remaining_paragraphs: + # Add a newline inbetween the header and the remaining paragraphs + yield Text("") + # Join with double linebreaks for markdown and Rich markup + remaining_lines = "\n\n".join(remaining_paragraphs) + + yield _make_rich_text( + text=remaining_lines, + style=STYLE_HELPTEXT, + markup_mode=markup_mode, + ) + + +def _get_parameter_help( + *, + param: Union[click.Option, click.Argument, click.Parameter], + ctx: click.Context, + markup_mode: MarkupModeStrict, +) -> Columns: + """Build primary help text for a click option or argument. + + Returns the prose help text for an option or argument, rendered either + as a Rich Text object or as Markdown. + Additional elements are appended to show the default and required status if + applicable. + """ + # import here to avoid cyclic imports + from .core import TyperArgument, TyperOption + + items: list[Union[Text, Markdown]] = [] + + # Get the environment variable first + + envvar = getattr(param, "envvar", None) + var_str = "" + # https://github.com/pallets/click/blob/0aec1168ac591e159baf6f61026d6ae322c53aaf/src/click/core.py#L2720-L2726 + if envvar is None: + if ( + getattr(param, "allow_from_autoenv", None) + and getattr(ctx, "auto_envvar_prefix", None) is not None + and param.name is not None + ): + envvar = f"{ctx.auto_envvar_prefix}_{param.name.upper()}" + if envvar is not None: + var_str = ( + envvar if isinstance(envvar, str) else ", ".join(str(d) for d in envvar) + ) + + # Main help text + help_value: Union[str, None] = getattr(param, "help", None) + if help_value: + paragraphs = help_value.split("\n\n") + # Remove single linebreaks + if markup_mode != MARKUP_MODE_MARKDOWN: + paragraphs = [ + x.replace("\n", " ").strip() + if not x.startswith("\b") + else "{}\n".format(x.strip("\b\n")) + for x in paragraphs + ] + items.append( + _make_rich_text( + text="\n".join(paragraphs).strip(), + style=STYLE_OPTION_HELP, + markup_mode=markup_mode, + ) + ) + + # Environment variable AFTER help text + if envvar and getattr(param, "show_envvar", None): + items.append(Text(ENVVAR_STRING.format(var_str), style=STYLE_OPTION_ENVVAR)) + + # Default value + # This uses Typer's specific param._get_default_string + if isinstance(param, (TyperOption, TyperArgument)): + default_value = param._extract_default_help_str(ctx=ctx) + show_default_is_str = isinstance(param.show_default, str) + if show_default_is_str or ( + default_value is not None and (param.show_default or ctx.show_default) + ): + default_str = param._get_default_string( + ctx=ctx, + show_default_is_str=show_default_is_str, + default_value=default_value, + ) + if default_str: + items.append( + Text( + DEFAULT_STRING.format(default_str), + style=STYLE_OPTION_DEFAULT, + ) + ) + + # Required? + if param.required: + items.append(Text(REQUIRED_LONG_STRING, style=STYLE_REQUIRED_LONG)) + + # Use Columns - this allows us to group different renderable types + # (Text, Markdown) onto a single line. + return Columns(items) + + +def _make_command_help( + *, + help_text: str, + markup_mode: MarkupModeStrict, +) -> Union[Text, Markdown]: + """Build cli help text for a click group command. + + That is, when calling help on groups with multiple subcommands + (not the main help text when calling the subcommand help). + + Returns the first paragraph of help text for a command, rendered either as a + Rich Text object or as Markdown. + Ignores single newlines as paragraph markers, looks for double only. + """ + paragraphs = inspect.cleandoc(help_text).split("\n\n") + # Remove single linebreaks + if markup_mode != MARKUP_MODE_RICH and not paragraphs[0].startswith("\b"): + paragraphs[0] = paragraphs[0].replace("\n", " ") + elif paragraphs[0].startswith("\b"): + paragraphs[0] = paragraphs[0].replace("\b\n", "") + return _make_rich_text( + text=paragraphs[0].strip(), + style=STYLE_OPTION_HELP, + markup_mode=markup_mode, + ) + + +def _print_options_panel( + *, + name: str, + params: Union[list[click.Option], list[click.Argument]], + ctx: click.Context, + markup_mode: MarkupModeStrict, + console: Console, +) -> None: + options_rows: list[list[RenderableType]] = [] + required_rows: list[Union[str, Text]] = [] + for param in params: + # Short and long form + opt_long_strs = [] + opt_short_strs = [] + secondary_opt_long_strs = [] + secondary_opt_short_strs = [] + for opt_str in param.opts: + if "--" in opt_str: + opt_long_strs.append(opt_str) + else: + opt_short_strs.append(opt_str) + for opt_str in param.secondary_opts: + if "--" in opt_str: + secondary_opt_long_strs.append(opt_str) + else: + secondary_opt_short_strs.append(opt_str) + + # Column for a metavar, if we have one + metavar = Text(style=STYLE_METAVAR, overflow="fold") + # TODO: when deprecating Click < 8.2, make ctx required + signature = inspect.signature(param.make_metavar) + if "ctx" in signature.parameters: + metavar_str = param.make_metavar(ctx=ctx) + else: + # Click < 8.2 + metavar_str = param.make_metavar() # type: ignore[call-arg] + + # Do it ourselves if this is a positional argument + if ( + isinstance(param, click.Argument) + and param.name + and metavar_str == param.name.upper() + ): + metavar_str = param.type.name.upper() + + # Skip booleans and choices (handled above) + if metavar_str != "BOOLEAN": + metavar.append(metavar_str) + + # Range - from + # https://github.com/pallets/click/blob/c63c70dabd3f86ca68678b4f00951f78f52d0270/src/click/core.py#L2698-L2706 # noqa: E501 + # skip count with default range type + if ( + isinstance(param.type, click.types._NumberRangeBase) + and isinstance(param, click.Option) + and not (param.count and param.type.min == 0 and param.type.max is None) + ): + range_str = param.type._describe_range() + if range_str: + metavar.append(RANGE_STRING.format(range_str)) + + # Required asterisk + required: Union[str, Text] = "" + if param.required: + required = Text(REQUIRED_SHORT_STRING, style=STYLE_REQUIRED_SHORT) + + # Highlighter to make [ | ] and <> dim + class MetavarHighlighter(RegexHighlighter): + highlights = [ + r"^(?P<metavar_sep>(\[|<))", + r"(?P<metavar_sep>\|)", + r"(?P<metavar_sep>(\]|>)$)", + ] + + metavar_highlighter = MetavarHighlighter() + + required_rows.append(required) + options_rows.append( + [ + highlighter(",".join(opt_long_strs)), + highlighter(",".join(opt_short_strs)), + negative_highlighter(",".join(secondary_opt_long_strs)), + negative_highlighter(",".join(secondary_opt_short_strs)), + metavar_highlighter(metavar), + _get_parameter_help( + param=param, + ctx=ctx, + markup_mode=markup_mode, + ), + ] + ) + rows_with_required: list[list[RenderableType]] = [] + if any(required_rows): + for required, row in zip(required_rows, options_rows): + rows_with_required.append([required, *row]) + else: + rows_with_required = options_rows + if options_rows: + t_styles: dict[str, Any] = { + "show_lines": STYLE_OPTIONS_TABLE_SHOW_LINES, + "leading": STYLE_OPTIONS_TABLE_LEADING, + "box": STYLE_OPTIONS_TABLE_BOX, + "border_style": STYLE_OPTIONS_TABLE_BORDER_STYLE, + "row_styles": STYLE_OPTIONS_TABLE_ROW_STYLES, + "pad_edge": STYLE_OPTIONS_TABLE_PAD_EDGE, + "padding": STYLE_OPTIONS_TABLE_PADDING, + } + box_style = getattr(box, t_styles.pop("box"), None) + + options_table = Table( + highlight=True, + show_header=False, + expand=True, + box=box_style, + **t_styles, + ) + for row in rows_with_required: + options_table.add_row(*row) + console.print( + Panel( + options_table, + border_style=STYLE_OPTIONS_PANEL_BORDER, + title=name, + title_align=ALIGN_OPTIONS_PANEL, + ) + ) + + +def _print_commands_panel( + *, + name: str, + commands: list[click.Command], + markup_mode: MarkupModeStrict, + console: Console, + cmd_len: int, +) -> None: + t_styles: dict[str, Any] = { + "show_lines": STYLE_COMMANDS_TABLE_SHOW_LINES, + "leading": STYLE_COMMANDS_TABLE_LEADING, + "box": STYLE_COMMANDS_TABLE_BOX, + "border_style": STYLE_COMMANDS_TABLE_BORDER_STYLE, + "row_styles": STYLE_COMMANDS_TABLE_ROW_STYLES, + "pad_edge": STYLE_COMMANDS_TABLE_PAD_EDGE, + "padding": STYLE_COMMANDS_TABLE_PADDING, + } + box_style = getattr(box, t_styles.pop("box"), None) + + commands_table = Table( + highlight=False, + show_header=False, + expand=True, + box=box_style, + **t_styles, + ) + # Define formatting in first column, as commands don't match highlighter + # regex + commands_table.add_column( + style=STYLE_COMMANDS_TABLE_FIRST_COLUMN, + no_wrap=True, + width=cmd_len, + ) + + # A big ratio makes the description column be greedy and take all the space + # available instead of allowing the command column to grow and misalign with + # other panels. + commands_table.add_column("Description", justify="left", no_wrap=False, ratio=10) + rows: list[list[Union[RenderableType, None]]] = [] + deprecated_rows: list[Union[RenderableType, None]] = [] + for command in commands: + helptext = command.short_help or command.help or "" + command_name = command.name or "" + if command.deprecated: + command_name_text = Text(f"{command_name}", style=STYLE_DEPRECATED_COMMAND) + deprecated_rows.append(Text(DEPRECATED_STRING, style=STYLE_DEPRECATED)) + else: + command_name_text = Text(command_name) + deprecated_rows.append(None) + rows.append( + [ + command_name_text, + _make_command_help( + help_text=helptext, + markup_mode=markup_mode, + ), + ] + ) + rows_with_deprecated = rows + if any(deprecated_rows): + rows_with_deprecated = [] + for row, deprecated_text in zip(rows, deprecated_rows): + rows_with_deprecated.append([*row, deprecated_text]) + for row in rows_with_deprecated: + commands_table.add_row(*row) + if commands_table.row_count: + console.print( + Panel( + commands_table, + border_style=STYLE_COMMANDS_PANEL_BORDER, + title=name, + title_align=ALIGN_COMMANDS_PANEL, + ) + ) + + +def rich_format_help( + *, + obj: Union[click.Command, click.Group], + ctx: click.Context, + markup_mode: MarkupModeStrict, +) -> None: + """Print nicely formatted help text using rich. + + Based on original code from rich-cli, by @willmcgugan. + https://github.com/Textualize/rich-cli/blob/8a2767c7a340715fc6fbf4930ace717b9b2fc5e5/src/rich_cli/__main__.py#L162-L236 + + Replacement for the click function format_help(). + Takes a command or group and builds the help text output. + """ + console = _get_rich_console() + + # Print usage + console.print( + Padding(highlighter(obj.get_usage(ctx)), 1), style=STYLE_USAGE_COMMAND + ) + + # Print command / group help if we have some + if obj.help: + # Print with some padding + console.print( + Padding( + Align( + _get_help_text( + obj=obj, + markup_mode=markup_mode, + ), + pad=False, + ), + (0, 1, 1, 1), + ) + ) + panel_to_arguments: defaultdict[str, list[click.Argument]] = defaultdict(list) + panel_to_options: defaultdict[str, list[click.Option]] = defaultdict(list) + for param in obj.get_params(ctx): + # Skip if option is hidden + if getattr(param, "hidden", False): + continue + if isinstance(param, click.Argument): + panel_name = ( + getattr(param, _RICH_HELP_PANEL_NAME, None) or ARGUMENTS_PANEL_TITLE + ) + panel_to_arguments[panel_name].append(param) + elif isinstance(param, click.Option): + panel_name = ( + getattr(param, _RICH_HELP_PANEL_NAME, None) or OPTIONS_PANEL_TITLE + ) + panel_to_options[panel_name].append(param) + default_arguments = panel_to_arguments.get(ARGUMENTS_PANEL_TITLE, []) + _print_options_panel( + name=ARGUMENTS_PANEL_TITLE, + params=default_arguments, + ctx=ctx, + markup_mode=markup_mode, + console=console, + ) + for panel_name, arguments in panel_to_arguments.items(): + if panel_name == ARGUMENTS_PANEL_TITLE: + # Already printed above + continue + _print_options_panel( + name=panel_name, + params=arguments, + ctx=ctx, + markup_mode=markup_mode, + console=console, + ) + default_options = panel_to_options.get(OPTIONS_PANEL_TITLE, []) + _print_options_panel( + name=OPTIONS_PANEL_TITLE, + params=default_options, + ctx=ctx, + markup_mode=markup_mode, + console=console, + ) + for panel_name, options in panel_to_options.items(): + if panel_name == OPTIONS_PANEL_TITLE: + # Already printed above + continue + _print_options_panel( + name=panel_name, + params=options, + ctx=ctx, + markup_mode=markup_mode, + console=console, + ) + + if isinstance(obj, click.Group): + panel_to_commands: defaultdict[str, list[click.Command]] = defaultdict(list) + for command_name in obj.list_commands(ctx): + command = obj.get_command(ctx, command_name) + if command and not command.hidden: + panel_name = ( + getattr(command, _RICH_HELP_PANEL_NAME, None) + or COMMANDS_PANEL_TITLE + ) + panel_to_commands[panel_name].append(command) + + # Identify the longest command name in all panels + max_cmd_len = max( + [ + len(command.name or "") + for commands in panel_to_commands.values() + for command in commands + ], + default=0, + ) + + # Print each command group panel + default_commands = panel_to_commands.get(COMMANDS_PANEL_TITLE, []) + _print_commands_panel( + name=COMMANDS_PANEL_TITLE, + commands=default_commands, + markup_mode=markup_mode, + console=console, + cmd_len=max_cmd_len, + ) + for panel_name, commands in panel_to_commands.items(): + if panel_name == COMMANDS_PANEL_TITLE: + # Already printed above + continue + _print_commands_panel( + name=panel_name, + commands=commands, + markup_mode=markup_mode, + console=console, + cmd_len=max_cmd_len, + ) + + # Epilogue if we have it + if obj.epilog: + # Remove single linebreaks, replace double with single + lines = obj.epilog.split("\n\n") + epilogue = "\n".join([x.replace("\n", " ").strip() for x in lines]) + epilogue_text = _make_rich_text(text=epilogue, markup_mode=markup_mode) + console.print(Padding(Align(epilogue_text, pad=False), 1)) + + +def rich_format_error(self: click.ClickException) -> None: + """Print richly formatted click errors. + + Called by custom exception handler to print richly formatted click errors. + Mimics original click.ClickException.echo() function but with rich formatting. + """ + # Don't do anything when it's a NoArgsIsHelpError (without importing it, cf. #1278) + if self.__class__.__name__ == "NoArgsIsHelpError": + return + + console = _get_rich_console(stderr=True) + ctx: Union[click.Context, None] = getattr(self, "ctx", None) + if ctx is not None: + console.print(ctx.get_usage()) + + if ctx is not None and ctx.command.get_help_option(ctx) is not None: + console.print( + RICH_HELP.format( + command_path=ctx.command_path, help_option=ctx.help_option_names[0] + ), + style=STYLE_ERRORS_SUGGESTION, + ) + + console.print( + Panel( + highlighter(self.format_message()), + border_style=STYLE_ERRORS_PANEL_BORDER, + title=ERRORS_PANEL_TITLE, + title_align=ALIGN_ERRORS_PANEL, + ) + ) + + +def rich_abort_error() -> None: + """Print richly formatted abort error.""" + console = _get_rich_console(stderr=True) + console.print(ABORTED_TEXT, style=STYLE_ABORTED) + + +def escape_before_html_export(input_text: str) -> str: + """Ensure that the input string can be used for HTML export.""" + return escape(input_text).strip() + + +def rich_to_html(input_text: str) -> str: + """Print the HTML version of a rich-formatted input string. + + This function does not provide a full HTML page, but can be used to insert + HTML-formatted text spans into a markdown file. + """ + console = Console(record=True, highlight=False, file=io.StringIO()) + + console.print(input_text, overflow="ignore", crop=False) + + return console.export_html(inline_styles=True, code_format="{code}").strip() + + +def rich_render_text(text: str) -> str: + """Remove rich tags and render a pure text representation""" + console = _get_rich_console() + return "".join(segment.text for segment in console.render(text)).rstrip("\n") + + +def get_traceback( + exc: BaseException, + exception_config: DeveloperExceptionConfig, + internal_dir_names: list[str], +) -> Traceback: + rich_tb = Traceback.from_exception( + type(exc), + exc, + exc.__traceback__, + show_locals=exception_config.pretty_exceptions_show_locals, + suppress=internal_dir_names, + width=MAX_WIDTH, + ) + return rich_tb diff --git a/contrib/python/typer-slim/typer/testing.py b/contrib/python/typer-slim/typer/testing.py new file mode 100644 index 00000000000..61f337b7892 --- /dev/null +++ b/contrib/python/typer-slim/typer/testing.py @@ -0,0 +1,30 @@ +from collections.abc import Mapping, Sequence +from typing import IO, Any, Optional, Union + +from click.testing import CliRunner as ClickCliRunner # noqa +from click.testing import Result +from typer.main import Typer +from typer.main import get_command as _get_command + + +class CliRunner(ClickCliRunner): + def invoke( # type: ignore + self, + app: Typer, + args: Optional[Union[str, Sequence[str]]] = None, + input: Optional[Union[bytes, str, IO[Any]]] = None, + env: Optional[Mapping[str, Optional[str]]] = None, + catch_exceptions: bool = True, + color: bool = False, + **extra: Any, + ) -> Result: + use_cli = _get_command(app) + return super().invoke( + use_cli, + args=args, + input=input, + env=env, + catch_exceptions=catch_exceptions, + color=color, + **extra, + ) diff --git a/contrib/python/typer-slim/typer/utils.py b/contrib/python/typer-slim/typer/utils.py new file mode 100644 index 00000000000..568639bade3 --- /dev/null +++ b/contrib/python/typer-slim/typer/utils.py @@ -0,0 +1,190 @@ +import inspect +import sys +from copy import copy +from typing import Any, Callable, cast + +from ._typing import Annotated, get_args, get_origin, get_type_hints +from .models import ArgumentInfo, OptionInfo, ParameterInfo, ParamMeta + + +def _param_type_to_user_string(param_type: type[ParameterInfo]) -> str: + # Render a `ParameterInfo` subclass for use in error messages. + # User code doesn't call `*Info` directly, so errors should present the classes how + # they were (probably) defined in the user code. + if param_type is OptionInfo: + return "`Option`" + elif param_type is ArgumentInfo: + return "`Argument`" + # This line shouldn't be reachable during normal use. + return f"`{param_type.__name__}`" # pragma: no cover + + +class AnnotatedParamWithDefaultValueError(Exception): + argument_name: str + param_type: type[ParameterInfo] + + def __init__(self, argument_name: str, param_type: type[ParameterInfo]): + self.argument_name = argument_name + self.param_type = param_type + + def __str__(self) -> str: + param_type_str = _param_type_to_user_string(self.param_type) + return ( + f"{param_type_str} default value cannot be set in `Annotated`" + f" for {self.argument_name!r}. Set the default value with `=` instead." + ) + + +class MixedAnnotatedAndDefaultStyleError(Exception): + argument_name: str + annotated_param_type: type[ParameterInfo] + default_param_type: type[ParameterInfo] + + def __init__( + self, + argument_name: str, + annotated_param_type: type[ParameterInfo], + default_param_type: type[ParameterInfo], + ): + self.argument_name = argument_name + self.annotated_param_type = annotated_param_type + self.default_param_type = default_param_type + + def __str__(self) -> str: + annotated_param_type_str = _param_type_to_user_string(self.annotated_param_type) + default_param_type_str = _param_type_to_user_string(self.default_param_type) + msg = f"Cannot specify {annotated_param_type_str} in `Annotated` and" + if self.annotated_param_type is self.default_param_type: + msg += " default value" + else: + msg += f" {default_param_type_str} as a default value" + msg += f" together for {self.argument_name!r}" + return msg + + +class MultipleTyperAnnotationsError(Exception): + argument_name: str + + def __init__(self, argument_name: str): + self.argument_name = argument_name + + def __str__(self) -> str: + return ( + "Cannot specify multiple `Annotated` Typer arguments" + f" for {self.argument_name!r}" + ) + + +class DefaultFactoryAndDefaultValueError(Exception): + argument_name: str + param_type: type[ParameterInfo] + + def __init__(self, argument_name: str, param_type: type[ParameterInfo]): + self.argument_name = argument_name + self.param_type = param_type + + def __str__(self) -> str: + param_type_str = _param_type_to_user_string(self.param_type) + return ( + "Cannot specify `default_factory` and a default value together" + f" for {param_type_str}" + ) + + +def _split_annotation_from_typer_annotations( + base_annotation: type[Any], +) -> tuple[type[Any], list[ParameterInfo]]: + if get_origin(base_annotation) is not Annotated: + return base_annotation, [] + base_annotation, *maybe_typer_annotations = get_args(base_annotation) + return base_annotation, [ + annotation + for annotation in maybe_typer_annotations + if isinstance(annotation, ParameterInfo) + ] + + +def get_params_from_function(func: Callable[..., Any]) -> dict[str, ParamMeta]: + if sys.version_info >= (3, 10): + signature = inspect.signature(func, eval_str=True) + else: + signature = inspect.signature(func) + + type_hints = get_type_hints(func) + params = {} + for param in signature.parameters.values(): + annotation, typer_annotations = _split_annotation_from_typer_annotations( + param.annotation, + ) + if len(typer_annotations) > 1: + raise MultipleTyperAnnotationsError(param.name) + + default = param.default + if typer_annotations: + # It's something like `my_param: Annotated[str, Argument()]` + [parameter_info] = typer_annotations + + # Forbid `my_param: Annotated[str, Argument()] = Argument("...")` + if isinstance(param.default, ParameterInfo): + raise MixedAnnotatedAndDefaultStyleError( + argument_name=param.name, + annotated_param_type=type(parameter_info), + default_param_type=type(param.default), + ) + + parameter_info = copy(parameter_info) + + # When used as a default, `Option` takes a default value and option names + # as positional arguments: + # `Option(some_value, "--some-argument", "-s")` + # When used in `Annotated` (ie, what this is handling), `Option` just takes + # option names as positional arguments: + # `Option("--some-argument", "-s")` + # In this case, the `default` attribute of `parameter_info` is actually + # meant to be the first item of `param_decls`. + if ( + isinstance(parameter_info, OptionInfo) + and parameter_info.default is not ... + ): + parameter_info.param_decls = ( + cast(str, parameter_info.default), + *(parameter_info.param_decls or ()), + ) + parameter_info.default = ... + + # Forbid `my_param: Annotated[str, Argument('some-default')]` + if parameter_info.default is not ...: + raise AnnotatedParamWithDefaultValueError( + param_type=type(parameter_info), + argument_name=param.name, + ) + if param.default is not param.empty: + # Put the parameter's default (set by `=`) into `parameter_info`, where + # typer can find it. + parameter_info.default = param.default + + default = parameter_info + elif param.name in type_hints: + # Resolve forward references. + annotation = type_hints[param.name] + + if isinstance(default, ParameterInfo): + parameter_info = copy(default) + # Click supports `default` as either + # - an actual value; or + # - a factory function (returning a default value.) + # The two are not interchangeable for static typing, so typer allows + # specifying `default_factory`. Move the `default_factory` into `default` + # so click can find it. + if parameter_info.default is ... and parameter_info.default_factory: + parameter_info.default = parameter_info.default_factory + elif parameter_info.default_factory: + raise DefaultFactoryAndDefaultValueError( + argument_name=param.name, param_type=type(parameter_info) + ) + default = parameter_info + + params[param.name] = ParamMeta( + name=param.name, default=default, annotation=annotation + ) + return params diff --git a/contrib/python/typer-slim/ya.make b/contrib/python/typer-slim/ya.make new file mode 100644 index 00000000000..ebd3c585432 --- /dev/null +++ b/contrib/python/typer-slim/ya.make @@ -0,0 +1,47 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(0.21.1) + +LICENSE(MIT) + +PEERDIR( + contrib/python/click + contrib/python/typing-extensions +) + +NO_LINT() + +NO_CHECK_IMPORTS( + typer.rich_utils +) + +PY_SRCS( + TOP_LEVEL + typer/__init__.py + typer/__main__.py + typer/_completion_classes.py + typer/_completion_shared.py + typer/_types.py + typer/_typing.py + typer/cli.py + typer/colors.py + typer/completion.py + typer/core.py + typer/main.py + typer/models.py + typer/params.py + typer/rich_utils.py + typer/testing.py + typer/utils.py +) + +RESOURCE_FILES( + PREFIX contrib/python/typer-slim/ + .dist-info/METADATA + .dist-info/entry_points.txt + typer/py.typed +) + +END() diff --git a/contrib/python/typer/.dist-info/METADATA b/contrib/python/typer/.dist-info/METADATA new file mode 100644 index 00000000000..8fc14ea2b68 --- /dev/null +++ b/contrib/python/typer/.dist-info/METADATA @@ -0,0 +1,412 @@ +Metadata-Version: 2.4 +Name: typer +Version: 0.24.1 +Summary: Typer, build great CLIs. Easy to code. Based on Python type hints. +Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= <[email protected]> +License-Expression: MIT +License-File: LICENSE +Classifier: Intended Audience :: Information Technology +Classifier: Intended Audience :: System Administrators +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python +Classifier: Topic :: Software Development :: Libraries :: Application Frameworks +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Software Development :: Libraries +Classifier: Topic :: Software Development +Classifier: Typing :: Typed +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Project-URL: Homepage, https://github.com/fastapi/typer +Project-URL: Documentation, https://typer.tiangolo.com +Project-URL: Repository, https://github.com/fastapi/typer +Project-URL: Issues, https://github.com/fastapi/typer/issues +Project-URL: Changelog, https://typer.tiangolo.com/release-notes/ +Requires-Python: >=3.10 +Requires-Dist: click>=8.2.1 +Requires-Dist: shellingham>=1.3.0 +Requires-Dist: rich>=12.3.0 +Requires-Dist: annotated-doc>=0.0.2 +Description-Content-Type: text/markdown + +<p align="center"> + <a href="https://typer.tiangolo.com"><img src="https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg#only-light" alt="Typer"></a> + +</p> +<p align="center"> + <em>Typer, build great CLIs. Easy to code. Based on Python type hints.</em> +</p> +<p align="center"> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3ATest+event%3Apush+branch%3Amaster" target="_blank"> + <img src="https://github.com/fastapi/typer/actions/workflows/test.yml/badge.svg?event=push&branch=master" alt="Test"> +</a> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3APublish" target="_blank"> + <img src="https://github.com/fastapi/typer/workflows/Publish/badge.svg" alt="Publish"> +</a> +<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/typer" target="_blank"> + <img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/typer.svg" alt="Coverage"> +<a href="https://pypi.org/project/typer" target="_blank"> + <img src="https://img.shields.io/pypi/v/typer?color=%2334D058&label=pypi%20package" alt="Package version"> +</a> +</p> + +--- + +**Documentation**: <a href="https://typer.tiangolo.com" target="_blank">https://typer.tiangolo.com</a> + +**Source Code**: <a href="https://github.com/fastapi/typer" target="_blank">https://github.com/fastapi/typer</a> + +--- + +Typer is a library for building <abbr title="command line interface, programs executed from a terminal">CLI</abbr> applications that users will **love using** and developers will **love creating**. Based on Python type hints. + +It's also a command line tool to run scripts, automatically converting them to CLI applications. + +The key features are: + +* **Intuitive to write**: Great editor support. <abbr title="also known as auto-complete, autocompletion, IntelliSense">Completion</abbr> everywhere. Less time debugging. Designed to be easy to use and learn. Less time reading docs. +* **Easy to use**: It's easy to use for the final users. Automatic help, and automatic completion for all shells. +* **Short**: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs. +* **Start simple**: The simplest example adds only 2 lines of code to your app: **1 import, 1 function call**. +* **Grow large**: Grow in complexity as much as you want, create arbitrarily complex trees of commands and groups of subcommands, with options and arguments. +* **Run scripts**: Typer includes a `typer` command/program that you can use to run scripts, automatically converting them to CLIs, even if they don't use Typer internally. + +## 2026 February - Typer developer survey + +Help us define Typer's future by filling the <a href="https://forms.gle/nYvutPrVkmBQZLas7" class="external-link" target="_blank">Typer developer survey</a>. โจ + +## FastAPI of CLIs + +**Typer** is <a href="https://fastapi.tiangolo.com" class="external-link" target="_blank">FastAPI</a>'s little sibling, it's the FastAPI of CLIs. + +## Installation + +Create and activate a <a href="https://typer.tiangolo.com/virtual-environments/" class="external-link" target="_blank">virtual environment</a> and then install **Typer**: + +<div class="termy"> + +```console +$ pip install typer +---> 100% +Successfully installed typer rich shellingham +``` + +</div> + +## Example + +### The absolute minimum + +* Create a file `main.py` with: + +```Python +def main(name: str): + print(f"Hello {name}") +``` + +This script doesn't even use Typer internally. But you can use the `typer` command to run it as a CLI application. + +### Run it + +Run your application with the `typer` command: + +<div class="termy"> + +```console +// Run your application +$ typer main.py run + +// You get a nice error, you are missing NAME +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME +Try 'typer [PATH_OR_MODULE] run --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ typer main.py run --help + +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME + +Run the provided Typer app. + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ typer main.py run Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +This is the simplest use case, not even using Typer internally, but it can already be quite useful for simple scripts. + +**Note**: auto-completion works when you create a Python package and run it with `--install-completion` or when you use the `typer` command. + +## Use Typer in your code + +Now let's start using Typer in your own code, update `main.py` with: + +```Python +import typer + + +def main(name: str): + print(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) +``` + +Now you could run it with Python directly: + +<div class="termy"> + +```console +// Run your application +$ python main.py + +// You get a nice error, you are missing NAME +Usage: main.py [OPTIONS] NAME +Try 'main.py --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ python main.py --help + +Usage: main.py [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ python main.py Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +**Note**: you can also call this same script with the `typer` command, but you don't need to. + +## Example upgrade + +This was the simplest example possible. + +Now let's see one a bit more complex. + +### An example with two subcommands + +Modify the file `main.py`. + +Create a `typer.Typer()` app, and create two subcommands with their parameters. + +```Python hl_lines="3 6 11 20" +import typer + +app = typer.Typer() + + +def hello(name: str): + print(f"Hello {name}") + + +def goodbye(name: str, formal: bool = False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + + +if __name__ == "__main__": + app() +``` + +And that will: + +* Explicitly create a `typer.Typer` app. + * The previous `typer.run` actually creates one implicitly for you. +* Add two subcommands with `@app.command()`. +* Execute the `app()` itself, as if it was a function (instead of `typer.run`). + +### Run the upgraded example + +Check the new help: + +<div class="termy"> + +```console +$ python main.py --help + + Usage: main.py [OPTIONS] COMMAND [ARGS]... + +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --install-completion Install completion โ +โ for the current โ +โ shell. โ +โ --show-completion Show completion for โ +โ the current shell, โ +โ to copy it or โ +โ customize the โ +โ installation. โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ goodbye โ +โ hello โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// When you create a package you get โจ auto-completion โจ for free, installed with --install-completion + +// You have 2 subcommands (the 2 functions): goodbye and hello +``` + +</div> + +Now check the help for the `hello` command: + +<div class="termy"> + +```console +$ python main.py hello --help + + Usage: main.py hello [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +``` + +</div> + +And now check the help for the `goodbye` command: + +<div class="termy"> + +```console +$ python main.py goodbye --help + + Usage: main.py goodbye [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --formal --no-formal [default: no-formal] โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Automatic --formal and --no-formal for the bool option ๐ +``` + +</div> + +Now you can try out the new command line application: + +<div class="termy"> + +```console +// Use it with the hello command + +$ python main.py hello Camila + +Hello Camila + +// And with the goodbye command + +$ python main.py goodbye Camila + +Bye Camila! + +// And with --formal + +$ python main.py goodbye --formal Camila + +Goodbye Ms. Camila. Have a good day. +``` + +</div> + +**Note**: If your app only has one command, by default the command name is **omitted** in usage: `python main.py Camila`. However, when there are multiple commands, you must **explicitly include the command name**: `python main.py hello Camila`. See [One or Multiple Commands](https://typer.tiangolo.com/tutorial/commands/one-or-multiple/) for more details. + +### Recap + +In summary, you declare **once** the types of parameters (*CLI arguments* and *CLI options*) as function parameters. + +You do that with standard modern Python types. + +You don't have to learn a new syntax, the methods or classes of a specific library, etc. + +Just standard **Python**. + +For example, for an `int`: + +```Python +total: int +``` + +or for a `bool` flag: + +```Python +force: bool +``` + +And similarly for **files**, **paths**, **enums** (choices), etc. And there are tools to create **groups of subcommands**, add metadata, extra **validation**, etc. + +**You get**: great editor support, including **completion** and **type checks** everywhere. + +**Your users get**: automatic **`--help`**, **auto-completion** in their terminal (Bash, Zsh, Fish, PowerShell) when they install your package or when using the `typer` command. + +For a more complete example including more features, see the <a href="https://typer.tiangolo.com/tutorial/">Tutorial - User Guide</a>. + +## Dependencies + +**Typer** stands on the shoulders of giants. It has three required dependencies: + +* <a href="https://click.palletsprojects.com/" class="external-link" target="_blank">Click</a>: a popular tool for building CLIs in Python. Typer is based on it. +* <a href="https://rich.readthedocs.io/en/stable/index.html" class="external-link" target="_blank"><code>rich</code></a>: to show nicely formatted errors automatically. +* <a href="https://github.com/sarugaku/shellingham" class="external-link" target="_blank"><code>shellingham</code></a>: to automatically detect the current shell when installing completion. + +### `typer-slim` + +There used to be a slimmed-down version of Typer called `typer-slim`, which didn't include the dependencies `rich` and `shellingham`, nor the `typer` command. + +However, since version 0.22.0, we have stopped supporting this, and `typer-slim` now simply installs (all of) Typer. + +If you want to disable Rich globally, you can set an environmental variable `TYPER_USE_RICH` to `False` or `0`. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/contrib/python/typer/.dist-info/entry_points.txt b/contrib/python/typer/.dist-info/entry_points.txt new file mode 100644 index 00000000000..ca44c05a071 --- /dev/null +++ b/contrib/python/typer/.dist-info/entry_points.txt @@ -0,0 +1,5 @@ +[console_scripts] +typer = typer.cli:main + +[gui_scripts] + diff --git a/contrib/python/typer/.dist-info/top_level.txt b/contrib/python/typer/.dist-info/top_level.txt new file mode 100644 index 00000000000..ec184f68004 --- /dev/null +++ b/contrib/python/typer/.dist-info/top_level.txt @@ -0,0 +1 @@ +typer diff --git a/contrib/python/typer/.yandex_meta/yamaker.yaml b/contrib/python/typer/.yandex_meta/yamaker.yaml new file mode 100644 index 00000000000..15c0c41de02 --- /dev/null +++ b/contrib/python/typer/.yandex_meta/yamaker.yaml @@ -0,0 +1,4 @@ +requirements: + - typer-slim +exclude: + - typer/* diff --git a/contrib/python/typer/LICENSE b/contrib/python/typer/LICENSE new file mode 100644 index 00000000000..a7694736cf3 --- /dev/null +++ b/contrib/python/typer/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Sebastiรกn Ramรญrez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/contrib/python/typer/README.md b/contrib/python/typer/README.md new file mode 100644 index 00000000000..8e203e31e1a --- /dev/null +++ b/contrib/python/typer/README.md @@ -0,0 +1,375 @@ +<p align="center"> + <a href="https://typer.tiangolo.com"><img src="https://typer.tiangolo.com/img/logo-margin/logo-margin-vector.svg#only-light" alt="Typer"></a> + +</p> +<p align="center"> + <em>Typer, build great CLIs. Easy to code. Based on Python type hints.</em> +</p> +<p align="center"> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3ATest+event%3Apush+branch%3Amaster" target="_blank"> + <img src="https://github.com/fastapi/typer/actions/workflows/test.yml/badge.svg?event=push&branch=master" alt="Test"> +</a> +<a href="https://github.com/fastapi/typer/actions?query=workflow%3APublish" target="_blank"> + <img src="https://github.com/fastapi/typer/workflows/Publish/badge.svg" alt="Publish"> +</a> +<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/typer" target="_blank"> + <img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/typer.svg" alt="Coverage"> +<a href="https://pypi.org/project/typer" target="_blank"> + <img src="https://img.shields.io/pypi/v/typer?color=%2334D058&label=pypi%20package" alt="Package version"> +</a> +</p> + +--- + +**Documentation**: <a href="https://typer.tiangolo.com" target="_blank">https://typer.tiangolo.com</a> + +**Source Code**: <a href="https://github.com/fastapi/typer" target="_blank">https://github.com/fastapi/typer</a> + +--- + +Typer is a library for building <abbr title="command line interface, programs executed from a terminal">CLI</abbr> applications that users will **love using** and developers will **love creating**. Based on Python type hints. + +It's also a command line tool to run scripts, automatically converting them to CLI applications. + +The key features are: + +* **Intuitive to write**: Great editor support. <abbr title="also known as auto-complete, autocompletion, IntelliSense">Completion</abbr> everywhere. Less time debugging. Designed to be easy to use and learn. Less time reading docs. +* **Easy to use**: It's easy to use for the final users. Automatic help, and automatic completion for all shells. +* **Short**: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs. +* **Start simple**: The simplest example adds only 2 lines of code to your app: **1 import, 1 function call**. +* **Grow large**: Grow in complexity as much as you want, create arbitrarily complex trees of commands and groups of subcommands, with options and arguments. +* **Run scripts**: Typer includes a `typer` command/program that you can use to run scripts, automatically converting them to CLIs, even if they don't use Typer internally. + +## 2026 February - Typer developer survey + +Help us define Typer's future by filling the <a href="https://forms.gle/nYvutPrVkmBQZLas7" class="external-link" target="_blank">Typer developer survey</a>. โจ + +## FastAPI of CLIs + +**Typer** is <a href="https://fastapi.tiangolo.com" class="external-link" target="_blank">FastAPI</a>'s little sibling, it's the FastAPI of CLIs. + +## Installation + +Create and activate a <a href="https://typer.tiangolo.com/virtual-environments/" class="external-link" target="_blank">virtual environment</a> and then install **Typer**: + +<div class="termy"> + +```console +$ pip install typer +---> 100% +Successfully installed typer rich shellingham +``` + +</div> + +## Example + +### The absolute minimum + +* Create a file `main.py` with: + +```Python +def main(name: str): + print(f"Hello {name}") +``` + +This script doesn't even use Typer internally. But you can use the `typer` command to run it as a CLI application. + +### Run it + +Run your application with the `typer` command: + +<div class="termy"> + +```console +// Run your application +$ typer main.py run + +// You get a nice error, you are missing NAME +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME +Try 'typer [PATH_OR_MODULE] run --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ typer main.py run --help + +Usage: typer [PATH_OR_MODULE] run [OPTIONS] NAME + +Run the provided Typer app. + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ typer main.py run Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +This is the simplest use case, not even using Typer internally, but it can already be quite useful for simple scripts. + +**Note**: auto-completion works when you create a Python package and run it with `--install-completion` or when you use the `typer` command. + +## Use Typer in your code + +Now let's start using Typer in your own code, update `main.py` with: + +```Python +import typer + + +def main(name: str): + print(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) +``` + +Now you could run it with Python directly: + +<div class="termy"> + +```console +// Run your application +$ python main.py + +// You get a nice error, you are missing NAME +Usage: main.py [OPTIONS] NAME +Try 'main.py --help' for help. +โญโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ Missing argument 'NAME'. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + + +// You get a --help for free +$ python main.py --help + +Usage: main.py [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] | +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Now pass the NAME argument +$ python main.py Camila + +Hello Camila + +// It works! ๐ +``` + +</div> + +**Note**: you can also call this same script with the `typer` command, but you don't need to. + +## Example upgrade + +This was the simplest example possible. + +Now let's see one a bit more complex. + +### An example with two subcommands + +Modify the file `main.py`. + +Create a `typer.Typer()` app, and create two subcommands with their parameters. + +```Python hl_lines="3 6 11 20" +import typer + +app = typer.Typer() + + +def hello(name: str): + print(f"Hello {name}") + + +def goodbye(name: str, formal: bool = False): + if formal: + print(f"Goodbye Ms. {name}. Have a good day.") + else: + print(f"Bye {name}!") + + +if __name__ == "__main__": + app() +``` + +And that will: + +* Explicitly create a `typer.Typer` app. + * The previous `typer.run` actually creates one implicitly for you. +* Add two subcommands with `@app.command()`. +* Execute the `app()` itself, as if it was a function (instead of `typer.run`). + +### Run the upgraded example + +Check the new help: + +<div class="termy"> + +```console +$ python main.py --help + + Usage: main.py [OPTIONS] COMMAND [ARGS]... + +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --install-completion Install completion โ +โ for the current โ +โ shell. โ +โ --show-completion Show completion for โ +โ the current shell, โ +โ to copy it or โ +โ customize the โ +โ installation. โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ goodbye โ +โ hello โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// When you create a package you get โจ auto-completion โจ for free, installed with --install-completion + +// You have 2 subcommands (the 2 functions): goodbye and hello +``` + +</div> + +Now check the help for the `hello` command: + +<div class="termy"> + +```console +$ python main.py hello --help + + Usage: main.py hello [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --help Show this message and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +``` + +</div> + +And now check the help for the `goodbye` command: + +<div class="termy"> + +```console +$ python main.py goodbye --help + + Usage: main.py goodbye [OPTIONS] NAME + +โญโ Arguments โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ * name TEXT [default: None] [required] โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ +โญโ Options โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ +โ --formal --no-formal [default: no-formal] โ +โ --help Show this message โ +โ and exit. โ +โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ + +// Automatic --formal and --no-formal for the bool option ๐ +``` + +</div> + +Now you can try out the new command line application: + +<div class="termy"> + +```console +// Use it with the hello command + +$ python main.py hello Camila + +Hello Camila + +// And with the goodbye command + +$ python main.py goodbye Camila + +Bye Camila! + +// And with --formal + +$ python main.py goodbye --formal Camila + +Goodbye Ms. Camila. Have a good day. +``` + +</div> + +**Note**: If your app only has one command, by default the command name is **omitted** in usage: `python main.py Camila`. However, when there are multiple commands, you must **explicitly include the command name**: `python main.py hello Camila`. See [One or Multiple Commands](https://typer.tiangolo.com/tutorial/commands/one-or-multiple/) for more details. + +### Recap + +In summary, you declare **once** the types of parameters (*CLI arguments* and *CLI options*) as function parameters. + +You do that with standard modern Python types. + +You don't have to learn a new syntax, the methods or classes of a specific library, etc. + +Just standard **Python**. + +For example, for an `int`: + +```Python +total: int +``` + +or for a `bool` flag: + +```Python +force: bool +``` + +And similarly for **files**, **paths**, **enums** (choices), etc. And there are tools to create **groups of subcommands**, add metadata, extra **validation**, etc. + +**You get**: great editor support, including **completion** and **type checks** everywhere. + +**Your users get**: automatic **`--help`**, **auto-completion** in their terminal (Bash, Zsh, Fish, PowerShell) when they install your package or when using the `typer` command. + +For a more complete example including more features, see the <a href="https://typer.tiangolo.com/tutorial/">Tutorial - User Guide</a>. + +## Dependencies + +**Typer** stands on the shoulders of giants. It has three required dependencies: + +* <a href="https://click.palletsprojects.com/" class="external-link" target="_blank">Click</a>: a popular tool for building CLIs in Python. Typer is based on it. +* <a href="https://rich.readthedocs.io/en/stable/index.html" class="external-link" target="_blank"><code>rich</code></a>: to show nicely formatted errors automatically. +* <a href="https://github.com/sarugaku/shellingham" class="external-link" target="_blank"><code>shellingham</code></a>: to automatically detect the current shell when installing completion. + +### `typer-slim` + +There used to be a slimmed-down version of Typer called `typer-slim`, which didn't include the dependencies `rich` and `shellingham`, nor the `typer` command. + +However, since version 0.22.0, we have stopped supporting this, and `typer-slim` now simply installs (all of) Typer. + +If you want to disable Rich globally, you can set an environmental variable `TYPER_USE_RICH` to `False` or `0`. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/contrib/python/typer/ya.make b/contrib/python/typer/ya.make new file mode 100644 index 00000000000..48ea26ec6f6 --- /dev/null +++ b/contrib/python/typer/ya.make @@ -0,0 +1,26 @@ +# Generated by devtools/yamaker (pypi). + +PY3_LIBRARY() + +VERSION(0.24.1) + +LICENSE(MIT) + +PEERDIR( + contrib/python/annotated-doc + contrib/python/click + contrib/python/rich + contrib/python/shellingham + contrib/python/typer-slim +) + +NO_LINT() + +RESOURCE_FILES( + PREFIX contrib/python/typer/ + .dist-info/METADATA + .dist-info/entry_points.txt + .dist-info/top_level.txt +) + +END() diff --git a/contrib/python/xmltodict/py3/.dist-info/METADATA b/contrib/python/xmltodict/py3/.dist-info/METADATA index 00cc7dab64f..d0be5149ca6 100644 --- a/contrib/python/xmltodict/py3/.dist-info/METADATA +++ b/contrib/python/xmltodict/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: xmltodict -Version: 1.0.3 +Version: 1.0.4 Summary: Makes working with XML feel like you are working with JSON Author: Martin Blech License-Expression: MIT @@ -255,6 +255,7 @@ Convert a Python dictionary back into XML. - `input_dict`: Dictionary to convert to XML. - `output=None`: File-like object to write XML to; returns string if None. - `encoding='utf-8'`: Encoding of the output XML. +- `bytes_errors='replace'`: Error handler used when decoding byte values during unparse (for example `'replace'`, `'strict'`, `'ignore'`). - `full_document=True`: Include XML declaration if True. - `short_empty_elements=False`: Use short tags for empty elements (`<tag/>`). - `attr_prefix='@'`: Prefix for dictionary keys representing attributes. diff --git a/contrib/python/xmltodict/py3/README.md b/contrib/python/xmltodict/py3/README.md index ff62bf49b61..80a0501974e 100644 --- a/contrib/python/xmltodict/py3/README.md +++ b/contrib/python/xmltodict/py3/README.md @@ -228,6 +228,7 @@ Convert a Python dictionary back into XML. - `input_dict`: Dictionary to convert to XML. - `output=None`: File-like object to write XML to; returns string if None. - `encoding='utf-8'`: Encoding of the output XML. +- `bytes_errors='replace'`: Error handler used when decoding byte values during unparse (for example `'replace'`, `'strict'`, `'ignore'`). - `full_document=True`: Include XML declaration if True. - `short_empty_elements=False`: Use short tags for empty elements (`<tag/>`). - `attr_prefix='@'`: Prefix for dictionary keys representing attributes. diff --git a/contrib/python/xmltodict/py3/tests/test_dicttoxml.py b/contrib/python/xmltodict/py3/tests/test_dicttoxml.py index e67600e27de..873fa9075cb 100644 --- a/contrib/python/xmltodict/py3/tests/test_dicttoxml.py +++ b/contrib/python/xmltodict/py3/tests/test_dicttoxml.py @@ -178,6 +178,29 @@ def test_unparse_with_multiple_top_level_comments(): assert xml == "<!--t1--><!--t2--><a>1</a>" +def test_unparse_with_bytes_comment_uses_output_encoding(): + obj = {"#comment": b"caf\xe9", "a": "1"} + xml = _strip(unparse(obj, full_document=True, encoding="iso-8859-1")) + assert xml == "<!--caf\xe9--><a>1</a>" + + +def test_unparse_invalid_bytes_comment_replaced_by_default(): + obj = {"#comment": b"\xff", "a": "1"} + xml = _strip(unparse(obj, full_document=True, encoding="utf-8")) + assert xml == "<!--\ufffd--><a>1</a>" + + +def test_unparse_rejects_invalid_bytes_comment_for_encoding_with_strict(): + obj = {"#comment": b"\xff", "a": "1"} + with pytest.raises(UnicodeDecodeError, match="'utf-8' codec can't decode byte 0xff"): + unparse( + obj, + full_document=True, + encoding="utf-8", + bytes_errors="strict", + ) + + def test_unparse_rejects_comment_with_double_hyphen(): obj = {"#comment": "bad--comment", "a": "1"} with pytest.raises(ValueError, match="cannot contain '--'"): @@ -298,6 +321,39 @@ xmlns:b="http://b.com/"><x a:attr="val">1</x><a:y>2</a:y><b:z>3</b:z></root>''' assert xml == expected_xml +def test_xmlns_values_use_consistent_boolean_coercion(): + xml = unparse({"root": {"@xmlns": {"a": True}}}, full_document=False) + assert xml == '<root xmlns:a="true"></root>' + + +def test_xmlns_values_decode_bytes_with_output_encoding(): + xml = unparse( + {"root": {"@xmlns": {"a": b"http://ex\xe9.com/"}}}, + full_document=False, + encoding="iso-8859-1", + ) + assert xml == '<root xmlns:a="http://ex\xe9.com/"></root>' + + +def test_xmlns_values_replace_invalid_bytes_by_default(): + xml = unparse( + {"root": {"@xmlns": {"a": b"\xff"}}}, + full_document=False, + encoding="utf-8", + ) + assert xml == '<root xmlns:a="\ufffd"></root>' + + +def test_xmlns_values_reject_invalid_bytes_with_strict(): + with pytest.raises(UnicodeDecodeError, match="'utf-8' codec can't decode byte 0xff"): + unparse( + {"root": {"@xmlns": {"a": b"\xff"}}}, + full_document=False, + encoding="utf-8", + bytes_errors="strict", + ) + + def test_boolean_unparse(): expected_xml = '<?xml version="1.0" encoding="utf-8"?>\n<x>true</x>' xml = unparse(dict(x=True)) @@ -307,6 +363,56 @@ def test_boolean_unparse(): xml = unparse(dict(x=False)) assert xml == expected_xml + expected_xml = '<?xml version="1.0" encoding="utf-8"?>\n<x attr="true"></x>' + xml = unparse({'x': {'@attr': True}}) + assert xml == expected_xml + + expected_xml = '<?xml version="1.0" encoding="utf-8"?>\n<x attr="false"></x>' + xml = unparse({'x': {'@attr': False}}) + assert xml == expected_xml + + +def test_unparse_bytes_in_attributes_and_cdata_use_output_encoding(): + xml = unparse({"x": {"@attr": b"caf\xe9", "#text": b"caf\xe9"}}, full_document=False, encoding="iso-8859-1") + assert xml == '<x attr="caf\xe9">caf\xe9</x>' + + +def test_unparse_bytes_text_node_uses_output_encoding(): + xml = unparse({"x": b"caf\xe9"}, full_document=False, encoding="iso-8859-1") + assert xml == "<x>caf\xe9</x>" + + +def test_unparse_bytes_text_node_with_expand_iter_uses_output_encoding(): + xml = unparse({"x": b"caf\xe9"}, full_document=False, encoding="iso-8859-1", expand_iter="item") + assert xml == "<x>caf\xe9</x>" + + +def test_unparse_invalid_bytes_in_attributes_and_cdata_replaced_by_default(): + xml = unparse({"x": {"@attr": b"\xff", "#text": b"\xff"}}, full_document=False, encoding="utf-8") + assert xml == '<x attr="\ufffd">\ufffd</x>' + + +def test_unparse_invalid_bytes_text_node_replaced_by_default(): + xml = unparse({"x": b"\xff"}, full_document=False, encoding="utf-8") + assert xml == "<x>\ufffd</x>" + + +def test_unparse_rejects_invalid_bytes_in_attributes_and_cdata_for_encoding_with_strict(): + with pytest.raises(UnicodeDecodeError, match="'utf-8' codec can't decode byte 0xff"): + unparse({"x": {"@attr": b"\xff"}}, full_document=False, encoding="utf-8", bytes_errors="strict") + with pytest.raises(UnicodeDecodeError, match="'utf-8' codec can't decode byte 0xff"): + unparse({"x": {"#text": b"\xff"}}, full_document=False, encoding="utf-8", bytes_errors="strict") + + +def test_unparse_rejects_invalid_bytes_text_node_for_encoding_with_strict(): + with pytest.raises(UnicodeDecodeError, match="'utf-8' codec can't decode byte 0xff"): + unparse({"x": b"\xff"}, full_document=False, encoding="utf-8", bytes_errors="strict") + + +def test_unparse_rejects_invalid_bytes_errors_handler(): + with pytest.raises(ValueError, match="Invalid bytes_errors handler: nope"): + unparse({"x": {"@attr": b"\xff"}}, full_document=False, bytes_errors="nope") + def test_rejects_tag_name_with_angle_brackets(): # Minimal guard: disallow '<' or '>' to prevent breaking tag context diff --git a/contrib/python/xmltodict/py3/tests/ya.make b/contrib/python/xmltodict/py3/tests/ya.make index f70781b60ec..b54a107ca88 100644 --- a/contrib/python/xmltodict/py3/tests/ya.make +++ b/contrib/python/xmltodict/py3/tests/ya.make @@ -4,10 +4,7 @@ PEERDIR( contrib/python/xmltodict ) -TEST_SRCS( - test_dicttoxml.py - test_xmltodict.py -) +ALL_PYTEST_SRCS(RECURSIVE) NO_LINT() diff --git a/contrib/python/xmltodict/py3/xmltodict.py b/contrib/python/xmltodict/py3/xmltodict.py index 6bdf947590a..6ad4e45916d 100644 --- a/contrib/python/xmltodict/py3/xmltodict.py +++ b/contrib/python/xmltodict/py3/xmltodict.py @@ -6,6 +6,7 @@ from xml.sax.saxutils import XMLGenerator, escape from xml.sax.xmlreader import AttributesImpl from io import StringIO from inspect import isgenerator +import codecs class ParsingInterrupted(Exception): pass @@ -369,15 +370,17 @@ def parse(xml_input, encoding=None, expat=expat, process_namespaces=False, return handler.item -def _convert_value_to_string(value): +def _convert_value_to_string(value, encoding='utf-8', bytes_errors='replace'): """Convert a value to its string representation for XML output. Handles boolean values consistently by converting them to lowercase. """ - if isinstance(value, (str, bytes)): + if isinstance(value, str): return value if isinstance(value, bool): return "true" if value else "false" + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value).decode(encoding, errors=bytes_errors) return str(value) @@ -448,6 +451,8 @@ def _emit(key, value, content_handler, namespaces=None, full_document=True, expand_iter=None, + encoding='utf-8', + bytes_errors='replace', comment_key='#comment'): if isinstance(key, str) and key == comment_key: comments_list = value if isinstance(value, list) else [value] @@ -456,7 +461,9 @@ def _emit(key, value, content_handler, for comment_text in comments_list: if comment_text is None: continue - comment_text = _convert_value_to_string(comment_text) + comment_text = _convert_value_to_string( + comment_text, encoding=encoding, bytes_errors=bytes_errors + ) if not comment_text: continue if pretty: @@ -474,7 +481,7 @@ def _emit(key, value, content_handler, key, value = result # Minimal validation to avoid breaking out of tag context _validate_name(key, "element") - if not hasattr(value, '__iter__') or isinstance(value, (str, dict)): + if not hasattr(value, '__iter__') or isinstance(value, (str, bytes, bytearray, memoryview, dict)): value = [value] for index, v in enumerate(value): if full_document and depth == 0 and index > 0: @@ -482,10 +489,10 @@ def _emit(key, value, content_handler, if v is None: v = {} elif not isinstance(v, (dict, str)): - if expand_iter and hasattr(v, '__iter__'): + if expand_iter and hasattr(v, '__iter__') and not isinstance(v, (bytes, bytearray, memoryview)): v = {expand_iter: v} else: - v = _convert_value_to_string(v) + v = _convert_value_to_string(v, encoding=encoding, bytes_errors=bytes_errors) if isinstance(v, str): v = {cdata_key: v} cdata = None @@ -496,7 +503,7 @@ def _emit(key, value, content_handler, if iv is None: cdata = None else: - cdata = _convert_value_to_string(iv) + cdata = _convert_value_to_string(iv, encoding=encoding, bytes_errors=bytes_errors) continue if isinstance(ik, str) and ik.startswith(attr_prefix): ik = _process_namespace(ik, namespaces, namespace_separator, @@ -505,12 +512,14 @@ def _emit(key, value, content_handler, for k, v in iv.items(): _validate_name(k, "attribute") attr = 'xmlns{}'.format(f':{k}' if k else '') - attrs[attr] = '' if v is None else str(v) + attrs[attr] = '' if v is None else _convert_value_to_string( + v, encoding=encoding, bytes_errors=bytes_errors + ) continue if iv is None: iv = '' elif not isinstance(iv, str): - iv = str(iv) + iv = _convert_value_to_string(iv, encoding=encoding, bytes_errors=bytes_errors) attr_name = ik[len(attr_prefix) :] _validate_name(attr_name, "attribute") attrs[attr_name] = iv @@ -530,7 +539,8 @@ def _emit(key, value, content_handler, attr_prefix, cdata_key, depth+1, preprocessor, pretty, newl, indent, namespaces=namespaces, namespace_separator=namespace_separator, - expand_iter=expand_iter, comment_key=comment_key) + expand_iter=expand_iter, encoding=encoding, + bytes_errors=bytes_errors, comment_key=comment_key) if cdata is not None: content_handler.characters(cdata) if pretty and children: @@ -565,8 +575,16 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True, The `pretty` parameter (default=`False`) enables pretty-printing. In this mode, lines are terminated with `'\n'` and indented with `'\t'`, but this can be customized with the `newl` and `indent` parameters. + The `bytes_errors` parameter controls decoding errors for byte values and + defaults to `'replace'`. """ + bytes_errors = kwargs.pop('bytes_errors', 'replace') + try: + codecs.lookup_error(bytes_errors) + except LookupError as exc: + raise ValueError(f"Invalid bytes_errors handler: {bytes_errors}") from exc + must_return = False if output is None: output = StringIO() @@ -581,7 +599,16 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True, for key, value in input_dict.items(): if key != comment_key and full_document and seen_root: raise ValueError("Document must have exactly one root.") - _emit(key, value, content_handler, full_document=full_document, comment_key=comment_key, **kwargs) + _emit( + key, + value, + content_handler, + full_document=full_document, + encoding=encoding, + bytes_errors=bytes_errors, + comment_key=comment_key, + **kwargs, + ) if key != comment_key: seen_root = True if full_document and not seen_root: diff --git a/contrib/python/xmltodict/py3/ya.make b/contrib/python/xmltodict/py3/ya.make index a26f3f5f3ee..2bf0720ef05 100644 --- a/contrib/python/xmltodict/py3/ya.make +++ b/contrib/python/xmltodict/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(1.0.3) +VERSION(1.0.4) LICENSE(MIT) |
