Skip to content

Commit

Permalink
Keyword-only arguments as options (closes #191) (#199)
Browse files Browse the repository at this point in the history
  • Loading branch information
neithere authored Oct 21, 2023
1 parent 6baa0e4 commit 3cb9261
Show file tree
Hide file tree
Showing 17 changed files with 808 additions and 129 deletions.
24 changes: 24 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ Backwards incompatible changes:
pre_call_hook(ns)
argh.run_endpoint_function(func, ns, ...)

- A new policy for mapping function arguments to CLI arguments is used by
default (see :class:`argh.assembling.NameMappingPolicy`).
In case you need to retain the CLI mapping but cannot modify the function
signature to use kwonly args for options, consider using this::

set_default_command(
func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
)

- The name mapping policy `BY_NAME_IF_HAS_DEFAULT` slightly deviates from the
old behaviour. Kwonly arguments without default values used to be marked as
required options (``--foo FOO``), now they are treated as positionals
(``foo``). Please consider the new default policy (`BY_NAME_IF_KWONLY`) for
a better treatment of kwonly.

Deprecated:

- The `@expects_obj` decorator. Rationale: it used to support the old,
Expand All @@ -51,6 +66,15 @@ Enhancements:

Please note that the names may change in the upcoming versions.

- Configurable name mapping policy has been introduced for function argument
to CLI argument translation (#191 → #199):

- `BY_NAME_IF_KWONLY` (default and recommended).
- `BY_NAME_IF_HAS_DEFAULT` (close to pre-v.0.30 behaviour);

Please check API docs on :class:`argh.assembling.NameMappingPolicy` for
details.

Version 0.29.4
--------------

Expand Down
2 changes: 1 addition & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ A potentially modular application with more control over the process:
"Returns given word as is."
return text
def greet(name, greeting: str = "Hello") -> str:
def greet(name: str, greeting: str = "Hello") -> str:
"Greets the user with given name. The greeting is customizable."
return f"{greeting}, {name}!"
Expand Down
2 changes: 2 additions & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@
}

nitpicky = True

autodoc_typehints = "both"
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Details
tutorial
reference
cookbook
the_story
similar
projects
subparsers
Expand Down
3 changes: 0 additions & 3 deletions docs/source/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ API Reference
.. automodule:: argh.exceptions
:members:

.. automodule:: argh.io
:members:

.. automodule:: argh.utils
:members:

Expand Down
14 changes: 12 additions & 2 deletions docs/source/similar.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ supports Python3. Not every "yes" in this table would count as pro.
* opster_ and finaloption_ support nested commands but are based on the
outdated `optparse` library and therefore reimplement some features available
in `argparse`. They also introduce decorators that don't just decorate
functions but change their behaviour, which is bad practice.
functions but change their behaviour, which is a questionable practice.
* simpleopt_ has an odd API and is rather a simple replacement for standard
libraries than an extension.
* opterator_ is based on the outdated `optparse` and does not support nested
Expand All @@ -36,11 +36,20 @@ supports Python3. Not every "yes" in this table would count as pro.
worth migrating but it is surely very flexible and easy to use.
* baker_
* plumbum_
* docopt_
* docopt_ takes an inverted approach: you write the usage docs, it generates a
parser. Then you need to wire the parsing results into you code manually.
* aaargh_
* cliff_
* cement_
* autocommand_
* click_ is a rather popular library, a bit younger than Argh. The authors of
both libraries even gave lightning talks on a PyCon within a few minutes :)
Although I expected it to kill Argh because it comes with Flask, in fact
it takes an approach so different from Argh that they can coexist.
Like Opster, Click's decorator replaces the underlying function (a
questionable practice); it does not derive the CLI arguments from the
function signature but entirely relies on additional decorators, while Argh
strives for the opposite.

.. _argdeclare: http://code.activestate.com/recipes/576935-argdeclare-declarative-interface-to-argparse/
.. _argparse-cli: http://code.google.com/p/argparse-cli/
Expand All @@ -59,3 +68,4 @@ supports Python3. Not every "yes" in this table would count as pro.
.. _cliff: http://pypi.python.org/pypi/cliff
.. _cement: http://builtoncement.com/2.0/
.. _autocommand: https://pypi.python.org/pypi/autocommand/
.. _click: https://click.palletsprojects.com
123 changes: 123 additions & 0 deletions docs/source/the_story.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
The Story of Argh
~~~~~~~~~~~~~~~~~

Early history
-------------

Argh was first drafted by Andy in the airport while waiting for his flight.
The idea was to make a simplified wrapper for Argparse with support for nested
commands. We'll focus on the function arguments vs. CLI arguments here.

This is what Argh began with (around 2010)::

@arg("path", description="path to the file to load")
@arg("--file-format", choices=["yaml", "json"], default="json")
@arg("--dry-run", default=False)
def load(args):
do_something(args.path, args.file_format, args.dry_run)

