From 87aad96fffec89c473dd429d48d85a688334a1e8 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:10:45 +1200 Subject: [PATCH 01/10] Refactor the handling of shellingham lazy-loading --- pyproject.toml | 7 ++-- .../test_completion_install.py | 3 +- tests/test_completion/test_completion_show.py | 3 +- tests/test_others.py | 3 +- tests/utils.py | 16 ++------ typer/_completion_classes.py | 5 --- typer/_completion_shared.py | 9 ++--- typer/completion.py | 38 ++++++++++++++----- typer/core.py | 1 + 9 files changed, 45 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 62acd7d5cb..a8de10e9af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # "__init__.py" = ["F401"] -# rich_utils is allowed to use rich imports +# `rich_utils` is allowed to use rich imports "typer/rich_utils.py" = ["TID251"] # This file is more readable without yield from "docs_src/progressbar/tutorial004.py" = ["UP028", "B007"] @@ -209,8 +209,9 @@ known-first-party = ["reigns", "towns", "lands", "items", "users"] keep-runtime-typing = true [tool.ruff.lint.flake8-tidy-imports] -# Import rich_utils from within functions (lazy), not at the module level (TID253) -banned-module-level-imports = ["typer.rich_utils"] +# Import rich_utils and shellingham from within functions (lazy), +# not at the module level (TID253) +banned-module-level-imports = ["typer.rich_utils", "shellingham"] [tool.ruff.lint.flake8-tidy-imports.banned-api] "rich".msg = "Use 'typer.rich_utils' instead of importing from 'rich' directly." diff --git a/tests/test_completion/test_completion_install.py b/tests/test_completion/test_completion_install.py index 873c1416e9..f6b95deb66 100644 --- a/tests/test_completion/test_completion_install.py +++ b/tests/test_completion/test_completion_install.py @@ -4,7 +4,6 @@ from pathlib import Path from unittest import mock -import shellingham import typer from typer.testing import CliRunner @@ -141,6 +140,8 @@ def test_completion_install_fish(): @requires_completion_permission def test_completion_install_powershell(): + import shellingham + completion_path: Path = ( Path.home() / ".config/powershell/Microsoft.PowerShell_profile.ps1" ) diff --git a/tests/test_completion/test_completion_show.py b/tests/test_completion/test_completion_show.py index 10d8b6ff39..3db2696f8e 100644 --- a/tests/test_completion/test_completion_show.py +++ b/tests/test_completion/test_completion_show.py @@ -3,7 +3,6 @@ import sys from unittest import mock -import shellingham import typer from typer.testing import CliRunner @@ -142,6 +141,8 @@ def test_completion_source_pwsh(): def test_completion_show_invalid_shell(): + import shellingham + with mock.patch.object( shellingham, "detect_shell", return_value=("xshell", "/usr/bin/xshell") ): diff --git a/tests/test_others.py b/tests/test_others.py index 93f8728072..75340cd7c1 100644 --- a/tests/test_others.py +++ b/tests/test_others.py @@ -7,7 +7,6 @@ import click import pytest -import shellingham import typer import typer.completion from typer.core import _split_opt @@ -78,6 +77,8 @@ def convert( @requires_completion_permission def test_install_invalid_shell(): + import shellingham + app = typer.Typer() @app.command() diff --git a/tests/utils.py b/tests/utils.py index 019b006fa0..5c5413a7d5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,18 +2,7 @@ from os import getenv import pytest - -try: - import shellingham - from shellingham import ShellDetectionFailure - - shell = shellingham.detect_shell()[0] -except ImportError: # pragma: no cover - shellingham = None - shell = None -except ShellDetectionFailure: # pragma: no cover - shell = None - +from typer.completion import _get_shell_name needs_py310 = pytest.mark.skipif( sys.version_info < (3, 10), reason="requires python3.10+" @@ -23,8 +12,9 @@ not sys.platform.startswith("linux"), reason="Test requires Linux" ) +shell_name = _get_shell_name() needs_bash = pytest.mark.skipif( - not shellingham or not shell or "bash" not in shell, reason="Test requires Bash" + shell_name is None or "bash" not in shell_name, reason="Test requires Bash" ) requires_completion_permission = pytest.mark.skipif( diff --git a/typer/_completion_classes.py b/typer/_completion_classes.py index 5980248afe..b726865e93 100644 --- a/typer/_completion_classes.py +++ b/typer/_completion_classes.py @@ -24,11 +24,6 @@ split_arg_string as click_split_arg_string, ) -try: - import shellingham -except ImportError: # pragma: no cover - shellingham = None - def _sanitize_help_text(text: str) -> str: """Sanitizes the help text by removing rich tags""" diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py index cc0add992c..fd4759c46c 100644 --- a/typer/_completion_shared.py +++ b/typer/_completion_shared.py @@ -7,10 +7,7 @@ import click -try: - import shellingham -except ImportError: # pragma: no cover - shellingham = None +from .completion import _get_shell_name class Shells(str, Enum): @@ -213,8 +210,8 @@ def install( 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 shellingham is not None and not test_disable_detection: - shell, _ = shellingham.detect_shell() + 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 diff --git a/typer/completion.py b/typer/completion.py index c355baa781..7cd6a5e9c8 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -6,23 +6,18 @@ from ._completion_classes import completion_init from ._completion_shared import Shells, get_completion_script, install +from .core import HAS_SHELLINGHAM from .models import ParamMeta from .params import Option from .utils import get_params_from_function -try: - import shellingham -except ImportError: # pragma: no cover - shellingham = None - - _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 shellingham and not test_disable_detection: + if HAS_SHELLINGHAM and not test_disable_detection: parameters = get_params_from_function(_install_completion_placeholder_function) else: parameters = get_params_from_function( @@ -50,12 +45,15 @@ def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any 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 shellingham and not test_disable_detection: - shell, _ = shellingham.detect_shell() + elif not test_disable_detection: + shell = _get_shell_name() + if shell is None: + shell = "" + else: + shell = "" script_content = get_completion_script( prog_name=prog_name, complete_var=complete_var, shell=shell ) @@ -147,3 +145,23 @@ def shell_complete( click.echo(f'Completion instruction "{instruction}" not supported.', err=True) return 1 + +def _get_shell_name() -> str | None: + """Get the current shell name, if available. + + The name will always be lowercase. If the shell cannot be detected, None is + returned. + """ + if HAS_SHELLINGHAM: + import shellingham + + try: + # N.B. detect_shell returns a tuple of (shell name, shell command). + # We only need the name. + name, _cmd = shellingham.detect_shell() + except shellingham.ShellDetectionFailure: # pragma: no cover + name = None + else: + name = None # pragma: no cover + + return name diff --git a/typer/core.py b/typer/core.py index 048f28c137..7f555539b5 100644 --- a/typer/core.py +++ b/typer/core.py @@ -32,6 +32,7 @@ MarkupMode = Literal["markdown", "rich", None] 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" From 9a8a4e606a5bac5dd152a63b937fd9a055763f81 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 09:11:17 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/completion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/typer/completion.py b/typer/completion.py index 7cd6a5e9c8..5b2eee5301 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -146,6 +146,7 @@ def shell_complete( click.echo(f'Completion instruction "{instruction}" not supported.', err=True) return 1 + def _get_shell_name() -> str | None: """Get the current shell name, if available. From aa61b968a90fbff8f960aa4f26ed0e9a98b61305 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:15:41 +1200 Subject: [PATCH 03/10] Refactor to mypy compliance --- typer/completion.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/typer/completion.py b/typer/completion.py index 7cd6a5e9c8..f86fca3b82 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -45,15 +45,14 @@ def show_callback(ctx: click.Context, param: click.Parameter, value: Any) -> Any 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: - shell = _get_shell_name() - if shell is None: - shell = "" - else: - shell = "" + 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 ) @@ -152,6 +151,7 @@ def _get_shell_name() -> str | None: The name will always be lowercase. If the shell cannot be detected, None is returned. """ + name: str | None # N.B. shellingham is untyped if HAS_SHELLINGHAM: import shellingham From 2b82a1bd4388d2e31fcd35ebea0b059d925dbca5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 09:15:52 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/completion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/completion.py b/typer/completion.py index 4e416c79ec..f589332690 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -152,7 +152,7 @@ def _get_shell_name() -> str | None: The name will always be lowercase. If the shell cannot be detected, None is returned. """ - name: str | None # N.B. shellingham is untyped + name: str | None # N.B. shellingham is untyped if HAS_SHELLINGHAM: import shellingham From f7e89c80a6f92963a1ad531125594d6c08323efa Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:16:09 +1200 Subject: [PATCH 05/10] Remove backticks from comment --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a8de10e9af..d7f609f751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] # "__init__.py" = ["F401"] -# `rich_utils` is allowed to use rich imports +# rich_utils is allowed to use rich imports "typer/rich_utils.py" = ["TID251"] # This file is more readable without yield from "docs_src/progressbar/tutorial004.py" = ["UP028", "B007"] From 2f0fb636fe6295848b97f834fc6ed4bb2fe613b4 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:17:15 +1200 Subject: [PATCH 06/10] Rename variable for consistency --- tests/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/utils.py b/tests/utils.py index 5c5413a7d5..fb46342e38 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -12,9 +12,9 @@ not sys.platform.startswith("linux"), reason="Test requires Linux" ) -shell_name = _get_shell_name() +shell = _get_shell_name() needs_bash = pytest.mark.skipif( - shell_name is None or "bash" not in shell_name, reason="Test requires Bash" + shell is None or "bash" not in shell, reason="Test requires Bash" ) requires_completion_permission = pytest.mark.skipif( From d7f84ba1293564818905cb1be1927d2b3364246d Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:24:06 +1200 Subject: [PATCH 07/10] Ban calls to `shellingham.detect_shell` --- pyproject.toml | 1 + typer/completion.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a8de10e9af..82003540e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -215,3 +215,4 @@ banned-module-level-imports = ["typer.rich_utils", "shellingham"] [tool.ruff.lint.flake8-tidy-imports.banned-api] "rich".msg = "Use 'typer.rich_utils' instead of importing from 'rich' directly." +"shellingham.detect_shell".msg = "Use 'typer.completion._get_shell_name' instead of using 'shellingham.detect_shell' directly." diff --git a/typer/completion.py b/typer/completion.py index 4e416c79ec..5601af9132 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -159,7 +159,7 @@ def _get_shell_name() -> str | None: try: # N.B. detect_shell returns a tuple of (shell name, shell command). # We only need the name. - name, _cmd = shellingham.detect_shell() + name, _cmd = shellingham.detect_shell() # noqa: TID251 except shellingham.ShellDetectionFailure: # pragma: no cover name = None else: From 9fab46b5adac3c7e6e13fbaef13b35efcdd28d66 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:29:32 +1200 Subject: [PATCH 08/10] Move `_get_shell_name` to `._completion_shared` to avoid circular imports --- pyproject.toml | 2 +- tests/utils.py | 2 +- typer/_completion_shared.py | 24 ++++++++++++++++++++++-- typer/completion.py | 22 +--------------------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3878d4ef1a..cb490aab14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -215,4 +215,4 @@ banned-module-level-imports = ["typer.rich_utils", "shellingham"] [tool.ruff.lint.flake8-tidy-imports.banned-api] "rich".msg = "Use 'typer.rich_utils' instead of importing from 'rich' directly." -"shellingham.detect_shell".msg = "Use 'typer.completion._get_shell_name' instead of using 'shellingham.detect_shell' directly." +"shellingham.detect_shell".msg = "Use 'typer._completion_shared._get_shell_name' instead of using 'shellingham.detect_shell' directly." diff --git a/tests/utils.py b/tests/utils.py index fb46342e38..ee190d8438 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -2,7 +2,7 @@ from os import getenv import pytest -from typer.completion import _get_shell_name +from typer._completion_shared import _get_shell_name needs_py310 = pytest.mark.skipif( sys.version_info < (3, 10), reason="requires python3.10+" diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py index fd4759c46c..f4c0b12c57 100644 --- a/typer/_completion_shared.py +++ b/typer/_completion_shared.py @@ -6,8 +6,7 @@ from typing import Optional, Tuple import click - -from .completion import _get_shell_name +from typer.core import HAS_SHELLINGHAM class Shells(str, Enum): @@ -235,3 +234,24 @@ def install( else: click.echo(f"Shell {shell} is not supported.") raise click.exceptions.Exit(1) + +def _get_shell_name() -> 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: str | None # N.B. shellingham is untyped + if HAS_SHELLINGHAM: + import 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/typer/completion.py b/typer/completion.py index 50c8ad89fc..576c9bc4f2 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -5,7 +5,7 @@ import click from ._completion_classes import completion_init -from ._completion_shared import Shells, get_completion_script, install +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 @@ -146,23 +146,3 @@ def shell_complete( return 1 -def _get_shell_name() -> 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: str | None # N.B. shellingham is untyped - if HAS_SHELLINGHAM: - import 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 From f61dece794967f1085d330dfe703099aba71ae9d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 09:29:44 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- typer/_completion_shared.py | 1 + typer/completion.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py index f4c0b12c57..3f1be77225 100644 --- a/typer/_completion_shared.py +++ b/typer/_completion_shared.py @@ -235,6 +235,7 @@ def install( click.echo(f"Shell {shell} is not supported.") raise click.exceptions.Exit(1) + def _get_shell_name() -> str | None: """Get the current shell name, if available. diff --git a/typer/completion.py b/typer/completion.py index 576c9bc4f2..b2080c05b1 100644 --- a/typer/completion.py +++ b/typer/completion.py @@ -144,5 +144,3 @@ def shell_complete( click.echo(f'Completion instruction "{instruction}" not supported.', err=True) return 1 - - From d599732d29809046ab3415054f2f124841641576 Mon Sep 17 00:00:00 2001 From: Nathan McDougall Date: Sat, 20 Sep 2025 21:32:33 +1200 Subject: [PATCH 10/10] Use pre-3.10 style type unions --- typer/_completion_shared.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/typer/_completion_shared.py b/typer/_completion_shared.py index f4c0b12c57..12e518326e 100644 --- a/typer/_completion_shared.py +++ b/typer/_completion_shared.py @@ -3,7 +3,7 @@ import subprocess from enum import Enum from pathlib import Path -from typing import Optional, Tuple +from typing import Optional, Tuple, Union import click from typer.core import HAS_SHELLINGHAM @@ -235,13 +235,13 @@ def install( click.echo(f"Shell {shell} is not supported.") raise click.exceptions.Exit(1) -def _get_shell_name() -> str | None: +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: str | None # N.B. shellingham is untyped + name: Union[str, None] # N.B. shellingham is untyped if HAS_SHELLINGHAM: import shellingham