From: Mike Bayer Date: Thu, 19 Jan 2023 20:34:46 +0000 (-0500) Subject: typing updates X-Git-Tag: rel_2_0_0~23^2 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=d1eeef5e67fa4632f88a894f0c5cf4445f04ba2b;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git typing updates The :meth:`_sql.ColumnOperators.in_` and :meth:`_sql.ColumnOperators.not_in_` are typed to include ``Iterable[Any]`` rather than ``Sequence[Any]`` for more flexibility in argument type. The :func:`_sql.or_` and :func:`_sql.and_` from a typing perspective require the first argument to be present, however these functions still accept zero arguments which will emit a deprecation warning at runtime. Typing is also added to support sending the fixed literal ``False`` for :func:`_sql.or_` and ``True`` for :func:`_sql.and_` as the first argument only, however the documentation now indicates sending the :func:`_sql.false` and :func:`_sql.true` constructs in these cases as a more explicit approach. Fixed typing issue where iterating over a :class:`_orm.Query` object was not correctly typed. Fixes: #9122 Fixes: #9123 Fixes: #9125 Change-Id: I500e3e1b826717b3dd49afa1e682c3c8279c9226 --- diff --git a/doc/build/changelog/unreleased_20/more_typing.rst b/doc/build/changelog/unreleased_20/more_typing.rst new file mode 100644 index 0000000000..b958d0d91f --- /dev/null +++ b/doc/build/changelog/unreleased_20/more_typing.rst @@ -0,0 +1,30 @@ +.. change:: + :tags: typing, bug + :tickets: 9122 + + The :meth:`_sql.ColumnOperators.in_` and + :meth:`_sql.ColumnOperators.not_in_` are typed to include + ``Iterable[Any]`` rather than ``Sequence[Any]`` for more flexibility in + argument type. + + +.. change:: + :tags: typing, bug + :tickets: 9123 + + The :func:`_sql.or_` and :func:`_sql.and_` from a typing perspective + require the first argument to be present, however these functions still + accept zero arguments which will emit a deprecation warning at runtime. + Typing is also added to support sending the fixed literal ``False`` for + :func:`_sql.or_` and ``True`` for :func:`_sql.and_` as the first argument + only, however the documentation now indicates sending the + :func:`_sql.false` and :func:`_sql.true` constructs in these cases as a + more explicit approach. + + +.. change:: + :tags: typing, bug + :tickets: 9125 + + Fixed typing issue where iterating over a :class:`_orm.Query` object + was not correctly typed. diff --git a/lib/sqlalchemy/orm/query.py b/lib/sqlalchemy/orm/query.py index 8d29a45a84..ad4b3abcf5 100644 --- a/lib/sqlalchemy/orm/query.py +++ b/lib/sqlalchemy/orm/query.py @@ -28,6 +28,7 @@ from typing import cast from typing import Dict from typing import Generic from typing import Iterable +from typing import Iterator from typing import List from typing import Mapping from typing import Optional @@ -2856,10 +2857,10 @@ class Query( except sa_exc.NoResultFound: return None - def __iter__(self) -> Iterable[_T]: + def __iter__(self) -> Iterator[_T]: result = self._iter() try: - yield from result + yield from result # type: ignore except GeneratorExit: # issue #8710 - direct iteration is not re-usable after # an iterable block is broken, so close the result diff --git a/lib/sqlalchemy/sql/_elements_constructors.py b/lib/sqlalchemy/sql/_elements_constructors.py index d97ede8685..9b96322734 100644 --- a/lib/sqlalchemy/sql/_elements_constructors.py +++ b/lib/sqlalchemy/sql/_elements_constructors.py @@ -16,6 +16,7 @@ from typing import Optional from typing import overload from typing import Sequence from typing import Tuple as typing_Tuple +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union @@ -112,7 +113,10 @@ def all_(expr: _ColumnExpressionArgument[_T]) -> CollectionAggregate[bool]: return CollectionAggregate._create_all(expr) -def and_(*clauses: _ColumnExpressionArgument[bool]) -> ColumnElement[bool]: +def and_( # type: ignore[empty-body] + initial_clause: Union[Literal[True], _ColumnExpressionArgument[bool]], + *clauses: _ColumnExpressionArgument[bool], +) -> ColumnElement[bool]: r"""Produce a conjunction of expressions joined by ``AND``. E.g.:: @@ -150,13 +154,15 @@ def and_(*clauses: _ColumnExpressionArgument[bool]) -> ColumnElement[bool]: argument in order to be valid; a :func:`.and_` construct with no arguments is ambiguous. To produce an "empty" or dynamically generated :func:`.and_` expression, from a given list of expressions, - a "default" element of ``True`` should be specified:: + a "default" element of :func:`_sql.true` (or just ``True``) should be + specified:: - criteria = and_(True, *expressions) + from sqlalchemy import true + criteria = and_(true(), *expressions) The above expression will compile to SQL as the expression ``true`` or ``1 = 1``, depending on backend, if no other expressions are - present. If expressions are present, then the ``True`` value is + present. If expressions are present, then the :func:`_sql.true` value is ignored as it does not affect the outcome of an AND expression that has other elements. @@ -170,7 +176,13 @@ def and_(*clauses: _ColumnExpressionArgument[bool]) -> ColumnElement[bool]: :func:`.or_` """ - return BooleanClauseList.and_(*clauses) + ... + + +if not TYPE_CHECKING: + # handle deprecated case which allows zero-arguments + def and_(*clauses): # noqa: F811 + return BooleanClauseList.and_(*clauses) def any_(expr: _ColumnExpressionArgument[_T]) -> CollectionAggregate[bool]: @@ -1251,7 +1263,10 @@ def nulls_last(column: _ColumnExpressionArgument[_T]) -> UnaryExpression[_T]: return UnaryExpression._create_nulls_last(column) -def or_(*clauses: _ColumnExpressionArgument[bool]) -> ColumnElement[bool]: +def or_( # type: ignore[empty-body] + initial_clause: Union[Literal[False], _ColumnExpressionArgument[bool]], + *clauses: _ColumnExpressionArgument[bool], +) -> ColumnElement[bool]: """Produce a conjunction of expressions joined by ``OR``. E.g.:: @@ -1279,13 +1294,15 @@ def or_(*clauses: _ColumnExpressionArgument[bool]) -> ColumnElement[bool]: argument in order to be valid; a :func:`.or_` construct with no arguments is ambiguous. To produce an "empty" or dynamically generated :func:`.or_` expression, from a given list of expressions, - a "default" element of ``False`` should be specified:: + a "default" element of :func:`_sql.false` (or just ``False``) should be + specified:: - or_criteria = or_(False, *expressions) + from sqlalchemy import false + or_criteria = or_(false(), *expressions) The above expression will compile to SQL as the expression ``false`` or ``0 = 1``, depending on backend, if no other expressions are - present. If expressions are present, then the ``False`` value is + present. If expressions are present, then the :func:`_sql.false` value is ignored as it does not affect the outcome of an OR expression which has other elements. @@ -1299,7 +1316,13 @@ def or_(*clauses: _ColumnExpressionArgument[bool]) -> ColumnElement[bool]: :func:`.and_` """ - return BooleanClauseList.or_(*clauses) + ... + + +if not TYPE_CHECKING: + # handle deprecated case which allows zero-arguments + def or_(*clauses): # noqa: F811 + return BooleanClauseList.or_(*clauses) def over( diff --git a/lib/sqlalchemy/sql/coercions.py b/lib/sqlalchemy/sql/coercions.py index 9fe65b0cd6..3b187e49d4 100644 --- a/lib/sqlalchemy/sql/coercions.py +++ b/lib/sqlalchemy/sql/coercions.py @@ -311,7 +311,7 @@ def expect( @overload def expect( - role: Union[Type[roles.JoinTargetRole], Type[roles.OnClauseRole]], + role: Type[roles.JoinTargetRole], element: _JoinTargetProtocol, **kw: Any, ) -> _JoinTargetProtocol: diff --git a/lib/sqlalchemy/sql/elements.py b/lib/sqlalchemy/sql/elements.py index 6d19494253..981f719649 100644 --- a/lib/sqlalchemy/sql/elements.py +++ b/lib/sqlalchemy/sql/elements.py @@ -863,7 +863,7 @@ class SQLCoreOperations(Generic[_T], ColumnOperators, TypingOnly): def in_( self, other: Union[ - Sequence[Any], BindParameter[Any], roles.InElementRole + Iterable[Any], BindParameter[Any], roles.InElementRole ], ) -> BinaryExpression[bool]: ... @@ -871,7 +871,7 @@ class SQLCoreOperations(Generic[_T], ColumnOperators, TypingOnly): def not_in( self, other: Union[ - Sequence[Any], BindParameter[Any], roles.InElementRole + Iterable[Any], BindParameter[Any], roles.InElementRole ], ) -> BinaryExpression[bool]: ... @@ -2944,16 +2944,39 @@ class BooleanClauseList(ExpressionClauseList[bool]): operator: OperatorType, continue_on: Any, skip_on: Any, - *clauses: _ColumnExpressionArgument[Any], + initial_clause: Any = _NoArg.NO_ARG, + *clauses: Any, **kw: Any, ) -> ColumnElement[Any]: + + if initial_clause is _NoArg.NO_ARG: + # no elements period. deprecated use case. return an empty + # ClauseList construct that generates nothing unless it has + # elements added to it. + name = operator.__name__ + + util.warn_deprecated( + f"Invoking {name}() without arguments is deprecated, and " + f"will be disallowed in a future release. For an empty " + f"""{name}() construct, use '{name}({ + 'true()' if continue_on is True_._singleton else 'false()' + }, *args)' """ + f"""or '{name}({ + 'True' if continue_on is True_._singleton else 'False' + }, *args)'.""", + version="1.4", + ) + return cls._construct_raw(operator) # type: ignore[no-any-return] + lcc, convert_clauses = cls._process_clauses_for_boolean( operator, continue_on, skip_on, [ coercions.expect(roles.WhereHavingRole, clause) - for clause in util.coerce_generator_arg(clauses) + for clause in util.coerce_generator_arg( + (initial_clause,) + clauses + ) ], ) @@ -2969,27 +2992,11 @@ class BooleanClauseList(ExpressionClauseList[bool]): ) return cls._construct_raw(operator, flattened_clauses) # type: ignore # noqa: E501 - elif lcc == 1: + else: + assert lcc # just one element. return it as a single boolean element, # not a list and discard the operator. return convert_clauses[0] # type: ignore[no-any-return] # noqa: E501 - else: - # no elements period. deprecated use case. return an empty - # ClauseList construct that generates nothing unless it has - # elements added to it. - util.warn_deprecated( - "Invoking %(name)s() without arguments is deprecated, and " - "will be disallowed in a future release. For an empty " - "%(name)s() construct, use %(name)s(%(continue_on)s, *args)." - % { - "name": operator.__name__, - "continue_on": "True" - if continue_on is True_._singleton - else "False", - }, - version="1.4", - ) - return cls._construct_raw(operator) # type: ignore[no-any-return] # noqa: E501 @classmethod def _construct_for_whereclause( @@ -3035,26 +3042,42 @@ class BooleanClauseList(ExpressionClauseList[bool]): @classmethod def and_( - cls, *clauses: _ColumnExpressionArgument[bool] + cls, + initial_clause: Union[ + Literal[True], _ColumnExpressionArgument[bool], _NoArg + ] = _NoArg.NO_ARG, + *clauses: _ColumnExpressionArgument[bool], ) -> ColumnElement[bool]: r"""Produce a conjunction of expressions joined by ``AND``. See :func:`_sql.and_` for full documentation. """ return cls._construct( - operators.and_, True_._singleton, False_._singleton, *clauses + operators.and_, + True_._singleton, + False_._singleton, + initial_clause, + *clauses, ) @classmethod def or_( - cls, *clauses: _ColumnExpressionArgument[bool] + cls, + initial_clause: Union[ + Literal[False], _ColumnExpressionArgument[bool], _NoArg + ] = _NoArg.NO_ARG, + *clauses: _ColumnExpressionArgument[bool], ) -> ColumnElement[bool]: """Produce a conjunction of expressions joined by ``OR``. See :func:`_sql.or_` for full documentation. """ return cls._construct( - operators.or_, False_._singleton, True_._singleton, *clauses + operators.or_, + False_._singleton, + True_._singleton, + initial_clause, + *clauses, ) @property diff --git a/test/ext/mypy/plain_files/session.py b/test/ext/mypy/plain_files/session.py index 49f1b44cb4..636e3854a5 100644 --- a/test/ext/mypy/plain_files/session.py +++ b/test/ext/mypy/plain_files/session.py @@ -77,4 +77,16 @@ with Session(e) as sess: ) sess.query(User).update({"name": User.name + " some name"}) + # test #9125 + + for row in sess.query(User.id, User.name): + + # EXPECTED_TYPE: Row[Tuple[int, str]] + reveal_type(row) + + for uobj1 in sess.query(User): + + # EXPECTED_TYPE: User + reveal_type(uobj1) + # more result tests in typed_results.py diff --git a/test/ext/mypy/plain_files/sql_operations.py b/test/ext/mypy/plain_files/sql_operations.py index 0ed0df661d..33db5f2ccf 100644 --- a/test/ext/mypy/plain_files/sql_operations.py +++ b/test/ext/mypy/plain_files/sql_operations.py @@ -1,11 +1,15 @@ import typing +from sqlalchemy import and_ from sqlalchemy import Boolean from sqlalchemy import column +from sqlalchemy import false from sqlalchemy import func from sqlalchemy import Integer +from sqlalchemy import or_ from sqlalchemy import select from sqlalchemy import String +from sqlalchemy import true # builtin.pyi stubs define object.__eq__() as returning bool, which @@ -21,6 +25,28 @@ c2 = column("a", Integer) expr2 = c2.in_([1, 2, 3]) +expr2_set = c2.in_({1, 2, 3}) + +expr2_gen = c2.in_((x for x in (1, 2, 3))) + +nexpr2 = c2.not_in([1, 2, 3]) + +nexpr2_set = c2.not_in({1, 2, 3}) + +nexpr2_gen = c2.not_in((x for x in (1, 2, 3))) + +short_cir1 = and_(True, c2 == 5) +short_cir2 = or_(False, c2 == 5) + +short_cir3 = and_(true(), c2 == 5) +short_cir4 = or_(false(), c2 == 5) + +# EXPECTED_MYPY: Missing positional argument "initial_clause" in call to "and_" +no_empty_1 = and_() + +# EXPECTED_MYPY: Missing positional argument "initial_clause" in call to "or_" +no_empty_2 = or_() + expr3 = c2 / 5 expr4 = -c2 diff --git a/test/sql/test_deprecations.py b/test/sql/test_deprecations.py index fdfb87f724..65f6e77768 100644 --- a/test/sql/test_deprecations.py +++ b/test/sql/test_deprecations.py @@ -85,7 +85,8 @@ class DeprecationWarningsTest(fixtures.TestBase, AssertsCompiledSQL): with testing.expect_deprecated( r"Invoking and_\(\) without arguments is deprecated, and " r"will be disallowed in a future release. For an empty " - r"and_\(\) construct, use and_\(True, \*args\)" + r"and_\(\) construct, use 'and_\(true\(\), \*args\)' or " + r"'and_\(True, \*args\)'" ): self.assert_compile(or_(and_()), "") diff --git a/test/sql/test_operators.py b/test/sql/test_operators.py index 103520f1fa..d93ba61bac 100644 --- a/test/sql/test_operators.py +++ b/test/sql/test_operators.py @@ -2,6 +2,7 @@ import collections.abc as collections_abc import datetime import operator import pickle +import re from sqlalchemy import and_ from sqlalchemy import between @@ -1401,20 +1402,34 @@ class ConjunctionTest(fixtures.TestBase, testing.AssertsCompiledSQL): dialect=default.DefaultDialect(supports_native_boolean=False), ) - @combinations((and_, "and_", "True"), (or_, "or_", "False")) - def test_empty_clauses(self, op, str_op, str_continue): + @combinations( + (and_, "and_", r"true", "True"), + (or_, "or_", r"false", "False"), + ) + def test_empty_clauses(self, op, str_op, str_continue, str_continue_2): # these warning classes will change to ArgumentError when the # deprecated behavior is disabled with expect_deprecated( - r"Invoking %(str_op)s\(\) without arguments is deprecated, and " - r"will be disallowed in a future release. For an empty " - r"%(str_op)s\(\) construct, use " - r"%(str_op)s\(%(str_continue)s, \*args\)\." - % {"str_op": str_op, "str_continue": str_continue} + re.escape( + f"Invoking {str_op}() without arguments is deprecated, and " + "will be disallowed in a future release. For an empty " + f"{str_op}() construct, use " + f"'{str_op}({str_continue}(), *args)' or " + f"'{str_op}({str_continue_2}, *args)'." + ) ): op() + def test_empty_construct_for_whereclause(self): + eq_(BooleanClauseList._construct_for_whereclause(()), None) + + def test_non_empty_construct_for_whereclause(self): + self.assert_compile( + BooleanClauseList._construct_for_whereclause([column("q") == 5]), + "q = :q_1", + ) + def test_empty_and_raw(self): self.assert_compile( BooleanClauseList._construct_raw(operators.and_), ""