From e6e9dbaabff8039f742c0f616a4aa2bdcbe3afd8 Mon Sep 17 00:00:00 2001 From: "min.tang" Date: Thu, 24 Oct 2024 16:10:37 +0800 Subject: [PATCH] wip: support pydantic v1 & v2 --- docs/changelog.en.md | 5 + docs/changelog.zh.md | 5 + pydantic_resolve/compat.py | 4 + pydantic_resolve/core.py | 17 +- pydantic_resolve/util.py | 249 ++++++++++++++++-- tests/{resolver => pydantic_v1}/__init__.py | 0 tests/{ => pydantic_v1}/core/test_field.py | 0 .../core/test_field_dataclass.py | 0 .../core/test_field_dataclass_anno.py | 0 .../{ => pydantic_v1}/core/test_field_mix.py | 0 .../core/test_field_pydantic.py | 0 .../core/test_field_pydantic_error.py | 0 ...eld_validate_and_create_loader_instance.py | 0 tests/{ => pydantic_v1}/core/test_input.py | 0 .../core/test_scan_post_method.py | 0 .../core/test_scan_resolve_method.py | 0 .../core/test_specific_type.py | 0 .../resolver}/__init__.py | 0 .../resolver/test_0_depends.py | 4 - .../test_10_sqlalchemy_query_with_change.py | 0 .../test_11_sqlalchemy_query_global_filter.py | 0 .../test_12_loader_global_filter_exception.py | 0 .../resolver/test_13_check_wrong_type.py | 0 .../resolver/test_14_check_loader_name.py | 4 +- .../resolver/test_14_deps/__init__.py | 0 .../resolver/test_14_deps/mod_a.py | 0 .../resolver/test_14_deps/mod_b.py | 0 .../resolver/test_15_support_batch_load_fn.py | 0 .../resolver/test_16_mapper.py | 0 .../resolver/test_16_mapper_1.py | 0 .../resolver/test_16_mapper_2.py | 0 .../resolver/test_16_mapper_3.py | 0 .../resolver/test_16_mapper_4.py | 0 .../resolver/test_16_mapper_5.py | 0 .../resolver/test_16_mapper_6.py | 0 .../resolver/test_17_mapper_deep.py | 0 .../resolver/test_18_post_methods.py | 0 .../test_19_post_methods_exception.py | 0 .../resolver/test_1_pydantic_resolve.py | 0 .../resolver/test_20_loader_instance.py | 0 .../test_21_not_stop_by_idle_level.py | 0 ...est_22_not_stop_by_idle_level_complex_1.py | 0 ...est_22_not_stop_by_idle_level_complex_2.py | 0 .../test_23_parse_to_obj_for_pydantic.py | 0 .../test_24_parse_to_obj_for_dataclass.py | 0 ...rse_to_obj_for_pydantic_with_annotation.py | 0 .../resolver/test_26_tree.py | 0 .../resolver/test_27_context.py | 0 ...se_to_obj_for_dataclass_with_annotation.py | 0 .../test_29_better_warning_of_list.py | 0 .../resolver/test_2_resolve_object.py | 0 .../resolver/test_30_loader_in_object.py | 0 ...31_dynamic_variable_for_descdant_loader.py | 0 ..._variable_for_descdant_loader_exception.py | 0 .../resolver/test_33_global_loader_filter.py | 0 .../test_34_filter_rename_deprecation.py | 0 .../resolver/test_35_collector.py | 0 .../test_36_collector_level_by_level.py | 0 .../resolver/test_37_specific_types.py | 0 .../resolver/test_38_parent.py | 0 .../resolver/test_39_post_async.py | 0 .../resolver/test_3_tuple_list.py | 0 .../test_40_multiple_collect_source.py | 0 .../test_41_validate_collect_relationship.py | 0 .../resolver/test_4_resolve_return_types.py | 0 .../resolver/test_5_exception.py | 0 .../resolver/test_6_resolve_dataclass.py | 0 .../resolver/test_7_sqlalchemy_query.py | 0 .../resolver/test_8_loader_depend.py | 0 .../test_9_sqlalchemy_query_fix_cache.py | 0 .../utils/test_2_ensure_subset.py | 0 tests/{ => pydantic_v1}/utils/test_merge.py | 0 .../utils/test_model_config.py | 5 + tests/{ => pydantic_v1}/utils/test_output.py | 0 .../{ => pydantic_v1}/utils/test_output_2.py | 0 tests/{ => pydantic_v1}/utils/test_parse.py | 0 .../utils/test_parse_forward_ref.py | 0 tests/{ => pydantic_v1}/utils/test_utils.py | 0 tests/pydantic_v2/__init__.py | 0 tests/pydantic_v2/core/test_field.py | 13 + .../pydantic_v2/core/test_field_dataclass.py | 78 ++++++ .../core/test_field_dataclass_anno.py | 114 ++++++++ tests/pydantic_v2/core/test_field_mix.py | 40 +++ tests/pydantic_v2/core/test_field_pydantic.py | 162 ++++++++++++ .../core/test_field_pydantic_error.py | 27 ++ ...eld_validate_and_create_loader_instance.py | 90 +++++++ tests/pydantic_v2/core/test_input.py | 15 ++ .../pydantic_v2/core/test_scan_post_method.py | 68 +++++ .../core/test_scan_resolve_method.py | 74 ++++++ tests/pydantic_v2/core/test_specific_type.py | 27 ++ tests/pydantic_v2/resolver/__init__.py | 0 tests/pydantic_v2/resolver/test_0_depends.py | 68 +++++ .../test_10_sqlalchemy_query_with_change.py | 197 ++++++++++++++ .../test_11_sqlalchemy_query_global_filter.py | 206 +++++++++++++++ .../test_12_loader_global_filter_exception.py | 45 ++++ .../resolver/test_13_check_wrong_type.py | 16 ++ .../resolver/test_14_check_loader_name.py | 37 +++ .../resolver/test_14_deps/__init__.py | 0 .../resolver/test_14_deps/mod_a.py | 16 ++ .../resolver/test_14_deps/mod_b.py | 17 ++ .../resolver/test_15_support_batch_load_fn.py | 38 +++ tests/pydantic_v2/resolver/test_16_mapper.py | 229 ++++++++++++++++ .../resolver/test_17_mapper_deep.py | 155 +++++++++++ .../resolver/test_18_post_methods.py | 84 ++++++ .../test_19_post_methods_exception.py | 71 +++++ .../resolver/test_1_pydantic_resolve.py | 41 +++ .../resolver/test_20_loader_instance.py | 105 ++++++++ .../test_21_not_stop_by_idle_level.py | 52 ++++ .../test_22_not_stop_by_idle_level_complex.py | 85 ++++++ .../test_23_parse_to_obj_for_pydantic.py | 179 +++++++++++++ .../test_24_parse_to_obj_for_dataclass.py | 38 +++ ...rse_to_obj_for_pydantic_with_annotation.py | 41 +++ tests/pydantic_v2/resolver/test_26_tree.py | 54 ++++ tests/pydantic_v2/resolver/test_27_context.py | 72 +++++ ...se_to_obj_for_dataclass_with_annotation.py | 49 ++++ .../test_29_better_warning_of_list.py | 116 ++++++++ .../resolver/test_2_resolve_object.py | 75 ++++++ .../resolver/test_30_loader_in_object.py | 59 +++++ ...31_dynamic_variable_for_descdant_loader.py | 69 +++++ ..._variable_for_descdant_loader_exception.py | 108 ++++++++ .../resolver/test_33_global_loader_filter.py | 70 +++++ .../test_34_filter_rename_deprecation.py | 59 +++++ .../pydantic_v2/resolver/test_35_collector.py | 85 ++++++ .../test_36_collector_level_by_level.py | 84 ++++++ .../resolver/test_37_specific_types.py | 17 ++ tests/pydantic_v2/resolver/test_39_parent.py | 76 ++++++ .../pydantic_v2/resolver/test_3_tuple_list.py | 28 ++ .../resolver/test_4_resolve_return_types.py | 42 +++ .../pydantic_v2/resolver/test_5_exception.py | 49 ++++ .../resolver/test_6_resolve_dataclass.py | 38 +++ .../resolver/test_7_sqlalchemy_query.py | 164 ++++++++++++ .../resolver/test_8_loader_depend.py | 54 ++++ .../test_9_sqlalchemy_query_fix_cache.py | 162 ++++++++++++ .../pydantic_v2/utils/test_2_ensure_subset.py | 54 ++++ tests/pydantic_v2/utils/test_model_config.py | 47 ++++ tests/pydantic_v2/utils/test_output.py | 66 +++++ tests/pydantic_v2/utils/test_output_2.py | 35 +++ tests/pydantic_v2/utils/test_parse.py | 81 ++++++ .../utils/test_parse_forward_ref.py | 50 ++++ tests/pydantic_v2/utils/test_utils.py | 175 ++++++++++++ tox.ini | 54 +++- 141 files changed, 4763 insertions(+), 50 deletions(-) create mode 100644 pydantic_resolve/compat.py rename tests/{resolver => pydantic_v1}/__init__.py (100%) rename tests/{ => pydantic_v1}/core/test_field.py (100%) rename tests/{ => pydantic_v1}/core/test_field_dataclass.py (100%) rename tests/{ => pydantic_v1}/core/test_field_dataclass_anno.py (100%) rename tests/{ => pydantic_v1}/core/test_field_mix.py (100%) rename tests/{ => pydantic_v1}/core/test_field_pydantic.py (100%) rename tests/{ => pydantic_v1}/core/test_field_pydantic_error.py (100%) rename tests/{ => pydantic_v1}/core/test_field_validate_and_create_loader_instance.py (100%) rename tests/{ => pydantic_v1}/core/test_input.py (100%) rename tests/{ => pydantic_v1}/core/test_scan_post_method.py (100%) rename tests/{ => pydantic_v1}/core/test_scan_resolve_method.py (100%) rename tests/{ => pydantic_v1}/core/test_specific_type.py (100%) rename tests/{resolver/test_14_deps => pydantic_v1/resolver}/__init__.py (100%) rename tests/{ => pydantic_v1}/resolver/test_0_depends.py (93%) rename tests/{ => pydantic_v1}/resolver/test_10_sqlalchemy_query_with_change.py (100%) rename tests/{ => pydantic_v1}/resolver/test_11_sqlalchemy_query_global_filter.py (100%) rename tests/{ => pydantic_v1}/resolver/test_12_loader_global_filter_exception.py (100%) rename tests/{ => pydantic_v1}/resolver/test_13_check_wrong_type.py (100%) rename tests/{ => pydantic_v1}/resolver/test_14_check_loader_name.py (91%) create mode 100644 tests/pydantic_v1/resolver/test_14_deps/__init__.py rename tests/{ => pydantic_v1}/resolver/test_14_deps/mod_a.py (100%) rename tests/{ => pydantic_v1}/resolver/test_14_deps/mod_b.py (100%) rename tests/{ => pydantic_v1}/resolver/test_15_support_batch_load_fn.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper_1.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper_2.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper_3.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper_4.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper_5.py (100%) rename tests/{ => pydantic_v1}/resolver/test_16_mapper_6.py (100%) rename tests/{ => pydantic_v1}/resolver/test_17_mapper_deep.py (100%) rename tests/{ => pydantic_v1}/resolver/test_18_post_methods.py (100%) rename tests/{ => pydantic_v1}/resolver/test_19_post_methods_exception.py (100%) rename tests/{ => pydantic_v1}/resolver/test_1_pydantic_resolve.py (100%) rename tests/{ => pydantic_v1}/resolver/test_20_loader_instance.py (100%) rename tests/{ => pydantic_v1}/resolver/test_21_not_stop_by_idle_level.py (100%) rename tests/{ => pydantic_v1}/resolver/test_22_not_stop_by_idle_level_complex_1.py (100%) rename tests/{ => pydantic_v1}/resolver/test_22_not_stop_by_idle_level_complex_2.py (100%) rename tests/{ => pydantic_v1}/resolver/test_23_parse_to_obj_for_pydantic.py (100%) rename tests/{ => pydantic_v1}/resolver/test_24_parse_to_obj_for_dataclass.py (100%) rename tests/{ => pydantic_v1}/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py (100%) rename tests/{ => pydantic_v1}/resolver/test_26_tree.py (100%) rename tests/{ => pydantic_v1}/resolver/test_27_context.py (100%) rename tests/{ => pydantic_v1}/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py (100%) rename tests/{ => pydantic_v1}/resolver/test_29_better_warning_of_list.py (100%) rename tests/{ => pydantic_v1}/resolver/test_2_resolve_object.py (100%) rename tests/{ => pydantic_v1}/resolver/test_30_loader_in_object.py (100%) rename tests/{ => pydantic_v1}/resolver/test_31_dynamic_variable_for_descdant_loader.py (100%) rename tests/{ => pydantic_v1}/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py (100%) rename tests/{ => pydantic_v1}/resolver/test_33_global_loader_filter.py (100%) rename tests/{ => pydantic_v1}/resolver/test_34_filter_rename_deprecation.py (100%) rename tests/{ => pydantic_v1}/resolver/test_35_collector.py (100%) rename tests/{ => pydantic_v1}/resolver/test_36_collector_level_by_level.py (100%) rename tests/{ => pydantic_v1}/resolver/test_37_specific_types.py (100%) rename tests/{ => pydantic_v1}/resolver/test_38_parent.py (100%) rename tests/{ => pydantic_v1}/resolver/test_39_post_async.py (100%) rename tests/{ => pydantic_v1}/resolver/test_3_tuple_list.py (100%) rename tests/{ => pydantic_v1}/resolver/test_40_multiple_collect_source.py (100%) rename tests/{ => pydantic_v1}/resolver/test_41_validate_collect_relationship.py (100%) rename tests/{ => pydantic_v1}/resolver/test_4_resolve_return_types.py (100%) rename tests/{ => pydantic_v1}/resolver/test_5_exception.py (100%) rename tests/{ => pydantic_v1}/resolver/test_6_resolve_dataclass.py (100%) rename tests/{ => pydantic_v1}/resolver/test_7_sqlalchemy_query.py (100%) rename tests/{ => pydantic_v1}/resolver/test_8_loader_depend.py (100%) rename tests/{ => pydantic_v1}/resolver/test_9_sqlalchemy_query_fix_cache.py (100%) rename tests/{ => pydantic_v1}/utils/test_2_ensure_subset.py (100%) rename tests/{ => pydantic_v1}/utils/test_merge.py (100%) rename tests/{ => pydantic_v1}/utils/test_model_config.py (97%) rename tests/{ => pydantic_v1}/utils/test_output.py (100%) rename tests/{ => pydantic_v1}/utils/test_output_2.py (100%) rename tests/{ => pydantic_v1}/utils/test_parse.py (100%) rename tests/{ => pydantic_v1}/utils/test_parse_forward_ref.py (100%) rename tests/{ => pydantic_v1}/utils/test_utils.py (100%) create mode 100644 tests/pydantic_v2/__init__.py create mode 100644 tests/pydantic_v2/core/test_field.py create mode 100644 tests/pydantic_v2/core/test_field_dataclass.py create mode 100644 tests/pydantic_v2/core/test_field_dataclass_anno.py create mode 100644 tests/pydantic_v2/core/test_field_mix.py create mode 100644 tests/pydantic_v2/core/test_field_pydantic.py create mode 100644 tests/pydantic_v2/core/test_field_pydantic_error.py create mode 100644 tests/pydantic_v2/core/test_field_validate_and_create_loader_instance.py create mode 100644 tests/pydantic_v2/core/test_input.py create mode 100644 tests/pydantic_v2/core/test_scan_post_method.py create mode 100644 tests/pydantic_v2/core/test_scan_resolve_method.py create mode 100644 tests/pydantic_v2/core/test_specific_type.py create mode 100644 tests/pydantic_v2/resolver/__init__.py create mode 100644 tests/pydantic_v2/resolver/test_0_depends.py create mode 100644 tests/pydantic_v2/resolver/test_10_sqlalchemy_query_with_change.py create mode 100644 tests/pydantic_v2/resolver/test_11_sqlalchemy_query_global_filter.py create mode 100644 tests/pydantic_v2/resolver/test_12_loader_global_filter_exception.py create mode 100644 tests/pydantic_v2/resolver/test_13_check_wrong_type.py create mode 100644 tests/pydantic_v2/resolver/test_14_check_loader_name.py create mode 100644 tests/pydantic_v2/resolver/test_14_deps/__init__.py create mode 100644 tests/pydantic_v2/resolver/test_14_deps/mod_a.py create mode 100644 tests/pydantic_v2/resolver/test_14_deps/mod_b.py create mode 100644 tests/pydantic_v2/resolver/test_15_support_batch_load_fn.py create mode 100644 tests/pydantic_v2/resolver/test_16_mapper.py create mode 100644 tests/pydantic_v2/resolver/test_17_mapper_deep.py create mode 100644 tests/pydantic_v2/resolver/test_18_post_methods.py create mode 100644 tests/pydantic_v2/resolver/test_19_post_methods_exception.py create mode 100644 tests/pydantic_v2/resolver/test_1_pydantic_resolve.py create mode 100644 tests/pydantic_v2/resolver/test_20_loader_instance.py create mode 100644 tests/pydantic_v2/resolver/test_21_not_stop_by_idle_level.py create mode 100644 tests/pydantic_v2/resolver/test_22_not_stop_by_idle_level_complex.py create mode 100644 tests/pydantic_v2/resolver/test_23_parse_to_obj_for_pydantic.py create mode 100644 tests/pydantic_v2/resolver/test_24_parse_to_obj_for_dataclass.py create mode 100644 tests/pydantic_v2/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py create mode 100644 tests/pydantic_v2/resolver/test_26_tree.py create mode 100644 tests/pydantic_v2/resolver/test_27_context.py create mode 100644 tests/pydantic_v2/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py create mode 100644 tests/pydantic_v2/resolver/test_29_better_warning_of_list.py create mode 100644 tests/pydantic_v2/resolver/test_2_resolve_object.py create mode 100644 tests/pydantic_v2/resolver/test_30_loader_in_object.py create mode 100644 tests/pydantic_v2/resolver/test_31_dynamic_variable_for_descdant_loader.py create mode 100644 tests/pydantic_v2/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py create mode 100644 tests/pydantic_v2/resolver/test_33_global_loader_filter.py create mode 100644 tests/pydantic_v2/resolver/test_34_filter_rename_deprecation.py create mode 100644 tests/pydantic_v2/resolver/test_35_collector.py create mode 100644 tests/pydantic_v2/resolver/test_36_collector_level_by_level.py create mode 100644 tests/pydantic_v2/resolver/test_37_specific_types.py create mode 100644 tests/pydantic_v2/resolver/test_39_parent.py create mode 100644 tests/pydantic_v2/resolver/test_3_tuple_list.py create mode 100644 tests/pydantic_v2/resolver/test_4_resolve_return_types.py create mode 100644 tests/pydantic_v2/resolver/test_5_exception.py create mode 100644 tests/pydantic_v2/resolver/test_6_resolve_dataclass.py create mode 100644 tests/pydantic_v2/resolver/test_7_sqlalchemy_query.py create mode 100644 tests/pydantic_v2/resolver/test_8_loader_depend.py create mode 100644 tests/pydantic_v2/resolver/test_9_sqlalchemy_query_fix_cache.py create mode 100644 tests/pydantic_v2/utils/test_2_ensure_subset.py create mode 100644 tests/pydantic_v2/utils/test_model_config.py create mode 100644 tests/pydantic_v2/utils/test_output.py create mode 100644 tests/pydantic_v2/utils/test_output_2.py create mode 100644 tests/pydantic_v2/utils/test_parse.py create mode 100644 tests/pydantic_v2/utils/test_parse_forward_ref.py create mode 100644 tests/pydantic_v2/utils/test_utils.py diff --git a/docs/changelog.en.md b/docs/changelog.en.md index ffc13f0..9b94ac3 100644 --- a/docs/changelog.en.md +++ b/docs/changelog.en.md @@ -1,5 +1,10 @@ # Changelog +## v.1.11.0 (2024.10.24) + +- support pydantic v2 +- remove hidden_fields in model_config + ## v.1.10.8 (2024.8.27) - collector suppor tuple for value diff --git a/docs/changelog.zh.md b/docs/changelog.zh.md index ffc13f0..9b94ac3 100644 --- a/docs/changelog.zh.md +++ b/docs/changelog.zh.md @@ -1,5 +1,10 @@ # Changelog +## v.1.11.0 (2024.10.24) + +- support pydantic v2 +- remove hidden_fields in model_config + ## v.1.10.8 (2024.8.27) - collector suppor tuple for value diff --git a/pydantic_resolve/compat.py b/pydantic_resolve/compat.py new file mode 100644 index 0000000..a0e6d49 --- /dev/null +++ b/pydantic_resolve/compat.py @@ -0,0 +1,4 @@ +from pydantic.version import VERSION as P_VERSION + +PYDANTIC_VERSION = P_VERSION +PYDANTIC_V2 = PYDANTIC_VERSION.startswith("2.") diff --git a/pydantic_resolve/core.py b/pydantic_resolve/core.py index 12e1098..c2edcb2 100644 --- a/pydantic_resolve/core.py +++ b/pydantic_resolve/core.py @@ -10,6 +10,7 @@ import pydantic_resolve.util as util import pydantic_resolve.constant as const from .exceptions import ResolverTargetAttrNotFound, LoaderFieldNotProvidedError, MissingCollector +from pydantic_resolve.compat import PYDANTIC_V2 def LoaderDepend( # noqa: N802 dependency: Optional[Callable[..., Any]] = None, @@ -56,8 +57,15 @@ def values(self): def _get_pydantic_attrs(kls): - for k, v in kls.__fields__.items(): - shelled_type = util.shelling_type(v.type_) + if PYDANTIC_V2: + items = kls.model_fields.items() + else: + items = kls.__fields__.items() + + for k, v in items: + t = v.annotation if PYDANTIC_V2 else v.type_ + + shelled_type = util.shelling_type(t) if is_acceptable_kls(shelled_type): yield (k, shelled_type) # type_ is the most inner type @@ -249,7 +257,10 @@ def scan_and_store_metadata(root_class): def _get_all_fields_and_object_fields(kls): if util.safe_issubclass(kls, BaseModel): - all_fields = set(kls.__fields__.keys()) + if PYDANTIC_V2: + all_fields = set(kls.model_fields.keys()) + else: + all_fields = set(kls.__fields__.keys()) object_fields = list(_get_pydantic_attrs(kls)) # dive and recursively analysis elif is_dataclass(kls): all_fields = set([f.name for f in dc_fields(kls)]) diff --git a/pydantic_resolve/util.py b/pydantic_resolve/util.py index f55a128..4b236e5 100644 --- a/pydantic_resolve/util.py +++ b/pydantic_resolve/util.py @@ -3,12 +3,17 @@ import functools from collections import defaultdict from dataclasses import is_dataclass -from pydantic import BaseModel, parse_obj_as, ValidationError from inspect import iscoroutine, isfunction from typing import Any, DefaultDict, Sequence, Type, TypeVar, List, Callable, Optional, Mapping, Union, Iterator, Dict, get_type_hints import pydantic_resolve.constant as const from pydantic_resolve.exceptions import GlobalLoaderFieldOverlappedError from aiodataloader import DataLoader +from pydantic_resolve.compat import PYDANTIC_V2 + +if PYDANTIC_V2: + from pydantic import BaseModel, TypeAdapter, ValidationError +else: + from pydantic import BaseModel, parse_obj_as, ValidationError def get_class_field_annotations(cls: Type): anno = cls.__dict__.get('__annotations__') or {} @@ -18,6 +23,20 @@ def get_class_field_annotations(cls: Type): T = TypeVar("T") V = TypeVar("V") +if PYDANTIC_V2: + class TypeAdapterManager: + apapters = {} + + @classmethod + def get(cls, type): + adapter = cls.apapters.get(type) + if adapter: + return adapter + else: + new_adapter = TypeAdapter(type) + cls.apapters[type] = new_adapter + return new_adapter + def safe_issubclass(kls, classinfo): try: return issubclass(kls, classinfo) @@ -59,13 +78,28 @@ def replace_method(cls: Type, cls_name: str, func_name: str, func: Callable): return KLS + + def get_required_fields(kls: BaseModel): required_fields = [] + def _is_require(field): + if PYDANTIC_V2: + return field.is_required() + else: + return field.required + # 1. get required fields - for fname, field in kls.__fields__.items(): - if field.required: + if PYDANTIC_V2: + items = kls.model_fields.items() + else: + items = kls.__fields__.items() + + + for fname, field in items: + if _is_require(field): required_fields.append(fname) + # 2. get resolve_ and post_ target fields for f in dir(kls): @@ -79,7 +113,8 @@ def get_required_fields(kls: BaseModel): return required_fields -def output(kls): + +def output_v1(kls): """ set required as True for all fields, make typescript code gen result friendly to use """ @@ -94,7 +129,30 @@ def _schema_extra(schema: Dict[str, Any], model) -> None: raise AttributeError(f'target class {kls.__name__} is not BaseModel') return kls -def model_config(hidden_fields: Optional[List[str]]=None, default_required: bool = True): +def output_v2(kls): + """ + set required as True for all fields + make typescript code gen result friendly to use + """ + + if safe_issubclass(kls, BaseModel): + + def build(): + def schema_extra(schema: Dict[str, Any], model) -> None: + fnames = get_required_fields(model) + schema['required'] = fnames + return schema_extra + + kls.model_config['json_schema_extra'] = staticmethod(build()) + + else: + raise AttributeError(f'target class {kls.__name__} is not BaseModel') + return kls + +output = output_v2 if PYDANTIC_V2 else output_v1 + + +def model_config_v1(default_required: bool = True): """ - hidden_fields: fields want to hide - default_required: @@ -103,27 +161,12 @@ def model_config(hidden_fields: Optional[List[str]]=None, default_required: bool """ def wrapper(kls): if safe_issubclass(kls, BaseModel): - # handle __exclude_fields__ - if hidden_fields: - # validate - for f in hidden_fields: - if f not in kls.__fields__.keys(): - raise KeyError(f'{f} is not valid') - - # exclude in dict() - excludes_fields = kls.__exclude_fields__ or {} - hiddens_fields = {k: True for k in hidden_fields} - kls.__exclude_fields__ = {**excludes_fields, **hiddens_fields} # override schema_extra method def _schema_extra(schema: Dict[str, Any], model) -> None: # define schema.properties excludes = set() - if hidden_fields: - for hf in hidden_fields: - excludes.add(hf) - if kls.__exclude_fields__: for k in kls.__exclude_fields__.keys(): excludes.add(k) @@ -137,8 +180,6 @@ def _schema_extra(schema: Dict[str, Any], model) -> None: # define schema.required if default_required: fnames = get_required_fields(model) - if hidden_fields: - fnames = [n for n in fnames if n not in hidden_fields] schema['required'] = fnames kls.__config__.schema_extra = staticmethod(_schema_extra) else: @@ -146,6 +187,48 @@ def _schema_extra(schema: Dict[str, Any], model) -> None: return kls return wrapper +def model_config_v2(default_required: bool=True): + """ + in pydantic v2, we can not use __exclude_field__ to set hidden field in model_config hidden_field params + model_config now is just a simple decorator to remove fields (with exclude=True) from schema.properties + and set schema.required for better schema description. + (same like `output` decorator, you can replace output with model_config) + + it keeps the form of model_config(params) in order to extend new features in future + """ + def wrapper(kls): + if safe_issubclass(kls, BaseModel): + def build(): + def _schema_extra(schema: Dict[str, Any], model) -> None: + # 1. collect exclude fields and then hide in both schema and dump (default action) + excluded_fields = [k for k, v in kls.model_fields.items() if v.exclude == True] + props = {} + + # config schema properties + for k, v in schema.get('properties', {}).items(): + if k not in excluded_fields: + props[k] = v + schema['properties'] = props + + # config schema required (fields with default values will not be listed in required field) + # and the generated typescript models will define it as optional, and is troublesome in use + if default_required: + fnames = get_required_fields(model) + if excluded_fields: + fnames = [n for n in fnames if n not in excluded_fields] + schema['required'] = fnames + + return _schema_extra + + kls.model_config['json_schema_extra'] = staticmethod(build()) + else: + raise AttributeError(f'target class {kls.__name__} is not BaseModel') + return kls + return wrapper + +model_config = model_config_v2 if PYDANTIC_V2 else model_config_v1 + + def mapper(func_or_class: Union[Callable, Type]): """ execute post-transform function after the value is reolved @@ -185,7 +268,7 @@ async def wrap(*args, **kwargs): return wrap return inner -def _get_mapping_rule(target, source) -> Optional[Callable]: +def _get_mapping_rule_v1(target, source) -> Optional[Callable]: # do noting if isinstance(source, target): return None @@ -211,6 +294,41 @@ def _get_mapping_rule(target, source) -> Optional[Callable]: raise NotImplementedError(f"{type(source)} -> {target.__name__}: faild to get auto mapping rule and execut mapping, use your own rule instead.") +def _get_mapping_rule_v2(target, source) -> Optional[Callable]: + # do noting + if isinstance(source, target): + return None + + # pydantic + if safe_issubclass(target, BaseModel): + if target.model_config.get('from_attributes'): + if isinstance(source, dict): + raise AttributeError(f"{type(source)} -> {target.__name__}: pydantic from_orm can't handle dict object") + else: + return lambda t, s: t.model_validate(s) + + if isinstance(source, dict): + return lambda t, s: t.model_validate(s) + + if isinstance(source, BaseModel): + if source.model_config.get('from_attributes'): + return lambda t, s: t.model_validate(s) + else: + return lambda t, s: t(**s.model_dump()) + + else: + raise AttributeError(f"{type(source)} -> {target.__name__}: pydantic can't handle non-dict data") + + # dataclass + if is_dataclass(target): + if isinstance(source, dict): + return lambda t, s: t(**s) + + raise NotImplementedError(f"{type(source)} -> {target.__name__}: faild to get auto mapping rule and execut mapping, use your own rule instead.") + +_get_mapping_rule = _get_mapping_rule_v2 if PYDANTIC_V2 else _get_mapping_rule_v1 + + def _apply_rule(rule: Optional[Callable], target, source: Any, is_list: bool): if not rule: # no change return source @@ -220,7 +338,7 @@ def _apply_rule(rule: Optional[Callable], target, source: Any, is_list: bool): else: return rule(target, source) -def ensure_subset(base): +def ensure_subset_v1(base): """ used with pydantic class to make sure a class's field is subset of target class @@ -240,8 +358,33 @@ def inner(): raise AttributeError(f'type of {k} not consistent with {base.__name__}' ) return kls return inner() + return wrap + +def ensure_subset_v2(base): + """ + used with pydantic class to make sure a class's field is + subset of target class + """ + def wrap(kls): + assert safe_issubclass(base, BaseModel), 'base should be pydantic class' + assert safe_issubclass(kls, BaseModel), 'class should be pydantic class' + + @functools.wraps(kls) + def inner(): + for k, field in kls.model_fields.items(): + if field.is_required(): + base_field = base.model_fields.get(k) + if not base_field: + raise AttributeError(f'{k} not existed in {base.__name__}.') + if base_field and base_field.annotation != field.annotation: + raise AttributeError(f'type of {k} not consistent with {base.__name__}' ) + return kls + return inner() return wrap +ensure_subset = ensure_subset_v2 if PYDANTIC_V2 else ensure_subset_v1 + + def update_forward_refs(kls): def update_pydantic_forward_refs(kls: Type[BaseModel]): """ @@ -249,11 +392,24 @@ def update_pydantic_forward_refs(kls: Type[BaseModel]): """ if getattr(kls, const.PYDANTIC_FORWARD_REF_UPDATED, False): return - kls.update_forward_refs() + + if PYDANTIC_V2: + kls.model_rebuild() + else: + kls.update_forward_refs() + setattr(kls, const.PYDANTIC_FORWARD_REF_UPDATED, True) - for field in kls.__fields__.values(): - shelled_type = shelling_type(field.type_) + if PYDANTIC_V2: + values = kls.model_fields.values() + else: + values = kls.__fields__.values() + + for field in values: + if PYDANTIC_V2: + shelled_type = shelling_type(field.annotation) + else: + shelled_type = shelling_type(field.type_) update_forward_refs(shelled_type) def update_dataclass_forward_refs(kls): @@ -272,7 +428,8 @@ def update_dataclass_forward_refs(kls): if is_dataclass(kls): update_dataclass_forward_refs(kls) -def try_parse_data_to_target_field_type(target, field_name, data): + +def try_parse_data_to_target_field_type_v1(target, field_name, data): """ parse to pydantic or dataclass object 1. get type of target field @@ -303,6 +460,42 @@ def try_parse_data_to_target_field_type(target, field_name, data): else: return data #noqa +def try_parse_data_to_target_field_type_v2(target, field_name, data): + """ + parse to pydantic or dataclass object + 1. get type of target field + 2. parse + """ + field_type = None + + # 1. get type of target field + if isinstance(target, BaseModel): + _fields = target.__class__.model_fields + field_type = _fields[field_name].annotation + + # handle optional logic + if data is None and _fields[field_name].is_required() == False: + return data + + elif is_dataclass(target): + field_type = target.__class__.__annotations__[field_name] + + # 2. parse + if field_type: + try: + # https://docs.pydantic.dev/latest/concepts/performance/#typeadapter-instantiated-once + adapter = TypeAdapterManager.get(field_type) + result = adapter.validate_python(data) + return result + except ValidationError as e: + print(f'Warning: type mismatch, pls check the return type for "{field_name}", expected: {field_type}') + raise e + + else: + return data #noqa + +try_parse_data_to_target_field_type = try_parse_data_to_target_field_type_v2 if PYDANTIC_V2 else try_parse_data_to_target_field_type_v1 + def _is_optional(annotation): annotation_origin = getattr(annotation, "__origin__", None) return annotation_origin == Union \ diff --git a/tests/resolver/__init__.py b/tests/pydantic_v1/__init__.py similarity index 100% rename from tests/resolver/__init__.py rename to tests/pydantic_v1/__init__.py diff --git a/tests/core/test_field.py b/tests/pydantic_v1/core/test_field.py similarity index 100% rename from tests/core/test_field.py rename to tests/pydantic_v1/core/test_field.py diff --git a/tests/core/test_field_dataclass.py b/tests/pydantic_v1/core/test_field_dataclass.py similarity index 100% rename from tests/core/test_field_dataclass.py rename to tests/pydantic_v1/core/test_field_dataclass.py diff --git a/tests/core/test_field_dataclass_anno.py b/tests/pydantic_v1/core/test_field_dataclass_anno.py similarity index 100% rename from tests/core/test_field_dataclass_anno.py rename to tests/pydantic_v1/core/test_field_dataclass_anno.py diff --git a/tests/core/test_field_mix.py b/tests/pydantic_v1/core/test_field_mix.py similarity index 100% rename from tests/core/test_field_mix.py rename to tests/pydantic_v1/core/test_field_mix.py diff --git a/tests/core/test_field_pydantic.py b/tests/pydantic_v1/core/test_field_pydantic.py similarity index 100% rename from tests/core/test_field_pydantic.py rename to tests/pydantic_v1/core/test_field_pydantic.py diff --git a/tests/core/test_field_pydantic_error.py b/tests/pydantic_v1/core/test_field_pydantic_error.py similarity index 100% rename from tests/core/test_field_pydantic_error.py rename to tests/pydantic_v1/core/test_field_pydantic_error.py diff --git a/tests/core/test_field_validate_and_create_loader_instance.py b/tests/pydantic_v1/core/test_field_validate_and_create_loader_instance.py similarity index 100% rename from tests/core/test_field_validate_and_create_loader_instance.py rename to tests/pydantic_v1/core/test_field_validate_and_create_loader_instance.py diff --git a/tests/core/test_input.py b/tests/pydantic_v1/core/test_input.py similarity index 100% rename from tests/core/test_input.py rename to tests/pydantic_v1/core/test_input.py diff --git a/tests/core/test_scan_post_method.py b/tests/pydantic_v1/core/test_scan_post_method.py similarity index 100% rename from tests/core/test_scan_post_method.py rename to tests/pydantic_v1/core/test_scan_post_method.py diff --git a/tests/core/test_scan_resolve_method.py b/tests/pydantic_v1/core/test_scan_resolve_method.py similarity index 100% rename from tests/core/test_scan_resolve_method.py rename to tests/pydantic_v1/core/test_scan_resolve_method.py diff --git a/tests/core/test_specific_type.py b/tests/pydantic_v1/core/test_specific_type.py similarity index 100% rename from tests/core/test_specific_type.py rename to tests/pydantic_v1/core/test_specific_type.py diff --git a/tests/resolver/test_14_deps/__init__.py b/tests/pydantic_v1/resolver/__init__.py similarity index 100% rename from tests/resolver/test_14_deps/__init__.py rename to tests/pydantic_v1/resolver/__init__.py diff --git a/tests/resolver/test_0_depends.py b/tests/pydantic_v1/resolver/test_0_depends.py similarity index 93% rename from tests/resolver/test_0_depends.py rename to tests/pydantic_v1/resolver/test_0_depends.py index d53d1cb..520b1df 100644 --- a/tests/resolver/test_0_depends.py +++ b/tests/pydantic_v1/resolver/test_0_depends.py @@ -2,10 +2,6 @@ from typing import Any, Callable, Optional import pytest -def test_pydantic_version(): - from pydantic.version import VERSION - assert VERSION.startswith('1.') - # verify the Feasibility of implement depends class Loader: def load(self): diff --git a/tests/resolver/test_10_sqlalchemy_query_with_change.py b/tests/pydantic_v1/resolver/test_10_sqlalchemy_query_with_change.py similarity index 100% rename from tests/resolver/test_10_sqlalchemy_query_with_change.py rename to tests/pydantic_v1/resolver/test_10_sqlalchemy_query_with_change.py diff --git a/tests/resolver/test_11_sqlalchemy_query_global_filter.py b/tests/pydantic_v1/resolver/test_11_sqlalchemy_query_global_filter.py similarity index 100% rename from tests/resolver/test_11_sqlalchemy_query_global_filter.py rename to tests/pydantic_v1/resolver/test_11_sqlalchemy_query_global_filter.py diff --git a/tests/resolver/test_12_loader_global_filter_exception.py b/tests/pydantic_v1/resolver/test_12_loader_global_filter_exception.py similarity index 100% rename from tests/resolver/test_12_loader_global_filter_exception.py rename to tests/pydantic_v1/resolver/test_12_loader_global_filter_exception.py diff --git a/tests/resolver/test_13_check_wrong_type.py b/tests/pydantic_v1/resolver/test_13_check_wrong_type.py similarity index 100% rename from tests/resolver/test_13_check_wrong_type.py rename to tests/pydantic_v1/resolver/test_13_check_wrong_type.py diff --git a/tests/resolver/test_14_check_loader_name.py b/tests/pydantic_v1/resolver/test_14_check_loader_name.py similarity index 91% rename from tests/resolver/test_14_check_loader_name.py rename to tests/pydantic_v1/resolver/test_14_check_loader_name.py index 8c74d54..f6a460e 100644 --- a/tests/resolver/test_14_check_loader_name.py +++ b/tests/pydantic_v1/resolver/test_14_check_loader_name.py @@ -3,8 +3,8 @@ import pytest from pydantic import BaseModel from pydantic_resolve import Resolver, LoaderDepend -import tests.resolver.test_14_deps.mod_a as a -import tests.resolver.test_14_deps.mod_b as b +import tests.pydantic_v1.resolver.test_14_deps.mod_a as a +import tests.pydantic_v1.resolver.test_14_deps.mod_b as b @pytest.mark.asyncio async def test_loader_depends(): diff --git a/tests/pydantic_v1/resolver/test_14_deps/__init__.py b/tests/pydantic_v1/resolver/test_14_deps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/resolver/test_14_deps/mod_a.py b/tests/pydantic_v1/resolver/test_14_deps/mod_a.py similarity index 100% rename from tests/resolver/test_14_deps/mod_a.py rename to tests/pydantic_v1/resolver/test_14_deps/mod_a.py diff --git a/tests/resolver/test_14_deps/mod_b.py b/tests/pydantic_v1/resolver/test_14_deps/mod_b.py similarity index 100% rename from tests/resolver/test_14_deps/mod_b.py rename to tests/pydantic_v1/resolver/test_14_deps/mod_b.py diff --git a/tests/resolver/test_15_support_batch_load_fn.py b/tests/pydantic_v1/resolver/test_15_support_batch_load_fn.py similarity index 100% rename from tests/resolver/test_15_support_batch_load_fn.py rename to tests/pydantic_v1/resolver/test_15_support_batch_load_fn.py diff --git a/tests/resolver/test_16_mapper.py b/tests/pydantic_v1/resolver/test_16_mapper.py similarity index 100% rename from tests/resolver/test_16_mapper.py rename to tests/pydantic_v1/resolver/test_16_mapper.py diff --git a/tests/resolver/test_16_mapper_1.py b/tests/pydantic_v1/resolver/test_16_mapper_1.py similarity index 100% rename from tests/resolver/test_16_mapper_1.py rename to tests/pydantic_v1/resolver/test_16_mapper_1.py diff --git a/tests/resolver/test_16_mapper_2.py b/tests/pydantic_v1/resolver/test_16_mapper_2.py similarity index 100% rename from tests/resolver/test_16_mapper_2.py rename to tests/pydantic_v1/resolver/test_16_mapper_2.py diff --git a/tests/resolver/test_16_mapper_3.py b/tests/pydantic_v1/resolver/test_16_mapper_3.py similarity index 100% rename from tests/resolver/test_16_mapper_3.py rename to tests/pydantic_v1/resolver/test_16_mapper_3.py diff --git a/tests/resolver/test_16_mapper_4.py b/tests/pydantic_v1/resolver/test_16_mapper_4.py similarity index 100% rename from tests/resolver/test_16_mapper_4.py rename to tests/pydantic_v1/resolver/test_16_mapper_4.py diff --git a/tests/resolver/test_16_mapper_5.py b/tests/pydantic_v1/resolver/test_16_mapper_5.py similarity index 100% rename from tests/resolver/test_16_mapper_5.py rename to tests/pydantic_v1/resolver/test_16_mapper_5.py diff --git a/tests/resolver/test_16_mapper_6.py b/tests/pydantic_v1/resolver/test_16_mapper_6.py similarity index 100% rename from tests/resolver/test_16_mapper_6.py rename to tests/pydantic_v1/resolver/test_16_mapper_6.py diff --git a/tests/resolver/test_17_mapper_deep.py b/tests/pydantic_v1/resolver/test_17_mapper_deep.py similarity index 100% rename from tests/resolver/test_17_mapper_deep.py rename to tests/pydantic_v1/resolver/test_17_mapper_deep.py diff --git a/tests/resolver/test_18_post_methods.py b/tests/pydantic_v1/resolver/test_18_post_methods.py similarity index 100% rename from tests/resolver/test_18_post_methods.py rename to tests/pydantic_v1/resolver/test_18_post_methods.py diff --git a/tests/resolver/test_19_post_methods_exception.py b/tests/pydantic_v1/resolver/test_19_post_methods_exception.py similarity index 100% rename from tests/resolver/test_19_post_methods_exception.py rename to tests/pydantic_v1/resolver/test_19_post_methods_exception.py diff --git a/tests/resolver/test_1_pydantic_resolve.py b/tests/pydantic_v1/resolver/test_1_pydantic_resolve.py similarity index 100% rename from tests/resolver/test_1_pydantic_resolve.py rename to tests/pydantic_v1/resolver/test_1_pydantic_resolve.py diff --git a/tests/resolver/test_20_loader_instance.py b/tests/pydantic_v1/resolver/test_20_loader_instance.py similarity index 100% rename from tests/resolver/test_20_loader_instance.py rename to tests/pydantic_v1/resolver/test_20_loader_instance.py diff --git a/tests/resolver/test_21_not_stop_by_idle_level.py b/tests/pydantic_v1/resolver/test_21_not_stop_by_idle_level.py similarity index 100% rename from tests/resolver/test_21_not_stop_by_idle_level.py rename to tests/pydantic_v1/resolver/test_21_not_stop_by_idle_level.py diff --git a/tests/resolver/test_22_not_stop_by_idle_level_complex_1.py b/tests/pydantic_v1/resolver/test_22_not_stop_by_idle_level_complex_1.py similarity index 100% rename from tests/resolver/test_22_not_stop_by_idle_level_complex_1.py rename to tests/pydantic_v1/resolver/test_22_not_stop_by_idle_level_complex_1.py diff --git a/tests/resolver/test_22_not_stop_by_idle_level_complex_2.py b/tests/pydantic_v1/resolver/test_22_not_stop_by_idle_level_complex_2.py similarity index 100% rename from tests/resolver/test_22_not_stop_by_idle_level_complex_2.py rename to tests/pydantic_v1/resolver/test_22_not_stop_by_idle_level_complex_2.py diff --git a/tests/resolver/test_23_parse_to_obj_for_pydantic.py b/tests/pydantic_v1/resolver/test_23_parse_to_obj_for_pydantic.py similarity index 100% rename from tests/resolver/test_23_parse_to_obj_for_pydantic.py rename to tests/pydantic_v1/resolver/test_23_parse_to_obj_for_pydantic.py diff --git a/tests/resolver/test_24_parse_to_obj_for_dataclass.py b/tests/pydantic_v1/resolver/test_24_parse_to_obj_for_dataclass.py similarity index 100% rename from tests/resolver/test_24_parse_to_obj_for_dataclass.py rename to tests/pydantic_v1/resolver/test_24_parse_to_obj_for_dataclass.py diff --git a/tests/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py b/tests/pydantic_v1/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py similarity index 100% rename from tests/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py rename to tests/pydantic_v1/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py diff --git a/tests/resolver/test_26_tree.py b/tests/pydantic_v1/resolver/test_26_tree.py similarity index 100% rename from tests/resolver/test_26_tree.py rename to tests/pydantic_v1/resolver/test_26_tree.py diff --git a/tests/resolver/test_27_context.py b/tests/pydantic_v1/resolver/test_27_context.py similarity index 100% rename from tests/resolver/test_27_context.py rename to tests/pydantic_v1/resolver/test_27_context.py diff --git a/tests/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py b/tests/pydantic_v1/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py similarity index 100% rename from tests/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py rename to tests/pydantic_v1/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py diff --git a/tests/resolver/test_29_better_warning_of_list.py b/tests/pydantic_v1/resolver/test_29_better_warning_of_list.py similarity index 100% rename from tests/resolver/test_29_better_warning_of_list.py rename to tests/pydantic_v1/resolver/test_29_better_warning_of_list.py diff --git a/tests/resolver/test_2_resolve_object.py b/tests/pydantic_v1/resolver/test_2_resolve_object.py similarity index 100% rename from tests/resolver/test_2_resolve_object.py rename to tests/pydantic_v1/resolver/test_2_resolve_object.py diff --git a/tests/resolver/test_30_loader_in_object.py b/tests/pydantic_v1/resolver/test_30_loader_in_object.py similarity index 100% rename from tests/resolver/test_30_loader_in_object.py rename to tests/pydantic_v1/resolver/test_30_loader_in_object.py diff --git a/tests/resolver/test_31_dynamic_variable_for_descdant_loader.py b/tests/pydantic_v1/resolver/test_31_dynamic_variable_for_descdant_loader.py similarity index 100% rename from tests/resolver/test_31_dynamic_variable_for_descdant_loader.py rename to tests/pydantic_v1/resolver/test_31_dynamic_variable_for_descdant_loader.py diff --git a/tests/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py b/tests/pydantic_v1/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py similarity index 100% rename from tests/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py rename to tests/pydantic_v1/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py diff --git a/tests/resolver/test_33_global_loader_filter.py b/tests/pydantic_v1/resolver/test_33_global_loader_filter.py similarity index 100% rename from tests/resolver/test_33_global_loader_filter.py rename to tests/pydantic_v1/resolver/test_33_global_loader_filter.py diff --git a/tests/resolver/test_34_filter_rename_deprecation.py b/tests/pydantic_v1/resolver/test_34_filter_rename_deprecation.py similarity index 100% rename from tests/resolver/test_34_filter_rename_deprecation.py rename to tests/pydantic_v1/resolver/test_34_filter_rename_deprecation.py diff --git a/tests/resolver/test_35_collector.py b/tests/pydantic_v1/resolver/test_35_collector.py similarity index 100% rename from tests/resolver/test_35_collector.py rename to tests/pydantic_v1/resolver/test_35_collector.py diff --git a/tests/resolver/test_36_collector_level_by_level.py b/tests/pydantic_v1/resolver/test_36_collector_level_by_level.py similarity index 100% rename from tests/resolver/test_36_collector_level_by_level.py rename to tests/pydantic_v1/resolver/test_36_collector_level_by_level.py diff --git a/tests/resolver/test_37_specific_types.py b/tests/pydantic_v1/resolver/test_37_specific_types.py similarity index 100% rename from tests/resolver/test_37_specific_types.py rename to tests/pydantic_v1/resolver/test_37_specific_types.py diff --git a/tests/resolver/test_38_parent.py b/tests/pydantic_v1/resolver/test_38_parent.py similarity index 100% rename from tests/resolver/test_38_parent.py rename to tests/pydantic_v1/resolver/test_38_parent.py diff --git a/tests/resolver/test_39_post_async.py b/tests/pydantic_v1/resolver/test_39_post_async.py similarity index 100% rename from tests/resolver/test_39_post_async.py rename to tests/pydantic_v1/resolver/test_39_post_async.py diff --git a/tests/resolver/test_3_tuple_list.py b/tests/pydantic_v1/resolver/test_3_tuple_list.py similarity index 100% rename from tests/resolver/test_3_tuple_list.py rename to tests/pydantic_v1/resolver/test_3_tuple_list.py diff --git a/tests/resolver/test_40_multiple_collect_source.py b/tests/pydantic_v1/resolver/test_40_multiple_collect_source.py similarity index 100% rename from tests/resolver/test_40_multiple_collect_source.py rename to tests/pydantic_v1/resolver/test_40_multiple_collect_source.py diff --git a/tests/resolver/test_41_validate_collect_relationship.py b/tests/pydantic_v1/resolver/test_41_validate_collect_relationship.py similarity index 100% rename from tests/resolver/test_41_validate_collect_relationship.py rename to tests/pydantic_v1/resolver/test_41_validate_collect_relationship.py diff --git a/tests/resolver/test_4_resolve_return_types.py b/tests/pydantic_v1/resolver/test_4_resolve_return_types.py similarity index 100% rename from tests/resolver/test_4_resolve_return_types.py rename to tests/pydantic_v1/resolver/test_4_resolve_return_types.py diff --git a/tests/resolver/test_5_exception.py b/tests/pydantic_v1/resolver/test_5_exception.py similarity index 100% rename from tests/resolver/test_5_exception.py rename to tests/pydantic_v1/resolver/test_5_exception.py diff --git a/tests/resolver/test_6_resolve_dataclass.py b/tests/pydantic_v1/resolver/test_6_resolve_dataclass.py similarity index 100% rename from tests/resolver/test_6_resolve_dataclass.py rename to tests/pydantic_v1/resolver/test_6_resolve_dataclass.py diff --git a/tests/resolver/test_7_sqlalchemy_query.py b/tests/pydantic_v1/resolver/test_7_sqlalchemy_query.py similarity index 100% rename from tests/resolver/test_7_sqlalchemy_query.py rename to tests/pydantic_v1/resolver/test_7_sqlalchemy_query.py diff --git a/tests/resolver/test_8_loader_depend.py b/tests/pydantic_v1/resolver/test_8_loader_depend.py similarity index 100% rename from tests/resolver/test_8_loader_depend.py rename to tests/pydantic_v1/resolver/test_8_loader_depend.py diff --git a/tests/resolver/test_9_sqlalchemy_query_fix_cache.py b/tests/pydantic_v1/resolver/test_9_sqlalchemy_query_fix_cache.py similarity index 100% rename from tests/resolver/test_9_sqlalchemy_query_fix_cache.py rename to tests/pydantic_v1/resolver/test_9_sqlalchemy_query_fix_cache.py diff --git a/tests/utils/test_2_ensure_subset.py b/tests/pydantic_v1/utils/test_2_ensure_subset.py similarity index 100% rename from tests/utils/test_2_ensure_subset.py rename to tests/pydantic_v1/utils/test_2_ensure_subset.py diff --git a/tests/utils/test_merge.py b/tests/pydantic_v1/utils/test_merge.py similarity index 100% rename from tests/utils/test_merge.py rename to tests/pydantic_v1/utils/test_merge.py diff --git a/tests/utils/test_model_config.py b/tests/pydantic_v1/utils/test_model_config.py similarity index 97% rename from tests/utils/test_model_config.py rename to tests/pydantic_v1/utils/test_model_config.py index c9ee7f4..85f5501 100644 --- a/tests/utils/test_model_config.py +++ b/tests/pydantic_v1/utils/test_model_config.py @@ -5,6 +5,7 @@ import json from typing import List +@pytest.mark.skip @pytest.mark.asyncio async def test_schema_config_hidden(): @@ -41,6 +42,7 @@ def resolve_passwords(self): assert y.json() == json.dumps({'name': 'kikodo'}) +@pytest.mark.skip @pytest.mark.asyncio async def test_schema_config_hidden_with_field(): """Field(exclude=True) will also work """ @@ -67,6 +69,7 @@ def resolve_password(self): +@pytest.mark.skip @pytest.mark.asyncio async def test_schema_config_required(): @model_config() @@ -85,6 +88,7 @@ def resolve_password(self): assert set(schema['required']) == {'id', 'name', 'password'} +@pytest.mark.skip @pytest.mark.asyncio async def test_schema_config_required_false(): @model_config(default_required=False) @@ -102,6 +106,7 @@ def resolve_password(self): schema = Y.schema() assert set(schema['required']) == {'name'} +@pytest.mark.skip @pytest.mark.asyncio async def test_nested_loader(): diff --git a/tests/utils/test_output.py b/tests/pydantic_v1/utils/test_output.py similarity index 100% rename from tests/utils/test_output.py rename to tests/pydantic_v1/utils/test_output.py diff --git a/tests/utils/test_output_2.py b/tests/pydantic_v1/utils/test_output_2.py similarity index 100% rename from tests/utils/test_output_2.py rename to tests/pydantic_v1/utils/test_output_2.py diff --git a/tests/utils/test_parse.py b/tests/pydantic_v1/utils/test_parse.py similarity index 100% rename from tests/utils/test_parse.py rename to tests/pydantic_v1/utils/test_parse.py diff --git a/tests/utils/test_parse_forward_ref.py b/tests/pydantic_v1/utils/test_parse_forward_ref.py similarity index 100% rename from tests/utils/test_parse_forward_ref.py rename to tests/pydantic_v1/utils/test_parse_forward_ref.py diff --git a/tests/utils/test_utils.py b/tests/pydantic_v1/utils/test_utils.py similarity index 100% rename from tests/utils/test_utils.py rename to tests/pydantic_v1/utils/test_utils.py diff --git a/tests/pydantic_v2/__init__.py b/tests/pydantic_v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pydantic_v2/core/test_field.py b/tests/pydantic_v2/core/test_field.py new file mode 100644 index 0000000..4ccbd74 --- /dev/null +++ b/tests/pydantic_v2/core/test_field.py @@ -0,0 +1,13 @@ +# from __future__ import annotations +from pydantic import BaseModel +from pydantic_resolve.core import get_class + +def test_get_class(): + class Student(BaseModel): + name: str = 'kikodo' + + stu = Student() + stus = [Student(), Student()] + + assert get_class(stu) == Student + assert get_class(stus) == Student diff --git a/tests/pydantic_v2/core/test_field_dataclass.py b/tests/pydantic_v2/core/test_field_dataclass.py new file mode 100644 index 0000000..adf4e59 --- /dev/null +++ b/tests/pydantic_v2/core/test_field_dataclass.py @@ -0,0 +1,78 @@ +# from __future__ import annotations +from dataclasses import dataclass, field +from typing import Optional, List +from pydantic_resolve.core import scan_and_store_metadata + +@dataclass +class Queue: + name: str + +@dataclass +class Zone: + name: str + qs: List[Queue] + +@dataclass +class Zeta: + name: str + + +@dataclass +class Student: + __pydantic_resolve_expose__ = {'name': 'student_name'} + zone: Optional[Zone] = None + name: str = '' + + def resolve_name(self): + return '.' + + def post_name(self): + return '.' + + zeta2: Optional[List[Zeta]] = None + zetas2: List[Optional[Zeta]] = field(default_factory=list) + + zeta: Optional[Zeta] = None + + def resolve_zeta(self): + return dict(name='z') + + +def test_get_all_fields(): + result = scan_and_store_metadata(Student) + expect = { + 'test_field_dataclass.Student': { + 'resolve': ['resolve_name', 'resolve_zeta'], + 'post': ['post_name'], + 'attribute': ['zone', 'zeta2', 'zetas2'], + 'expose_dict': {'name': 'student_name'}, + 'collect_dict': {}, + 'has_context': False, + }, + 'test_field_dataclass.Zone': { + 'resolve': [], + 'post': [], + 'attribute': ['qs'], + 'expose_dict': {}, + 'collect_dict': {}, + 'has_context': False, + }, + 'test_field_dataclass.Queue': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {}, + 'has_context': False, + }, + 'test_field_dataclass.Zeta': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {}, + 'has_context': False, + } + } + for k, v in result.items(): + assert expect[k].items() <= v.items() \ No newline at end of file diff --git a/tests/pydantic_v2/core/test_field_dataclass_anno.py b/tests/pydantic_v2/core/test_field_dataclass_anno.py new file mode 100644 index 0000000..8a2dd98 --- /dev/null +++ b/tests/pydantic_v2/core/test_field_dataclass_anno.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass, field +from typing import Optional, List +from pydantic_resolve.core import scan_and_store_metadata +from pydantic_resolve import LoaderDepend, Collector + + +async def loader_fn(keys): + return keys + +@dataclass +class Student: + __pydantic_resolve_expose__ = {'name': 'student_name'} + zone: Optional['Zone'] = None + name: str = '' + + def resolve_name(self, context, ancestor_context, loader=LoaderDepend(loader_fn)): + return '.' + + def post_name(self): + return '.' + + zeta: Optional['Zeta'] = None + + def resolve_zeta(self): + return dict(name='z') + + queue_names: List[str] = field(default_factory=list) + def post_queue_names(self, collector=Collector('queue_name')): + return collector.values() + +@dataclass +class Queue: + __pydantic_resolve_collect__ = {"name": "queue_name"} + name: str + +@dataclass +class Zone: + name: str + qs: List[Queue] + +@dataclass +class Zeta: + name: str + + +def test_get_all_fields(): + result = scan_and_store_metadata(Student) + expect = { + 'test_field_dataclass_anno.Student': { + 'resolve': ['resolve_name', 'resolve_zeta'], + 'post': ['post_name', 'post_queue_names'], + 'attribute': ['zone'], + 'expose_dict': {'name': 'student_name'}, + 'collect_dict': {}, + 'kls': Student + }, + 'test_field_dataclass_anno.Zone': { + 'resolve': [], + 'post': [], + 'attribute': ['qs'], + 'expose_dict': {}, + 'collect_dict': {}, + 'kls': Zone + }, + 'test_field_dataclass_anno.Queue': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {'name': 'queue_name'}, + 'kls': Queue + }, + 'test_field_dataclass_anno.Zeta': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {}, + 'kls': Zeta + } + } + for k, v in result.items(): + assert expect[k].items() <= v.items() + +def test_resolve_params(): + result = scan_and_store_metadata(Student) + expect = { + 'test_field_dataclass_anno.Student': { + 'resolve_params': { + 'resolve_name': { + 'trim_field': 'name', + 'context': True, + 'ancestor_context': True, + 'parent': False, + 'dataloaders': [ + { + 'param': 'loader', + 'kls': loader_fn, + 'path': 'test_field_dataclass_anno.loader_fn' + } + ], + }, + 'resolve_zeta': { + 'trim_field': 'zeta', + 'context': False, + 'ancestor_context': False, + 'parent': False, + 'dataloaders': [], + } + }, + }, + } + key = 'test_field_dataclass_anno.Student' + assert expect[key].items() <= result[key].items() \ No newline at end of file diff --git a/tests/pydantic_v2/core/test_field_mix.py b/tests/pydantic_v2/core/test_field_mix.py new file mode 100644 index 0000000..645a41b --- /dev/null +++ b/tests/pydantic_v2/core/test_field_mix.py @@ -0,0 +1,40 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Optional, List +from pydantic import BaseModel +from pydantic_resolve.core import scan_and_store_metadata + +@dataclass +class Book: + name: str + +class Student(BaseModel): + __pydantic_resolve_expose__ = {'name': 's_name'} + name: str = '' + + books: List[Book] = [] + + def resolve_books(self): + return [] + + +def test_get_all_fields(): + result = scan_and_store_metadata(Student) + expect = { + 'test_field_mix.Student': { + 'resolve': ['resolve_books'], + 'post': [], + 'attribute': [], + 'expose_dict': {'name': 's_name'}, + 'collect_dict': {} + }, + 'test_field_mix.Book': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {} + }, + } + for k, v in result.items(): + assert expect[k].items() <= v.items() \ No newline at end of file diff --git a/tests/pydantic_v2/core/test_field_pydantic.py b/tests/pydantic_v2/core/test_field_pydantic.py new file mode 100644 index 0000000..67c532b --- /dev/null +++ b/tests/pydantic_v2/core/test_field_pydantic.py @@ -0,0 +1,162 @@ +from __future__ import annotations +from typing import Optional, List +from pydantic import BaseModel +from pydantic_resolve.core import scan_and_store_metadata, convert_metadata_key_as_kls +from pydantic_resolve import LoaderDepend + +async def loader_fn(keys): + return keys + +class Student(BaseModel): + __pydantic_resolve_expose__ = {'name': 'student_name'} + name: str = '' + resolve_hello: str = '' + + def resolve_name(self, context, ancestor_context, loader=LoaderDepend(loader_fn)): + return '.' + + def post_name(self): + return '.' + + zones: List[Optional[Zone]] = [None] + zones2: List[Zone] = [] + zone: Optional[Optional[Zone]] = None + z: str # ignored in attributes + + zeta: Optional[Zeta] = None + + def resolve_zeta(self): + return dict(name='z') + +class Zone(BaseModel): + name: str + qs: List[Queue] + +class Queue(BaseModel): + name: str + +class Zeta(BaseModel): + name: str + + +def test_get_all_fields(): + result = scan_and_store_metadata(Student) + expect = { + 'test_field_pydantic.Student': { + 'resolve': ['resolve_name', 'resolve_zeta'], + 'post': ['post_name'], + 'attribute': ['zones', 'zones2', 'zone'], + 'expose_dict': {'name': 'student_name'}, + 'collect_dict': {} + # ... others + }, + 'test_field_pydantic.Zone': { + 'resolve': [], + 'post': [], + 'attribute': ['qs'], + 'expose_dict': {}, + 'collect_dict': {} + # ... others + }, + 'test_field_pydantic.Queue': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {} + # ... others + }, + 'test_field_pydantic.Zeta': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {} + # ... others + } + } + for k, v in result.items(): + assert expect[k].items() <= v.items() + + +def test_convert_metadata(): + result = scan_and_store_metadata(Student) + result = convert_metadata_key_as_kls(result) + expect = { + Student: { + 'resolve': ['resolve_name', 'resolve_zeta'], + 'post': ['post_name'], + 'attribute': ['zones', 'zones2', 'zone'], + 'expose_dict': {'name': 'student_name'}, + 'collect_dict': {}, + 'kls': Student, + 'kls_path': 'test_field_pydantic.Student', + # ... others + }, + Zone: { + 'resolve': [], + 'post': [], + 'attribute': ['qs'], + 'expose_dict': {}, + 'collect_dict': {}, + 'kls': Zone, + 'kls_path': 'test_field_pydantic.Zone', + # ... others + }, + Queue: { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {}, + 'kls': Queue, + 'kls_path': 'test_field_pydantic.Queue', + # ... others + }, + Zeta: { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {}, + 'kls': Zeta, + 'kls_path': 'test_field_pydantic.Zeta', + # ... others + } + } + for k, v in result.items(): + assert expect[k].items() <= v.items() + + +def test_resolve_params(): + result = scan_and_store_metadata(Student) + expect = { + 'test_field_pydantic.Student': { + # ... others + 'has_context': True, + 'resolve_params': { + 'resolve_name': { + 'trim_field': 'name', + 'context': True, + 'ancestor_context': True, + 'parent': False, + 'dataloaders': [ + { + 'param': 'loader', + 'kls': loader_fn, + 'path': 'test_field_pydantic.loader_fn' + } + ], + }, + 'resolve_zeta': { + 'trim_field': 'zeta', + 'context': False, + 'ancestor_context': False, + 'parent': False, + 'dataloaders': [], + } + }, + } + } + key = 'test_field_pydantic.Student' + assert expect[key].items() <= result[key].items() \ No newline at end of file diff --git a/tests/pydantic_v2/core/test_field_pydantic_error.py b/tests/pydantic_v2/core/test_field_pydantic_error.py new file mode 100644 index 0000000..6d14eb9 --- /dev/null +++ b/tests/pydantic_v2/core/test_field_pydantic_error.py @@ -0,0 +1,27 @@ +from __future__ import annotations +import pytest +from typing import Optional +from pydantic import BaseModel +from pydantic_resolve.core import scan_and_store_metadata + +class A(BaseModel): + __pydantic_resolve_expose__ = {'name': 'A_name'} + name: str = '' + b: Optional[B] = None + def resolve_b(self): + return dict(name='b') + +class B(BaseModel): + __pydantic_resolve_expose__ = {'name': 'A_name'} + name: str + c: Optional[C] = None + def resolve_c(self): + return dict(name='c') + +class C(BaseModel): + name: str + + +def test_raise_exception(): + with pytest.raises(AttributeError): + scan_and_store_metadata(A) \ No newline at end of file diff --git a/tests/pydantic_v2/core/test_field_validate_and_create_loader_instance.py b/tests/pydantic_v2/core/test_field_validate_and_create_loader_instance.py new file mode 100644 index 0000000..969742f --- /dev/null +++ b/tests/pydantic_v2/core/test_field_validate_and_create_loader_instance.py @@ -0,0 +1,90 @@ +from __future__ import annotations +import pytest +from typing import Optional, List +from pydantic import BaseModel +from aiodataloader import DataLoader +from pydantic_resolve.core import scan_and_store_metadata, validate_and_create_loader_instance +from pydantic_resolve import LoaderDepend, LoaderFieldNotProvidedError + +async def loader_fn(keys): + return keys + +class MyLoader(DataLoader): + param: str + async def batch_load_fn(self, keys): + return keys + +class Student(BaseModel): + __pydantic_resolve_expose__ = {'name': 'student_name'} + name: str = '' + resolve_hello: str = '' + + def resolve_name(self, context, ancestor_context, loader=LoaderDepend(loader_fn)): + return '.' + + def post_name(self, loader=LoaderDepend(loader_fn)): + return '.' + + zones: List[Optional[Zone]] = [None] + zones2: List[Zone] = [] + zone: Optional[Optional[Zone]] = None + + zeta: Optional[Zeta] = None + + def resolve_zeta(self): + return dict(name='z') + +class Zone(BaseModel): + name: str + qs: List[Queue] + def resolve_qs(self, qs_loader=LoaderDepend(MyLoader)): + return qs_loader.load(self.name) + +class Queue(BaseModel): + name: str + +class Zeta(BaseModel): + name: str + + +loader_params = { + MyLoader: { + 'param': 'aaa' + } +} +global_loader_param = { + 'param': 'aaa' +} + +@pytest.mark.asyncio +async def test_instance_1(): + metadata = scan_and_store_metadata(Student) + loader_instance = validate_and_create_loader_instance(loader_params, {}, {}, metadata) + + assert isinstance(loader_instance['test_field_validate_and_create_loader_instance.loader_fn'] , DataLoader) + assert isinstance(loader_instance['test_field_validate_and_create_loader_instance.MyLoader'] , MyLoader) + +@pytest.mark.asyncio +async def test_instance_2(): + """ test cache works """ + metadata = scan_and_store_metadata(Student) + loader_instance = validate_and_create_loader_instance(loader_params, {}, {}, metadata) + + assert len(loader_instance) == 2 + +@pytest.mark.asyncio +async def test_instance_3(): + """test global param""" + metadata = scan_and_store_metadata(Student) + loader_instance = validate_and_create_loader_instance({}, global_loader_param, {}, metadata) + + assert isinstance(loader_instance['test_field_validate_and_create_loader_instance.loader_fn'] , DataLoader) + assert len(loader_instance) == 2 + +@pytest.mark.asyncio +async def test_instance_4(): + """raise missing param error""" + metadata = scan_and_store_metadata(Student) + + with pytest.raises(LoaderFieldNotProvidedError): + loader_instance = validate_and_create_loader_instance({}, {}, {}, metadata) \ No newline at end of file diff --git a/tests/pydantic_v2/core/test_input.py b/tests/pydantic_v2/core/test_input.py new file mode 100644 index 0000000..9f54db2 --- /dev/null +++ b/tests/pydantic_v2/core/test_input.py @@ -0,0 +1,15 @@ +from pydantic_resolve import Resolver +import pytest + +@pytest.mark.asyncio +async def test_input(): + data = [] + data = await Resolver().resolve(data) + assert data == [] + + +@pytest.mark.asyncio +async def test_input_2(): + data = 'hello' + with pytest.raises(AttributeError): + await Resolver().resolve(data) diff --git a/tests/pydantic_v2/core/test_scan_post_method.py b/tests/pydantic_v2/core/test_scan_post_method.py new file mode 100644 index 0000000..9c8912f --- /dev/null +++ b/tests/pydantic_v2/core/test_scan_post_method.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel +from pydantic_resolve.core import _scan_post_method, _scan_post_default_handler +from pydantic_resolve import Collector + +def test_scan_post_method_1(): + class A(BaseModel): + a: str + def post_a(self): + return 2 * self.a + + result = _scan_post_method(A.post_a, 'post_a') + + assert result == { + 'trim_field': 'a', + 'context': False, + 'ancestor_context': False, + 'parent': False, + 'collectors': [] + } + + +def test_scan_post_method_2(): + class A(BaseModel): + a: str + def post_a(self, context, ancestor_context, parent): + return 2 * self.a + + result = _scan_post_method(A.post_a, 'post_a') + + assert result == { + 'trim_field': 'a', + 'context': True, + 'ancestor_context': True, + 'parent': True, + 'collectors': [] + } + + +def test_scan_post_method_3(): + class A(BaseModel): + a: str + def post_a(self, context, ancestor_context, collector=Collector(alias='c_name')): + return 2 * self.a + + result = _scan_post_method(A.post_a, 'post_a') + + assert len(result['collectors']) == 1 + assert result['collectors'][0]['field'] == 'post_a' + assert result['collectors'][0]['param'] == 'collector' + assert result['collectors'][0]['alias'] == 'c_name' + assert isinstance(result['collectors'][0]['instance'], Collector) + + +def test_scan_post_method_4(): + class A(BaseModel): + a: str + def post_a(self, context, ancestor_context, collector=Collector(alias='c_name')): + return 2 * self.a + + def post_default_handler(self, context, ancestor_context, parent): + return 1 + + result = _scan_post_default_handler(A.post_default_handler) + + assert result['context'] == True + assert result['parent'] == True + assert result['ancestor_context'] == True + diff --git a/tests/pydantic_v2/core/test_scan_resolve_method.py b/tests/pydantic_v2/core/test_scan_resolve_method.py new file mode 100644 index 0000000..c63ef43 --- /dev/null +++ b/tests/pydantic_v2/core/test_scan_resolve_method.py @@ -0,0 +1,74 @@ +import pytest +from aiodataloader import DataLoader +from pydantic import BaseModel +from pydantic_resolve.core import _scan_resolve_method, LoaderDepend, Collector + +def test_scan_resolve_method_1(): + class A(BaseModel): + a: str + def resolve_a(self): + return 2 * self.a + + result = _scan_resolve_method(A.resolve_a, 'resolve_a') + + assert result == { + 'trim_field': 'a', + 'context': False, + 'ancestor_context': False, + 'parent': False, + 'dataloaders': [] + } + + +def test_scan_resolve_method_2(): + class A(BaseModel): + a: str + def resolve_a(self, context, ancestor_context, parent): + return 2 * self.a + + result = _scan_resolve_method(A.resolve_a, 'resolve_a') + + assert result == { + 'trim_field': 'a', + 'context': True, + 'ancestor_context': True, + 'parent': True, + 'dataloaders': [] + } + + +def test_scan_resolve_method_3(): + class Loader(DataLoader): + async def batch_loader_fn(self, keys): + return keys + + class A(BaseModel): + a: str + def resolve_a(self, context, ancestor_context, parent, loader=LoaderDepend(Loader)): + return 2 * self.a + + result = _scan_resolve_method(A.resolve_a, 'resolve_a') + + assert result == { + 'trim_field': 'a', + 'context': True, + 'ancestor_context': True, + 'parent': True, + 'dataloaders': [ + { + 'param': 'loader', + 'kls': Loader, + 'path': 'test_scan_resolve_method.test_scan_resolve_method_3..Loader' + } + ] + } + + +def test_scan_resolve_method_4(): + class A(BaseModel): + a: str + def resolve_a(self, c=Collector('some_field')): + return c.values() + + with pytest.raises(AttributeError): + _scan_resolve_method(A.resolve_a, 'resolve_a') diff --git a/tests/pydantic_v2/core/test_specific_type.py b/tests/pydantic_v2/core/test_specific_type.py new file mode 100644 index 0000000..2ab8cf9 --- /dev/null +++ b/tests/pydantic_v2/core/test_specific_type.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from typing import Tuple +from pydantic import BaseModel +from pydantic_resolve.core import scan_and_store_metadata, convert_metadata_key_as_kls +from pydantic_resolve import LoaderDepend + +async def loader_fn(keys): + return keys + +class Student(BaseModel): + zones: Tuple[int, int] = (0, 0) + +def test_get_all_fields(): + # https://github.com/allmonday/pydantic2-resolve/issues/7 + result = scan_and_store_metadata(Student) + expect = { + 'test_specific_type.Student': { + 'resolve': [], + 'post': [], + 'attribute': [], + 'expose_dict': {}, + 'collect_dict': {} + # ... others + }, + } + for k, v in result.items(): + assert expect[k].items() <= v.items() diff --git a/tests/pydantic_v2/resolver/__init__.py b/tests/pydantic_v2/resolver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pydantic_v2/resolver/test_0_depends.py b/tests/pydantic_v2/resolver/test_0_depends.py new file mode 100644 index 0000000..520b1df --- /dev/null +++ b/tests/pydantic_v2/resolver/test_0_depends.py @@ -0,0 +1,68 @@ +import inspect +from typing import Any, Callable, Optional +import pytest + +# verify the Feasibility of implement depends +class Loader: + def load(self): + print('load') + +def Depend( # noqa: N802 + dependency: Optional[Callable[..., Any]] = None +) -> Any: + return Depends(dependency=dependency) + +class Depends: + def __init__( + self, dependency: Optional[Callable[..., Any]] = None + ): + self.dependency = dependency + +class LoaderA(Loader): + def load(self): + print('load-a') + +class TestClass: + def resolve(self, loader = Depend(LoaderA)): + loader.load() + +def runner_maker(): + cache = {} + counter = { + "init_count": 0 + } + + def exec(t_method): + signature = inspect.signature(t_method) + params = {} + + for k, v in signature.parameters.items(): + if v.default == inspect._empty: + continue + + if isinstance(v.default, Depends): + cache_key = v.default.dependency.__name__ + hit = cache.get(cache_key) + if hit: + instance = hit + else: + instance = v.default.dependency() + cache[cache_key] = instance + counter["init_count"] += 1 + params[k] = instance + t_method(**params) + return counter + return exec + +@pytest.mark.asyncio +async def test_depend(): + run = runner_maker() + t = TestClass() + t2 = TestClass() + t3 = TestClass() + + run(t.resolve) # missing + run(t2.resolve) # hit + counter = run(t3.resolve) # hit + + assert counter["init_count"] == 1 \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_10_sqlalchemy_query_with_change.py b/tests/pydantic_v2/resolver/test_10_sqlalchemy_query_with_change.py new file mode 100644 index 0000000..30b4af4 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_10_sqlalchemy_query_with_change.py @@ -0,0 +1,197 @@ +import pytest +from typing import List +from collections import Counter, defaultdict +from aiodataloader import DataLoader +from pydantic import ConfigDict, BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from pydantic_resolve import Resolver, LoaderDepend + +@pytest.mark.asyncio +async def test_sqlite_and_dataloader(): + counter = Counter() + engine = create_async_engine( + "sqlite+aiosqlite://", + echo=False, + ) + async_session = async_sessionmaker(engine, expire_on_commit=False) + + class Base(DeclarativeBase): + pass + + class Task(Base): + __tablename__ = "task" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + + class Comment(Base): + __tablename__ = "comment" + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column() + content: Mapped[str] + + class Feedback(Base): + __tablename__ = "feedback" + id: Mapped[int] = mapped_column(primary_key=True) + comment_id: Mapped[int] = mapped_column() + content: Mapped[str] + + async def insert_objects() -> None: + async with async_session() as session: + async with session.begin(): + session.add_all( + [ + Task(id=1, name="task-1"), + + Comment(id=1, task_id=1, content="comment-1 for task 1"), + + Feedback(id=1, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=2, comment_id=1, content="feedback-2 for comment-1"), + Feedback(id=3, comment_id=1, content="feedback-3 for comment-1"), + ] + ) + + async def insert_and_update_objects() -> None: + async with async_session() as session: + async with session.begin(): + task_1 = (await session.execute(select(Task).filter_by(id=1))).scalar_one() + task_1.name = 'task-1 xyz' + + comment_1 = (await session.execute(select(Comment).filter_by(id=1))).scalar_one() + comment_1.content = 'comment-1 for task 1 (changes)' + + feedback_1 = (await session.execute(select(Feedback).filter_by(id=1))).scalar_one() + feedback_1.content = 'feedback-1 for comment-1 (changes)' + + session.add(task_1) + session.add(comment_1) + session.add(feedback_1) + session.add_all( + [ + Comment(id=2, task_id=1, content="comment-2 for task 1"), + + Feedback(id=4, comment_id=2, content="test"), + ] + ) + + + # =========================== Pydantic Schema layer ========================= + class FeedbackLoader(DataLoader): + async def batch_load_fn(self, comment_ids): + counter['load-feedback'] += 1 + async with async_session() as session: + res = await session.execute(select(Feedback).where(Feedback.comment_id.in_(comment_ids))) + rows = res.scalars().all() + dct = defaultdict(list) + for row in rows: + dct[row.comment_id].append(FeedbackSchema.model_validate(row)) + return [dct.get(k, []) for k in comment_ids] + + class CommentLoader(DataLoader): + async def batch_load_fn(self, task_ids): + counter['load-comment'] += 1 + async with async_session() as session: + res = await session.execute(select(Comment).where(Comment.task_id.in_(task_ids))) + rows = res.scalars().all() + + dct = defaultdict(list) + for row in rows: + dct[row.task_id].append(CommentSchema.model_validate(row)) + return [dct.get(k, []) for k in task_ids] + + class FeedbackSchema(BaseModel): + id: int + comment_id: int + content: str + model_config = ConfigDict(from_attributes=True) + + class CommentSchema(BaseModel): + id: int + task_id: int + content: str + feedbacks: List[FeedbackSchema] = [] + + def resolve_feedbacks(self, loader=LoaderDepend(FeedbackLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + class TaskSchema(BaseModel): + id: int + name: str + comments: List[CommentSchema] = [] + + def resolve_comments(self, loader=LoaderDepend(CommentLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + async def init(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await insert_objects() + + async def task_query(task_id = 1): + async with async_session() as session: + tasks = (await session.execute(select(Task).where(Task.id == task_id))).scalars().all() + task_objs = [TaskSchema.model_validate(t) for t in tasks] + resolved_results = await Resolver().resolve(task_objs) + to_dict_arr = [r.model_dump() for r in resolved_results] + return to_dict_arr + + await init() + result_1 = await task_query() + await insert_and_update_objects() + result_2 = await task_query() + + expected_1 = [ + { + 'id': 1, + 'name': 'task-1', + 'comments': [ + { + 'content': 'comment-1 for task 1', + 'feedbacks': [ + {'comment_id': 1, 'content': 'feedback-1 for comment-1', 'id': 1}, + {'comment_id': 1, 'content': 'feedback-2 for comment-1', 'id': 2}, + {'comment_id': 1, 'content': 'feedback-3 for comment-1', 'id': 3} + ], + 'id': 1, + 'task_id': 1}, + ], + } + ] + + expected_2 = [ + { + 'id': 1, + 'name': 'task-1 xyz', + 'comments': [ + { + 'content': 'comment-1 for task 1 (changes)', + 'feedbacks': [ + {'comment_id': 1, 'content': 'feedback-1 for comment-1 (changes)', 'id': 1}, + {'comment_id': 1, 'content': 'feedback-2 for comment-1', 'id': 2}, + {'comment_id': 1, 'content': 'feedback-3 for comment-1', 'id': 3} + ], + 'id': 1, + 'task_id': 1}, + { + 'content': 'comment-2 for task 1', + 'feedbacks': [ + {'comment_id': 2, 'content': 'test', 'id': 4}, + ], + 'id': 2, + 'task_id': 1} + ], + } + ] + + assert result_1 == expected_1 + assert result_2 == expected_2 + + assert counter['load-comment'] == 2 # Resolver + LoaderDepend can fix cache issue + assert counter['load-feedback'] == 2 diff --git a/tests/pydantic_v2/resolver/test_11_sqlalchemy_query_global_filter.py b/tests/pydantic_v2/resolver/test_11_sqlalchemy_query_global_filter.py new file mode 100644 index 0000000..5ff07a1 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_11_sqlalchemy_query_global_filter.py @@ -0,0 +1,206 @@ +from __future__ import annotations +import pytest +from typing import List +from collections import Counter, defaultdict +from aiodataloader import DataLoader +from pydantic import ConfigDict, BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from pydantic_resolve import Resolver, LoaderDepend + + +""" +CAUTION: using annotations at top +so all schema should be defined in global scope +""" + +counter = Counter() +engine = create_async_engine( + "sqlite+aiosqlite://", + echo=False, +) +async_session = async_sessionmaker(engine, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +class Task(Base): + __tablename__ = "task" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + +class Comment(Base): + __tablename__ = "comment" + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column(index=True) + content: Mapped[str] + +class Feedback(Base): + __tablename__ = "feedback" + id: Mapped[int] = mapped_column(primary_key=True) + comment_id: Mapped[int] = mapped_column(index=True) + content: Mapped[str] + private: Mapped[bool] + +async def insert_objects() -> None: + async with async_session() as session: + async with session.begin(): + session.add_all( + [ + Task(id=1, name="task-1"), + + Comment(id=1, task_id=1, content="comment-1 for task 1"), + + Feedback(id=1, comment_id=1, content="feedback-1 for comment-1", private=True), + Feedback(id=2, comment_id=1, content="feedback-2 for comment-1", private=False), + Feedback(id=3, comment_id=1, content="feedback-3 for comment-1", private=True), + ] + ) + +async def insert_and_update_objects() -> None: + async with async_session() as session: + async with session.begin(): + task_1 = (await session.execute(select(Task).filter_by(id=1))).scalar_one() + task_1.name = 'task-1 xyz' + + comment_1 = (await session.execute(select(Comment).filter_by(id=1))).scalar_one() + comment_1.content = 'comment-1 for task 1 (changes)' + + feedback_1 = (await session.execute(select(Feedback).filter_by(id=1))).scalar_one() + feedback_1.content = 'feedback-1 for comment-1 (changes)' + + session.add(task_1) + session.add(comment_1) + session.add(feedback_1) + session.add_all( + [ + Comment(id=2, task_id=1, content="comment-2 for task 1"), + + Feedback(id=4, comment_id=2, content="test", private=False), + ] + ) + + +# =========================== Pydantic Schema layer ========================= +class FeedbackLoader(DataLoader): + private: bool + async def batch_load_fn(self, comment_ids): + counter['load-feedback'] += 1 + async with async_session() as session: + res = await session.execute(select(Feedback).where(Feedback.private == self.private).where(Feedback.comment_id.in_(comment_ids))) + rows = res.scalars().all() + dct = defaultdict(list) + for row in rows: + dct[row.comment_id].append(FeedbackSchema.model_validate(row)) + return [dct.get(k, []) for k in comment_ids] + +class CommentLoader(DataLoader): + async def batch_load_fn(self, task_ids): + counter['load-comment'] += 1 + async with async_session() as session: + res = await session.execute(select(Comment).where(Comment.task_id.in_(task_ids))) + rows = res.scalars().all() + + dct = defaultdict(list) + for row in rows: + dct[row.task_id].append(CommentSchema.model_validate(row)) + return [dct.get(k, []) for k in task_ids] + +class FeedbackSchema(BaseModel): + id: int + comment_id: int + content: str + model_config = ConfigDict(from_attributes=True) + +class CommentSchema(BaseModel): + id: int + task_id: int + content: str + feedbacks: List[FeedbackSchema] = [] + + def resolve_feedbacks(self, loader=LoaderDepend(FeedbackLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + +class TaskSchema(BaseModel): + id: int + name: str + comments: List[CommentSchema] = [] + + def resolve_comments(self, loader=LoaderDepend(CommentLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + +async def init(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await insert_objects() + +async def task_query(task_id = 1): + async with async_session() as session: + tasks = (await session.execute(select(Task).where(Task.id == task_id))).scalars().all() + task_objs = [TaskSchema.model_validate(t) for t in tasks] + resolved_results = await Resolver(loader_params={FeedbackLoader: {'private': True}}).resolve(task_objs) + to_dict_arr = [r.model_dump() for r in resolved_results] + return to_dict_arr + +@pytest.mark.asyncio +async def test_sqlite_and_dataloader(): + await init() + result_1 = await task_query() + await insert_and_update_objects() + result_2 = await task_query() + + expected_1 = [ + { + 'id': 1, + 'name': 'task-1', + 'comments': [ + { + 'content': 'comment-1 for task 1', + 'feedbacks': [ + {'comment_id': 1, 'content': 'feedback-1 for comment-1', 'id': 1}, + # {'comment_id': 1, 'content': 'feedback-2 for comment-1', 'id': 2}, + {'comment_id': 1, 'content': 'feedback-3 for comment-1', 'id': 3} + ], + 'id': 1, + 'task_id': 1}, + ], + } + ] + + expected_2 = [ + { + 'id': 1, + 'name': 'task-1 xyz', + 'comments': [ + { + 'content': 'comment-1 for task 1 (changes)', + 'feedbacks': [ + {'comment_id': 1, 'content': 'feedback-1 for comment-1 (changes)', 'id': 1}, + # {'comment_id': 1, 'content': 'feedback-2 for comment-1', 'id': 2}, + {'comment_id': 1, 'content': 'feedback-3 for comment-1', 'id': 3} + ], + 'id': 1, + 'task_id': 1}, + { + 'content': 'comment-2 for task 1', + 'feedbacks': [ + # {'comment_id': 2, 'content': 'test', 'id': 4}, # private is false + ], + 'id': 2, + 'task_id': 1} + ], + } + ] + + assert result_1 == expected_1 + assert result_2 == expected_2 + + assert counter['load-comment'] == 2 # Resolver + LoaderDepend can fix cache issue + assert counter['load-feedback'] == 2 diff --git a/tests/pydantic_v2/resolver/test_12_loader_global_filter_exception.py b/tests/pydantic_v2/resolver/test_12_loader_global_filter_exception.py new file mode 100644 index 0000000..d64f234 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_12_loader_global_filter_exception.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend, LoaderFieldNotProvidedError +from aiodataloader import DataLoader + +@pytest.mark.asyncio +async def test_loader_depends(): + counter = { + "book": 0 + } + + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + + class BookLoader(DataLoader): + inventory: bool + async def batch_load_fn(self, keys) -> List[List[Book]]: + counter["book"] += 1 + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books + + # for testing, loder instance need to initialized inside a thread with eventloop + # (which means it can't be put in global scope of this file) + # otherwise it will generate anthoer loop which will raise error of + # "task attached to another loop" + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + with pytest.raises(LoaderFieldNotProvidedError): + await Resolver().resolve(students) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_13_check_wrong_type.py b/tests/pydantic_v2/resolver/test_13_check_wrong_type.py new file mode 100644 index 0000000..dc4c569 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_13_check_wrong_type.py @@ -0,0 +1,16 @@ +from __future__ import annotations +from pydantic import BaseModel +from pydantic_resolve import Resolver, MissingAnnotationError +import pytest + +class Student(BaseModel): + name: str + intro: str = '' + def resolve_intro(self): + return f'hello {self.name}' + +@pytest.mark.asyncio +async def test_check_miss_anno(): + stu = Student(name="martin") + with pytest.raises(MissingAnnotationError): + await Resolver(ensure_type=True).resolve(stu) diff --git a/tests/pydantic_v2/resolver/test_14_check_loader_name.py b/tests/pydantic_v2/resolver/test_14_check_loader_name.py new file mode 100644 index 0000000..ce89ef8 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_14_check_loader_name.py @@ -0,0 +1,37 @@ +from __future__ import annotations +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend +import tests.pydantic_v2.resolver.test_14_deps.mod_a as a +import tests.pydantic_v2.resolver.test_14_deps.mod_b as b + +@pytest.mark.asyncio +async def test_loader_depends(): + print(a.BookLoader.__module__) + class Student(BaseModel): + id: int + name: str + + a_books: List[a.Book] = [] + def resolve_a_books(self, loader=LoaderDepend(a.BookLoader)): + return loader.load(self.id) + + b_books: List[b.Book] = [] + def resolve_b_books(self, loader=LoaderDepend(b.BookLoader)): + return loader.load(self.id) + + students = [Student(id=1, name="jack"), Student(id=2, name="mike")] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + expected = [ + {'id': 1, 'name': 'jack', + 'a_books': [{ 'name': 'book1'}, {'name': 'book2'}], + 'b_books': [{ 'name': 'book1', 'public': 'public'}, {'name': 'book2', 'public': 'public'}] + }, + {'id': 2, 'name': 'mike', + 'a_books': [{ 'name': 'book3'}, {'name': 'book4'}], + 'b_books': [{ 'name': 'book3', 'public': 'public'}, {'name': 'book4', 'public': 'public'}] + }, + ] + assert source == expected diff --git a/tests/pydantic_v2/resolver/test_14_deps/__init__.py b/tests/pydantic_v2/resolver/test_14_deps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pydantic_v2/resolver/test_14_deps/mod_a.py b/tests/pydantic_v2/resolver/test_14_deps/mod_a.py new file mode 100644 index 0000000..f5ae150 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_14_deps/mod_a.py @@ -0,0 +1,16 @@ +from aiodataloader import DataLoader +from pydantic import BaseModel + +BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], +} + +class Book(BaseModel): + name: str + +class BookLoader(DataLoader): + async def batch_load_fn(self, keys): + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_14_deps/mod_b.py b/tests/pydantic_v2/resolver/test_14_deps/mod_b.py new file mode 100644 index 0000000..f3dbcc8 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_14_deps/mod_b.py @@ -0,0 +1,17 @@ +from aiodataloader import DataLoader +from pydantic import BaseModel + +BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], +} + +class Book(BaseModel): + name: str + public: str = 'public' + +class BookLoader(DataLoader): + async def batch_load_fn(self, keys): + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_15_support_batch_load_fn.py b/tests/pydantic_v2/resolver/test_15_support_batch_load_fn.py new file mode 100644 index 0000000..68bb9c6 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_15_support_batch_load_fn.py @@ -0,0 +1,38 @@ +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend + +@pytest.mark.asyncio +async def test_loader_depends(): + + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + + async def batch_load_fn(keys): + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + expected = [ + {'id': 1, 'name': 'jack', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}, + {'id': 2, 'name': 'mike', 'books': [{ 'name': 'book3'}, {'name': 'book4'}]}, + {'id': 3, 'name': 'wiki', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}, + ] + assert source == expected \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_16_mapper.py b/tests/pydantic_v2/resolver/test_16_mapper.py new file mode 100644 index 0000000..d60980b --- /dev/null +++ b/tests/pydantic_v2/resolver/test_16_mapper.py @@ -0,0 +1,229 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import List, Optional +import pytest +from pydantic import ConfigDict, BaseModel +from pydantic_resolve import Resolver, LoaderDepend, mapper +from aiodataloader import DataLoader + +@pytest.mark.asyncio +async def test_mapper(): + """ + user provided mapper + """ + class BookLoader(DataLoader): + async def batch_load_fn(self, keys): + return keys + + class Student(BaseModel): + id: int + name: str + + books: str = '' + @mapper(lambda x: str(x)) + def resolve_books(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + students = [ + Student(id=1, name="jack"), + Student(id=2, name="jack") + ] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + + expected = [ + {'id': 1, 'name': 'jack', 'books': '1' }, + {'id': 2, 'name': 'jack', 'books': '2' } + ] + assert source == expected + + +@pytest.mark.asyncio +async def test_mapper_2(): + """ + auto mapping: dict -> pydantic + """ + + class BookLoader(DataLoader): + async def batch_load_fn(self, keys): + return [[{'name': f'book-{k}'}] for k in keys ] # return [[obj]] + + class Book(BaseModel): + name: str + + class Student(BaseModel): + id: int + name: str + + book: List[Book] = [] + @mapper(Book) + def resolve_book(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + students = [ Student(id=1, name="jack") ] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + + expected = [ + {'id': 1, 'name': 'jack', 'book': [{'name': 'book-1'}] }, + ] + assert source == expected + + +@pytest.mark.asyncio +async def test_mapper_3(): + """ + auto mapping: obj -> pydantic + """ + class Bo: + def __init__(self, name): + self.name = name + + async def batch_load_fn(keys): + return [Bo(name=f'book-{k}') for k in keys ] # return [obj] + + class Book(BaseModel): + name: str + model_config = ConfigDict(from_attributes=True) + + class Student(BaseModel): + id: int + name: str + + book: Optional[Book] = None + @mapper(Book) + def resolve_book(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + students = [ Student(id=1, name="jack") ] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + + expected = [ + {'id': 1, 'name': 'jack', 'book': {'name': 'book-1'}}, + ] + assert source == expected + + +@pytest.mark.asyncio +async def test_mapper_4(): + """ + auto mapping: dict -> dataclass + """ + + async def batch_load_fn(keys): + return [[{'name': f'book-{k}'}] for k in keys ] + + @dataclass + class Book: + name: str + + class Student(BaseModel): + id: int + name: str + + book: List[Book] = [] + @mapper(Book) + def resolve_book(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + students = [ Student(id=1, name="jack") ] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + + expected = [ + {'id': 1, 'name': 'jack', 'book': [{'name':'book-1'}]}, + ] + assert source == expected + + +@pytest.mark.asyncio +async def test_mapper_5(): + """ + auto mapping fail + """ + class Bo: + def __init__(self, name): + self.name = name + + async def batch_load_fn(keys): + return [[Bo(name=f'book-{k}')] for k in keys ] + + @dataclass + class Book: + name: str + + class Student(BaseModel): + id: int + name: str + + book: List[Book] = [] + @mapper(Book) + def resolve_book(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + students = [ Student(id=1, name="jack") ] + with pytest.raises(NotImplementedError): + await Resolver().resolve(students) + + +@pytest.mark.asyncio +async def test_mapper_6(): + """ + pydantic to pydantic + """ + class Bo(BaseModel): + name: str + + class Book(BaseModel): + name: str + published: bool = False + + async def batch_load_fn(keys): + return [[Bo(name=f'book-{k}')] for k in keys ] + + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + @mapper(Book) + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + students = [ Student(id=1, name="jack") ] + result = await Resolver().resolve(students) + assert result[0].model_dump() == {'id':1, 'name':"jack", 'books':[{'name': "book-1", 'published':False}]} + + +@pytest.mark.asyncio +async def test_mapper_7(): + """ + pydantic to pydantic + """ + class Bo(BaseModel): + name: str + + class Book(BaseModel): + name: str + published: bool = False + + model_config = ConfigDict(from_attributes=True) + + async def batch_load_fn(keys): + return [[Bo(name=f'book-{k}')] for k in keys ] + + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + @mapper(Book) + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + students = [ Student(id=1, name="jack") ] + result = await Resolver().resolve(students) + assert result[0].model_dump() == {'id':1, 'name':"jack", 'books':[{'name': "book-1", 'published':False}]} diff --git a/tests/pydantic_v2/resolver/test_17_mapper_deep.py b/tests/pydantic_v2/resolver/test_17_mapper_deep.py new file mode 100644 index 0000000..1497c43 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_17_mapper_deep.py @@ -0,0 +1,155 @@ +from __future__ import annotations +from typing import List +import pytest +from collections import Counter, defaultdict +from aiodataloader import DataLoader +from pydantic import ConfigDict, BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from pydantic_resolve import Resolver, LoaderDepend, mapper, build_list + +class Base(DeclarativeBase): + pass + +class Task(Base): + __tablename__ = "task" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + +class Comment(Base): + __tablename__ = "comment" + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column() + content: Mapped[str] + +class Feedback(Base): + __tablename__ = "feedback" + id: Mapped[int] = mapped_column(primary_key=True) + comment_id: Mapped[int] = mapped_column() + content: Mapped[str] + +@pytest.mark.asyncio +async def test_sqlite_and_dataloader(): + counter = Counter() + engine = create_async_engine( + "sqlite+aiosqlite://", + echo=False, + ) + async_session = async_sessionmaker(engine, expire_on_commit=False) + + async def insert_objects() -> None: + async with async_session() as session: + async with session.begin(): + session.add_all( + [ + Task(id=1, name="task-1"), + Task(id=2, name="task-2"), + Task(id=3, name="task-3"), + + Comment(id=1, task_id=1, content="comment-1 for task 1"), + Comment(id=2, task_id=1, content="comment-2 for task 1"), + Comment(id=3, task_id=2, content="comment-1 for task 2"), + + Feedback(id=1, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=2, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=3, comment_id=1, content="feedback-1 for comment-1"), + ] + ) + + class FeedbackLoader(DataLoader): + async def batch_load_fn(self, comment_ids): + counter['load-feedback'] += 1 + async with async_session() as session: + res = await session.execute(select(Feedback).where(Feedback.comment_id.in_(comment_ids))) + rows = res.scalars().all() + return build_list(rows, comment_ids, lambda x: x.comment_id) + + class CommentLoader(DataLoader): + async def batch_load_fn(self, task_ids): + counter['load-comment'] += 1 + async with async_session() as session: + res = await session.execute(select(Comment).where(Comment.task_id.in_(task_ids))) + rows = res.scalars().all() + return build_list(rows, task_ids, lambda x: x.task_id) + + class FeedbackSchema(BaseModel): + id: int + comment_id: int + content: str + model_config = ConfigDict(from_attributes=True) + + class CommentSchema(BaseModel): + id: int + task_id: int + content: str + feedbacks: List[FeedbackSchema] = [] + @mapper(FeedbackSchema) + def resolve_feedbacks(self, loader=LoaderDepend(FeedbackLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + class TaskSchema(BaseModel): + id: int + name: str + comments: List[CommentSchema] = [] + @mapper(CommentSchema) + def resolve_comments(self, loader=LoaderDepend(CommentLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + async def init(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def query(): + async with async_session() as session: + tasks = (await session.execute(select(Task))).scalars().all() + task_objs = [TaskSchema.model_validate(t) for t in tasks] + resolved_results = await Resolver().resolve(task_objs) + to_dict_arr = [r.model_dump() for r in resolved_results] + return to_dict_arr + + await init() + await insert_objects() + result = await query() + expected = [ + { + 'comments': [{'content': 'comment-1 for task 1', + 'feedbacks': [{'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 1}, + {'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 2}, + {'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 3}], + 'id': 1, + 'task_id': 1}, + {'content': 'comment-2 for task 1', + 'feedbacks': [], + 'id': 2, + 'task_id': 1}], + 'id': 1, + 'name': 'task-1'}, + {'comments': [{'content': 'comment-1 for task 2', + 'feedbacks': [], + 'id': 3, + 'task_id': 2}], + 'id': 2, + 'name': 'task-2'}, + {'comments': [], 'id': 3, 'name': 'task-3'}] + + assert result == expected + assert counter['load-comment'] == 1 # batch_load_fn only called once + assert counter['load-feedback'] == 1 + + await query() + + assert counter['load-comment'] == 2 # Resolver + LoaderDepend can fix cache issue + assert counter['load-feedback'] == 2 \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_18_post_methods.py b/tests/pydantic_v2/resolver/test_18_post_methods.py new file mode 100644 index 0000000..e8ae49e --- /dev/null +++ b/tests/pydantic_v2/resolver/test_18_post_methods.py @@ -0,0 +1,84 @@ +from typing import List, Optional +from pydantic import BaseModel +from pydantic_resolve import Resolver, mapper, LoaderDepend +import pytest + +# define loader functions +async def friends_batch_load_fn(names): + mock_db = { + 'tangkikodo': ['tom', 'jerry'], + 'john': ['mike', 'wallace'], + 'trump': ['sam', 'jim'], + 'sally': ['sindy', 'lydia'], + } + return [mock_db.get(name, []) for name in names] + +async def cash_batch_load_fn(names): + mock_db = { + 'jerry':200, 'mike': 3000, 'wallace': 400, 'sam': 500, + 'jim': 600, 'sindy': 700, 'lydia': 800, 'tangkikodo': 900, 'john': 1000, + 'trump': 1200 + } + result = [] + for name in names: + n = mock_db.get(name, None) + result.append({'number': n} if n else None) # conver to auto mapping compatible style + return result + +# define schemas +class Cash(BaseModel): + number: Optional[int] = None + +class Friend(BaseModel): + name: str + + cash: Optional[Cash] = None + @mapper(Cash) # auto mapping + def resolve_cash(self, contact_loader=LoaderDepend(cash_batch_load_fn)): + return contact_loader.load(self.name) + + has_cash: bool = False + def post_has_cash(self): + # self.has_cash = self.cash is not None + return self.cash is not None + + + +class User(BaseModel): + name: str + age: int + + friends: List[Friend] = [] + @mapper(lambda names: [Friend(name=name) for name in names]) + def resolve_friends(self, friend_loader=LoaderDepend(friends_batch_load_fn)): + return friend_loader.load(self.name) + + has_cash: bool = False + def post_has_cash(self): + return any([f.has_cash for f in self.friends]) + +class Root(BaseModel): + users: List[User] = [] + @mapper(lambda items: [User(**item) for item in items]) + def resolve_users(self): + return [ + {"name": "tangkikodo", "age": 19}, + {"name": "noone", "age": 19}, + ] + hello: str = '' + def post_default_handler(self, context): + self.hello = f'hello, {context["world"]}' + +@pytest.mark.asyncio +async def test_post_methods(): + root = Root() + root = await Resolver(context={"world": "new world"}).resolve(root) + dct = root.model_dump() + assert dct == {'users': [{'age': 19, + 'has_cash': True, + 'friends': [{'has_cash': False,'cash': None, 'name': 'tom'}, + {'has_cash': True, 'cash': {'number': 200}, 'name': 'jerry'}], + 'name': 'tangkikodo'}, + {'name': 'noone', 'age': 19, 'friends': [], 'has_cash':False } + ], + 'hello': 'hello, new world'} diff --git a/tests/pydantic_v2/resolver/test_19_post_methods_exception.py b/tests/pydantic_v2/resolver/test_19_post_methods_exception.py new file mode 100644 index 0000000..25f3770 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_19_post_methods_exception.py @@ -0,0 +1,71 @@ +from typing import List, Optional +from pydantic import BaseModel +from pydantic_resolve import Resolver, mapper, LoaderDepend, ResolverTargetAttrNotFound +import pytest + +# define loader functions +async def friends_batch_load_fn(names): + mock_db = { + 'tangkikodo': ['tom', 'jerry'], + 'john': ['mike', 'wallace'], + 'trump': ['sam', 'jim'], + 'sally': ['sindy', 'lydia'], + } + return [mock_db.get(name, []) for name in names] + +async def cash_batch_load_fn(names): + mock_db = { + 'tom': 100, 'jerry':200, 'mike': 3000, 'wallace': 400, 'sam': 500, + 'jim': 600, 'sindy': 700, 'lydia': 800, 'tangkikodo': 900, 'john': 1000, + 'trump': 1200, 'sally': 1300, + } + result = [] + for name in names: + n = mock_db.get(name, None) + result.append({'number': n} if n else None) # conver to auto mapping compatible style + return result + +# define schemas +class Cash(BaseModel): + number: Optional[int] = None + +class Friend(BaseModel): + name: str + + cash: Optional[Cash] = None + @mapper(Cash) # auto mapping + def resolve_cash(self, contact_loader=LoaderDepend(cash_batch_load_fn)): + return contact_loader.load(self.name) + + has_cash: bool = False + def post_has_cashx(self): + self.has_cash = self.cash is not None + + +class User(BaseModel): + name: str + age: int + + friends: List[Friend] = [] + @mapper(lambda names: [Friend(name=name) for name in names]) + def resolve_friends(self, friend_loader=LoaderDepend(friends_batch_load_fn)): + return friend_loader.load(self.name) + + has_cash: bool = False + def post_has_cash(self): + self.has_cash = any([f.has_cash for f in self.friends]) + +class Root(BaseModel): + users: List[User] = [] + @mapper(lambda items: [User(**item) for item in items]) + def resolve_users(self): + return [ + {"name": "tangkikodo", "age": 19}, + {"name": "noone", "age": 19}, + ] + +@pytest.mark.asyncio +async def test_post_methods(): + root = Root() + with pytest.raises(ResolverTargetAttrNotFound): + await Resolver().resolve(root) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_1_pydantic_resolve.py b/tests/pydantic_v2/resolver/test_1_pydantic_resolve.py new file mode 100644 index 0000000..3117ced --- /dev/null +++ b/tests/pydantic_v2/resolver/test_1_pydantic_resolve.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from typing import List +import asyncio +from pydantic import BaseModel +from pydantic_resolve import Resolver +import pytest + +class Student(BaseModel): + name: str + intro: str = '' + def resolve_intro(self) -> str: + return f'hello {self.name}' + + books: List[str] = [] + async def resolve_books(self) -> List[str]: + await asyncio.sleep(1) + return ['book1', 'book2'] + + +@pytest.mark.asyncio +async def test_resolve_object(): + stu = Student(name="martin") + result = await Resolver().resolve(stu) + expected = { + 'name': 'martin', + 'intro': 'hello martin', + 'books': ['book1', 'book2'] + } + assert result.model_dump() == expected + +@pytest.mark.asyncio +async def test_resolve_array(): + stu = [Student(name="martin")] + results = await Resolver().resolve(stu) + results = [r.model_dump() for r in results] + expected = [{ + 'name': 'martin', + 'intro': 'hello martin', + 'books': ['book1', 'book2'] + }] + assert results == expected diff --git a/tests/pydantic_v2/resolver/test_20_loader_instance.py b/tests/pydantic_v2/resolver/test_20_loader_instance.py new file mode 100644 index 0000000..ae972a1 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_20_loader_instance.py @@ -0,0 +1,105 @@ +from typing import List +from pydantic import BaseModel +from pydantic_resolve import Resolver, mapper, LoaderDepend +from aiodataloader import DataLoader +import pytest + +counter = { + "n": 0 +} + +# define loader functions +class FriendLoader(DataLoader): + async def batch_load_fn(self, names): + print(names) + counter["n"] += 1 + mock_db = { + 'tangkikodo': ['tom', 'jerry'], + 'john': ['mike', 'wallace'], + 'trump': ['sam', 'jim'], + 'sally': ['sindy', 'lydia'], + } + return [mock_db.get(name, []) for name in names] + +class FriendLoaderCopy(DataLoader): + async def batch_load_fn(self, names): + print(names) + counter["n"] += 1 + mock_db = { + 'tangkikodo': ['tom', 'jerry'], + 'john': ['mike', 'wallace'], + 'trump': ['sam', 'jim'], + 'sally': ['sindy', 'lydia'], + } + return [mock_db.get(name, []) for name in names] + +class Friend(BaseModel): + name: str + +class User(BaseModel): + name: str + age: int + + friends: List[Friend] = [] + @mapper(lambda names: [Friend(name=name) for name in names]) + def resolve_friends(self, friend_loader=LoaderDepend(FriendLoader)): + return friend_loader.load(self.name) + +class Root(BaseModel): + users: List[User] = [] + @mapper(lambda items: [User(**item) for item in items]) + def resolve_users(self): + return [ + {"name": "tangkikodo", "age": 19}, + {"name": "john", "age": 19}, + ] + +@pytest.mark.asyncio +async def test_loader_instance_0(): + counter["n"] = 0 + root = Root() + loader = FriendLoader() + loader.prime('tangkikodo', ['tom', 'jerry']) + loader.prime('john', ['mike', 'wallace']) + result = await Resolver(loader_instances={FriendLoader: loader}).resolve(root) + assert len(result.users[0].friends) == 2 + assert counter["n"] == 0 + +@pytest.mark.asyncio +async def test_loader_instance_1(): + counter["n"] = 0 + root = Root() + loader = FriendLoader() + await loader.load_many(['tangkikodo', 'john']) + result = await Resolver(loader_instances={FriendLoader: loader}).resolve(root) + assert len(result.users[0].friends) == 2 + assert counter["n"] == 1 + +@pytest.mark.asyncio +async def test_loader_instance_2(): + counter["n"] = 0 + root = Root() + loader = FriendLoader() + await loader.load_many(['tangkikodo']) + result = await Resolver(loader_instances={FriendLoader: loader}).resolve(root) + assert len(result.users[0].friends) == 2 + assert counter["n"] == 2 + +@pytest.mark.asyncio +async def test_loader_instance_3(): + root = Root() + loader = FriendLoader() + await loader.load_many(['tangkikodo']) + with pytest.raises(AttributeError): + await Resolver(loader_instances={FriendLoaderCopy: loader}).resolve(root) + +@pytest.mark.asyncio +async def test_loader_instance_4(): + root = Root() + loader = FriendLoader() + await loader.load_many(['tangkikodo']) + class A: + name= 'a' + + with pytest.raises(AttributeError): + await Resolver(loader_instances={A: loader}).resolve(root) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_21_not_stop_by_idle_level.py b/tests/pydantic_v2/resolver/test_21_not_stop_by_idle_level.py new file mode 100644 index 0000000..4c24100 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_21_not_stop_by_idle_level.py @@ -0,0 +1,52 @@ +import pytest +from pydantic import BaseModel +from typing import Optional +from pydantic_resolve import Resolver +import asyncio + +class D(BaseModel): + name: str = 'kikodo' + age: int = 0 + def resolve_age(self): + return 11 + +class C(BaseModel): + name: str = '' + d: D = D() + + +class B(BaseModel): + name: str + c: Optional[C] = None + async def resolve_c(self) -> Optional[C]: + await asyncio.sleep(1) + return C(name='hello world') + +class A(BaseModel): + b: B + +class Z(BaseModel): + a: A + resolve_age: int + +@pytest.mark.asyncio +async def test_resolve_object(): + s = Z(a=A(b=B(name="kikodo")), resolve_age=21) + + result = await Resolver().resolve(s) + expected = { + "a": { + "b": { + "name":"kikodo", + "c": { + "name": "hello world", + "d": { + "name": "kikodo", + "age": 11 + } + } + } + }, + "resolve_age": 21 + } + assert result.model_dump() == expected diff --git a/tests/pydantic_v2/resolver/test_22_not_stop_by_idle_level_complex.py b/tests/pydantic_v2/resolver/test_22_not_stop_by_idle_level_complex.py new file mode 100644 index 0000000..4f461e9 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_22_not_stop_by_idle_level_complex.py @@ -0,0 +1,85 @@ +from dataclasses import asdict, dataclass, field +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend + +@pytest.mark.asyncio +async def test_loader_depends(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + + async def batch_load_fn(keys): + print('oader') + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + class ClassRoom(BaseModel): + students: List[Student] + + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + res = await Resolver().resolve(classroom) + source = res.model_dump() + expected = { + "students": [ + {'id': 1, 'name': 'jack', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}, + {'id': 2, 'name': 'mike', 'books': [{ 'name': 'book3'}, {'name': 'book4'}]}, + {'id': 3, 'name': 'wiki', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}]} + assert source == expected + + +@pytest.mark.asyncio +async def test_loader_depends_2(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + @dataclass + class Book(): + name: str + + async def batch_load_fn(keys): + books = [[Book(name=bb['name']) for bb in BOOKS.get(k, [])] for k in keys] + return books + + @dataclass + class Student(): + id: int + name: str + + books: List[Book] = field(default_factory=list) + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + @dataclass + class ClassRoom(): + students: List[Student] + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + res = await Resolver().resolve(classroom) + source = asdict(res) + expected = { + "students": [ + {'id': 1, 'name': 'jack', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}, + {'id': 2, 'name': 'mike', 'books': [{ 'name': 'book3'}, {'name': 'book4'}]}, + {'id': 3, 'name': 'wiki', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}]} + assert source == expected \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_23_parse_to_obj_for_pydantic.py b/tests/pydantic_v2/resolver/test_23_parse_to_obj_for_pydantic.py new file mode 100644 index 0000000..a8a6ae5 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_23_parse_to_obj_for_pydantic.py @@ -0,0 +1,179 @@ +from collections import namedtuple +from typing import List +import pytest +from pydantic import ConfigDict, BaseModel, ValidationError +from pydantic_resolve import Resolver, LoaderDepend, mapper + +@pytest.mark.asyncio +async def test_1(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + + async def batch_load_fn(keys): + print('oader') + books = [BOOKS.get(k, []) for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + class ClassRoom(BaseModel): + students: List[Student] + + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + classroom = await Resolver().resolve(classroom) + assert isinstance(classroom.students[0].books[0], Book) + + +@pytest.mark.asyncio +async def test_2(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + num: int + + async def batch_load_fn(keys): + print('oader') + books = [[bb for bb in BOOKS.get(k, [])] for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + @mapper(lambda xx: [Book(name=x['name'], num=11) for x in xx]) + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + class ClassRoom(BaseModel): + students: List[Student] + + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + classroom = await Resolver().resolve(classroom) + assert isinstance(classroom.students[0].books[0], Book) + +@pytest.mark.asyncio +async def test_3(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + num: int # missing fields + + async def batch_load_fn(keys): + books = [[bb for bb in BOOKS.get(k, [])] for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + class ClassRoom(BaseModel): + students: List[Student] + + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + with pytest.raises(ValidationError): + await Resolver().resolve(classroom) + + +@pytest.mark.asyncio +async def test_4(): + BB = namedtuple('BB', 'name') + + BOOKS = { + 1: [BB(name='book1'),BB(name='book2')], + 2: [BB(name='book3'),BB(name='book4')], + 3: [BB(name='book1'),BB(name='book2')], + } + + class Book(BaseModel): + name: str + model_config = ConfigDict(from_attributes=True) + + async def batch_load_fn(keys): + books = [BOOKS.get(k, []) for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + class ClassRoom(BaseModel): + students: List[Student] + + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + classroom = await Resolver().resolve(classroom) + assert isinstance(classroom.students[0].books[0], Book) + +@pytest.mark.asyncio +async def test_5(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + num: int # missing fields + + async def batch_load_fn(keys): + books = [[bb for bb in BOOKS.get(k, [])] for k in keys] + return books + + class StudentBase(BaseModel): + id: int + name: str + + class Student(StudentBase): + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + class ClassRoom(BaseModel): + students: List[Student] + + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + with pytest.raises(ValidationError): + await Resolver().resolve(classroom) + diff --git a/tests/pydantic_v2/resolver/test_24_parse_to_obj_for_dataclass.py b/tests/pydantic_v2/resolver/test_24_parse_to_obj_for_dataclass.py new file mode 100644 index 0000000..4e2fde0 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_24_parse_to_obj_for_dataclass.py @@ -0,0 +1,38 @@ +from typing import List +import pytest +from dataclasses import dataclass, field +from pydantic_resolve import Resolver, LoaderDepend + +@pytest.mark.asyncio +async def test_loader_depends_1(): + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + @dataclass + class Book(): + name: str + + async def batch_load_fn(keys): + books = [[dict(name=bb['name']) for bb in BOOKS.get(k, [])] for k in keys] + return books + + @dataclass + class Student(): + id: int + name: str + + books: List[Book] = field(default_factory=list) + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + + @dataclass + class ClassRoom(): + students: List[Student] + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + res = await Resolver().resolve(classroom) + assert isinstance(res.students[0].books[0], Book) diff --git a/tests/pydantic_v2/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py b/tests/pydantic_v2/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py new file mode 100644 index 0000000..f8da875 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_25_parse_to_obj_for_pydantic_with_annotation.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend + +BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], +} +async def batch_load_fn(keys): + books = [BOOKS.get(k, []) for k in keys] + return books + +# in reverse order + +class ClassRoom(BaseModel): + students: List[Student] = [] + def resolve_students(self): + students = [dict(id=1, name="jack"), dict(id=2, name="mike"), dict(id=3, name="wiki")] + return students + +class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + +class Book(BaseModel): + name: str + + +@pytest.mark.asyncio +async def test_1(): + classroom = ClassRoom() + classroom = await Resolver().resolve(classroom) # annotation is not required any more. + assert isinstance(classroom.students[0].books[0], Book) + diff --git a/tests/pydantic_v2/resolver/test_26_tree.py b/tests/pydantic_v2/resolver/test_26_tree.py new file mode 100644 index 0000000..504de50 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_26_tree.py @@ -0,0 +1,54 @@ +from __future__ import annotations +from collections import defaultdict +from dataclasses import dataclass, field, asdict +from typing import List +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend, mapper +from aiodataloader import DataLoader +import pytest + +class DummyLoader(DataLoader): + async def batch_load_fn(self, keys): + d = dict() + return [d.get(k, []) for k in keys] + +class Tree(BaseModel): + id: int + content: str + children: List[Tree] = [] + def resolve_children(self, loader=LoaderDepend(DummyLoader)): + return loader.load(self.id) + + +@pytest.mark.asyncio +async def test_1(): + + loader = DummyLoader() + + records = [ + {'id': 2, 'parent': 1, 'content': '2'}, + {'id': 3, 'parent': 1, 'content': '3'}, + {'id': 4, 'parent': 2, 'content': '4'}, + ] + + d = defaultdict(list) + for r in records: + d[r['parent']].append(r) + + for k, v in d.items(): + loader.prime(k, v) + + tree = Tree(id=1, content='1') + tree = await Resolver(loader_instances={DummyLoader: loader}).resolve(tree) + expected = { + 'id': 1, 'content': '1', 'children': [ + { + 'id': 2, 'content': '2', 'children': [ + { 'id': 4, 'content': '4', 'children': [] } + ] + }, + {'id': 3, 'content': '3', 'children': [] }, + ], + } + assert tree.model_dump() == expected + diff --git a/tests/pydantic_v2/resolver/test_27_context.py b/tests/pydantic_v2/resolver/test_27_context.py new file mode 100644 index 0000000..9045e4c --- /dev/null +++ b/tests/pydantic_v2/resolver/test_27_context.py @@ -0,0 +1,72 @@ +import asyncio +from random import random +from pydantic import BaseModel +from pydantic_resolve import Resolver +from typing import List +import pytest + +class Human(BaseModel): + name: str + lucky: bool = True + async def resolve_lucky(self): + print('calculating...') + await asyncio.sleep(1) # mock i/o + return random() > 0.5 + +class Earth(BaseModel): + humans: List[Human] = [] + def resolve_humans(self, context): + return [dict(name=f'man-{i}') for i in range(context['count'])] + + count: int = 0 + def post_count(self, context): + return context['count'] + +class EarthBad(BaseModel): + humans: List[Human] = [] + def resolve_humans(self, context): + context['name'] = 'earth' # mappingproxytype will raise TypeError + return [dict(name=f'man-{i}') for i in range(context['count'])] + +class EarthBad2(BaseModel): + humans: List[Human] = [] + def resolve_humans(self, context): + return [dict(name=f'man-{i}') for i in range(context['count'])] + + count: int = 0 + def post_count(self, context): + context['count'] = 111 + return context['count'] + + +@pytest.mark.asyncio +async def test_1(): + earth = Earth() + earth = await Resolver(context={'count': 10}).resolve(earth) + assert len(earth.humans) == 10 + assert earth.count == 10 + + +@pytest.mark.asyncio +async def test_2(): + earth = Earth() + with pytest.raises(AttributeError): + await Resolver().resolve(earth) + +@pytest.mark.asyncio +async def test_3(): + earth = Earth() + with pytest.raises(KeyError): + await Resolver(context={'age': 10}).resolve(earth) + +@pytest.mark.asyncio +async def test_4(): + earth = EarthBad() + with pytest.raises(TypeError): + await Resolver(context={'count': 10}).resolve(earth) + +@pytest.mark.asyncio +async def test_5(): + earth = EarthBad2() + with pytest.raises(TypeError): + await Resolver(context={'count': 10}).resolve(earth) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py b/tests/pydantic_v2/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py new file mode 100644 index 0000000..e5974f6 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_28_parse_to_obj_for_dataclass_with_annotation.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import List, Optional +import pytest +from dataclasses import dataclass, field +from pydantic_resolve import Resolver, LoaderDepend + +BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], +} + +async def batch_load_fn(keys): + books = [[dict(name=bb['name']) for bb in BOOKS.get(k, [])] for k in keys] + return books + +@dataclass +class Student(): + id: int + name: str + + books: List[Book] = field(default_factory=list) + def resolve_books(self, loader=LoaderDepend(batch_load_fn)): + return loader.load(self.id) + +@dataclass +class ClassRoom(): + students: List[Student] + info: Optional[Info] = None + def resolve_info(self): + return dict(content='kikodo') + +@dataclass +class Book(): + name: str + +@dataclass +class Info(): + content: str + +@pytest.mark.asyncio +async def test_loader_depends_1(): + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + classroom = ClassRoom(students=students) + res = await Resolver().resolve(classroom) # auto resolve + assert isinstance(res.students[0].books[0], Book) + assert isinstance(res.info, Info) diff --git a/tests/pydantic_v2/resolver/test_29_better_warning_of_list.py b/tests/pydantic_v2/resolver/test_29_better_warning_of_list.py new file mode 100644 index 0000000..9e6cee6 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_29_better_warning_of_list.py @@ -0,0 +1,116 @@ +from __future__ import annotations +from pydantic import BaseModel, ValidationError +from pydantic_resolve import build_object, LoaderDepend, Resolver, build_list +from typing import List, Optional +import pytest + + +departments = [ + dict(id=1, name='INFRA'), + dict(id=2, name='DevOps'), + dict(id=3, name='Sales'), +] + +teams = [ + dict(id=1, department_id=1, name="K8S"), + dict(id=2, department_id=1, name="MONITORING"), + dict(id=3, department_id=1, name="Jenkins"), + dict(id=5, department_id=2, name="Frontend"), + dict(id=6, department_id=2, name="Bff"), + dict(id=7, department_id=2, name="Backend"), + dict(id=8, department_id=3, name="CAT"), + dict(id=9, department_id=3, name="Account"), + dict(id=10, department_id=3, name="Operation"), +] + +department_lead = [ + dict(id=1, department_id=1, name='mike'), + dict(id=2, department_id=2, name='john'), + dict(id=3, department_id=3, name='ralf'), +] + +members = [ + dict(id=1, team_id=1, name="Sophia", gender='female'), + dict(id=2, team_id=1, name="Jackson", gender='male'), + dict(id=3, team_id=2, name="Olivia", gender='female'), + dict(id=4, team_id=2, name="Liam", gender='male'), + dict(id=5, team_id=3, name="Emma", gender='female'), + dict(id=6, team_id=4, name="Noah", gender='male'), + dict(id=7, team_id=5, name="Ava", gender='female'), + dict(id=8, team_id=6, name="Lucas", gender='male'), + dict(id=9, team_id=6, name="Isabella", gender='female'), + dict(id=10, team_id=6, name="Mason", gender='male'), + dict(id=11, team_id=7, name="Mia", gender='female'), + dict(id=12, team_id=8, name="Ethan", gender='male'), + dict(id=13, team_id=8, name="Amelia", gender='female'), + dict(id=14, team_id=9, name="Oliver", gender='male'), + dict(id=15, team_id=9, name="Charlotte", gender='female'), + dict(id=16, team_id=10, name="Jacob", gender='male'), + dict(id=17, team_id=10, name="Abigail", gender='female'), + dict(id=18, team_id=10, name="Daniel", gender='male'), + dict(id=19, team_id=10, name="Emily", gender='female'), + dict(id=20, team_id=10, name="Ella", gender='female') +] + +class DepartmentBase(BaseModel): + id: int + name: str + +class TeamBase(BaseModel): + id: int + name: str + + +class MemberBase(BaseModel): + id: int + name: str + gender: str + + +async def lead_batch_load_fn(department_ids): + return build_list(department_lead, department_ids, lambda t: t['department_id']) + +async def teams_batch_load_fn(department_ids): + return build_list(teams, department_ids, lambda t: t['department_id']) + +async def members_batch_load_fn(team_ids): + return build_list(members, team_ids, lambda t: t['team_id']) + + +class Result(BaseModel): + departments: List[Department] + +class Department(DepartmentBase): + teams: List[Team] = [] + def resolve_teams(self, loader=LoaderDepend(teams_batch_load_fn)): + return loader.load(self.id) + + lead: Optional[Lead] = None + def resolve_lead(self, loader=LoaderDepend(lead_batch_load_fn)): + return loader.load(self.id) + +class Team(TeamBase): + members: List[Member] = [] + def resolve_members(self, loader=LoaderDepend(members_batch_load_fn)): + return loader.load(self.id) + +class Member(MemberBase): + ... + +class Lead(BaseModel): + id: int + name: str + + +@pytest.mark.asyncio +async def test_error(): + """ + 1. generate data from departments to members (top to bottom) + """ + Result.model_rebuild() + department_ids = {2,3} + _departments = [Department(**d) for d in departments if d['id'] in department_ids] + result = Result(departments=_departments) + with pytest.raises(ValidationError): + await Resolver().resolve(result) # auto resolve + diff --git a/tests/pydantic_v2/resolver/test_2_resolve_object.py b/tests/pydantic_v2/resolver/test_2_resolve_object.py new file mode 100644 index 0000000..aa80204 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_2_resolve_object.py @@ -0,0 +1,75 @@ +import pytest +from pydantic import BaseModel +from typing import Optional +from pydantic_resolve import Resolver +import asyncio +import time + +class DetailA(BaseModel): + name: str = '' + +class ServiceDetail1(BaseModel): + detail_a: Optional[DetailA] = None + + async def resolve_detail_a(self) -> Optional[DetailA]: + await asyncio.sleep(1) + return DetailA(name='hello world') + + detail_b: str = '' + + async def resolve_detail_b(self) -> str: + await asyncio.sleep(1) + return 'good' + +class Service(BaseModel): + service_detail_1: Optional[ServiceDetail1] = None + async def resolve_service_detail_1(self) -> Optional[ServiceDetail1]: + await asyncio.sleep(1) + return ServiceDetail1() + + service_detail_1b: Optional[ServiceDetail1] = None + async def resolve_service_detail_1b(self) -> Optional[ServiceDetail1]: + await asyncio.sleep(1) + return ServiceDetail1() + + service_detail_2: str = '' + async def resolve_service_detail_2(self) -> str: + await asyncio.sleep(1) + return "detail_2" + + service_detail_3: str = '' + async def resolve_service_detail_3(self) -> str: + await asyncio.sleep(1) + return "detail_3" + + service_detail_4: str = '' + async def resolve_service_detail_4(self) -> str: + await asyncio.sleep(1) + return "detail_4" + +@pytest.mark.asyncio +async def test_resolve_object(): + t = time.time() + s = Service() + result = await Resolver().resolve(s) + expected = { + "service_detail_1": { + "detail_a": { + "name": "hello world" + }, + "detail_b": "good" + }, + "service_detail_1b": { + "detail_a": { + "name": "hello world" + }, + "detail_b": "good" + }, + "service_detail_2": "detail_2", + "service_detail_3": "detail_3", + "service_detail_4": "detail_4", + } + assert result.model_dump() == expected + delta = time.time() - t + + assert delta < 2.1 \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_30_loader_in_object.py b/tests/pydantic_v2/resolver/test_30_loader_in_object.py new file mode 100644 index 0000000..81f207c --- /dev/null +++ b/tests/pydantic_v2/resolver/test_30_loader_in_object.py @@ -0,0 +1,59 @@ +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend +from aiodataloader import DataLoader + +# for testing, loder instance need to initialized inside a thread with eventloop +# (which means it can't be put in global scope of this file) +# otherwise it will generate anthoer loop which will raise error of +# "task attached to another loop" + +@pytest.mark.asyncio +async def test_loader_depends(): + counter = { + "book": 0 + } + + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + + class BookLoader(DataLoader): + async def batch_load_fn(self, keys): + counter["book"] += 1 + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + books_2: List[Book] = [] + def resolve_books_2(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + books_3: List[Book] = [] + def resolve_books_3(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + await Resolver().resolve(students) + assert counter["book"] == 1 + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + await Resolver().resolve(students) + assert counter["book"] == 2 + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + await Resolver().resolve(students) + assert counter["book"] == 3 \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_31_dynamic_variable_for_descdant_loader.py b/tests/pydantic_v2/resolver/test_31_dynamic_variable_for_descdant_loader.py new file mode 100644 index 0000000..bec12c2 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_31_dynamic_variable_for_descdant_loader.py @@ -0,0 +1,69 @@ +import pytest +from typing import List, Optional +from pydantic import BaseModel +from pydantic_resolve.resolver import Resolver + + +class Kar(BaseModel): + name: str + + desc: str = '' + def resolve_desc(self, ancestor_context): + return f"{self.name} - {ancestor_context['bar_num']} - {ancestor_context['foo_a']} - {ancestor_context['foo_b']}" + + output: str = '' + def post_output(self, ancestor_context): + return f"{self.name} - {ancestor_context['bar_num']} - {ancestor_context['foo_a']} - {ancestor_context['foo_b']}" + + kar_c: Optional[str] = None + def resolve_kar_c(self, ancestor_context): + return ancestor_context['foo_c'] + + +class Bar(BaseModel): + __pydantic_resolve_expose__ = {'num': 'bar_num'} + + num: int + + kars: List[Kar] = [] + def resolve_kars(self): + return [{'name': n} for n in ['a', 'b', 'c']] + + +class Foo(BaseModel): + __pydantic_resolve_expose__ = {'a': 'foo_a', 'b': 'foo_b', 'c': 'foo_c'} + + a: str + b: str + c: Optional[str] = None + nums:List[int] + bars: List[Bar] = [] + + def resolve_bars(self): + return [{'num': n} for n in self.nums] + + +@pytest.mark.asyncio +async def test_case(): + foo = Foo(nums=[1,2,3], a='a', b='b', c=None) + await Resolver().resolve(foo) + assert foo.model_dump() == { + 'a': 'a', + 'b': 'b', + 'c': None, + 'nums': [1,2,3], + 'bars': [ + {'num': 1, 'kars': [ + {'name': 'a', 'desc': 'a - 1 - a - b', 'output': 'a - 1 - a - b', 'kar_c': None}, + {'name': 'b', 'desc': 'b - 1 - a - b', 'output': 'b - 1 - a - b', 'kar_c': None}, + {'name': 'c', 'desc': 'c - 1 - a - b', 'output': 'c - 1 - a - b', 'kar_c': None} ]}, + {'num': 2, 'kars': [ + {'name': 'a', 'desc': 'a - 2 - a - b', 'output': 'a - 2 - a - b', 'kar_c': None}, + {'name': 'b', 'desc': 'b - 2 - a - b', 'output': 'b - 2 - a - b', 'kar_c': None}, + {'name': 'c', 'desc': 'c - 2 - a - b', 'output': 'c - 2 - a - b', 'kar_c': None} ]}, + {'num': 3, 'kars': [ + {'name': 'a', 'desc': 'a - 3 - a - b', 'output': 'a - 3 - a - b', 'kar_c': None}, + {'name': 'b', 'desc': 'b - 3 - a - b', 'output': 'b - 3 - a - b', 'kar_c': None}, + {'name': 'c', 'desc': 'c - 3 - a - b', 'output': 'c - 3 - a - b', 'kar_c': None} ]} + ] + } \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py b/tests/pydantic_v2/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py new file mode 100644 index 0000000..3669e36 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_32_dynamic_variable_for_descdant_loader_exception.py @@ -0,0 +1,108 @@ +import pytest +from typing import List +from pydantic import BaseModel +from pydantic_resolve.resolver import Resolver + + +@pytest.mark.asyncio +async def test_case_0(): + class Kar(BaseModel): + name: str + + desc: str = '' + def resolve_desc(self, ancestor_context): + return f"{self.name} - {ancestor_context['bar_num']}" + + + class Bar(BaseModel): + __pydantic_resolve_expose__ = {'num': 'bar_num'} + + num: int + + kars: List[Kar] = [] + def resolve_kars(self): + return [{'name': n} for n in ['a', 'b', 'c']] + + + class Foo(BaseModel): + __pydantic_resolve_expose__ = {'nums': 'bar_num'} + + nums:List[int] + bars: List[Bar] = [] + + def resolve_bars(self): + return [{'num': n} for n in self.nums] + + + foo = Foo(nums=[1,2,3]) + with pytest.raises(AttributeError) as e: + await Resolver().resolve(foo) + assert 'alias name conflicts, please check' in str(e.value) + + +@pytest.mark.asyncio +async def test_case_1(): + class Kar(BaseModel): + name: str + + desc: str = '' + def resolve_desc(self, ancestor_context): + return f"{self.name} - {ancestor_context['bar_num']}" + + + class Bar(BaseModel): + __pydantic_resolve_expose__ = ['num', 'bar_num'] + + num: int + + kars: List[Kar] = [] + def resolve_kars(self): + return [{'name': n} for n in ['a', 'b', 'c']] + + + class Foo(BaseModel): + nums:List[int] + bars: List[Bar] = [] + + def resolve_bars(self): + return [{'num': n} for n in self.nums] + + + foo = Foo(nums=[1,2,3]) + with pytest.raises(AttributeError) as e: + await Resolver().resolve(foo) + assert 'is not dict' in str(e.value) + + +@pytest.mark.asyncio +async def test_case_2(): + class Kar(BaseModel): + name: str + + desc: str = '' + def resolve_desc(self, ancestor_context): + return f"{self.name} - {ancestor_context['bar_num']}" + + + class Bar(BaseModel): + __pydantic_resolve_expose__ = {'xnum': 'bar_num'} + + num: int + + kars: List[Kar] = [] + def resolve_kars(self): + return [{'name': n} for n in ['a', 'b', 'c']] + + + class Foo(BaseModel): + nums:List[int] + bars: List[Bar] = [] + + def resolve_bars(self): + return [{'num': n} for n in self.nums] + + + foo = Foo(nums=[1,2,3]) + with pytest.raises(AttributeError) as e: + await Resolver().resolve(foo) + assert 'does not existed' in str(e.value) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_33_global_loader_filter.py b/tests/pydantic_v2/resolver/test_33_global_loader_filter.py new file mode 100644 index 0000000..3d67ad5 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_33_global_loader_filter.py @@ -0,0 +1,70 @@ +from pydantic import BaseModel +from typing import List +from pydantic_resolve import Resolver, LoaderDepend +from aiodataloader import DataLoader +import pytest +from pydantic_resolve.exceptions import GlobalLoaderFieldOverlappedError + +class LoaderA(DataLoader): + power: int + async def batch_load_fn(self, keys: List[int]): + return [ k** self.power for k in keys ] + +class LoaderB(DataLoader): + power: int + async def batch_load_fn(self, keys: List[int]): + return [ k** self.power for k in keys ] + +class LoaderC(DataLoader): + power: int + add: int + async def batch_load_fn(self, keys: List[int]): + return [ k** self.power + self.add for k in keys ] + + +async def loader_fn_a(keys): + return [ k**2 for k in keys ] + +class A(BaseModel): + val: int + + a: int = 0 + def resolve_a(self, loader=LoaderDepend(LoaderA)): + return loader.load(self.val) + + b: int = 0 + def resolve_b(self, loader=LoaderDepend(LoaderB)): + return loader.load(self.val) + + c: int = 0 + def resolve_c(self, loader=LoaderDepend(LoaderC)): + return loader.load(self.val) + + +@pytest.mark.asyncio +async def test_case_0(): + data = [A(val=n) for n in range(3)] + data = await Resolver(global_loader_param={'power': 2}, + loader_params={LoaderC:{'add': 1}}).resolve(data) + assert data[2].a == 4 + assert data[2].b == 4 + assert data[2].c == 5 + + +@pytest.mark.asyncio +async def test_case_1(): + data = [A(val=n) for n in range(3)] + data = await Resolver(loader_params={LoaderA:{'power': 2}, + LoaderB:{'power': 3}, + LoaderC:{'power': 3, 'add': 1}}).resolve(data) + assert data[2].a == 4 + assert data[2].b == 8 + assert data[2].c == 9 + + +@pytest.mark.asyncio +async def test_case_3(): + data = [A(val=n) for n in range(3)] + with pytest.raises(GlobalLoaderFieldOverlappedError): + data = await Resolver(global_loader_param={'power': 2}, + loader_params={LoaderC:{'add': 1, 'power': 3}}).resolve(data) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_34_filter_rename_deprecation.py b/tests/pydantic_v2/resolver/test_34_filter_rename_deprecation.py new file mode 100644 index 0000000..86e6772 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_34_filter_rename_deprecation.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel +from typing import List +from pydantic_resolve import Resolver, LoaderDepend +from aiodataloader import DataLoader +import pytest +from pydantic_resolve.exceptions import GlobalLoaderFieldOverlappedError + +class LoaderA(DataLoader): + power: int + async def batch_load_fn(self, keys: List[int]): + return [ k** self.power for k in keys ] + +class LoaderB(DataLoader): + power: int + async def batch_load_fn(self, keys: List[int]): + return [ k** self.power for k in keys ] + +class LoaderC(DataLoader): + power: int + add: int + async def batch_load_fn(self, keys: List[int]): + return [ k** self.power + self.add for k in keys ] + + +async def loader_fn_a(keys): + return [ k**2 for k in keys ] + +class A(BaseModel): + val: int + + a: int = 0 + def resolve_a(self, loader=LoaderDepend(LoaderA)): + return loader.load(self.val) + + b: int = 0 + def resolve_b(self, loader=LoaderDepend(LoaderB)): + return loader.load(self.val) + + c: int = 0 + def resolve_c(self, loader=LoaderDepend(LoaderC)): + return loader.load(self.val) + + +@pytest.mark.asyncio +async def test_case_0(): + data = [A(val=n) for n in range(3)] + with pytest.warns(DeprecationWarning): + data = await Resolver(global_loader_filter={'power': 2}, + loader_filters={LoaderC:{'add': 1}}).resolve(data) + + +@pytest.mark.asyncio +async def test_case_1(): + data = [A(val=n) for n in range(3)] + with pytest.warns(DeprecationWarning): + data = await Resolver(loader_filters={LoaderA:{'power': 2}, + LoaderB:{'power': 3}, + LoaderC:{'power': 3, 'add': 1}}).resolve(data) + diff --git a/tests/pydantic_v2/resolver/test_35_collector.py b/tests/pydantic_v2/resolver/test_35_collector.py new file mode 100644 index 0000000..4fbec79 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_35_collector.py @@ -0,0 +1,85 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import List +from pydantic_resolve import Resolver, Collector +import pytest + +from pydantic_resolve.core import LoaderDepend + +class SubCollector(Collector): + def add(self, val): # replace with your implementation + print('add') + self.val.append(val) + +async def c_loader_fn(keys): + return [[C(detail=f'{k}-1'), C(detail=f'{k}-2')] for k in keys] + +class A(BaseModel): + b_list: List[B] = [] + async def resolve_b_list(self): + return [dict(name='b1'), dict(name='b2')] + + names: List[str] = [] + def post_names(self, collector=SubCollector('b_name')): + return collector.values() + + items: List[str] = [] + def post_items(self, collector=Collector('b_items', flat=True)): + return collector.values() + + details: List[str] = [] + def post_details(self, collector=Collector('c_details', flat=True)): + return collector.values() + + details_nest: List[List[str]] = [] + def post_details_nest(self, + collector=Collector('c_details')): + return collector.values() + + details_compare: bool = False + def post_details_compare(self, + collector=Collector('c_details'), + collector2=Collector('c_details'), + ): + return collector.values() == collector2.values() + +class B(BaseModel): + __pydantic_resolve_collect__ = { + 'name': 'b_name', + 'items': 'b_items' + } + name: str + items: List[str] = ['x', 'y'] + + details: List[str] = [] + def post_details(self, collector=Collector('c_details', flat=True)): + return collector.values() + + c_list: List[C] = [] + async def resolve_c_list(self, loader=LoaderDepend(c_loader_fn)): + return loader.load(self.name) + +class C(BaseModel): + __pydantic_resolve_collect__ = { + 'details': 'c_details' + } + detail: str + + details: List[str] = [] + def resolve_details(self): + return [f'{self.detail}-detail-1', f'{self.detail}-detail-2'] + + +@pytest.mark.asyncio +async def test_collector_1(): + a = A() + a = await Resolver().resolve(a) + assert a.names == ['b1', 'b2'] + assert a.items == ['x', 'y', 'x', 'y'] + assert a.details == ['b1-1-detail-1', 'b1-1-detail-2', 'b1-2-detail-1', 'b1-2-detail-2', 'b2-1-detail-1', 'b2-1-detail-2', 'b2-2-detail-1', 'b2-2-detail-2'] + assert a.details_nest == [['b1-1-detail-1', 'b1-1-detail-2'], + ['b1-2-detail-1', 'b1-2-detail-2'], + ['b2-1-detail-1', 'b2-1-detail-2'], + ['b2-2-detail-1', 'b2-2-detail-2']] + + assert a.details_compare is True \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_36_collector_level_by_level.py b/tests/pydantic_v2/resolver/test_36_collector_level_by_level.py new file mode 100644 index 0000000..9878529 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_36_collector_level_by_level.py @@ -0,0 +1,84 @@ +from __future__ import annotations +from pydantic import BaseModel +from typing import List +from pydantic_resolve import Resolver, Collector +from pydantic_resolve.core import LoaderDepend +import pytest + + +class Root(BaseModel): + list_a: List[A] = [] + def resolve_list_a(self): + return data + + names: List[str] = [] + def post_names(self, collector=Collector('b_name')): + return collector.values() + +class A(BaseModel): + list_b: List[B] + + names: List[str] = [] + def post_names(self, collector=Collector('b_name')): + return collector.values() + +class B(BaseModel): + __pydantic_resolve_collect__ = {'name': 'b_name'} + name: str + + +data = [ + {'list_b': [ + {'name': 'b1'}, + {'name': 'b2'}, + ]}, + {'list_b': [ + {'name': 'b3'}, + {'name': 'b4'}, + ]}, + ] + +@pytest.mark.asyncio +async def test_level(): + r = Root() + resolver = Resolver() + r = await resolver.resolve(r) + # print(resolver.object_collect_alias_map_store) + assert r.model_dump() == { + "list_a": [ + { + "list_b": [ + { + "name": "b1" + }, + { + "name": "b2" + } + ], + "names": [ + "b1", + "b2" + ] + }, + { + "list_b": [ + { + "name": "b3" + }, + { + "name": "b4" + } + ], + "names": [ + "b3", + "b4" + ] + } + ], + "names": [ + "b1", + "b2", + "b3", + "b4" + ] + } \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_37_specific_types.py b/tests/pydantic_v2/resolver/test_37_specific_types.py new file mode 100644 index 0000000..b4f0639 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_37_specific_types.py @@ -0,0 +1,17 @@ +import pytest +from typing import Tuple +from pydantic import BaseModel +from pydantic_resolve import Resolver + +class Case(BaseModel): + value: Tuple[int, int]=(0,0) + + def resolve_value(self): + return (1, 1) + + +@pytest.mark.asyncio +async def test_works(): + # https://github.com/allmonday/pydantic2-resolve/issues/7 + result = await Resolver().resolve(Case()) + assert result.value == (1, 1) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_39_parent.py b/tests/pydantic_v2/resolver/test_39_parent.py new file mode 100644 index 0000000..a135cf0 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_39_parent.py @@ -0,0 +1,76 @@ +from __future__ import annotations +import pytest +from typing import Optional, List +from pydantic import BaseModel +from pydantic_resolve import Resolver + + +class Base(BaseModel): + name: str + + child: Optional[Child] = None + def resolve_child(self): + return Child() + + children: List[Child] = [] + def resolve_children(self): + return [Child()] + + parent: Optional[str] = '123' + def resolve_parent(self, parent): + return parent + +class Child(BaseModel): + pname: str = '' + def resolve_pname(self, parent: Base): + return parent.name + + pname2: str = '' + def resolve_pname2(self, parent: Base): + return parent.name + + +@pytest.mark.asyncio +async def test_parent(): + b = Base(name='kikodo') + b = await Resolver().resolve(b) + assert b.parent is None # parent of root is none + + assert b.name == 'kikodo' + assert b.child.pname == 'kikodo' + assert b.child.pname2 == 'kikodo' # work with obj + + assert b.children[0].pname == 'kikodo' + assert b.children[0].pname2 == 'kikodo' # work with list + + +class Tree(BaseModel): + name: str + + path: str = '' + def resolve_path(self, parent): + if parent is not None: + return f'{parent.path}/{self.name}' + return self.name + children: List[Tree] = [] + +@pytest.mark.asyncio +async def test_tree(): + data = dict(name="a", children=[ + dict(name="b", children=[ + dict(name="c") + ]), + dict(name="d", children=[ + dict(name="c") + ]) + ]) + data = await Resolver().resolve(Tree(**data)) + assert data.model_dump() == dict(name="a", path="a", children=[ + dict(name="b", path="a/b", children=[ + dict(name="c", path="a/b/c", children=[]) + ]), + dict(name="d", path="a/d", children=[ + dict(name="c", path="a/d/c", children=[]) + ]) + ]) + print(data.model_dump_json(indent=2)) diff --git a/tests/pydantic_v2/resolver/test_3_tuple_list.py b/tests/pydantic_v2/resolver/test_3_tuple_list.py new file mode 100644 index 0000000..97dd371 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_3_tuple_list.py @@ -0,0 +1,28 @@ +from __future__ import annotations +from typing import List, Tuple +import asyncio +from pydantic import BaseModel +from pydantic_resolve import Resolver +import pytest + +class Student(BaseModel): + new_books: List[str] = [] + async def resolve_new_books(self) -> List[str]: + await asyncio.sleep(1) + return ['book1', 'book2'] + + old_books: List[str] = [] + async def resolve_old_books(self) -> List[str]: + await asyncio.sleep(1) + return ['book1', 'book2'] + + +@pytest.mark.asyncio +async def test_type_definition(): + stu = Student() + result = await Resolver().resolve(stu) + expected = { + 'new_books': ['book1', 'book2'], + 'old_books': ['book1', 'book2'] + } + assert result.model_dump() == expected diff --git a/tests/pydantic_v2/resolver/test_4_resolve_return_types.py b/tests/pydantic_v2/resolver/test_4_resolve_return_types.py new file mode 100644 index 0000000..d56a76f --- /dev/null +++ b/tests/pydantic_v2/resolver/test_4_resolve_return_types.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from typing import List, Optional +import asyncio +from pydantic import BaseModel +from pydantic_resolve import Resolver +import pytest + +async def set_after(fut, value): + await asyncio.sleep(1) + fut.set_result(value) + +class Student(BaseModel): + scores: List[int] = [] + async def resolve_scores(self) -> List[int]: + return [1,2,3] + + age: Optional[int] = None + def resolve_age(self) -> Optional[int]: + return 12 + + name: Optional[str] = None + def resolve_name(self) -> Optional[str]: + return 'name' + + future: Optional[str] = None + def resolve_future(self) -> Optional[str]: + loop = asyncio.get_running_loop() + fut = loop.create_future() + loop.create_task(set_after(fut, 'hello')) + return fut + +@pytest.mark.asyncio +async def test_resolve_future(): + stu = Student() + result = await Resolver().resolve(stu) + expected = { + 'name': 'name', + 'scores': [1,2,3], + 'age': 12, + 'future': 'hello' + } + assert result.model_dump() == expected diff --git a/tests/pydantic_v2/resolver/test_5_exception.py b/tests/pydantic_v2/resolver/test_5_exception.py new file mode 100644 index 0000000..41c9a2f --- /dev/null +++ b/tests/pydantic_v2/resolver/test_5_exception.py @@ -0,0 +1,49 @@ +from __future__ import annotations +from pydantic_resolve import ResolverTargetAttrNotFound, Resolver +from pydantic import ConfigDict, BaseModel, ValidationError +import pytest + +class Service(BaseModel): + service_detail: str = '' + def resolve_service_details(self) -> str: + return "detail" + +@pytest.mark.asyncio +async def test_exception_1(): + with pytest.raises(ResolverTargetAttrNotFound, match="attribute service_details not found"): + s = Service() + await Resolver().resolve(s) + +class CustomException(Exception): + pass + +class CustomException2(Exception): + pass + +class Service2(BaseModel): + service_detail: str = '' + async def resolve_service_detail(self) -> str: + raise CustomException2('oops') + + service_detail_2: str = '' + async def resolve_service_detail_2(self) -> str: + raise CustomException('oops') + +@pytest.mark.asyncio +async def test_custom_exception(): + with pytest.raises((CustomException, CustomException2), match="oops"): + s = Service2() + await Resolver().resolve(s) + + +class Service3(BaseModel): + service_detail: int = 0 + def resolve_service_detail(self) -> int: + return 'abc' # type:ignore + model_config = ConfigDict(validate_assignment=True) + +@pytest.mark.asyncio +async def test_pydantic_validate_exception(): + with pytest.raises(ValidationError): + s = Service3() + await Resolver().resolve(s) diff --git a/tests/pydantic_v2/resolver/test_6_resolve_dataclass.py b/tests/pydantic_v2/resolver/test_6_resolve_dataclass.py new file mode 100644 index 0000000..260a61f --- /dev/null +++ b/tests/pydantic_v2/resolver/test_6_resolve_dataclass.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, asdict, field +import asyncio +from pydantic_resolve import Resolver +from typing import List +import pytest + +@dataclass +class Wheel: + is_ok: bool + +@dataclass +class Car: + name: str + wheels: List[Wheel] = field(default_factory=list) + + async def resolve_wheels(self) -> List[Wheel]: + await asyncio.sleep(1) + return [Wheel(is_ok=True)] + +@pytest.mark.asyncio +async def test_resolve_dataclass_1(): + car = Car(name="byd") + result = await Resolver().resolve(car) + expected = { + 'name': 'byd', + 'wheels': [{'is_ok': True}] + } + assert asdict(result) == expected + +@pytest.mark.asyncio +async def test_resolver_dataclass_2(): + car = [Car(name="byd")] + result = await Resolver().resolve(car) + expected = { + 'name': 'byd', + 'wheels': [{'is_ok': True}] + } + assert asdict(result[0]) == expected \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_7_sqlalchemy_query.py b/tests/pydantic_v2/resolver/test_7_sqlalchemy_query.py new file mode 100644 index 0000000..dde9542 --- /dev/null +++ b/tests/pydantic_v2/resolver/test_7_sqlalchemy_query.py @@ -0,0 +1,164 @@ +from typing import List +import pytest +from collections import Counter, defaultdict +from aiodataloader import DataLoader +from pydantic import ConfigDict, BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from pydantic_resolve import Resolver + +class Base(DeclarativeBase): + pass + +class Task(Base): + __tablename__ = "task" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + +class Comment(Base): + __tablename__ = "comment" + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column() + content: Mapped[str] + +class Feedback(Base): + __tablename__ = "feedback" + id: Mapped[int] = mapped_column(primary_key=True) + comment_id: Mapped[int] = mapped_column() + content: Mapped[str] + +@pytest.mark.asyncio +async def test_sqlite_and_dataloader(): + counter = Counter() + engine = create_async_engine( + "sqlite+aiosqlite://", + echo=False, + ) + async_session = async_sessionmaker(engine, expire_on_commit=False) + + async def insert_objects() -> None: + async with async_session() as session: + async with session.begin(): + session.add_all( + [ + Task(id=1, name="task-1"), + Task(id=2, name="task-2"), + Task(id=3, name="task-3"), + + Comment(id=1, task_id=1, content="comment-1 for task 1"), + Comment(id=2, task_id=1, content="comment-2 for task 1"), + Comment(id=3, task_id=2, content="comment-1 for task 2"), + + Feedback(id=1, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=2, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=3, comment_id=1, content="feedback-1 for comment-1"), + ] + ) + + class FeedbackLoader(DataLoader): + async def batch_load_fn(self, comment_ids): + counter['load-feedback'] += 1 + async with async_session() as session: + res = await session.execute(select(Feedback).where(Feedback.comment_id.in_(comment_ids))) + rows = res.scalars().all() + dct = defaultdict(list) + for row in rows: + dct[row.comment_id].append(FeedbackSchema.model_validate(row)) + return [dct.get(k, []) for k in comment_ids] + + class CommentLoader(DataLoader): + async def batch_load_fn(self, task_ids): + counter['load-comment'] += 1 + async with async_session() as session: + res = await session.execute(select(Comment).where(Comment.task_id.in_(task_ids))) + rows = res.scalars().all() + + dct = defaultdict(list) + for row in rows: + dct[row.task_id].append(CommentSchema.model_validate(row)) + return [dct.get(k, []) for k in task_ids] + + feedback_loader = FeedbackLoader() + comment_loader = CommentLoader() + + class FeedbackSchema(BaseModel): + id: int + comment_id: int + content: str + model_config = ConfigDict(from_attributes=True) + + class CommentSchema(BaseModel): + id: int + task_id: int + content: str + feedbacks: List[FeedbackSchema] = [] + + def resolve_feedbacks(self): + return feedback_loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + class TaskSchema(BaseModel): + id: int + name: str + comments: List[CommentSchema] = [] + + def resolve_comments(self): + return comment_loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + async def init(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def query(): + async with async_session() as session: + tasks = (await session.execute(select(Task))).scalars().all() + task_objs = [TaskSchema.model_validate(t) for t in tasks] + resolved_results = await Resolver().resolve(task_objs) + to_dict_arr = [r.model_dump() for r in resolved_results] + return to_dict_arr + + await init() + await insert_objects() + result = await query() + expected = [ + { + 'comments': [{'content': 'comment-1 for task 1', + 'feedbacks': [{'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 1}, + {'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 2}, + {'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 3}], + 'id': 1, + 'task_id': 1}, + {'content': 'comment-2 for task 1', + 'feedbacks': [], + 'id': 2, + 'task_id': 1}], + 'id': 1, + 'name': 'task-1'}, + {'comments': [{'content': 'comment-1 for task 2', + 'feedbacks': [], + 'id': 3, + 'task_id': 2}], + 'id': 2, + 'name': 'task-2'}, + {'comments': [], 'id': 3, 'name': 'task-3'}] + + assert result == expected + assert counter['load-comment'] == 1 # batch_load_fn only called once + assert counter['load-feedback'] == 1 + + await query() + + assert counter['load-comment'] == 1 # Resolver alone can't fix cache issue + assert counter['load-feedback'] == 1 \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_8_loader_depend.py b/tests/pydantic_v2/resolver/test_8_loader_depend.py new file mode 100644 index 0000000..31889ee --- /dev/null +++ b/tests/pydantic_v2/resolver/test_8_loader_depend.py @@ -0,0 +1,54 @@ +from typing import List +import pytest +from pydantic import BaseModel +from pydantic_resolve import Resolver, LoaderDepend +from aiodataloader import DataLoader + +@pytest.mark.asyncio +async def test_loader_depends(): + counter = { + "book": 0 + } + + BOOKS = { + 1: [{'name': 'book1'}, {'name': 'book2'}], + 2: [{'name': 'book3'}, {'name': 'book4'}], + 3: [{'name': 'book1'}, {'name': 'book2'}], + } + + class Book(BaseModel): + name: str + + class BookLoader(DataLoader): + async def batch_load_fn(self, keys): + counter["book"] += 1 + books = [[Book(**bb) for bb in BOOKS.get(k, [])] for k in keys] + return books + + # for testing, loder instance need to initialized inside a thread with eventloop + # (which means it can't be put in global scope of this file) + # otherwise it will generate anthoer loop which will raise error of + # "task attached to another loop" + + class Student(BaseModel): + id: int + name: str + + books: List[Book] = [] + def resolve_books(self, loader=LoaderDepend(BookLoader)): + return loader.load(self.id) + + students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + results = await Resolver().resolve(students) + source = [r.model_dump() for r in results] + expected = [ + {'id': 1, 'name': 'jack', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}, + {'id': 2, 'name': 'mike', 'books': [{ 'name': 'book3'}, {'name': 'book4'}]}, + {'id': 3, 'name': 'wiki', 'books': [{ 'name': 'book1'}, {'name': 'book2'}]}, + ] + assert source == expected + assert counter["book"] == 1 + + students2 = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] + await Resolver().resolve(students2) + assert counter["book"] == 2 # called twice (means no cache) \ No newline at end of file diff --git a/tests/pydantic_v2/resolver/test_9_sqlalchemy_query_fix_cache.py b/tests/pydantic_v2/resolver/test_9_sqlalchemy_query_fix_cache.py new file mode 100644 index 0000000..592c46d --- /dev/null +++ b/tests/pydantic_v2/resolver/test_9_sqlalchemy_query_fix_cache.py @@ -0,0 +1,162 @@ +from typing import List +import pytest +from collections import Counter, defaultdict +from typing import Tuple +from aiodataloader import DataLoader +from pydantic import ConfigDict, BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from pydantic_resolve import Resolver, LoaderDepend + +class Base(DeclarativeBase): + pass + +class Task(Base): + __tablename__ = "task" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] + +class Comment(Base): + __tablename__ = "comment" + id: Mapped[int] = mapped_column(primary_key=True) + task_id: Mapped[int] = mapped_column() + content: Mapped[str] + +class Feedback(Base): + __tablename__ = "feedback" + id: Mapped[int] = mapped_column(primary_key=True) + comment_id: Mapped[int] = mapped_column() + content: Mapped[str] + +@pytest.mark.asyncio +async def test_sqlite_and_dataloader(): + counter = Counter() + engine = create_async_engine( + "sqlite+aiosqlite://", + echo=False, + ) + async_session = async_sessionmaker(engine, expire_on_commit=False) + + async def insert_objects() -> None: + async with async_session() as session: + async with session.begin(): + session.add_all( + [ + Task(id=1, name="task-1"), + Task(id=2, name="task-2"), + Task(id=3, name="task-3"), + + Comment(id=1, task_id=1, content="comment-1 for task 1"), + Comment(id=2, task_id=1, content="comment-2 for task 1"), + Comment(id=3, task_id=2, content="comment-1 for task 2"), + + Feedback(id=1, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=2, comment_id=1, content="feedback-1 for comment-1"), + Feedback(id=3, comment_id=1, content="feedback-1 for comment-1"), + ] + ) + + class FeedbackLoader(DataLoader): + async def batch_load_fn(self, comment_ids): + counter['load-feedback'] += 1 + async with async_session() as session: + res = await session.execute(select(Feedback).where(Feedback.comment_id.in_(comment_ids))) + rows = res.scalars().all() + dct = defaultdict(list) + for row in rows: + dct[row.comment_id].append(FeedbackSchema.model_validate(row)) + return [dct.get(k, []) for k in comment_ids] + + class CommentLoader(DataLoader): + async def batch_load_fn(self, task_ids): + counter['load-comment'] += 1 + async with async_session() as session: + res = await session.execute(select(Comment).where(Comment.task_id.in_(task_ids))) + rows = res.scalars().all() + + dct = defaultdict(list) + for row in rows: + dct[row.task_id].append(CommentSchema.model_validate(row)) + return [dct.get(k, []) for k in task_ids] + + class FeedbackSchema(BaseModel): + id: int + comment_id: int + content: str + model_config = ConfigDict(from_attributes=True) + + class CommentSchema(BaseModel): + id: int + task_id: int + content: str + feedbacks: List[FeedbackSchema] = [] + + def resolve_feedbacks(self, loader=LoaderDepend(FeedbackLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + class TaskSchema(BaseModel): + id: int + name: str + comments: List[CommentSchema] = [] + + def resolve_comments(self, loader=LoaderDepend(CommentLoader)): + return loader.load(self.id) + model_config = ConfigDict(from_attributes=True) + + async def init(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def query(): + async with async_session() as session: + tasks = (await session.execute(select(Task))).scalars().all() + task_objs = [TaskSchema.model_validate(t) for t in tasks] + resolved_results = await Resolver().resolve(task_objs) + to_dict_arr = [r.model_dump() for r in resolved_results] + return to_dict_arr + + await init() + await insert_objects() + result = await query() + expected = [ + { + 'comments': [{'content': 'comment-1 for task 1', + 'feedbacks': [{'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 1}, + {'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 2}, + {'comment_id': 1, + 'content': 'feedback-1 for comment-1', + 'id': 3}], + 'id': 1, + 'task_id': 1}, + {'content': 'comment-2 for task 1', + 'feedbacks': [], + 'id': 2, + 'task_id': 1}], + 'id': 1, + 'name': 'task-1'}, + {'comments': [{'content': 'comment-1 for task 2', + 'feedbacks': [], + 'id': 3, + 'task_id': 2}], + 'id': 2, + 'name': 'task-2'}, + {'comments': [], 'id': 3, 'name': 'task-3'}] + + assert result == expected + assert counter['load-comment'] == 1 # batch_load_fn only called once + assert counter['load-feedback'] == 1 + + await query() + + assert counter['load-comment'] == 2 # Resolver + LoaderDepend can fix cache issue + assert counter['load-feedback'] == 2 \ No newline at end of file diff --git a/tests/pydantic_v2/utils/test_2_ensure_subset.py b/tests/pydantic_v2/utils/test_2_ensure_subset.py new file mode 100644 index 0000000..4f82b0f --- /dev/null +++ b/tests/pydantic_v2/utils/test_2_ensure_subset.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from pydantic import BaseModel +from pydantic_resolve import util +import pytest + +def test_ensure_subset(): + class Base(BaseModel): + a: str + b: int + + @dataclass + class BaseX(): + a: str + b: int + + @util.ensure_subset(Base) + class ChildA(BaseModel): + a: str + + @util.ensure_subset(Base) + class ChildB(BaseModel): + a: str + c: int = 0 + def resolve_c(self): + return 21 + + with pytest.raises(AttributeError): + @util.ensure_subset(Base) + class ChildC(BaseModel): + a: str + b: str + + with pytest.raises(AttributeError): + @util.ensure_subset(Base) + class ChildD(BaseModel): + a: str + b: int + c: int + + with pytest.raises(AssertionError): + @util.ensure_subset(Base) + @dataclass + class ChildE(): + a: str + b: int + c: int + + with pytest.raises(AssertionError): + @util.ensure_subset(BaseX) + @dataclass + class ChildF(): + a: str + b: int + c: int \ No newline at end of file diff --git a/tests/pydantic_v2/utils/test_model_config.py b/tests/pydantic_v2/utils/test_model_config.py new file mode 100644 index 0000000..8c7f51e --- /dev/null +++ b/tests/pydantic_v2/utils/test_model_config.py @@ -0,0 +1,47 @@ +from pydantic_resolve.util import model_config +from pydantic import BaseModel, Field +from pydantic_resolve import Resolver +import pytest + +@pytest.mark.asyncio +async def test_schema_config(): + + @model_config() + class Y(BaseModel): + id: int = Field(default=0, exclude=True) + def resolve_id(self): + return 1 + + name: str + + password: str = Field(default='', exclude=True) + def resolve_password(self): + return 'confidential' + + schema = Y.model_json_schema() + assert list((schema['properties']).keys()) == ['name'] + + y = Y(name='kikodo') + + y = await Resolver().resolve(y) + assert y.model_dump() == {'name': 'kikodo'} + + + +@pytest.mark.asyncio +async def test_schema_config_required(): + @model_config(default_required=False) + class Y(BaseModel): + id: int = 0 + def resolve_id(self): + return 1 + + name: str + + password: str = '' + def resolve_password(self): + return 'confidential' + + schema = Y.model_json_schema() + assert set(schema['required']) == {'name'} + diff --git a/tests/pydantic_v2/utils/test_output.py b/tests/pydantic_v2/utils/test_output.py new file mode 100644 index 0000000..7277ed3 --- /dev/null +++ b/tests/pydantic_v2/utils/test_output.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from pydantic_resolve.util import output +from pydantic import BaseModel +from typing import List, Optional +import pytest + +def test_output(): + @output + class A(BaseModel): + id: int + opt: Optional[str] = None + + name: str = '' + def resolve_name(self): + return 'hi' + + age: int = 0 + def resolve_age(self): + return 1 + + greet: str = '' + def post_greet(self): + return 'hi' + + json_schema = A.model_json_schema() + assert set(json_schema['required']) == {'id', 'name', 'age', 'greet'} + + a = A(id=1) + assert a.id == 1 # can assign + + +def test_output2(): + + with pytest.raises(AttributeError): + @output + @dataclass + class A(): + name: str + age: int + + +def test_output_3(): + + class B(BaseModel): + id: int + + class ABase(BaseModel): + id: int + + @output + class A(ABase): + name: List[str] = [] + def resolve_name(self): + return ['hi'] + + age: int = 0 + def resolve_age(self): + return 1 + + bs: List[B] = [] + def resolve_bs(self): + return [] + + json_schema = A.model_json_schema() + assert set(json_schema['required']) == {'id', 'name', 'age', 'bs'} + diff --git a/tests/pydantic_v2/utils/test_output_2.py b/tests/pydantic_v2/utils/test_output_2.py new file mode 100644 index 0000000..c480a9c --- /dev/null +++ b/tests/pydantic_v2/utils/test_output_2.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from pydantic_resolve.util import output +from pydantic import BaseModel +from typing import List, Optional +import pytest + +def test_output_will_not_affect_other(): + + @output + class X(BaseModel): + id: int + name: str + + class A(BaseModel): + id: int + opt: Optional[str] = None + + name: str = '' + def resolve_name(self): + return 'hi' + + age: int = 0 + def resolve_age(self): + return 1 + + greet: str = '' + def post_greet(self): + return 'hi' + + json_schema = A.model_json_schema() + assert set(json_schema['required']) == {'id'} + + a = A(id=1) + assert a.id == 1 # can assign + diff --git a/tests/pydantic_v2/utils/test_parse.py b/tests/pydantic_v2/utils/test_parse.py new file mode 100644 index 0000000..3750472 --- /dev/null +++ b/tests/pydantic_v2/utils/test_parse.py @@ -0,0 +1,81 @@ +from pydantic_resolve import util +from typing import Optional +from pydantic import ConfigDict, BaseModel, ValidationError +import pytest +from dataclasses import dataclass + +def test_pydantic(): + class A(BaseModel): + name: Optional[str] = None + + a = A(name=None) + value = util.try_parse_data_to_target_field_type(a, 'name', '123') + assert value == '123' + + value = util.try_parse_data_to_target_field_type(a, 'name', None) + assert value is None + + with pytest.raises(ValidationError): + util.try_parse_data_to_target_field_type(a, 'name', [1,2,3]) + + +def test_dataclass(): + @dataclass + class B: + age: int + + @dataclass + class A: + b: Optional[B] + + a = A(b=None) + value = util.try_parse_data_to_target_field_type(a, 'b', None) + assert value is None + + value = util.try_parse_data_to_target_field_type(a, 'b', {'age': 21}) + assert value == B(age=21) + + with pytest.raises(ValidationError): + util.try_parse_data_to_target_field_type(a, 'b', [1,2,3]) + +def test_mix(): + class B(BaseModel): + age: int + + @dataclass + class A: + b: Optional[B] + + a = A(b=None) + value = util.try_parse_data_to_target_field_type(a, 'b', None) + assert value is None + + value = util.try_parse_data_to_target_field_type(a, 'b', {'age': 21}) + assert value == B(age=21) + + with pytest.raises(ValidationError): + util.try_parse_data_to_target_field_type(a, 'b', [1,2,3]) + +def test_orm(): + class B(BaseModel): + age: int + model_config = ConfigDict(from_attributes=True) + + @dataclass + class A: + b: Optional[B] + + class BB(): + def __init__(self, age: int): + self.age = age + + + a = A(b=None) + value = util.try_parse_data_to_target_field_type(a, 'b', None) + assert value is None + + value = util.try_parse_data_to_target_field_type(a, 'b', BB(age=21)) + assert value == B(age=21) + + with pytest.raises(ValidationError): + util.try_parse_data_to_target_field_type(a, 'b', [1,2,3]) diff --git a/tests/pydantic_v2/utils/test_parse_forward_ref.py b/tests/pydantic_v2/utils/test_parse_forward_ref.py new file mode 100644 index 0000000..fe1a1be --- /dev/null +++ b/tests/pydantic_v2/utils/test_parse_forward_ref.py @@ -0,0 +1,50 @@ +from __future__ import annotations # which will cause config error +from dataclasses import dataclass +from typing import Optional +from pydantic import ConfigDict, BaseModel, ValidationError +import pytest + +def test_dc(): + @dataclass + class A: + name: str + + @dataclass + class B: + a: A + + b = B(a=A(name='kikodo')) + assert isinstance(b, B) + + +def test_parse(): + class B(BaseModel): + age: int + + class A(BaseModel): + b: Optional[B] = None + + a = A.model_validate({'b': {'age': 21}}) + assert isinstance(a, A) + +def test_orm(): + class B(BaseModel): + age: int + model_config = ConfigDict(from_attributes=True) + + + class A(BaseModel): + b: Optional[B] = None + model_config = ConfigDict(from_attributes=True) + + class AA: + def __init__(self, b): + self.b = b + class BB: + def __init__(self, age): + self.age = age + + aa = AA(b=BB(age=21)) + + a = A.model_validate(aa) + assert isinstance(a, A) diff --git a/tests/pydantic_v2/utils/test_utils.py b/tests/pydantic_v2/utils/test_utils.py new file mode 100644 index 0000000..67c2f24 --- /dev/null +++ b/tests/pydantic_v2/utils/test_utils.py @@ -0,0 +1,175 @@ +import asyncio +from dataclasses import dataclass +from typing import List +from pydantic_resolve import util +from pydantic import ConfigDict, BaseModel +import pytest + +def test_get_class_field_annotations(): + class C: + hello: str + + def __init__(self, c: str): + self.c = c + + class D(C): + pass + + class E(C): + world: str + + assert list(util.get_class_field_annotations(C)) == ['hello'] + assert list(util.get_class_field_annotations(D)) == [] + assert list(util.get_class_field_annotations(E)) == ['world'] + + +class User(BaseModel): + id: int + name: str + age: int + + +def test_build_object(): + raw = [(1, 'peter', 10), (2, 'mike', 21), (3, 'john', 12)] + users = [User(id=i[0], name=i[1], age=i[2]) for i in raw] + a, b, c = users + ids = [2, 3, 1, 4] + output = util.build_object(users, ids, lambda x: x.id) + assert list(output) == [b, c, a, None] + + +def test_build_list(): + raw = [(1, 'peter', 10), (2, 'mike', 21), (3, 'john', 12)] + users = [User(id=i[0], name=i[1], age=i[2]) for i in raw] + a, b, c = users + ids = [2, 3, 1, 4] + output = util.build_list(users, ids, lambda x: x.id) + assert list(output) == [[b], [c], [a], []] + +@pytest.mark.asyncio +async def test_replace_method(): + class A(): + def __init__(self, name: str): + self.name = name + + async def say(self, arr: List[str]): + return f'{self.name}, {len(arr)}' + + a = A('kikodo') + r1 = await a.say(['1']) + assert r1 == 'kikodo, 1' + + async def kls_method(self, *args): + v = await A.say(self, *args) + return f'hello, {v}' + + AA = util.replace_method(A, 'AA', 'say', kls_method) + k = AA('kimi') + r2 = await k.say(['1', '2', '3']) + + assert r2 == 'hello, kimi, 3' + assert AA.__name__ == 'AA' + + +def test_super_logic(): + class A(): + def say(self): + return 'A' + + class B(A): + def say(self): + val = A().say() + return f'B.{val}' + + + b = B() + assert b.say() == 'B.A' + + + +@pytest.mark.asyncio +async def test_mapper_1(): + class A(BaseModel): + a: int + + @util.mapper(lambda x: A(**x)) + async def foo(): + return {'a': 1} + + async def call_later(f): + await asyncio.sleep(1) + f.set_result({'a': 1}) + + @util.mapper(lambda x: A(**x)) + async def bar(): + lp = asyncio.get_event_loop() + f = lp.create_future() + asyncio.create_task(call_later(f)) + return f + + ret = await foo() + ret2 = await bar() + assert ret == A(a=1) + assert ret2 == A(a=1) + + +def test_auto_mapper_1(): + class A(BaseModel): + a: int + + params = (A, {'a': 1}) + ret = util._get_mapping_rule(*params)(*params) # type:ignore + assert ret == A(a=1) + + @dataclass + class B: + a: int + + params2 = (B, {'a': 1}) + ret2 = util._get_mapping_rule(*params2)(*params2) # type:ignore + assert ret2 == B(a=1) + + +def test_auto_mapper_2(): + class A(BaseModel): + a: int + model_config = ConfigDict(from_attributes=True) + + class AA: + def __init__(self, a): + self.a = a + + p1 = (A, AA(1)) + ret = util._get_mapping_rule(*p1)(*p1) # type: ignore + assert ret == A(a=1) + + p2 = (A, {'a': 1}) + with pytest.raises(AttributeError): + util._get_mapping_rule(*p2) # type: ignore + + +def test_auto_mapper_3(): + class A(BaseModel): + a: int + model_config = ConfigDict(from_attributes=True) + + p1 = (A, A(a=1)) + rule = util._get_mapping_rule(*p1) # type: ignore + + assert rule is None + output = util._apply_rule(rule, *p1, is_list=False) + assert output == A(a=1) + + +def test_auto_mapper_4(): + class A(BaseModel): + a: int + + class AA: + def __init__(self, a): + self.a = a + + p1 = (A, AA(a=1)) + with pytest.raises(AttributeError): + util._get_mapping_rule(*p1)(*p1) # type: ignore + diff --git a/tox.ini b/tox.ini index 12e5573..72ce4dd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,31 +1,59 @@ [tox] envlist = - ; py37 py38pyd1 py38pyd2 - py39 - py310 + py39pyd1 + py39pyd2 + py310pyd1 + py310pyd2 setenv = VIRTUALENV_DISCOVERY=pyenv [testenv] allowlist_externals = poetry + + +[testenv:py38pyd1] +basepython = python3.8 commands_pre = poetry install --no-root --sync commands = - poetry run pytest tests/ + poetry run pytest tests/pydantic_v1 -[testenv:py38ppyd1] -basepython = python3.8 - -[testenv:py38ppyd2] -commands_pre = +[testenv:py38pyd2] +commands_pre = + poetry install --no-root --sync pip install pydantic==2.* basepython = python3.8 +commands = + poetry run pytest tests/pydantic_v2 + +[testenv:py39pyd1] +basepython = python3.9 +commands_pre = + poetry install --no-root --sync +commands = + poetry run pytest tests/pydantic_v1 -[testenv:py39] +[testenv:py39pyd2] +commands_pre = + poetry install --no-root --sync + pip install pydantic==2.* basepython = python3.9 -; deps = pydantic==2.* +commands = + poetry run pytest tests/pydantic_v2 + +[testenv:py310pyd1] +basepython = python3.10 +commands_pre = + poetry install --no-root --sync +commands = + poetry run pytest tests/pydantic_v1 + -[testenv:py310] +[testenv:py310pyd2] basepython = python3.10 -; deps = pydantic==2.* \ No newline at end of file +commands_pre = + poetry install --no-root --sync + pip install pydantic==2.* +commands = + poetry run pytest tests/pydantic_v2