Skip to content

Commit

Permalink
Type hints: basic usage to infer argument types (fixes #203) (#211)
Browse files Browse the repository at this point in the history
* add draft for typing hints inspection

* refactor: use inspect.signature()

Also drop argh.utils.get_arg_spec().  It is no longer needed because
while getfullargspec() did not handle decorated functions properly,
signature() does.

A backwards incompatible change (an extremely rare edge case)
is documented in the changelog.

* docs: update changelog

* feat: basic argspec guessing from typing hints

* chore: remove @expects_obj

* fix: edge case with bool and annotations

* feat: simple policy switch in dispatch_command(s)

* docs: rewrite the tutorial, document hints

* docs: fix formatting

* docs: add help examples

* docs: improve wording

* docs: promote new example to readme itself

* docs: extract quickstart to a separate doc

* docs: named examples in README

* feat: support `Literal[a,b]` as `choices=(a,b)`

* chore: bump version

* refactor: use constant

* style: fix minor stuff
  • Loading branch information
neithere authored Dec 30, 2023
1 parent 934998e commit 6180ca9
Show file tree
Hide file tree
Showing 25 changed files with 1,036 additions and 456 deletions.
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

0 comments on commit 6180ca9

Please sign in to comment.