Repository: neithere/argh (language: python)

Issue:
Type hints: basic usage to infer argument types
Look at #107 and see what can be implemented quickly.


Discussion / hints:

Fix patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 00f7831..66c12cb 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -1,5 +1,5 @@
 Contributors
-~~~~~~~~~~~~
+============
 
 .. note::
 
diff --git a/CHANGES.rst b/CHANGES.rst
index 52cd3a2..4fd744b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,71 @@
-~~~~~~~~~
 Changelog
-~~~~~~~~~
+=========
+
+Version 0.31.0 (2023-12-30)
+---------------------------
+
+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``);
+  - ``Literal[T1, T2, ...]`` (interpreted as ``choices``);
+  - ``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()`.
+  Rationale: continued API cleanup.  It's already possible to mutate the
+  namespace object between parsing and calling the endpoint; it's unlikely that
+  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)
 ---------------------------
diff --git a/README.rst b/README.rst
index 3c8ed04..0beb1eb 100644
--- a/README.rst
+++ b/README.rst
@@ -43,28 +43,15 @@ In a nutshell
 
 `Argh`-powered applications are *simple* but *flexible*:
 
-:Modular:
-    Declaration of commands can be decoupled from assembling and dispatching;
-
-:Pythonic:
-    Commands are declared naturally, no complex API calls in most cases;
+:Pythonic, KISS:
+    Commands are plain Python functions.  Almost no CLI-specific API to learn.
 
 :Reusable:
-    Commands are plain functions, can be used directly outside of CLI context;
-
-:Layered:
-    The complexity of code raises with requirements;
+    Endpoint functions can be used directly outside of CLI context.
 
-:Transparent:
-    The full power of argparse is available whenever needed;
-
-:Namespaced:
-    Nested commands are a piece of cake, no messing with subparsers (though
-    they are of course used under the hood);
-
-:Unobtrusive:
-    `Argh` can dispatch a subset of pure-`argparse` code, and pure-`argparse`
-    code can update and dispatch a parser assembled with `Argh`;
+:Static typing friendly:
+    100% of the code including endpoint functions can be type-checked.
+    Argh is driven primarily by type annotations.
 
 :DRY:
     Don't Repeat Yourself.  The amount of boilerplate code is minimal.
@@ -72,14 +59,28 @@ In a nutshell
 
     * infer command name from function name;
     * infer arguments from function signature;
-    * infer argument type from the default value;
-    * infer argument action from the default value (for booleans);
+    * infer argument types, actions and much more from annotations.
+
+:Modular:
+    Declaration of commands can be decoupled from assembling and dispatching.
+
+:Layered:
+    The complexity of code raises with requirements.
+
+:Transparent:
+    You can directly access `argparse.ArgumentParser` if needed.
+
+:Subcommands:
+    Easily nested commands.  Argh isolates the complexity of subparsers.
 
 :NIH free:
     `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!
+:Compact:
+    No dependencies apart from Python's standard library.
+
+Sounds good?  Check the :doc:`quickstart` and the :doc:`tutorial`!
 
 Relation to argparse
 --------------------
@@ -98,6 +99,9 @@ Installation
 Examples
 --------
 
+Hello World
+...........
+
 A very simple application with one command:
 
 .. code-block:: python
@@ -116,6 +120,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
@@ -133,6 +160,9 @@ Run it:
     $ ./app.py echo Hey
     Hey
 
+Modularity
+..........
+
 A potentially modular application with more control over the process:
 
 .. code-block:: python
@@ -195,31 +225,19 @@ 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:
 
 .. code-block:: python
 
-    @arg("text", default="hello world", nargs="+", help="The message")
-    def echo(text: str) -> None:
-        print text
-
-The approaches can be safely combined even up to this level:
-
-.. code-block:: python
+    @arg("words", default="hello world", nargs="+", help="The message")
+    def echo(words: list[str]) -> str:
+        return " ".join(words)
 
-    # adding help to `foo` which is in the function signature:
-    @arg("foo", help="blah")
-    # these are not in the signature so they go to **kwargs:
-    @arg("baz")
-    @arg("-q", "--quux")
-    # the function itself:
-    def cmd(foo: str, bar: int = 1, *args, **kwargs) -> Iterator[str]:
-        yield foo
-        yield bar
-        yield ", ".join(args)
-        yield kwargs["baz"]
-        yield kwargs["quux"]
+Please note that decorators will soon be fully replaced with annotations.
 
 Links
 -----
diff --git a/docs/source/cookbook.rst b/docs/source/cookbook.rst
index d5d6c55..e6fb1da 100644
--- a/docs/source/cookbook.rst
+++ b/docs/source/cookbook.rst
@@ -1,5 +1,5 @@
 Cookbook
-~~~~~~~~
+========
 
 Multiple values per argument
 ----------------------------
@@ -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)
diff --git a/docs/source/index.rst b/docs/source/index.rst
index f643c05..4aa4b67 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -23,6 +23,7 @@ Details
 .. toctree::
    :maxdepth: 2
 
+   quickstart
    tutorial
    reference
    cookbook
diff --git a/docs/source/projects.rst b/docs/source/projects.rst
index 570adf4..64828b7 100644
--- a/docs/source/projects.rst
+++ b/docs/source/projects.rst
@@ -1,5 +1,5 @@
 Real-life usage
-~~~~~~~~~~~~~~~
+===============
 
 Below are some examples of applications using `argh`, grouped by supported
 version of Python.
diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst
new file mode 100644
index 0000000..7981567
--- /dev/null
+++ b/docs/source/quickstart.rst
@@ -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.
diff --git a/docs/source/reference.rst b/docs/source/reference.rst
index 90d11d3..3ff1f7a 100644
--- a/docs/source/reference.rst
+++ b/docs/source/reference.rst
@@ -1,18 +1,18 @@
 API Reference
-~~~~~~~~~~~~~
+=============
 
 .. automodule:: argh
    :members:
 
-.. automodule:: argh.decorators
-   :members:
-
 .. automodule:: argh.assembling
    :members:
 
 .. automodule:: argh.dispatching
    :members:
 
+.. automodule:: argh.decorators
+   :members:
+
 .. automodule:: argh.interaction
    :members:
 
diff --git a/docs/source/similar.rst b/docs/source/similar.rst
index f1c4e3e..97fd7d6 100644
--- a/docs/source/similar.rst
+++ b/docs/source/similar.rst
@@ -1,5 +1,5 @@
 Similar projects
-~~~~~~~~~~~~~~~~
+================
 
 Obviously, `Argh` is not the only CLI helper library in the Python world.
 It was created when some similar solutions already existed; more appeared
diff --git a/docs/source/subparsers.rst b/docs/source/subparsers.rst
index 8840ac2..1960779 100644
--- a/docs/source/subparsers.rst
+++ b/docs/source/subparsers.rst
@@ -1,5 +1,5 @@
 Subparsers
-~~~~~~~~~~
+==========
 
 The statement ``parser.add_commands([bar, quux])`` builds two subparsers named
 `bar` and `quux`. A "subparser" is an argument parser bound to a group name. In
@@ -26,7 +26,7 @@ The equivalent code without `Argh` would be::
     bar_parser.set_defaults(function=bar)
 
     args = parser.parse_args()
-    print args.function(args)
+    print(args.function(args))
 
 Now consider this expression::
 
@@ -54,7 +54,7 @@ to write something like this (generic argparse API)::
     foo_quux_parser.set_defaults(function=quux)
 
     args = parser.parse_args()
-    print args.function(args)
+    print(args.function(args))
 
 .. note::
 
diff --git a/docs/source/the_story.rst b/docs/source/the_story.rst
index 45c04ac..ecce529 100644
--- a/docs/source/the_story.rst
+++ b/docs/source/the_story.rst
@@ -1,5 +1,5 @@
 The Story of Argh
-~~~~~~~~~~~~~~~~~
+=================
 
 Early history
 -------------
diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst
index aa6ac12..b0f0934 100644
--- a/docs/source/tutorial.rst
+++ b/docs/source/tutorial.rst
@@ -1,5 +1,5 @@
 Tutorial
-~~~~~~~~
+========
 
 `Argh` is a small library that provides several layers of abstraction on top
 of `argparse`.  You are free to use any layer that fits given task best.
