Skip to content

Commit b8b07a2

Browse files
committed
implement pep-649 workarounds, test suite passing for python 3.14
Changes to the test suite to accommodate Python 3.14 as of version 3.14.0b1 Originally this included a major breaking change to how python 3.14 implemented :pep:`649`, however this was resolved by [1]. As of a7, greenlet is skipped due to issues in a7 and later b1 in [2]. 1. the change to rewrite all conditionals in annotation related tests is reverted. 2. test_memusage needed an explicit set_start_method() call so that it can continue to use plain fork 3. unfortunately at the moment greenlet has to be re-disabled for 3.14. 4. Changes to tox overall, remove pysqlcipher which hasn't worked in years, etc. 5. we need to support upcoming typing-extensions also, install the beta 6. 3.14.0a7 introduces major regressions to our runtime typing utilities, unfortunately, it's not clear if these can be resolved 7. for 3.14.0b1, we have to vendor get_annotations to work around [3] [1] python/cpython#130881 [2] python-greenlet/greenlet#440 [3] python/cpython#133684 py314: yes Fixes: #12405 References: #12399 Change-Id: I8715d02fae599472dd64a2a46ccf8986239ecd99
1 parent 39491be commit b8b07a2

File tree

12 files changed

+297
-90
lines changed

12 files changed

+297
-90
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.. change::
2+
:tags: bug, orm
3+
:tickets: 12405
4+
5+
Changes to the test suite to accommodate Python 3.14 and its new
6+
implementation of :pep:`649`, which highly modifies how typing annotations
7+
are interpreted at runtime. Use of the new
8+
``annotationlib.get_annotations()`` function is enabled when python 3.14 is
9+
present, and many other changes to how pep-484 type objects are interpreted
10+
at runtime are made.

lib/sqlalchemy/testing/requirements.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from __future__ import annotations
2121

22+
import os
2223
import platform
2324

2425
from . import asyncio as _test_asyncio
@@ -1498,6 +1499,10 @@ def timing_intensive(self):
14981499

14991500
return config.add_to_marker.timing_intensive
15001501

1502+
@property
1503+
def posix(self):
1504+
return exclusions.skip_if(lambda: os.name != "posix")
1505+
15011506
@property
15021507
def memory_intensive(self):
15031508
from . import config
@@ -1539,6 +1544,27 @@ def check(config):
15391544

15401545
return exclusions.skip_if(check)
15411546

1547+
@property
1548+
def up_to_date_typealias_type(self):
1549+
# this checks a particular quirk found in typing_extensions <=4.12.0
1550+
# using older python versions like 3.10 or 3.9, we use TypeAliasType
1551+
# from typing_extensions which does not provide for sufficient
1552+
# introspection prior to 4.13.0
1553+
def check(config):
1554+
import typing
1555+
import typing_extensions
1556+
1557+
TypeAliasType = getattr(
1558+
typing, "TypeAliasType", typing_extensions.TypeAliasType
1559+
)
1560+
TV = typing.TypeVar("TV")
1561+
TA_generic = TypeAliasType( # type: ignore
1562+
"TA_generic", typing.List[TV], type_params=(TV,)
1563+
)
1564+
return hasattr(TA_generic[int], "__value__")
1565+
1566+
return exclusions.only_if(check)
1567+
15421568
@property
15431569
def python310(self):
15441570
return exclusions.only_if(
@@ -1557,6 +1583,26 @@ def python312(self):
15571583
lambda: util.py312, "Python 3.12 or above required"
15581584
)
15591585

