Skip to content

Commit 2681205

Browse files
patrick91pre-commit-ci[bot]tiangolo
authored
✨ Add support for extending typer apps without passing a name, add commands to the top level (#1037)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sebastián Ramírez <[email protected]>
1 parent 3af0a84 commit 2681205

File tree

15 files changed

+469
-6
lines changed

15 files changed

+469
-6
lines changed

docs/tutorial/one-file-per-command.md

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# One File Per Command
2+
3+
When your CLI application grows, you can split it into multiple files and modules. This pattern helps maintain a clean and organized code structure. ✨
4+
5+
This tutorial will show you how to use `add_typer` to create sub commands and organize your commands in multiple files.
6+
7+
We will create a simple CLI with the following commands:
8+
9+
- `version`
10+
- `users add NAME`
11+
- `users delete NAME`
12+
13+
## CLI structure
14+
15+
Here is the structure we'll be working with:
16+
17+
```text
18+
mycli/
19+
├── __init__.py
20+
├── main.py
21+
├── users/
22+
│ ├── __init__.py
23+
│ ├── add.py
24+
│ └── delete.py
25+
└── version.py
26+
```
27+
28+
`mycli` will be our <abbr title="a directory with an __init__.py file, it can be imported">package</abbr>, and it will contain the following modules:
29+
30+
- `main.py`: The main <abbr title="a Python file that can be imported">module</abbr> that will import the `version` and `users` modules.
31+
- `version.py`: A <abbr title="a Python file that can be imported">module</abbr> that will contain the `version` command.
32+
- `users/`: A <abbr title="another directory with an __init__.py file, it can also be imported">package</abbr> (inside of our `mycli` package) that will contain the `add` and `delete` commands.
33+
34+
## Implementation
35+
36+
Let's start implementing our CLI! 🚀
37+
38+
We'll create the `version` module, the `main` module, and the `users` package.
39+
40+
### Version Module (`version.py`)
41+
42+
Let's start by creating the `version` module. This module will contain the `version` command.
43+
44+
{* docs_src/one_file_per_command/version.py *}
45+
46+
In this file we are creating a new Typer app instance for the `version` command.
47+
48+
This is not required in single-file applications, but in the case of multi-file applications it will allow us to include this command in the main application using `app.add_typer()`.
49+
50+
Let's see that next!
51+
52+
### Main Module (`main.py`)
53+
54+
The main module will be the entry point of the application. It will import the version module and the users module.
55+
56+
/// tip
57+
58+
We'll see how to implement the users module in the next section.
59+
60+
///
61+
62+
{* docs_src/one_file_per_command/main.py hl[8,9] *}
63+
64+
In this module, we import the `version` and `users` modules and add them to the main app using `app.add_typer()`.
65+
66+
For the `users` module, we specify the name as `"users"` to group the commands under the `users` sub-command.
67+
68+
Notice that we didn't add a name for the `version_app` Typer app. Because of this, Typer will add the commands (just one in this case) declared in the `version_app` directly at the top level. So, there will be a top-level `version` sub-command.
69+
70+
But for `users`, we add a name `"users"`, this way those commands will be under the sub-command `users` instead of at the top level. So, there will be a `users add` and `users delete` sub-sub-commands. 😅
71+
72+
/// tip
73+
74+
If you want a command to group the included commands in a sub-app, add a name.
75+
76+
If you want to include the commands from a sub-app directly at the top level, don't add a name, or set it to `None`. 🤓
77+
78+
///
79+
80+
Let's now create the `users` module with the `add` and `delete` commands.
81+
82+
### Users Add Command (`users/add.py`)
83+
84+
{* docs_src/one_file_per_command/users/add.py *}
85+
86+
Like the `version` module, we create a new Typer app instance for the `users/add` command. This allows us to include the `add` command in the users app.
87+
88+
### Users Delete Command (`users/delete.py`)
89+
90+
{* docs_src/one_file_per_command/users/delete.py *}
91+
92+
And once again, we create a new Typer app instance for the `users/delete` command. This allows us to include the `delete` command in the users app.
93+
94+
### Users' app (`users/__init__.py`)
95+
96+
Finally, we need to create an `__init__.py` file in the `users` directory to define the `users` app.
97+
98+
{* docs_src/one_file_per_command/users/__init__.py *}
99+
100+
Similarly to the `version` module, we create a new `Typer` app instance for the `users` module. This allows us to include the `add` and `delete` commands in the users app.
101+
102+
## Running the Application
103+
104+
Now we are ready to run the application! 😎
105+
106+
To run the application, you can execute it as a Python module:
107+
108+
<div class="termy">
109+
110+
```console
111+
$ python -m mycli.main version
112+
113+
My CLI Version 1.0
114+
115+
$ python -m mycli.main users add Camila
116+
117+
Adding user: Camila
118+
```
119+
120+
</div>
121+
122+
And if you built a package and installed your app, you can then use the `mycli` command:
123+
124+
<div class="termy">
125+
126+
```console
127+
$ mycli version
128+
129+
My CLI Version 1.0
130+
131+
$ mycli users add Camila
132+
133+
Adding user: Camila
134+
```
135+
136+
</div>
137+
138+
## Callbacks
139+
140+
Have in mind that if you include a sub-app with `app.add_typer()` **without a name**, the commands will be added to the top level, so **only the top level callback** (if there's any) will be used, the one declared in the main app.
141+
142+
If you **want to use a callback** for a sub-app, you need to include the sub-app **with a name**, which creates a sub-command grouping the commands in that sub-app. 🤓
143+
144+
In the example above, if the `users` sub-app had a callback, it would be used. But if the `version` sub-app had a callback, it would not be used, because the `version` sub-app was included without a name.

docs_src/one_file_per_command/__init__.py

Whitespace-only changes.

docs_src/one_file_per_command/main.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import typer
2+
3+
from .users import app as users_app
4+
from .version import app as version_app
5+
6+
app = typer.Typer()
7+
8+
app.add_typer(version_app)
9+
app.add_typer(users_app, name="users")
10+
11+
12+
if __name__ == "__main__":
13+
app()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import typer
2+
3+
from .add import app as add_app
4+
from .delete import app as delete_app
5+
6+
app = typer.Typer()
7+
8+
app.add_typer(add_app)
9+
app.add_typer(delete_app)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import typer
2+
3+
app = typer.Typer()
4+
5+
6+
@app.command()
7+
def add(name: str):
8+
print(f"Adding user: {name}")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import typer
2+
3+
app = typer.Typer()
4+
5+
6+
@app.command()
7+
def delete(name: str):
8+
print(f"Deleting user: {name}")
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import typer
2+
3+
app = typer.Typer()
4+
5+
6+
@app.command()
7+
def version():
8+
print("My CLI Version 1.0")

mkdocs.yml

+1
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ nav:
134134
- tutorial/using-click.md
135135
- tutorial/package.md
136136
- tutorial/exceptions.md
137+
- tutorial/one-file-per-command.md
137138
- tutorial/typer-command.md
138139
- Resources:
139140
- resources/index.md

tests/assets/cli/extended_app_cli.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import typer
2+
3+
sub_sub_app = typer.Typer()
4+
5+
6+
@sub_sub_app.command()
7+
def sub_sub_command():
8+
typer.echo("sub_sub_command")
9+
10+
11+
sub_app = typer.Typer()
12+
sub_app.add_typer(sub_sub_app, name="sub")
13+
14+
15+
@sub_app.command()
16+
def hello():
17+
typer.echo("hello there")
18+
19+
20+
@sub_app.command()
21+
def bye():
22+
typer.echo("bye bye")
23+
24+
25+
cli = typer.Typer()
26+
cli.add_typer(sub_app)
27+
28+
29+
@cli.command()
30+
def top():
31+
typer.echo("top")
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import typer
2+
3+
cli = typer.Typer()
4+
sub_app = typer.Typer()
5+
cli.add_typer(sub_app)
6+
7+
8+
@sub_app.command()
9+
def hello():
10+
typer.echo("hello there")
11+
12+
13+
@sub_app.command()
14+
def bye():
15+
typer.echo("bye bye")

tests/test_cli/test_extending_app.py

+120
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import subprocess
2+
import sys
3+
4+
5+
def test_script_help():
6+
result = subprocess.run(
7+
[
8+
sys.executable,
9+
"-m",
10+
"coverage",
11+
"run",
12+
"-m",
13+
"typer",
14+
"tests/assets/cli/extended_app_cli.py",
15+
"run",
16+
"--help",
17+
],
18+
capture_output=True,
19+
encoding="utf-8",
20+
)
21+
assert "top" in result.stdout
22+
assert "hello" in result.stdout
23+
assert "sub" in result.stdout
24+
25+
26+
def test_script_top():
27+
result = subprocess.run(
28+
[
29+
sys.executable,
30+
"-m",
31+
"coverage",
32+
"run",
33+
"-m",
34+
"typer",
35+
"tests/assets/cli/extended_app_cli.py",
36+
"run",
37+
"top",
38+
],
39+
capture_output=True,
40+
encoding="utf-8",
41+
)
42+
assert "top" in result.stdout
43+
44+
45+
def test_script_hello():
46+
result = subprocess.run(
47+
[
48+
sys.executable,
49+
"-m",
50+
"coverage",
51+
"run",
52+
"-m",
53+
"typer",
54+
"tests/assets/cli/extended_app_cli.py",
55+
"run",
56+
"hello",
57+
],
58+
capture_output=True,
59+
encoding="utf-8",
60+
)
61+
assert "hello there" in result.stdout
62+
63+
64+
def test_script_bye():
65+
result = subprocess.run(
66+
[
67+
sys.executable,
68+
"-m",
69+
"coverage",
70+
"run",
71+
"-m",
72+
"typer",
73+
"tests/assets/cli/extended_app_cli.py",
74+
"run",
75+
"bye",
76+
],
77+
capture_output=True,
78+
encoding="utf-8",
79+
)
80+
assert "bye" in result.stdout
81+
82+
83+
def test_script_sub_command_help():
84+
result = subprocess.run(
85+
[
86+
sys.executable,
87+
"-m",
88+
"coverage",
89+
"run",
90+
"-m",
91+
"typer",
92+
"tests/assets/cli/extended_app_cli.py",
93+
"run",
94+
"sub",
95+
"--help",
96+
],
97+
capture_output=True,
98+
encoding="utf-8",
99+
)
100+
assert "sub-sub-command" in result.stdout
101+
102+
103+
def test_script_sub_sub_command():
104+
result = subprocess.run(
105+
[
106+
sys.executable,
107+
"-m",
108+
"coverage",
109+
"run",
110+
"-m",
111+
"typer",
112+
"tests/assets/cli/extended_app_cli.py",
113+
"run",
114+
"sub",
115+
"sub-sub-command",
116+
],
117+
capture_output=True,
118+
encoding="utf-8",
119+
)
120+
assert "sub_sub_command" in result.stdout

0 commit comments

Comments
 (0)