Skip to content

feat(cli): generate Pydantic models with appwrite types -l python - #2072

Merged
ChiragAgg5k merged 3 commits into
mainfrom
feat/cli-types-python
Oct 16, 2026
Merged

ChiragAgg5k merged 3 commits into
mainfrom
feat/cli-types-python

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

What

Adds python to appwrite types. It writes a single appwrite_types.py of Pydantic models, one per table or collection, and a (str, Enum) per enum column. Pass the models to the Python SDK as model_type: TablesDB.get_row(..., model_type=Books) returns a Row[Books] whose .data is the validated model.

  • -l python, plus the aliases py and python3
  • auto picks Python when it finds pyproject.toml, requirements.txt, Pipfile or setup.py. It checks in the existing order, so a project that also has a package.json still resolves to JS
  • --strict converts 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
  • An output path ending in .py writes to that file, the same as ts and js

Why

The Python SDK now ships models and takes model_type on 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

  • One module, not one file per table. Relationships are often mutual, as in Authors.books and Books.author. With from __future__ import annotations, Pydantic resolves those references inside one module, so there are no import cycles.
  • Subclasses pydantic.BaseModel, not the SDK's AppwriteModel. The appwrite package has no py.typed marker. Under mypy --strict, subclassing a class from it is a disallow_subclassing_any error, so the generated file would fail type-checking in every user project. Each class sets ConfigDict(extra="allow", populate_by_name=True), the same settings AppwriteModel uses. $id and the other system fields on a nested related row are kept, and fields can be filled by name or by alias.
  • The column key is the alias whenever the field name differs. The SDK builds the model with model_type(**data), using the raw keys. So copies-sold, published.at, class, json and _hidden each get a usable name (copies_sold, published_at, class_, json_, hidden) with Field(alias=...) set to the key. Names are protected against Python keywords, BaseModel attributes, and names used in the annotations. A field named str would 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: with populate_by_name=True that field would also accept the other column's key, so a payload missing fullName but carrying full_name would validate and fill both. In strict mode fullName next to a full_name column becomes full_name_1 and full_name keeps its key. Class names are deduplicated across tables and enums for the whole schema.
  • Models are keyed by database and table id. Two databases can hold tables with the same id, so models and enums are identified by both, and a relationship resolves to the table with that id in its own database first. Keyed by table id alone, a/items named First and b/items named Second rendered class Second twice and dropped First.
  • Relationships are 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 as Authors failed validation on a plain get_row.
  • Every column type is handled. string/varchar/text/mediumtext/longtext/datetime/email/url/ip become str (datetime stays a string, matching the SDK's own $createdAt). integer and bigint become int, double becomes float, boolean becomes bool. Point, linestring and polygon become nested List[float]. Arrays become List[...], and a column that is not required becomes Optional[...] = None.
  • The output is already Black-clean. No formatter runs after generation. The emitter lays out long fields, enum members and class lines the way Black 26 does at its default line length of 88.

Sample output

From the staging project used below (appwrite types ./out -l python):

# This file is auto-generated by the Appwrite CLI.
# You can regenerate it by running `appwrite types ./out -l python`.

from __future__ import annotations

from enum import Enum
from typing import List, Optional, Union

from pydantic import BaseModel, ConfigDict, Field


class BooksZinesStatus(str, Enum):
    DRAFT = "draft"
    IN_REVIEW = "in-review"
    PUBLISHED = "published"


class Authors(BaseModel):
    model_config = ConfigDict(extra="allow", populate_by_name=True)

    name: str
    books: Optional[List[Union[BooksZines, str]]] = None


class BooksZines(BaseModel):
    model_config = ConfigDict(extra="allow", populate_by_name=True)

    title: str
    summary: Optional[str] = None
    pages: Optional[int] = None
    copies_sold: Optional[int] = Field(default=None, alias="copies-sold")
    price: float
    inPrint: Optional[bool] = None
    published_at: Optional[str] = Field(default=None, alias="published.at")
    contact: Optional[str] = None
    homepage: Optional[str] = None
    origin: Optional[str] = None
    status: BooksZinesStatus
    tags: Optional[List[str]] = None
    store: Optional[List[float]] = None
    route: Optional[List[List[float]]] = None
    region: Optional[List[List[List[float]]]] = None
    author: Optional[Union[Authors, str]] = None

Reproduction

The tests ran against Appwrite Cloud staging in a scratch project. A database audit188 held two tables. Authors had name. Books & Zines had varchar, text, integer, bigint, double, boolean, datetime, email, url, ip, an enum, a varchar array, point, linestring and polygon columns, plus a two-way manyToOne relationship to Authors. I ran appwrite pull tables --force first.

Before (released 28.6.0):

$ appwrite --version
appwrite version 28.6.0
$ appwrite types ./out -l python
✗ Error: language 'python' is not supported. The supported languages are ts, js, php, kotlin, swift, java, dart, cs

After (binary built from this branch):

$ appwrite types --help | grep language
  -l, --language string   The language of the types (auto|ts|js|php|kotlin|swift|java|dart|cs|python) (default "auto")
$ appwrite types ./out -l python
ℹ Info: Directory: ./out does not exist, creating...
ℹ Info: Found 2 tables: Authors, Books & Zines
ℹ Info: Found 18 columns across all tables
ℹ Info: Added types to out/appwrite_types.py
✓ Success: Generated types for all the listed tables
$ touch pyproject.toml && appwrite types ./auto
ℹ Info: Detected language: python
...
✓ Success: Generated types for all the listed tables

The generated module was then used with the released appwrite==25.0.0 from PyPI. The script created an author and a book, then read them back with TablesDB.get_row(..., model_type=...):

plain The Dispossessed BooksZinesStatus.IN_REVIEW True 9007199254740993
plain author 6ad14251003b6a19c126 published None store [12.5, 41.9]
loaded author Ursula Authors
row id matches True
reverse ['The Dispossessed'] ['BooksZines']
  • Without a select, author comes back as the ID string. With Query.select(["*", "author.*"]) it becomes an Authors instance.
  • The reverse books side is validated into BooksZines.
  • A bigint above 2^53 round-trips exactly.

mypy --strict --follow-untyped-imports --allow-untyped-calls on that script plus the generated module reports no issues. The two flags are needed only because the SDK has no py.typed and leaves Client(), ID.unique() and Query.select() unannotated. A probe file shows the generic flows through:

probe.py:7: note: Revealed type is "appwrite_types.BooksZines"
probe.py:8: error: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
probe.py:9: error: "BooksZines" has no attribute "missing"  [attr-defined]

Tests

  • TestPythonMatchesBaseline renders golden files in loose and strict mode. One pair comes from the shared collections.json fixture. The other comes from a new python.collections.json, which covers every column type, keyword and BaseModel names, 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.
  • TestPythonModelsAreKeyedByDatabase renders two databases that each hold items and owners tables and checks each class, enum and relationship appears exactly once and points into its own database. Before the fix it rendered class SecondStatus twice and no First.
  • TestPythonFieldNamesAreUsableIdentifiers (now also asserting that strict fullName yields full_name_1 beside a full_name column), TestPythonClassNamesNeverCollide and TestPythonStringMatchesBlackQuoting cover name sanitising, class-name deduplication and Black's quote preference.
  • TestTypesDetectsPythonProjects checks that auto resolves each of the four marker files to Python. TestDetectedLanguagesWithoutAnEmitterAreExplained now points py at python and keeps Ruby as the unsupported case.
  • validation.yml gains a CLI step. It installs black==26.10.1, mypy==2.4.0 and pydantic==2.14.1, then runs black --check, mypy --strict and an import with -W error on 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.
  • Locally: php example.php cli, gofmt -l (clean), go build ./... && go vet ./... && go test -race ./... (all pass), composer refactor:check.
  • A fuzz run (500 random schemas, not committed) passed Black, mypy --strict and 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

  • Black's layout for a very long statement depends on which of several splits fits best, and the emitter does not cover every case. Fuzzing found mismatches only with field names over about 75 characters together with enum class names over 80. Appwrite caps column keys at 36 characters, so real schemas stay well inside the tested range.
  • Not caused by this change, but found while testing: on staging, creating a row that sets a column whose key contains a dot (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

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.
@hansi-codes

hansi-codes Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🔵 Tier A · Mergeable after minor fixes

The only finding is a minor edge case where a missing relationship target can resolve to a same-ID resource in another database.

Adds Python support to appwrite types, generating a single Pydantic module with models, enums, aliases, and relationship types. It also registers Python language aliases and project auto-detection, and adds golden fixtures plus CI validation for generated Python output.

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.

Verdict New comments Fixed Still open
💬 Commented 1 0 0

Note

Not approving while a bug finding is open: Avoid resolving a relationship into another database

Finding Where
🟡 Avoid resolving a relationship into another database templates/cli/internal/typegen/python.go:106
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
File Change
.github/workflows/validation.yml Adds CI checks for Black formatting, strict mypy typing, and importing generated Python fixtures.
src/SDK/Language/CLI.php Includes the Python typegen implementation and fixtures in generated CLI builds.
templates/cli/internal/cmd/typegen_commands_test.go Tests Python language detection and clarifies unsupported-language suggestions.
templates/cli/internal/cmd/types.go Registers Python and the py and python3 aliases for appwrite types.
templates/cli/internal/typegen/language.go Adds Python project-marker detection, including setup.py.
templates/cli/internal/typegen/language_test.go Adds Python emitter baselines and regression tests, including same-ID resources in separate databases and strict-name collisions.
templates/cli/internal/typegen/python.go Implements the single-file Python/Pydantic emitter, with database-aware model and enum keys, field aliases, and relationship annotations.
templates/cli/internal/typegen/testdata/python.collections.json Adds schema fixture coverage, including a resource in a second database.
templates/cli/internal/typegen/testdata/python.coverage.loose.appwrite_types.py Updates loose-mode generated output for the added multi-database fixture.
templates/cli/internal/typegen/testdata/python.coverage.strict.appwrite_types.py Updates strict-mode generated output for the added fixture and adjusted collision handling.
templates/cli/internal/typegen/testdata/python.loose.appwrite_types.py Adds the loose-mode Python output baseline for the shared schema.
templates/cli/internal/typegen/testdata/python.strict.appwrite_types.py Adds the strict-mode Python output baseline for the shared schema.

Merged main (d50ff37); no changes to this PR's code since a111fcc · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

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.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

}
}

return RelatedCollection(attribute, collections)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ChiragAgg5k
ChiragAgg5k merged commit a42d3fd into main Oct 16, 2026
61 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the feat/cli-types-python branch October 16, 2026 19:00
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

Successfully merging this pull request may close these issues.

Generate Pydantic models with appwrite types -l python 🚀 Enhancement: Types support for Python

1 participant