fal-ai / fal

⚡ Fastest way to serve open source ML models to millions
https://fal.ai/docs
Apache License 2.0
543 stars 44 forks source link

pydantic 2 migration issues #29

Closed chamini2 closed 6 months ago

chamini2 commented 9 months ago

Tested with the diff https://github.com/fal-ai/fal/compare/main...matteo/pydantic2-test

I was trying out what the migration to Pydantic 2 could look like and I get the following:

from __future__ import annotations

from fal import cached, function

@cached
def my_cached_function():
    import datetime

    print("Cached function")
    return datetime.datetime.now()

@function(
    machine_type="S",
    serve=True,
)
def hello_query(name: str):
    started_at = my_cached_function()
    print(started_at, f"Messaged by: {name}")
    return f"Hello, {name}"

# Now with pydantic dataclasses for the payload
from pydantic.dataclasses import dataclass

@dataclass
class HelloModel:
    name: str

@function(
    machine_type="S",
    serve=True,
)
def hello_dataclass(data: HelloModel):
    started_at = my_cached_function()
    print(started_at, f"Messaged by: {data.name}")
    return f"Hello, {data.name}"

Runing the query version it works as expected and can be used

❯ fal fn run t/other.py hello_query

...

2024-01-12 18:23:30.971 [info     ] Installing collected packages: typing-extensions, tblib, sniffio, protobuf, platformdirs, idna, h11, grpcio, dill, click, annotated-types, uvicorn, pydantic-core, isolate, anyio, starlette, pydantic, fastapi
2024-01-12 18:23:33.576 [info     ] Successfully installed annotated-types-0.6.0 anyio-4.2.0 click-8.1.7 dill-0.3.7 fastapi-0.109.0 grpcio-1.60.0 h11-0.14.0 idna-3.6 isolate-0.12.3 platformdirs-4.1.0 protobuf-4.25.2 pydantic-2.5.3 pydantic-core-2.14.6 sniffio-1.3.0 starlette-0.35.1 tblib-3.0.0 typing-extensions-4.9.0 uvicorn-0.25.0
2024-01-12 18:23:33.850 [info     ]
2024-01-12 18:23:33.850 [info     ] [notice] A new release of pip is available: 23.3.1 -> 23.3.2
2024-01-12 18:23:33.850 [info     ] [notice] To update, run: python -m pip install --upgrade pip
2024-01-12 18:23:35.990 [stdout   ] Compression complete. Uploading it...
2024-01-12 18:23:36.127 [stdout   ] Upload complete.
2024-01-12 18:23:36.128 [stdout   ]
2024-01-12 18:23:57.195 [info     ] Access your exposed service at https://1774a50b-9241-4db0-ab5b-0457cc6a5b9f.gateway.alpha.fal.ai
2024-01-12 18:23:59.430 [stderr   ] INFO:     Started server process [42]
2024-01-12 18:23:59.431 [stderr   ] INFO:     Waiting for application startup.
2024-01-12 18:23:59.432 [stderr   ] INFO:     Application startup complete.
2024-01-12 18:23:59.432 [stderr   ] INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)
2024-01-12 18:28:33.325 [stdout   ] INFO:     10.52.4.70:42250 - "GET / HTTP/1.1" 405 Method Not Allowed
2024-01-12 18:28:52.066 [stdout   ] INFO:     10.52.4.70:48494 - "POST / HTTP/1.1" 422 Unprocessable Entity
2024-01-12 18:28:57.409 [stdout   ] Cached function
2024-01-12 18:28:59.038 [stdout   ] 2024-01-12 18:28:57.409594 Messaged by: matteo
2024-01-12 18:28:59.039 [stdout   ] INFO:     10.52.4.70:56564 - "POST /?name=matteo HTTP/1.1" 200 OK

But when I try to use the dataclass version I get an error

❯ fal fn run t/other.py hello_dataclass
2024-01-12 18:30:44.615 [info     ] Access your exposed service at https://4c793170-2fe2-4fbe-a633-64d52d1e704f.gateway.alpha.fal.ai
Error while deserializing the given object

Notice this error while deserializing seems to be local (gRPC deserializing?)

Specifically, running the above command with --debug I get