@@ -7,80 +7,7 @@ The layers can be mixed.  It is always possible to declare a command with
 the  highest possible (and least flexible) layer and then tune the behaviour
 with any of the lower layers including the native API of `argparse`.
 
-Dive In
--------
-
-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!
-
-This is our business logic:
-
-.. code-block:: python
-
-    def main(name: str = "unknown user") -> str:
-        return f"Hello {name}!"
-
-That was plain Python, nothing CLI-specific.
-Let's convert the function into a complete CLI application::
-
-    argh.dispatch_command(main)
-
-Done.  Dead simple.
-
-You may want to 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:
-        ...
-
-Everything to the left of ``*`` becomes a positional CLI argument.  Everything
-to the right of ``*`` becomes a named one.
-
-What about multiple commands?  Easy::
-
-    argh.dispatch_commands([load, dump])
-
-And then call your script like this::
-
-    $ ./app.py dump
-    $ ./app.py load fixture.json
-    $ ./app.py load fixture.yaml --format=yaml
-
-I guess you get the picture.  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.
-
-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?
-
-Just read on.
+Please make sure you have read the :doc:`quickstart` before proceeding.
 
 Declaring Commands
 ------------------
@@ -115,10 +42,26 @@ Let's make this function available as a CLI command::
 
 
     if __name__ == "__main__":
-        argh.dispatch_commands([my_command])
+        argh.dispatch_commands([my_command], old_name_mapping_policy=False)
 
 That's all.  You don't need to do anything else.
 
+.. note::
+
+    Note that we're using ``old_name_mapping_policy=False`` here and in some
+    other examples.  This has to do with the recent changes in the default way
+    Argh maps function arguments to CLI arguments.  We're currently in a
+    transitional period.
+
+    In most cases Argh can guess what you want but there are edge cases, and
+    the `beta` argument is one of them.  It's a positional argument with
+    default value.  Usually you will not need those but it's shown here for the
+    sake of completeness.  Argh does not know how you want to treat it, so you
+    should specify the name mapping policy explicitly.  This issue will go away
+    when `BY_NAME_IF_KWONLY` becomes the default policy (v.1.0 or earlier).
+
+    See :class:`~argh.assembling.NameMappingPolicy` for details.
+
 When executed as ``./app.py my-command --help``, such application prints::
 
     usage: app.py my-command [-h] -g GAMMA [-d] alpha [beta] [args ...]
@@ -168,7 +111,7 @@ single line in addition to your function.
 
 `Argh` allows for more expressive and pythonic code because:
 
-* everything is inferred from the function signature;
+* everything is inferred from the function signature and type annotations;
 * regular function arguments are represented as positional CLI arguments;
 * varargs (``*args``) are represented as a "zero or more" positional CLI argument;
 * kwonly (keyword-only arguments, see :pep:`3102`) are represented as named CLI
@@ -189,6 +132,72 @@ 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.
 
+Annotations
+...........
+
+Since v.0.31 `Argh` can use type annotations to infer the argument types and
+some other properties.  This approach will eventually replace the `@arg`
+decorator.
+
+Inferring the type
+~~~~~~~~~~~~~~~~~~
+
+Let's consider this example::
+
+    def increment(n: int) -> int:
+        return n + 1
+
+The `n` argument will be automatically converted to `int`.
+
+Currently supported types are:
+
+- `str`
+- `int`
+- `float`
+- `bool`
+
+Inferring choices
+~~~~~~~~~~~~~~~~~
+
+Use `Literal` to specify the choices::
+
+    from typing import Literal
+    import argh
+
+    def greet(name: Literal["Alice", "Bob"]) -> str:
+        return f"Hello, {name}!"
+
+    argh.dispatch_command(greet)
+
+Let's explore this CLI::
+
+    $ ./greet.py foo
+    usage: greet.py [-h] {Alice,Bob}
+    greet.py: error: argument name: invalid choice: 'foo' (choose from 'Alice', 'Bob')
+
+    $ ./greet.py Alice
+    Hello, Alice!
+
+Inferring nargs and nested type
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Here's another example::
+
+    def summarise(numbers: list[int]) -> int:
+        return sum(numbers)
+
+    argh.dispatch_command(summarise)
+
+Let's call it::
+
+    $ ./app.py 1 2 3
+    6
+
+The ``list[int]`` hint was interpreted as ``nargs="+"`` + ``type=int``.
+
+Please note that this part of the API is experimental and may change in the
+future releases.
+
 Documenting Your Commands
 .........................
 
@@ -253,35 +262,6 @@ Mixing ``**kwargs`` with straightforward signatures is also possible::
    declared via decorators because the results can be pretty confusing (though
    predictable).  See `argh` tests for details.
 
-Namespace Objects
-.................
-
-The default approach of `argparse` is similar to ``**kwargs``: the function
-expects a single object and the CLI arguments are defined elsewhere.
-
-In order to dispatch such "argparse-style" command via `argh`, you need to
-tell the latter that the function expects a namespace object.  This is done by
-wrapping the function into the :func:`~argh.decorators.expects_obj` decorator::
-
-    @expects_obj
-    def cmd(args) -> str:
-        return args.foo
-
-This way arguments cannot be defined in the Natural Way but the
-:class:`~argh.decorators.arg` decorator works as usual.
-
-.. deprecated:: 0.30
-    The `@expects_obj` decorator will removed in v0.31 or a later version.
-    Please consider using the main feature Argh offers — the mapping of
-    function signature to CLI.  Otherwise you are basically using vanilla
-    Argparse.
-
-.. note::
-
-   In both cases — ``**kwargs``-only and `@expects_obj` — the arguments
-   **must** be declared via decorators or directly via the `argparse` API.
-   Otherwise the command has zero arguments (apart from ``--help``).
-
 Assembling Commands
 -------------------
 
diff --git a/pyproject.toml b/pyproject.toml
index 6b64c58..159e27b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "flit_core.buildapi"
 
 [project]
 name = "argh"
-version = "0.30.5"
+version = "0.31.0"
 description = "An unobtrusive argparse wrapper with natural syntax"
 readme = "README.rst"
 requires-python = ">=3.8"
diff --git a/src/argh/__init__.py b/src/argh/__init__.py
index fa54828..5d6b048 100644
--- a/src/argh/__init__.py
+++ b/src/argh/__init__.py
@@ -12,7 +12,7 @@
 #  Software Foundation. See the file README.rst for copying conditions.
 #
 from .assembling import add_commands, add_subcommands, set_default_command
-from .decorators import aliases, arg, expects_obj, named, wrap_errors
+from .decorators import aliases, arg, named, wrap_errors
 from .dispatching import (
     PARSER_FORMATTER,
     ArghNamespace,
@@ -33,7 +33,6 @@
     "set_default_command",
     "aliases",
     "arg",
-    "expects_obj",
     "named",
     "wrap_errors",
     "PARSER_FORMATTER",
diff --git a/src/argh/assembling.py b/src/argh/assembling.py
index c239fce..2dc554f 100644
--- a/src/argh/assembling.py
+++ b/src/argh/assembling.py
@@ -19,13 +19,32 @@
 from argparse import OPTIONAL, ZERO_OR_MORE, ArgumentParser
 from collections import OrderedDict
 from enum import Enum
-from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterator,
+    List,
+    Literal,
+    Optional,
+    Tuple,
+    Union,
+    get_args,
+    get_origin,
+)
+
+# types.UnionType was introduced in Python < 3.10
+try:  # pragma: no cover
+    from types import UnionType
+
+    UNION_TYPES = [Union, UnionType]
+except ImportError:  # pragma: no cover
+    UNION_TYPES = [Union]
 
 from argh.completion import COMPLETION_ENABLED
 from argh.constants import (
     ATTR_ALIASES,
     ATTR_ARGS,
-    ATTR_EXPECTS_NAMESPACE_OBJECT,
     ATTR_NAME,
     DEFAULT_ARGUMENT_TEMPLATE,
     DEST_FUNCTION,
@@ -33,7 +52,7 @@
 )
 from argh.dto import NotDefined, ParserAddArgumentSpec
 from argh.exceptions import AssemblingError
