From ece8951574ece55b1ae5cb283e349b13cd69a72a Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 25 Mar 2026 16:48:07 +0100 Subject: [PATCH 1/3] merge _click.Path and TyperPath --- typer/_click/__init__.py | 1 - typer/_click/types.py | 169 --------------------------------------- typer/models.py | 100 ++++++++++++++++++++++- 3 files changed, 97 insertions(+), 173 deletions(-) diff --git a/typer/_click/__init__.py b/typer/_click/__init__.py index 3b9455b78a..075b0707a6 100644 --- a/typer/_click/__init__.py +++ b/typer/_click/__init__.py @@ -42,7 +42,6 @@ from .types import FloatRange as FloatRange from .types import IntRange as IntRange from .types import ParamType as ParamType -from .types import Path as Path from .types import Tuple as Tuple from .utils import echo as echo from .utils import format_filename as format_filename diff --git a/typer/_click/types.py b/typer/_click/types.py index 01b4f33bfd..cf4f469879 100644 --- a/typer/_click/types.py +++ b/typer/_click/types.py @@ -3,7 +3,6 @@ import collections.abc as cabc import enum import os -import stat import sys import typing as t from datetime import datetime @@ -807,174 +806,6 @@ def _is_file_like(value: t.Any) -> te.TypeGuard[t.IO[t.Any]]: return hasattr(value, "read") or hasattr(value, "write") -class Path(ParamType): - """The ``Path`` type is similar to the :class:`File` type, but - returns the filename instead of an open file. Various checks can be - enabled to validate the type of file and permissions. - - :param exists: The file or directory needs to exist for the value to - be valid. If this is not set to ``True``, and the file does not - exist, then all further checks are silently skipped. - :param file_okay: Allow a file as a value. - :param dir_okay: Allow a directory as a value. - :param readable: if true, a readable check is performed. - :param writable: if true, a writable check is performed. - :param executable: if true, an executable check is performed. - :param resolve_path: Make the value absolute and resolve any - symlinks. A ``~`` is not expanded, as this is supposed to be - done by the shell only. - :param allow_dash: Allow a single dash as a value, which indicates - a standard stream (but does not open it). - :param path_type: Convert the incoming path value to this type. If - ``None``, keep Python's default, which is ``str``. Useful to - convert to :class:`pathlib.Path`. - - .. versionchanged:: 8.1 - Added the ``executable`` parameter. - - .. versionchanged:: 8.0 - Allow passing ``path_type=pathlib.Path``. - - .. versionchanged:: 6.0 - Added the ``allow_dash`` parameter. - """ - - envvar_list_splitter: t.ClassVar[str] = os.path.pathsep - - def __init__( - self, - 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: type[t.Any] | None = None, - executable: bool = False, - ): - self.exists = exists - self.file_okay = file_okay - self.dir_okay = dir_okay - self.readable = readable - self.writable = writable - self.executable = executable - self.resolve_path = resolve_path - self.allow_dash = allow_dash - self.type = path_type - - if self.file_okay and not self.dir_okay: - self.name: str = _("file") - elif self.dir_okay and not self.file_okay: - self.name = _("directory") - else: - self.name = _("path") - - def coerce_path_result( - self, value: str | os.PathLike[str] - ) -> str | bytes | os.PathLike[str]: - if self.type is not None and not isinstance(value, self.type): - if self.type is str: - return os.fsdecode(value) - elif self.type is bytes: - return os.fsencode(value) - else: - return t.cast("os.PathLike[str]", self.type(value)) - - return value - - def convert( - self, - value: str | os.PathLike[str], - param: Parameter | None, - ctx: Context | None, - ) -> str | bytes | os.PathLike[str]: - rv = value - - is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") - - if not is_dash: - if self.resolve_path: - rv = os.path.realpath(rv) - - try: - st = os.stat(rv) - except OSError: - if not self.exists: - return self.coerce_path_result(rv) - self.fail( - _("{name} {filename!r} does not exist.").format( - name=self.name.title(), filename=format_filename(value) - ), - param, - ctx, - ) - - if not self.file_okay and stat.S_ISREG(st.st_mode): - self.fail( - _("{name} {filename!r} is a file.").format( - name=self.name.title(), filename=format_filename(value) - ), - param, - ctx, - ) - if not self.dir_okay and stat.S_ISDIR(st.st_mode): - self.fail( - _("{name} {filename!r} is a directory.").format( - name=self.name.title(), filename=format_filename(value) - ), - param, - ctx, - ) - - if self.readable and not os.access(rv, os.R_OK): - self.fail( - _("{name} {filename!r} is not readable.").format( - name=self.name.title(), filename=format_filename(value) - ), - param, - ctx, - ) - - if self.writable and not os.access(rv, os.W_OK): - self.fail( - _("{name} {filename!r} is not writable.").format( - name=self.name.title(), filename=format_filename(value) - ), - param, - ctx, - ) - - if self.executable and not os.access(value, os.X_OK): - self.fail( - _("{name} {filename!r} is not executable.").format( - name=self.name.title(), filename=format_filename(value) - ), - param, - ctx, - ) - - return self.coerce_path_result(rv) - - def shell_complete( - self, ctx: Context, param: Parameter, incomplete: str - ) -> list[CompletionItem]: - """Return a special completion marker that tells the completion - system to use the shell to provide path completions for only - directories or any paths. - - :param ctx: Invocation context for this command. - :param param: The parameter that is requesting completion. - :param incomplete: Value being completed. May be empty. - - .. versionadded:: 8.0 - """ - from .shell_completion import CompletionItem - - type = "dir" if self.dir_okay and not self.file_okay else "file" - return [CompletionItem(incomplete, type=type)] - - class Tuple(CompositeParamType): """The default behavior of Click is to apply a type on a value directly. This works well in most cases, except for when `nargs` is set to a fixed diff --git a/typer/models.py b/typer/models.py index c945e69fd1..2b35ce418c 100644 --- a/typer/models.py +++ b/typer/models.py @@ -1,14 +1,18 @@ import inspect import io +import os +import stat from collections.abc import Callable, Sequence from typing import ( TYPE_CHECKING, Any, + ClassVar, Optional, TypeVar, + cast, ) -from . import _click +from . import _click, format_filename if TYPE_CHECKING: # pragma: no cover from .core import TyperCommand, TyperGroup @@ -639,8 +643,98 @@ def __init__( self.pretty_exceptions_short = pretty_exceptions_short -class TyperPath(_click.Path): - # Overwrite Click's behaviour to be compatible with Typer's autocompletion system +class TyperPath(_click.ParamType): + # Based originally on code from Click + # Partly rewritten and added an override for shell_complete + + envvar_list_splitter: ClassVar[str] = os.path.pathsep + + def __init__( + self, + 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: type[Any] | None = None, + executable: bool = False, + ): + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.readable = readable + self.writable = writable + self.executable = executable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.type = path_type + + if self.file_okay and not self.dir_okay: + self.name = "file" + elif self.dir_okay and not self.file_okay: + self.name = "directory" + else: + self.name = "path" + + def coerce_path_result( + self, value: str | os.PathLike[str] + ) -> str | bytes | os.PathLike[str]: + if self.type is not None and not isinstance(value, self.type): + if self.type is str: + return os.fsdecode(value) + elif self.type is bytes: + return os.fsencode(value) + else: + return cast("os.PathLike[str]", self.type(value)) + + return value + + def convert( + self, + value: str | os.PathLike[str], + param: _click.Parameter | None, + ctx: Context | None, + ) -> str | bytes | os.PathLike[str]: + rv = value + + is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") + + if not is_dash: + if self.resolve_path: + rv = os.path.realpath(rv) + + try: + st = os.stat(rv) + except OSError: + if not self.exists: + return self.coerce_path_result(rv) + self.fail( + f"{self.name.title()} {format_filename(value)!r} does not exist.", + param, + ctx, + ) + + name = self.name.title() + loc = repr(format_filename(value)) + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail(f"{name} {loc} is a file.", param, ctx) + + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail(f"{name} {loc} is a directory.", param, ctx) + + if self.readable and not os.access(rv, os.R_OK): + self.fail(f"{name} {loc} is not readable.", param, ctx) + + if self.writable and not os.access(rv, os.W_OK): + self.fail(f"{name} {loc} is not writable.", param, ctx) + + if self.executable and not os.access(value, os.X_OK): + self.fail(f"{name} {loc} is not executable.", param, ctx) + + return self.coerce_path_result(rv) + def shell_complete( self, ctx: _click.Context, param: _click.Parameter, incomplete: str ) -> list[_click.shell_completion.CompletionItem]: From 7efd0dc9ed5d8350ea7fcbd2b8dd022c74bddc6c Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 25 Mar 2026 16:55:46 +0100 Subject: [PATCH 2/3] disable ty (for now) --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09b0f914f3..959e5f21ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,12 +35,12 @@ repos: language: unsupported pass_filenames: false - - id: local-ty - name: ty check - entry: uv run ty check typer - require_serial: true - language: unsupported - pass_filenames: false +# - id: local-ty +# name: ty check +# entry: uv run ty check typer +# require_serial: true +# language: unsupported +# pass_filenames: false - id: generate-readme language: unsupported From f817535eec6e0bdf50530db9f82599049b1cb8f9 Mon Sep 17 00:00:00 2001 From: svlandeg Date: Wed, 25 Mar 2026 16:57:59 +0100 Subject: [PATCH 3/3] fix mypy warning --- typer/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typer/models.py b/typer/models.py index 2b35ce418c..eb5c76f1f3 100644 --- a/typer/models.py +++ b/typer/models.py @@ -695,7 +695,7 @@ def convert( self, value: str | os.PathLike[str], param: _click.Parameter | None, - ctx: Context | None, + ctx: Context | None, # type: ignore[override] ) -> str | bytes | os.PathLike[str]: rv = value