From f6b61af6ae5266999cdb2a61cbfa6e24eaa925c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 29 Dec 2019 17:08:31 +0100 Subject: [PATCH 1/3] :memo: Add docs for CLI Arguments --- docs/src/arguments/tutorial001.py | 9 ++ docs/src/arguments/tutorial002.py | 12 ++ docs/src/arguments/tutorial003.py | 9 ++ docs/tutorial/arguments.md | 213 ++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 5 files changed, 244 insertions(+) create mode 100644 docs/src/arguments/tutorial001.py create mode 100644 docs/src/arguments/tutorial002.py create mode 100644 docs/src/arguments/tutorial003.py create mode 100644 docs/tutorial/arguments.md diff --git a/docs/src/arguments/tutorial001.py b/docs/src/arguments/tutorial001.py new file mode 100644 index 0000000..e0412c8 --- /dev/null +++ b/docs/src/arguments/tutorial001.py @@ -0,0 +1,9 @@ +import typer + + +def main(name: str = typer.Argument(...)): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/arguments/tutorial002.py b/docs/src/arguments/tutorial002.py new file mode 100644 index 0000000..c624c87 --- /dev/null +++ b/docs/src/arguments/tutorial002.py @@ -0,0 +1,12 @@ +import typer + + +def main(name: str = typer.Argument(None)): + if name is None: + typer.echo("Hello World!") + else: + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/src/arguments/tutorial003.py b/docs/src/arguments/tutorial003.py new file mode 100644 index 0000000..538397c --- /dev/null +++ b/docs/src/arguments/tutorial003.py @@ -0,0 +1,9 @@ +import typer + + +def main(name: str = typer.Argument("Wade Wilson")): + typer.echo(f"Hello {name}") + + +if __name__ == "__main__": + typer.run(main) diff --git a/docs/tutorial/arguments.md b/docs/tutorial/arguments.md new file mode 100644 index 0000000..a1d7ff8 --- /dev/null +++ b/docs/tutorial/arguments.md @@ -0,0 +1,213 @@ +The same way that you have `typer.Option()` to help you define things for *CLI options*, there's also the equivalent `typer.Argument()` for *CLI arguments*. + +## Optional *CLI arguments* + +We said before that *by default*: + +* *CLI options* are **optional** +* *CLI arguments* are **required** + +Again, that's how they work *by default*, and that's the convention in many CLI programs and systems. + +But you can change that. + +In fact, it's very common to have **optional** *CLI arguments*, it's way more common than having **required** *CLI options*. + +As an example of how it could be useful, let's see how the `ls` command works. + +
+ +```console +// If you just type +$ ls + +// ls will "list" the files and directories in the current directory +typer tests README.md LICENSE + +// But it also receives an optional CLI argument +$ ls ./tests/ + +// And then ls will list the files and directories inside of that directory from the CLI argument +__init__.py test_tutorial +``` + +
+ +### An alternative *CLI argument* declaration + +In the First Steps you saw how to add a *CLI argument*: + +```Python hl_lines="4" +{!./src/first_steps/tutorial002.py!} +``` + +Now let's see an alternative way to create the same *CLI argument*: + +```Python hl_lines="4" +{!./src/arguments/tutorial001.py!} +``` + +Before you had this function parameter: + +```Python +name: str +``` + +And because `name` didn't have any default value it would be a **required parameter** for the Python function, in Python terms. + +**Typer** does the same and makes it a **required** *CLI argument*. + +And then we changed it to: + +```Python +name: str = typer.Argument(...) +``` + +The same as with `typer.Option()`, there is a `typer.Argument()`. + +And now as `typer.Argument()` is the "default value" of the function's parameter, in Python terms, it would mean that "it is no longer required" (in Python terms). + +As we no longer have the Python function default value (or its absence) to tell it if something is required or not and what is the default value, the first parameter to `typer.Argument()` serves the same purpose of defining that default value, or making it required. + +To make it *required*, we pass `...` as that first parameter to the function. + +!!! info + If you hadn't seen that `...` before: it is a a special single value, it is part of Python and is called "Ellipsis". + +!!! tip + This works exactly the same way `typer.Option()` does. + +All we did there achieves the same thing as before, a **required** *CLI argument*: + +
+ +```console +$ python main.py + +Usage: main.py [OPTIONS] NAME +Try "main.py --help" for help. + +Error: Missing argument "NAME". +``` + +
+ +It's still not very useful, but it works correctly. + +And being able to declare a **required** *CLI argument* using `name: str = typer.Argument(...)` that works exactly the same as `name: str` will come handy later. + +### Make an optional *CLI argument* + +Now, finally what we came for, an optional *CLI argument*. + +To make a *CLI argument* optional, use `typer.Argument()` and pass a different "default" as the first parameter to `typer.Argument()`, for example `None`: + +```Python hl_lines="4" +{!./src/arguments/tutorial002.py!} +``` + +Now we have: + +```Python +name: str = typer.Argument(None) +``` + +Because we are using `typer.Argument()` **Typer** will know that this is a *CLI argument* (no matter if *required* or *optional*). + +And because the first parameter passed to `typer.Argument(None)` (the new "default" value) is `None`, **Typer** knows that this is an **optional** *CLI argument*, if no value is provided when calling it in the command line, it will have that default value of `None`. + +Check the help: + +
+ +```console +// First check the help +$ python main.py --help + +Usage: main.py [OPTIONS] [NAME] + +Options: + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or customize the installation. + --help Show this message and exit. +``` + +
+ +!!! tip + Notice that `NAME` is still a *CLI argument*, it's shown up there in the "`Usage: main.py` ...". + + Also notice that now `[NAME]` has brackets ("`[`" and "`]`") around (before it was just `NAME`) to denote that it's **optional**, not **required**. + +Now run it and test it: + +
+ +```console +// With no CLI argument +$ python main.py + +Hello World! + +// With one optional CLI argument +$ python main.py + +Hello Camila +``` + +
+ +!!! tip + Notice that "`Camila`" here is an optional *CLI argument*, not a *CLI option*, because we didn't use something like "`--name Camila`", we just passed "`Camila`" directly to the program/command. + +## An optional *CLI argument* with a default + +We can also make a *CLI argument* have a default value other than `None`: + +```Python hl_lines="4" +{!./src/arguments/tutorial003.py!} +``` + +And test it: + +
+ +```console +// With no optional CLI argument +$ python main.py + +Hello Wade Wilson + +// With one CLI argument +$ python main.py Camila + +Hello Camila +``` + +
+ +## About *CLI arguments* help + +*CLI arguments* are commonly used for the most necessary things in a program. + +They are normally required and, when present, they are normally the main subject of whatever the command is doing. + +For that reason, Typer (actually Click underneath) doesn't attempt to automatically document *CLI arguments*. + +And you should document them as part of the command documentation, normally in a docstring. + +Check the last example from the First Steps: + +```Python hl_lines="5 6 7 8 9" +{!./src/first_steps/tutorial006.py!} +``` + +Here the *CLI argument* `NAME` is documented as part of the command help text. + +You should document your *CLI arguments* the same way. + +## Other uses + +`typer.Argument()` has several other users. For data validation, to enable other features, etc. + +But you will see about that later in the docs. diff --git a/mkdocs.yml b/mkdocs.yml index be5ed5f..adcfba9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,7 @@ nav: - Tutorial - User Guide - Intro: 'tutorial/index.md' - First Steps: 'tutorial/first-steps.md' - CLI Options: 'tutorial/options.md' + - CLI Arguments: 'tutorial/arguments.md' - Alternatives, Inspiration and Comparisons: 'alternatives.md' - Help Typer - Get Help: 'help-typer.md' - Development - Contributing: 'contributing.md' From ce1316ce8fab528ce44cc9d5efdf7981f95615e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 29 Dec 2019 17:09:19 +0100 Subject: [PATCH 2/3] :art: Tweak Termynal CSS and emojis --- docs/css/custom.css | 2 +- docs/css/termynal.css | 3 ++- docs/js/termynal.js | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/css/custom.css b/docs/css/custom.css index d4063f2..b9d77de 100644 --- a/docs/css/custom.css +++ b/docs/css/custom.css @@ -4,6 +4,6 @@ display: block; } -.termy { +.termy [data-termynal] { white-space: pre-wrap; } diff --git a/docs/css/termynal.css b/docs/css/termynal.css index 954d005..0484e65 100644 --- a/docs/css/termynal.css +++ b/docs/css/termynal.css @@ -18,7 +18,8 @@ background: var(--color-bg); color: var(--color-text); font-size: 18px; - font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; + /* font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; */ + font-family: 'Roboto Mono', 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; border-radius: 4px; padding: 75px 45px 35px; position: relative; diff --git a/docs/js/termynal.js b/docs/js/termynal.js index 47f7f47..8b0e933 100644 --- a/docs/js/termynal.js +++ b/docs/js/termynal.js @@ -135,7 +135,7 @@ class Termynal { } restart.href = '#' restart.setAttribute('data-terminal-control', '') - restart.innerHTML = "restart \u27f3" // Refresh emoji + restart.innerHTML = "restart ↻" return restart } @@ -149,7 +149,7 @@ class Termynal { } finish.href = '#' finish.setAttribute('data-terminal-control', '') - finish.innerHTML = "fast \u2b95" // Fast emoji arrow + finish.innerHTML = "fast →" this.finishElement = finish return finish } From 3332ab3cae5c760f7a9f61c3859c4500bf6ed638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Sun, 29 Dec 2019 17:09:56 +0100 Subject: [PATCH 3/3] :white_check_mark: Add tests for CLI arguments --- .../test_tutorial/test_arguments/__init__.py | 0 .../test_arguments/test_tutorial001.py | 33 ++++++++++++++++ .../test_arguments/test_tutorial002.py | 39 +++++++++++++++++++ .../test_arguments/test_tutorial003.py | 39 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 tests/test_tutorial/test_arguments/__init__.py create mode 100644 tests/test_tutorial/test_arguments/test_tutorial001.py create mode 100644 tests/test_tutorial/test_arguments/test_tutorial002.py create mode 100644 tests/test_tutorial/test_arguments/test_tutorial003.py diff --git a/tests/test_tutorial/test_arguments/__init__.py b/tests/test_tutorial/test_arguments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_tutorial/test_arguments/test_tutorial001.py b/tests/test_tutorial/test_arguments/test_tutorial001.py new file mode 100644 index 0000000..668e657 --- /dev/null +++ b/tests/test_tutorial/test_arguments/test_tutorial001.py @@ -0,0 +1,33 @@ +import subprocess + +import typer +from typer.testing import CliRunner + +from arguments import tutorial001 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_call_no_arg(): + result = runner.invoke(app) + assert result.exit_code != 0 + assert 'Error: Missing argument "NAME".' in result.output + + +def test_call_arg(): + result = runner.invoke(app, ["Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_arguments/test_tutorial002.py b/tests/test_tutorial/test_arguments/test_tutorial002.py new file mode 100644 index 0000000..82c6198 --- /dev/null +++ b/tests/test_tutorial/test_arguments/test_tutorial002.py @@ -0,0 +1,39 @@ +import subprocess + +import typer +from typer.testing import CliRunner + +from arguments import tutorial002 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "[OPTIONS] [NAME]" in result.output + + +def test_call_no_arg(): + result = runner.invoke(app) + assert result.exit_code == 0 + assert "Hello World!" in result.output + + +def test_call_arg(): + result = runner.invoke(app, ["Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/tests/test_tutorial/test_arguments/test_tutorial003.py b/tests/test_tutorial/test_arguments/test_tutorial003.py new file mode 100644 index 0000000..255b793 --- /dev/null +++ b/tests/test_tutorial/test_arguments/test_tutorial003.py @@ -0,0 +1,39 @@ +import subprocess + +import typer +from typer.testing import CliRunner + +from arguments import tutorial003 as mod + +runner = CliRunner() + +app = typer.Typer() +app.command()(mod.main) + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "[OPTIONS] [NAME]" in result.output + + +def test_call_no_arg(): + result = runner.invoke(app) + assert result.exit_code == 0 + assert "Hello Wade Wilson" in result.output + + +def test_call_arg(): + result = runner.invoke(app, ["Camila"]) + assert result.exit_code == 0 + assert "Hello Camila" in result.output + + +def test_script(): + result = subprocess.run( + ["coverage", "run", mod.__file__, "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", + ) + assert "Usage" in result.stdout