[QUESTION] How to avoid explicit call to subcommands command #1074
-
First check
DescriptionI'm not sure how to articulate this question without an example. Because of that I had some trouble googling around. I have two installed packages,
So here goes with an example. In one file import typer
app = typer.Typer()
@app.command()
def main(path: str = typer.Argument(...)):
typer.echo(f"Doing bar things to {path}") If I call
which is great. This is installed with this """Setup."""
from setuptools import find_packages
from setuptools import setup
PACKAGE_NAME = "bar"
setup(
name=PACKAGE_NAME,
version="0.0.1",
package_dir={"": "src"},
packages=find_packages(where="src"),
zip_safe=False,
include_package_data=True,
python_requires=">=3.7",
entry_points={
"console_scripts": [f"{PACKAGE_NAME} = {PACKAGE_NAME}.cli:app"],
"foo.cli_plugins": [f"{PACKAGE_NAME} = {PACKAGE_NAME}.cli"],
},
install_requires=["typer"],
)
In another file import typer
import pkg_resources
app = typer.Typer()
# real program has lots of plugins
CLI_PLUGINS = {
entry_point.name: entry_point.load()
for entry_point in pkg_resources.iter_entry_points("foo.cli_plugins")
}
for name, entry_point in CLI_PLUGINS.items():
app.add_typer(entry_point.app, name=name)
@app.command()
def fizz(path: str = typer.Argument(...)):
typer.echo(f"Fizzy {path}") This is installed with this """Setup."""
from pathlib import Path
from setuptools import find_packages
from setuptools import setup
PACKAGE_NAME = "foo"
setup(
name=PACKAGE_NAME,
version="0.0.1",
package_dir={"": "src"},
packages=find_packages(where="src"),
zip_safe=False,
include_package_data=True,
python_requires=">=3.7",
entry_points={
"console_scripts": [f"{PACKAGE_NAME} = {PACKAGE_NAME}.cli:app"],
},
install_requires=["typer"],
)
When I call
which is also great. This is exactly what I want. However.. When I call
Which is not what I want. I don't want to have to call Question
What I want for
Additional contextI have tried adding a callback to the app.add_typer(entry_point.app, name=name) to app.add_typer(entry_point.app, name=name, callback=entry_point.main)
I feel like there is a nice way to get this working and I just cannot see it. I looked at the documentation (which is beautiful) and the example with subcommands doesn't quite match this use case. There the subcommands are designed to be called explicitly. If there is any more information I can provide please let me know. Thank you for your great work on |
Beta Was this translation helpful? Give feedback.
Replies: 14 comments
-
I played around with this for a while and came up with a potential solution: Change this: for name, entry_point in CLI_PLUGINS.items():
app.add_typer(entry_point.app, name=name) To this: for name, entry_point in CLI_PLUGINS.items():
app.registered_commands.append(CommandInfo(name=name, callback=entry_point.main)) Which appears to work. Is there a better way to do this? Are there any unintended consequences of this approach? I don't see any documentation for Thanks again for your time, apologies for the wall of text. |
Beta Was this translation helpful? Give feedback.
-
Hey @Andrew-Sheridan , here's the thing, in the first case, when you do On the second case, as it is included as a sub-command of the main application and it is a complete Typer application, then it can no longer assume that it's just a simple command directly and will treat it as a group, no matter if it has one or several subcommands. In this case, I think you can make it explicitly a group from the beginning, by not using The That way, it would run directly when you call the first version alone, and should work fine when you use Maybe the docs here can help: https://typer.tiangolo.com/tutorial/commands/callback/ |
Beta Was this translation helpful? Give feedback.
-
@tiangolo Thank you answering! Your explanation seems to make sense but I can't actually seem to implement it correctly. I've gone through the docs and the examples but something just must not be clicking. If I set the
import typer
app = typer.Typer()
@app.callback()
def main(path: str = typer.Argument(...)):
typer.echo(f"Doing bar things to {path}")
You can see it asking for a command when there isn't one. The same issue occurs when calling it with Is there an aspect to this I'm missing or should I fall back to the undocumented Thank you again |
Beta Was this translation helpful? Give feedback.
-
@Andrew-Sheridan @tiangolo codesub.pyimport typer
app = typer.Typer()
@app.command()
def main(arg: str):
typer.echo(f'sub(arg={arg})')
if __name__ == "__main__":
app() sub2.pyimport typer
app = typer.Typer()
@app.command()
def cmd1(arg: str):
typer.echo(f'sub2.cmd1(arg={arg})')
@app.command()
def cmd2(arg: str):
typer.echo(f'sub2.cmd2(arg={arg})')
if __name__ == "__main__":
app() main.pyimport typer
import sub, sub2
app = typer.Typer()
@app.command()
def main(arg: str):
typer.echo(f'main(arg={arg})')
if __name__ == "__main__":
if len(sub.app.registered_commands) == 1:
app.command('sub')(sub.app.registered_commands[0].callback)
else:
app.add_typer(sub.app, name='sub')
if len(sub2.app.registered_commands) == 1:
app.command('sub2')(sub2.app.registered_commands[0].callback)
else:
app.add_typer(sub2.app, name='sub2')
app() runmain.py$ python main.py --help
Usage: main.py [OPTIONS] COMMAND [ARGS]...
Options:
--install-completion [bash|zsh|fish|powershell|pwsh]
Install completion for the specified shell.
--show-completion [bash|zsh|fish|powershell|pwsh]
Show completion for the specified shell, to
copy it or customize the installation.
--help Show this message and exit.
Commands:
main
sub
sub2
$ python main.py main --help
Usage: main.py main [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--help Show this message and exit.
$ python main.py sub --help
Usage: main.py sub [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--help Show this message and exit.
$ python main.py sub2 --help
Usage: main.py sub2 [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
cmd1
cmd2
$ python main.py sub2 cmd1 --help
Usage: main.py sub2 cmd1 [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--help Show this message and exit.
$ python main.py sub2 cmd2 --help
Usage: main.py sub2 cmd2 [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--help Show this message and exit.
$ python main.py main sth
main(arg=sth)
$ python main.py sub sth
sub(arg=sth)
$ python main.py sub2 cmd1 sth
sub2.cmd1(arg=sth)
$ python main.py sub2 cmd2 sth
sub2.cmd2(arg=sth) sub.py$ python sub.py --help
Usage: sub.py [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--install-completion [bash|zsh|fish|powershell|pwsh]
Install completion for the specified shell.
--show-completion [bash|zsh|fish|powershell|pwsh]
Show completion for the specified shell, to
copy it or customize the installation.
--help Show this message and exit.
$ python sub.py sth
sub(arg=sth) sub2.py$ python sub2.py --help
Usage: sub2.py [OPTIONS] COMMAND [ARGS]...
Options:
--install-completion [bash|zsh|fish|powershell|pwsh]
Install completion for the specified shell.
--show-completion [bash|zsh|fish|powershell|pwsh]
Show completion for the specified shell, to
copy it or customize the installation.
--help Show this message and exit.
Commands:
cmd1
cmd2
$ python sub2.py cmd1 --help
Usage: sub2.py cmd1 [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--help Show this message and exit.
$ python sub2.py cmd2 --help
Usage: sub2.py cmd2 [OPTIONS] ARG
Arguments:
ARG [required]
Options:
--help Show this message and exit.
$ python sub2.py cmd1 sth
sub2.cmd1(arg=sth)
$ python sub2.py cmd2 sth
sub2.cmd2(arg=sth) |
Beta Was this translation helpful? Give feedback.
-
Well done @cataerogong I had similar problem but in my case I knew, that once I want to add command group, another time single subcommand. For the later the callable can be simply registered via user.pyimport typer
app = typer.Typer()
@app.command()
def create(name: str):
"""Create user
"""
typer.echo(f"Creating user: {name}")
@app.command()
def delete(name: str):
"""Delete user
"""
typer.echo(f"Deleting user: {name}")
if __name__ == "__main__":
app() hello.pyimport typer
app = typer.Typer()
@app.command(name="hello")
def action(name: str):
"""Say Hello to NAME.
"""
typer.echo(f"Hello {name}")
if __name__ == "__main__":
app() main.pyimport typer
import hello
import user
app = typer.Typer()
app.command(name="hello")(hello.action)
app.add_typer(user.app, name="user")
if __name__ == "__main__":
app() At Typer is not modifying the callable Implicit expectationsAs seen in this issue, there are more users having the same expectation, that It would be nice, if Typer would work this (implicitly expected) way. So far it was the only case when Typer it surprised me. Great work @tiangolo. |
Beta Was this translation helpful? Give feedback.
-
Thank you both for your comments and suggestions! I'll be reviewing these in more depth over the weekend. In the mean time, I'm curious if anyone could comment on my note about using  |
Beta Was this translation helpful? Give feedback.
-
Running on Typer v0.3.2, I tried all of the suggestions above for sub-modules with only 1 command. Unfortunately, none of them seem to work 😕 |
Beta Was this translation helpful? Give feedback.
-
What about the |
Beta Was this translation helpful? Give feedback.
-
@andrewasheridan that as well. I started at the top. Here is my project structure:
import typer
cli = typer.Typer()
def arg_check(values: List[str]) -> List[str]:
for value in values:
if value not in ['wikipedia', 'reddit']:
raise typer.BadParameter(f"Bad value '{value}'.")
return values
@cli.command()
def crawl(websites: List[str] = typer.Argument(..., callback=arg_check)):
typer.echo(websites)
import typer
from cli.commands import cmd_crawl
cli = typer.Typer()
cli.add_typer(cmd_crawl.cli, name="crawl") Which would make me type in the terminal:
import typer
from typer.models import CommandInfo
from cli.commands import cmd_crawl
cli = typer.Typer()
cli.registered_commands.append(CommandInfo(name="crawl", callback=cmd_crawl.crawl)) Now, these don't work: But this one does (not what I want): |
Beta Was this translation helpful? Give feedback.
-
OK... I solved my problem:
import typer
# cli = typer.Typer() -> No need to create a Typer app instance
def arg_check(values: List[str]) -> List[str]:
for value in values:
if value not in ['wikipedia', 'reddit']:
raise typer.BadParameter(f"Bad value '{value}'.")
return values
# @cli.command() -> We just need to have a simple function.
def crawl(websites: List[str] = typer.Argument(..., callback=arg_check)):
typer.echo(websites)
import typer
from cli.commands import cmd_crawl
cli = typer.Typer()
# cli.add_typer(cmd_crawl.cli, name="crawl")
cli.command(name="crawl")(cmd_crawl.crawl) Now this works: |
Beta Was this translation helpful? Give feedback.
-
Update: Steps below do not work, COMMAND is still expected.
The invoke_without_command option appears to solve this problem. @app.callback(invoke_without_command=True)
def main(path: str = typer.Argument(...)):
typer.echo(f"Doing bar things to {path}") With this option set, the command help appears correctly and it can be executed. I'm not sure if this is a gross misuse of the option, but it works as expected for adding a single subcommand to the command tree. |
Beta Was this translation helpful? Give feedback.
-
This is just what I was in need for. In my humble view, an example/use-case of this deserves its own section in the tutorial. ps- https://gitlab.com/NikosAlexandris/typer-command-with-two-subcommands |
Beta Was this translation helpful? Give feedback.
-
Following the above recommendation, there seems to be some annoyance : shouldn't the I followed the above recommendation and it works. There is one problem, which is reported elsewhere, I recall but cannot find it. Here's one more example, using a custom package after installing it with
plotdata on main [!?] via v3.10.9 (.plotdata_virtual_environment)
❯ plot-data --help
Usage: plot-data [OPTIONS] COMMAND [ARGS]...
callback() : Plot time series
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --verbose --no-verbose Verbosity level [default: no-verbose] │
│ --version -v Show the application's version and exit. │
│ --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the specified shell. [default: None] │
│ --show-completion [bash|zsh|fish|powershell|pwsh] Show completion for the specified shell, to copy it or customize the installation. │
│ [default: None] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ outliers Plot series │
│ series Plot series │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ Then, asking for help on the subcommand plotdata on main [!?] via v3.10.9 (.plotdata_virtual_environment)
❯ plot-data series --help
Usage: plot-data series [OPTIONS] NETCDF [LONGITUDE] [LATITUDE] COMMAND
[ARGS]...
Plot series
╭─ Arguments ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ * netcdf PATH Input netCDF file [default: None] [required] │
│ longitude [LONGITUDE] Longitude in decimal degrees ranging in [0, 360]. [default: None] │
│ latitude [LATITUDE] Latitude in decimal degrees, south is negative [default: None] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --output-filename PATH Figure output filename [default: series_in] │
│ --variable-name-as-suffix --no-variable-name-as-suffix Suffix the output filename with the variable [default: variable-name-as-suffix] │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Helpers ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --zero-longitude-center --no-zero-longitude-center Convert range of of longitude values to [-180, 180] [default: no-zero-longitude-center] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ However, asking for plotdata on main [!?] via v3.10.9 (.plotdata_virtual_environment)
❯ plot-data series /spacetime/pvgis/from_jeodpp_nextcloud/raster/era5/2009_01_cds_era5_surface_net_solar_radiation.nc -42.419 -22.275 --help
Usage: plot-data series [OPTIONS] NETCDF [LONGITUDE] [LATITUDE] COMMAND
[ARGS]...
Try 'plot-data series --help' for help.
╭─ Error ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Missing argument 'NETCDF'. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ |
Beta Was this translation helpful? Give feedback.
-
This was fixed in Typer 0.15.0, now if you don't pass name to See the docs here: https://typer.tiangolo.com/tutorial/one-file-per-command/ |
Beta Was this translation helpful? Give feedback.
This was fixed in Typer 0.15.0, now if you don't pass name to
add_typer
the commands will merged into the app!See the docs here: https://typer.tiangolo.com/tutorial/one-file-per-command/