--- /dev/null
+.. 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.
from __future__ import annotations
+import os
import platform
from . import asyncio as _test_asyncio
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
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 python310(self):
return exclusions.only_if(
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(
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 pypy as pypy
from .compat import win32 as win32
from .concurrency import await_ as await_
from typing import Tuple
from typing import Type
+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)
_MA = TypeVar("_MA", bound="HasMemoized.memoized_attribute[Any]")
_M = TypeVar("_M", bound=ModuleType)
-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)
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[
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}
return _de_optionalize_fwd_ref_union_types(type_, False)
elif is_union(type_) and includes_none(type_):
- typ = set(type_.__args__)
-
- typ.discard(NoneType)
- typ.discard(NoneFwd)
+ typ = {
+ t
+ for t in type_.__args__
+ if t is not NoneType and not is_fwd_none(t)
+ }
return make_union_type(*typ)
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:
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
# 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",
# 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()
@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
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 typing as sa_typing
TV = typing.TypeVar("TV")
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:
+ # for py310 through py313, remove new-style unions `int | str` that
+ # are not generic
new_ut = type(int | str)
res = [t for t in res if not isinstance(t, new_ut)]
return res
]
+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]
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'
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]]"),
)
]
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):
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]})
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]"),
)
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):
typing.Union[bool, TAext_int, NT_str],
)
- 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))
-
+ @requires.up_to_date_typealias_type
+ def test_includes_none_generics(self):
# TODO: these are false negatives
false_negatives = {
TA_null_union4, # does not evaluate FW ref
TAext_null_union4, # does not evaluate FW ref
}
+
for t in type_aliases() + new_types():
if t in false_negatives:
exp = False
exp = "null" in t.__name__
eq_(sa_typing.includes_none(t), exp, str(t))
+ 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),
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
errs = []
def go():
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
-
async def main():
tasks = [task() for _ in range(2)]
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]",
@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
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)
cov: True
extras=
- py{3,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{39,310}-sqlite_file: sqlcipher
postgresql: postgresql
postgresql: postgresql_pg8000
postgresql: postgresql_psycopg
python -I -m pip install --only-binary=pymssql {opts} {packages}
deps=
+ typing-extensions>=4.13.0rc1
+
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
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{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}
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