argh.dispatch_command(load)

You don't have to remember the details of the underlying Argparse interface
(especially for subparsers); you would still declare almost everything, but in
one place, close to the function itself.

"The Natural Way"
-----------------

In late 2012 the behaviour previously available via `@plain_signature`
decorator became standard::


@arg("path", help="path to the file to load")
@arg("--file-format", choices=["yaml", "json"])
def load(path, file_format="json", dry_run=False):
do_something(path, file_format, dry_run)

argh.dispatch_command(load)

This unleashed the killer feature of Argh: now you can write normal functions —
not for argparse but general-purpose ones. Argh would infer the basic CLI
argument definitions straight from the function signature. The types and some
actions (e.g. `store_true`) would be inferred from defaults. You would only need
to use the `@arg` decorator to enhance the information with something that had
no place in function signature of the Python 2.x era.

There's still an little ugly thing about it: you have to mention the argument
name twice, in function signature and the decorator. Also the type cannot be
inferred if there's no default value, so you'd have to use the decorator even
for that.

Hiatus
------

The primary author's new job required focus on other languages for a number of
years and he had no energy to develop his FOSS projects, although he continued
using Argh for his own purposes on a daily basis.

A few forks were created by other developers but none survived. (The forks,
not developers.)

By coincidence, around the beginning of this period a library called Click was
shipped with Flask and it seemed obvious that it will become the new standard
for simple CLI APIs and Argh wouldn't be needed. (Plot twist: it did become
popular but its goals are too different from Argh's to replace it.)

Revival
-------

The author returned to his FOSS projects in early 2023. To his surprise, Argh
was not dead at all and its niche as the "natural API" was not occupied by any
other project. It actually made sense to revive it.

A deep modernisation and refactoring began.

A number of pending issues were resolved and the last version to support
Python 2.x was released with a bunch of bugfixes.

The next few releases have deprecated and removed a lot of outdated features
and paved the way to a better Argh. Some design decisions have been revised
and the streamlined. The work continues.

Goodbye Decorators
------------------

As type hints became mature and widespread in Python code, the old approach
with decorators seems to make less and less sense. A lot more can be now
inferred directly from the signature. In fact, possibly everything.

Here's what Argh is heading for (around 2024).

A minimal example (note how there's literally nothing CLI-specific here)::

def load(path: str, *, file_format: str = "json", dry_run: bool = False) -> str:
return do_something(path, file_format, dry_run)

argh.dispatch_command(load)

A more complete example::

from typing import Annotated
from argh import Choices, Help

def load(
path: Annotated[str, Help("path to the file to load")],
*,
file_format: Annotated[str, Choices(FORMAT_CHOICES))] = DEFAULT_FORMAT,
dry_run: bool = False
) -> str:
return do_something(path, file_format, dry_run)

argh.dispatch_command(load)

The syntax is subject to change but the essence is clear:

* as few surprises to the reader as possible;
* the function used as a CLI command is declared and callable in the normal
way, like any other function;
* type hints are used instead of ``@arg("foo", type=int)``
* additional metadata can be injected into type hints when necessary in a way
that won't confuse type checkers (like in FastAPI_, requires Python 3.9+);
* non-kwonly become CLI positionals, kwonly become CLI options.

.. _FastAPI: https://fastapi.tiangolo.com/python-types/#type-hints-with-metadata-annotations
20 changes: 19 additions & 1 deletion docs/source/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ knowing about `argh`::

When executed as ``app.py my-command --help``, such application prints::

usage: app.py my-command [-h] [-b BETA] [-g] alpha [delta [delta ...]]
usage: app.py my-command [-h] [-b BETA] [-g] alpha [delta ...]

positional arguments:
alpha
Expand Down Expand Up @@ -123,6 +123,24 @@ Verbose, hardly readable, requires learning another API.
Hey, that's a lot for such a simple case! But then, that's why the API feels
natural: `argh` does a lot of work for you.

.. note::

The pattern described above is the "by name if has default" mapping policy.
It used to be *the* policy but starting with Argh v.0.30 there's a better
one, "by name if kwonly". Although the older one will remain the default
policy for a while, it may be eventually dropped in favour of the new one.

Please check `~argh.assembling.NameMappingPolicy` for details.

Usage example::

def my_command(alpha, beta=1, *, gamma, delta=False, **kwargs):
...

argh.dispatch_command(
my_command, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY
)

Well, there's nothing more elegant than a simple function. But simplicity
comes at a cost in terms of flexibility. Fortunately, `argh` doesn't stay in
the way and offers less natural but more powerful tools.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,6 @@ include = [
"tests/",
"tox.ini",
]

[tool.doc8]
max-line-length = 95
Loading

0 comments on commit 3cb9261

Please sign in to comment.