Skip to content

conditional blocks in class definitions seem to be evaluating types even when these conditionals are false #130881

Closed

Description

@zzzeek

Bug report

Bug description:

using a conditional in a class definition like if TYPE_CHECKING where TYPE_CHECKING is False, or any kind of false conditional, seems to be ignored when types are evaluated under non-future annotations mode:

class MyClass:
    somevalue: str

    if False:
        someothervalue: int

assert MyClass.__annotations__ == {"somevalue": str}, f"MyClass.__annotations__ == {MyClass.__annotations__}"

this seems to be something that might have been done with some intention, which is extremely troubling as this would be a huge showstopper for SQLAlchemy if these conditionals are no longer honored at runtime. A similar example

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    # is not evaluated under any python at runtime
    from some_module import SpecialType

class MyClass:
    somevalue: str

    if TYPE_CHECKING:
        # **is** evaluated under python 3.14.0a5, fails
        someothervalue: SpecialType

assert MyClass.__annotations__ == {"somevalue": str}, f"MyClass.__annotations__ == {MyClass.__annotations__}"

calling upon __annotations__ seems to be the trigger that makes the above fail:

Traceback (most recent call last):
  File "/home/classic/dev/sqlalchemy/test4.py", line 14, in <module>
    assert MyClass.__annotations__ == {"somevalue": str}, f"MyClass.__annotations__ == {MyClass.__annotations__}"
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/classic/dev/sqlalchemy/test4.py", line 12, in __annotate__
    someothervalue: SpecialType
                    ^^^^^^^^^^^
NameError: name 'SpecialType' is not defined

however this appears to be some very strange quantum-physics type of evaluation that isn't actually running Python fully; below, the "someothervalue" type is evaluted, but not the value function assigned!

from typing import TYPE_CHECKING

def do_my_thing() -> int:
    print("HEY!")
    assert False
    return 3 + 4

class MyClass:
    somevalue: str

    if TYPE_CHECKING:
        someothervalue: int = do_my_thing()

assert MyClass.__annotations__ == {"somevalue": str}, f"MyClass.__annotations__ == {MyClass.__annotations__}"

This is an enormous and strange behavioral change that is really going to make things extremely difficult for SQLAlchemy, starting with we will have to rewrite thousands (OK, it turned out to be "dozens") of lines of test suite code into a much bigger exploded form and also a lot of end-user use cases we've defined using TYPE_CHECKING blocks aren't going to work anymore, it's not clear how much further this change will go.

I suppose if the whole thing boils down to what __annotations__ does, and we can call upon something like __annotations_but_not_false_blocks__, that could save us, but overall I'm really hoping this is just a bad dream that we can wake up from.

CPython versions tested on:

3.14

Operating systems tested on:

Linux

