From 9bd09b0e6e8257a1fa741b8e6cf9842c19d232e7 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Wed, 18 Mar 2026 16:30:33 +0100 Subject: [PATCH 01/23] WIP: Update to dev version of validataclass 0.12.0 We use a manually built dev version here so that we can already test and work on the next release. This should not be merged to main until validataclass 0.12.0 has been released and the requirements have been updated. --- setup.cfg | 6 ++++-- tox.ini | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 642db67..1e31b0a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,8 +30,10 @@ packages = find: python_requires = ~=3.10 install_requires = typing-extensions ~= 4.15 - # Allow validataclass 0.10.* and 0.11.* - validataclass >= 0.10.0, < 0.12.0 + # Allow validataclass 0.12.* + #validataclass >= 0.12.0, < 0.13.0 + # TODO: 0.12.0 is not released yet. For testing we need to build and install validataclass manually (see tox.ini). + validataclass == 0.12.0a1.dev1 sqlalchemy >= 1.4, < 2.1 [options.packages.find] diff --git a/tox.ini b/tox.ini index b8c56ea..92c6710 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,11 @@ per-file-ignores = extras = testing commands = python -m pytest --cov --cov-append {posargs} +# TODO: 0.12.0 is not released yet. For testing we need to build and install validataclass manually. +# TODO: (In the validataclass repo, run `make build`. Then copy the resulting .whl file to _tmp.) +deps = + ./_tmp/validataclass-0.12.0a1.dev1-py3-none-any.whl + [testenv:flake8] commands = flake8 src/ tests/ From 1f9e3576deb4ea2928e65dd2654341929f1618c9 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Wed, 18 Mar 2026 17:07:34 +0100 Subject: [PATCH 02/23] Remove obsolete "_name" argument from validataclass_field() calls --- .../search_queries/search_query_dataclass.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/validataclass_search_queries/search_queries/search_query_dataclass.py b/src/validataclass_search_queries/search_queries/search_query_dataclass.py index 00b5010..67a5948 100644 --- a/src/validataclass_search_queries/search_queries/search_query_dataclass.py +++ b/src/validataclass_search_queries/search_queries/search_query_dataclass.py @@ -109,12 +109,11 @@ def _prepare_search_query_dataclass(cls) -> None: if field_args.get('default', None) is None: field_args['default'] = Default(None) - # Create validataclass field (undocumented parameter _name is needed for required fields in Python < 3.10) + # Create validataclass field setattr(cls, name, validataclass_field( validator=field_args.get('validator'), default=field_args.get('default'), metadata={'search_param': field_args.get('search_param')}, - _name=name, )) From 551eeaa58ba72f5f13bcdb3b54b3558ac5976949 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 13:14:58 +0100 Subject: [PATCH 03/23] Compatibility changes for validataclass 0.12.0 --- .../search_queries/search_query_dataclass.py | 17 ++++++++--------- .../search_query_dataclass_test.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/validataclass_search_queries/search_queries/search_query_dataclass.py b/src/validataclass_search_queries/search_queries/search_query_dataclass.py index 67a5948..ffa392d 100644 --- a/src/validataclass_search_queries/search_queries/search_query_dataclass.py +++ b/src/validataclass_search_queries/search_queries/search_query_dataclass.py @@ -6,10 +6,11 @@ import dataclasses from collections.abc import Callable +from inspect import get_annotations from typing import Any, TypeVar, overload from typing_extensions import dataclass_transform -from validataclass.dataclasses import validataclass, validataclass_field, Default +from validataclass.dataclasses import validataclass, validataclass_field, BaseDefault, Default from validataclass.exceptions import DataclassValidatorFieldException from validataclass.validators import Validator @@ -76,8 +77,8 @@ def _prepare_search_query_dataclass(cls) -> None: # In case of a subclassed dataclass, get the already existing fields existing_fields = _get_existing_validator_fields(cls) - # Get class annotations - cls_annotations = cls.__dict__.get('__annotations__', {}) + # Get annotations of this class (ignores base classes) + cls_annotations = get_annotations(cls) # Prepare dataclass fields by checking for validators and setting metadata accordingly for name, field_type in cls_annotations.items(): @@ -102,8 +103,7 @@ def _prepare_search_query_dataclass(cls) -> None: # Ensure that a validator is set if not isinstance(field_args.get('validator', None), Validator): - # TODO: Update exception messages to be consistent with validataclass 0.12.0 - raise DataclassValidatorFieldException(f'Dataclass field "{name}" must specify a Validator.') + raise DataclassValidatorFieldException(f'Dataclass field "{name}" must specify a validator.') # For SearchParam fields, use Default(None) if no explicit default was set if field_args.get('default', None) is None: @@ -151,15 +151,14 @@ def _parse_validator_tuple(args: Any) -> dict: # Find validator, default object and search param in tuple and return them as a dictionary arg_dict = {} - # TODO: Update exception messages to be consistent with validataclass 0.12.0 for arg in args: if isinstance(arg, Validator): if 'validator' in arg_dict: - raise ValueError('Only one Validator can be specified.') + raise ValueError('Only one validator can be specified.') arg_dict['validator'] = arg - elif isinstance(arg, Default): + elif isinstance(arg, BaseDefault): if 'default' in arg_dict: - raise ValueError('Only one Default can be specified.') + raise ValueError('Only one default can be specified.') arg_dict['default'] = arg elif isinstance(arg, SearchParam): if 'search_param' in arg_dict: diff --git a/tests/unit/search_queries/search_query_dataclass_test.py b/tests/unit/search_queries/search_query_dataclass_test.py index 128998a..baaa6cc 100644 --- a/tests/unit/search_queries/search_query_dataclass_test.py +++ b/tests/unit/search_queries/search_query_dataclass_test.py @@ -331,35 +331,35 @@ def test_search_query_dataclass_with_invalid_values(): class InvalidSearchQueryDataclass: foo: int - assert str(exception_info.value) == 'Dataclass field "foo" must specify a Validator.' + assert str(exception_info.value) == 'Dataclass field "foo" must specify a validator.' @pytest.mark.parametrize( 'field_tuple, expected_exception_msg', [ # Missing validator - (None, 'Dataclass field "foo" must specify a Validator.'), - ((Default(3)), 'Dataclass field "foo" must specify a Validator.'), - ((SearchParamEquals(), Default(0)), 'Dataclass field "foo" must specify a Validator.'), + (None, 'Dataclass field "foo" must specify a validator.'), + ((Default(3)), 'Dataclass field "foo" must specify a validator.'), + ((SearchParamEquals(), Default(0)), 'Dataclass field "foo" must specify a validator.'), # Too many validators ( (IntegerValidator(), StringValidator()), - 'Dataclass field "foo": Only one Validator can be specified.', + 'Dataclass field "foo": Only one validator can be specified.', ), ( (SearchParamEquals(), IntegerValidator(), IntegerValidator()), - 'Dataclass field "foo": Only one Validator can be specified.', + 'Dataclass field "foo": Only one validator can be specified.', ), # Too many defaults ( (Default(1), IntegerValidator(), Default(2)), - 'Dataclass field "foo": Only one Default can be specified.', + 'Dataclass field "foo": Only one default can be specified.', ), ( (Default(1), SearchParamEquals(), IntegerValidator(), Default(2)), - 'Dataclass field "foo": Only one Default can be specified.', + 'Dataclass field "foo": Only one default can be specified.', ), # Too many SearchParams From 764f9ebe7d6de342bfa54a434ec39f9585c099ab Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 12 Mar 2026 19:52:51 +0100 Subject: [PATCH 04/23] Add support for Python 3.13 and 3.14 Note: Python 3.14 support requires validataclass 0.12 --- .github/workflows/tests.yml | 2 ++ Makefile | 16 +++++++++++++++- tox.ini | 4 ++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a837e7e..1b2299b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,6 +25,8 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' + - '3.14' sqlalchemy-version: - '1.4' - '2.0' diff --git a/Makefile b/Makefile index b1290ef..cd375e2 100644 --- a/Makefile +++ b/Makefile @@ -65,9 +65,19 @@ _docker-tox: docker-tox: _docker-tox # Run partial tox test suites in Docker -.PHONY: docker-test-py312-sqlalchemy1.4 docker-test-py312-sqlalchemy2.0 \ +.PHONY: docker-test-py314-sqlalchemy1.4 docker-test-py314-sqlalchemy2.0 \ + docker-test-py313-sqlalchemy1.4 docker-test-py313-sqlalchemy2.0 \ + docker-test-py312-sqlalchemy1.4 docker-test-py312-sqlalchemy2.0 \ docker-test-py311-sqlalchemy1.4 docker-test-py311-sqlalchemy2.0 \ docker-test-py310-sqlalchemy1.4 docker-test-py310-sqlalchemy2.0 +docker-test-py314-sqlalchemy1.4: TOX_ARGS="-e clean,py314-sqlalchemy1.4,py312-report" +docker-test-py314-sqlalchemy1.4: _docker-tox +docker-test-py314-sqlalchemy2.0: TOX_ARGS="-e clean,py314-sqlalchemy2.0,py312-report" +docker-test-py314-sqlalchemy2.0: _docker-tox +docker-test-py313-sqlalchemy1.4: TOX_ARGS="-e clean,py313-sqlalchemy1.4,py312-report" +docker-test-py313-sqlalchemy1.4: _docker-tox +docker-test-py313-sqlalchemy2.0: TOX_ARGS="-e clean,py313-sqlalchemy2.0,py312-report" +docker-test-py313-sqlalchemy2.0: _docker-tox docker-test-py312-sqlalchemy1.4: TOX_ARGS="-e clean,py312-sqlalchemy1.4,py312-report" docker-test-py312-sqlalchemy1.4: _docker-tox docker-test-py312-sqlalchemy2.0: TOX_ARGS="-e clean,py312-sqlalchemy2.0,py312-report" @@ -90,6 +100,10 @@ docker-test-all: make docker-test-py311-sqlalchemy2.0 make docker-test-py312-sqlalchemy1.4 make docker-test-py312-sqlalchemy2.0 + make docker-test-py313-sqlalchemy1.4 + make docker-test-py313-sqlalchemy2.0 + make docker-test-py314-sqlalchemy1.4 + make docker-test-py314-sqlalchemy2.0 # Pull the latest image of the multi-python Docker image .PHONY: docker-pull diff --git a/tox.ini b/tox.ini index 92c6710..cd0bd7f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 4.5.1 -envlist = clean,py{310,311,312}-sqlalchemy{1.4,2.0},report,flake8 +envlist = clean,py{310,311,312,313,314}-sqlalchemy{1.4,2.0},report,flake8 skip_missing_interpreters = true [flake8] @@ -26,7 +26,7 @@ commands = flake8 src/ tests/ [testenv:clean] commands = coverage erase -[testenv:report,py{310,311,312}-report] +[testenv:report,py{310,311,312,313,314}-report] commands = coverage html coverage xml From d611ffde6eb5d1ca8cbaf873d4f7e4dc30995282 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Wed, 18 Mar 2026 16:52:44 +0100 Subject: [PATCH 05/23] Use absolute imports (unless same package) --- .../pagination/cursor_pagination_mixin.py | 2 +- .../pagination/offset_pagination_mixin.py | 2 +- .../pagination/response_helpers.py | 2 +- .../repositories/search_query_repository_mixin.py | 8 ++++---- .../search_queries/base_search_query.py | 2 +- .../search_queries/search_query_dataclass.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py index 55db410..d4ce2c0 100644 --- a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py @@ -11,10 +11,10 @@ from validataclass.dataclasses import validataclass, Default from validataclass.validators import IntegerValidator +from validataclass_search_queries import pagination, sorting from .abstract_pagination_mixin import AbstractPaginationMixin from .paginated_result import PaginatedResult from .pagination_limit_validator import PaginationLimitValidator -from .. import pagination, sorting __all__ = [ 'CursorPaginationMixin', diff --git a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py index f448e91..b53cce0 100644 --- a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py @@ -10,10 +10,10 @@ from validataclass.dataclasses import validataclass, Default from validataclass.validators import IntegerValidator +from validataclass_search_queries import pagination from .abstract_pagination_mixin import AbstractPaginationMixin from .paginated_result import PaginatedResult from .pagination_limit_validator import PaginationLimitValidator -from .. import pagination __all__ = [ 'OffsetPaginationMixin', diff --git a/src/validataclass_search_queries/pagination/response_helpers.py b/src/validataclass_search_queries/pagination/response_helpers.py index d30bcad..dbad243 100644 --- a/src/validataclass_search_queries/pagination/response_helpers.py +++ b/src/validataclass_search_queries/pagination/response_helpers.py @@ -6,9 +6,9 @@ from typing import Any +from validataclass_search_queries.search_queries import BaseSearchQuery from .abstract_pagination_mixin import AbstractPaginationMixin from .paginated_result import PaginatedResult -from ..search_queries import BaseSearchQuery __all__ = [ 'paginated_api_response', diff --git a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py index f1816f8..0768b38 100644 --- a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py +++ b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py @@ -9,10 +9,10 @@ from sqlalchemy.orm import Query -from ..filters import BoundSearchFilter -from ..pagination import AbstractPaginationMixin, PaginatedResult -from ..search_queries import BaseSearchQuery -from ..sorting import AbstractSortingMixin +from validataclass_search_queries.filters import BoundSearchFilter +from validataclass_search_queries.pagination import AbstractPaginationMixin, PaginatedResult +from validataclass_search_queries.search_queries import BaseSearchQuery +from validataclass_search_queries.sorting import AbstractSortingMixin __all__ = [ 'SearchQueryRepositoryMixin', diff --git a/src/validataclass_search_queries/search_queries/base_search_query.py b/src/validataclass_search_queries/search_queries/base_search_query.py index 67cbe0e..cb4e77d 100644 --- a/src/validataclass_search_queries/search_queries/base_search_query.py +++ b/src/validataclass_search_queries/search_queries/base_search_query.py @@ -11,7 +11,7 @@ from validataclass.helpers import UnsetValue -from ..filters import BoundSearchFilter +from validataclass_search_queries.filters import BoundSearchFilter __all__ = [ 'BaseSearchQuery', diff --git a/src/validataclass_search_queries/search_queries/search_query_dataclass.py b/src/validataclass_search_queries/search_queries/search_query_dataclass.py index ffa392d..f7bd6ef 100644 --- a/src/validataclass_search_queries/search_queries/search_query_dataclass.py +++ b/src/validataclass_search_queries/search_queries/search_query_dataclass.py @@ -14,7 +14,7 @@ from validataclass.exceptions import DataclassValidatorFieldException from validataclass.validators import Validator -from ..filters import SearchParam +from validataclass_search_queries.filters import SearchParam __all__ = [ 'search_query_dataclass', From 1adb79c7abb818591e6c6b1c6e32da6517c36bfc Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Wed, 18 Mar 2026 16:55:38 +0100 Subject: [PATCH 06/23] Don't export type vars to discourage reusing them --- src/validataclass_search_queries/pagination/paginated_result.py | 2 -- .../repositories/search_query_repository_mixin.py | 1 - 2 files changed, 3 deletions(-) diff --git a/src/validataclass_search_queries/pagination/paginated_result.py b/src/validataclass_search_queries/pagination/paginated_result.py index 03e2923..89a2322 100644 --- a/src/validataclass_search_queries/pagination/paginated_result.py +++ b/src/validataclass_search_queries/pagination/paginated_result.py @@ -9,8 +9,6 @@ __all__ = [ 'PaginatedResult', - 'T_Result', - 'T_MappedResult', ] T_Result = TypeVar('T_Result') diff --git a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py index 0768b38..c7fb800 100644 --- a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py +++ b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py @@ -16,7 +16,6 @@ __all__ = [ 'SearchQueryRepositoryMixin', - 'T_Model', ] T_Model = TypeVar('T_Model') From 3cee79d66db7c3ff2ca91631a353384764dcd556 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 14:13:21 +0100 Subject: [PATCH 07/23] Add mypy to dev toolchain --- Makefile | 5 +++++ pyproject.toml | 39 +++++++++++++++++++++++++++++++++++++++ setup.cfg | 1 + tox.ini | 9 ++++++++- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index cd375e2..f449ac6 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,11 @@ test: flake8: tox run -e flake8 +# Only run mypy (via tox; you can also just run "mypy" directly) +.PHONY: mypy +mypy: + tox run -e mypy + # Open HTML coverage report in browser .PHONY: open-coverage open-coverage: diff --git a/pyproject.toml b/pyproject.toml index 9097f7d..ecfd1f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,42 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "src/validataclass_search_queries/_version.py" version_scheme = "post-release" + +[tool.mypy] +files = ["src/", "tests/"] +mypy_path = "src/" +explicit_package_bases = true + +# Enable strict type checking +# TODO: Enable strict mode! +#strict = true + +# TODO: This is included in strict and can be removed when enabling strict mode. +check_untyped_defs = true + +# Enable further checks that are not included in strict mode +# TODO: Enable more checks! +#disallow_any_unimported = true +#strict_equality_for_none = true +#warn_unreachable = true +#enable_error_code = [ +# "deprecated", +# "explicit-override", +# "ignore-without-code", +# "mutable-override", +# "possibly-undefined", +# "redundant-expr", +# "redundant-self", +# "truthy-bool", +# "truthy-iterable", +# "unused-awaitable", +#] + +# TODO: Remove this after enabling the validataclass mypy plugin. +disable_error_code = "assignment" + +[[tool.mypy.overrides]] +module = 'tests.*' + +# Don't enforce typed definitions in tests, this is a lot of unnecessary work (most parameters would be Any anyway). +allow_untyped_defs = true diff --git a/setup.cfg b/setup.cfg index 1e31b0a..baf3d06 100644 --- a/setup.cfg +++ b/setup.cfg @@ -45,3 +45,4 @@ testing = pytest-cov ~= 7.0 coverage ~= 7.13 flake8 ~= 7.3 + mypy ~= 1.19 diff --git a/tox.ini b/tox.ini index cd0bd7f..cd3e6e4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] minversion = 4.5.1 -envlist = clean,py{310,311,312,313,314}-sqlalchemy{1.4,2.0},report,flake8 +envlist = clean,py{310,311,312,313,314}-sqlalchemy{1.4,2.0},report,flake8,mypy skip_missing_interpreters = true [flake8] @@ -23,6 +23,13 @@ deps = [testenv:flake8] commands = flake8 src/ tests/ +[testenv:mypy,py{310,311,312,313,314}-mypy] +commands = mypy {posargs} + +[testenv:mypy-debug] +# Use no-incremental to disable mypy caching when developing the mypy plugin +commands = mypy --show-traceback --no-incremental {posargs} + [testenv:clean] commands = coverage erase From 87b3d3585e206c835c1b9e4813d6700fa2e34e2e Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 14:15:06 +0100 Subject: [PATCH 08/23] Refactor search_query_dataclass decorator for better typing --- .../search_queries/search_query_dataclass.py | 86 ++++++++++++------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/src/validataclass_search_queries/search_queries/search_query_dataclass.py b/src/validataclass_search_queries/search_queries/search_query_dataclass.py index f7bd6ef..2debd2f 100644 --- a/src/validataclass_search_queries/search_queries/search_query_dataclass.py +++ b/src/validataclass_search_queries/search_queries/search_query_dataclass.py @@ -20,6 +20,14 @@ 'search_query_dataclass', ] + +@dataclasses.dataclass +class _ValidatorField: + validator: Validator[Any] | None = None + default: BaseDefault[Any] | None = None + search_param: SearchParam | None = None + + _T = TypeVar('_T') @@ -32,7 +40,7 @@ def search_query_dataclass(cls: type[_T]) -> type[_T]: @overload -def search_query_dataclass(cls: None = None, /, **kwargs) -> Callable[[type[_T]], type[_T]]: +def search_query_dataclass(cls: None = None, /, **kwargs: Any) -> Callable[[type[_T]], type[_T]]: ... @@ -63,7 +71,10 @@ def search_query_dataclass( """ def decorator(_cls: type[_T]) -> type[_T]: + # Transform class to be a valid validataclass _prepare_search_query_dataclass(_cls) + + # Use @validataclass decorator to transform class into a validataclass return validataclass(_cls, **kwargs) # Allow decorator to be called with and without parenthesis @@ -89,37 +100,51 @@ def _prepare_search_query_dataclass(cls) -> None: continue # Get current validator etc. if the field is already existing - field_args = existing_fields.get(name, {}) + existing_field = existing_fields.get(name, _ValidatorField()) - # Overwrite existing field arguments with validator etc. from tuple + # Parse field tuple try: - field_args.update(_parse_validator_tuple(value)) + parsed_field = _parse_validator_tuple(value) except Exception as e: raise DataclassValidatorFieldException(f'Dataclass field "{name}": {e}') + # Overwrite existing field arguments with validator etc. from tuple + field = _ValidatorField( + validator=parsed_field.validator or existing_field.validator, + default=parsed_field.default or existing_field.default, + search_param=parsed_field.search_param or existing_field.search_param, + ) + # Ignore all fields without a SearchParam (they will be handled by @validataclass as usual validataclass fields) - if 'search_param' not in field_args.keys(): + if field.search_param is None: continue # Ensure that a validator is set - if not isinstance(field_args.get('validator', None), Validator): + if not isinstance(field.validator, Validator): raise DataclassValidatorFieldException(f'Dataclass field "{name}" must specify a validator.') # For SearchParam fields, use Default(None) if no explicit default was set - if field_args.get('default', None) is None: - field_args['default'] = Default(None) + if field.default is None: + field.default = Default(None) # Create validataclass field setattr(cls, name, validataclass_field( - validator=field_args.get('validator'), - default=field_args.get('default'), - metadata={'search_param': field_args.get('search_param')}, + validator=field.validator, + default=field.default, + metadata={'search_param': field.search_param}, )) -def _get_existing_validator_fields(cls) -> dict[str, dict[str, Any]]: +def _get_existing_validator_fields(cls) -> dict[str, _ValidatorField]: """ - Internal helper function used by @search_query_dataclass to get all pre-existing validataclass fields from the base classes. + Returns a dictionary containing all fields (as `_ValidatorField` objects) of an existing validataclass that have a + validator set in their metadata, or an empty dictionary if the class is not a dataclass (yet). + + Existing dataclass fields are determined by looking at all direct parent classes that are dataclasses themselves. + If two unrelated base classes define a field with the same name, the most-left class takes precedence (for example, + in `class C(B, A)`, the definitions of B take precendence over A). + + (Internal helper function.) """ existing_fields = {} @@ -128,43 +153,46 @@ def _get_existing_validator_fields(cls) -> dict[str, dict[str, Any]]: continue for field in dataclasses.fields(base_cls): - existing_fields[field.name] = { - 'validator': field.metadata.get('validator', None), - 'default': field.metadata.get('validator_default', None), - 'search_param': field.metadata.get('search_param', None), - } + existing_fields[field.name] = _ValidatorField( + validator=field.metadata.get('validator', None), + default=field.metadata.get('validator_default', None), + search_param=field.metadata.get('search_param', None), + ) return existing_fields -def _parse_validator_tuple(args: Any) -> dict: +def _parse_validator_tuple(args: Any) -> _ValidatorField: """ - Internal helper function used by @search_query_dataclass to parse validataclass-style field tuples to dictionaries. + Parses field arguments (the value of a field in a dataclass that has not been parsed by `@dataclass` yet) to a + `_ValidatorField` object. + + (Internal helper function.) """ if args is None: - return {} + return _ValidatorField() # Ensure args is a tuple if not isinstance(args, tuple): args = (args,) # Find validator, default object and search param in tuple and return them as a dictionary - arg_dict = {} + field = _ValidatorField() for arg in args: if isinstance(arg, Validator): - if 'validator' in arg_dict: + if field.validator is not None: raise ValueError('Only one validator can be specified.') - arg_dict['validator'] = arg + field.validator = arg elif isinstance(arg, BaseDefault): - if 'default' in arg_dict: + if field.default is not None: raise ValueError('Only one default can be specified.') - arg_dict['default'] = arg + field.default = arg elif isinstance(arg, SearchParam): - if 'search_param' in arg_dict: + if field.search_param is not None: raise ValueError('Only one SearchParam can be specified.') - arg_dict['search_param'] = arg + field.search_param = arg else: raise TypeError('Unexpected type of argument: ' + type(arg).__name__) - return arg_dict + return field From eb13e4d39aa15a4d94531a111edb0e79109e0d61 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 14:51:52 +0100 Subject: [PATCH 09/23] Fix typing of multi-select validators --- .../validators/multi_select_any_of_validator.py | 8 +++++--- .../validators/multi_select_enum_validator.py | 2 +- .../validators/multi_select_validator.py | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/validataclass_search_queries/validators/multi_select_any_of_validator.py b/src/validataclass_search_queries/validators/multi_select_any_of_validator.py index 1ea57af..d08ec93 100644 --- a/src/validataclass_search_queries/validators/multi_select_any_of_validator.py +++ b/src/validataclass_search_queries/validators/multi_select_any_of_validator.py @@ -5,7 +5,7 @@ """ from collections.abc import Iterable -from typing import Any +from typing import TypeVar from validataclass.validators import AnyOfValidator @@ -15,8 +15,10 @@ 'MultiSelectAnyOfValidator', ] +T_AnyOfValues = TypeVar('T_AnyOfValues') -class MultiSelectAnyOfValidator(MultiSelectValidator): + +class MultiSelectAnyOfValidator(MultiSelectValidator[T_AnyOfValues]): """ Validator for multi-select search parameters that only allows a specified set of values. @@ -26,7 +28,7 @@ class MultiSelectAnyOfValidator(MultiSelectValidator): def __init__( self, # AnyOfValidator settings - allowed_values: Iterable[Any], + allowed_values: Iterable[T_AnyOfValues], # TODO: case_insensitive is deprecated in validataclass and must be removed in a future version. case_sensitive: bool | None = None, case_insensitive: bool | None = None, diff --git a/src/validataclass_search_queries/validators/multi_select_enum_validator.py b/src/validataclass_search_queries/validators/multi_select_enum_validator.py index 689de06..98ac81a 100644 --- a/src/validataclass_search_queries/validators/multi_select_enum_validator.py +++ b/src/validataclass_search_queries/validators/multi_select_enum_validator.py @@ -29,7 +29,7 @@ class MultiSelectEnumValidator(MultiSelectValidator[T_Enum]): def __init__( self, # EnumValidator settings - enum_cls: type[Enum], + enum_cls: type[T_Enum], *, allowed_values: Iterable[Any] | None = None, # TODO: case_insensitive is deprecated in validataclass and must be removed in a future version. diff --git a/src/validataclass_search_queries/validators/multi_select_validator.py b/src/validataclass_search_queries/validators/multi_select_validator.py index 7467e53..9e4c33f 100644 --- a/src/validataclass_search_queries/validators/multi_select_validator.py +++ b/src/validataclass_search_queries/validators/multi_select_validator.py @@ -6,6 +6,7 @@ from typing import Any, TypeVar +from typing_extensions import override from validataclass.validators import ListValidator, Validator __all__ = [ @@ -43,7 +44,7 @@ class MultiSelectValidator(ListValidator[T_ListItem]): def __init__( self, - item_validator: Validator, + item_validator: Validator[T_ListItem], *, delimiter: str = ',', max_length: int | None = None, @@ -64,6 +65,7 @@ def __init__( ) self.delimiter = delimiter + @override def validate(self, input_data: Any, **kwargs) -> list[T_ListItem]: """ Validate input data as string. Returns a validated list. From bb1bb5d3fa41c3ca35c8ab9253d7715321e1c51f Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 15:39:15 +0100 Subject: [PATCH 10/23] Fix typing of PaginationLimitValidator --- .../pagination/pagination_limit_validator.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/validataclass_search_queries/pagination/pagination_limit_validator.py b/src/validataclass_search_queries/pagination/pagination_limit_validator.py index b14f10a..2c07ea8 100644 --- a/src/validataclass_search_queries/pagination/pagination_limit_validator.py +++ b/src/validataclass_search_queries/pagination/pagination_limit_validator.py @@ -6,8 +6,9 @@ from typing import Any +from typing_extensions import override from validataclass.exceptions import ValidationError -from validataclass.validators import IntegerValidator +from validataclass.validators import IntegerValidator, Validator __all__ = [ 'PaginationLimitValidator', @@ -15,7 +16,7 @@ ] -class PaginationLimitValidator(IntegerValidator): +class PaginationLimitValidator(Validator[int | None]): """ Validator for the pagination limit, based on an IntegerValidator. @@ -44,6 +45,9 @@ class PaginationLimitValidator(IntegerValidator): ``` """ + # Base validator for integer validation + integer_validator: IntegerValidator + # If true, pagination is optional for the user (set limit=0 to disable pagination) optional: bool @@ -63,14 +67,16 @@ def __init__( optional: Boolean, whether pagination is optional, i.e. the user can set limit=0 to disable pagination (default: False) max_value: Integer or None, maximum value for pagination limit (default: IntegerValidator.DEFAULT_MAX_VALUE = 2147483647) """ - super().__init__( - min_value=0, # if optional else 1, + # Initialize base integer validator + self.integer_validator = IntegerValidator( + min_value=0, max_value=max_value, allow_strings=True, ) self.optional = optional - def validate(self, input_data: Any, **kwargs) -> int | None: + @override + def validate(self, input_data: Any, **kwargs: Any) -> int | None: """ Validates the input as an integer. Returns the integer or None if the input is 0 or None. """ @@ -79,7 +85,7 @@ def validate(self, input_data: Any, **kwargs) -> int | None: input_data = 0 # Validate input as integer - validated_input = super().validate(input_data, **kwargs) + validated_input = self.integer_validator.validate(input_data, **kwargs) # If pagination is optional, treat 0 as "no limit" (i.e. no pagination) if validated_input == 0: From 54259780902f1885f070ad74837e86322227dc37 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 15:41:02 +0100 Subject: [PATCH 11/23] Fix several typing issues --- .../search_params/base_search_param.py | 5 ++-- .../pagination/cursor_pagination_mixin.py | 27 +++++++++++++------ .../pagination/paginated_result.py | 7 +++-- .../search_queries/base_search_query.py | 1 + tests/unit/sorting/sorting_mixin_test.py | 5 ++-- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/validataclass_search_queries/filters/search_params/base_search_param.py b/src/validataclass_search_queries/filters/search_params/base_search_param.py index 879ac83..343845a 100644 --- a/src/validataclass_search_queries/filters/search_params/base_search_param.py +++ b/src/validataclass_search_queries/filters/search_params/base_search_param.py @@ -52,9 +52,8 @@ class MySearchQuery(BaseSearchQuery): def __init__(self, column_name: str | None = None): self.column_name = column_name - @staticmethod # pragma: nocover - @abstractmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @abstractmethod # pragma: nocover + def sqlalchemy_filter(self, column: ColumnElement, value: Any) -> ColumnElement: """ This abstract method defines the SQLAlchemy filter expression. See existing implementations for examples. """ diff --git a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py index d4ce2c0..f581c06 100644 --- a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py @@ -95,9 +95,13 @@ class ExampleSearchQuery(CursorPaginationMixin, BaseSearchQuery): def __init_subclass__(cls, **kwargs): # Pagination mixins are not compatible with each other, only one can be used at the same time if issubclass(cls, pagination.OffsetPaginationMixin): - raise TypeError(f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed') + raise TypeError( + f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed' + ) if issubclass(cls, sorting.SortingMixin): - raise TypeError(f'Invalid base classes in {cls}: CursorPaginationMixin cannot be combined with SortingMixin') + raise TypeError( + f'Invalid base classes in {cls}: CursorPaginationMixin cannot be combined with SortingMixin' + ) super().__init_subclass__(**kwargs) @@ -142,9 +146,11 @@ def apply_pagination_to_query(self, query: Query, model_cls: Any) -> Query: key_column = self.get_cursor_column(model_cls) # Cursor pagination requires the data to be ordered by the cursor column - return query.order_by(key_column) \ - .filter(key_column >= self.start) \ + return ( + query.order_by(key_column) + .filter(key_column >= self.start) .limit(self.limit) + ) def get_start_parameter_name(self) -> str: """ @@ -170,11 +176,16 @@ def get_next_start_value(self, paginated_result: PaginatedResult) -> int | None: # Get last result in list last_item = paginated_result[-1] - # Get cursor value (e.g. ID) of last result, allowing both objects and dictionaries, and increment by one + # Get cursor value (e.g. ID) of last result, allowing both objects and dictionaries cursor_key = self.get_cursor_column_name() if isinstance(last_item, dict): - return last_item.get(cursor_key) + 1 + last_item_value = last_item.get(cursor_key) elif hasattr(last_item, cursor_key): - return getattr(last_item, cursor_key) + 1 + last_item_value = getattr(last_item, cursor_key) else: - raise Exception(f'Last item of PaginatedResult has neither attribute nor dictionary key "{cursor_key}": {last_item}') + raise Exception( + f'Last item of PaginatedResult has neither attribute nor dictionary key "{cursor_key}": {last_item}' + ) + + # Return last cursor value incremented by one + return last_item_value + 1 if isinstance(last_item_value, int) else None diff --git a/src/validataclass_search_queries/pagination/paginated_result.py b/src/validataclass_search_queries/pagination/paginated_result.py index 89a2322..3b73141 100644 --- a/src/validataclass_search_queries/pagination/paginated_result.py +++ b/src/validataclass_search_queries/pagination/paginated_result.py @@ -62,8 +62,11 @@ def map_customers(customer: Customer) -> dict: mapped_customers = paginated_result.map(Customers.to_dict) ``` """ - # We use self.__class__() instead of PaginatedResult() to properly support subclassing - return self.__class__( + # Previously, we used self.__class__() instead of PaginatedResult() here to properly support subclassing. + # However, we don't know whether this potential subclass is a Generic too, so it might not even support + # T_MappedResult. In other words, `self.__class__` can only be a subtype of `PaginatedResult[T_Result]`, + # not of `PaginatedResult[T_MappedResult]`. + return PaginatedResult( map(map_func, self), total_count=self.total_count, ) diff --git a/src/validataclass_search_queries/search_queries/base_search_query.py b/src/validataclass_search_queries/search_queries/base_search_query.py index cb4e77d..f475aba 100644 --- a/src/validataclass_search_queries/search_queries/base_search_query.py +++ b/src/validataclass_search_queries/search_queries/base_search_query.py @@ -18,6 +18,7 @@ ] +@dataclasses.dataclass class BaseSearchQuery: """ Base class for search query validataclasses, which can be used to validate search parameters (e.g. GET query diff --git a/tests/unit/sorting/sorting_mixin_test.py b/tests/unit/sorting/sorting_mixin_test.py index 1898063..bd9c285 100644 --- a/tests/unit/sorting/sorting_mixin_test.py +++ b/tests/unit/sorting/sorting_mixin_test.py @@ -7,6 +7,7 @@ import pytest import sqlalchemy from sqlalchemy.sql import ColumnElement +from sqlalchemy.sql.elements import ColumnClause from validataclass.dataclasses import validataclass, Default from validataclass.exceptions import DictFieldsValidationError from validataclass.validators import DataclassValidator, AnyOfValidator @@ -16,8 +17,8 @@ class MockModelCls: """ This class is used as a mock for a database model class. """ - id = sqlalchemy.column('id') - unit_test_field = sqlalchemy.column('unit_test_field') + id: ColumnClause[int] = sqlalchemy.column('id') + unit_test_field: ColumnClause[str] = sqlalchemy.column('unit_test_field') def test_sorting_mixin_get_sorting_column(): From 78915b7be459fea1b0519921732e188128ebc987 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 16:50:16 +0100 Subject: [PATCH 12/23] Enable and configure validataclass mypy plugin --- pyproject.toml | 23 ++++++++++++++++--- .../search_query_dataclass_test.py | 4 ++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ecfd1f6..8044226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,11 @@ files = ["src/", "tests/"] mypy_path = "src/" explicit_package_bases = true +# Enable validataclass mypy plugin +plugins = [ + "validataclass.mypy.plugin", +] + # Enable strict type checking # TODO: Enable strict mode! #strict = true @@ -40,11 +45,23 @@ check_untyped_defs = true # "unused-awaitable", #] -# TODO: Remove this after enabling the validataclass mypy plugin. -disable_error_code = "assignment" - [[tool.mypy.overrides]] module = 'tests.*' # Don't enforce typed definitions in tests, this is a lot of unnecessary work (most parameters would be Any anyway). allow_untyped_defs = true + +[tool.validataclass_mypy] +# Allow incompatible overrides for fields in validataclass sub classes (this is the default, but the default might be +# changed in the future). +allow_incompatible_field_overrides = true + +# Declare @search_query_dataclass as a decorator that creates validataclasses +custom_validataclass_decorators = [ + "validataclass_search_queries.search_queries.search_query_dataclass.search_query_dataclass", +] + +# Ignore SearchParam objects in validataclass field definitions +ignore_custom_types_in_fields = [ + "validataclass_search_queries.filters.search_params.base_search_param.SearchParam", +] diff --git a/tests/unit/search_queries/search_query_dataclass_test.py b/tests/unit/search_queries/search_query_dataclass_test.py index baaa6cc..e6cc16c 100644 --- a/tests/unit/search_queries/search_query_dataclass_test.py +++ b/tests/unit/search_queries/search_query_dataclass_test.py @@ -329,7 +329,7 @@ def test_search_query_dataclass_with_invalid_values(): with pytest.raises(DataclassValidatorFieldException) as exception_info: @search_query_dataclass class InvalidSearchQueryDataclass: - foo: int + foo: int # type: ignore[validataclass] assert str(exception_info.value) == 'Dataclass field "foo" must specify a validator.' @@ -378,6 +378,6 @@ def test_search_query_dataclass_with_invalid_field_tuples(field_tuple, expected_ with pytest.raises(DataclassValidatorFieldException) as exception_info: @search_query_dataclass class InvalidSearchQueryDataclass: - foo: int = field_tuple + foo: int = field_tuple # type: ignore[validataclass] assert str(exception_info.value) == expected_exception_msg From bdf423e55d021ca5254b7cf1da501c922409dae2 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 17:02:06 +0100 Subject: [PATCH 13/23] Explicitly re-export imports using __all__ This is necessary to avoid errors from the no-implicit-reexport rule both in the library and in projects using the library. It's also cleaner to have explicit exports. --- .../filters/__init__.py | 21 +++++++++++++++++++ .../filters/search_params/__init__.py | 20 ++++++++++++++++++ .../pagination/__init__.py | 10 +++++++++ .../repositories/__init__.py | 4 ++++ .../search_queries/__init__.py | 5 +++++ .../sorting/__init__.py | 7 +++++++ .../validators/__init__.py | 7 +++++++ tests/helpers/__init__.py | 5 +++++ 8 files changed, 79 insertions(+) diff --git a/src/validataclass_search_queries/filters/__init__.py b/src/validataclass_search_queries/filters/__init__.py index 0abf26e..c450530 100644 --- a/src/validataclass_search_queries/filters/__init__.py +++ b/src/validataclass_search_queries/filters/__init__.py @@ -24,3 +24,24 @@ SearchParamStartsWith, SearchParamEndsWith, ) + +__all__ = [ + 'BoundSearchFilter', + 'SearchParam', + 'SearchParamBoolean', + 'SearchParamIsNone', + 'SearchParamIsNotNone', + 'SearchParamTernary', + 'SearchParamCustom', + 'SearchParamEquals', + 'SearchParamGreaterThan', + 'SearchParamGreaterOrEqual', + 'SearchParamLessThan', + 'SearchParamLessOrEqual', + 'SearchParamSince', + 'SearchParamUntil', + 'SearchParamMultiSelect', + 'SearchParamContains', + 'SearchParamStartsWith', + 'SearchParamEndsWith', +] diff --git a/src/validataclass_search_queries/filters/search_params/__init__.py b/src/validataclass_search_queries/filters/search_params/__init__.py index 1b32121..77ca58f 100644 --- a/src/validataclass_search_queries/filters/search_params/__init__.py +++ b/src/validataclass_search_queries/filters/search_params/__init__.py @@ -27,3 +27,23 @@ SearchParamStartsWith, SearchParamEndsWith, ) + +__all__ = [ + 'SearchParam', + 'SearchParamBoolean', + 'SearchParamIsNone', + 'SearchParamIsNotNone', + 'SearchParamTernary', + 'SearchParamCustom', + 'SearchParamEquals', + 'SearchParamGreaterThan', + 'SearchParamGreaterOrEqual', + 'SearchParamLessThan', + 'SearchParamLessOrEqual', + 'SearchParamSince', + 'SearchParamUntil', + 'SearchParamMultiSelect', + 'SearchParamContains', + 'SearchParamStartsWith', + 'SearchParamEndsWith', +] diff --git a/src/validataclass_search_queries/pagination/__init__.py b/src/validataclass_search_queries/pagination/__init__.py index 49bc070..7255f4b 100644 --- a/src/validataclass_search_queries/pagination/__init__.py +++ b/src/validataclass_search_queries/pagination/__init__.py @@ -10,3 +10,13 @@ from .paginated_result import PaginatedResult from .pagination_limit_validator import PaginationLimitValidator, PaginationLimitRequiredError from .response_helpers import paginated_api_response + +__all__ = [ + 'AbstractPaginationMixin', + 'CursorPaginationMixin', + 'OffsetPaginationMixin', + 'PaginatedResult', + 'PaginationLimitValidator', + 'PaginationLimitRequiredError', + 'paginated_api_response', +] diff --git a/src/validataclass_search_queries/repositories/__init__.py b/src/validataclass_search_queries/repositories/__init__.py index 4cd9529..6318f19 100644 --- a/src/validataclass_search_queries/repositories/__init__.py +++ b/src/validataclass_search_queries/repositories/__init__.py @@ -5,3 +5,7 @@ """ from .search_query_repository_mixin import SearchQueryRepositoryMixin + +__all__ = [ + 'SearchQueryRepositoryMixin', +] diff --git a/src/validataclass_search_queries/search_queries/__init__.py b/src/validataclass_search_queries/search_queries/__init__.py index 7a173b5..ff69981 100644 --- a/src/validataclass_search_queries/search_queries/__init__.py +++ b/src/validataclass_search_queries/search_queries/__init__.py @@ -6,3 +6,8 @@ from .base_search_query import BaseSearchQuery from .search_query_dataclass import search_query_dataclass + +__all__ = [ + 'BaseSearchQuery', + 'search_query_dataclass', +] diff --git a/src/validataclass_search_queries/sorting/__init__.py b/src/validataclass_search_queries/sorting/__init__.py index 2d06345..4579524 100644 --- a/src/validataclass_search_queries/sorting/__init__.py +++ b/src/validataclass_search_queries/sorting/__init__.py @@ -7,3 +7,10 @@ from .abstract_sorting_mixin import AbstractSortingMixin from .sorting_direction import SortingDirection, SortingDirectionValidator from .sorting_mixin import SortingMixin + +__all__ = [ + 'AbstractSortingMixin', + 'SortingDirection', + 'SortingDirectionValidator', + 'SortingMixin', +] diff --git a/src/validataclass_search_queries/validators/__init__.py b/src/validataclass_search_queries/validators/__init__.py index 8914157..7d7844d 100644 --- a/src/validataclass_search_queries/validators/__init__.py +++ b/src/validataclass_search_queries/validators/__init__.py @@ -8,3 +8,10 @@ from .multi_select_enum_validator import MultiSelectEnumValidator from .multi_select_integer_validator import MultiSelectIntegerValidator from .multi_select_validator import MultiSelectValidator + +__all__ = [ + 'MultiSelectAnyOfValidator', + 'MultiSelectEnumValidator', + 'MultiSelectIntegerValidator', + 'MultiSelectValidator', +] diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index be44628..f98815c 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -6,3 +6,8 @@ from .assertions import assert_column_element from .mocks import UnitTestEnum + +__all__ = [ + 'assert_column_element', + 'UnitTestEnum', +] From 41a5bc74f928f16fd002b6427aa3c0b7e9d9e1bc Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 17:41:27 +0100 Subject: [PATCH 14/23] Improve typing of search filters --- .../filters/bound_search_filter.py | 2 +- .../search_params/base_search_param.py | 2 +- .../search_params/search_param_boolean.py | 21 +++++++++------- .../search_params/search_param_custom.py | 5 ++-- .../search_params/search_param_equals.py | 7 +++--- .../search_param_greater_less.py | 25 ++++++++++--------- .../search_param_multi_select.py | 5 ++-- .../search_params/search_param_substring.py | 13 +++++----- tests/unit/filters/search_params/conftest.py | 4 ++- .../search_param_boolean_test.py | 12 +++++++-- .../search_param_substring_test.py | 18 ++++++++++--- 11 files changed, 72 insertions(+), 42 deletions(-) diff --git a/src/validataclass_search_queries/filters/bound_search_filter.py b/src/validataclass_search_queries/filters/bound_search_filter.py index fe2caac..da96042 100644 --- a/src/validataclass_search_queries/filters/bound_search_filter.py +++ b/src/validataclass_search_queries/filters/bound_search_filter.py @@ -69,7 +69,7 @@ def column_name(self) -> str: """ return self.search_param.column_name or self.param_name - def get_sqlalchemy_filter(self, column: ColumnElement) -> ColumnElement: + def get_sqlalchemy_filter(self, column: ColumnElement[Any]) -> ColumnElement[bool]: """ Returns an SQLAlchemy filter for the given column (can be any ColumnElement, i.e. any SQLAlchemy expression that can be used in a WHERE clause) based on the filter function defined by the SearchParam. diff --git a/src/validataclass_search_queries/filters/search_params/base_search_param.py b/src/validataclass_search_queries/filters/search_params/base_search_param.py index 343845a..05b72cb 100644 --- a/src/validataclass_search_queries/filters/search_params/base_search_param.py +++ b/src/validataclass_search_queries/filters/search_params/base_search_param.py @@ -53,7 +53,7 @@ def __init__(self, column_name: str | None = None): self.column_name = column_name @abstractmethod # pragma: nocover - def sqlalchemy_filter(self, column: ColumnElement, value: Any) -> ColumnElement: + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: """ This abstract method defines the SQLAlchemy filter expression. See existing implementations for examples. """ diff --git a/src/validataclass_search_queries/filters/search_params/search_param_boolean.py b/src/validataclass_search_queries/filters/search_params/search_param_boolean.py index 443bac8..04b58d0 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_boolean.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_boolean.py @@ -5,6 +5,7 @@ """ from typing import Any +from typing_extensions import override from sqlalchemy.sql import ColumnElement @@ -23,8 +24,8 @@ class SearchParamBoolean(SearchParam): Boolean search parameter to filter a boolean column for true or false. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: return column.is_(bool(value)) @@ -36,21 +37,22 @@ class SearchParamIsNone(SearchParam): If the search parameter is False, only results where the specified column is NOT None will be included. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: return column.is_(None) if value is True else column.is_not(None) class SearchParamIsNotNone(SearchParam): """ - Boolean search parameter to filter a column for values that are None or not None. Inverted version of SearchParamIsNone. + Boolean search parameter to filter a column for values that are None or not None. + Inverted version of SearchParamIsNone. If the search parameter is True, only results where the specified column is NOT None will be included. If the search parameter is False, only results where the specified column is None will be included. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: return column.is_not(None) if value is True else column.is_(None) @@ -74,5 +76,6 @@ def __init__(self, true: Any, false: Any, *, column_name: str | None = None): self.value_true = true self.value_false = false - def sqlalchemy_filter(self, column: ColumnElement, value: Any) -> ColumnElement: - return column == (self.value_true if value else self.value_false) + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: + return column.__eq__(self.value_true if value else self.value_false) diff --git a/src/validataclass_search_queries/filters/search_params/search_param_custom.py b/src/validataclass_search_queries/filters/search_params/search_param_custom.py index 5461187..28aa622 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_custom.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_custom.py @@ -5,6 +5,7 @@ """ from typing import Any +from typing_extensions import override from sqlalchemy.sql import ColumnElement @@ -24,6 +25,6 @@ class SearchParamCustom(SearchParam): overriding either `_apply_bound_search_filter` or `_filter_by_search_query`. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: raise NotImplementedError('Custom search parameter needs to be handled in the repository!') diff --git a/src/validataclass_search_queries/filters/search_params/search_param_equals.py b/src/validataclass_search_queries/filters/search_params/search_param_equals.py index 55ff16f..db7a87c 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_equals.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_equals.py @@ -5,6 +5,7 @@ """ from typing import Any +from typing_extensions import override from sqlalchemy.sql import ColumnElement @@ -22,6 +23,6 @@ class SearchParamEquals(SearchParam): Note: For strings, this might or might not be case sensitive, depending on your database collations. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: - return column == value + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: + return column.__eq__(value) diff --git a/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py b/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py index e0e6bd0..64b467c 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py @@ -5,6 +5,7 @@ """ from typing import Any +from typing_extensions import override from sqlalchemy.sql import ColumnElement @@ -25,9 +26,9 @@ class SearchParamGreaterThan(SearchParam): Search parameter to filter for values greater than the filter value (`column > value`). """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: - return column > value + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: + return column.__gt__(value) class SearchParamGreaterOrEqual(SearchParam): @@ -35,9 +36,9 @@ class SearchParamGreaterOrEqual(SearchParam): Search parameter to filter for values greater than or equal to the filter value (`column >= value`). """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: - return column >= value + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: + return column.__ge__(value) class SearchParamLessThan(SearchParam): @@ -45,9 +46,9 @@ class SearchParamLessThan(SearchParam): Search parameter to filter for values less than the filter value (`column < value`). """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: - return column < value + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: + return column.__lt__(value) class SearchParamLessOrEqual(SearchParam): @@ -55,9 +56,9 @@ class SearchParamLessOrEqual(SearchParam): Search parameter to filter for values less than or equal to the filter value (`column <= value`). """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: - return column <= value + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: + return column.__le__(value) class SearchParamSince(SearchParamGreaterOrEqual): diff --git a/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py b/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py index 29aa2cc..7f19716 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py @@ -5,6 +5,7 @@ """ from typing import Any +from typing_extensions import override from sqlalchemy.sql import ColumnElement @@ -26,7 +27,7 @@ class SearchParamMultiSelect(SearchParam): is set to a single value, the filter will be equivalent to an "equals" filter. See the `MultiSelectValidator`. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: value_list = value if isinstance(value, list) else [value] return column.in_(value_list) diff --git a/src/validataclass_search_queries/filters/search_params/search_param_substring.py b/src/validataclass_search_queries/filters/search_params/search_param_substring.py index 5290d29..233f5b9 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_substring.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_substring.py @@ -5,6 +5,7 @@ """ from typing import Any +from typing_extensions import override from sqlalchemy.sql import ColumnElement @@ -25,8 +26,8 @@ class SearchParamContains(SearchParam): interpreted as literal characters, not as wildcard characters. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: # Short-circuit if value is empty return column.contains(value, autoescape=True) if value else column @@ -39,8 +40,8 @@ class SearchParamStartsWith(SearchParam): interpreted as literal characters, not as wildcard characters. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: # Short-circuit if value is empty return column.startswith(value, autoescape=True) if value else column @@ -53,7 +54,7 @@ class SearchParamEndsWith(SearchParam): interpreted as literal characters, not as wildcard characters. """ - @staticmethod - def sqlalchemy_filter(column: ColumnElement, value: Any) -> ColumnElement: + @override + def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: # Short-circuit if value is empty return column.endswith(value, autoescape=True) if value else column diff --git a/tests/unit/filters/search_params/conftest.py b/tests/unit/filters/search_params/conftest.py index fc12de3..96daf05 100644 --- a/tests/unit/filters/search_params/conftest.py +++ b/tests/unit/filters/search_params/conftest.py @@ -4,11 +4,13 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ +from typing import Any + import pytest import sqlalchemy from sqlalchemy.sql import ColumnElement @pytest.fixture -def sqlalchemy_column() -> ColumnElement: +def sqlalchemy_column() -> ColumnElement[Any]: return sqlalchemy.column('unit_test_column') diff --git a/tests/unit/filters/search_params/search_param_boolean_test.py b/tests/unit/filters/search_params/search_param_boolean_test.py index 1f5f1a5..fd5b0a5 100644 --- a/tests/unit/filters/search_params/search_param_boolean_test.py +++ b/tests/unit/filters/search_params/search_param_boolean_test.py @@ -48,5 +48,13 @@ def test_search_param_is_not_none(sqlalchemy_column): def test_search_param_ternary(sqlalchemy_column): """ Test the SearchParamTernary search parameter. """ param = SearchParamTernary('yes', 'no') - assert_column_element(param.sqlalchemy_filter(sqlalchemy_column, True), 'unit_test_column = :unit_test_column_1', 'yes') - assert_column_element(param.sqlalchemy_filter(sqlalchemy_column, False), 'unit_test_column = :unit_test_column_1', 'no') + assert_column_element( + param.sqlalchemy_filter(sqlalchemy_column, True), + 'unit_test_column = :unit_test_column_1', + 'yes', + ) + assert_column_element( + param.sqlalchemy_filter(sqlalchemy_column, False), + 'unit_test_column = :unit_test_column_1', + 'no', + ) diff --git a/tests/unit/filters/search_params/search_param_substring_test.py b/tests/unit/filters/search_params/search_param_substring_test.py index e704f95..cbe1bbd 100644 --- a/tests/unit/filters/search_params/search_param_substring_test.py +++ b/tests/unit/filters/search_params/search_param_substring_test.py @@ -25,21 +25,33 @@ def test_search_param_contains(sqlalchemy_column, input_value, expected_param): """ Test the SearchParamContains search parameter. """ search_filter = SearchParamContains().sqlalchemy_filter(sqlalchemy_column, input_value) - assert_column_element(search_filter, "unit_test_column LIKE '%' || :unit_test_column_1 || '%' ESCAPE '/'", expected_param) + assert_column_element( + search_filter, + "unit_test_column LIKE '%' || :unit_test_column_1 || '%' ESCAPE '/'", + expected_param, + ) @pytest.mark.parametrize('input_value, expected_param', test_data_substring_matches) def test_search_param_starts_with(sqlalchemy_column, input_value, expected_param): """ Test the SearchParamStartsWith search parameter. """ search_filter = SearchParamStartsWith().sqlalchemy_filter(sqlalchemy_column, input_value) - assert_column_element(search_filter, "unit_test_column LIKE :unit_test_column_1 || '%' ESCAPE '/'", expected_param) + assert_column_element( + search_filter, + "unit_test_column LIKE :unit_test_column_1 || '%' ESCAPE '/'", + expected_param, + ) @pytest.mark.parametrize('input_value, expected_param', test_data_substring_matches) def test_search_param_ends_with(sqlalchemy_column, input_value, expected_param): """ Test the SearchParamEndsWith search parameter. """ search_filter = SearchParamEndsWith().sqlalchemy_filter(sqlalchemy_column, input_value) - assert_column_element(search_filter, "unit_test_column LIKE '%' || :unit_test_column_1 ESCAPE '/'", expected_param) + assert_column_element( + search_filter, + "unit_test_column LIKE '%' || :unit_test_column_1 ESCAPE '/'", + expected_param, + ) @pytest.mark.parametrize('search_param_cls', [SearchParamContains, SearchParamStartsWith, SearchParamEndsWith]) From 514dc81bd34ed88b47c63e6bc420d0f8d0bdaebb Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 17:59:38 +0100 Subject: [PATCH 15/23] Fix incorrect short-circuiting of substring search filters --- .../filters/search_params/search_param_substring.py | 9 +++------ .../filters/search_params/search_param_substring_test.py | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/validataclass_search_queries/filters/search_params/search_param_substring.py b/src/validataclass_search_queries/filters/search_params/search_param_substring.py index 233f5b9..bf09780 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_substring.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_substring.py @@ -28,8 +28,7 @@ class SearchParamContains(SearchParam): @override def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: - # Short-circuit if value is empty - return column.contains(value, autoescape=True) if value else column + return column.contains(value, autoescape=True) class SearchParamStartsWith(SearchParam): @@ -42,8 +41,7 @@ class SearchParamStartsWith(SearchParam): @override def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: - # Short-circuit if value is empty - return column.startswith(value, autoescape=True) if value else column + return column.startswith(value, autoescape=True) class SearchParamEndsWith(SearchParam): @@ -56,5 +54,4 @@ class SearchParamEndsWith(SearchParam): @override def sqlalchemy_filter(self, column: ColumnElement[Any], value: Any) -> ColumnElement[bool]: - # Short-circuit if value is empty - return column.endswith(value, autoescape=True) if value else column + return column.endswith(value, autoescape=True) diff --git a/tests/unit/filters/search_params/search_param_substring_test.py b/tests/unit/filters/search_params/search_param_substring_test.py index cbe1bbd..5b47e5e 100644 --- a/tests/unit/filters/search_params/search_param_substring_test.py +++ b/tests/unit/filters/search_params/search_param_substring_test.py @@ -12,6 +12,9 @@ # Test data for SearchParamContains, SearchParamStartsWidth, SearchParamEndsWidth (substring matching search filters) # (Parameters: input_value, expected_param) test_data_substring_matches = [ + # Empty string + ('', ''), + # Simple string ('banana', 'banana'), @@ -52,9 +55,3 @@ def test_search_param_ends_with(sqlalchemy_column, input_value, expected_param): "unit_test_column LIKE '%' || :unit_test_column_1 ESCAPE '/'", expected_param, ) - - -@pytest.mark.parametrize('search_param_cls', [SearchParamContains, SearchParamStartsWith, SearchParamEndsWith]) -def test_search_param_substring_matching_shortcircuit(sqlalchemy_column, search_param_cls): - """ Test that SearchParamContains, SearchParamStartsWith and SearchParamEndsWith short-circuit if the value is empty. """ - assert search_param_cls().sqlalchemy_filter(sqlalchemy_column, '') is sqlalchemy_column From 2452ded7ad35ac711e966fb4c4ae6ad7bb28fac7 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 18:55:33 +0100 Subject: [PATCH 16/23] Enable mypy strict mode; fix various typing issues --- docs/02-using-search-queries.md | 6 +++- pyproject.toml | 6 +--- .../pagination/abstract_pagination_mixin.py | 8 +++-- .../pagination/cursor_pagination_mixin.py | 16 ++++++---- .../pagination/offset_pagination_mixin.py | 10 ++++--- .../pagination/paginated_result.py | 4 +-- .../pagination/response_helpers.py | 4 +-- .../search_query_repository_mixin.py | 16 +++++----- .../search_queries/search_query_dataclass.py | 4 +-- .../sorting/abstract_sorting_mixin.py | 10 ++++--- .../sorting/sorting_mixin.py | 14 +++++---- .../validators/multi_select_validator.py | 2 +- tests/helpers/assertions.py | 2 +- .../unit/pagination/paginated_result_test.py | 10 ++++--- .../unit/pagination/response_helpers_test.py | 30 +++++++++++-------- tests/unit/sorting/sorting_mixin_test.py | 24 +++++++-------- 16 files changed, 95 insertions(+), 71 deletions(-) diff --git a/docs/02-using-search-queries.md b/docs/02-using-search-queries.md index 61c71ee..6af09c2 100644 --- a/docs/02-using-search-queries.md +++ b/docs/02-using-search-queries.md @@ -197,6 +197,7 @@ handle your special filter, and then continue using the methods like you would n Here is an example how you could implement this: ```python +from typing import TypeVar, override from sqlalchemy.orm import Session, Query from validataclass.validators import StringValidator from validataclass_search_queries.filters import SearchParamContains, BoundSearchFilter @@ -204,6 +205,8 @@ from validataclass_search_queries.pagination import PaginatedResult from validataclass_search_queries.repositories import SearchQueryRepositoryMixin from validataclass_search_queries.search_queries import search_query_dataclass, BaseSearchQuery +T_Query = TypeVar('T_Query') + # Stubs for SQLAlchemy models (Customer needs a 1:n relationship "addresses" to Address) class Customer: ... class Address: ... @@ -231,7 +234,8 @@ class CustomerRepository(SearchQueryRepositoryMixin[Customer]): query = self.session.query(Customer).join(Customer.addresses) return self._search_and_paginate(query, search_query) - def _apply_bound_search_filter(self, query: Query, bound_filter: BoundSearchFilter) -> Query: + @override + def _apply_bound_search_filter(self, query: Query[T_Query], bound_filter: BoundSearchFilter) -> Query[T_Query]: # Implement special handling for the "city" filter if bound_filter.column_name == 'city': return query.filter(bound_filter.get_sqlalchemy_filter(Address.city)) diff --git a/pyproject.toml b/pyproject.toml index 8044226..40f394f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,7 @@ plugins = [ ] # Enable strict type checking -# TODO: Enable strict mode! -#strict = true - -# TODO: This is included in strict and can be removed when enabling strict mode. -check_untyped_defs = true +strict = true # Enable further checks that are not included in strict mode # TODO: Enable more checks! diff --git a/src/validataclass_search_queries/pagination/abstract_pagination_mixin.py b/src/validataclass_search_queries/pagination/abstract_pagination_mixin.py index 65bfeb3..3d0d9ce 100644 --- a/src/validataclass_search_queries/pagination/abstract_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/abstract_pagination_mixin.py @@ -5,7 +5,7 @@ """ from abc import ABC, abstractmethod -from typing import Any +from typing import Any, TypeVar from sqlalchemy.orm import Query @@ -15,6 +15,8 @@ 'AbstractPaginationMixin', ] +T = TypeVar('T') + class AbstractPaginationMixin(ABC): """ @@ -25,7 +27,7 @@ class AbstractPaginationMixin(ABC): limit: int | None @abstractmethod - def apply_pagination_to_query(self, query: Query, model_cls: Any) -> Query: + def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the pagination parameters to an SQLAlchemy query and returns the new query. @@ -45,7 +47,7 @@ def get_start_parameter_name(self) -> str: raise NotImplementedError @abstractmethod - def get_next_start_value(self, paginated_result: PaginatedResult) -> int | None: + def get_next_start_value(self, paginated_result: PaginatedResult[Any]) -> int | None: """ Returns the next value for the pagination start parameter (see also: `get_start_parameter_name()`) to retrieve the next page of data, or None if there is no next page. diff --git a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py index f581c06..d953248 100644 --- a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any +from typing import Any, TypeVar, cast from sqlalchemy.orm import Query from sqlalchemy.sql import ColumnElement @@ -20,6 +20,8 @@ 'CursorPaginationMixin', ] +T = TypeVar('T') + @validataclass class CursorPaginationMixin(AbstractPaginationMixin): @@ -92,7 +94,7 @@ class ExampleSearchQuery(CursorPaginationMixin, BaseSearchQuery): # Limit: Number of entries per page limit: int | None = PaginationLimitValidator(max_value=100), Default(20) - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: Any): # Pagination mixins are not compatible with each other, only one can be used at the same time if issubclass(cls, pagination.OffsetPaginationMixin): raise TypeError( @@ -115,7 +117,7 @@ def get_cursor_column_name() -> str: """ return 'id' - def get_cursor_column(self, model_cls: Any) -> ColumnElement: + def get_cursor_column(self, model_cls: Any) -> ColumnElement[Any]: """ Returns the column that is used as cursor for cursor pagination. @@ -125,9 +127,11 @@ def get_cursor_column(self, model_cls: Any) -> ColumnElement: THIS method, be sure to also adjust `get_cursor_column_name()` so that it still works with other methods like `get_next_start_value()`. """ - return getattr(model_cls, self.get_cursor_column_name()) + # SQLAlchemy's typing is complicated and we don't know what exact types we have to expect here, so we'll just + # pretend it's always a ColumnElement to make the type checker happy. + return cast(ColumnElement[Any], getattr(model_cls, self.get_cursor_column_name())) - def apply_pagination_to_query(self, query: Query, model_cls: Any) -> Query: + def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the pagination parameters to an SQLAlchemy query and returns the new query. @@ -158,7 +162,7 @@ def get_start_parameter_name(self) -> str: """ return 'start' - def get_next_start_value(self, paginated_result: PaginatedResult) -> int | None: + def get_next_start_value(self, paginated_result: PaginatedResult[Any]) -> int | None: """ Returns the next value for the pagination start parameter to retrieve the next page of data, or None if there is no next page. diff --git a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py index b53cce0..302dce7 100644 --- a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any +from typing import Any, TypeVar from sqlalchemy.orm import Query from validataclass.dataclasses import validataclass, Default @@ -19,6 +19,8 @@ 'OffsetPaginationMixin', ] +T = TypeVar('T') + @validataclass class OffsetPaginationMixin(AbstractPaginationMixin): @@ -87,14 +89,14 @@ class ExampleSearchQuery(OffsetPaginationMixin, BaseSearchQuery): # Limit: Number of entries per page limit: int | None = PaginationLimitValidator(max_value=100), Default(20) - def __init_subclass__(cls, **kwargs): + def __init_subclass__(cls, **kwargs: Any): # Pagination mixins are not compatible with each other, only one can be used at the same time if issubclass(cls, pagination.CursorPaginationMixin): raise TypeError(f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed') super().__init_subclass__(**kwargs) - def apply_pagination_to_query(self, query: Query, model_cls: Any) -> Query: + def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the pagination parameters to an SQLAlchemy query and returns the new query. @@ -112,7 +114,7 @@ def get_start_parameter_name(self) -> str: """ return 'offset' - def get_next_start_value(self, paginated_result: PaginatedResult) -> int | None: + def get_next_start_value(self, paginated_result: PaginatedResult[Any]) -> int | None: """ Returns the next value for the pagination start parameter to retrieve the next page of data, or None if there is no next page. diff --git a/src/validataclass_search_queries/pagination/paginated_result.py b/src/validataclass_search_queries/pagination/paginated_result.py index 3b73141..fd8114f 100644 --- a/src/validataclass_search_queries/pagination/paginated_result.py +++ b/src/validataclass_search_queries/pagination/paginated_result.py @@ -5,7 +5,7 @@ """ from collections.abc import Callable, Iterable -from typing import TypeVar +from typing import Any, TypeVar __all__ = [ 'PaginatedResult', @@ -71,7 +71,7 @@ def map_customers(customer: Customer) -> dict: total_count=self.total_count, ) - def to_dict(self, *, recursive: bool = False) -> dict: + def to_dict(self, *, recursive: bool = False) -> dict[str, Any]: """ Returns a dictionary representing the PaginatedResult, consisting of the keys "items" (a list of the items) and "total_count" (the total count as an integer). diff --git a/src/validataclass_search_queries/pagination/response_helpers.py b/src/validataclass_search_queries/pagination/response_helpers.py index dbad243..3507903 100644 --- a/src/validataclass_search_queries/pagination/response_helpers.py +++ b/src/validataclass_search_queries/pagination/response_helpers.py @@ -21,8 +21,8 @@ def paginated_api_response( *, recursive_to_dict: bool = True, request_path: str | None = None, - original_params: dict | None = None, -) -> dict: + original_params: dict[str, Any] | None = None, +) -> dict[str, Any]: """ Constructs a REST API response (as a dictionary) for paginated results. diff --git a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py index c7fb800..18e7950 100644 --- a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py +++ b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py @@ -5,7 +5,7 @@ """ from abc import ABC, abstractmethod -from typing import Generic, TypeVar +from typing import Generic, TypeVar, Any from sqlalchemy.orm import Query @@ -19,6 +19,7 @@ ] T_Model = TypeVar('T_Model') +T_Query = TypeVar('T_Query') class SearchQueryRepositoryMixin(Generic[T_Model], ABC): @@ -113,7 +114,8 @@ def fetch_customers(self, *, search_query: BaseSearchQuery | None = None) -> Pag return self._search_and_paginate(query, search_query) # Override the default method for applying search filters - def _apply_bound_search_filter(self, query: Query, bound_filter: BoundSearchFilter) -> Query: + @override + def _apply_bound_search_filter(self, query: Query[T_Query], bound_filter: BoundSearchFilter) -> Query[T_Query]: # Only implement a special case for the "modified" column if bound_filter.column_name == 'modified': # Get column objects for both models @@ -144,7 +146,7 @@ def model_cls(self) -> type[T_Model]: """ raise NotImplementedError - def _search_and_paginate(self, query: Query, search_query: BaseSearchQuery | None) -> PaginatedResult[T_Model]: + def _search_and_paginate(self, query: Query[Any], search_query: BaseSearchQuery | None) -> PaginatedResult[T_Model]: """ Filters a query based on search parameters (usually parsed from HTTP query parameters) and paginates the result. @@ -154,7 +156,7 @@ def _search_and_paginate(self, query: Query, search_query: BaseSearchQuery | Non query = self._order_by_search_query(query, search_query) return self._paginate_result(query, search_query) - def _filter_by_search_query(self, query: Query, search_query: BaseSearchQuery | None) -> Query: + def _filter_by_search_query(self, query: Query[T_Query], search_query: BaseSearchQuery | None) -> Query[T_Query]: """ Filters a query based on search parameters (usually parsed from HTTP query parameters), *excluding* pagination. @@ -169,7 +171,7 @@ def _filter_by_search_query(self, query: Query, search_query: BaseSearchQuery | return query - def _apply_bound_search_filter(self, query: Query, bound_filter: BoundSearchFilter) -> Query: + def _apply_bound_search_filter(self, query: Query[T_Query], bound_filter: BoundSearchFilter) -> Query[T_Query]: """ Filters a query based on a BoundSearchFilter. Called by _filter_by_search_query() for every set search filter. @@ -179,7 +181,7 @@ def _apply_bound_search_filter(self, query: Query, bound_filter: BoundSearchFilt col = getattr(self.model_cls, bound_filter.column_name) return query.filter(bound_filter.get_sqlalchemy_filter(col)) - def _order_by_search_query(self, query: Query, search_query: BaseSearchQuery | None) -> Query: + def _order_by_search_query(self, query: Query[T_Query], search_query: BaseSearchQuery | None) -> Query[T_Query]: """ Applies sorting (order_by) to a query based on sorting parameters from a search query. @@ -191,7 +193,7 @@ def _order_by_search_query(self, query: Query, search_query: BaseSearchQuery | N return query - def _paginate_result(self, query: Query, search_query: BaseSearchQuery | None) -> PaginatedResult[T_Model]: + def _paginate_result(self, query: Query[Any], search_query: BaseSearchQuery | None) -> PaginatedResult[T_Model]: """ Applies pagination to a query based on search parameters, executes the query and returns a paginated result list. diff --git a/src/validataclass_search_queries/search_queries/search_query_dataclass.py b/src/validataclass_search_queries/search_queries/search_query_dataclass.py index 2debd2f..9e11976 100644 --- a/src/validataclass_search_queries/search_queries/search_query_dataclass.py +++ b/src/validataclass_search_queries/search_queries/search_query_dataclass.py @@ -81,7 +81,7 @@ def decorator(_cls: type[_T]) -> type[_T]: return decorator if cls is None else decorator(cls) -def _prepare_search_query_dataclass(cls) -> None: +def _prepare_search_query_dataclass(cls: type) -> None: """ Internal helper function used by @search_query_dataclass to prepare validataclass fields in a soon-to-be dataclass. """ @@ -135,7 +135,7 @@ def _prepare_search_query_dataclass(cls) -> None: )) -def _get_existing_validator_fields(cls) -> dict[str, _ValidatorField]: +def _get_existing_validator_fields(cls: type) -> dict[str, _ValidatorField]: """ Returns a dictionary containing all fields (as `_ValidatorField` objects) of an existing validataclass that have a validator set in their metadata, or an empty dictionary if the class is not a dataclass (yet). diff --git a/src/validataclass_search_queries/sorting/abstract_sorting_mixin.py b/src/validataclass_search_queries/sorting/abstract_sorting_mixin.py index 491d098..5b35be7 100644 --- a/src/validataclass_search_queries/sorting/abstract_sorting_mixin.py +++ b/src/validataclass_search_queries/sorting/abstract_sorting_mixin.py @@ -5,7 +5,7 @@ """ from abc import ABC, abstractmethod -from typing import Any +from typing import Any, TypeVar from sqlalchemy.orm import Query from sqlalchemy.sql import ColumnElement @@ -16,6 +16,8 @@ 'AbstractSortingMixin', ] +T = TypeVar('T') + class AbstractSortingMixin(ABC): """ @@ -29,7 +31,7 @@ class AbstractSortingMixin(ABC): sorting_direction: SortingDirection @abstractmethod - def get_sorting_column(self, model_cls: Any) -> ColumnElement: + def get_sorting_column(self, model_cls: Any) -> ColumnElement[Any]: """ Returns the column that the query should be ordered by (excluding the sorting direction). @@ -38,7 +40,7 @@ def get_sorting_column(self, model_cls: Any) -> ColumnElement: raise NotImplementedError @abstractmethod - def apply_sorting_direction(self, column: ColumnElement) -> ColumnElement: + def apply_sorting_direction(self, column: ColumnElement[T]) -> ColumnElement[T]: """ Applies the sorting direction to an SQLAlchemy column element, i.e. `column.asc()` or `column.desc()`, and returns the new column element. @@ -46,7 +48,7 @@ def apply_sorting_direction(self, column: ColumnElement) -> ColumnElement: raise NotImplementedError @abstractmethod - def apply_sorting_to_query(self, query: Query, model_cls: Any) -> Query: + def apply_sorting_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the sorting parameters to an SQLAlchemy query (`query.order_by()`) and returns the new query. diff --git a/src/validataclass_search_queries/sorting/sorting_mixin.py b/src/validataclass_search_queries/sorting/sorting_mixin.py index def73ba..6a9d9e1 100644 --- a/src/validataclass_search_queries/sorting/sorting_mixin.py +++ b/src/validataclass_search_queries/sorting/sorting_mixin.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import Any +from typing import Any, TypeVar, cast from sqlalchemy.orm import Query from sqlalchemy.sql import ColumnElement @@ -18,6 +18,8 @@ 'SortingMixin', ] +T = TypeVar('T') + @validataclass class SortingMixin(AbstractSortingMixin): @@ -58,22 +60,24 @@ class ExampleSearchQuery(SortingMixin, BaseSearchQuery): # Sorting direction ("ASC" or "DESC", case-insensitive) sorting_direction: SortingDirection = SortingDirectionValidator(), Default(SortingDirection.ASC) - def get_sorting_column(self, model_cls: Any) -> ColumnElement: + def get_sorting_column(self, model_cls: Any) -> ColumnElement[Any]: """ Returns the column that the query should be ordered by (excluding the sorting direction). The "model_cls" parameter should be the class of the database model that is queried. """ - return getattr(model_cls, self.sorted_by) + # SQLAlchemy's typing is complicated and we don't know what exact types we have to expect here, so we'll just + # pretend it's always a ColumnElement to make the type checker happy. + return cast(ColumnElement[Any], getattr(model_cls, self.sorted_by)) - def apply_sorting_direction(self, column: ColumnElement) -> ColumnElement: + def apply_sorting_direction(self, column: ColumnElement[T]) -> ColumnElement[T]: """ Applies the sorting direction to an SQLAlchemy column element, i.e. `column.asc()` or `column.desc()`, and returns the new column element. """ return column.desc() if self.sorting_direction is SortingDirection.DESC else column.asc() - def apply_sorting_to_query(self, query: Query, model_cls: Any) -> Query: + def apply_sorting_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the sorting parameters to an SQLAlchemy query (`query.order_by()`) and returns the new query. diff --git a/src/validataclass_search_queries/validators/multi_select_validator.py b/src/validataclass_search_queries/validators/multi_select_validator.py index 9e4c33f..aba7ab9 100644 --- a/src/validataclass_search_queries/validators/multi_select_validator.py +++ b/src/validataclass_search_queries/validators/multi_select_validator.py @@ -66,7 +66,7 @@ def __init__( self.delimiter = delimiter @override - def validate(self, input_data: Any, **kwargs) -> list[T_ListItem]: + def validate(self, input_data: Any, **kwargs: Any) -> list[T_ListItem]: """ Validate input data as string. Returns a validated list. """ diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py index b7b52f8..59af22c 100644 --- a/tests/helpers/assertions.py +++ b/tests/helpers/assertions.py @@ -9,7 +9,7 @@ from sqlalchemy.sql import ColumnElement -def assert_column_element(element: Any, expected_string: str, *expected_params) -> None: +def assert_column_element(element: Any, expected_string: str, *expected_params: Any) -> None: """ Helper function to check the SQL and bound parameters of a generated ColumnElement. """ assert isinstance(element, ColumnElement) compiled_expr = element.compile() diff --git a/tests/unit/pagination/paginated_result_test.py b/tests/unit/pagination/paginated_result_test.py index 7170371..95ab4be 100644 --- a/tests/unit/pagination/paginated_result_test.py +++ b/tests/unit/pagination/paginated_result_test.py @@ -4,6 +4,8 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ +from typing import Any + import pytest from validataclass_search_queries.pagination import PaginatedResult @@ -22,7 +24,7 @@ def __eq__(self, other): class MockItemToDictable(MockItem): """ Variation of MockItem that has a to_dict() method. """ - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, Any]: return {'name': self.name} @@ -33,14 +35,14 @@ def __init__(self, name: str): self.name = name @staticmethod - def map_static(item) -> str: + def map_static(item: MockItem) -> str: return str(item) @classmethod - def map_class(cls, item) -> str: + def map_class(cls, item: MockItem) -> str: return f'[{cls.__name__}] {item}' - def map_instance(self, item) -> str: + def map_instance(self, item: MockItem) -> str: return f'[{self.name}] {item}' diff --git a/tests/unit/pagination/response_helpers_test.py b/tests/unit/pagination/response_helpers_test.py index 5785443..5709d74 100644 --- a/tests/unit/pagination/response_helpers_test.py +++ b/tests/unit/pagination/response_helpers_test.py @@ -5,10 +5,16 @@ """ from dataclasses import dataclass +from typing import Any import pytest -from validataclass_search_queries.pagination import CursorPaginationMixin, OffsetPaginationMixin, PaginatedResult, paginated_api_response +from validataclass_search_queries.pagination import ( + CursorPaginationMixin, + OffsetPaginationMixin, + PaginatedResult, + paginated_api_response, +) from validataclass_search_queries.search_queries import BaseSearchQuery, search_query_dataclass @@ -17,7 +23,7 @@ class MockItem: """ Object used to test the response helper functions. """ id: int - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, Any]: return {'id': self.id} @@ -41,7 +47,7 @@ class ExampleQueryOffsetPagination(OffsetPaginationMixin, BaseSearchQuery): { 'items': [1, 3, 1, 2], 'total_count': 10, - } + }, ), ( # Empty result (implies last page) @@ -50,7 +56,7 @@ class ExampleQueryOffsetPagination(OffsetPaginationMixin, BaseSearchQuery): { 'items': [], 'total_count': 10, - } + }, ), ( # Full page with cursor pagination @@ -60,7 +66,7 @@ class ExampleQueryOffsetPagination(OffsetPaginationMixin, BaseSearchQuery): 'items': [{'id': 13}, {'id': 37}, {'id': 41}], 'total_count': 10, 'next_id': 42, - } + }, ), ( # Full page with offset pagination @@ -70,7 +76,7 @@ class ExampleQueryOffsetPagination(OffsetPaginationMixin, BaseSearchQuery): 'items': [{'id': 13}, {'id': 37}, {'id': 41}], 'total_count': 10, 'next_offset': 9, - } + }, ), ( # Non-full page (implies last page) @@ -79,9 +85,9 @@ class ExampleQueryOffsetPagination(OffsetPaginationMixin, BaseSearchQuery): { 'items': [{'id': 99}], 'total_count': 10, - } + }, ), - ] + ], ) def test_paginated_api_response(paginated_result, search_query, expected_response): """ Test paginated_api_response() with different search queries and results. """ @@ -104,7 +110,7 @@ def test_paginated_api_response(paginated_result, search_query, expected_respons 'total_count': 10, 'next_id': 42, 'next_path': '/unit/test?start=42&limit=3', - } + }, ), ( # Full page with offset pagination @@ -116,7 +122,7 @@ def test_paginated_api_response(paginated_result, search_query, expected_respons 'total_count': 10, 'next_offset': 9, 'next_path': '/unit/test?offset=9&limit=3', - } + }, ), ( # With original parameters (numbers are strings here, like in HTTP query parameters) @@ -128,9 +134,9 @@ def test_paginated_api_response(paginated_result, search_query, expected_respons 'total_count': 10, 'next_offset': 9, 'next_path': '/unit/test?foo=bar&limit=3&offset=9&something=else', - } + }, ), - ] + ], ) def test_paginated_api_response_with_next_path(paginated_result, search_query, original_params, expected_response): """ Test paginated_api_response() with request_path and original_params to generate the "next_path" field. """ diff --git a/tests/unit/sorting/sorting_mixin_test.py b/tests/unit/sorting/sorting_mixin_test.py index bd9c285..a119fa5 100644 --- a/tests/unit/sorting/sorting_mixin_test.py +++ b/tests/unit/sorting/sorting_mixin_test.py @@ -18,15 +18,15 @@ class MockModelCls: """ This class is used as a mock for a database model class. """ id: ColumnClause[int] = sqlalchemy.column('id') - unit_test_field: ColumnClause[str] = sqlalchemy.column('unit_test_field') + test_field: ColumnClause[str] = sqlalchemy.column('test_field') def test_sorting_mixin_get_sorting_column(): """ Test SortingMixin.get_sorting_column() on its own. """ # It's supposed to be used as a mixin, but it should function on its own, too. # (Also, we bypass the validators here by creating the object directly, so we can test with any sorted_by key.) - sorting_mixin = SortingMixin(sorted_by='unit_test_field', sorting_direction=SortingDirection.DESC) - assert sorting_mixin.get_sorting_column(MockModelCls) is MockModelCls.unit_test_field + sorting_mixin = SortingMixin(sorted_by='test_field', sorting_direction=SortingDirection.DESC) + assert sorting_mixin.get_sorting_column(MockModelCls) is MockModelCls.test_field @pytest.mark.parametrize( @@ -38,11 +38,11 @@ def test_sorting_mixin_get_sorting_column(): ) def test_sorting_mixin_apply_sorting_direction(sorting_direction, expected_dir_str): """ Test SortingMixin.apply_sorting_direction() on its own. """ - sorting_mixin = SortingMixin(sorted_by='unit_test_field', sorting_direction=sorting_direction) - order_column = sorting_mixin.apply_sorting_direction(sqlalchemy.column('custom_column')) + sorting_mixin = SortingMixin(sorted_by='test_field', sorting_direction=sorting_direction) + order_column = sorting_mixin.apply_sorting_direction(MockModelCls.test_field) assert isinstance(order_column, ColumnElement) - assert str(order_column) == f'custom_column {expected_dir_str}' + assert str(order_column) == f'test_field {expected_dir_str}' # TODO: Tests for SortingMixin.apply_sorting_to_query() @@ -109,23 +109,23 @@ def test_sorting_mixin_with_validation_invalid(): 'query_input, expected_column, expected_order_column_str', [ # Defaults - ({}, MockModelCls.unit_test_field, 'unit_test_field DESC'), + ({}, MockModelCls.test_field, 'test_field DESC'), ({'sorted_by': 'id'}, MockModelCls.id, 'id DESC'), - ({'sorting_direction': 'ASC'}, MockModelCls.unit_test_field, 'unit_test_field ASC'), + ({'sorting_direction': 'ASC'}, MockModelCls.test_field, 'test_field ASC'), # Explicit values ({'sorted_by': 'id', 'sorting_direction': 'ASC'}, MockModelCls.id, 'id ASC'), ({'sorted_by': 'id', 'sorting_direction': 'DESC'}, MockModelCls.id, 'id DESC'), - ({'sorted_by': 'unit_test_field', 'sorting_direction': 'ASC'}, MockModelCls.unit_test_field, 'unit_test_field ASC'), - ({'sorted_by': 'unit_test_field', 'sorting_direction': 'DESC'}, MockModelCls.unit_test_field, 'unit_test_field DESC'), - ] + ({'sorted_by': 'test_field', 'sorting_direction': 'ASC'}, MockModelCls.test_field, 'test_field ASC'), + ({'sorted_by': 'test_field', 'sorting_direction': 'DESC'}, MockModelCls.test_field, 'test_field DESC'), + ], ) def test_dataclass_with_sorting_mixin_with_validation(query_input, expected_column, expected_order_column_str): """ Test a dataclass that uses and customizes the SortingMixin with validation. """ @validataclass class UnitTestSortingQuery(SortingMixin): - sorted_by: str = AnyOfValidator(['id', 'unit_test_field']), Default('unit_test_field') + sorted_by: str = AnyOfValidator(['id', 'test_field']), Default('test_field') sorting_direction: SortingDirection = Default(SortingDirection.DESC) query_validator = DataclassValidator(UnitTestSortingQuery) From 66f321170dac263cfb139cd6f0d18de91b347863 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 19:19:00 +0100 Subject: [PATCH 17/23] Enable more strict mypy rules; fix typing issues --- pyproject.toml | 31 +++++++++---------- .../pagination/cursor_pagination_mixin.py | 13 +++++--- .../pagination/offset_pagination_mixin.py | 7 ++++- .../sorting/sorting_mixin.py | 10 ++++-- .../unit/pagination/paginated_result_test.py | 2 ++ 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 40f394f..b67f19c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,22 +24,21 @@ plugins = [ strict = true # Enable further checks that are not included in strict mode -# TODO: Enable more checks! -#disallow_any_unimported = true -#strict_equality_for_none = true -#warn_unreachable = true -#enable_error_code = [ -# "deprecated", -# "explicit-override", -# "ignore-without-code", -# "mutable-override", -# "possibly-undefined", -# "redundant-expr", -# "redundant-self", -# "truthy-bool", -# "truthy-iterable", -# "unused-awaitable", -#] +disallow_any_unimported = true +strict_equality_for_none = true +warn_unreachable = true +enable_error_code = [ + "deprecated", + "explicit-override", + "ignore-without-code", + "mutable-override", + "possibly-undefined", + "redundant-expr", + "redundant-self", + "truthy-bool", + "truthy-iterable", + "unused-awaitable", +] [[tool.mypy.overrides]] module = 'tests.*' diff --git a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py index d953248..f3ff54a 100644 --- a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Query from sqlalchemy.sql import ColumnElement +from typing_extensions import override from validataclass.dataclasses import validataclass, Default from validataclass.validators import IntegerValidator @@ -94,13 +95,14 @@ class ExampleSearchQuery(CursorPaginationMixin, BaseSearchQuery): # Limit: Number of entries per page limit: int | None = PaginationLimitValidator(max_value=100), Default(20) + @override def __init_subclass__(cls, **kwargs: Any): # Pagination mixins are not compatible with each other, only one can be used at the same time - if issubclass(cls, pagination.OffsetPaginationMixin): + if issubclass(cls, pagination.OffsetPaginationMixin): # type: ignore[unreachable] raise TypeError( f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed' ) - if issubclass(cls, sorting.SortingMixin): + if issubclass(cls, sorting.SortingMixin): # type: ignore[unreachable] raise TypeError( f'Invalid base classes in {cls}: CursorPaginationMixin cannot be combined with SortingMixin' ) @@ -131,6 +133,7 @@ def get_cursor_column(self, model_cls: Any) -> ColumnElement[Any]: # pretend it's always a ColumnElement to make the type checker happy. return cast(ColumnElement[Any], getattr(model_cls, self.get_cursor_column_name())) + @override def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the pagination parameters to an SQLAlchemy query and returns the new query. @@ -143,8 +146,8 @@ def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T] return query # The start parameter should always be set, but in case it is not, default to 0 - if self.start is None: - self.start = 0 + if self.start is None: # type: ignore[comparison-overlap] + self.start = 0 # type: ignore[unreachable] # Get the cursor column from the model class key_column = self.get_cursor_column(model_cls) @@ -156,12 +159,14 @@ def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T] .limit(self.limit) ) + @override def get_start_parameter_name(self) -> str: """ Returns the name of the pagination start parameter ("start" for cursor pagination). """ return 'start' + @override def get_next_start_value(self, paginated_result: PaginatedResult[Any]) -> int | None: """ Returns the next value for the pagination start parameter to retrieve the next page of data, or None if there diff --git a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py index 302dce7..26b5573 100644 --- a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py @@ -7,6 +7,7 @@ from typing import Any, TypeVar from sqlalchemy.orm import Query +from typing_extensions import override from validataclass.dataclasses import validataclass, Default from validataclass.validators import IntegerValidator @@ -89,13 +90,15 @@ class ExampleSearchQuery(OffsetPaginationMixin, BaseSearchQuery): # Limit: Number of entries per page limit: int | None = PaginationLimitValidator(max_value=100), Default(20) + @override def __init_subclass__(cls, **kwargs: Any): # Pagination mixins are not compatible with each other, only one can be used at the same time - if issubclass(cls, pagination.CursorPaginationMixin): + if issubclass(cls, pagination.CursorPaginationMixin): # type: ignore[unreachable] raise TypeError(f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed') super().__init_subclass__(**kwargs) + @override def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the pagination parameters to an SQLAlchemy query and returns the new query. @@ -108,12 +111,14 @@ def apply_pagination_to_query(self, query: Query[T], model_cls: Any) -> Query[T] return query.offset(self.offset).limit(self.limit) + @override def get_start_parameter_name(self) -> str: """ Returns the name of the pagination start parameter ("offset" for offset pagination). """ return 'offset' + @override def get_next_start_value(self, paginated_result: PaginatedResult[Any]) -> int | None: """ Returns the next value for the pagination start parameter to retrieve the next page of data, or None if there diff --git a/src/validataclass_search_queries/sorting/sorting_mixin.py b/src/validataclass_search_queries/sorting/sorting_mixin.py index 6a9d9e1..2743e60 100644 --- a/src/validataclass_search_queries/sorting/sorting_mixin.py +++ b/src/validataclass_search_queries/sorting/sorting_mixin.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Query from sqlalchemy.sql import ColumnElement +from typing_extensions import override from validataclass.dataclasses import validataclass, Default from validataclass.validators import AnyOfValidator @@ -60,6 +61,7 @@ class ExampleSearchQuery(SortingMixin, BaseSearchQuery): # Sorting direction ("ASC" or "DESC", case-insensitive) sorting_direction: SortingDirection = SortingDirectionValidator(), Default(SortingDirection.ASC) + @override def get_sorting_column(self, model_cls: Any) -> ColumnElement[Any]: """ Returns the column that the query should be ordered by (excluding the sorting direction). @@ -70,6 +72,7 @@ def get_sorting_column(self, model_cls: Any) -> ColumnElement[Any]: # pretend it's always a ColumnElement to make the type checker happy. return cast(ColumnElement[Any], getattr(model_cls, self.sorted_by)) + @override def apply_sorting_direction(self, column: ColumnElement[T]) -> ColumnElement[T]: """ Applies the sorting direction to an SQLAlchemy column element, i.e. `column.asc()` or `column.desc()`, and @@ -77,6 +80,7 @@ def apply_sorting_direction(self, column: ColumnElement[T]) -> ColumnElement[T]: """ return column.desc() if self.sorting_direction is SortingDirection.DESC else column.asc() + @override def apply_sorting_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: """ Applies the sorting parameters to an SQLAlchemy query (`query.order_by()`) and returns the new query. @@ -84,9 +88,9 @@ def apply_sorting_to_query(self, query: Query[T], model_cls: Any) -> Query[T]: The "model_cls" parameter should be the class of the database model that is queried. It is needed to get the sorting column from the model. """ - # If we want to disable sorting for some reason - if self.sorted_by is None: - return query + # If someone wants to disable sorting for some reason + if self.sorted_by is None: # type: ignore[comparison-overlap] + return query # type: ignore[unreachable] sorting_column = self.get_sorting_column(model_cls) return query.order_by(self.apply_sorting_direction(sorting_column)) diff --git a/tests/unit/pagination/paginated_result_test.py b/tests/unit/pagination/paginated_result_test.py index 95ab4be..1398313 100644 --- a/tests/unit/pagination/paginated_result_test.py +++ b/tests/unit/pagination/paginated_result_test.py @@ -7,6 +7,7 @@ from typing import Any import pytest +from typing_extensions import override from validataclass_search_queries.pagination import PaginatedResult @@ -17,6 +18,7 @@ class MockItem: def __init__(self, name: str): self.name = name + @override def __eq__(self, other): return type(self) is type(other) and self.name == other.name From 5668517e88606d10ecc005a1a878618a5e2558a2 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 19:21:12 +0100 Subject: [PATCH 18/23] Reformatting; reduce max-line-length to 120 --- .../search_params/search_param_boolean.py | 2 +- .../search_params/search_param_custom.py | 2 +- .../search_params/search_param_equals.py | 2 +- .../search_param_greater_less.py | 2 +- .../search_param_multi_select.py | 2 +- .../search_params/search_param_substring.py | 2 +- .../pagination/paginated_result.py | 2 +- .../pagination/pagination_limit_validator.py | 4 +-- .../pagination/response_helpers.py | 11 ++++---- .../search_query_repository_mixin.py | 25 +++++++++++-------- .../unit/pagination/paginated_result_test.py | 4 ++- tox.ini | 5 +--- 12 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/validataclass_search_queries/filters/search_params/search_param_boolean.py b/src/validataclass_search_queries/filters/search_params/search_param_boolean.py index 04b58d0..0805e79 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_boolean.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_boolean.py @@ -5,9 +5,9 @@ """ from typing import Any -from typing_extensions import override from sqlalchemy.sql import ColumnElement +from typing_extensions import override from .base_search_param import SearchParam diff --git a/src/validataclass_search_queries/filters/search_params/search_param_custom.py b/src/validataclass_search_queries/filters/search_params/search_param_custom.py index 28aa622..134b06d 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_custom.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_custom.py @@ -5,9 +5,9 @@ """ from typing import Any -from typing_extensions import override from sqlalchemy.sql import ColumnElement +from typing_extensions import override from .base_search_param import SearchParam diff --git a/src/validataclass_search_queries/filters/search_params/search_param_equals.py b/src/validataclass_search_queries/filters/search_params/search_param_equals.py index db7a87c..f1e9bb9 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_equals.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_equals.py @@ -5,9 +5,9 @@ """ from typing import Any -from typing_extensions import override from sqlalchemy.sql import ColumnElement +from typing_extensions import override from .base_search_param import SearchParam diff --git a/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py b/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py index 64b467c..ec13c58 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_greater_less.py @@ -5,9 +5,9 @@ """ from typing import Any -from typing_extensions import override from sqlalchemy.sql import ColumnElement +from typing_extensions import override from .base_search_param import SearchParam diff --git a/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py b/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py index 7f19716..4bc61c2 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_multi_select.py @@ -5,9 +5,9 @@ """ from typing import Any -from typing_extensions import override from sqlalchemy.sql import ColumnElement +from typing_extensions import override from .base_search_param import SearchParam diff --git a/src/validataclass_search_queries/filters/search_params/search_param_substring.py b/src/validataclass_search_queries/filters/search_params/search_param_substring.py index bf09780..5ef3dc4 100644 --- a/src/validataclass_search_queries/filters/search_params/search_param_substring.py +++ b/src/validataclass_search_queries/filters/search_params/search_param_substring.py @@ -5,9 +5,9 @@ """ from typing import Any -from typing_extensions import override from sqlalchemy.sql import ColumnElement +from typing_extensions import override from .base_search_param import SearchParam diff --git a/src/validataclass_search_queries/pagination/paginated_result.py b/src/validataclass_search_queries/pagination/paginated_result.py index fd8114f..0e7dcd3 100644 --- a/src/validataclass_search_queries/pagination/paginated_result.py +++ b/src/validataclass_search_queries/pagination/paginated_result.py @@ -58,7 +58,7 @@ def map_customers(customer: Customer) -> dict: # This results in a PaginatedResult[dict] containing dictionaries as defined in map_customers above: mapped_customers = paginated_result.map(map_customers) - # Assuming that the Customer class has a similar method `to_dict()` that takes no arguments, we can also do this: + # If the Customer class has a similar method `to_dict()` that takes no arguments, we can also do this: mapped_customers = paginated_result.map(Customers.to_dict) ``` """ diff --git a/src/validataclass_search_queries/pagination/pagination_limit_validator.py b/src/validataclass_search_queries/pagination/pagination_limit_validator.py index 2c07ea8..a090b17 100644 --- a/src/validataclass_search_queries/pagination/pagination_limit_validator.py +++ b/src/validataclass_search_queries/pagination/pagination_limit_validator.py @@ -64,8 +64,8 @@ def __init__( meaning that pagination is disabled (i.e. unlimited results). Parameters: - optional: Boolean, whether pagination is optional, i.e. the user can set limit=0 to disable pagination (default: False) - max_value: Integer or None, maximum value for pagination limit (default: IntegerValidator.DEFAULT_MAX_VALUE = 2147483647) + `optional`: bool, whether pagination can be disabled by setting limit to 0 (default: False) + `max_value`: int or None, maximum value for pagination limit (default: `IntegerValidator.DEFAULT_MAX_VALUE`) """ # Initialize base integer validator self.integer_validator = IntegerValidator( diff --git a/src/validataclass_search_queries/pagination/response_helpers.py b/src/validataclass_search_queries/pagination/response_helpers.py index 3507903..d44ccde 100644 --- a/src/validataclass_search_queries/pagination/response_helpers.py +++ b/src/validataclass_search_queries/pagination/response_helpers.py @@ -53,9 +53,9 @@ def paginated_api_response( "total_count" is the total number of results before pagination (i.e. if there are 123 results and the page limit is 10, there will be 10 items, but "total_count" will be 123). - If there might be a next page (which is determined e.g. by the number of results and the given pagination parameters), - there will be another field that contains the start value for the next page. In case of offset pagination, this - field will be called "next_offset", in case of cursor pagination, it will be called "next_id". + If it is possible that there is a next page (which is determined e.g. by the number of results and the given + pagination parameters), there will be another field that contains the start value for the next page. For offset + pagination, this field will be called "next_offset", for cursor pagination, it will be called "next_id". Additionally, if the optional parameter `request_path` is set, another field called "next_path" will be added to the response, containing the URL path with query parameters that can be used to retrieve the next page. This string @@ -83,14 +83,15 @@ def paginated_api_response( if next_start_value is None: return response_data - # Write next start parameter to response. For legacy reasons, cursor pagination uses "next_id" instead of "next_start". + # Add next start parameter to response. For compatibility reasons, cursor pagination uses "next_id" instead of + # "next_start". # TODO: This might be changed in the future, but we need to keep compatibility somehow... start_param = search_query.get_start_parameter_name() response_data['next_id' if start_param == 'start' else f'next_{start_param}'] = next_start_value # Only set next_path if a request base path is given if request_path is not None: - # Construct parameters for next page from original request parameters (if given). Ensure limit parameter is set. + # Construct parameters for next page from original request (if given). Ensure limit parameter is set. next_path_params = dict(original_params) if original_params is not None else {} next_path_params.update({ start_param: next_start_value, diff --git a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py index 18e7950..261f2fb 100644 --- a/src/validataclass_search_queries/repositories/search_query_repository_mixin.py +++ b/src/validataclass_search_queries/repositories/search_query_repository_mixin.py @@ -85,8 +85,9 @@ def fetch_examples(self, *, search_query: BaseSearchQuery | None = None) -> Pagi The default implementation of `_apply_bound_search_filter()` first gets the column of the model class with the name specified in the SearchParam (`getattr(self.model_cls, bound_filter.column_name)` and applies the search filter to - this column (`query.filter(bound_filter.get_sqlalchemy_filter(col))`). For example, with a `SearchParamSince('created')` - and the `Example` model, the "since" filter (i.e. `>=`) would be applied to the column `Example.created`. + this column (`query.filter(bound_filter.get_sqlalchemy_filter(col))`). For example, with a search parameter + `SearchParamSince('created')` and the `Example` model, the "since" filter (i.e. `>=`) would be applied to the + column `Example.created`. If you need to, you can override this method, choose a different column (maybe even from a different model, or using some SQL functions) and apply the search filter to this column. @@ -148,7 +149,8 @@ def model_cls(self) -> type[T_Model]: def _search_and_paginate(self, query: Query[Any], search_query: BaseSearchQuery | None) -> PaginatedResult[T_Model]: """ - Filters a query based on search parameters (usually parsed from HTTP query parameters) and paginates the result. + Apply filters, sorting and pagination to a database query, based on search parameters (usually parsed from + HTTP query parameters), then execute the query and return a paginated list of results. Shortcut method for calling `_filter_by_search_query()`, `_order_by_search_query()` and `_paginate_result()`. """ @@ -158,7 +160,8 @@ def _search_and_paginate(self, query: Query[Any], search_query: BaseSearchQuery def _filter_by_search_query(self, query: Query[T_Query], search_query: BaseSearchQuery | None) -> Query[T_Query]: """ - Filters a query based on search parameters (usually parsed from HTTP query parameters), *excluding* pagination. + Apply filters to a database query, based on search parameters (usually parsed from HTTP query parameters). + This does not include sorting or pagination! If no search query is given (or no search parameter is set), the database query is returned unmodified. """ @@ -173,7 +176,8 @@ def _filter_by_search_query(self, query: Query[T_Query], search_query: BaseSearc def _apply_bound_search_filter(self, query: Query[T_Query], bound_filter: BoundSearchFilter) -> Query[T_Query]: """ - Filters a query based on a BoundSearchFilter. Called by _filter_by_search_query() for every set search filter. + Apply a single search filter from a `BoundSearchFilter` to a database query with `query.filter(...)`. + Called by `_filter_by_search_query()` for every set search filter. Override this method to implement custom handling for (all or specific) search filters. """ @@ -183,7 +187,7 @@ def _apply_bound_search_filter(self, query: Query[T_Query], bound_filter: BoundS def _order_by_search_query(self, query: Query[T_Query], search_query: BaseSearchQuery | None) -> Query[T_Query]: """ - Applies sorting (order_by) to a query based on sorting parameters from a search query. + Apply sorting (`query.order_by(...)`) to a database query based on sorting parameters from a search query. If the search query does not implement sorting (i.e. it does not inherit from `AbstractSortingMixin`), the database query is returned unmodified. @@ -195,13 +199,14 @@ def _order_by_search_query(self, query: Query[T_Query], search_query: BaseSearch def _paginate_result(self, query: Query[Any], search_query: BaseSearchQuery | None) -> PaginatedResult[T_Model]: """ - Applies pagination to a query based on search parameters, executes the query and returns a paginated result list. + Apply pagination to a database query based on search parameters, execute the query and return a paginated list + of results. - To define pagination parameters in your search query dataclass, use a pagination mixin like OffsetPaginationMixin - or StablePaginationMixin. + To define pagination parameters in your search query dataclass, use a pagination mixin class like + `OffsetPaginationMixin` or `CursorPaginationMixin`. If the search query does not implement pagination (i.e. it does not inherit from `AbstractPaginationMixin`), - a PaginatedResult with ALL results is returned (as if the pagination limit was set to infinity). + a `PaginatedResult` with ALL results is returned (as if there was no pagination limit). """ # Get total count of search results BEFORE pagination is applied total_count = query.count() diff --git a/tests/unit/pagination/paginated_result_test.py b/tests/unit/pagination/paginated_result_test.py index 1398313..8893b65 100644 --- a/tests/unit/pagination/paginated_result_test.py +++ b/tests/unit/pagination/paginated_result_test.py @@ -87,7 +87,9 @@ def test_paginated_result(input_list, total_count): ] ) def test_paginated_result_to_dict_basic_types(paginated_result, expected_dict): - """ Test PaginatedResult.to_dict() with basic types and objects without to_dict() method (recursive and non-recursive). """ + """ + Test PaginatedResult.to_dict() with basic types and objects without to_dict() method (recursive and non-recursive). + """ assert paginated_result.to_dict() == expected_dict assert paginated_result.to_dict(recursive=False) == expected_dict assert paginated_result.to_dict(recursive=True) == expected_dict diff --git a/tox.ini b/tox.ini index cd3e6e4..d99d2eb 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,9 @@ envlist = clean,py{310,311,312,313,314}-sqlalchemy{1.4,2.0},report,flake8,mypy skip_missing_interpreters = true [flake8] -max-line-length = 140 +max-line-length = 120 exclude = _version.py ignore = -per-file-ignores = - # False positives for "unused imports" in __init__.py - __init__.py: F401 [testenv] extras = testing From 1945f502a9132350274522270c86dbee178e13ee Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 19:21:38 +0100 Subject: [PATCH 19/23] Add py.typed file to make the package PEP 561 compatible --- src/validataclass_search_queries/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/validataclass_search_queries/py.typed diff --git a/src/validataclass_search_queries/py.typed b/src/validataclass_search_queries/py.typed new file mode 100644 index 0000000..e69de29 From 0581805ecbc9d30ee9e3c6190907cbe810fbc701 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Thu, 26 Mar 2026 19:22:30 +0100 Subject: [PATCH 20/23] CI: Run mypy in test workflow --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1b2299b..cdf2900 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,7 +44,7 @@ jobs: - name: Run test suite with tox # Run tox using the version of Python in `PATH` - run: tox run -e clean,py-sqlalchemy${{ matrix.sqlalchemy-version }},report,flake8 -- --junit-xml=reports/pytest_${{ matrix.python-version }}_sqlalchemy${{ matrix.sqlalchemy-version }}.xml + run: tox run -e clean,py-sqlalchemy${{ matrix.sqlalchemy-version }},report,flake8,mypy -- --junit-xml=reports/pytest_${{ matrix.python-version }}_sqlalchemy${{ matrix.sqlalchemy-version }}.xml - name: Upload test result artifacts uses: actions/upload-artifact@v4 From 65396987eba85833f2ebd5cd6560ebf3ed873012 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 14 Apr 2026 18:02:03 +0200 Subject: [PATCH 21/23] Pin testing dependencies to minor versions instead of major versions --- setup.cfg | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/setup.cfg b/setup.cfg index baf3d06..182692b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,9 +40,11 @@ install_requires = where = src [options.extras_require] +# Set minimum versions but allow patch level updates with "~= x.y.z" to avoid breaking tests or similar, e.g. when mypy +# changes how error messages look in a minor version update. testing = - pytest ~= 9.0 - pytest-cov ~= 7.0 - coverage ~= 7.13 - flake8 ~= 7.3 - mypy ~= 1.19 + pytest ~= 9.0.3 + pytest-cov ~= 7.0.0 + coverage ~= 7.13.5 + flake8 ~= 7.3.0 + mypy ~= 1.19.1 From 9de66d7c1a5ba5fd88f59692019c598a7aef212b Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 14 Apr 2026 18:06:35 +0200 Subject: [PATCH 22/23] Update to final release of validataclass 0.12.0 --- setup.cfg | 4 +--- tox.ini | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/setup.cfg b/setup.cfg index 182692b..41bdb86 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,9 +31,7 @@ python_requires = ~=3.10 install_requires = typing-extensions ~= 4.15 # Allow validataclass 0.12.* - #validataclass >= 0.12.0, < 0.13.0 - # TODO: 0.12.0 is not released yet. For testing we need to build and install validataclass manually (see tox.ini). - validataclass == 0.12.0a1.dev1 + validataclass >= 0.12.0, < 0.13.0 sqlalchemy >= 1.4, < 2.1 [options.packages.find] diff --git a/tox.ini b/tox.ini index d99d2eb..e31e24c 100644 --- a/tox.ini +++ b/tox.ini @@ -12,11 +12,6 @@ ignore = extras = testing commands = python -m pytest --cov --cov-append {posargs} -# TODO: 0.12.0 is not released yet. For testing we need to build and install validataclass manually. -# TODO: (In the validataclass repo, run `make build`. Then copy the resulting .whl file to _tmp.) -deps = - ./_tmp/validataclass-0.12.0a1.dev1-py3-none-any.whl - [testenv:flake8] commands = flake8 src/ tests/ From 2c785c0ce512285dce5341bcde8006fc6636bb54 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 14 Apr 2026 18:27:10 +0200 Subject: [PATCH 23/23] Fix weird mypy thing that only happens on older Python versions --- .../pagination/cursor_pagination_mixin.py | 4 ++-- .../pagination/offset_pagination_mixin.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py index f3ff54a..3846e78 100644 --- a/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/cursor_pagination_mixin.py @@ -98,11 +98,11 @@ class ExampleSearchQuery(CursorPaginationMixin, BaseSearchQuery): @override def __init_subclass__(cls, **kwargs: Any): # Pagination mixins are not compatible with each other, only one can be used at the same time - if issubclass(cls, pagination.OffsetPaginationMixin): # type: ignore[unreachable] + if issubclass(cls, pagination.OffsetPaginationMixin): # type: ignore[unreachable, unused-ignore] raise TypeError( f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed' ) - if issubclass(cls, sorting.SortingMixin): # type: ignore[unreachable] + if issubclass(cls, sorting.SortingMixin): # type: ignore[unreachable, unused-ignore] raise TypeError( f'Invalid base classes in {cls}: CursorPaginationMixin cannot be combined with SortingMixin' ) diff --git a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py index 26b5573..258f24d 100644 --- a/src/validataclass_search_queries/pagination/offset_pagination_mixin.py +++ b/src/validataclass_search_queries/pagination/offset_pagination_mixin.py @@ -93,7 +93,7 @@ class ExampleSearchQuery(OffsetPaginationMixin, BaseSearchQuery): @override def __init_subclass__(cls, **kwargs: Any): # Pagination mixins are not compatible with each other, only one can be used at the same time - if issubclass(cls, pagination.CursorPaginationMixin): # type: ignore[unreachable] + if issubclass(cls, pagination.CursorPaginationMixin): # type: ignore[unreachable, unused-ignore] raise TypeError(f'Invalid base classes in {cls}: Combining multiple pagination mixins is not allowed') super().__init_subclass__(**kwargs)