1586+
@property
1587+
def fail_python314b1(self):
1588+
return exclusions.fails_if(
1589+
lambda: util.compat.py314b1, "Fails as of python 3.14.0b1"
1590+
)
1591+
1592+
@property
1593+
def not_python314(self):
1594+
"""This requirement is interim to assist with backporting of
1595+
issue #12405.
1596+
1597+
SQLAlchemy 2.0 still includes the ``await_fallback()`` method that
1598+
makes use of ``asyncio.get_event_loop_policy()``. This is removed
1599+
in SQLAlchemy 2.1.
1600+
1601+
"""
1602+
return exclusions.skip_if(
1603+
lambda: util.py314, "Python 3.14 or above not supported"
1604+
)
1605+
15601606
@property
15611607
def cpython(self):
15621608
return exclusions.only_if(

lib/sqlalchemy/util/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
from .compat import py311 as py311
6666
from .compat import py312 as py312
6767
from .compat import py313 as py313
68+
from .compat import py314 as py314
6869
from .compat import pypy as pypy
6970
from .compat import win32 as win32
7071
from .concurrency import await_ as await_

lib/sqlalchemy/util/compat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
from typing import Tuple
3232
from typing import Type
3333

34+
py314b1 = sys.version_info >= (3, 14, 0, "beta", 1)
35+
py314 = sys.version_info >= (3, 14)
3436
py313 = sys.version_info >= (3, 13)
3537
py312 = sys.version_info >= (3, 12)
3638
py311 = sys.version_info >= (3, 11)

lib/sqlalchemy/util/langhelpers.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,85 @@
5858
_MA = TypeVar("_MA", bound="HasMemoized.memoized_attribute[Any]")
5959
_M = TypeVar("_M", bound=ModuleType)
6060

61-
if compat.py310:
61+
if compat.py314:
62+
# vendor a minimal form of get_annotations per
63+
# https://github.com/python/cpython/issues/133684#issuecomment-2863841891
64+
65+
from annotationlib import call_annotate_function # type: ignore
66+
from annotationlib import Format
67+
68+
def _get_and_call_annotate(obj, format): # noqa: A002
69+
annotate = getattr(obj, "__annotate__", None)
70+
if annotate is not None:
71+
ann = call_annotate_function(annotate, format, owner=obj)
72+
if not isinstance(ann, dict):
73+
raise ValueError(f"{obj!r}.__annotate__ returned a non-dict")
74+
return ann
75+
return None
76+
77+
# this is ported from py3.13.0a7
78+
_BASE_GET_ANNOTATIONS = type.__dict__["__annotations__"].__get__ # type: ignore # noqa: E501
79+
80+
def _get_dunder_annotations(obj):
81+
if isinstance(obj, type):
82+
try:
83+
ann = _BASE_GET_ANNOTATIONS(obj)
84+
except AttributeError:
85+
# For static types, the descriptor raises AttributeError.
86+
return {}
87+
else:
88+
ann = getattr(obj, "__annotations__", None)
89+
if ann is None:
90+
return {}
91+
92+
if not isinstance(ann, dict):
93+
raise ValueError(
94+
f"{obj!r}.__annotations__ is neither a dict nor None"
95+
)
96+
return dict(ann)
97+
98+
def _vendored_get_annotations(
99+
obj: Any, *, format: Format # noqa: A002
100+
) -> Mapping[str, Any]:
101+
"""A sparse implementation of annotationlib.get_annotations()"""
102+
103+
try:
104+
ann = _get_dunder_annotations(obj)
105+
except Exception:
106+
pass
107+
else:
108+
if ann is not None:
109+
return dict(ann)
110+
111+
# But if __annotations__ threw a NameError, we try calling __annotate__
112+
ann = _get_and_call_annotate(obj, format)
113+
if ann is None:
114+
# If that didn't work either, we have a very weird object:
115+
# evaluating
116+
# __annotations__ threw NameError and there is no __annotate__.
117+
# In that case,
118+
# we fall back to trying __annotations__ again.
119+
ann = _get_dunder_annotations(obj)
120+
121+
if ann is None:
122+
if isinstance(obj, type) or callable(obj):
123+
return {}
124+
raise TypeError(f"{obj!r} does not have annotations")
125+
126+
if not ann:
127+
return {}
128+
129+
return dict(ann)
130+
131+
def get_annotations(obj: Any) -> Mapping[str, Any]:
132+
# FORWARDREF has the effect of giving us ForwardRefs and not
133+
# actually trying to evaluate the annotations. We need this so
134+
# that the annotations act as much like
135+
# "from __future__ import annotations" as possible, which is going
136+
# away in future python as a separate mode
137+
return _vendored_get_annotations(obj, format=Format.FORWARDREF)
138+
139+
elif compat.py310:
62140

63141
def get_annotations(obj: Any) -> Mapping[str, Any]:
64142
return inspect.get_annotations(obj)

lib/sqlalchemy/util/typing.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@
7777
else:
7878
NoneType = type(None) # type: ignore
7979

80-
NoneFwd = ForwardRef("None")
80+
81+
def is_fwd_none(typ: Any) -> bool:
82+
return isinstance(typ, ForwardRef) and typ.__forward_arg__ == "None"
8183

8284

8385
_AnnotationScanType = Union[
@@ -393,7 +395,7 @@ def recursive_value(inner_type):
393395
if isinstance(t, list):
394396
stack.extend(t)
395397
else:
396-
types.add(None if t in {NoneType, NoneFwd} else t)
398+
types.add(None if t is NoneType or is_fwd_none(t) else t)
397399
return types
398400
else:
399401
return {res}
@@ -445,10 +447,11 @@ def de_optionalize_union_types(
445447
return _de_optionalize_fwd_ref_union_types(type_, False)
446448

447449
elif is_union(type_) and includes_none(type_):
448-
typ = set(type_.__args__)
449-
450-
typ.discard(NoneType)
451-
typ.discard(NoneFwd)
450+
typ = {
451+
t
452+
for t in type_.__args__
453+
if t is not NoneType and not is_fwd_none(t)
454+
}
452455

453456
return make_union_type(*typ)
454457

@@ -524,7 +527,8 @@ def _de_optionalize_fwd_ref_union_types(
524527

525528
def make_union_type(*types: _AnnotationScanType) -> Type[Any]:
526529
"""Make a Union type."""
527-
return Union.__getitem__(types) # type: ignore
530+
531+
return Union[types] # type: ignore
528532

529533

530534
def includes_none(type_: Any) -> bool:
@@ -550,7 +554,7 @@ def includes_none(type_: Any) -> bool:
550554
if is_newtype(type_):
551555
return includes_none(type_.__supertype__)
552556
try:
553-
return type_ in (NoneFwd, NoneType, None)
557+
return type_ in (NoneType, None) or is_fwd_none(type_)
554558
except TypeError:
555559
# if type_ is Column, mapped_column(), etc. the use of "in"
556560
# resolves to ``__eq__()`` which then gives us an expression object

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ filterwarnings = [
154154
# sqlite3 warnings due to test/dialect/test_sqlite.py->test_native_datetime,
155155
# which is asserting that these deprecated-in-py312 handlers are functional
156156
"ignore:The default (date)?(time)?(stamp)? (adapter|converter):DeprecationWarning",
157+
158+
# warning regarding using "fork" mode for multiprocessing when the parent
159+
# has threads; using pytest-xdist introduces threads in the parent
160+
# and we use multiprocessing in test/aaa_profiling/test_memusage.py where
161+
# we require "fork" mode
162+
# https://github.com/python/cpython/pull/100229#issuecomment-2704616288
163+
"ignore:This process .* is multi-threaded:DeprecationWarning",
157164
]
158165
markers = [
159166
"memory_intensive: memory / CPU intensive suite tests",

test/aaa_profiling/test_memusage.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,14 @@ def run_plain(*func_args):
223223
# return run_plain
224224

225225
def run_in_process(*func_args):
226-
queue = multiprocessing.Queue()
227-
proc = multiprocessing.Process(
228-
target=profile, args=(queue, func_args)
229-
)
226+
# see
227+
# https://docs.python.org/3.14/whatsnew/3.14.html
228+
# #incompatible-changes - the default run type is no longer
229+
# "fork", but since we are running closures in the process
230+
# we need forked mode
231+
ctx = multiprocessing.get_context("fork")
232+
queue = ctx.Queue()
233+
proc = ctx.Process(target=profile, args=(queue, func_args))
230234
proc.start()
231235
while True:
232236
row = queue.get()
@@ -394,7 +398,7 @@ def go():
394398

395399
@testing.add_to_marker.memory_intensive
396400
class MemUsageWBackendTest(fixtures.MappedTest, EnsureZeroed):
397-
__requires__ = "cpython", "memory_process_intensive", "no_asyncio"
401+
__requires__ = "cpython", "posix", "memory_process_intensive", "no_asyncio"
398402
__sparse_backend__ = True
399403

400404
# ensure a pure growing test trips the assertion

0 commit comments

Comments
 (0)