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

Python: Typehints for argparse.Namespace objects

有没有办法让 Python 静态分析器(例如在 PyCharm、其他 IDE 中)获取 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.

有什么想法吗?


更新

受下面 的启发,我能找到的最简单的解决方案是使用 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 的评论,我能找到的最接近上述方法的方法是 "magically" 提取已解析对象的属性,它会从每个对象中提取 dest 属性解析器的 _actions.:

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 也是如此。

考虑定义扩展 class 到 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

我不知道 PyCharm 如何处理这些类型提示,但理解 Namespace 代码。

argparse.Namespace是一个简单的class;本质上是一个具有一些方法的对象,可以更轻松地查看属性。为了便于单元测试,它有一个 __eq__ 方法。您可以阅读 argparse.py 文件中的定义。

parser 以最通用的方式与命名空间交互 - 使用 getattrsetattrhasattr。因此,您几乎可以使用任何 dest 字符串,甚至是您无法使用 .dest 语法访问的字符串。

请确保您没有混淆 add_argument type= 参数;这是一个函数。

按照其他答案中的建议使用您自己的 namespace class(从头开始或 subclassed)可能是最佳选择。这在文档中有简要描述。 Namespace Object。尽管我已经多次建议它来处理特殊的存储需求,但我并没有看到它做了太多。所以你必须要试验一下。

如果使用子解析器,使用自定义命名空间 class 可能会中断,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

这可能会妨碍类型提示。无论您尝试什么解决方案,请注意默认值。


A namedtuple 不能作为 Namespace

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

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

也就是说,您初始化 class 的一个实例,并将其作为参数传递。返回的 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 的另一个问题是它没有设置任何类型的 type 信息。它只是定义了 field/attribute 个名字。所以静态类型没有什么可检查的。

虽然很容易从 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]

Actions dest 是普通的属性名称。但是 type 不是该属性的预期静态类型。它是一个可能会或可能不会转换输入字符串的函数。这里 None 表示输入的字符串按原样保存。

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

我认为你能做的最好的事情就是创建你自己的参数数据库,可能在字典中,然后使用你自己的实用函数从中创建命名空间 class 和解析器。

假设 dd 是带有必要键的字典。然后我们可以创建一个参数:

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

您或其他人将不得不想出一个命名空间 class 定义来设置 default(简单)和来自这样一个字典的静态类型(困难?)。

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

完全披露:我是该库的创建者之一。

如果您处于可以从头开始的情况,那么有一些有趣的解决方案,例如

但是,就我而言,它们不是理想的解决方案,因为:

  1. 我有许多基于 argparse 的现有 CLI,我无法使用此类从类型推断参数的方法重写它们。
  2. 从类型推断参数时,支持普通 argparse 支持的所有高级 CLI 功能可能很棘手。
  3. 与替代方案相比,在普通命令式 argparse 中,在多个 CLI 中重复使用通用 arg 定义通常更容易。

因此,我开发了一个小型库 typed_argparse,它允许在不进行大量重构的情况下引入类型化参数。这个想法是添加一个派生自特殊 TypedArg class 的类型,然后简单地包装普通的 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 类型匹配,并且它只是一个非常简单的迁移到类型化的选项CLI。

这些答案中的大多数涉及使用另一个包来处理输入。只有当没有像我将要提出的那样简单的解决方案时,这才是一个好主意。

第 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")

第 3 步。解析参数

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

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

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

如果参数很少,另一种方法可能是理想的,如下所示。

首先创建一个函数来设置解析器和 returns 命名空间。例如:

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)

当你调用你的 main 时,你会这样做:

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

从您的 main 开始,您的变量 ab 将被您的静态类型检查器正确识别,尽管您将不再拥有包含所有参数的对象,根据您的用例,这可能是好事也可能是坏事。