shtab icon indicating copy to clipboard operation
shtab copied to clipboard

support `type={pathlib.Path,argparse.FileType}` without explicit `.complete = shtab.FILE`

Open mpkocher opened this issue 7 months ago • 6 comments

Requirements:

  • When defining arguments with using pathlib.Path, such as p.add_argument("-s", "--src", type=Path) on an ArgumentParser instance, the autocomplete mechanism should understand the type and emit an autocomplete that is compatible with both a file or directory.

Context/Comments

  • Path is commonly used and communicates the fundamental intent that the argument is PathLike.
  • Currently, when defining File/Dir, the user needs to add .complete (e.g., p.add_argument("-s", "--src", type=Path).complete = shtab.FILE). This is easy to forget and it's not a particulary obvious interface to Action. Also, your text editing won't be able to help or assist you. For example, a simple mistyping of .completion = shtab.FILE can create confusion.
  • Adding .complete doesn't play well with static analysis tools such as mypy in strict mode. Using .complete = ... will yield an error (example.py:29: error: "Action" has no attribute "complete" [attr-defined]
  • Using .complete = shtab.FILE model requires an explicit dependency on shtab. It's useful to make shtab an optional dependency with a try/catch ImportError and use shtab.add_argument_to(p) when defining a get_parser() function.
  • If you don't explicitly add .complete style for a Path, then you loose any "default" autocomplete mechanics of file/dir behavior in the shell.

mpkocher avatar May 30 '25 06:05 mpkocher

A bit hesitant as it's not possible to infer which (FILE or DIR) the user expects type=Path to mean.

casperdcl avatar May 30 '25 08:05 casperdcl

A bit hesitant as it's not possible to infer which (FILE or DIR) the user expects type=Path to mean.

I agree, but adding basic support for autocompleting any file or directory would be useful out of the box experience. If you need to further customize it, then you can use the specific file or directory version (e.g, .complete = shtab.DIR).

For example, in zsh, I believe converting :input_file: to :input_file:_files: would be good enough for most cases with Path?

mpkocher avatar Jun 01 '25 06:06 mpkocher

Also should add argparse.FileType too I suppose

casperdcl avatar Jun 18 '25 18:06 casperdcl

Currently, there's two different styles with the usage of choices= and the .completer=.

 p = ArgumentParser(description="Desc")
 p.add_argument("-s", "--src",  help="Source file", choices=shtab.Required.FILE)
 p.add_argument("-d", "--dest", help="Dest file").completer = shtab.FILE

It might useful to explore ideas of putting the Completion component into the type= of .add_argument (Only requires Callable[[Any], T]] as an interface) and have something like this:

def example() -> ArgumentParser:
    p = ArgumentParser(description="Scratch Pad autocomplete")
    p.add_argument("-s", "--src", type=FileCompleter(exts=(".py", ".rb")), help="Source file", required=False)
    # the required or optional should be pulled from the keyword
    p.add_argument("-o", "--output-dir", type=DirCompleter(), help="Output directory", required=True)
    p.add_argument("-m", "--max-records", type=int, help="Max number of records")
    return shtab.add_argument_to(p)

With adding .complete to provide a customizable and re-useable hook.


class Completer(Protocol):

    @abstractmethod
    def __call__(self, args: Any, **kwargs: Any) -> Any:
        ...

    def complete(self, name: str) -> str:
        cx = {"zsh": self.complete_zsh,
              "bash": self.complete_bash,
              "fish": self.complete_fish}
        return cx[name]()

    @abstractmethod
    def complete_zsh(self) -> str:
        raise NotImplementedError

    @abstractmethod
    def complete_bash(self) -> str:
        raise NotImplementedError

    @abstractmethod
    def complete_fish(self) -> str:
        raise NotImplementedError



class FileCompleter(Completer):
    def __init__(self, exts: tuple[str, ...]):
        self.exts = exts

    def __call__(self, args: Any, **kwargs: Any) -> Any:
        px = Path(args)
        if px.is_file() and px.suffix in self.exts:
            return px
        raise ValueError(f"{px} is not a file")

    def complete_zsh(self) -> str:
        # do stuff with ext to
        return ""

    def complete_bash(self) -> str:
        return ""

    def complete_fish(self) -> str:
        return ""


class DirCompleter(Completer):
    def __init__(self):
        ...

    def __call__(self, args: Any, **kwargs: Any) -> Any:
        px = Path(args)
        if px.is_dir():
            return px
        raise ValueError(f"{px} is not a directory")

    def complete_zsh(self) -> str:
        # emit
        return ""

    def complete_bash(self) -> str:
        return ""

    def complete_fish(self) -> str:
        return ""

mpkocher avatar Jun 20 '25 06:06 mpkocher

If we want to piggy-back off of types it might be better to have:

p.add_argument("-o", "--output-dir",
    type=typing.Annotated[str, shtab.DIR],
    help="Output directory")

casperdcl avatar Jun 20 '25 07:06 casperdcl

Also should add argparse.FileType too I suppose

It looks like this is starting the depreciated process with a warning in 3.14.

https://github.com/python/cpython/issues/58032

mpkocher avatar Jun 26 '25 02:06 mpkocher