Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion typer/_click/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 0 additions & 169 deletions typer/_click/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
100 changes: 97 additions & 3 deletions typer/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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, # type: ignore[override]
) -> 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]:
Expand Down
Loading