Linked PRs

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    on Mar 5, 2025
  2. zzzeek commented on Mar 5, 2025

    @zzzeek
    Author

    so I'm reading https://peps.python.org/pep-0749/ now which seems to be where we're going to have to go

  3. zzzeek commented on Mar 5, 2025

    @zzzeek
    Author

    if I can get some guidance on a function in annotationlib that will give me annotations with some way to ignore False blocks, rather than us calling __annotate__, that would be helpful

  4. zzzeek commented on Mar 5, 2025

    @zzzeek
    Author

    this change is really quite bizarre and I continue to hope this isn't intended

    class MyClass:
        somevalue: str
    
        if True:
            someothervalue: int
        else:
            someothervalue: str
    
    print(MyClass.__annotations__)

    under python 3.13 and below prints {'somevalue': <class 'str'>, 'someothervalue': <class 'int'>}, under 3.14.0a5 prints {'somevalue': <class 'str'>, 'someothervalue': <class 'str'>}. if you reverse the order of the conditional, then they match

    python3.14.0a5 is literally running through code and quasi-evaluating it without paying attention to conditionals, this is really the most Perl-ish thing I've ever seen in Python by a mile

  5. vytas7 commented on Mar 5, 2025

    @vytas7

    On the other hand, it seems to open new possibilities to interesting experiments that were not possible before 🤔

    import enum
    import random
    
    
    class CatState(enum.Enum):
        alive = True
        dead = False
    
    
    class SchrödingersCat:
        cat: CatState.alive = CatState.alive
        if random.choice((True, False)):
            cat: CatState.dead = CatState.dead
    
    
    print(SchrödingersCat.__annotations__, SchrödingersCat.cat)
  6. tomasr8 commented on Mar 5, 2025

    @tomasr8
    Member
  7. zzzeek commented on Mar 5, 2025

    @zzzeek
    Author

    I think the answer we're going to get for all of these is that "typing is static and should not have conditionals"

    I also have vague memories of this discussion re: __annotations__ running all the annotations, etc. and I probably had similar concerns at that time.

    this hits us in the way our test suite is written, which is one side of it, but we also have some end-user recipes where on the SQLAlchemy side we'd have to tell them to not use TYPE_CHECKING and instead some kind of sentinel value that SQLAlchemy can evaluate, so that when it gets __annotations__ from a class, we have even more contextual information such that "this particular annotation is only for typing purposes, dont evaluate it in a SQLAlchemy runtime context"

  8. CaselIT commented on Mar 5, 2025

    @CaselIT

    Personally I don't think it's correct to evaluate false branches in a way that have effect at runtime. So I hope this is treated as a bug.
    The fact that you have someothervalue is evaluated to be a string in the example mentioned here #130881 (comment) makes no logical sense.

  9. sobolevn commented on Mar 5, 2025

    @sobolevn
    Member

    I think the answer we're going to get for all of these is that "typing is static and should not have conditionals"

    Not at all! Typing is not only static, we also care about our runtime type checkers.
    Regression in #130881 (comment) 100% looks like a bug to me.

  10. zzzeek commented on Mar 5, 2025

    @zzzeek
    Author

    well while we wait in suspense, it's just this behavior is so weird that I find it hard to believe it was done by accident!

  11. JelleZijlstra commented on Mar 6, 2025

    @JelleZijlstra
    Member

    This is documented in PEP 649: "Code that sets annotations on module or class attributes from inside any kind of flow control statement. It’s currently possible to set module and class attributes with annotations inside an if or try statement, and it works as one would expect. It’s untenable to support this behavior when this PEP is active."

    However, I agree that the behavior described here is unfortunate. Here's a possible way to fix it: In module and class scopes, assign each annotated assignment an identifier that is unique within that scope (e.g., a small integer starting at 0). Maintain an internal set (hidden to the user) that represents the assignments that have been executed. Add to the set whenever an annotated assignment is executed. Inside the generated __annotate__, emit key/value pairs only for assignments that are in the executed set.

    Something like this:

    class SchrödingersCat:
        __executed_set = set()
        cat: CatState.alive = CatState.alive
        __executed_set.add(0)
        if random.choice((True, False)):
            cat: CatState.dead = CatState.dead
            __executed_set.add(1)
    
        def __annotate__(self, format):
            if format > 2: raise NotImplementedError
            annos = {}
            if 0 in __executed_set:
                annos["cat"] = CatState.alive
            if 1 in __executed_set:
                annos["cat"] = CatState.dead
            return annos

    I believe we could optimize away the __executed_set check for assignments at the top level, which are executed unconditionally.

  12. CaselIT commented on Mar 6, 2025

    @CaselIT

    From the pep

    There are two uncommon interactions possible with class and module annotations that work with stock semantics that would no longer work when this PEP was active. These two interactions would have to be prohibited. The good news is, neither is common, and neither is considered good practice. In fact, they’re rarely seen outside of Python’s own regression test suite. They are:

    Code that sets annotations on module or class attributes from inside any kind of flow control statement. It’s currently possible to set module and class attributes with annotations inside an if or try statement, and it works as one would expect. It’s untenable to support this behavior when this PEP is active.

    Note that these are both also pain points for static type checkers, and are unsupported by those tools. It seems reasonable to declare that both are at the very least unsupported, and their use results in undefined behavior. It might be worth making a small effort to explicitly prohibit them with compile-time checks.

    I don't think the assumptions are correct, since it includes if TYPE_CHECKING that I would argue is quite common.

    It's also wrong that type checker don't support such features. They certainly do then using if TYPE_CHECKING or static conditional such as if False.

    A quick glance at https://github.com/search?q=%22++++if+TYPE_CHECKING%22&type=code&p=1 also seems to indicate that such pattern is in use in at least in class definitions

  13. CaselIT commented on Mar 6, 2025

    @CaselIT

    I think this will break all tools that use annotations to do runtime operations, for example dataclasses.

    This does not work in 3.14a5

    from dataclasses import dataclass
    
    @dataclass
    class Foo:
        a: int
        b: str
        if False:
            c: str
    
    print(Foo(1, "2"))

    raises

        print(Foo(1, "2"))
              ~~~^^^^^^^^
    TypeError: Foo.__init__() missing 1 required positional argument: 'c'
    

    Also it breaks other popular libreries. For example pydantic<2 does not even import in python 3.14:

    Python 3.14.0a5 (main, Feb 25 2025, 02:40:42) [GCC 12.2.0] on linux
    Type "help", "copyright", "credits" or "license" for more information.
    >>> import pydantic
    Traceback (most recent call last):
      File "<python-input-0>", line 1, in <module>
        import pydantic
      File "/usr/local/lib/python3.14/site-packages/pydantic/__init__.py", line 2, in <module>
        from pydantic import dataclasses
      File "/usr/local/lib/python3.14/site-packages/pydantic/dataclasses.py", line 55, in <module>
        from pydantic.main import create_model, validate_model
      File "/usr/local/lib/python3.14/site-packages/pydantic/main.py", line 316, in <module>
        class BaseModel(Representation, metaclass=ModelMetaclass):
        ...<605 lines>...
                ]
      File "/usr/local/lib/python3.14/site-packages/pydantic/main.py", line 289, in __new__
        getattr(cls, '__annotations__', {}).clear()
        ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      File "/usr/local/lib/python3.14/site-packages/pydantic/main.py", line 331, in __annotate__
        __class_vars__: ClassVar[SetStr]
                                 ^^^^^^
    NameError: name 'SetStr' is not defined. Did you mean: 'setattr'?
    

    This error if due to the if TYPE_CHECKING inside a class: https://github.com/pydantic/pydantic/blob/0cbba0f9774c470e8d8a2a62abb921b8cea4027d/pydantic/main.py#L316-L332

    I can't install pydantic>=2 since it has compile errors, but I would expect it to behave like dataclasses.

  14. zzzeek commented on Mar 6, 2025

    @zzzeek
    Author

    @JelleZijlstra if we could have a simple flag on get_annotations() that simply says, "honor_conditionals=True" or something like that, this will allow libraries like pydantic / SQLAlchemy and others to not have to re-think our entire development model.

  15. 6 remaining items

  16. CaselIT commented on Mar 6, 2025

    @CaselIT

    This situation is very analogous to that, I think.

    Indeed forgot about this case.
    I would argue that it's worse though, since it this case no exception is raised and the print prints 2.

  17. zzzeek commented on Mar 6, 2025

    @zzzeek
    Author

    on the SQLAlchemy side I've proposed two workarounds, one for end-user TYPE_CHECKING recipes we will provide an "ignore" sentinel like this, the existing use case looks like:

    class MyMappedClass(Base):
         # ...
    
         if TYPE_CHECKING:
              things: Mapped[list[Stuff]]
    
    # ... later
    MyMappedClass.things = relationship(<complicated arguments that can't be inline in the class>)

    the user wants to apply relationship() outside the class body but still have a type annotation for the attribute. We want SQLAlchemy to not attempt to runtime-evaluate Mapped[list[Stuff]] since that will raise without the relationship being immediately present. A user with this use case will be able to force this annotation to be skipped at runtime as follows:

    from sqlalchemy.orm import ignore_mapped_attr
    
    class MyMappedClass(Base):
         # ...
    
         if TYPE_CHECKING:
              things: Mapped[list[Stuff]]
         else:
              things = ignore_mapped_attr()
    
    # ... later
    MyMappedClass.things = relationship(<complicated arguments that can't be inline in the class>)

    so while SQLAlchemy declarative will get the Mapped[list[Stuff]] annotation, it will also get the ignore_mapped_attr() sentinel value so it will know to skip that. We also need to use get_annotations() with Format.FORWARDREF since we mostly prefer that ORM mapped annotations act like from __future__ import annotations mode, that is, we need string names of related classes not the class itself. So it's nice that that's there at least.

    For our test suite, we have conditional suites that are just exercising many different syntaxes of annotations to test that SQLAlchemy can interpret them:

            # unit test code that uses pytest parameterize to provide different cases
    
            class A(Base):
                __tablename__ = "a"
    
                id: Mapped[int] = mapped_column(primary_key=True)
                data: Mapped[str] = mapped_column()
    
                if brackets.oneset:
                    if option.not_optional:
                        json: Mapped[Dict[str, Decimal]] = mapped_column()  # type: ignore  # noqa: E501
                    elif option.optional:
                        json: Mapped[Optional[Dict[str, Decimal]]] = mc
                    elif option.optional_fwd_ref:
                        json: Mapped["Optional[Dict[str, Decimal]]"] = mc
    
                     # many more conditionals etc..

    for these we have to write out the full "A" class each time for each conditional, which makes the test more verbose. This is a handful of test modules where we are trying to exercise parsing of many types of annotation and how they come in for different python versions, so hoping this is manageable. mostly I'm concerned with cognitively being able to catch suites like this which can silently fail to exercise the tests for the previous conditionals that are overwritten. seeing conditionals in source code and having to keep in one's mind that "part of this code will be not subject to the conditionals as though they weren't written, and this other part will be" is just unintuitive and strange.

  18. CaselIT commented on Mar 6, 2025

    @CaselIT

    Just for fun I managed to make the interpreter run arbitrary code from if False blocks.

    in file other.py

    def __getattr__(name):
        if name == "MyClass":
            print('hi there')
            return int
        raise AttributeError(f"module {__name__} has no attribute {name}")

    then in another file

    import dataclasses
    import other
    
    @dataclasses.dataclass
    class Foo:
        a: int
        if False:
            b: other.MyClass

    executing this prints hi there

  19. JelleZijlstra commented on Mar 6, 2025

    @JelleZijlstra
    Member

    I am going to try to implement the fix proposed in my previous comment. Let's keep the discussion focused on how to fix this; we don't need more examples.

  20. JelleZijlstra commented on Mar 8, 2025

    @JelleZijlstra
    Member

    I proposed a fix: #130935. If you're interested in this issue, please try it out and let me know if you encounter any problems.

  21. zzzeek commented on Mar 9, 2025

    @zzzeek
    Author

    ill see if i can try it out

  22. zzzeek commented on Mar 10, 2025

    @zzzeek
    Author

    OK that seems to fix the conditional issue. Something else changed in 3.14 since a5 with Unions but I came up with a workaround for that.

  23. added a commit that references this issue on Mar 26, 2025
  24. added a commit that references this issue on Apr 1, 2025
  25. hugovk commented on Apr 7, 2025

    @hugovk
    Member

    Can this issue be closed now #130935 has been merged?

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

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions