Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pydantic2 & double conversions (#22) #38

Open
ClaytonJY opened this issue Nov 1, 2023 · 5 comments
Open

Pydantic2 & double conversions (#22) #38

ClaytonJY opened this issue Nov 1, 2023 · 5 comments

Comments

@ClaytonJY
Copy link

ClaytonJY commented Nov 1, 2023

Hello,

Love this repo!

I was trying to modify your example from #22 to check if nested models are round-tripped unnecessarily if contained within the dict I return in my endpoint function. Before I could do that, I had to update the example code in #22 to work with pydantic 2.0 (fastapi==0.100.1, pydantic==2.4.2). I first changed the root_validator to model_validator:

    @model_validator(mode="before")
    @classmethod
    def debug_usage(cls, data: dict):
        print("created pydantic model")

        return data

and when I run the app and hit that endpoint, I see "created pydantic model" once, and do not get "called dict" logged at all.

The dict method is deprecated in favor of model_dump, but if I also override model_dump and model_dump_json:

    def model_dump(self, *args, **kwargs):
        print("called model_dump")
        return super().model_dump(*args, **kwargs)

    def model_dump_json(self, *args, **kwargs):
        print("called model_dump_json")
        return super().model_dump_json(*args, **kwargs)

I don't get any of those "called ..." messages printed. If I use jsonable_encoder on a model in a terminal, I can see it uses model_dump_json, but FastAPI doesn't seem to use any of these!

So my questions are:

  1. in recent versions of pydantic and fastapi, what happens if I return an object whose type matches response_model?
  2. is this double-encoding problem still a problem? That I only see the object created once makes me think it isn't, but since I can't replicate the full example I'm a but unsure what's going on.
@zhanymkanov
Copy link
Owner

That is the nice catch! Probably, I messed up with migration from Pydantic v1 to v2 since the code of converting the BaseModel to dict is still there and there somewhere must be a validation with response_model

That is the important issue, so I will look at it properly later!

@ClaytonJY
Copy link
Author

Thanks for the prompt response, and the fastapi link! I used that to dig a bit deeper. I see there it does call model_dump underneath.

I'll spare you the code, but I can modify you original code to override model_dump instead of dict, and call _prepare_response_content, I get the expected behavior of each message printing once. But in a FastAPI endpoint, I still only get the creation message, and only once.

Looks like _prepare_response_content is only called 3 times: twice within itself (list/dict, just below where you linked me), and then here, inside serialize_response. But the comment above that line suggests that's only done for Pydantic v1! So I think _prepare_response_content is a red herring?

Yet when I call serialize_response(response_content=profile_response), I do get the model_dump message!

It's unclear to me which path the endpoint is taking through serialize_response that avoids calling model_dump (or dict or model_dump_json), but it seems clear the ProfileResponse is only created once, which seems like a good sign that this double-serializing is no longer an issue?

@zhanymkanov
Copy link
Owner

Well, I have some understanding right now with those updates.

1, If you change from mode=before to mode=after in the model_validator, you'll see the print is called twice.
2. It seems like re-validation of the model with ModelField happens in create_response_field function. Not sure though.

Probably, the usage of those modes in ModelField (validation vs serialization) influences the way Pydantic validates models with modes like before and after. Also, "field" part of the "ModeField" shouldn't confuse you to think that it's only the fields inside since it seems like it's not. Maybe I'm wrong though :)

@zhanymkanov
Copy link
Owner

If you want, you can make a PR with fixing the mode so that you would be added to the contributors list.

@ClaytonJY
Copy link
Author

ClaytonJY commented Nov 5, 2023

Used logging to print a stack trace and learned some things!

First, my code:

code
import logging

from fastapi import FastAPI
from pydantic import BaseModel, model_validator

STACK_INFO = False

app = FastAPI()


class ProfileResponse(BaseModel):
    def __init__(self, *args, **kwargs):
        logging.error("init'd", stack_info=STACK_INFO)

        super().__init__(*args, **kwargs)

    @model_validator(mode="before")
    @classmethod
    def validate_before(cls, data: dict):
        logging.error("validated before", stack_info=STACK_INFO)

        return data

    @model_validator(mode="after")
    def validate_after(self):
        logging.error("validated after", stack_info=STACK_INFO)

        return self

    def model_dump(self, **kwargs) -> dict:
        logging.error("called model_dump", stack_info=STACK_INFO)
        return super().model_dump(**kwargs)

    def model_dump_json(self, *args, **kwargs) -> str:
        logging.error("called model_dump_json", stack_info=STACK_INFO)
        return super().model_dump_json(*args, **kwargs)

    def dict(self, *args, **kwargs) -> dict:
        logging.error("called dict", stack_info=STACK_INFO)
        return super().dict(*args, **kwargs)


@app.get("/", response_model=ProfileResponse)
async def root():
    return ProfileResponse()

Calling that endpoint shows we only create the object and call the before-validator once, but we call the after-validator twice:

ERROR:root:init'd
ERROR:root:validated before
ERROR:root:validated after
ERROR:root:validated after

If we modify the endpoint to return {} instead of ProfileResponse, we get exactly the same logs! So it appears that FastAPI is making an additional call to the after-validator, without recreating the object, in either case.

If we then change STACK_INFO = True at the top, we can see where this is coming from.

The first set of logs, up through the first call to the after-validator, come from either run_endpoint_function (return ProfileResponse) or serialize_response (return {})

stack traces
<truncated>
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/starlette/routing.py", line 66, in app
    response = await func(request)
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 273, in app
    raw_response = await run_endpoint_function(
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 190, in run_endpoint_function
    return await dependant.call(**values)
  File "/home/claytonjy/src/ears-asr/temp.py", line 45, in root
    return ProfileResponse()
  File "/home/claytonjy/src/ears-asr/temp.py", line 15, in __init__
    super().__init__(*args, **kwargs)
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/main.py", line 164, in __init__
    __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)
  File "/home/claytonjy/src/ears-asr/temp.py", line 26, in validate_after
    logging.error("validated after", stack_info=STACK_INFO)
<truncated>
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/starlette/routing.py", line 66, in app
    response = await func(request)
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 291, in app
    content = await serialize_response(
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 144, in serialize_response
    value, errors_ = field.validate(response_content, {}, loc=("response",))
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/_compat.py", line 119, in validate
    self._type_adapter.validate_python(value, from_attributes=True),
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/type_adapter.py", line 283, in validate_python
    return self.validator.validate_python(__object, strict=strict, from_attributes=from_attributes, context=context)
  File "/home/claytonjy/src/ears-asr/temp.py", line 15, in __init__
    super().__init__(*args, **kwargs)
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/main.py", line 164, in __init__
    __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)
  File "/home/claytonjy/src/ears-asr/temp.py", line 26, in validate_after
    logging.error("validated after", stack_info=STACK_INFO)

but the additional after-validation comes from serialize_response in both cases

stack trace
<truncated>
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/starlette/routing.py", line 66, in app
    response = await func(request)
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 291, in app
    content = await serialize_response(
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/routing.py", line 144, in serialize_response
    value, errors_ = field.validate(response_content, {}, loc=("response",))
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/fastapi/_compat.py", line 119, in validate
    self._type_adapter.validate_python(value, from_attributes=True),
  File "/home/claytonjy/src/ears-asr/.venv/lib/python3.10/site-packages/pydantic/type_adapter.py", line 283, in validate_python
    return self.validator.validate_python(__object, strict=strict, from_attributes=from_attributes, context=context)
  File "/home/claytonjy/src/ears-asr/temp.py", line 26, in validate_after
    logging.error("validated after", stack_info=STACK_INFO)

I think this shows that the double-creation problem has been fixed in the latest versions, but that a new, possibly-undesirable extra call to the after-validator is now present? Perhaps there's some other way to avoid that?

Happy to file a PR; should I remove the section, or add some kind of note about it no longer applying?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants