Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type hints: basic usage to infer argument types (fixes #203) #211

Merged
merged 21 commits into from
Dec 30, 2023
Merged
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
2 changes: 1 addition & 1 deletion AUTHORS.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Contributors
~~~~~~~~~~~~
============

.. note::

Expand Down
50 changes: 50 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,56 @@ Changelog
Version 0.31.0
--------------

Breaking changes:

- The typing hints introspection feature is automatically enabled for any
command (function) which does **not** have any arguments specified via `@arg`
decorator.

This means that, for example, the following function used to fail and now
it will pass::

def main(count: int):
assert isinstance(count, int)

This may lead to unexpected behaviour in some rare cases.

- A small change in the legacy argument mapping policy `BY_NAME_IF_HAS_DEFAULT`
concerning the order of variadic positional vs. keyword-only arguments.

The following function now results in ``main alpha [args ...] beta`` instead of
``main alpha beta [args ...]``::

def main(alpha, *args, beta): ...

This does **not** concern the default name mapping policy. Even for the
legacy one it's an edge case which is extremely unlikely to appear in any
real-life application.

- Removed the previously deprecated decorator `@expects_obj`.

Enhancements:

- Added experimental support for basic typing hints (issue #203)

The following hints are currently supported:

- ``str``, ``int``, ``float``, ``bool`` (goes to ``type``);
- ``list`` (affects ``nargs``), ``list[T]`` (first subtype goes into ``type``);
- ``Optional[T]`` AKA ``T | None`` (currently interpreted as
``required=False`` for optional and ``nargs="?"`` for positional
arguments; likely to change in the future as use cases accumulate).

The exact interpretation of the type hints is subject to change in the
upcoming versions of Argh.

- Added `always_flush` argument to `dispatch()` (issue #145)

- High-level functions `argh.dispatch_command()` and `argh.dispatch_commands()`
now accept a new parameter `old_name_mapping_policy`. The behaviour hasn't
changed because the parameter is `True` by default. It will change to
`False` in Argh v.0.33 or v.1.0.

Deprecated:

- the `namespace` argument in `argh.dispatch()` and `argh.parse_and_resolve()`.
Expand All @@ -17,6 +63,10 @@ Deprecated:
anyone would need to specify a custom namespace class or pre-populate it
before parsing. Please file an issue if you have a valid use case.

Other changes:

- Refactoring.

Version 0.30.5 (2023-12-25)
---------------------------

Expand Down
34 changes: 33 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ In a nutshell
`Argh` supports *completion*, *progress bars* and everything else by being
friendly to excellent 3rd-party libraries. No need to reinvent the wheel.

Sounds good? Check the tutorial!
Sounds good? Check the :doc:`quickstart` and the :doc:`tutorial`!

Relation to argparse
--------------------
Expand All @@ -98,6 +98,9 @@ Installation
Examples
--------

Hello World
...........

A very simple application with one command:

.. code-block:: python
Expand All @@ -116,6 +119,29 @@ Run it:
$ ./app.py
Hello world

Type Annotations
................

Type annotations are used to infer argument types:

.. code-block:: python

def summarise(numbers: list[int]) -> int:
return sum(numbers)

argh.dispatch_command(summarise)

Run it (note that ``nargs="+"`` + ``type=int`` were inferred from the
annotation):

.. code-block:: bash

$ ./app.py 1 2 3
6

Multiple Commands
.................

An app with multiple commands:

.. code-block:: python
Expand All @@ -133,6 +159,9 @@ Run it:
$ ./app.py echo Hey
Hey

Modularity
..........

A potentially modular application with more control over the process:

.. code-block:: python
Expand Down Expand Up @@ -195,6 +224,9 @@ to CLI arguments)::

(The help messages have been simplified a bit for brevity.)

Decorators
..........

`Argh` easily maps plain Python functions to CLI. Sometimes this is not
enough; in these cases the powerful API of `argparse` is also available:

Expand Down
9 changes: 2 additions & 7 deletions docs/source/cookbook.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Cookbook
~~~~~~~~
========

Multiple values per argument
----------------------------
Expand Down Expand Up @@ -46,9 +46,4 @@ will be eventually the default one):
distros = ("abc", "xyz")
return [d for d in distros if any(p in d for p in patterns)]

if __name__ == "__main__":
parser = argh.ArghParser()
parser.set_default_command(
cmd, name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY
)
argh.dispatch(parser)
argh.dispatch_command(cmd, old_name_mapping_policy=False)
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Details
.. toctree::
:maxdepth: 2

quickstart
tutorial
reference
cookbook
Expand Down
2 changes: 1 addition & 1 deletion docs/source/projects.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Real-life usage
~~~~~~~~~~~~~~~
===============

Below are some examples of applications using `argh`, grouped by supported
version of Python.
Expand Down
215 changes: 215 additions & 0 deletions docs/source/quickstart.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
Quick Start
===========

Command-Line Interface
----------------------

CLI is a very efficient way to interact with an application.
If GUI is like pointing your finger at things, then CLI is like talking.

Building a good CLI may require quite a bit of effort. You need to connect two
worlds: your Python API and the command-line interface which has its own rules.

At a closer inspection you may notice that a CLI command is very similar to a function.
You have positional and named arguments, you pass them into the function and
get a return value — and the same happens with a command. However, the mapping is not
exactly straightforward and a lot of boilerplate is required to make it work.

The intent of Argh is to radically streamline this function-to-CLI mapping.

We'll try to demonstrate it with a few examples here.

Passing name as positional argument
-----------------------------------

Assume we need a CLI application which output is modulated by arguments:

.. code-block:: bash

$ ./greet.py
Hello unknown user!

$ ./greet.py John
Hello John!

Let's start with a simple function:

.. code-block:: python

def main(name: str = "unknown user") -> str:
return f"Hello {name}!"

Now make it a CLI command:

.. code-block:: python

#!/usr/bin/env python3

import argh

def main(name: str = "unknown user") -> str:
return f"Hello {name}!"

argh.dispatch_command(main, old_name_mapping_policy=False)

Save it as `greet.py` and try to run it::

$ chmod +x greet.py
$ ./greet.py
Hello unknown user!

It works! Now try passing arguments. Use ``--help`` if unsure::

$ ./greet.py --help

usage: greet.py [-h] [name]

positional arguments:
name 'unknown user'

options:
-h, --help show this help message and exit

Multiple positional arguments; limitations
------------------------------------------

You can add more positional arguments. They are determined by their position
in the function signature::

def main(first, second, third):
print(f"second: {second}")

main(1, 2, 3) # prints "two: 2"

Same will happen if we dispatch this function as a CLI command::

$ ./app.py 1 2 3
two: 2

This is fine, but it's usually hard to remember the order of arguments when
their number is over three or so.

Moreover, you may want to omit the first one and specify the rest — but it's
impossible. How would the computer know if the element you are skipping is
supposed to be the first, the last or somewhere in the middle? There's no way.

If only it was possible to pass such arguments by name!

Indeed, a good command-line interface is likely to have one or two positional
arguments but the rest should be named.

In Python you can do it by calling your function this way::

main(first=1, second=2, third=3)

In CLI named arguments are called "options". Please see the next section to
learn how to use them.

Passing name as an option
-------------------------

Let's return to our small application and see if we can make the name
an "option" AKA named CLI argument, like this::

$ ./greet.py --name John

In that case it's enough to make the function argument `name` "keyword-only"
(see :pep:`3102` for explanation)::

def main(*, name: str = "unknown user") -> str:
...

We just took the previous function and added ``*,`` before the first argument.

Let's check how the app help now looks like::

$ ./greet.py --help

usage: greet.py [-h] [-n NAME]

options:
-h, --help show this help message and exit
-n NAME, --name NAME 'unknown user'

Positional vs options: recap
----------------------------

Here's a function with one positional argument and one "option"::

def main(name: str, *, age: int = 0) -> str:
...

* All arguments to the left of ``*`` are considered positional.
* All arguments to the right of ``*`` are considered named (or "options").

Multiple Commands
-----------------

We used `argh.dispatch_command()` to run a single command.

In order to enable multiple commands we simply use a sister function
`argh.dispatch_commands()` and pass a list of functions to it::

argh.dispatch_commands([load, dump])

Bam! Now we can call our script like this::

$ ./app.py dump
$ ./app.py load fixture.json
$ ./app.py load fixture.yaml --format=yaml
\______/ \__/ \________________________/
| | |
| | `-- command arguments
| |
| `-- command name (function name)
|
`-- script file name

Typing Hints
------------

Typing hints are picked up when it makes sense too. Consider this::

def summarise(numbers: list[int]) -> int:
return sum(numbers)

argh.dispatch_command(summarise)

Call it::

$ ./app 1 2 3
6

It worked exactly as you would expect. Argh looked at the annotation and
understood that you want a list of integers. This information was then
reworded for `argparse`.

Quick Start Wrap-Up
-------------------

To sum up, the commands are **ordinary functions** with ordinary signatures:

* Declare them somewhere, dispatch them elsewhere. This ensures **loose
coupling** of components in your application.
* They are **natural** and pythonic. No fiddling with the parser and the
related intricacies like ``action="store_true"`` which you could never
remember.

Next: Tutorial
--------------

Still, there's much more to commands than this.

The examples above raise some questions, including:

* do we have to ``return``, or ``print`` and ``yield`` are also supported?
* what's the difference between ``dispatch_command()``
and ``dispatch_commands()``? What's going on under the hood?
* how do I add help for each argument?
* how do I access the parser to fine-tune its behaviour?
* how to keep the code as DRY as possible?
* how do I expose the function under custom name and/or define aliases?
* how do I have values converted to given type?
* can I use a namespace object instead of the natural way?

Please check the :doc:`tutorial` for answers.
Loading