From cdaa95aa50514c8a5153e5778ddd3cebfe542309 Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Tue, 15 Jul 2025 14:15:57 +0800 Subject: [PATCH 1/7] chore: add extra ut for from_attributes --- ...ype_adapter_conver_from_pydantic_object.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py b/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py index 25ab297..24d1769 100644 --- a/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py +++ b/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py @@ -2,6 +2,7 @@ import pytest from pydantic import BaseModel, ValidationError from pydantic_resolve import Resolver +from pydantic_core._pydantic_core import ValidationError as ValidationError2 @pytest.mark.asyncio async def test_1(): @@ -56,3 +57,22 @@ def resolve_item(self): assert c.item and c.item.name == 'name-1' + +@pytest.mark.asyncio +async def test_4(): + class Base(BaseModel): + name: str + + class Child(Base): + id: Optional[int] = None + + class Container(BaseModel): + items: List[Child] = [] + def resolve_items(self): + return [Base(name='name-1'), Base(name='name-2')] + + c = Container() + c = await Resolver(enable_from_attribute_in_type_adapter=True).resolve(c) + assert c.items[0].name == 'name-1' + + From 7d0724cbcd008e54b47c92c3a5686a9f68350c4e Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Tue, 15 Jul 2025 15:04:53 +0800 Subject: [PATCH 2/7] chore: add more ut --- ...ype_adapter_conver_from_pydantic_object.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py b/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py index 24d1769..75019fa 100644 --- a/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py +++ b/tests/pydantic_v2/resolver/test_43_type_adapter_conver_from_pydantic_object.py @@ -2,10 +2,9 @@ import pytest from pydantic import BaseModel, ValidationError from pydantic_resolve import Resolver -from pydantic_core._pydantic_core import ValidationError as ValidationError2 @pytest.mark.asyncio -async def test_1(): +async def test_function_param(): class Base(BaseModel): name: str id: int @@ -25,9 +24,33 @@ def resolve_items(self): c = await Resolver(enable_from_attribute_in_type_adapter=True).resolve(c) assert c.items[0].name == 'name-1' +@pytest.mark.asyncio +async def test_globally_env(): + import os + os.environ['PYDANTIC_RESOLVE_ENABLE_FROM_ATTRIBUTE'] = 'true' + + class Base(BaseModel): + name: str + id: int + + class Child(BaseModel): + name: str + + class Container(BaseModel): + items: List[Child] = [] + def resolve_items(self): + return [Base(name='name-1', id=1), Base(name='name-2', id=2)] + + c = Container() + + c = await Resolver().resolve(c) + assert c.items[0].name == 'name-1' + + # delete env + os.environ.pop('PYDANTIC_RESOLVE_ENABLE_FROM_ATTRIBUTE') @pytest.mark.asyncio -async def test_2(): +async def test_list(): class Child(BaseModel): name: str @@ -43,7 +66,7 @@ def resolve_items(self): @pytest.mark.asyncio -async def test_3(): +async def test_object(): class Child(BaseModel): name: str @@ -59,7 +82,7 @@ def resolve_item(self): @pytest.mark.asyncio -async def test_4(): +async def test_base_to_child(): class Base(BaseModel): name: str From e1a3f534cf17ba9b5f59ff280d393c7e09eadf32 Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Sat, 19 Jul 2025 10:26:27 +0800 Subject: [PATCH 3/7] wip - optimize internal API. --- README.md | 42 ++--- docs/changelog.md | 10 + pydantic_resolve/__init__.py | 4 +- pydantic_resolve/analysis.py | 16 +- pydantic_resolve/resolver.py | 4 +- pydantic_resolve/utils/class_util.py | 178 +++++++++--------- pydantic_resolve/utils/conversion.py | 9 + pydantic_resolve/utils/depend.py | 8 +- pydantic_resolve/utils/openapi.py | 6 +- tests/pydantic_v1/core/test_field.py | 6 +- .../resolver/test_8_loader_depend.py | 4 +- tests/pydantic_v1/utils/test_utils.py | 8 +- tests/pydantic_v2/core/test_field.py | 6 +- tests/pydantic_v2/utils/test_utils.py | 8 +- 14 files changed, 169 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index 14dae5b..fa8092b 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ it supports: ```python class Task(BaseTask): user: Optional[BaseUser] = None - def resolve_user(self, loader=LoaderDepend(UserLoader)): + def resolve_user(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) if self.assignee_id else None class Story(BaseStory): tasks: list[Task] = [] - def resolve_tasks(self, loader=LoaderDepend(StoryTaskLoader)): + def resolve_tasks(self, loader=Loader(StoryTaskLoader)): return loader.load(self.id) ``` @@ -146,24 +146,24 @@ Create domain-specific data structures through selective composition and relatio image ```python -from pydantic_resolve import LoaderDepend +from pydantic_resolve import Loader class Task(BaseTask): user: Optional[BaseUser] = None - def resolve_user(self, loader=LoaderDepend(UserLoader)): + def resolve_user(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) if self.assignee_id else None class Story(BaseStory): tasks: list[Task] = [] - def resolve_tasks(self, loader=LoaderDepend(StoryTaskLoader)): + def resolve_tasks(self, loader=Loader(StoryTaskLoader)): return loader.load(self.id) assignee: Optional[BaseUser] = None - def resolve_assignee(self, loader=LoaderDepend(UserLoader)): + def resolve_assignee(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) if self.assignee_id else None reporter: Optional[BaseUser] = None - def resolve_reporter(self, loader=LoaderDepend(UserLoader)): + def resolve_reporter(self, loader=Loader(UserLoader)): return loader.load(self.report_to) if self.report_to else None ``` @@ -177,7 +177,7 @@ class Story(BaseModel): report_to: int tasks: list[BaseTask] = [] - def resolve_tasks(self, loader=LoaderDepend(StoryTaskLoader)): + def resolve_tasks(self, loader=Loader(StoryTaskLoader)): return loader.load(self.id) ``` @@ -195,26 +195,26 @@ Leverage post_field methods for ancestor data access, node transfers, and in-pla image ```python -from pydantic_resolve import LoaderDepend, Collector +from pydantic_resolve import Loader, Collector class Task(BaseTask): __pydantic_resolve_collect__ = {'user': 'related_users'} # Propagate user to collector: 'related_users' user: Optional[BaseUser] = None - def resolve_user(self, loader=LoaderDepend(UserLoader)): + def resolve_user(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) class Story(BaseStory): tasks: list[Task] = [] - def resolve_tasks(self, loader=LoaderDepend(StoryTaskLoader)): + def resolve_tasks(self, loader=Loader(StoryTaskLoader)): return loader.load(self.id) assignee: Optional[BaseUser] = None - def resolve_assignee(self, loader=LoaderDepend(UserLoader)): + def resolve_assignee(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) reporter: Optional[BaseUser] = None - def resolve_reporter(self, loader=LoaderDepend(UserLoader)): + def resolve_reporter(self, loader=Loader(UserLoader)): return loader.load(self.report_to) # ---------- Post-processing ------------ @@ -230,15 +230,15 @@ class Story(BaseStory): ```python class Story(BaseStory): tasks: list[Task] = [] - def resolve_tasks(self, loader=LoaderDepend(StoryTaskLoader)): + def resolve_tasks(self, loader=Loader(StoryTaskLoader)): return loader.load(self.id) assignee: Optional[BaseUser] = None - def resolve_assignee(self, loader=LoaderDepend(UserLoader)): + def resolve_assignee(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) reporter: Optional[BaseUser] = None - def resolve_reporter(self, loader=LoaderDepend(UserLoader)): + def resolve_reporter(self, loader=Loader(UserLoader)): return loader.load(self.report_to) # ---------- Post-processing ------------ @@ -250,11 +250,11 @@ class Story(BaseStory): ### Pattern 3: Propagate Ancestor Context ```python -from pydantic_resolve import LoaderDepend +from pydantic_resolve import Loader class Task(BaseTask): user: Optional[BaseUser] = None - def resolve_user(self, loader=LoaderDepend(UserLoader)): + def resolve_user(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) # ---------- Post-processing ------------ @@ -265,15 +265,15 @@ class Story(BaseStory): __pydantic_resolve_expose__ = {'name': 'story_name'} tasks: list[Task] = [] - def resolve_tasks(self, loader=LoaderDepend(StoryTaskLoader)): + def resolve_tasks(self, loader=Loader(StoryTaskLoader)): return loader.load(self.id) assignee: Optional[BaseUser] = None - def resolve_assignee(self, loader=LoaderDepend(UserLoader)): + def resolve_assignee(self, loader=Loader(UserLoader)): return loader.load(self.assignee_id) reporter: Optional[BaseUser] = None - def resolve_reporter(self, loader=LoaderDepend(UserLoader)): + def resolve_reporter(self, loader=Loader(UserLoader)): return loader.load(self.report_to) ``` diff --git a/docs/changelog.md b/docs/changelog.md index 0b3a3e6..150dff9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,16 @@ ## v1.12 +### v1.12.4 (2025.7.19) + +feature: +- add short name `Loader` for `LoaderDepend` +- set Loader return type as DataLoader + +non-functional: +- add more tests +- rename internal variable names + ### v1.12.3 (2025.7.12) update python versions in pyproject.toml diff --git a/pydantic_resolve/__init__.py b/pydantic_resolve/__init__.py index de446b6..730e003 100644 --- a/pydantic_resolve/__init__.py +++ b/pydantic_resolve/__init__.py @@ -9,7 +9,7 @@ GlobalLoaderFieldOverlappedError, MissingCollector) from .resolver import Resolver -from .utils.depend import LoaderDepend +from .utils.depend import LoaderDepend, Loader from .utils.openapi import ( model_config) @@ -17,7 +17,7 @@ __all__ = [ 'Resolver', 'LoaderDepend', - + 'Loader', # short 'Collector', 'ICollector', diff --git a/pydantic_resolve/analysis.py b/pydantic_resolve/analysis.py index cf4c5bf..662aa9d 100644 --- a/pydantic_resolve/analysis.py +++ b/pydantic_resolve/analysis.py @@ -108,7 +108,7 @@ def _scan_resolve_method(method, field: str, request_type: Optional[Type]) -> Re info: DataLoaderType = { 'param': name, 'kls': param.default.dependency, # for later initialization - 'path': class_util.get_kls_full_path(param.default.dependency), + 'path': class_util.get_kls_full_name(param.default.dependency), 'request_type': request_type } result['dataloaders'].append(info) @@ -146,7 +146,7 @@ def _scan_post_method(method, field: str, request_type: Optional[Type]) -> PostM loader_info: DataLoaderType = { 'param': name, 'kls': param.default.dependency, # for later initialization - 'path': class_util.get_kls_full_path(param.default.dependency), + 'path': class_util.get_kls_full_name(param.default.dependency), 'request_type': request_type } result['dataloaders'].append(loader_info) @@ -233,7 +233,7 @@ def _create_instance(loader): global_loader_param, loader_params.get(loader_kls, {})) - for field, has_default in class_util.get_class_field_without_default_value(loader_kls): + for field, has_default in class_util.get_fields_default_value_not_provided(loader_kls): try: if has_default and field not in param_config: continue @@ -249,7 +249,7 @@ def _create_instance(loader): def _get_all_fields(kls): if class_util.safe_issubclass(kls, BaseModel): - return list(class_util.get_keys(kls)) + return list(class_util.get_pydantic_field_keys(kls)) elif is_dataclass(kls): return [f.name for f in dc_fields(kls)] @@ -320,11 +320,11 @@ def _get_request_type_for_loader(object_field_pairs, field_name: str): def _get_all_fields_and_object_fields(kls): if class_util.safe_issubclass(kls, BaseModel): - all_fields = set(class_util.get_keys(kls)) - object_fields = list(class_util.get_pydantic_attrs(kls)) # dive and recursively analysis + all_fields = set(class_util.get_pydantic_field_keys(kls)) + object_fields = list(class_util.get_pydantic_fields(kls)) # dive and recursively analysis elif is_dataclass(kls): all_fields = set([f.name for f in dc_fields(kls)]) - object_fields = list(class_util.get_dataclass_attrs(kls)) + object_fields = list(class_util.get_dataclass_fields(kls)) else: raise AttributeError('invalid type: should be pydantic object or dataclass object') #noqa return all_fields, object_fields, { x:y for x, y in object_fields} @@ -413,7 +413,7 @@ def _populate_ancestors(parents): metadata[kls_name]['should_traverse'] = True def walker(kls, ancestors: List[Tuple[str, str]]): - kls_name = class_util.get_kls_full_path(kls) + kls_name = class_util.get_kls_full_name(kls) hit = metadata.get(kls_name) if hit: # if populated by previous node, or self has_config diff --git a/pydantic_resolve/resolver.py b/pydantic_resolve/resolver.py index ca70478..bec0497 100644 --- a/pydantic_resolve/resolver.py +++ b/pydantic_resolve/resolver.py @@ -299,7 +299,7 @@ async def _traverse(self, node: T, parent: object) -> T: return node kls = node.__class__ - kls_path = class_util.get_kls_full_path(kls) + kls_path = class_util.get_kls_full_name(kls) self._prepare_collectors(node, kls) self._prepare_expose_fields(node) @@ -357,7 +357,7 @@ async def _traverse(self, node: T, parent: object) -> T: async def resolve(self, node: T) -> T: if isinstance(node, list) and node == []: return node - root_class = class_util.get_class(node) + root_class = class_util.get_class_of_object(node) metadata = analysis.scan_and_store_metadata(root_class) self.metadata = analysis.convert_metadata_key_as_kls(metadata) diff --git a/pydantic_resolve/utils/class_util.py b/pydantic_resolve/utils/class_util.py index 13d65af..bd41633 100644 --- a/pydantic_resolve/utils/class_util.py +++ b/pydantic_resolve/utils/class_util.py @@ -9,72 +9,19 @@ from pydantic import BaseModel -def get_class_field_without_default_value(cls: Type) -> List[Tuple[str, bool]]: # field name, has default value - """ - return class field which do not have a default value. - - class MyClass: - a: int - b: int = 1 - - print(hasattr(MyClass, 'a')) # False - print(hasattr(MyClass, 'b')) # True - """ - anno = cls.__dict__.get('__annotations__') or {} - return [(k, hasattr(cls, k)) for k in anno.keys()] - - -def safe_issubclass(kls, classinfo): - try: - return issubclass(kls, classinfo) - except TypeError: - return False - - +# ----------------------- rebuild ----------------------- def rebuild_v1(kls): kls.update_forward_refs() + def rebuild_v2(kls): kls.model_rebuild() -rebuild = rebuild_v2 if PYDANTIC_V2 else rebuild_v1 - -def update_forward_refs(kls): - def update_pydantic_forward_refs(kls: Type[BaseModel]): - """ - recursively update refs. - """ - if getattr(kls, const.PYDANTIC_FORWARD_REF_UPDATED, False): - return - - rebuild(kls) - - setattr(kls, const.PYDANTIC_FORWARD_REF_UPDATED, True) - - values = get_values(kls) - - for field in values: - shelled_type = shelling_type(get_type(field)) - - update_forward_refs(shelled_type) - - def update_dataclass_forward_refs(kls): - if not getattr(kls, const.DATACLASS_FORWARD_REF_UPDATED, False): - anno = get_type_hints(kls) - kls.__annotations__ = anno - setattr(kls, const.DATACLASS_FORWARD_REF_UPDATED, True) - - for _, v in kls.__annotations__.items(): - shelled_type = shelling_type(v) - update_forward_refs(shelled_type) - - if safe_issubclass(kls, BaseModel): - update_pydantic_forward_refs(kls) - if is_dataclass(kls): - update_dataclass_forward_refs(kls) +rebuild = rebuild_v2 if PYDANTIC_V2 else rebuild_v1 +# ---------------------- ensure_subset ------------------ def ensure_subset_v1(base): """ used with pydantic class or dataclass to make sure a class's field is @@ -103,7 +50,7 @@ def inner(): def inner(): base_fields = {f.name: f.type for f in fields(base)} for f in fields(kls): - has_default = dataclass_has_default(f) + has_default = is_dataclass_field_has_default_value(f) if not has_default: if f.name not in base_fields: raise AttributeError(f'{f.name} not existed in {base.__name__}.') @@ -161,7 +108,7 @@ def inner(): def inner(): base_fields = {f.name: f.type for f in fields(base)} for f in fields(kls): - has_default = dataclass_has_default(f) + has_default = is_dataclass_field_has_default_value(f) if not has_default: if f.name not in base_fields: raise AttributeError(f'{f.name} not existed in {base.__name__}.') @@ -177,45 +124,50 @@ def inner(): ensure_subset = ensure_subset_v2 if PYDANTIC_V2 else ensure_subset_v1 -def get_kls_full_path(kls): - return f'{kls.__module__}.{kls.__qualname__}' - - -def _get_items_v1(kls): +def _get_pydantic_field_items_v1(kls): return kls.__fields__.items() -def _get_items_v2(kls): +def _get_pydantic_field_items_v2(kls): return kls.model_fields.items() -get_items = _get_items_v2 if PYDANTIC_V2 else _get_items_v1 +get_pydantic_field_items = _get_pydantic_field_items_v2 if PYDANTIC_V2 else _get_pydantic_field_items_v1 -def _get_keys_v1(kls) -> str: +def _get_pydantic_field_keys_v1(kls) -> str: return kls.__fields__.keys() -def _get_keys_v2(kls) -> str: +def _get_pydantic_field_keys_v2(kls) -> str: return kls.model_fields.keys() -get_keys = _get_keys_v2 if PYDANTIC_V2 else _get_keys_v1 +get_pydantic_field_keys = _get_pydantic_field_keys_v2 if PYDANTIC_V2 else _get_pydantic_field_keys_v1 -def _get_values_v1(kls): +def _get_pydantic_field_values_v1(kls): return kls.__fields__.values() -def _get_values_v2(kls): +def _get_pydantic_field_values_v2(kls): return kls.model_fields.values() -get_values = _get_values_v2 if PYDANTIC_V2 else _get_values_v1 +get_pydantic_field_values = _get_pydantic_field_values_v2 if PYDANTIC_V2 else _get_pydantic_field_values_v1 + + +def _is_pydantic_field_required_v1(field): + return field.required + +def _is_pydantic_field_required_v2(field): + return field.is_required() + +is_pydantic_field_required_field = _is_pydantic_field_required_v2 if PYDANTIC_V2 else _is_pydantic_field_required_v1 -def get_pydantic_attrs(kls): - items = class_util.get_items(kls) +def get_pydantic_fields(kls): + items = class_util.get_pydantic_field_items(kls) for name, v in items: t = get_type(v) @@ -225,31 +177,85 @@ def get_pydantic_attrs(kls): yield (name, shelled_type) # type_ is the most inner type -def get_dataclass_attrs(kls): +def get_dataclass_fields(kls): for name, v in kls.__annotations__.items(): shelled_type = shelling_type(v) if is_acceptable_kls(shelled_type): yield (name, shelled_type) -def get_class(target): +def get_class_of_object(target): if isinstance(target, list): return target[0].__class__ else: return target.__class__ -def _is_required_v1(field): - return field.required - -def _is_required_v2(field): - return field.is_required() - -is_required_field = _is_required_v2 if PYDANTIC_V2 else _is_required_v1 - -def dataclass_has_default(field): +def is_dataclass_field_has_default_value(field): if field.default is not MISSING or field.default_factory is not MISSING: return True typ = field.type - return _is_optional(typ) \ No newline at end of file + return _is_optional(typ) + + +def update_forward_refs(kls): + def update_pydantic_forward_refs(kls: Type[BaseModel]): + """ + recursively update refs. + """ + if getattr(kls, const.PYDANTIC_FORWARD_REF_UPDATED, False): + return + + rebuild(kls) + + setattr(kls, const.PYDANTIC_FORWARD_REF_UPDATED, True) + + values = get_pydantic_field_values(kls) + + for field in values: + shelled_type = shelling_type(get_type(field)) + + update_forward_refs(shelled_type) + + def update_dataclass_forward_refs(kls): + if not getattr(kls, const.DATACLASS_FORWARD_REF_UPDATED, False): + anno = get_type_hints(kls) + kls.__annotations__ = anno + setattr(kls, const.DATACLASS_FORWARD_REF_UPDATED, True) + + for _, v in kls.__annotations__.items(): + shelled_type = shelling_type(v) + update_forward_refs(shelled_type) + + if safe_issubclass(kls, BaseModel): + update_pydantic_forward_refs(kls) + + if is_dataclass(kls): + update_dataclass_forward_refs(kls) + + +def get_kls_full_name(kls): + return f'{kls.__module__}.{kls.__qualname__}' + + +def get_fields_default_value_not_provided(cls: Type) -> List[Tuple[str, bool]]: # field name, has default value + """ + return class field which do not have a default value. + + class MyClass: + a: int + b: int = 1 + + print(hasattr(MyClass, 'a')) # False + print(hasattr(MyClass, 'b')) # True + """ + anno = cls.__dict__.get('__annotations__') or {} + return [(k, hasattr(cls, k)) for k in anno.keys()] + + +def safe_issubclass(kls, classinfo): + try: + return issubclass(kls, classinfo) + except TypeError: + return False \ No newline at end of file diff --git a/pydantic_resolve/utils/conversion.py b/pydantic_resolve/utils/conversion.py index d226a29..8a97119 100644 --- a/pydantic_resolve/utils/conversion.py +++ b/pydantic_resolve/utils/conversion.py @@ -186,6 +186,15 @@ def mapper(func_or_class: Union[Callable, Type]): func_or_class: is func: run func is class: call auto_mapping to have a try + + @dataclass + class K: + id: int + + field: str = '' + @mapper(lambda x: x.name) + def resolve_field(self, loader=Loader(field_batch_load_fn)): + return loader.load(self.id) """ def inner(inner_fn): diff --git a/pydantic_resolve/utils/depend.py b/pydantic_resolve/utils/depend.py index 4ca87b1..409c747 100644 --- a/pydantic_resolve/utils/depend.py +++ b/pydantic_resolve/utils/depend.py @@ -1,4 +1,5 @@ from typing import Any, Callable, Optional +from aiodataloader import DataLoader class Depends: def __init__( @@ -10,5 +11,8 @@ def __init__( def LoaderDepend( # noqa: N802 dependency: Optional[Callable[..., Any]] = None, -) -> Any: - return Depends(dependency=dependency) \ No newline at end of file +) -> DataLoader: + return Depends(dependency=dependency) + + +Loader = LoaderDepend \ No newline at end of file diff --git a/pydantic_resolve/utils/openapi.py b/pydantic_resolve/utils/openapi.py index 554f6cd..5e2f49f 100644 --- a/pydantic_resolve/utils/openapi.py +++ b/pydantic_resolve/utils/openapi.py @@ -4,16 +4,16 @@ from pydantic import BaseModel import pydantic_resolve.constant as const from pydantic_resolve.compat import PYDANTIC_V2 -from pydantic_resolve.utils.class_util import safe_issubclass, is_required_field, get_items +from pydantic_resolve.utils.class_util import safe_issubclass, is_pydantic_field_required_field, get_pydantic_field_items def _get_required_fields(kls: BaseModel): required_fields = [] - items = get_items(kls) + items = get_pydantic_field_items(kls) for fname, field in items: - if is_required_field(field): + if is_pydantic_field_required_field(field): required_fields.append(fname) diff --git a/tests/pydantic_v1/core/test_field.py b/tests/pydantic_v1/core/test_field.py index 5f7b1c8..1f7b407 100644 --- a/tests/pydantic_v1/core/test_field.py +++ b/tests/pydantic_v1/core/test_field.py @@ -1,6 +1,6 @@ # from __future__ import annotations from pydantic import BaseModel -from pydantic_resolve.utils.class_util import get_class +from pydantic_resolve.utils.class_util import get_class_of_object def test_get_class(): class Student(BaseModel): @@ -9,5 +9,5 @@ class Student(BaseModel): stu = Student() stus = [Student(), Student()] - assert get_class(stu) == Student - assert get_class(stus) == Student + assert get_class_of_object(stu) == Student + assert get_class_of_object(stus) == Student diff --git a/tests/pydantic_v1/resolver/test_8_loader_depend.py b/tests/pydantic_v1/resolver/test_8_loader_depend.py index fcc4c3e..8881299 100644 --- a/tests/pydantic_v1/resolver/test_8_loader_depend.py +++ b/tests/pydantic_v1/resolver/test_8_loader_depend.py @@ -1,7 +1,7 @@ from typing import List import pytest from pydantic import BaseModel -from pydantic_resolve import Resolver, LoaderDepend +from pydantic_resolve import Resolver, Loader from aiodataloader import DataLoader @pytest.mark.asyncio @@ -35,7 +35,7 @@ class Student(BaseModel): name: str books: List[Book] = [] - def resolve_books(self, loader=LoaderDepend(BookLoader)): + def resolve_books(self, loader=Loader(BookLoader)): return loader.load(self.id) students = [Student(id=1, name="jack"), Student(id=2, name="mike"), Student(id=3, name="wiki")] diff --git a/tests/pydantic_v1/utils/test_utils.py b/tests/pydantic_v1/utils/test_utils.py index 1c0065c..98cccf7 100644 --- a/tests/pydantic_v1/utils/test_utils.py +++ b/tests/pydantic_v1/utils/test_utils.py @@ -25,10 +25,10 @@ class D(C): class E(C): world: str - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(B)) == [('hello', True)] - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(C)) == [('hello', False)] - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(D)) == [] - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(E)) == [('world', False)] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(B)) == [('hello', True)] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(C)) == [('hello', False)] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(D)) == [] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(E)) == [('world', False)] class User(BaseModel): diff --git a/tests/pydantic_v2/core/test_field.py b/tests/pydantic_v2/core/test_field.py index 5f7b1c8..1f7b407 100644 --- a/tests/pydantic_v2/core/test_field.py +++ b/tests/pydantic_v2/core/test_field.py @@ -1,6 +1,6 @@ # from __future__ import annotations from pydantic import BaseModel -from pydantic_resolve.utils.class_util import get_class +from pydantic_resolve.utils.class_util import get_class_of_object def test_get_class(): class Student(BaseModel): @@ -9,5 +9,5 @@ class Student(BaseModel): stu = Student() stus = [Student(), Student()] - assert get_class(stu) == Student - assert get_class(stus) == Student + assert get_class_of_object(stu) == Student + assert get_class_of_object(stus) == Student diff --git a/tests/pydantic_v2/utils/test_utils.py b/tests/pydantic_v2/utils/test_utils.py index 40764ed..0104954 100644 --- a/tests/pydantic_v2/utils/test_utils.py +++ b/tests/pydantic_v2/utils/test_utils.py @@ -25,10 +25,10 @@ class D(C): class E(C): world: str - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(B)) == [('hello', True)] - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(C)) == [('hello', False)] - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(D)) == [] - assert list(pydantic_resolve.utils.class_util.get_class_field_without_default_value(E)) == [('world', False)] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(B)) == [('hello', True)] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(C)) == [('hello', False)] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(D)) == [] + assert list(pydantic_resolve.utils.class_util.get_fields_default_value_not_provided(E)) == [('world', False)] class User(BaseModel): From 80078972e0048c4bd52f28798a0fea8c538c2c01 Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Thu, 24 Jul 2025 08:07:40 +0800 Subject: [PATCH 4/7] chore: fix typo, update doc --- docs/api.md | 2 ++ docs/api.zh.md | 2 ++ docs/changelog.md | 4 ++-- pyproject.toml | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 4eb3a56..bedf856 100644 --- a/docs/api.md +++ b/docs/api.md @@ -337,6 +337,8 @@ In `post_default_handler`, you can additionally collect data from the return val In pydantic-resolve, you need to use `LoaderDepend` to manage `DataLoader`. +> new in v1.12.5, a shorter name `Loader` is also available. + It supports declaring multiple `DataLoader` instances in one method. ```python diff --git a/docs/api.zh.md b/docs/api.zh.md index 14acf87..d5895cb 100644 --- a/docs/api.zh.md +++ b/docs/api.zh.md @@ -329,6 +329,8 @@ DataLoader 可以将并发的多个异步查询合并为一个。 在 pydantic-resolve 中需要使用 LoaderDepend 来管理 DataLoader。 +> 从 v1.12.5 开始, 你也可以使用 `Loader`, 两者是等价的。 + 支持一个方法中申明多个 DataLoader。 ```python diff --git a/docs/changelog.md b/docs/changelog.md index 150dff9..d464117 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,7 @@ ## v1.12 -### v1.12.4 (2025.7.19) +### v1.12.5 (2025.7.12) feature: - add short name `Loader` for `LoaderDepend` @@ -12,7 +12,7 @@ non-functional: - add more tests - rename internal variable names -### v1.12.3 (2025.7.12) +### v1.12.4 (2025.7.12) update python versions in pyproject.toml diff --git a/pyproject.toml b/pyproject.toml index df67f67..64daafd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pydantic-resolve" -version = "1.12.4" +version = "1.12.5" description = "It just provide a pair of pre & post methods around pydantic fields, the rest is up to your imagination" authors = ["tangkikodo "] readme = "README.md" From 9365ab35dea6cd15f991a7ef096fa29f1ee73481 Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Thu, 24 Jul 2025 08:09:48 +0800 Subject: [PATCH 5/7] chore: adjust changelog. --- docs/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index d464117..7a8da7f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,7 @@ ## v1.12 -### v1.12.5 (2025.7.12) +### v1.12.5 (2025.7.24) feature: - add short name `Loader` for `LoaderDepend` From fe81b0f0a12433230c0a83a9d16d7011ad25c2ee Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Thu, 14 Aug 2025 14:18:51 +0800 Subject: [PATCH 6/7] add tox envlist: 3.11, 3.12 --- tox.ini | 77 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/tox.ini b/tox.ini index 6acca94..9fa24e3 100644 --- a/tox.ini +++ b/tox.ini @@ -6,66 +6,95 @@ envlist = py39pyd2 py310pyd1 py310pyd2 -setenv = VIRTUALENV_DISCOVERY=pyenv + py311pyd1 + py311pyd2 + py312pyd1 + py312pyd2 +setenv = VIRTUALENV_DISCOVERY=builtin [testenv] allowlist_externals = poetry - -[testenv:py38-duration] -basepython = python3.8 -commands_pre = - poetry install --no-root --sync -commands = - poetry run pytest --durations=20 tests/pydantic_v1 +# install into tox's venv (not Poetry's) to enable caching/reuse between runs +setenv = + POETRY_VIRTUALENVS_CREATE = false + POETRY_NO_INTERACTION = 1 + PIP_DISABLE_PIP_VERSION_CHECK = 1 [testenv:py38pyd1] basepython = python3.8 commands_pre = - poetry install --no-root --sync + poetry install --sync commands = - poetry run pytest tests/pydantic_v1 tests/common + pytest tests/pydantic_v1 tests/common [testenv:py38pyd2] commands_pre = - poetry install --no-root --sync + poetry install --sync pip install pydantic==2.* basepython = python3.8 commands = - poetry run pytest tests/pydantic_v2 tests/common + pytest tests/pydantic_v2 tests/common [testenv:py39pyd1] basepython = python3.9 commands_pre = - poetry install --no-root --sync + poetry install --sync commands = - poetry run pytest tests/pydantic_v1 tests/common + pytest tests/pydantic_v1 tests/common [testenv:py39pyd2] commands_pre = - poetry install --no-root --sync + poetry install --sync pip install pydantic==2.* basepython = python3.9 commands = - poetry run pytest tests/pydantic_v2 tests/common + pytest tests/pydantic_v2 tests/common [testenv:py310pyd1] basepython = python3.10 commands_pre = - poetry install --no-root --sync + poetry install --sync commands = - ; poetry run pytest tests/pydantic_v1 - poetry run coverage run --data-file=./cov/.result1 -m pytest tests/pydantic_v1 tests/common - ; poetry run coverage xml -o ./cov/coverage.xml - + coverage run --data-file=./cov/.result1 -m pytest tests/pydantic_v1 tests/common [testenv:py310pyd2] basepython = python3.10 commands_pre = - poetry install --no-root --sync + poetry install --sync + pip install pydantic==2.* +commands = + coverage run --data-file=./cov/.result2 -m pytest tests/pydantic_v2 tests/common + +[testenv:py311pyd1] +basepython = python3.11 +commands_pre = + poetry install --sync +commands = + pytest tests/pydantic_v1 tests/common + +[testenv:py311pyd2] +commands_pre = + poetry install --sync + pip install pydantic==2.* +basepython = python3.11 +commands = + pytest tests/pydantic_v2 tests/common + +[testenv:py312pyd1] +basepython = python3.12 +commands_pre = + poetry install --sync +commands = + pytest tests/pydantic_v1 tests/common + +[testenv:py312pyd2] +commands_pre = + poetry install --sync pip install pydantic==2.* +basepython = python3.12 commands = - ; poetry run pytest tests/pydantic_v2 - poetry run coverage run --data-file=./cov/.result2 -m pytest tests/pydantic_v2 tests/common + pytest tests/pydantic_v2 tests/common + [testenv:coverage] description = Combine coverage data and generate report From c7f9616edc399299ebd4ab8509d40fa979217dad Mon Sep 17 00:00:00 2001 From: tangkikodo Date: Thu, 14 Aug 2025 14:23:09 +0800 Subject: [PATCH 7/7] adjust readme images --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4ba112c..8a81e5f 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Building complex data structures requires only 3 systematic steps, let's take Establish entity relationships as foundational data models (stable, serves as architectural blueprint) -image +image ```python from pydantic import BaseModel @@ -160,7 +160,7 @@ DataLoader implementations support flexible data sources, from database queries Based on a specific business logic, create domain-specific data structures through selective schemas and relationship dataloader (stable, reusable across use cases) -image +image ```python from pydantic_resolve import Loader @@ -209,7 +209,7 @@ Leverage post_field methods for ancestor data access, node transfers, and in-pla #### Case 1: Aggregate or collect items -image +image ```python from pydantic_resolve import Loader, Collector @@ -242,7 +242,7 @@ class Story(BaseStory): #### Case 2: Compute extra fields -image +image ```python class Story(BaseStory):