attrs icon indicating copy to clipboard operation
attrs copied to clipboard

Use `TypeGuard` for `has` in Python 3.10 and above

Open layday opened this issue 3 years ago • 2 comments

Summary

Conditionally defines has using TypeGuard in Python 3.10+ in the stub file.

xref: https://github.com/python-attrs/attrs/issues/987

Pull Request Check List

  • [x] Added tests for changed code. Our CI fails if coverage is not 100%.
  • [ ] New features have been added to our Hypothesis testing strategy.
  • [ ] Changes or additions to public APIs are reflected in our type stubs (files ending in .pyi).
    • [ ] ...and used in the stub test file tests/typing_example.py.
    • [ ] If they've been added to attr/__init__.pyi, they've also been re-imported in attrs/__init__.pyi.
  • [ ] Updated documentation for changed code.
    • [ ] New functions/classes have to be added to docs/api.rst by hand.
    • [ ] Changes to the signature of @attr.s() have to be added by hand too.
    • [ ] Changed/added classes/methods/functions have appropriate versionadded, versionchanged, or deprecated directives. Find the appropriate next version in our __init__.py file.
  • [ ] Documentation in .rst files is written using semantic newlines.
  • [ ] Changes (and possible deprecations) have news fragments in changelog.d.
  • [x] Consider granting push permissions to the PR branch, so maintainers can fix minor issues themselves without pestering you.

layday avatar Aug 08 '22 12:08 layday

I don't see a direct use-case for this (since attrs classes should be statically known now) but I don't see the harm either.

Tinche avatar Aug 08 '22 12:08 Tinche

The class might not be statically known to be an attrs class (#987, #996) or it can appear in a union with other types.

layday avatar Aug 08 '22 13:08 layday

So what exactly does this do?

hynek avatar Aug 10 '22 14:08 hynek

It essentially makes has work at compile time too.

Let's say your function takes a class (not an instance, but an actual class). You will type your argument as type (or type[C]). You can check if it's an attrs class with has, and if that's true you can pass it to a different function that takes type[AttrsInstance]. But Mypy will still yell at you, since it doesn't know that has means it's an attrs class.

If we annotate has with TypeGuard, Mypy will know.

Code example:

def needs_attrs_class(cls: type[AttrsInstance]) -> None:
    pass

def my_function(cls: type) -> None:
    if has(cls):
        needs_attrs_class(cls)  # Mypy's gonna be unhappy, unless it knows `has` narrows the type of is argument

Tinche avatar Aug 10 '22 14:08 Tinche

It tells the type checker that if the condition is true then cls is of type type[AttrsInstance]. Simplified example:

def is_dict_no_typeguard(value: object) -> bool:
    return isinstance(value, dict)

def is_dict_typeguard(value: object) -> TypeGuard[dict]:
    return isinstance(value, dict)

might_be_a_dict = ...

if is_dict_no_typeguard(might_be_a_dict):
    "type of might_be_a_dict is object"

if is_dict_typeguard(might_be_a_dict):
    "type of might_be_a_dict is dict"


layday avatar Aug 10 '22 14:08 layday

Sounds cool! Any reason against it? If not: make it happen Tin. ;)

hynek avatar Aug 10 '22 15:08 hynek

Would you mind coming up with a way to verify this works in https://github.com/python-attrs/attrs/blob/main/tests/typing_example.py ?

hynek avatar Aug 15 '22 13:08 hynek

There's a test in test_mypy.yml. Should I move it over to typing_example.py?

layday avatar Aug 15 '22 13:08 layday

No, both would be great, since they work somewhat differently. Maybe if has(c): print(c.__attrs_attrs__) or something like that? That should pass, right?

hynek avatar Aug 15 '22 15:08 hynek

Just so I understand this correctly: the import from typing_extensions only works, because we have the stubs in pyi files that are only touched by mypy which in turn brings in typing_extensions? IOW: if we'd use inline type hints, this would fall apart?

hynek avatar Aug 16 '22 05:08 hynek

That's right. The typeshed has typing_extensions in the stdlib. I assume they did this so that they can use new typing constructs in the typeshed without tripping MyPy. At runtime, you'd have to do something like:

import typing

if typing.TYPE_CHECKING:
    from typing_extensions import TypeGuard

# Use TypeGuard either in quotes or with `from __future__ import annotations`

layday avatar Aug 16 '22 05:08 layday

Thanks!

hynek avatar Aug 16 '22 06:08 hynek