...
│ .../python3.11/site-packages/isolate/connections │
│ /grpc/interface.py:29 in _                                                                       │
│                                                                                                  │
│   26                                                                                             │
│   27 @from_grpc.register                                                                         │
│   28 def _(message: definitions.SerializedObject) -> Any:                                        │
│ ❱ 29 │   return load_serialized_object(                                                          │
│   30 │   │   message.method,                                                                     │
│   31 │   │   message.definition,                                                                 │
│   32 │   │   was_it_raised=message.was_it_raised,                                                │
│                                                                                                  │
│ .../python3.11/site-packages/isolate/connections │
│ /common.py:76 in load_serialized_object                                                          │
│                                                                                                  │
│    73 │   │   │   importlib.import_module(serialization_method)                                  │
│    74 │   │   )                                                                                  │
│    75 │                                                                                          │
│ ❱  76 │   with _step("deserializing the given object"):                                          │
│    77 │   │   result = serialization_backend.loads(raw_object)                                   │
│    78 │                                                                                          │
│    79 │   if was_it_raised:                                                                      │
│                                                                                                  │
│ .../python3.11/contextlib.py:155 in __exit__                │
│                                                                                                  │
│   152 │   │   │   │   # tell if we get the same exception back                                   │
│   153 │   │   │   │   value = typ()                                                              │
│   154 │   │   │   try:                                                                           │
│ ❱ 155 │   │   │   │   self.gen.throw(typ, value, traceback)                                      │
│   156 │   │   │   except StopIteration as exc:                                                   │
│   157 │   │   │   │   # Suppress StopIteration *unless* it's the same exception that             │
│   158 │   │   │   │   # was passed to throw().  This prevents a StopIteration                    │
│                                                                                                  │
│ .../python3.11/site-packages/isolate/connections │
│ /common.py:42 in _step                                                                           │
│                                                                                                  │
│    39 │   try:                                                                                   │
│    40 │   │   yield                                                                              │
│    41 │   except BaseException as exception:                                                     │
│ ❱  42 │   │   raise SerializationError("Error while " + message) from exception                  │
│    43                                                                                            │
│    44                                                                                            │
│    45 def as_serialization_method(backend: Any) -> SerializationBackend:                         │
...
lmmx commented 9 months ago

I get the impression from your description/branch name you saw this as a v2 regression @chamini2 but this reproduces on main actually.

The hello_dataclass function does not actually serialise successfully in Pydantic 1.x from what I can see: but the serve=True parameter hides this by successfully starting a Uvicorn server.

There might indeed be problems with Pydantic 2, but I'd think it'd be best to start from a working v1.x baseline here.

Click to show edited version of the repro script ```py from __future__ import annotations from fal import cached, function @cached def my_cached_function(): import datetime print("Cached function") return datetime.datetime.now() @function(machine_type="S", serve=False) def hello_query(): name: str = "Louis" started_at = my_cached_function() print(started_at, f"Messaged by: {name}") return f"Hello, {name}" # Now with pydantic dataclasses for the payload from pydantic.dataclasses import dataclass @dataclass class HelloModel: name: str @function(machine_type="S", serve=False) def hello_dataclass(): data: HelloModel = HelloModel(name="Louis") started_at = my_cached_function() print(started_at, f"Messaged by: {data.name}") return f"Hello, {data.name}" ```

We can run hello_query

$ fal fn run demo.py hello_query
2024-01-18 22:11:38.933 [stdout   ] Cached function
2024-01-18 22:11:38.933 [stdout   ] 2024-01-18 22:11:38.932910 Messaged by: Louis

We cannot run hello_dataclass

$ fal fn run demo.py hello_dataclass
Error while serializing the given object

I would try a debugging approach that stays in Python rather than CLI (shell level) to get more visibility.

The stack trace shows it's emerging from dill

What's more if I use a Pydantic model it fails more verbosely [error shown with no --debug flag] and seems to indicate that the virtualenv cached directory does not have the necessary Pydantic module...

