Skip to content

Support 3.14 annotations in converter functions #1470

Open

Description

@DavidCEllis

While #1451 added support for forward references in the classes themselves, forward references in converter functions still fail due to the use of inspect.signature which by default uses Format.VALUE when getting annotations.

Example:

from attrs import define, field

@define
class Works:
    x: unknown1

def converter(x: unknown2 | str) -> str:
    return str(x)

@define
class Fails:
    x: str = field(converter=converter)

Result:

NameError: name 'unknown2' is not defined

Cause:

self.sig = inspect.signature(callable)

For Python 3.14 annotations you now need:

self.sig = inspect.signature(callable, annotation_format=annotationlib.Format.FORWARDREF)

Activity

  1. hynek commented on Sep 10, 2025

    @hynek
    Member

    After talking to @Tinche, this seems to be a lot more complicated than it looks like.

    We actually do use the results on class creation, so it might be that we'll have to special-case this entire thing and implement an __annotate__ on 3.14.

  2. added
    TypingTyping/stub/Mypy/PyRight related bugs.
    on Sep 10, 2025
  3. DavidCEllis commented on Sep 10, 2025

    @DavidCEllis
    ContributorAuthor

    You already kind of need to generate __annotate__ for 3.14 - at least for the __init__ function - but it's difficult with the current tools available from annotationlib. String annotations from attrs currently reveal the ForwardRef values.

    from attrs import define
    from annotationlib import get_annotations, Format
    
    Vector = list[float]
    
    @define
    class Example:
        examples: list[Example]
        vectors: list[Vector]
    
    # Format.STRING names show up in help()
    print(get_annotations(Example.__init__, format=Format.STRING))
    {
        'return': 'None', 
        # should be "list[Example]"
        'examples': "list[ForwardRef('Example', is_class=True, owner=<class 'ref_demo.Example'>)]", 
        # should be "list[Vector]"
        'vectors': 'list[list[float]]',
    }

    dataclasses has a related but different issue as it writes the types into the source code instead of generating __annotations__. See: python/cpython#137530

    In trying to solve this for dataclasses I've run into the issue that you can't easily use annotations collected in construction to generate a new __annotate__ function and recreating the logic used to gather the annotations in the first place is both awkward and problematic for VALUE annotations - do you leak forward references or do you fail for annotations on fields where init=False if they contain a forward reference?

    I'd like a Format for annotations that deferred the evaluation further. Internally annotationlib already uses such a format when there's an exception in trying to get FORWARDREF annotations see here.

    I think having these could make generating __annotate__ functions much closer to how you can currently create the __annotations__ dict. I've been kicking around a proposal for such a feature but I wasn't sure how broadly useful it would be - would you find this helpful?

  4. DavidCEllis commented on Mar 18, 2026

    @DavidCEllis
    ContributorAuthor

    A slight update on this, I made a DPO thread proposing a way to make handling 3.14+ annotations easier but didn't see much interest: https://discuss.python.org/t/add-a-format-deferred-option-for-pep-649-749-annotations/104001/2

    A short summary of the proposal as it's a bit long and detailed:

    Annotations come in three formats. None are suitable for creating new __annotate__ functions. Add a new DEFERRED format that can be evaluated to all possible formats individually, along with a helper to convert annotations in this format into new __annotate__ callables.

    I've also released a reannotate package that implements this functionality with some small modifications outside of the stdlib, I highly doubt you'd want such a dependency but it may be of interest.

    dataclasses itself went with reconstructing the field gathering logic for now, though I think this is overly complicated and still fails in cases where the DEFERRED approach would succeed.

  5. hynek commented on Mar 18, 2026

    @hynek
    Member

    Yeah a dependency is a no go for something rather obscure like this, but if you would be game, I'd be open to transplanting the parts that we need, if that would help? I've already forgotten all the problems and intricacies in play here, so I can't speak any smarter about it. 😅

  6. DavidCEllis commented on Mar 18, 2026

    @DavidCEllis
    ContributorAuthor

    Looking at the empty dependencies that's what I suspected 🙂.

    It's possible to make a reduced version of reannotate built by constructing ForwardRef instances from strings instead of evaluating the strings/ast objects directly.

    Something like this:
    https://github.com/DavidCEllis/Reannotate/blob/mini-reannotate/src/reannotate/_mini_reannotate.py

    Honestly I might be tempted to reduce the main version down to this, but I need to check performance and corner cases.

  7. DavidCEllis commented on Mar 19, 2026

    @DavidCEllis
    ContributorAuthor

    No simplification for me 🙁.

    I ran some performance tests and sadly this simplified version is significantly slower, especially if the annotations have lots of generics. This is because the minimised version includes additional time spent unparsing ast objects to create STRING annotations - the reannotate version stores the ast objects directly and skips this step.

    It's worth noting that STRING annotations are usually slower than FORWARDREF annotations. Building deferred annotations relies on the same logic so it's also slower. VALUE annotations if they succeed are much faster again.

    So there is the question of, how much of a slowdown from annotations would still be acceptable?

    Performance Values

    Getting annotations in the specified format 10k times each, "Evaluated Deferred" means further getting the FORWARDREF values from deferred annotations (as you'd need to analyse the annotations for processing).

    These use call_annotate_function for VALUE annotations to avoid the __annotations__ cache that gets created after first retrieving them.

    Example 1 - Basic identifiers:

    class Example:
        a: int
        b: str
        c: float
        d: bool
        e: object
    Method Time /s Diff
    VALUE 0.00235 1x
    STRING 0.0569 24.3x
    Deferred 0.0604 25.7x
    Evaluated Deferred 0.0682 29.1x

    Example 2 - Generics and Unions:

    class Example:
        a: int | float
        b: str | object
        c: list[float]
        d: dict[str, int]
        e: tuple[int, float, str]
    Method Time /s Diff
    VALUE 0.00858 1x
    STRING 0.332 38.7x
    Deferred 0.206 24.1x
    Evaluated Deferred 0.213 24.9x

    Example 3 - Generics and Forward References:

    Format.FORWARDREF is much slower than Format.VALUE, deferred annotations no longer have the cache from the fake globals check so are also slower, but better relatively.

    class Example:
        a: unknown[list[str, dict[str, int]]]
        b: unknown[list[str, dict[str, int]]]
        c: unknown[list[str, dict[str, int]]]
        d: unknown[list[str, dict[str, int]]]
        e: unknown[list[str, dict[str, int]]]
    Method Time /s Diff
    FORWARDREF 0.154 1x
    STRING 0.777 5.04x
    Deferred 0.475 3.08x
    Evaluated Deferred 1.71 11.1x

    Example 4 - Generics and Forward References and a missing attribute:

    In this case Format.FORWARDREF is now doing almost the same thing as deferred annotations, so they are similar in performance.

    class Example:
        a: unknown[list[str, dict[str, int]]]
        b: unknown[list[str, dict[str, int]]]
        c: unknown[list[str, dict[str, int]]]
        d: unknown[list[str, dict[str, int]]]
        e: unknown[list[str, dict[str, int]]]
        f: object.undefined
    Method Time /s Diff
    FORWARDREF 1.88 1x
    STRING 0.792 0.422x
    Deferred 0.484 0.258x
    Evaluated Deferred 1.86 0.99x
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    TypingTyping/stub/Mypy/PyRight related bugs.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions