Python: Typehints for argparse.Namespace objects
Consider defining an extension class to argparse.Namespace
that provides the type hints you want:
class MyProgramArgs(argparse.Namespace):
def __init__():
self.somearg = 'defaultval' # type: str
Then use namespace=
to pass that to parse_args
:
def process_argv():
parser = argparse.ArgumentParser()
parser.add_argument('--somearg')
nsp = MyProgramArgs()
parsed = parser.parse_args(['--somearg','someval'], namespace=nsp) # type: MyProgramArgs
the_arg = parsed.somearg # <- Pycharm should not complain
Typed argument parser was made for exactly this purpose. It wraps argparse
. Your example is implemented as:
from tap import Tap
class ArgumentParser(Tap):
somearg: str
parsed = ArgumentParser().parse_args(['--somearg', 'someval'])
the_arg = parsed.somearg
Here's a picture of it in action.
It's on PyPI and can be installed with: pip install typed-argument-parser
Full disclosure: I'm one of the creators of this library.
I don't know anything about how PyCharm handles these typehints, but understand the Namespace
code.
argparse.Namespace
is a simple class; essentially an object with a few methods that make it easier to view the attributes. And for ease of unittesting it has a __eq__
method. You can read the definition in the argparse.py
file.
The parser
interacts with the namespace in the most general way possible - with getattr
, setattr
, hasattr
. So you can use almost any dest
string, even ones you can't access with the .dest
syntax.
Make sure you don't confuse the add_argument
type=
parameter; that's a function.
Using your own namespace
class (from scratch or subclassed) as suggested in the other answer may be the best option. This is described briefly in the documentation. Namespace Object. I haven't seen this done much, though I've suggested it a few times to handle special storage needs. So you'll have to experiment.
If using subparsers, using a custom Namespace class may break, http://bugs.python.org/issue27859
Pay attention to handling of defaults. The default default for most argparse
actions is None
. It is handy to use this after parsing to do something special if the user did not provide this option.
if args.foo is None:
# user did not use this optional
args.foo = 'some post parsing default'
else:
# user provided value
pass
That could get in the way type hints. Whatever solution you try, pay attention to the defaults.
A namedtuple
won't work as a Namespace
.
First, the proper use of a custom Namespace class is:
nm = MyClass(<default values>)
args = parser.parse_args(namespace=nm)
That is, you initial an instance of that class, and pass it as the parameter. The returned args
will be the same instance, with new attributes set by parsing.
Second, a namedtuple can only created, it can't be changed.
In [72]: MagicSpace=namedtuple('MagicSpace',['foo','bar'])
In [73]: nm = MagicSpace(1,2)
In [74]: nm
Out[74]: MagicSpace(foo=1, bar=2)
In [75]: nm.foo='one'
...
AttributeError: can't set attribute
In [76]: getattr(nm, 'foo')
Out[76]: 1
In [77]: setattr(nm, 'foo', 'one') # not even with setattr
...
AttributeError: can't set attribute
A namespace has to work with getattr
and setattr
.
Another problem with namedtuple
is that it doesn't set any kind of type
information. It just defines field/attribute names. So there's nothing for the static typing to check.
While it is easy to get expected attribute names from the parser
, you can't get any expected types.
For a simple parser:
In [82]: parser.print_usage()
usage: ipython3 [-h] [-foo FOO] bar
In [83]: [a.dest for a in parser._actions[1:]]
Out[83]: ['foo', 'bar']
In [84]: [a.type for a in parser._actions[1:]]
Out[84]: [None, None]
The Actions dest
is the normal attribute name. But type
is not the expected static type of that attribute. It is a function that may or may not convert the input string. Here None
means the input string is saved as is.
Because static typing and argparse
require different information, there isn't an easy way to generate one from the other.
I think the best you can do is create your own database of parameters, probably in a dictionary, and create both the Namespace class and the parsesr from that, with your own utility function(s).
Let's say dd
is dictionary with the necessary keys. Then we can create an argument with:
parser.add_argument(dd['short'],dd['long'], dest=dd['dest'], type=dd['typefun'], default=dd['default'], help=dd['help'])
You or someone else will have to come up with a Namespace class definition that sets the default
(easy), and static type (hard?) from such a dictionary.
If you are in a situation where you can start from scratch there are interesting solutions like
- the already mentioned typed argument parser (TAP)
- typer
- typed-args
However, in my case they weren't an ideal solution because:
- I have many existing CLIs based on
argparse
, and I cannot afford to re-write them all using such args-inferred-from-types approaches. - When inferring args from types it can be tricky to support all advanced CLI features that plain
argparse
supports. - Re-using common arg definitions in multiple CLIs is often easier in plain imperative argparse compared to alternatives.
Therefore I worked on a tiny library typed_argparse that allows to introduce typed args without much refactoring. The idea is to add a type derived from a special TypedArg
class, which then simply wraps the plain argparse.Namespace
object:
# Step 1: Add an argument type.
class MyArgs(TypedArgs):
foo: str
num: Optional[int]
files: List[str]
def parse_args(args: List[str] = sys.argv[1:]) -> MyArgs:
parser = argparse.ArgumentParser()
parser.add_argument("--foo", type=str, required=True)
parser.add_argument("--num", type=int)
parser.add_argument("--files", type=str, nargs="*")
# Step 2: Wrap the plain argparser result with your type.
return MyArgs(parser.parse_args(args))
def main() -> None:
args = parse_args(["--foo", "foo", "--num", "42", "--files", "a", "b", "c"])
# Step 3: Done, enjoy IDE auto-completion and strong type safety
assert args.foo == "foo"
assert args.num == 42
assert args.files == ["a", "b", "c"]
This approach slightly violates the single-source-of-truth principle, but the library performs a full runtime validation to ensure that the type annotations match the argparse types, and it is just a very simple option to migrate towards typed CLIs.