Python:针对argparse.Namespace对象的类型提示

68

有没有办法让Python静态分析器(例如PyCharm、其他IDE)捕获argparse.Namespace对象上的 Typehints? 例如:

argparse.Namespace

parser = argparse.ArgumentParser()
parser.add_argument('--somearg')
parsed = parser.parse_args(['--somearg','someval'])  # type: argparse.Namespace
the_arg = parsed.somearg  # <- Pycharm complains that parsed object has no attribute 'somearg'

如果我在内联注释中移除类型声明,PyCharm 不会报错,但也不会检测无效的属性。例如:

parser = argparse.ArgumentParser()
parser.add_argument('--somearg')
parsed = parser.parse_args(['--somearg','someval'])  # no typehint
the_arg = parsed.somaerg   # <- typo in attribute, but no complaint in PyCharm.  Raises AttributeError when executed.

有什么想法吗?


更新

Austin的回答启发,我找到的最简单的解决方案是使用namedtuples

from collections import namedtuple
ArgNamespace = namedtuple('ArgNamespace', ['some_arg', 'another_arg'])

parser = argparse.ArgumentParser()
parser.add_argument('--some-arg')
parser.add_argument('--another-arg')
parsed = parser.parse_args(['--some-arg', 'val1', '--another-arg', 'val2'])  # type: ArgNamespace

x = parsed.some_arg  # good...
y = parsed.another_arg  # still good...
z = parsed.aint_no_arg  # Flagged by PyCharm!

虽然这样是可以接受的,但我仍然不喜欢重复参数的名称。如果参数列表变得相当庞大,更新两个位置将很繁琐。理想情况下,应该从parser对象中提取参数,如下所示:

parser = argparse.ArgumentParser()
parser.add_argument('--some-arg')
parser.add_argument('--another-arg')
MagicNamespace = parser.magically_extract_namespace()
parsed = parser.parse_args(['--some-arg', 'val1', '--another-arg', 'val2'])  # type: MagicNamespace

我没有在argparse模块中找到任何可以实现这一点的东西,而且我仍然不确定是否有任何静态分析工具可以聪明地获取那些值而不会使IDE停滞不前。
继续搜索...

更新2

根据hpaulj的评论,最接近描述上述方法的东西是从解析器的每个_action中提取dest属性的东西。
parser = argparse.ArgumentParser()
parser.add_argument('--some-arg')
parser.add_argument('--another-arg')
MagicNamespace = namedtuple('MagicNamespace', [act.dest for act in parser._actions])
parsed = parser.parse_args(['--some-arg', 'val1', '--another-arg', 'val2'])  # type: MagicNamespace

但是这仍然无法导致属性错误在静态分析中被标记。如果我在parser.parse_args调用中传递namespace=MagicNamespace,情况也是如此。


快速谷歌搜索显示,您可以在本地变量的第一次使用上使用类型提示。尝试在 parser = argparse.ArgumentParser() # type: argparse.Namespace 上使用它,看看是否有效。 - aghast
@Austin:在这种情况下,parser是一个argparse.ArgumentParser对象,而不是一个argparse.Namespace对象。我希望parsed对象被填充为参数属性。 - Billy
你说得对。我错过了parsedparser的区别。你真正想要的似乎是PyCharm在构建ArgumentParser时解析方法参数。我怀疑这样做是否有效。 - aghast
1
add_argument 返回它刚创建的 Action 对象。查看它的属性。parser._actions 是所有这些操作的列表,解析器在解析过程中使用它们。我在之前的 SO 回答中提到过它们。 - hpaulj
在你的新编辑中,你是否将新的命名空间传递给 parse_args 函数? - hpaulj
显示剩余2条评论
10个回答

37

Typed argument parser是专门为此目的而制作的。它包装了argparse。您的示例实现如下:

from tap import Tap


class ArgumentParser(Tap):
    somearg: str


parsed = ArgumentParser().parse_args(['--somearg', 'someval'])
the_arg = parsed.somearg

这是一个示例图片。 在这里输入图片描述

它已经在PyPI上,并且可以通过以下方式进行安装:pip install typed-argument-parser

完全公开透明:我是这个库的创建者之一。


