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] https://github.com/python/cpython/issues/130881
[2] https://github.com/python-greenlet/greenlet/issues/440
[3] https://github.com/python/cpython/issues/133684

py314: yes
Fixes: #12405
References: #12399
Change-Id: I8715d02fae599472dd64a2a46ccf8986239ecd99
This commit is contained in:
Mike Bayer
2025-03-06 09:12:43 -05:00
parent 14313b445f
commit adef933f8d
13 changed files with 293 additions and 87 deletions
+10
View File
@@ -0,0 +1,10 @@
.. change::
:tags: bug, orm
:tickets: 12405
Changes to the test suite to accommodate Python 3.14 and its new
implementation of :pep:`649`, which highly modifies how typing annotations
are interpreted at runtime. Use of the new
``annotationlib.get_annotations()`` function is enabled when python 3.14 is
present, and many other changes to how pep-484 type objects are interpreted
at runtime are made.
+46
View File
@@ -19,6 +19,7 @@ to provide specific inclusion/exclusions.
from __future__ import annotations
import os
import platform
from . import asyncio as _test_asyncio
@@ -1498,6 +1499,10 @@ class SuiteRequirements(Requirements):
return config.add_to_marker.timing_intensive
@property
def posix(self):
return exclusions.skip_if(lambda: os.name != "posix")
@property
def memory_intensive(self):
from . import config
@@ -1539,6 +1544,27 @@ class SuiteRequirements(Requirements):
return exclusions.skip_if(check)
@property
def up_to_date_typealias_type(self):
# this checks a particular quirk found in typing_extensions <=4.12.0
# using older python versions like 3.10 or 3.9, we use TypeAliasType
# from typing_extensions which does not provide for sufficient
# introspection prior to 4.13.0
def check(config):
import typing
import typing_extensions
TypeAliasType = getattr(
typing, "TypeAliasType", typing_extensions.TypeAliasType
)
TV = typing.TypeVar("TV")
TA_generic = TypeAliasType( # type: ignore
"TA_generic", typing.List[TV], type_params=(TV,)
)
return hasattr(TA_generic[int], "__value__")
return exclusions.only_if(check)
@property
def python38(self):
return exclusions.only_if(
@@ -1569,6 +1595,26 @@ class SuiteRequirements(Requirements):
lambda: util.py312, "Python 3.12 or above required"
)
@property
def fail_python314b1(self):
return exclusions.fails_if(
lambda: util.compat.py314b1, "Fails as of python 3.14.0b1"
)
@property
def not_python314(self):
"""This requirement is interim to assist with backporting of
issue #12405.
SQLAlchemy 2.0 still includes the ``await_fallback()`` method that
makes use of ``asyncio.get_event_loop_policy()``. This is removed
in SQLAlchemy 2.1.
"""
return exclusions.skip_if(
lambda: util.py314, "Python 3.14 or above not supported"
)
@property
def cpython(self):
return exclusions.only_if(
+1
View File
@@ -65,6 +65,7 @@ from .compat import py310 as py310
from .compat import py311 as py311
from .compat import py312 as py312
from .compat import py313 as py313
from .compat import py314 as py314
from .compat import py38 as py38
from .compat import py39 as py39
from .compat import pypy as pypy
+2
View File
@@ -32,6 +32,8 @@ from typing import Type
from typing import TypeVar
py314b1 = sys.version_info >= (3, 14, 0, "beta", 1)
py314 = sys.version_info >= (3, 14)
py313 = sys.version_info >= (3, 13)
py312 = sys.version_info >= (3, 12)
py311 = sys.version_info >= (3, 11)
+79 -1
View File
@@ -60,7 +60,85 @@ _HP = TypeVar("_HP", bound="hybridproperty[Any]")
_HM = TypeVar("_HM", bound="hybridmethod[Any]")
if compat.py310:
if compat.py314:
# vendor a minimal form of get_annotations per
# https://github.com/python/cpython/issues/133684#issuecomment-2863841891
from annotationlib import call_annotate_function # type: ignore
from annotationlib import Format
def _get_and_call_annotate(obj, format): # noqa: A002
annotate = getattr(obj, "__annotate__", None)
if annotate is not None:
ann = call_annotate_function(annotate, format, owner=obj)
if not isinstance(ann, dict):
raise ValueError(f"{obj!r}.__annotate__ returned a non-dict")
return ann
return None
# this is ported from py3.13.0a7
_BASE_GET_ANNOTATIONS = type.__dict__["__annotations__"].__get__ # type: ignore # noqa: E501
def _get_dunder_annotations(obj):
if isinstance(obj, type):
try:
ann = _BASE_GET_ANNOTATIONS(obj)
except AttributeError:
# For static types, the descriptor raises AttributeError.
return {}
else:
ann = getattr(obj, "__annotations__", None)
if ann is None:
return {}
if not isinstance(ann, dict):
raise ValueError(
f"{obj!r}.__annotations__ is neither a dict nor None"
)
return dict(ann)
def _vendored_get_annotations(
obj: Any, *, format: Format # noqa: A002
) -> Mapping[str, Any]:
"""A sparse implementation of annotationlib.get_annotations()"""
try:
ann = _get_dunder_annotations(obj)
except Exception:
pass
else:
if ann is not None:
return dict(ann)
# But if __annotations__ threw a NameError, we try calling __annotate__
ann = _get_and_call_annotate(obj, format)
if ann is None:
# If that didn't work either, we have a very weird object:
# evaluating
# __annotations__ threw NameError and there is no __annotate__.
# In that case,
# we fall back to trying __annotations__ again.
ann = _get_dunder_annotations(obj)
if ann is None:
if isinstance(obj, type) or callable(obj):
return {}
raise TypeError(f"{obj!r} does not have annotations")
if not ann:
return {}
return dict(ann)
def get_annotations(obj: Any) -> Mapping[str, Any]:
# FORWARDREF has the effect of giving us ForwardRefs and not
# actually trying to evaluate the annotations. We need this so
# that the annotations act as much like
# "from __future__ import annotations" as possible, which is going
# away in future python as a separate mode
return _vendored_get_annotations(obj, format=Format.FORWARDREF)
elif compat.py310:
def get_annotations(obj: Any) -> Mapping[str, Any]:
return inspect.get_annotations(obj)
+8 -6
View File
@@ -73,7 +73,9 @@ if compat.py310:
else:
NoneType = type(None) # type: ignore
NoneFwd = ForwardRef("None")
def is_fwd_none(typ: Any) -> bool:
return isinstance(typ, ForwardRef) and typ.__forward_arg__ == "None"
_AnnotationScanType = Union[
@@ -397,7 +399,7 @@ def pep695_values(type_: _AnnotationScanType) -> Set[Any]:
if isinstance(t, list):
stack.extend(t)
else:
types.add(None if t in {NoneType, NoneFwd} else t)
types.add(None if t is NoneType or is_fwd_none(t) else t)
return types
else:
return {res}
@@ -469,8 +471,7 @@ def de_optionalize_union_types(
typ.discard(None) # type: ignore
typ.discard(NoneType)
typ.discard(NoneFwd)
typ = {t for t in typ if t is not NoneType and not is_fwd_none(t)}
return make_union_type(*typ)
@@ -546,7 +547,8 @@ def _de_optionalize_fwd_ref_union_types(
def make_union_type(*types: _AnnotationScanType) -> Type[Any]:
"""Make a Union type."""
return Union.__getitem__(types) # type: ignore
return Union[types] # type: ignore
def includes_none(type_: Any) -> bool:
@@ -572,7 +574,7 @@ def includes_none(type_: Any) -> bool:
if is_newtype(type_):
return includes_none(type_.__supertype__)
try:
return type_ in (NoneFwd, NoneType, None)
return type_ in (NoneType, None) or is_fwd_none(type_)
except TypeError:
# if type_ is Column, mapped_column(), etc. the use of "in"
# resolves to ``__eq__()`` which then gives us an expression object
+7
View File
@@ -45,6 +45,13 @@ filterwarnings = [
# sqlite3 warnings due to test/dialect/test_sqlite.py->test_native_datetime,
# which is asserting that these deprecated-in-py312 handlers are functional
"ignore:The default (date)?(time)?(stamp)? (adapter|converter):DeprecationWarning",
# warning regarding using "fork" mode for multiprocessing when the parent
# has threads; using pytest-xdist introduces threads in the parent
# and we use multiprocessing in test/aaa_profiling/test_memusage.py where
# we require "fork" mode
# https://github.com/python/cpython/pull/100229#issuecomment-2704616288
"ignore:This process .* is multi-threaded:DeprecationWarning",
]
markers = [
"memory_intensive: memory / CPU intensive suite tests",
+9 -5
View File
@@ -223,10 +223,14 @@ def profile_memory(
# return run_plain
def run_in_process(*func_args):
queue = multiprocessing.Queue()
proc = multiprocessing.Process(
target=profile, args=(queue, func_args)
)
# see
# https://docs.python.org/3.14/whatsnew/3.14.html
# #incompatible-changes - the default run type is no longer
# "fork", but since we are running closures in the process
# we need forked mode
ctx = multiprocessing.get_context("fork")
queue = ctx.Queue()
proc = ctx.Process(target=profile, args=(queue, func_args))
proc.start()
while True:
row = queue.get()
@@ -394,7 +398,7 @@ class MemUsageTest(EnsureZeroed):
@testing.add_to_marker.memory_intensive
class MemUsageWBackendTest(fixtures.MappedTest, EnsureZeroed):
__requires__ = "cpython", "memory_process_intensive", "no_asyncio"
__requires__ = "cpython", "posix", "memory_process_intensive", "no_asyncio"
__sparse_backend__ = True
# ensure a pure growing test trips the assertion
+2
View File
@@ -4,6 +4,7 @@ import random
import threading
from sqlalchemy import exc
from sqlalchemy import testing
from sqlalchemy.testing import async_test
from sqlalchemy.testing import eq_
from sqlalchemy.testing import expect_raises
@@ -80,6 +81,7 @@ class TestAsyncioCompat(fixtures.TestBase):
with expect_raises_message(ValueError, "sync error"):
await greenlet_spawn(go)
@testing.requires.not_python314
def test_await_fallback_no_greenlet(self):
to_await = run1()
await_fallback(to_await)
+97 -53
View File
@@ -10,8 +10,8 @@ from sqlalchemy.testing import requires
from sqlalchemy.testing.assertions import eq_
from sqlalchemy.testing.assertions import is_
from sqlalchemy.util import py310
from sqlalchemy.util import py311
from sqlalchemy.util import py312
from sqlalchemy.util import py314
from sqlalchemy.util import py38
from sqlalchemy.util import typing as sa_typing
@@ -42,7 +42,7 @@ def null_union_types():
def generic_unions():
# remove new-style unions `int | str` that are not generic
res = union_types() + null_union_types()
if py310:
if py310 and not py314:
new_ut = type(int | str)
res = [t for t in res if not isinstance(t, new_ut)]
return res
@@ -200,6 +200,29 @@ A_null_union = typing_extensions.Annotated[
]
def compare_type_by_string(a, b):
"""python 3.14 has made ForwardRefs not really comparable or reliably
hashable.
As we need to compare types here, including structures like
`Union["str", "int"]`, without having to dive into cpython's source code
each time a new release comes out, compare based on stringification,
which still presents changing rules but at least are easy to diagnose
and correct for different python versions.
See discussion at https://github.com/python/cpython/issues/129463
for background
"""
if isinstance(a, (set, list)):
a = sorted(a, key=lambda x: str(x))
if isinstance(b, (set, list)):
b = sorted(b, key=lambda x: str(x))
eq_(str(a), str(b))
def annotated_l():
return [A_str, A_null_str, A_union, A_null_union]
@@ -234,14 +257,6 @@ class TestTestingThings(fixtures.TestBase):
is_(typing.Union, typing_extensions.Union)
is_(typing.Optional, typing_extensions.Optional)
def test_make_union(self):
v = int, str
eq_(typing.Union[int, str], typing.Union.__getitem__(v))
if py311:
# need eval since it's a syntax error in python < 3.11
eq_(typing.Union[int, str], eval("typing.Union[*(int, str)]"))
eq_(typing.Union[int, str], eval("typing.Union[*v]"))
@requires.python312
def test_make_type_alias_type(self):
# verify that TypeAliasType('foo', int) it the same as 'type foo = int'
@@ -253,9 +268,11 @@ class TestTestingThings(fixtures.TestBase):
eq_(x_type.__value__, x.__value__)
def test_make_fw_ref(self):
eq_(make_fw_ref("str"), typing.ForwardRef("str"))
eq_(make_fw_ref("str|int"), typing.ForwardRef("str|int"))
eq_(
compare_type_by_string(make_fw_ref("str"), typing.ForwardRef("str"))
compare_type_by_string(
make_fw_ref("str|int"), typing.ForwardRef("str|int")
)
compare_type_by_string(
make_fw_ref("Optional[Union[str, int]]"),
typing.ForwardRef("Optional[Union[str, int]]"),
)
@@ -317,8 +334,11 @@ class TestTyping(fixtures.TestBase):
]
for t in all_types():
# use is since union compare equal between new/old style
exp = any(t is k for k in generics)
if py314:
exp = any(t == k for k in generics)
else:
# use is since union compare equal between new/old style
exp = any(t is k for k in generics)
eq_(sa_typing.is_generic(t), exp, t)
def test_is_pep695(self):
@@ -360,70 +380,82 @@ class TestTyping(fixtures.TestBase):
eq_(sa_typing.pep695_values(TAext_null_union), {int, str, None})
eq_(sa_typing.pep695_values(TA_null_union2), {int, str, None})
eq_(sa_typing.pep695_values(TAext_null_union2), {int, str, None})
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_null_union3),
{int, typing.ForwardRef("typing.Union[None, bool]")},
[int, typing.ForwardRef("typing.Union[None, bool]")],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_null_union3),
{int, typing.ForwardRef("typing.Union[None, bool]")},
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_null_union4),
{int, typing.ForwardRef("TA_null_union2")},
[int, typing.ForwardRef("TA_null_union2")],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_null_union4),
{int, typing.ForwardRef("TAext_null_union2")},
)
eq_(sa_typing.pep695_values(TA_union_ta), {int, str})
eq_(sa_typing.pep695_values(TAext_union_ta), {int, str})
eq_(sa_typing.pep695_values(TA_null_union_ta), {int, str, None, float})
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_null_union_ta),
{int, str, None, float},
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_list),
{int, str, typing.List[typing.ForwardRef("TA_list")]},
[int, str, typing.List[typing.ForwardRef("TA_list")]],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_list),
{int, str, typing.List[typing.ForwardRef("TAext_list")]},
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_recursive),
{typing.ForwardRef("TA_recursive"), str},
[str, typing.ForwardRef("TA_recursive")],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_recursive),
{typing.ForwardRef("TAext_recursive"), str},
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_null_recursive),
{typing.ForwardRef("TA_recursive"), str, None},
[str, typing.ForwardRef("TA_recursive"), None],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_null_recursive),
{typing.ForwardRef("TAext_recursive"), str, None},
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_recursive_a),
{typing.ForwardRef("TA_recursive_b"), int},
[int, typing.ForwardRef("TA_recursive_b")],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_recursive_a),
{typing.ForwardRef("TAext_recursive_b"), int},
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TA_recursive_b),
{typing.ForwardRef("TA_recursive_a"), str},
[str, typing.ForwardRef("TA_recursive_a")],
)
eq_(
compare_type_by_string(
sa_typing.pep695_values(TAext_recursive_b),
{typing.ForwardRef("TAext_recursive_a"), str},
)
@requires.up_to_date_typealias_type
def test_pep695_value_generics(self):
# generics
eq_(sa_typing.pep695_values(TA_generic), {typing.List[TV]})
eq_(sa_typing.pep695_values(TAext_generic), {typing.List[TV]})
eq_(sa_typing.pep695_values(TA_generic_typed), {typing.List[TV]})
@@ -459,17 +491,23 @@ class TestTyping(fixtures.TestBase):
fn(typing.Optional[typing.Union[int, str]]), typing.Union[int, str]
)
eq_(fn(typing.Union[int, str, None]), typing.Union[int, str])
eq_(fn(typing.Union[int, str, "None"]), typing.Union[int, str])
eq_(fn(make_fw_ref("None")), typing_extensions.Never)
eq_(fn(make_fw_ref("typing.Union[None]")), typing_extensions.Never)
eq_(fn(make_fw_ref("Union[None, str]")), typing.ForwardRef("str"))
eq_(
compare_type_by_string(
fn(make_fw_ref("Union[None, str, int]")),
typing.Union["str", "int"],
)
eq_(fn(make_fw_ref("Optional[int]")), typing.ForwardRef("int"))
eq_(
compare_type_by_string(
fn(make_fw_ref("Optional[int]")), typing.ForwardRef("int")
)
compare_type_by_string(
fn(make_fw_ref("typing.Optional[Union[int | str]]")),
typing.ForwardRef("Union[int | str]"),
)
@@ -482,9 +520,12 @@ class TestTyping(fixtures.TestBase):
for t in union_types() + type_aliases() + new_types() + annotated_l():
eq_(fn(t), t)
eq_(
compare_type_by_string(
fn(make_fw_ref("Union[typing.Dict[str, int], int, None]")),
typing.Union["typing.Dict[str, int]", "int"],
typing.Union[
"typing.Dict[str, int]",
"int",
],
)
def test_make_union_type(self):
@@ -508,18 +549,9 @@ class TestTyping(fixtures.TestBase):
typing.Union[bool, TAext_int, NT_str],
)
@requires.up_to_date_typealias_type
@requires.python38
def test_includes_none(self):
eq_(sa_typing.includes_none(None), True)
eq_(sa_typing.includes_none(type(None)), True)
eq_(sa_typing.includes_none(typing.ForwardRef("None")), True)
eq_(sa_typing.includes_none(int), False)
for t in union_types():
eq_(sa_typing.includes_none(t), False)
for t in null_union_types():
eq_(sa_typing.includes_none(t), True, str(t))
def test_includes_none_generics(self):
# TODO: these are false negatives
false_negatives = {
TA_null_union4, # does not evaluate FW ref
@@ -532,6 +564,18 @@ class TestTyping(fixtures.TestBase):
exp = "null" in t.__name__
eq_(sa_typing.includes_none(t), exp, str(t))
@requires.python38
def test_includes_none(self):
eq_(sa_typing.includes_none(None), True)
eq_(sa_typing.includes_none(type(None)), True)
eq_(sa_typing.includes_none(typing.ForwardRef("None")), True)
eq_(sa_typing.includes_none(int), False)
for t in union_types():
eq_(sa_typing.includes_none(t), False)
for t in null_union_types():
eq_(sa_typing.includes_none(t), True, str(t))
for t in annotated_l():
eq_(
sa_typing.includes_none(t),
+10 -6
View File
@@ -269,9 +269,16 @@ class AsyncEngineTest(EngineFixture):
is_false(async_engine == None)
@async_test
async def test_no_attach_to_event_loop(self, testing_engine):
"""test #6409"""
def test_no_attach_to_event_loop(self, testing_engine):
"""test #6409
note this test does not seem to trigger the bug that was originally
fixed in #6409, when using python 3.10 and higher (the original issue
can repro in 3.8 at least, based on my testing). It's been simplified
to no longer explicitly create a new loop, asyncio.run() already
creates a new loop.
"""
import asyncio
import threading
@@ -279,9 +286,6 @@ class AsyncEngineTest(EngineFixture):
errs = []
def go():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def main():
tasks = [task() for _ in range(2)]
+8 -2
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm.query import Query
from sqlalchemy.sql.base import Executable
from sqlalchemy.testing import fixtures
from sqlalchemy.testing.assertions import eq_
from sqlalchemy.util.typing import is_fwd_ref
engine_execution_options = {
"compiled_cache": "Optional[CompiledCacheType]",
@@ -78,7 +79,12 @@ class OverloadTest(fixtures.TestBase):
@testing.combinations(
(CoreExecuteOptionsParameter, core_execution_options),
(OrmExecuteOptionsParameter, orm_execution_options),
# https://github.com/python/cpython/issues/133701
(
OrmExecuteOptionsParameter,
orm_execution_options,
testing.requires.fail_python314b1,
),
)
def test_typed_dicts(self, typ, expected):
# we currently expect these to be union types with first entry
@@ -90,7 +96,7 @@ class OverloadTest(fixtures.TestBase):
expected.pop("opt")
assert_annotations = {
key: fwd_ref.__forward_arg__
key: fwd_ref.__forward_arg__ if is_fwd_ref(fwd_ref) else fwd_ref
for key, fwd_ref in typed_dict.__annotations__.items()
}
eq_(assert_annotations, expected)
+14 -14
View File
@@ -28,9 +28,11 @@ usedevelop=
cov: True
extras=
py{3,37,38,39,310,311,312,313}: {[greenletextras]extras}
# this can be limited to specific python versions IF there is no
# greenlet available for the most recent python. otherwise
# keep this present in all cases
py{38,39,310,311,312,313}: {[greenletextras]extras}
py{37,38,39,310}-sqlite_file: sqlcipher
postgresql: postgresql
postgresql: postgresql_pg8000
postgresql: postgresql_psycopg
@@ -50,14 +52,13 @@ install_command=
python -I -m pip install --only-binary=pymssql {opts} {packages}
deps=
typing-extensions>=4.13.0rc1; python_version > '3.7'
pytest>=7.0.0,<8.4
# tracked by https://github.com/pytest-dev/pytest-xdist/issues/907
pytest-xdist!=3.3.0
py313: git+https://github.com/python-greenlet/greenlet.git\#egg=greenlet
dbapimain-sqlite: git+https://github.com/omnilib/aiosqlite.git\#egg=aiosqlite
dbapimain-sqlite: git+https://github.com/coleifer/sqlcipher3.git\#egg=sqlcipher3
dbapimain-postgresql: git+https://github.com/psycopg/psycopg2.git\#egg=psycopg2
dbapimain-postgresql: git+https://github.com/MagicStack/asyncpg.git\#egg=asyncpg
@@ -118,20 +119,19 @@ setenv=
oracle: ORACLE={env:TOX_ORACLE:--db oracle}
oracle: EXTRA_ORACLE_DRIVERS={env:EXTRA_ORACLE_DRIVERS:--dbdriver cx_oracle --dbdriver oracledb --dbdriver oracledb_async}
py{313,314}-oracle: EXTRA_ORACLE_DRIVERS={env:EXTRA_ORACLE_DRIVERS:--dbdriver cx_oracle --dbdriver oracledb}
sqlite: SQLITE={env:TOX_SQLITE:--db sqlite}
sqlite_file: SQLITE={env:TOX_SQLITE_FILE:--db sqlite_file}
sqlite: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver pysqlite_numeric --dbdriver aiosqlite}
py{313,314}-sqlite: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver pysqlite_numeric}
py{38,39,310,311,312,313}-sqlite: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver pysqlite_numeric --dbdriver aiosqlite}
py{314}-sqlite: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver pysqlite_numeric}
sqlite-nogreenlet: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver pysqlite_numeric}
py{37,38,39}-sqlite_file: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver aiosqlite --dbdriver pysqlcipher}
# note all of these would need limiting for py314 if we want tests to run until
# greenlet is available. I just dont see any clean way to do this in tox without writing
# all the versions out every time and it's ridiculous
# omit pysqlcipher for Python 3.10
py{3,310,311,312}-sqlite_file: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver aiosqlite}
sqlite_file: EXTRA_SQLITE_DRIVERS={env:EXTRA_SQLITE_DRIVERS:--dbdriver sqlite --dbdriver aiosqlite}
postgresql: POSTGRESQL={env:TOX_POSTGRESQL:--db postgresql}
@@ -150,10 +150,10 @@ setenv=
mssql: MSSQL={env:TOX_MSSQL:--db mssql}
mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc --dbdriver pymssql}
py{313,314}-mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc}
py{314}-mssql: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver aioodbc}
mssql-nogreenlet: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc --dbdriver pymssql}
py{313,314}-mssql-nogreenlet: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc}
py{314}-mssql-nogreenlet: EXTRA_MSSQL_DRIVERS={env:EXTRA_MSSQL_DRIVERS:--dbdriver pyodbc}
oracle,mssql,sqlite_file: IDENTS=--write-idents db_idents.txt