-from argh.utils import get_arg_spec, get_subparsers
+from argh.utils import get_subparsers
 
 __all__ = [
     "set_default_command",
@@ -108,23 +127,23 @@ def func(alpha, beta=1, *, gamma, delta=2): ...
 def infer_argspecs_from_function(
     function: Callable,
     name_mapping_policy: Optional[NameMappingPolicy] = None,
+    can_use_hints: bool = False,
 ) -> Iterator[ParserAddArgumentSpec]:
-    if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False):
-        return
-
     if name_mapping_policy and name_mapping_policy not in NameMappingPolicy:
         raise NotImplementedError(f"Unknown name mapping policy {name_mapping_policy}")
 
-    func_spec = get_arg_spec(function)
-    has_kwonly = bool(func_spec.kwonlyargs)
-
-    default_by_arg_name: Dict[str, Any] = dict(
-        zip(reversed(func_spec.args), reversed(func_spec.defaults or tuple()))
+    func_signature = inspect.signature(function)
+    has_kwonly = any(
+        p.kind == p.KEYWORD_ONLY for p in func_signature.parameters.values()
     )
 
     # define the list of conflicting option strings
     # (short forms, i.e. single-character ones)
-    named_args = set(list(default_by_arg_name) + func_spec.kwonlyargs)
+    named_args = [
+        p.name
+        for p in func_signature.parameters.values()
+        if p.default is not p.empty or p.kind == p.KEYWORD_ONLY
+    ]
     named_arg_chars = [a[0] for a in named_args]
     named_arg_char_counts = dict(
         (char, named_arg_chars.count(char)) for char in set(named_arg_chars)
@@ -146,99 +165,146 @@ def _make_cli_arg_names_options(arg_name) -> Tuple[List[str], List[str]]:
         return positionals, options
 
     default_value: Any
-    for arg_name in func_spec.args:
-        cli_arg_names_positional, cli_arg_names_options = _make_cli_arg_names_options(
-            arg_name
+    for parameter in func_signature.parameters.values():
+        (cli_arg_names_positional, cli_arg_names_options) = _make_cli_arg_names_options(
+            parameter.name
         )
-        default_value = default_by_arg_name.get(arg_name, NotDefined)
-
-        if default_value != NotDefined and not name_mapping_policy:
-            message = textwrap.dedent(
-                f"""
-                Argument "{arg_name}" in function "{function.__name__}"
-                is not keyword-only but has a default value.
-
-                Please note that since Argh v.0.30 the default name mapping
-                policy has changed.
-
-                More information:
-                https://argh.readthedocs.io/en/latest/changes.html#version-0-30-0-2023-10-21
-
-                You need to upgrade your functions so that the arguments
-                that have default values become keyword-only:
-
-                    f(x=1) -> f(*, x=1)
-
-                If you actually want an optional positional argument,
-                please set the name mapping policy explicitly to `BY_NAME_IF_KWONLY`.
+        if parameter.default is not parameter.empty:
+            default_value = parameter.default
+        else:
+            default_value = NotDefined
 
-                If you choose to postpone the migration, you have two options:
+        extra_spec_kwargs = {}
 
-                a) set the policy explicitly to `BY_NAME_IF_HAS_DEFAULT`;
-                b) pin Argh version to 0.29 until you are ready to migrate.
+        if can_use_hints:
+            hints = function.__annotations__
+            if parameter.name in hints:
+                extra_spec_kwargs = (
+                    TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params(
+                        hints[parameter.name]
+                    )
+                )
 
-                Thank you for understanding!
-                """
-            ).strip()
+        if parameter.kind in (
+            parameter.POSITIONAL_ONLY,
+            parameter.POSITIONAL_OR_KEYWORD,
+        ):
+            if default_value != NotDefined and not name_mapping_policy:
+                message = textwrap.dedent(
+                    f"""
+                    Argument "{parameter.name}" in function "{function.__name__}"
+                    is not keyword-only but has a default value.
+
+                    Please note that since Argh v.0.30 the default name mapping
+                    policy has changed.
+
+                    More information:
+                    https://argh.readthedocs.io/en/latest/changes.html#version-0-30-0-2023-10-21
+
+                    You need to upgrade your functions so that the arguments
+                    that have default values become keyword-only:
+
+                        f(x=1) -> f(*, x=1)
+
+                    If you actually want an optional positional argument,
+                    please set the name mapping policy explicitly to `BY_NAME_IF_KWONLY`.
+
+                    If you choose to postpone the migration, you have two options:
+
+                    a) set the policy explicitly to `BY_NAME_IF_HAS_DEFAULT`;
+                    b) pin Argh version to 0.29 until you are ready to migrate.
+
+                    Thank you for understanding!
+                    """
+                ).strip()
+
+                # Assume legacy policy and show a warning if the signature is
+                # simple (without kwonly args) so that the script continues working
+                # but the author is urged to upgrade it.
+                # When it cannot be auto-resolved (kwonly args mixed with old-style
+                # ones but no policy specified), throw an error.
+                #
+                # TODO: remove in v.0.33 if it happens, otherwise in v1.0.
+                if has_kwonly:
+                    raise ArgumentNameMappingError(message)
+                warnings.warn(DeprecationWarning(message))
+                name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
+
+            arg_spec = ParserAddArgumentSpec(
+                func_arg_name=parameter.name,
+                cli_arg_names=cli_arg_names_positional,
+                default_value=default_value,
+                other_add_parser_kwargs=extra_spec_kwargs,
+            )
 
-            # Assume legacy policy and show a warning if the signature is
-            # simple (without kwonly args) so that the script continues working
-            # but the author is urged to upgrade it.
-            # When it cannot be auto-resolved (kwonly args mixed with old-style
-            # ones but no policy specified), throw an error.
-            #
-            # TODO: remove in v.0.33 if it happens, otherwise in v1.0.
-            if has_kwonly:
-                raise ArgumentNameMappingError(message)
-            warnings.warn(DeprecationWarning(message))
-            name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
-
-        arg_spec = ParserAddArgumentSpec(
-            func_arg_name=arg_name,
-            cli_arg_names=cli_arg_names_positional,
-            default_value=default_value,
-        )
+            if default_value != NotDefined:
+                if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT:
+                    arg_spec.cli_arg_names = cli_arg_names_options
+                else:
+                    arg_spec.nargs = OPTIONAL
+
+            # annotations are interpreted without regard to the broader
+            # context, e.g. default values; in some cases argparse requires
+            # pretty specific combinations of props, so we need to adjust them
+            if can_use_hints:
+                # "required" is invalid for positional CLI argument;
+                # it may have been set from Optional[...] hint above.
+                # Reinterpret it as "optional positional" instead.
+                if "required" in arg_spec.other_add_parser_kwargs:
+                    value = arg_spec.other_add_parser_kwargs.pop("required")
+                    if value is False:
+                        arg_spec.nargs = OPTIONAL
+
+                if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT:
+                    # The guesser yields `type=bool` from `foo: bool = False`
+                    # but `type` is incompatible with `action="store_true"` which
+                    # is added by guess_extra_parser_add_argument_spec_kwargs().
+                    if (
+                        isinstance(arg_spec.default_value, bool)
+                        and arg_spec.other_add_parser_kwargs.get("type") == bool
+                    ):
+                        del arg_spec.other_add_parser_kwargs["type"]
+
+            yield arg_spec
+
+        elif parameter.kind == parameter.KEYWORD_ONLY:
+            arg_spec = ParserAddArgumentSpec(
+                func_arg_name=parameter.name,
+                cli_arg_names=cli_arg_names_positional,
+                default_value=default_value,
+                other_add_parser_kwargs=extra_spec_kwargs,
+            )
 
-        if default_value != NotDefined:
             if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT:
-                arg_spec.cli_arg_names = cli_arg_names_options
+                if default_value != NotDefined:
+                    arg_spec.cli_arg_names = cli_arg_names_options
             else:
-                arg_spec.nargs = OPTIONAL
-
-        yield arg_spec
-
-    for arg_name in func_spec.kwonlyargs:
-        cli_arg_names_positional, cli_arg_names_options = _make_cli_arg_names_options(
-            arg_name
-        )
-
-        if func_spec.kwonlydefaults and arg_name in func_spec.kwonlydefaults:
-            default_value = func_spec.kwonlydefaults[arg_name]
-        else:
-            default_value = NotDefined
-
-        arg_spec = ParserAddArgumentSpec(
-            func_arg_name=arg_name,
-            cli_arg_names=cli_arg_names_positional,
-            default_value=default_value,
-        )
-
-        if name_mapping_policy == NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT:
-            if default_value != NotDefined:
                 arg_spec.cli_arg_names = cli_arg_names_options
-        else:
-            arg_spec.cli_arg_names = cli_arg_names_options
-            if default_value == NotDefined:
-                arg_spec.is_required = True
-
-        yield arg_spec
-
-    if func_spec.varargs:
-        yield ParserAddArgumentSpec(
-            func_arg_name=func_spec.varargs,
-            cli_arg_names=[func_spec.varargs.replace("_", "-")],
-            nargs=ZERO_OR_MORE,
-        )
+                if default_value == NotDefined:
+                    arg_spec.is_required = True
+
+            # annotations are interpreted without regard to the broader
+            # context, e.g. default values; in some cases argparse requires
+            # pretty specific combinations of props, so we need to adjust them
+            if can_use_hints:
+                # The guesser yields `type=bool` from `foo: bool = False`
+                # but `type` is incompatible with `action="store_true"` which
+                # is added by guess_extra_parser_add_argument_spec_kwargs().
+                if (
+                    isinstance(arg_spec.default_value, bool)
+                    and arg_spec.other_add_parser_kwargs.get("type") == bool
+                ):
+                    del arg_spec.other_add_parser_kwargs["type"]
+
+            yield arg_spec
+
+        elif parameter.kind == parameter.VAR_POSITIONAL:
+            yield ParserAddArgumentSpec(
+                func_arg_name=parameter.name,
+                cli_arg_names=[parameter.name.replace("_", "-")],
+                nargs=ZERO_OR_MORE,
+                other_add_parser_kwargs=extra_spec_kwargs,
+            )
 
 
 def guess_extra_parser_add_argument_spec_kwargs(
@@ -341,12 +407,22 @@ def set_default_command(
        option name ``-h`` is silently removed from any argument.
 
     """
-    func_spec = get_arg_spec(function)
-    has_varkw = bool(func_spec.varkw)  # the **kwargs thing
+    func_signature = inspect.signature(function)
+
+    # the **kwargs thing
+    has_varkw = any(p.kind == p.VAR_KEYWORD for p in func_signature.parameters.values())
 
     declared_args: List[ParserAddArgumentSpec] = getattr(function, ATTR_ARGS, [])
+
+    # transitional period: hints are used for types etc. only if @arg is not used
+    can_use_hints = not declared_args
+
     inferred_args: List[ParserAddArgumentSpec] = list(
-        infer_argspecs_from_function(function, name_mapping_policy=name_mapping_policy)
+        infer_argspecs_from_function(
+            function,
+            name_mapping_policy=name_mapping_policy,
+            can_use_hints=can_use_hints,
+        )
     )
 
     if declared_args and not inferred_args and not has_varkw:
@@ -660,3 +736,66 @@ def add_subcommands(
 
 class ArgumentNameMappingError(AssemblingError):
     ...
+
+
+class TypingHintArgSpecGuesser:
+    BASIC_TYPES = (str, int, float, bool)
+
+    @classmethod
+    def typing_hint_to_arg_spec_params(
+        cls, type_def: type, is_positional: bool = False
+    ) -> Dict[str, Any]:
+        origin = get_origin(type_def)
+        args = get_args(type_def)
+
+        # `str`
+        if type_def in cls.BASIC_TYPES:
+            return {
+                "type": type_def
+                # "type": _parse_basic_type(type_def)
+            }
+
+        # `list`
+        if type_def == list:
+            return {"nargs": ZERO_OR_MORE}
+
+        # `Literal["a", "b"]`
+        if origin == Literal:
+            return {"choices": args, "type": type(args[0])}
+
+        # `str | int`
+        if any(origin is t for t in UNION_TYPES):
+            retval = {}
+            first_subtype = args[0]
+            if first_subtype in cls.BASIC_TYPES:
+                retval["type"] = first_subtype
+
+            if first_subtype == list:
+                retval["nargs"] = ZERO_OR_MORE
+
+            if get_origin(first_subtype) == list:
+                retval["nargs"] = ZERO_OR_MORE
+                item_type = cls._extract_item_type_from_list_type(first_subtype)
+                if item_type:
+                    retval["type"] = item_type
+
+            if type(None) in args:
+                retval["required"] = False
+            return retval
+
+        # `list[str]`
+        if origin == list:
+            retval = {}
+            retval["nargs"] = ZERO_OR_MORE
+            if args[0] in cls.BASIC_TYPES:
+                retval["type"] = args[0]
+            return retval
+
+        return {}
+
+    @classmethod
+    def _extract_item_type_from_list_type(cls, type_def) -> Optional[type]:
+        args = get_args(type_def)
+        if args[0] in cls.BASIC_TYPES:
+            return args[0]
+        return None
diff --git a/src/argh/constants.py b/src/argh/constants.py
index f98a539..9bf1672 100644
--- a/src/argh/constants.py
+++ b/src/argh/constants.py
@@ -19,7 +19,6 @@
     "ATTR_ARGS",
     "ATTR_WRAPPED_EXCEPTIONS",
     "ATTR_WRAPPED_EXCEPTIONS_PROCESSOR",
-    "ATTR_EXPECTS_NAMESPACE_OBJECT",
     "PARSER_FORMATTER",
     "DEFAULT_ARGUMENT_TEMPLATE",
     "DEST_FUNCTION",
@@ -46,9 +45,6 @@
 #: a function to preprocess the exception object when it is wrapped
 ATTR_WRAPPED_EXCEPTIONS_PROCESSOR = "argh_wrap_errors_processor"
 
-#: forcing argparse.Namespace object instead of signature introspection
-ATTR_EXPECTS_NAMESPACE_OBJECT = "argh_expects_namespace_object"
-
 #
 # Dest names in parser defaults
 #
diff --git a/src/argh/decorators.py b/src/argh/decorators.py
index 10ede10..8913a95 100644
--- a/src/argh/decorators.py
+++ b/src/argh/decorators.py
@@ -11,13 +11,11 @@
 Command decorators
 ~~~~~~~~~~~~~~~~~~
 """
-import warnings
 from typing import Callable, List, Optional
 
 from argh.constants import (
     ATTR_ALIASES,
     ATTR_ARGS,
-    ATTR_EXPECTS_NAMESPACE_OBJECT,
     ATTR_NAME,
     ATTR_WRAPPED_EXCEPTIONS,
     ATTR_WRAPPED_EXCEPTIONS_PROCESSOR,
@@ -25,7 +23,7 @@
 from argh.dto import ParserAddArgumentSpec
 from argh.utils import CliArgToFuncArgGuessingError, naive_guess_func_arg_name
 
-__all__ = ["aliases", "named", "arg", "wrap_errors", "expects_obj"]
+__all__ = ["aliases", "named", "arg", "wrap_errors"]
 
 
 def named(new_name: str) -> Callable:
@@ -205,40 +203,3 @@ def wrapper(func: Callable):
         return func
 
     return wrapper
-
-
-# TODO: deprecated — remove in v0.31+
-def expects_obj(func: Callable) -> Callable:
-    """
-    Marks given function as expecting a namespace object.
-
-    .. deprecated:: 0.30
-        Will removed in v0.31 or a later version.
-
-        Please consider using the main feature Argh offers — the mapping of
-        function signature to CLI.  Otherwise you are basically using vanilla
-        Argparse.
-
-    Usage::
-
-        @arg("bar")
-        @arg("--quux", default=123)
-        @expects_obj
-        def foo(args):
-            yield args.bar, args.quux
-
-    This is equivalent to::
-
-        def foo(bar, quux=123):
-            yield bar, quux
-
-    In most cases you don't need this decorator.
-    """
-    warnings.warn(
-        DeprecationWarning(
-            "The @expects_obj decorator is deprecated. Please update "
-            'your code to use the standard approach AKA "Natural Way".'
-        )
-    )
-    setattr(func, ATTR_EXPECTS_NAMESPACE_OBJECT, True)
-    return func
diff --git a/src/argh/dispatching.py b/src/argh/dispatching.py
index fb4b085..ef413f6 100644
--- a/src/argh/dispatching.py
+++ b/src/argh/dispatching.py
@@ -12,23 +12,22 @@
 ~~~~~~~~~~~
 """
 import argparse
+import inspect
 import io
 import sys
 import warnings
 from types import GeneratorType
 from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Tuple
 
-from argh.assembling import add_commands, set_default_command
+from argh.assembling import NameMappingPolicy, add_commands, set_default_command
 from argh.completion import autocomplete
 from argh.constants import (
-    ATTR_EXPECTS_NAMESPACE_OBJECT,
     ATTR_WRAPPED_EXCEPTIONS,
     ATTR_WRAPPED_EXCEPTIONS_PROCESSOR,
     DEST_FUNCTION,
     PARSER_FORMATTER,
 )
 from argh.exceptions import CommandError, DispatchingError
-from argh.utils import get_arg_spec
 
 __all__ = [
     "ArghNamespace",
@@ -79,6 +78,7 @@ def dispatch(
     raw_output: bool = False,
     namespace: Optional[argparse.Namespace] = None,
     skip_unknown_args: bool = False,
+    always_flush: bool = False,
 ) -> Optional[str]:
     """
     Parses given list of arguments using given parser, calls the relevant
@@ -137,10 +137,22 @@ def dispatch(
 
     :param namespace:
 
-        An `argparse.Namespace`-like object.  By default an
-        :class:`argh.dispatching.ArghNamespace` object is used.  Please note
-        that support for combined default and nested functions may be broken
-        if a different type of object is forced.
+        .. deprecated:: 0.31
+
+          This argument will be removed soon after v0.31.
+
+    :param always_flush:
+
+        If the output stream is not a terminal (i.e. redirected to a file or
+        another process), it's probably buffered.  In most cases it doesn't
+        matter.
+
+        However, if the output of your program is generated with delays
+        between the lines and you may want to redirect them to another process
+        and immediately see the results (e.g. `my_app.py | grep something`),
+        it's a good idea to force flushing of the buffer.
+
+        .. versionadded:: 0.31
 
     By default the exceptions are not wrapped and will propagate. The only
     exception that is always wrapped is :class:`~argh.exceptions.CommandError`
@@ -151,6 +163,13 @@ def dispatch(
     Wrapped exceptions, or other "expected errors" like parse failures,
     will cause a SystemExit to be raised.
     """
+    if namespace:
+        warnings.warn(
+            DeprecationWarning(
+                "The argument `namespace` in `dispatch()` is deprecated. "
+                "It will be removed in the next minor version after v0.31."
+            )
+        )
 
     # TODO: remove in v0.31+/v1.0
     if add_help_command:  # pragma: nocover
@@ -182,6 +201,7 @@ def dispatch(
         output_file=output_file,
         errors_file=errors_file,
         raw_output=raw_output,
+        always_flush=always_flush,
     )
 
 
@@ -196,6 +216,12 @@ def parse_and_resolve(
     .. versionadded:: 0.30
 
     Parses CLI arguments and resolves the endpoint function.
+
+    :param namespace:
+
+        .. deprecated:: 0.31
+
+          This argument will be removed soon after v0.31.
     """
     if completion:
         autocomplete(parser)
@@ -225,20 +251,29 @@ def run_endpoint_function(
     output_file: IO = sys.stdout,
     errors_file: IO = sys.stderr,
     raw_output: bool = False,
+    always_flush: bool = False,
 ) -> Optional[str]:
     """
     .. versionadded:: 0.30
 
     Extracts arguments from the namespace object, calls the endpoint function
     and processes its output.
+
+    :param always_flush:
+
+        Flush the buffer after every line even if `output_file` is not a TTY.
+        Turn this off if you don't need dynamic output)
     """
     lines = _execute_command(function, namespace_obj, errors_file)
 
-    return _process_command_output(lines, output_file, raw_output)
+    return _process_command_output(lines, output_file, raw_output, always_flush)
 
 
 def _process_command_output(
-    lines: Iterator[str], output_file: Optional[IO], raw_output: bool
+    lines: Iterator[str],
+    output_file: Optional[IO],
+    raw_output: bool,
+    always_flush: bool,
 ) -> Optional[str]:
     out_io: IO
 
@@ -260,6 +295,14 @@ def _process_command_output(
             # in most cases user wants one message per line
             out_io.write("\n")
 
+        # If it's not a terminal (i.e. redirected to a file or another
+        # process), it's probably buffered.  In most cases it doesn't matter
+        # but if the output is generated with delays between the lines and we
+        # may want to monitor it (e.g. `my_app.py | grep something`), it's a
+        # good idea to force flushing.
+        if always_flush:
+            out_io.flush()
+
     if output_file is None:
         # user wanted a string; return contents of our temporary file-like obj
         out_io.seek(0)
@@ -307,39 +350,48 @@ def _execute_command(
     # the function is nested to catch certain exceptions (see below)
     def _call():
         # Actually call the function
-        if getattr(function, ATTR_EXPECTS_NAMESPACE_OBJECT, False):
-            result = function(namespace_obj)
-        else:
-            # namespace -> dictionary
-            def _flat_key(key):
-                return key.replace("-", "_")
 
-            all_input = dict((_flat_key(k), v) for k, v in vars(namespace_obj).items())
+        # namespace -> dictionary
+        def _flat_key(key):
+            return key.replace("-", "_")
 
-            # filter the namespace variables so that only those expected
-            # by the actual function will pass
-
-            spec = get_arg_spec(function)
-
-            positional = [all_input[k] for k in spec.args]
-            kwonly = getattr(spec, "kwonlyargs", [])
-            keywords = dict((k, all_input[k]) for k in kwonly)
-
-            # *args
-            if spec.varargs:
-                positional += all_input[spec.varargs]
+        values_by_arg_name = dict(
+            (_flat_key(k), v) for k, v in vars(namespace_obj).items()
+        )
 
-            # **kwargs
-            varkw = getattr(spec, "varkw", getattr(spec, "keywords", []))
-            if varkw:
-                not_kwargs = [DEST_FUNCTION] + spec.args + [spec.varargs] + kwonly
-                for k in vars(namespace_obj):
-                    normalized_k = _flat_key(k)
-                    if k.startswith("_") or normalized_k in not_kwargs:
-                        continue
-                    keywords[normalized_k] = getattr(namespace_obj, k)
+        # filter the namespace variables so that only those expected
+        # by the actual function will pass
+
+        func_signature = inspect.signature(function)
+        func_params = func_signature.parameters.values()
+
+        positional_names = [
+            p.name
+            for p in func_params
+            if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
+        ]
+        kwonly_names = [p.name for p in func_params if p.kind == p.KEYWORD_ONLY]
+        varargs_names = [p.name for p in func_params if p.kind == p.VAR_POSITIONAL]
+        positional_values = [values_by_arg_name[name] for name in positional_names]
+        values_by_name = dict((k, values_by_arg_name[k]) for k in kwonly_names)
+
+        # *args
+        if varargs_names:
+            value = varargs_names[0]
+            positional_values += values_by_arg_name[value]
+
+        # **kwargs
+        if any(p for p in func_params if p.kind == p.VAR_KEYWORD):
+            not_kwargs = (
+                [DEST_FUNCTION] + positional_names + varargs_names + kwonly_names
+            )
+            for k in vars(namespace_obj):
+                normalized_k = _flat_key(k)
+                if k.startswith("_") or normalized_k in not_kwargs:
+                    continue
+                values_by_name[normalized_k] = getattr(namespace_obj, k)
 
-            result = function(*positional, **keywords)
+        result = function(*positional_values, **values_by_name)
 
         # Yield the results
         if isinstance(result, (GeneratorType, list, tuple)):
@@ -375,11 +427,23 @@ def default_exception_processor(exc: Exception) -> str:
         sys.exit(code)
 
 
-def dispatch_command(function: Callable, *args, **kwargs) -> None:
+def dispatch_command(
+    function: Callable, *args, old_name_mapping_policy=True, **kwargs
+) -> None:
     """
     A wrapper for :func:`dispatch` that creates a one-command parser.
     Uses :attr:`argh.constants.PARSER_FORMATTER`.
 
+    :param old_name_mapping_policy:
+
+        .. versionadded:: 0.31
+
+        If `True`, sets the default argument naming policy to
+        `~argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT`, otherwise
+        to `~argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY`.
+
+        .. warning:: tho default will be changed to `False` in v.0.33 (or v.1.0).
+
     This::
 
         dispatch_command(foo)
@@ -387,7 +451,7 @@ def dispatch_command(function: Callable, *args, **kwargs) -> None:
     ...is a shortcut for::
 
         parser = ArgumentParser()
-        set_default_command(parser, foo)
+        set_default_command(parser, foo, name_mapping_policy=...)
         dispatch(parser)
 
     This function can be also used as a decorator::
@@ -397,17 +461,34 @@ def main(foo: int = 123) -> int:
             return foo + 1
 
     """
+    if old_name_mapping_policy:
+        name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
+    else:
+        name_mapping_policy = NameMappingPolicy.BY_NAME_IF_KWONLY
+
     parser = argparse.ArgumentParser(formatter_class=PARSER_FORMATTER)
-    set_default_command(parser, function)
+    set_default_command(parser, function, name_mapping_policy=name_mapping_policy)
     dispatch(parser, *args, **kwargs)
 
 
-def dispatch_commands(functions: List[Callable], *args, **kwargs) -> None:
+def dispatch_commands(
+    functions: List[Callable], *args, old_name_mapping_policy=True, **kwargs
+) -> None:
     """
     A wrapper for :func:`dispatch` that creates a parser, adds commands to
     the parser and dispatches them.
     Uses :attr:`PARSER_FORMATTER`.
 
+    :param old_name_mapping_policy:
+
+        .. versionadded:: 0.31
+
+        If `True`, sets the default argument naming policy to
+        `~argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT`, otherwise
+        to `~argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY`.
+
+        .. warning:: tho default will be changed to `False` in v.0.33 (or v.1.0).
+
     This::
 
         dispatch_commands([foo, bar])
@@ -415,12 +496,17 @@ def dispatch_commands(functions: List[Callable], *args, **kwargs) -> None:
     ...is a shortcut for::
 
         parser = ArgumentParser()
-        add_commands(parser, [foo, bar])
+        add_commands(parser, [foo, bar], name_mapping_policy=...)
         dispatch(parser)
 
     """
+    if old_name_mapping_policy:
+        name_mapping_policy = NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
+    else:
+        name_mapping_policy = NameMappingPolicy.BY_NAME_IF_KWONLY
+
     parser = argparse.ArgumentParser(formatter_class=PARSER_FORMATTER)
-    add_commands(parser, functions)
+    add_commands(parser, functions, name_mapping_policy=name_mapping_policy)
     dispatch(parser, *args, **kwargs)
 
 
@@ -441,13 +527,13 @@ class EntryPoint:
         app = EntryPoint("main", {"description": "This is a cool app"})
 
         @app
-        def ls() -> None:
+        def ls() -> Iterator[int]:
             for i in range(10):
-                print i
+                yield i
 
         @app
-        def greet() -> None:
-            print "hello"
+        def greet() -> str:
+            return "hello"
 
         if __name__ == "__main__":
             app()
diff --git a/src/argh/utils.py b/src/argh/utils.py
index 9c232e9..3f7d42d 100644
--- a/src/argh/utils.py
+++ b/src/argh/utils.py
@@ -12,9 +12,8 @@
 ~~~~~~~~~
 """
 import argparse
-import inspect
 import re
-from typing import Callable, Tuple
+from typing import Tuple
 
 
 def get_subparsers(
@@ -46,23 +45,6 @@ def get_subparsers(
     raise SubparsersNotDefinedError()
 
 
-def get_arg_spec(function: Callable) -> inspect.FullArgSpec:
-    """
-    Returns argument specification for given function.
-
-    Gets to the innermost function through decorators.
-
-    Omits special arguments of instance methods (`self`) and class methods
-    (usually `cls` or something like this).  Supports static methods.
-    """
-    while hasattr(function, "__wrapped__"):
-        function = function.__wrapped__
-    spec = inspect.getfullargspec(function)
-    if inspect.ismethod(function):
-        spec = spec._replace(args=spec.args[1:])
-    return spec
-
-
 def unindent(text: str) -> str:
     """
     Given a multi-line string, decreases indentation of all lines so that the


Test patch:
diff --git a/tests/test_assembling.py b/tests/test_assembling.py
index dd38586..23c17a0 100644
--- a/tests/test_assembling.py
+++ b/tests/test_assembling.py
@@ -3,6 +3,7 @@
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 """
 import argparse
+from typing import Literal, Optional
 from unittest.mock import MagicMock, call, patch
 
 import pytest
@@ -398,6 +399,7 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_leg
         call("--gamma-pos-opt", default="gamma named", type=str, help=help_tmpl),
         call("--delta-pos-opt", default="delta named", type=str, help=help_tmpl),
         call("-t", "--theta-pos-opt", default="theta named", type=str, help=help_tmpl),
+        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
         call("--gamma-kwonly-opt", default="gamma kwonly", type=str, help=help_tmpl),
         call("delta-kwonly-req", help=help_tmpl),
         call("epsilon-kwonly-req-one", help=help_tmpl),
@@ -405,7 +407,6 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_leg
         call(
             "-z", "--zeta-kwonly-opt", default="zeta kwonly", type=str, help=help_tmpl
         ),
-        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
     ]
     assert parser.set_defaults.mock_calls == [
         call(function=big_command_with_everything)
@@ -472,6 +473,7 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_mod
             type=str,
             help=help_tmpl,
         ),
+        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
         call("--gamma-kwonly-opt", default="gamma kwonly", type=str, help=help_tmpl),
         call("--delta-kwonly-req", required=True, help=help_tmpl),
         call("--epsilon-kwonly-req-one", required=True, help=help_tmpl),
@@ -479,7 +481,6 @@ def test_set_default_command_infer_cli_arg_names_from_func_signature__policy_mod
         call(
             "-z", "--zeta-kwonly-opt", default="zeta kwonly", type=str, help=help_tmpl
         ),
-        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
     ]
     assert parser.set_defaults.mock_calls == [
         call(function=big_command_with_everything)
@@ -641,9 +642,9 @@ def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly):
     assert parser.add_argument.mock_calls == [
         call("foo-pos", help=help_tmpl),
         call("bar-pos", help=help_tmpl),
+        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
         call("-f", "--foo-kwonly", default="foo_kwonly", type=str, help=help_tmpl),
         call("bar-kwonly", help=help_tmpl),
-        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
     ]
 
 
@@ -662,9 +663,9 @@ def cmd(foo_pos, bar_pos, *args, foo_kwonly="foo_kwonly", bar_kwonly):
     assert parser.add_argument.mock_calls == [
         call("foo-pos", help=help_tmpl),
         call("bar-pos", help=help_tmpl),
+        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
         call("-f", "--foo-kwonly", default="foo_kwonly", type=str, help=help_tmpl),
         call("-b", "--bar-kwonly", required=True, help=help_tmpl),
-        call("args", nargs=argparse.ZERO_OR_MORE, help=help_tmpl),
     ]
 
 
@@ -762,3 +763,160 @@ def test_is_positional():
     # this spec is invalid but validation is out of scope of the function
     # as it only checks if the first argument has the leading dash
     assert argh.assembling._is_positional(["-f", "foo"]) is False
+
+
+def test_typing_hints_only_used_when_arg_deco_not_used():
+    @argh.arg("foo", type=int)
+    def func_decorated(foo: Optional[float]):
+        ...
+
+    def func_undecorated(bar: Optional[float]):
+        ...
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(parser, func_decorated)
+    assert parser.add_argument.mock_calls == [
+        call("foo", type=int, help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE),
+    ]
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(parser, func_undecorated)
+    assert parser.add_argument.mock_calls == [
+        call(
+            "bar",
+            nargs="?",
+            type=float,
+            help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE,
+        ),
+    ]
+
+
+def test_typing_hints_overview():
+    def func(
+        alpha,
+        beta: str,
+        gamma: Optional[int] = None,
+        *,
+        delta: float = 1.5,
+        epsilon: Optional[int] = 42,
+        zeta: bool = False,
+    ) -> str:
+        return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}, epsilon={epsilon}, zeta={zeta}"
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(
+        parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY
+    )
+    _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE}
+    assert parser.add_argument.mock_calls == [
+        call("alpha", **_extra_kw),
+        call("beta", type=str, **_extra_kw),
+        call("gamma", default=None, nargs="?", type=int, **_extra_kw),
+        call("-d", "--delta", type=float, default=1.5, **_extra_kw),
+        call("-e", "--epsilon", type=int, default=42, required=False, **_extra_kw),
+        call("-z", "--zeta", default=False, action="store_true", **_extra_kw),
+    ]
+
+
+def test_typing_hints_str__policy_by_name_if_has_default():
+    def func(alpha: str, beta: str = "N/A", *, gamma: str, delta: str = "N/A") -> str:
+        return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}"
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(
+        parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
+    )
+    _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE}
+    assert parser.add_argument.mock_calls == [
+        call("alpha", type=str, **_extra_kw),
+        call("-b", "--beta", default="N/A", type=str, **_extra_kw),
+        call("gamma", type=str, **_extra_kw),
+        call("-d", "--delta", default="N/A", type=str, **_extra_kw),
+    ]
+
+
+def test_typing_hints_str__policy_by_name_if_kwonly():
+    def func(alpha: str, beta: str = "N/A", *, gamma: str, delta: str = "N/A") -> str:
+        return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}"
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(
+        parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY
+    )
+    _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE}
+    assert parser.add_argument.mock_calls == [
+        call("alpha", type=str, help=argh.constants.DEFAULT_ARGUMENT_TEMPLATE),
+        call("beta", type=str, default="N/A", nargs="?", **_extra_kw),
+        call("-g", "--gamma", required=True, type=str, **_extra_kw),
+        call("-d", "--delta", default="N/A", type=str, **_extra_kw),
+    ]
+
+
+def test_typing_hints_bool__policy_by_name_if_has_default():
+    def func(
+        alpha: bool, beta: bool = False, *, gamma: bool, delta: bool = False
+    ) -> str:
+        return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}"
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(
+        parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
+    )
+    _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE}
+    assert parser.add_argument.mock_calls == [
+        call("alpha", type=bool, **_extra_kw),
+        call("-b", "--beta", default=False, action="store_true", **_extra_kw),
+        call("gamma", type=bool, **_extra_kw),
+        call("-d", "--delta", default=False, action="store_true", **_extra_kw),
+    ]
+
+
+def test_typing_hints_bool__policy_by_name_if_kwonly():
+    def func(
+        alpha: bool, beta: bool = False, *, gamma: bool, delta: bool = False
+    ) -> str:
+        return f"alpha={alpha}, beta={beta}, gamma={gamma}, delta={delta}"
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(
+        parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY
+    )
+    _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE}
+    assert parser.add_argument.mock_calls == [
+        call("alpha", type=bool, **_extra_kw),
+        call("beta", type=bool, default=False, nargs="?", **_extra_kw),
+        call("-g", "--gamma", required=True, type=bool, **_extra_kw),
+        call("-d", "--delta", default=False, action="store_true", **_extra_kw),
+    ]
+
+
+def test_typing_hints_literal():
+    def func(
+        name: Literal["Alice", "Bob"], *, greeting: Literal["Hello", "Hi"] = "Hello"
+    ) -> str:
+        return f"{greeting}, {name}!"
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument = MagicMock()
+    argh.set_default_command(
+        parser, func, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_KWONLY
+    )
+    _extra_kw = {"help": argh.constants.DEFAULT_ARGUMENT_TEMPLATE}
+    assert parser.add_argument.mock_calls == [
+        call("name", choices=("Alice", "Bob"), type=str, **_extra_kw),
+        call(
+            "-g",
+            "--greeting",
+            choices=("Hello", "Hi"),
+            type=str,
+            default="Hello",
+            **_extra_kw,
+        ),
+    ]
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index b813069..7c9b7cb 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -74,16 +74,6 @@ def func():
     assert attr == "STUB"
 
 
-# TODO: deprecated — remove in v0.31+
-def test_expects_obj():
-    @argh.expects_obj
-    def func(args):
-        pass
-
-    attr = getattr(func, argh.constants.ATTR_EXPECTS_NAMESPACE_OBJECT)
-    assert attr is True
-
-
 def test_naive_guess_func_arg_name() -> None:
     # none (error)
     with pytest.raises(CliArgToFuncArgGuessingError):
diff --git a/tests/test_dispatching.py b/tests/test_dispatching.py
index 987b8c8..1bfaf72 100644
--- a/tests/test_dispatching.py
+++ b/tests/test_dispatching.py
@@ -50,10 +50,31 @@ def func():
 
     mock_parser_class.assert_called_once()
     mock_parser = mock_parser_class.return_value
-    mock_set_default_command.assert_called_with(mock_parser, func)
+    mock_set_default_command.assert_called_with(
+        mock_parser,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT,
+    )
     mock_dispatch.assert_called_with(mock_parser)
 
 
+@pytest.mark.parametrize("always_flush", [True, False])
+def test_run_endpoint_function__always_flush(always_flush):
+    def func():
+        return ["first line", "second line"]
+
+    out_io = Mock(spec=io.StringIO)
+
+    argh.dispatching.run_endpoint_function(
+        func, argparse.Namespace(), output_file=out_io, always_flush=always_flush
+    )
+
+    if always_flush:
+        assert out_io.flush.call_count == 2
+    else:
+        out_io.flush.assert_not_called()
+
+
 @patch("argh.dispatching.parse_and_resolve")
 @patch("argh.dispatching.run_endpoint_function")
 def test_dispatch_command_two_stage(mock_run_endpoint_function, mock_parse_and_resolve):
@@ -68,6 +89,7 @@ def func() -> str:
     mock_errors_file = Mock(io.TextIOBase)
     raw_output = False
     skip_unknown_args = False
+    always_flush = True
     mock_endpoint_function = Mock()
     mock_namespace = Mock(argparse.Namespace)
     mock_namespace_obj = Mock(argparse.Namespace)
@@ -83,6 +105,7 @@ def func() -> str:
         output_file=mock_output_file,
         errors_file=mock_errors_file,
         raw_output=raw_output,
+        always_flush=always_flush,
     )
 
     mock_parse_and_resolve.assert_called_with(
@@ -98,6 +121,7 @@ def func() -> str:
         output_file=mock_output_file,
         errors_file=mock_errors_file,
         raw_output=raw_output,
+        always_flush=always_flush,
     )
     assert retval == "run_endpoint_function retval"
 
@@ -113,7 +137,11 @@ def func():
 
     mock_parser_class.assert_called_once()
     mock_parser = mock_parser_class.return_value
-    mock_add_commands.assert_called_with(mock_parser, [func])
+    mock_add_commands.assert_called_with(
+        mock_parser,
+        [func],
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT,
+    )
     mock_dispatch.assert_called_with(mock_parser)
 
 
@@ -162,3 +190,75 @@ def hit():
     add_commands_mock.assert_called_with(mocked_parser, [greet, hit])
     assert dispatch_mock.called
     dispatch_mock.assert_called_with(mocked_parser)
+
+
+@patch("argh.dispatching.dispatch")
+@patch("argh.dispatching.set_default_command")
+@patch("argparse.ArgumentParser")
+def test_dispatch_command_naming_policy(
+    parser_cls_mock, set_default_command_mock, dispatch_mock
+):
+    def func():
+        ...
+
+    parser_mock = Mock()
+    parser_cls_mock.return_value = parser_mock
+
+    argh.dispatching.dispatch_command(func)
+    set_default_command_mock.assert_called_with(
+        parser_mock,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT,
+    )
+    set_default_command_mock.reset_mock()
+
+    argh.dispatching.dispatch_command(func, old_name_mapping_policy=True)
+    set_default_command_mock.assert_called_with(
+        parser_mock,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT,
+    )
+    set_default_command_mock.reset_mock()
+
+    argh.dispatching.dispatch_command(func, old_name_mapping_policy=False)
+    set_default_command_mock.assert_called_with(
+        parser_mock,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY,
+    )
+
+
+@patch("argh.dispatching.dispatch")
+@patch("argh.dispatching.add_commands")
+@patch("argparse.ArgumentParser")
+def test_dispatch_commands_naming_policy(
+    parser_cls_mock, add_commands_mock, dispatch_mock
+):
+    def func():
+        ...
+
+    parser_mock = Mock()
+    parser_cls_mock.return_value = parser_mock
+
+    argh.dispatching.dispatch_commands(func)
+    add_commands_mock.assert_called_with(
+        parser_mock,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT,
+    )
+    add_commands_mock.reset_mock()
+
+    argh.dispatching.dispatch_commands(func, old_name_mapping_policy=True)
+    add_commands_mock.assert_called_with(
+        parser_mock,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT,
+    )
+    add_commands_mock.reset_mock()
+
+    argh.dispatching.dispatch_commands(func, old_name_mapping_policy=False)
+    add_commands_mock.assert_called_with(
+        parser_mock,
+        func,
+        name_mapping_policy=argh.assembling.NameMappingPolicy.BY_NAME_IF_KWONLY,
+    )
diff --git a/tests/test_integration.py b/tests/test_integration.py
index e70db4f..f863386 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -559,20 +559,6 @@ def whiner_iterable():
     )
 
 
-# TODO: deprecated — remove in v0.31+
-def test_custom_argparse_namespace():
-    @argh.expects_obj
-    def cmd(args):
-        return args.custom_value
-
-    parser = DebugArghParser()
-    parser.set_default_command(cmd)
-    namespace = argparse.Namespace()
-    namespace.custom_value = "foo"
-
-    assert run(parser, "", {"namespace": namespace}).out == "foo\n"
-
-
 @pytest.mark.parametrize(
     "argparse_namespace_class", [argparse.Namespace, argh.dispatching.ArghNamespace]
 )
@@ -672,17 +658,17 @@ def cmd(*args, foo="1", bar, baz="3", **kwargs):
         cmd, name_mapping_policy=NameMappingPolicy.BY_NAME_IF_HAS_DEFAULT
     )
 
-    expected_usage = "usage: pytest [-h] [-f FOO] [--baz BAZ] bar [args ...]\n"
+    expected_usage = "usage: pytest [-h] [-f FOO] [--baz BAZ] [args ...] bar\n"
     if sys.version_info < (3, 9):
         # https://github.com/python/cpython/issues/82619
         expected_usage = (
-            "usage: pytest [-h] [-f FOO] [--baz BAZ] bar [args [args ...]]\n"
+            "usage: pytest [-h] [-f FOO] [--baz BAZ] [args [args ...]] bar\n"
         )
     assert parser.format_usage() == expected_usage
 
     assert (
-        run(parser, "--baz=done test  this --baz=do").out
-        == "foo='1' bar='test' baz='do' args=('this',) kwargs={}\n"
+        run(parser, "--baz=baz! one  two").out
+        == "foo='1' bar='two' baz='baz!' args=('one',) kwargs={}\n"
     )
     assert (
         run(parser, "test --foo=do").out
@@ -710,8 +696,8 @@ def cmd(*args, foo="1", bar, baz="3", **kwargs):
     assert parser.format_usage() == expected_usage
 
     assert (
-        run(parser, "--baz=done test  this --bar=do").out
-        == "foo='1' bar='do' baz='done' args=('test', 'this') kwargs={}\n"
+        run(parser, "--baz=baz! one  two --bar=bar!").out
+        == "foo='1' bar='bar!' baz='baz!' args=('one', 'two') kwargs={}\n"
     )
     message = "the following arguments are required: --bar"
     assert run(parser, "test --foo=do", exit=True) == message
diff --git a/tests/test_typing_hints.py b/tests/test_typing_hints.py
new file mode 100644
index 0000000..1b02da7
--- /dev/null
+++ b/tests/test_typing_hints.py
@@ -0,0 +1,60 @@
+from typing import List, Literal, Optional, Union
+
+import pytest
+
+from argh.assembling import TypingHintArgSpecGuesser
+
+
+@pytest.mark.parametrize("arg_type", TypingHintArgSpecGuesser.BASIC_TYPES)
+def test_simple_types(arg_type):
+    guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params
+
+    # just the basic type
+    assert guess(arg_type) == {"type": arg_type}
+
+    # basic type or None
+    assert guess(Optional[arg_type]) == {
+        "type": arg_type,
+        "required": False,
+    }
+    assert guess(Union[None, arg_type]) == {"required": False}
+
+    # multiple basic types: the first one is used and None is looked up
+    assert guess(Union[arg_type, str, None]) == {
+        "type": arg_type,
+        "required": False,
+    }
+    assert guess(Union[str, arg_type, None]) == {
+        "type": str,
+        "required": False,
+    }
+
+
+def test_list():
+    guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params
+
+    assert guess(list) == {"nargs": "*"}
+    assert guess(Optional[list]) == {"nargs": "*", "required": False}
+
+    assert guess(List[str]) == {"nargs": "*", "type": str}
+    assert guess(List[int]) == {"nargs": "*", "type": int}
+    assert guess(Optional[List[str]]) == {"nargs": "*", "type": str, "required": False}
+    assert guess(Optional[List[tuple]]) == {"nargs": "*", "required": False}
+
+    assert guess(List[list]) == {"nargs": "*"}
+    assert guess(List[tuple]) == {"nargs": "*"}
+
+
+def test_literal():
+    guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params
+
+    assert guess(Literal["a"]) == {"choices": ("a",), "type": str}
+    assert guess(Literal["a", "b"]) == {"choices": ("a", "b"), "type": str}
+    assert guess(Literal[1]) == {"choices": (1,), "type": int}
+
+
+@pytest.mark.parametrize("arg_type", (dict, tuple))
+def test_unusable_types(arg_type):
+    guess = TypingHintArgSpecGuesser.typing_hint_to_arg_spec_params
+
+    assert guess(arg_type) == {}
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 02b26e9..df91f47 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -3,99 +3,11 @@
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 """
-import functools
 from argparse import ArgumentParser, _SubParsersAction
 
 import pytest
 
-from argh.utils import SubparsersNotDefinedError, get_arg_spec, get_subparsers, unindent
-
-
-def function(x, y=0):
-    return
-
-
-def decorated(func):
-    @functools.wraps(func)
-    def wrapped(*args, **kwargs):
-        print("Wrapping function call")
-        return func(*args, **kwargs)
-
-    return wrapped
-
-
-def _assert_spec(func, **overrides):
-    spec = get_arg_spec(func)
-
-    defaults = {
-        "args": ["x", "y"],
-        "varargs": None,
-        "varkw": None,
-        "defaults": (0,),
-        "kwonlyargs": [],
-        "annotations": {},
-    }
-
-    for k in defaults:
-        actual = getattr(spec, k)
-        expected = overrides[k] if k in overrides else defaults[k]
-        assert actual == expected
-
-
-def test_get_arg_spec__plain_func():
-    _assert_spec(function)
-
-
-def test_get_arg_spec__decorated_func():
-    def d(_f):
-        return _f
-
-    decorated = d(function)
-
-    _assert_spec(decorated)
-
-
-def test_get_arg_spec__wrapped():
-    wrapped = decorated(function)
-    _assert_spec(wrapped)
-
-
-def test_get_arg_spec__wrapped_nested():
-    wrapped = decorated(decorated(function))
-    _assert_spec(wrapped)
-
-
-def test_get_arg_spec__wrapped_complex():
-    def wrapper_deco(outer_arg):
-        def _outer(func):
-            @functools.wraps(func)
-            def _inner(*args, **kwargs):
-                return func(*args, **kwargs)
-
-            return _inner
-
-        return _outer
-
-    wrapped = wrapper_deco(5)(function)
-
-    _assert_spec(wrapped)
-
-
-def test_get_arg_spec__static_method():
-    class C:
-        @staticmethod
-        def func(x, y=0):
-            return x
-
-    _assert_spec(C.func)
-
-
-def test_get_arg_spec__method():
-    class C:
-        def func(self, x, y=0):
-            return x
-
-    _assert_spec(C.func, args=["self", "x", "y"])
+from argh.utils import SubparsersNotDefinedError, get_subparsers, unindent
 
 
 def test_util_unindent():