$ fal fn run demo.py hello_data_model
2024-01-18 22:38:55.311 [error    ] Traceback (most recent call last):
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/isolate/connections/common.py", line 40, in _step
    yield
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/isolate/connections/common.py", line 77, in load_serialized_object
    result = serialization_backend.loads(raw_object)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/dill/_dill.py", line 301, in loads
    return load(file, ignore, **kwds)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/dill/_dill.py", line 287, in load
    return Unpickler(file, ignore=ignore, **kwds).load()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/dill/_dill.py", line 442, in load
    obj = StockUnpickler.load(self)
          ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/dill/_dill.py", line 432, in find_class
    return StockUnpickler.find_class(self, module, name)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'pydantic'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/opt/venv/lib/python3.11/site-packages/isolate/connections/grpc/agent.py", line 113, in execute_function
    function = from_grpc(function)
               ^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.11/functools.py", line 909, in wrapper
    return dispatch(args[0].__class__)(*args, **kw)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/isolate/connections/grpc/interface.py", line 29, in _
    return load_serialized_object(
           ^^^^^^^^^^^^^^^^^^^^^^^
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/isolate/connections/common.py", line 76, in load_serialized_object
    with _step("deserializing the given object"):
  File "/usr/local/lib/python3.11/contextlib.py", line 158, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/root/.cache/isolate/virtualenv/dd85ba8a65db645667152bcadfce1749d4fbd40765c63ba1d368c48eafe9c34e/lib/python3.11/site-packages/isolate/connections/common.py", line 42, in _step
    raise SerializationError("Error while " + message) from exception
isolate.connections.common.SerializationError: Error while deserializing the given object

Function execution quit unexpectedly. Error contents: The function function could not be deserialized.

I get the impression this is the same error as the one raised from the Pydantic dataclass, but a bit clearer (no need to --debug to see it)

I checked and in fact this seems to have been cut off from the traceback you shared, it's the upstream cause of the 2nd exception in both cases though.

lmmx commented 9 months ago

Discussed this with @isidentical who suggested an initial repro which eventually turned into the following Pytest repro

Click to show intermediate steps ## Working Pydantic 1.x baseline From the main branch of fal, with its existing Pydantic 1.x dependency: ```py # demo_2.py import fal from fal.toolkit import File from pydantic import BaseModel, Field class Input(BaseModel): prompt: str num_steps: int = Field(default=2, ge=1, le=10) class Output(BaseModel): file: File @fal.function(_scheduler="nomad", requirements=["pydantic<2"]) def generate(input: Input) -> Output: file = File.from_bytes(input.prompt.encode() * input.num_steps, "text/plain") return Output(file=file) if __name__ == "__main__": # Run the function locally output = generate(Input(prompt="Hello, world!")) print(f"{output!r}") ``` That works OK (may time out, just repeat the command to run again) ## Breaking Pydantic 2.x baseline Then, make 2 modifications to the `fal` project source: - 6b851ac bump Pydantic pin to 2.5.3 - dfeae0f comment out pre-v2 Pydantic patch calls Which allow us to modify the `requirements` line in the `@fal.function(...)` decorator in our repro script: ```py # demo_3.py import fal from fal.toolkit import File from pydantic import BaseModel, Field class Input(BaseModel): prompt: str num_steps: int = Field(default=2, ge=1, le=10) class Output(BaseModel): file: File @fal.function(_scheduler="nomad", requirements=["pydantic>=2"]) def generate(input: Input) -> Output: file = File.from_bytes(input.prompt.encode() * input.num_steps, "text/plain") return Output(file=file) if __name__ == "__main__": # Run the function locally output = generate(Input(prompt="Hello, world!")) print(f"{output!r}") ``` This no longer succeeds. We get a deserialisation error: > fal.api.FalServerlessError: Couldn't deserialize your function on the remote server.
Click to show full traceback ```py /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot locate reference to . StockPickler.save(self, obj, save_persistent_id) /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot pickle : __main__.Input has recursive self-references that trigger a RecursionError. StockPickler.save(self, obj, save_persistent_id) /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot locate reference to . StockPickler.save(self, obj, save_persistent_id) /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot pickle : __main__.File has recursive self-references that trigger a RecursionError. StockPickler.save(self, obj, save_persistent_id) /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot locate reference to . StockPickler.save(self, obj, save_persistent_id) /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot pickle : __main__.Output has recursive self-references that trigger a RecursionError. StockPickler.save(self, obj, save_persistent_id) 2024-01-20 19:41:34.366 [info ] !!! HEADS UP !!! Scheduling a job with 2024-01-20 19:41:36.653 [error ] Traceback (most recent call last): File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/isolate/connections/common.py", line 40, in _step yield File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/isolate/connections/common.py", line 77, in load_serialized_object result = serialization_backend.loads(raw_object) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/dill/_dill.py", line 301, in loads return load(file, ignore, **kwds) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/dill/_dill.py", line 287, in load return Unpickler(file, ignore=ignore, **kwds).load() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/dill/_dill.py", line 442, in load obj = StockUnpickler.load(self) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/dill/_dill.py", line 432, in find_class return StockUnpickler.find_class(self, module, name) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ AttributeError: Can't get attribute 'Input' on The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/opt/venv/lib/python3.11/site-packages/isolate/connections/grpc/agent.py", line 113, in execute_function function = from_grpc(function) ^^^^^^^^^^^^^^^^^^^ File "/root/.pyenv/versions/3.11.3/lib/python3.11/functools.py", line 909, in wrapper return dispatch(args[0].__class__)(*args, **kw) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/isolate/connections/grpc/interface.py", line 29, in _ return load_serialized_object( ^^^^^^^^^^^^^^^^^^^^^^^ File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/isolate/connections/common.py", line 76, in load_serialized_object with _step("deserializing the given object"): File "/root/.pyenv/versions/3.11.3/lib/python3.11/contextlib.py", line 155, in __exit__ self.gen.throw(typ, value, traceback) File "/root/.cache/isolate/virtualenv/a6def1a7e84167f7c27bc25ba255dfaa6ca0e0514c26295eaa05112d614c0948/lib/python3.11/site-packages/isolate/connections/common.py", line 42, in _step raise SerializationError("Error while " + message) from exception isolate.connections.common.SerializationError: Error while deserializing the given object Traceback (most recent call last): File "/home/louis/lab/fal/pyd2/demo_3.py", line 23, in output = generate(Input(prompt="Hello, world!")) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/louis/dev/fal/pydantic-2-bump/projects/fal/src/fal/api.py", line 853, in __call__ raise FalServerlessError( fal.api.FalServerlessError: Couldn't deserialize your function on the remote server. [Hint] 'generate' function uses the following modules which weren't present in the environment definition: - '-main-' (accessed through 'File', 'Output') ```
## More direct Pydantic 2.x repro Batuhan suggested that we could reproduce directly (without the fal intermediary): ```py # demo_4.py import dill from pydantic import BaseModel, Field class Input(BaseModel): prompt: str num_steps: int = Field(default=2, ge=1, le=10) if __name__ == "__main__": dill.settings["recurse"] = True # Run the function locally cls = dill.loads(dill.dumps(Input)) print(cls("a")) ``` This gives a slightly different error. It's possible the difference just comes from the fal code being executed remotely, hiding the error's origin. > pydantic.errors.PydanticUserError: A non-annotated attribute was detected: `model_fields = {'prompt': FieldInfo(annotation=str, required=True), 'num_steps': FieldInfo(annotation=int, required=False, default=2, metadata=[Ge(ge=1), Le(le=10)])}`. All model fields require a type annotation; if `model_fields` is not meant to be a field, you may be able to resolve this error by annotating it as a `ClassVar` or updating `model_config['ignored_types']`.
Click to show full traceback ```py /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot locate reference to . StockPickler.save(self, obj, save_persistent_id) /home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py:412: PicklingWarning: Cannot pickle : __main__.Input has recursive self-references that trigger a RecursionError. StockPickler.save(self, obj, save_persistent_id) Traceback (most recent call last): File "/home/louis/lab/fal/pyd2/demo_4.py", line 14, in cls = dill.loads(dill.dumps(Input)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py", line 301, in loads return load(file, ignore, **kwds) ^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py", line 287, in load return Unpickler(file, ignore=ignore, **kwds).load() ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py", line 442, in load obj = StockUnpickler.load(self) ^^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/dill/_dill.py", line 591, in _create_type return typeobj(*args) ^^^^^^^^^^^^^^ File "/home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 92, in __new__ private_attributes = inspect_namespace( ^^^^^^^^^^^^^^^^^^ File "/home/louis/miniconda3/envs/fal/lib/python3.11/site-packages/pydantic/_internal/_model_construction.py", line 372, in inspect_namespace ```
## Difficulty to repro with pytest There's a curious inability to reproduce with pytest. If we take the `demo_4.py` module used above, and modify it slightly (in particular drop the condition that `__name__ == "__main__"`) we find that code run during test collection time does not reproduce the error. ```py # demo_4_pytest_friendly.py import dill from pydantic import BaseModel, Field class Input(BaseModel): prompt: str num_steps: int = Field(default=2, ge=1, le=10) def main(): dill.settings["recurse"] = True # Run the function locally cls = dill.loads(dill.dumps(Input)) model = cls(prompt="a") return model result = main() breakpoint() ``` Running `python -m pytest demo_4_pytest_friendly.py` hits the breakpoint (which shows the error was not raised), and printing the variable confirms `result` is a model. ```py (Pdb) p result Input(prompt='a', num_steps=2) ``` My interpretation of this pytest oddness (which may be off the mark) is that the "test collection" phase pytest does before running tests makes it equivalent to defining the class in a different module. The same [non-serialising] behaviour is seen in the direct Python invocation if you define the Pydantic model class in an external module and import it.
Click to show what this looks like (trivial example) ```py import dill from another_module import Input def main(): dill.settings["recurse"] = True # Run the function locally cls = dill.loads(dill.dumps(Input)) model = cls(prompt="a") return model if __name__ == "__main__": result = main() ```
- I also tried deleting the module from `sys.path` and the class definition from the `sys.modules` entry at various positions in the code, none of which avoided the interference observed whereby the original class definition was used instead of the deserialised model class definition [which would be recognisable by the error it raised], all that can be done is to break the reference to the `Input` classdef entirely. - A pytest maintainer's comments ([1](https://github.com/pytest-dev/pytest/issues/10845#issuecomment-1501086898), [2](https://github.com/pytest-dev/pytest/issues/10845#issuecomment-1501112830)) on a pytest issue raised by dill users seemed to show some opposition between the 2 libraries: > "To me that reads like dill choose to explicitly transfer things that are serialization unsafe in a unsafe way and now there is sudden surprise > Dill is clearly doing something that works by chance and is broken by design, I'm not going to pretend it's a theoretically sound approach"

Pytest repro workaround

It is possible to, in effect, reproduce in the form of a pytest test (which is desirable for maintaining future coverage of this bug when contributing a fix) by resorting to subprocess, since Python is ultimately just another program we can run within Python.

The following code is a standalone pytest test that reproduces the bug

# demo_4_pytest_subprocess.py
import subprocess
import sys

import dill
from pydantic import BaseModel, Field

class Input(BaseModel):
    """A simple Pydantic model used to demonstrate deserialisation via dill.

    Attributes:
        prompt: An input prompt for a generative AI model.
        num_steps: The number of steps to run a generative AI model for.
    """

    prompt: str
    num_steps: int = Field(default=2, ge=1, le=10)

def deserialise_pydantic_model():
    """Serialise (`dill.dumps`) then deserialise (`dill.loads`) a Pydantic model.

    The `recurse` setting must be set, counterintuitively, to prevent excessive
    recursion (refer to e.g. dill issue
    [#482](https://github.com/uqfoundation/dill/issues/482#issuecomment-1139017499)):

        to limit the amount of recursion that dill is doing to pickle the function, we
        need to turn on a setting called recurse, but that is because the setting
        actually recurses over the global dictionary and finds the smallest subset that
        the function needs to run, which will limit the number of objects that dill
        needs to include in the pickle.
    """
    dill.settings["recurse"] = True

    # Run the function locally
    cls = dill.loads(dill.dumps(Input))
    model = cls(prompt="a")
    return model

def test_main():
    """Test that deserialisation of a Pydantic model succeeds.

    The deserialisation failure mode reproduction is incompatible with pytest (see
    [#29](https://github.com/fal-ai/fal/issues/29#issuecomment-1902241217) for
    discussion) so we directly invoke the current Python executable on this file.
    """
    proc = subprocess.run([sys.executable, __file__], capture_output=True, text=True)
    assert not proc.returncode, f"Pydantic model deserialisation failed:\n{proc.stderr}"

if __name__ == "__main__":
    deserialise_pydantic_model()