使用 mypy 处理条件逻辑+哨兵值

问题描述 投票:0回答:4

我有一个大致如下所示的函数:

import datetime
from typing import Union

class Sentinel(object): pass
sentinel = Sentinel()

def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, Sentinel] = sentinel,
) -> str:

    if as_tz is not sentinel:
        # Never reached if as_tz has wrong type (Sentinel)
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

此处使用

sentinel
值是因为
None
已经是
.astimezone()
的有效参数,因此目的是正确识别用户根本不想调用
.astimezone()
的情况。

但是,

mypy
抱怨这种模式:

错误:“datetime”的“astimezone”的参数 1 具有不兼容的类型 “联盟[tzinfo,无,哨兵]”;预期“可选[tzinfo]”

这似乎是因为

datetime
存根(理所当然)使用:

def astimezone(self, tz: Optional[_tzinfo] = ...) -> datetime: ...

但是,有没有办法让 mypy 知道由于

sentinel
检查,
.astimezone()
值永远不会传递给
if
? 或者这是否只需要一个
# type: ignore
而没有更干净的方法?


另一个例子:

from typing import Optional
import requests


def func(session: Optional[requests.Session] = None):
    new_session_made = session is None
    if new_session_made:
        session = requests.Session()
    try:
        session.request("GET", "https://a.b.c.d.com/foo")
        # ...
    finally:
        if new_session_made:
            session.close()

第二个,像第一个一样,是“运行时安全的”(因为缺乏更好的术语):调用

AttributeError
None.request()
None.close()
将不会被达到或评估。 然而,mypy 仍然抱怨:

mypytest.py:9: error: Item "None" of "Optional[Session]" has no attribute "request"
mypytest.py:13: error: Item "None" of "Optional[Session]" has no attribute "close"

我应该在这里做一些不同的事情吗?

python python-typing mypy
4个回答
13
投票

根据我的经验,最好的解决方案是使用

enum.Enum

要求

一个好的哨兵模式有 3 个属性:

  1. 拥有明确的类型/值,不会被误认为是其他值。 例如
    object()
  2. 可以使用描述性常量来引用
  3. 可以使用
    is
    is not
  4. 进行简洁测试

解决方案

enum.Enum
经过 mypy 的特殊处理,因此它是我发现的唯一可以实现所有这三个要求并在 mypy 中正确验证的解决方案。

import datetime
import enum
from typing import Union

class Sentinel(enum.Enum):
    SKIP_TZ = object()

def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, Sentinel] = Sentinel.SKIP_TZ,
) -> str:

    if as_tz is not Sentinel.SKIP_TZ:
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

变化

这个解决方案还有一些其他有趣的属性。

可重复使用的 Sentinel 对象

sentinel.py

import enum
class Sentinel(enum.Enum):
    sentinel = object()

main.py

import datetime
from sentinel import Sentinel
from typing import Union

SKIP_TZ = Sentinel.sentinel

def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, Sentinel] = SKIP_TZ,
) -> str:

    if as_tz is not SKIP_TZ:
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

请注意,由于

Sentinel.sentinel
始终提供相同的
object
实例,因此两个可重用哨兵永远不应该在相同的上下文中使用。

使用
Literal

限制 Sentinel 值

Sentinel
替换
Literal[Sentinel.SKIP_TZ]]
可以使函数签名更加清晰,尽管这确实是多余的,因为只有一个枚举值。

import datetime
import enum
from typing import Union
from typing_extensions import Literal

class Sentinel(enum.Enum):
    SKIP_TZ = object()

def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, Literal[Sentinel.SKIP_TZ]] = Sentinel.SKIP_TZ,
) -> str:

    if as_tz is not Sentinel.SKIP_TZ:
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

func(datetime.datetime.now(), as_tz=Sentinel.SKIP_TZ)

不符合我要求的解决方案

自定义哨兵类

import datetime
from typing import Union

class SentinelType:
    pass

SKIP_TZ = SentinelType()


def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, SentinelType] = SKIP_TZ,
) -> str:

    if not isinstance(dt, SentinelType):
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

虽然这有效,但使用

isinstance(dt, SentinelType)
无法满足要求 3(“使用
is
”),因此也无法满足要求 2(“使用命名常量”)。 为了清楚起见,我希望能够使用
if dt is not SKIP_TZ

物体
Literal

Literal
不适用于任意值(尽管它适用于枚举。见上文。)

import datetime
from typing import Union
from typing_extensions import Literal

SKIP_TZ = object()

def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, Literal[SKIP_TZ]] = SKIP_TZ,
) -> str:

    if dt is SKIP_TZ:
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

产生以下 mypy 错误:

error: Parameter 1 of Literal[...] is invalid
error: Variable "sentinel.SKIP_TZ" is not valid as a type

字符串
Literal

在这次尝试中,我使用了字符串文字而不是对象:

import datetime
from typing import Union
from typing_extensions import Literal


def func(
    dt: datetime.datetime,
    as_tz: Union[datetime.tzinfo, None, Literal['SKIP_TZ']] = 'SKIP_TZ',
) -> str:

    if as_tz is not 'SKIP_TZ':
        dt = dt.astimezone(as_tz)
    # ...
    # do other meaningful stuff
    # ...
    return "foo"

func(datetime.datetime.now(), as_tz='SKIP_TZ')

即使这有效,它对于要求 1 来说也相当薄弱。

但是它没有在 mypy 中传递。 它产生错误:

error: Argument 1 to "astimezone" of "datetime" has incompatible type "Union[tzinfo, None, Literal['SKIP_TZ']]"; expected "Optional[tzinfo]"

3
投票

您可以使用显式的

cast
:

    from typing import cast
    ... 
    if as_tz is not sentinel:
        # Never reached if as_tz has wrong type (Sentinel)
        as_tz = cast(datetime.tzinfo, as_tz)
        dt = dt.astimezone(as_tz)

    new_session_made = session is None
    session = cast(requests.Session, session)

您也可以使用

assert
(尽管这是实际的运行时检查,而
cast
更明确地说是无操作):

        assert isinstance(as_tz, datetime.tzinfo)
        dt = dt.astimezone(as_tz)

    new_session_made = session is None
    assert session is not None

2
投票

绕过此问题的一种方法是执行以下操作:

from typing import Optional
import requests


def func(session: Optional[requests.Session] = None) -> None:
    new_session = session is None
    if not session:
        session = requests.Session()
    try:
        session.request("GET", "https://a.b.c.d.com/foo")
        # other stuff
    finally:
        if not new_session:
            session.close()

此外,我们还可以检查

mypy
是否可以处理使用不同参数类型的情况:

func('a')  # mypy_typing.py:14: error: Argument 1 to "func" has incompatible type "str"; expected "Optional[Session]"
func(1)  # mypy_typing.py:14: error: Argument 1 to "func" has incompatible type "int"; expected "Optional[Session]"
...
# PS:  The test will break for any kind of types except for None and requests.Session
...

但是,如果我们使用

None
request.Session()
对象作为参数,则测试会顺利通过:

func(None)  # No errors
func(requests.Session())  # No errors

有关更多信息,您可以阅读 mypy 官方文档中的

示例


0
投票

Mypy 对

isinstance
有特殊处理。除了检查身份之外,还可以这样做:

if not isinstance(as_tz, Sentinel):
    dt = dt.astimezone(as_tz)

...

通过此更改,您的示例似乎可以进行类型检查。

© www.soinside.com 2019 - 2024. All rights reserved.