Open Trophime opened 3 years ago
I've noticed a similar issue with delayed annotation from Pydantic. Copying the documentation exactly, the following code works. Here is my models.py
file. This code runs well:
class TeamBase(SQLModel):
name: str
headquarters: str
class Team(TeamBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
heroes: List["Hero"] = Relationship(back_populates="team")
class TeamCreate(TeamBase):
pass
class TeamRead(TeamBase):
id: int
class TeamUpdate(SQLModel):
id: Optional[int] = None
name: Optional[str] = None
headquarters: Optional[str] = None
class HeroBase(SQLModel):
name: str
secret_name: str
age: Optional[int] = None
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
class Hero(HeroBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
team: Optional[Team] = Relationship(back_populates="heroes")
class HeroRead(HeroBase):
id: int
class HeroCreate(HeroBase):
pass
class HeroUpdate(SQLModel):
name: Optional[str] = None
secret_name: Optional[str] = None
age: Optional[int] = None
team_id: Optional[int] = None
class HeroReadWithTeam(HeroRead):
team: Optional[TeamRead] = None
class TeamReadWithHeroes(TeamRead):
heroes: List[HeroRead] = []
If you split the models into teams.py
and heroes.py
then the same that Trophime is getting occurs.
Here's teams.py
:
class TeamBase(SQLModel):
name: str
headquarters: str
class Team(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
heroes: List["Hero"] = Relationship(back_populates="team")
class TeamRead(TeamBase):
id: int
class TeamCreate(TeamBase):
pass
class TeamUpdate(SQLModel):
id: Optional[int] = None
name: Optional[str] = None
headquarters: Optional[str] = None
class TeamReadWithHeroes(TeamRead):
heroes: List["HeroRead"] = []
And here's heroes.py
:
class HeroBase(SQLModel):
name: str
secret_name: str
age: Optional[int] = None
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
class Hero(HeroBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
team: Optional["Team"] = Relationship(back_populates="heroes")
class HeroRead(HeroBase):
id: int
class HeroCreate(HeroBase):
pass
class HeroUpdate(SQLModel):
name: Optional[str] = None
secret_name: Optional[str] = None
age: Optional[int] = None
team_id: Optional[int] = None
class HeroReadWithTeam(HeroRead):
team: Optional["TeamRead"] = None
The issue can even be replicated in a single models.py
file:
class TeamBase(SQLModel):
name: str
headquarters: str
class Team(TeamBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
heroes: List["Hero"] = Relationship(back_populates="team")
class TeamCreate(TeamBase):
pass
class TeamRead(TeamBase):
id: int
class TeamUpdate(SQLModel):
id: Optional[int] = None
name: Optional[str] = None
headquarters: Optional[str] = None
class TeamReadWithHeroes(TeamRead):
heroes: List["HeroRead"] = []
class HeroBase(SQLModel):
name: str
secret_name: str
age: Optional[int] = None
team_id: Optional[int] = Field(default=None, foreign_key="team.id")
class Hero(HeroBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
team: Optional[Team] = Relationship(back_populates="heroes")
class HeroRead(HeroBase):
id: int
class HeroCreate(HeroBase):
pass
class HeroUpdate(SQLModel):
name: Optional[str] = None
secret_name: Optional[str] = None
age: Optional[int] = None
team_id: Optional[int] = None
class HeroReadWithTeam(HeroRead):
team: Optional[TeamRead] = None
All of these produce the same exception. If you start uvicorn and then open your browser and go to the docs page you get the following exception:
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/uvicorn/protocols/http/h11_impl.py", line 373, in run_asgi
result = await app(self.scope, self.receive, self.send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/uvicorn/middleware/proxy_headers.py", line 75, in __call__
return await self.app(scope, receive, send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/fastapi/applications.py", line 208, in __call__
await super().__call__(scope, receive, send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/applications.py", line 112, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/middleware/errors.py", line 181, in __call__
raise exc from None
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/middleware/errors.py", line 159, in __call__
await self.app(scope, receive, _send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/exceptions.py", line 82, in __call__
raise exc from None
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/exceptions.py", line 71, in __call__
await self.app(scope, receive, sender)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/routing.py", line 580, in __call__
await route.handle(scope, receive, send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/routing.py", line 241, in handle
await self.app(scope, receive, send)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/starlette/routing.py", line 52, in app
response = await func(request)
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/fastapi/applications.py", line 161, in openapi
return JSONResponse(self.openapi())
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/fastapi/applications.py", line 136, in openapi
self.openapi_schema = get_openapi(
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/fastapi/openapi/utils.py", line 387, in get_openapi
definitions = get_model_definitions(
File "/Users/user/.pyenv/versions/3.9.7/envs/sqlmodel-try/lib/python3.9/site-packages/fastapi/utils.py", line 24, in get_model_definitions
m_schema, m_definitions, m_nested_models = model_process_schema(
File "pydantic/schema.py", line 548, in pydantic.schema.model_process_schema
File "pydantic/schema.py", line 589, in pydantic.schema.model_type_schema
File "pydantic/schema.py", line 241, in pydantic.schema.field_schema
File "pydantic/schema.py", line 440, in pydantic.schema.field_type_schema
File "pydantic/schema.py", line 773, in pydantic.schema.field_singleton_schema
File "pydantic/schema.py", line 667, in pydantic.schema.field_singleton_sub_fields_schema
File "pydantic/schema.py", line 495, in pydantic.schema.field_type_schema
File "pydantic/schema.py", line 839, in pydantic.schema.field_singleton_schema
File "/Users/user/.pyenv/versions/3.9.7/lib/python3.9/abc.py", line 123, in __subclasscheck__
return _abc_subclasscheck(cls, subclass)
TypeError: issubclass() arg 1 must be a class
INFO: 127.0.0.1:53550 - "GET /openapi.json HTTP/1.1" 500 Internal Server Error
I have tried this on Python 3.8.1 and Python 3.9.7. I think that Pydantic had an issue earlier that was fixed in a previous version: https://github.com/samuelcolvin/pydantic/issues/1298.
I am experiencing the same exception as well.
Prefacing this by saying I'm relatively new to pydantic and sqlalchemy, so take what I suggest with a grain of salt.
For @Trophime 's sample code - If you add this at the bottom of the file, it works:
MagnetUpdate.update_forward_refs()
This should be done after all the router endpoints are defined. It resolves this particular case, by updating the forward reference to the actual referenced model after everything has loaded.
For multiple files it seems to be a bit trickier -
You can do some non-PEP8 shenanigans like importing the forward ref models at the bottom of the file, and then update the forward ref - technically this can work, although your python linter will hate you:
class FirstModel(SQLModel, table=True):
ref: List["ForwardRefModel"]
# ...
from .other_module import ForwardRefModel
FirstModel.update_forward_refs()
If you don't want to engage in acts of dubious code style legality, you can also use a package file to import all the models together, and run any updates there...this is how I'm doing it for my current project. Something like:
"""
app/models/__init__.py
Import the various model modules in one place and resolve forward refs.
"""
# Make sure all modules are imported here...
from app.models.first_module import FirstModelBase, FirstModel, FirstModelRead # (etc)
from app.models.other_module import ForwardRefModelBase, ForwardRefModel # (etc)
FirstModel.update_forward_refs()
Note though, the above code caused me some NameErrors, unless I explicitly passed the namespace for the forward refs, like this:
FirstModel.update_forward_refs(ForwardRefModel=ForwardRefModel)
I haven't done a lot of testing on this; that issue could be specific to my code. I mostly figured this out by reading pydantic code, github issues, and applying brute force trial and error.
I'm not at all sure this is a good solution, but it is a workaround that seems to have sorted the issue for me, at least...for now. π
@LucidDan thank you so much! I did tinker around using update_forward_refs()
but I ran into NameErrors as well. Your solution of explicitly passing the the namespace gets around this!
I will keep on trying this with more complex models and see if I run into any issues. Thank you so much.
@LucidDan thanks for your help.
Now I have a stupid question as a beginner in this field.
How can I actually proceed to update mparts
of a magnet
in my example?
I've naively tried something like that:
{
"name": "string",
"mparts": [
{
"name": "string",
"id": 0
}
]
}
which is obviously not working
I think the simplest way is to use a method to add mpart from an mpart_id. Otherwise I guess I need to modify the updat_mpart method...
Thanks for your suggestions
If you don't want to engage in acts of dubious code style legality, you can also use a package file to import all the models together, and run any updates there...this is how I'm doing it for my current project. Something like:
""" app/models/__init__.py Import the various model modules in one place and resolve forward refs. """ # Make sure all modules are imported here... from app.models.first_module import FirstModelBase, FirstModel, FirstModelRead # (etc) from app.models.other_module import ForwardRefModelBase, ForwardRefModel # (etc) FirstModel.update_forward_refs()
Note though, the above code caused me some NameErrors, unless I explicitly passed the namespace for the forward refs, like this:
FirstModel.update_forward_refs(ForwardRefModel=ForwardRefModel)
I haven't done a lot of testing on this; that issue could be specific to my code. I mostly figured this out by reading pydantic code, github issues, and applying brute force trial and error.
I'm not at all sure this is a good solution, but it is a workaround that seems to have sorted the issue for me, at least...for now. π
The package file import all (e.g., __init__.py
) described above by @LucidDan works for my more complex case, but only with the ForwardRefModel=ForwardRefModel
and you have to do this for every forward reference...including ones in parent classes (even if you've already updated their forward refs).
For example, if the models in one of your files are like this:
class EventReadWithHosts(EventRead):
hosts: List["HostRead"] = []
class EventReadFull(EventReadWithHosts):
data_sources: List["DataSourceReadWithHost"] = []
...then you have to (or I had to at least) update forward refs as follows to avoid NameErrors and get the docs to actually load:
EventReadWithHosts.update_forward_refs(HostRead=HostRead)
EventReadFull.update_forward_refs(
HostRead=HostRead, DataSourceReadWithHost=DataSourceReadWithHost
)
note that I already updated EventReadWithHosts
-> HostRead
, but had to do it again for the EventReadFull
subclass
I'm sure I am misunderstanding/describing something incorrectly, but it didn't work for me until I did the above
@lobotmcj Yes, I found the exact same thing. You have to update the forward refs for every forward reference. I may be doing something wrong as well though!
Yeah to be clear folks, this is what I had to do, too. I only used a single example, but in my actual project, It wasn't just one model, it was several, and several namespace entries that had to be passed in as well.
How I approached finding the ones to add was I set my server up so it would immediately raise a runtime error and crashing out if there was any models that didn't resolve properly (rather than only logging a caught error when you accessed an endpoint), and then just added each of those models causing issues to the list of updates.
I could've perhaps run an iteration over all models and done an update on all of them but that seemed a bit excessive...it's a large project, I've got many dozens of models.
It's not ideal, for sure...definitely want to see a better solution, and I'll eventually put some time into figuring it out, at least for my own project. I'll update this issue if I make any progress on that, would love to hear from anyone else that figures out better solutions, too.
any updates?
@masreplay What updates were you looking for specifically? I think @LucidDan offered a solution.
@LucidDan's approach worked for me too, but isn't ideal. As a minimum, the tutorial / documentation should be updated. Better still would be if this could somehow be managed seamlessly by the library itself.
Yeah this is pretty complicated stuff, none of the above solutions seem 'good'. Relying on 'on import' behaviour always feels funny. But you can't solve it in the ASGI lifespan startup either as its too late by that point. I wonder if SQLModel can somehow update forward refs when they are added to the metadata.
Theoretically, it should be possible to iterate over the fields and go 'I can update this I know what it is'. But also quite hacky.
It sucks that the recommendation is to make 4+ models for each database table (if doing a simple CRUD app etc). So a medium size app with maybe 20 tables all of a sudden has a file with 80+ classes in it because you can't really solve the circular import issue π
At a minimum the documentation should be updated to address that this issue exists, the use TYPE_CHECKING solution doesn't actually fix the issue.
This seems to be much worse than simply a "all models need to be in the same file" or "fix with update_forward_refs"...
It also means you can't really abstract the CRUD or ROUTE code into per-table modules if there is any cross referencing, as then the importing becomes runtime circular.
Using the above modular example with the code from the tutorial, try to refactor the code paths as well:
from team import Team, select_teams
from hero import Hero, create_heroes, select_heroes, update_heroes
main.py: create_db_and_tables(engine=engine)
hero.py: create_heroes(engine=engine)
hero.py: select_heroes(engine=engine)
hero.py: update_heroes(engine=engine)
team.py: select_teams(engine=engine)
The code in hero,.py needs to refer to Team, and the code in team.py needs to refer to Hero...
How does one refactor these examples so that it is possible to create a Team and add a Hero to it (or create a Hero on a Team...) with per-table MODEL, CRUD and ROUTE files?
Alternatively, is there a good "large project" design pattern for keeping the business logic / database logic abstraction modular in the face of these relationships?
(I really like the ActiveRecord concept from Boris Lau in issue 254 that adds class methods to the SQLModel classes for the CRUD functionality, but this limitation seems to make it impractical for these cross reference cases)
Any updates on these issue ? it's really frustrating to not find a solution...
I am experiencing the same complexity, would love a more elegant solution.
Is there any on-going work on this? As @invokermain suggested, it should be possible to modify the SQLModelMetaclass
so that every time a model is defined its ForwardRef
s are updated if possible and every other model depending on the newly defined class is also checked. Not sure if this would be the neatest approach though, but other similar approaches based on SQLModelMetaclass
should be doable.
For now, as a workaround I'm doing what @LucidDan suggests and using the __init__.py
file of my models module for updating the forward refs providing the namespace. But since doing it manually it's such a pain, I am generating a dict with all the models (everything that inherits from SQLModel) and doing .update_forward_refs
on every of them using that dict to provide a namespace with every needed model. Of course this is not optimal since I'm not iterating only over those models with ForwardRef
s, but its easier this way. It should be possible to inspect the models and generate a list of the ones with forward refs to iterate on those only.
Here is the code I'm using in case it helps anyone:
def get_subclasses(cls):
for subclass in cls.__subclasses__():
yield from get_subclasses(subclass)
yield subclass
models_dict = {cls.__name__: cls for cls in get_subclasses(SQLModel)}
for cls in models_dict.values():
cls.update_forward_refs(**models_dict)
@Eryx5502 Thanks for providing your solution. It works well for me while using models in different files and allows me to maintain a direct import from the parent model. Here's the updated code block:
models/parent.py
from children import Children
class Parent(DealBase, table=True):
id: int = Field(default=None, primary_key=True, index=True)
children: List[Childern] = Relationship(
back_populates="parent",
)
models/children.py
if TYPE_CHECKING:
from parent import Parent
class Children(DealBase, table=True):
id: int = Field(default=None, primary_key=True, index=True)
parent_id: int = Field(default=None, foreign_key="parent.id", index=True)
parent: Parent = Relationship(
back_populates="children,
)
One of them needs to actually import the other model (in that case the parent imports the children), and then the second can do something like if TYPE_CHECKING:
, otherwise (when non of them actually using the import
Is there any on-going work on this? As @invokermain suggested, it should be possible to modify the
SQLModelMetaclass
so that every time a model is defined itsForwardRef
s are updated if possible and every other model depending on the newly defined class is also checked. Not sure if this would be the neatest approach though, but other similar approaches based onSQLModelMetaclass
should be doable.For now, as a workaround I'm doing what @LucidDan suggests and using the
__init__.py
file of my models module for updating the forward refs providing the namespace. But since doing it manually it's such a pain, I am generating a dict with all the models (everything that inherits from SQLModel) and doing.update_forward_refs
on every of them using that dict to provide a namespace with every needed model. Of course this is not optimal since I'm not iterating only over those models withForwardRef
s, but its easier this way. It should be possible to inspect the models and generate a list of the ones with forward refs to iterate on those only.Here is the code I'm using in case it helps anyone:
def get_subclasses(cls): for subclass in cls.__subclasses__(): yield from get_subclasses(subclass) yield subclass models_dict = {cls.__name__: cls for cls in get_subclasses(SQLModel)} for cls in models_dict.values(): cls.update_forward_refs(**models_dict)
Thank you! This actually works. This should definitely be addressed in the module itself. Just curious, is there an actual downside to doing it this way? My understanding is that it doesn't matter if you update references for packages that don't need it.
Not sure when it was added but with the addition of .model_rebuild()
, this solution resolved the annotation issue for me and seems the most "elegant".
# app/models/__init__.py
from .event import (
Event,
EventBase,
EventCreate,
EventPublic,
EventPublicWithOrganizersAndParticipants,
EventUpdate,
)
from .organization import (
Organization,
OrganizationBase,
OrganizationCreate,
OrganizationPublic,
OrganizationPublicWithEventsAndMembers,
OrganizationUpdate,
)
from .user import (
User,
UserBase,
UserCreate,
UserPublic,
UserPublicWithOrganizationsAndEvents,
UserUpdate,
)
UserPublicWithOrganizationsAndEvents.model_rebuild()
OrganizationPublicWithEventsAndMembers.model_rebuild()
EventPublicWithOrganizersAndParticipants.model_rebuild()
I also faced this problem. I'm using Python 3.9. Calling model_rebuild
in __init__
didn't help me. The NameError: name error still occurs '...' is not defined
, if you use an annotation List['Model']
Only the option mentioned here https://github.com/fastapi/sqlmodel/discussions/757#discussioncomment-8733516, but he looks terrible.
Remember to initialize (import) the models like it is said in the tutorial and docs (that is what worked very well for me): https://sqlmodel.tiangolo.com/tutorial/code-structure/?h=circular#app-file
First Check
Commit to Help
Example Code
Description
Operating System
Linux
Operating System Details
Ubuntu 20.04 LTS
SQLModel Version
0.0.4
Python Version
Python 3.8.10
Additional Context
I've tried to adapt the tutorial example with the many-to-many associative tables for using it with fastapi without success. If this feature is supported it would be great to have an example in the tutorial.
Best