Repository navigation
feat(cli): generate Pydantic models with appwrite types -l python - #2072
Conversation
The Python SDK now takes a model_type on row reads and returns Row[T], but the CLI could not produce a T for it: -l python was rejected and auto detection of a pyproject.toml or requirements.txt project ended in an unsupported-language error. The new emitter writes one appwrite_types.py so mutual relationships resolve without import cycles. Fields keep the column key as an alias whenever the Python name differs, so rows validate straight from the API, and an unselected relationship is typed as the id string the API returns in its place. The output is Black-clean and passes mypy --strict, which the validation workflow now checks against the baselines.
🔵 Tier A · Mergeable after minor fixes
Adds Python support to Latest changes: The newest commits key generated models and enums by database as well as resource ID, resolve relationships within the owning database, and avoid strict-mode field-name collisions with raw aliases; they add regression coverage for these cases.
Note Not approving while a bug finding is open: Avoid resolving a relationship into another database
Fix with agent prompt### Issue 1
templates/cli/internal/typegen/python.go:106
**Avoid resolving a relationship into another database**
If the related ID is absent from the owner's database but exists in another configured database, this fallback silently selects that unrelated model. The generated annotation then accepts the wrong model instead of surfacing the missing target; keep the fallback from crossing databases when the owner has a database ID.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.📂 Walkthrough · 12
Merged |
Models and enums were keyed by table id alone, so two databases holding a table with the same id overwrote each other: the second table's class was emitted twice and the first was lost, and relationships could point at the other database's model. They are now keyed by database and table id, and a relationship resolves within its own database first. A strict-mode field name could also equal another column's raw key, as fullName becoming full_name next to a full_name column. With populate_by_name the field then also accepted that column's key, so a payload missing fullName but carrying full_name validated and filled both fields. Generated names now skip every raw column key other than their own.
| } | ||
| } | ||
|
|
||
| return RelatedCollection(attribute, collections) |
There was a problem hiding this comment.
Avoid resolving a relationship into another database
If the related ID is absent from the owner's database but exists in another configured database, this fallback silently selects that unrelated model. The generated annotation then accepts the wrong model instead of surfacing the missing target; keep the fallback from crossing databases when the owner has a database ID.
Prompt To Fix With AI
This is a comment left during a code review.
Path: templates/cli/internal/typegen/python.go
Line: 106
Comment:
**Avoid resolving a relationship into another database**
If the related ID is absent from the owner's database but exists in another configured database, this fallback silently selects that unrelated model. The generated annotation then accepts the wrong model instead of surfacing the missing target; keep the fallback from crossing databases when the owner has a database ID.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · bug · Reply if this doesn't apply.
What
Adds
pythontoappwrite types. It writes a singleappwrite_types.pyof Pydantic models, one per table or collection, and a(str, Enum)per enum column. Pass the models to the Python SDK asmodel_type:TablesDB.get_row(..., model_type=Books)returns aRow[Books]whose.datais the validated model.-l python, plus the aliasespyandpython3autopicks Python when it findspyproject.toml,requirements.txt,Pipfileorsetup.py. It checks in the existing order, so a project that also has apackage.jsonstill resolves to JS--strictconverts every field to snake_case. Without it, a key that is already a valid identifier stays as it is (inPrint), and any other key is converted.pywrites to that file, the same astsandjsWhy
The Python SDK now ships models and takes
model_typeon row reads, but the CLI rejected-l python, and auto-detecting a Python project failed with an unsupported-language error. Fixes appwrite/sdk-for-cli#188.Design
Authors.booksandBooks.author. Withfrom __future__ import annotations, Pydantic resolves those references inside one module, so there are no import cycles.pydantic.BaseModel, not the SDK'sAppwriteModel. Theappwritepackage has nopy.typedmarker. Undermypy --strict, subclassing a class from it is adisallow_subclassing_anyerror, so the generated file would fail type-checking in every user project. Each class setsConfigDict(extra="allow", populate_by_name=True), the same settingsAppwriteModeluses.$idand the other system fields on a nested related row are kept, and fields can be filled by name or by alias.model_type(**data), using the raw keys. Socopies-sold,published.at,class,jsonand_hiddeneach get a usable name (copies_sold,published_at,class_,json_,hidden) withField(alias=...)set to the key. Names are protected against Python keywords,BaseModelattributes, and names used in the annotations. A field namedstrwould otherwise shadow the type when Pydantic evaluates annotations. Names that collide after conversion get a numeric suffix, and a converted name never takes another column's raw key: withpopulate_by_name=Truethat field would also accept the other column's key, so a payload missingfullNamebut carryingfull_namewould validate and fill both. In strict modefullNamenext to afull_namecolumn becomesfull_name_1andfull_namekeeps its key. Class names are deduplicated across tables and enums for the whole schema.a/itemsnamed First andb/itemsnamed Second renderedclass Secondtwice and dropped First.Union[Related, str]. Unless a query selects the relationship (Query.select(["*", "author.*"])), the API returns the related row's ID in its place. The end-to-end run below confirmed this: a model typed only asAuthorsfailed validation on a plainget_row.string/varchar/text/mediumtext/longtext/datetime/email/url/ip becomestr(datetime stays a string, matching the SDK's own$createdAt).integerandbigintbecomeint,doublebecomesfloat,booleanbecomesbool. Point, linestring and polygon become nestedList[float]. Arrays becomeList[...], and a column that is not required becomesOptional[...] = None.Sample output
From the staging project used below (
appwrite types ./out -l python):Reproduction
The tests ran against Appwrite Cloud staging in a scratch project. A database
audit188held two tables.Authorshadname.Books & Zineshad varchar, text, integer, bigint, double, boolean, datetime, email, url, ip, an enum, a varchar array, point, linestring and polygon columns, plus a two-waymanyToOnerelationship toAuthors. I ranappwrite pull tables --forcefirst.Before (released 28.6.0):
After (binary built from this branch):
The generated module was then used with the released
appwrite==25.0.0from PyPI. The script created an author and a book, then read them back withTablesDB.get_row(..., model_type=...):authorcomes back as the ID string. WithQuery.select(["*", "author.*"])it becomes anAuthorsinstance.booksside is validated intoBooksZines.bigintabove 2^53 round-trips exactly.mypy --strict --follow-untyped-imports --allow-untyped-callson that script plus the generated module reports no issues. The two flags are needed only because the SDK has nopy.typedand leavesClient(),ID.unique()andQuery.select()unannotated. A probe file shows the generic flows through:Tests
TestPythonMatchesBaselinerenders golden files in loose and strict mode. One pair comes from the sharedcollections.jsonfixture. The other comes from a newpython.collections.json, which covers every column type, keyword andBaseModelnames, colliding names, leading digits and underscores, dotted keys, quotes, backslashes and newlines in enum values, wrapping of long fields, enum members and class lines, an empty table, a table whose name collides with an enum, and a second database holding a table with the same id as one in the first, with its own enum and a relationship to itself.TestPythonModelsAreKeyedByDatabaserenders two databases that each holditemsandownerstables and checks each class, enum and relationship appears exactly once and points into its own database. Before the fix it renderedclass SecondStatustwice and noFirst.TestPythonFieldNamesAreUsableIdentifiers(now also asserting that strictfullNameyieldsfull_name_1beside afull_namecolumn),TestPythonClassNamesNeverCollideandTestPythonStringMatchesBlackQuotingcover name sanitising, class-name deduplication and Black's quote preference.TestTypesDetectsPythonProjectschecks thatautoresolves each of the four marker files to Python.TestDetectedLanguagesWithoutAnEmitterAreExplainednow pointspyatpythonand keeps Ruby as the unsupported case.validation.ymlgains a CLI step. It installsblack==26.10.1,mypy==2.4.0andpydantic==2.14.1, then runsblack --check,mypy --strictand an import with-W erroron every Python golden file. A change to the emitter that breaks Black, mypy or Pydantic fails CI there as well as in the golden comparison.php example.php cli,gofmt -l(clean),go build ./... && go vet ./... && go test -race ./...(all pass),composer refactor:check.mypy --strictand an import with-W error. It used column keys within Appwrite's 36-character limit and table names of up to about 40 characters, and a further 80 schemas used table names of 8 to 16 words. Pyright also reports no errors on the golden files, and they import on Python 3.9, the SDK's minimum version.Known limits
published.at) fails with "The schema changed while this request was running". The type generator handles those keys. The server rejects the write.Closes #2073