这是一个可靠的库,但非常基础。它缺乏诸如回调等功能,对于类型化子解析器的支持也不够好,这些问题在我花了一个小时后发现。虽然它似乎是一个令人兴奋的项目(而且他们似乎计划解决这些问题),但在2022年4月份,我认为它不能替代 argparse 的中等复杂用法。 - Paul Biggar
我强烈建议使用parse_argsnamespace参数,正如@aghast在这个答案中提到的那样。在Python 3.6及以上版本中,您可以定义自己的Namespace类,并带有仅具有类型提示的参数。 - amos

29

考虑定义一个扩展类来继承 argparse.Namespace,以提供所需的类型提示:

class MyProgramArgs(argparse.Namespace):
    def __init__():
        self.somearg = 'defaultval' # type: str

然后使用namespace=将其传递给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

1
在这个类中定义的 defaultval 会覆盖解析器方法中定义的任何默认参数。这可能是可取的。但是,在使用自定义命名空间时需要注意这个细节。 - hpaulj
2
__init__在Python 3.6及以上版本中不再需要,而是使用类型提示(没有值)添加属性。somearg: str - amos
1
在这里使用命名空间并不是必要的,因为它被设计用来给现有对象(在这种情况下是nsp)分配属性,但你却从parsed而不是nsp中访问了解析后的参数。你最后两行应该改成要么,1,不使用命名空间并使用类来进行类型注解parsed: MyProgramArgs = parser.parse_args(...),要么,2,保留namespace=nsp,不将其分配给parsed,并通过the_arg = nsp.somearg来访问/分配。更多细节请参见我的答案 - undefined

19
大多数回答涉及使用另一个包来处理打字。只有当我提出的解决方案没有这么简单时,这才是一个好主意。
步骤1. 类型声明
首先,像这样在数据类中定义每个参数的类型:
from dataclasses import dataclass

@dataclass
class MyProgramArgs:
    first_var: str
    second_var: int

步骤 2. 参数声明

接着,您可以根据需要设置与参数匹配的解析器。例如:

import argparse

parser = argparse.ArgumentParser("This CLI program uses type hints!")
parser.add_argument("-a", "--first-var")
parser.add_argument("-b", "--another-var", type=int, dest="second_var")

步骤三. 解析参数

最后,我们以一种静态类型检查器能知道每个参数类型的方式解析参数:

my_args = MyProgramArgs(**vars(parser.parse_args())

现在类型检查器知道my_argsMyProgramArgs类型,因此它确切地知道哪些字段可用以及它们的类型。

2
这是一个旧的帖子,但我想说这是一个完美的解决方案。不需要额外的软件包,而且实现起来非常简单。 - Josh Loecker
非常好的解决方案。如果您有多个子解析器,可能会更复杂。我认为您需要具有不同属性的不同数据类。每个数据类都必须共享一个父类。 - The Unfun Cat
@TheUnfunCat 对于多个子解析器,我想你可以为每个子解析器创建一个不同的类,然后根据第一个参数选择要实例化的类。 - Abraham Murciano Benzadon
我仍然喜欢这种方法,但是mypy无法验证类型。如果first_var不是字符串,mypy将不会抱怨。 - The Unfun Cat
1
这个解决方案是有效的,但正是OP不喜欢的:“我仍然不喜欢重复参数名称”。 - user202729

6
我对PyCharm如何处理这些类型提示一无所知,但我了解Namespace代码。 argparse.Namespace是一个简单的类;本质上是一个带有几个方法的对象,使得查看属性更加容易。为了方便进行单元测试,它还具有一个__eq__方法。你可以在argparse.py文件中阅读定义。 parser以最通用的方式与命名空间交互——使用getattrsetattrhasattr。因此,你可以使用几乎任何dest字符串,即使是你不能使用.dest语法访问的字符串。
确保不要混淆add_argument type=参数;那是一个函数。
按照其他答案中建议的使用自己的namespace类(从头开始或子类化)可能是最好的选择。文档中简要描述了这种方法。 Namespace Object。虽然我已经多次建议过使用它来处理特殊的存储需求,但我并没有经常看到这样做。因此,你需要进行实验。
如果使用子解析器,则使用自定义Namespace类可能会出现问题,http://bugs.python.org/issue27859 注意默认值的处理。大多数argparse操作的默认默认值为None。在解析后使用它可以方便地执行一些特殊操作,如果用户未提供此选项。
 if args.foo is None:
     # user did not use this optional
     args.foo = 'some post parsing default'
 else:
     # user provided value
     pass

这可能会影响类型提示。无论尝试什么解决方案,都要注意默认设置。


namedtuple 不能作为 Namespace 使用。

首先,自定义命名空间类的正确使用方式是:

nm = MyClass(<default values>)
args = parser.parse_args(namespace=nm)

也就是说,您需要初始化该类的一个实例,并将其作为参数传递。返回的args将是相同的实例,通过解析设置了新属性。

其次,namedtuple只能被创建,不能被改变。

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

命名空间需要与 getattrsetattr 一起使用。

namedtuple 的另一个问题是它不设置任何类型信息。它只定义字段/属性名称。因此,静态类型检查无法检查任何内容。

虽然从解析器中很容易获得预期的属性名称,但您无法获得任何预期的类型。

对于简单的解析器:

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]
< p > Actions的dest是正常的属性名称。但是type不是该属性预期的静态类型。它是一个可能或可能不会转换输入字符串的函数。在这里,None表示将输入字符串保存为原样。

由于静态类型和argparse需要不同的信息,因此没有一种简单的方法可以从一个中生成另一个。

我认为你能做的最好的事情就是创建自己的参数数据库,可能是一个字典,并使用自己的实用程序函数从中创建Namespace类和解析器。

假设dd是必要键的字典。然后我们可以使用以下代码创建一个参数:

parser.add_argument(dd['short'],dd['long'], dest=dd['dest'], type=dd['typefun'], default=dd['default'], help=dd['help'])

您或其他人需要提供一个命名空间类定义,从这样的字典中设置默认值(容易),和静态类型(困难?)。


4
如果你能从头开始,有一些有趣的解决方案,比如: 然而,在我的情况下,它们并不是理想的解决方案,因为:
  1. 我有很多现有基于 argparse 的 CLI,并且我不能承担使用这种从类型中推断出 args 的方法来重新编写它们。
  2. 当从类型中推断 args 时,支持所有高级 CLI 特性可以很棘手,而这些特性纯粹的 argparse 支持。
  3. 在纯命令式 argparse 中,与替代方案相比,在多个 CLI 中重用常见 arg 定义通常更容易。
因此,我开发了一个微小的库typed_argparse,允许引入带类型的 args 而无需大量重构。这个想法是添加一个从特殊的 TypedArg 类派生的类型,它只是简单地包装了纯 argparse.Namespace 对象:
# 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"]

这种方法略微违反单一真相原则,但库会执行完整的运行时验证以确保类型注释与argparse类型匹配,并且这只是一种非常简单的选择,可用于迁移到有类型的命令行界面。

2

如果您要处理的参数很少,另一种可能更理想的方法如下。

首先创建一个设置解析器并返回命名空间的函数。例如:

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("-a")
    parser.add_argument("-b", type=int)
    return parser.parse_args()

然后,您需要定义一个主函数,将上述声明的参数单独传入; 就像这样。
def main(a: str, b: int):
    print("hello world", a, b)

当您调用主函数时,应按以下方式进行调用:

if __name__ == "__main__":
    main(**vars(parse_args())

从你的主函数开始,你的静态类型检查器将正确识别你的变量ab,虽然你不再拥有一个包含所有参数的对象,这可能是一个好事或者坏事,具体取决于你的用例。


2
TL;DR:命名空间不是必需的,可以使用类作为类型注释。也适用于传递函数/方法。支持的版本一直到python3.5。
class MyProgramArgs(argparse.Namespace):
    my_arg1: str
    my_arg2: dict[str, str]

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--my_arg1')
    parser.add_argument('--my_arg2')

    parsed: MyProgramArgs = parser.parse_args()

    the_arg1: str = parsed.my_arg1             # <- Pycharm should not complain
    the_arg2: dict[str, str] = parsed.my_arg2  # <- Pycharm should not complain
    the_arg3: str = parsed.my_arg2             # <- Pycharm SHOULD complain

细节: 由于我无法回复@aghast的答案,我想提供我在经过几周的努力后得出的最简单的解决方案。

在他的实现中,使用命名空间是不必要的(根据docs),因为它的设计是用于为现有对象分配属性,例如他的情况下的nsp,而aghast在他的解决方案中没有使用,例如。

# bad, not using instantiated MyProgramArgs() object
nsp = MyProgramArgs()
parsed = parser.parse_args(['--somearg','someval'], namespace=nsp)
the_arg = parsed.somearg  # <- Pycharm should not complain

# using namespace properly
nsp = MyProgramArgs()
parser.parse_args(['--somearg','someval'], namespace=nsp)  # note no assignment
the_arg = nsp.somearg  # Properly accessing assigned attributes from provided namespace object

要么: 1. 不要实例化`nsp`,因为它没有被使用,使用类作为变量的类型注释,并完全跳过命名空间,例如`parsed: MyProgramArgs = parser.parse_args()`。 2. 根据文档和我上面的示例正确使用命名空间。

1

一个超级解决方案,只需对parse_args方法的NameSpace返回值进行类型提示。

import argparse
from typing import Type


class NameSpace(argparse.Namespace, Type):
    name: str


class CustomParser(argparse.ArgumentParser):
    def parse_args(self) -> NameSpace:
        return super().parse_args()


parser = CustomParser()

parser.add_argument("--name")

if __name__ == "__main__":
    args = parser.parse_args()
    print(args.name)

PARSED ARGS


2
可以通过将namespace=NameSpace()传递给parse_args()方法来缩短代码。无需使用自定义解析器。此外,从Python 3.6及以上版本开始,NameSpace类不需要继承Type - amos
这个不通过mypy:/ "无效的基类"Type"", "parse_args"的签名与超类型"ArgumentParser"不兼容" - The Unfun Cat

1

我想在这里提供一个更涉及解决方案的贡献,重点是关注原帖作者的问题:

虽然这个解决方案令人满意,但我仍然不喜欢重复参数名称。如果参数列表增加得很多,更新两个位置将会很繁琐。理想情况是从解析器对象中提取参数

这个解决方案不使用外部包。它使用dataclasses来定义参数,就像Abraham Murciano's answer一样,但参数的提取是使用fields函数完成的。所有定义都在Args数据类内部进行。

我还添加了一些类型工具,使解决方案能够完全通过类型检查。

我在VS Code中使用Pylance,它使用Pyright类型检查器。

1. 一些定义

函数make_arg将用于创建"args字段",add_args将添加参数

from __future__ import annotations
import argparse
from dataclasses import dataclass, field, fields
from typing import Sequence, Callable, Any

def make_arg(
    name: str | Sequence[str] | None = None,
    action: str = "store",
    nargs: int | str | None = None,
    const: Any = None,
    default: Any = None,
    type: Callable[[str], Any] = str,
    help: str = "",
    metavar: str | Sequence[str] | None = None,
):
    arg_dict = locals()
    if name is not None and not name[0].startswith("-"):
        raise ValueError("`name` should be passed only to flagged args")
    if isinstance(name, str):
        arg_dict["name"] = [name]
    if arg_dict["action"] in ["store_true", "store_const"]:
        arg_dict.pop("nargs")
        arg_dict.pop("const")
        arg_dict.pop("type")
        arg_dict.pop("metavar")
    arg_dict.pop("default")
    return field(default=default, metadata=arg_dict)

def add_args(args_cls: type[Args], parser: argparse.ArgumentParser):
    for arg in fields(args_cls):
        arg_dict = dict(arg.metadata)
        if arg_dict["name"] is None:  # no flagged arg
            arg_dict["name"] = [arg.name]
        else:  # flagged arg
            arg_dict["dest"] = arg.name
        arg_name = arg_dict.pop("name")
        parser.add_argument(*arg_name, **arg_dict)

2. 使用方法

所有的参数定义都在 Args 数据类中使用 marke_arg 函数进行一次定义即可。

@dataclass
class Args:
    some_arg: str = make_arg()
    flagged_arg: str = make_arg(name="--another-arg")

# Create the parser object
parser = argparse.ArgumentParser()

# Add the args using the dataclass and the parser
add_args(Args, parser)

parsed = parser.parse_args(
    ["val1", "--another-arg", "val2"],
    namespace=Args(),
)

x = parsed.some_arg  # good...
y = parsed.flagged_arg  # still good...
z = parsed.aint_no_arg  # Flagged by PyLance!

1

这是Jason Leaver的解决方案,其中包含amos建议的改进:

import argparse

class Namespace(argparse.Namespace):
    name: str

parser = argparse.ArgumentParser()
parser.add_argument("--name")

args = parser.parse_args(namespace=Namespace())

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接