supabase-orm 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ # Generated by hatch-vcs
2
+ src/supabase_orm/_version.py
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Sphinx documentation
60
+ docs/_build/
61
+
62
+ # PyBuilder
63
+ .pybuilder/
64
+ target/
65
+
66
+ # Jupyter Notebook
67
+ .ipynb_checkpoints
68
+
69
+ # IPython
70
+ profile_default/
71
+ ipython_config.py
72
+
73
+ # pyenv
74
+ .python-version
75
+
76
+ # pipenv / poetry / pdm / uv
77
+ Pipfile.lock
78
+ poetry.lock
79
+ pdm.lock
80
+ .pdm.toml
81
+ .pdm-python
82
+ .pdm-build/
83
+ __pypackages__/
84
+
85
+ # Environments
86
+ .env
87
+ .env.*
88
+ .venv
89
+ env/
90
+ venv/
91
+ ENV/
92
+ env.bak/
93
+ venv.bak/
94
+
95
+ # mypy / pyright / ruff
96
+ .mypy_cache/
97
+ .dmypy.json
98
+ dmypy.json
99
+ .pyre/
100
+ .pytype/
101
+ .ruff_cache/
102
+
103
+ # Cython debug symbols
104
+ cython_debug/
105
+
106
+ # IDEs
107
+ .idea/
108
+ .vscode/
109
+ *.swp
110
+ *.swo
111
+
112
+ # OS
113
+ .DS_Store
114
+ Thumbs.db
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: supabase-orm
3
+ Version: 0.0.0
4
+ Summary: Lightweight async ORM on top of supabase-py with Pydantic validation.
5
+ Author-email: Adnan Ahmad <viperadnan@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: pydantic>=2.13.4
9
+ Requires-Dist: supabase>=2.30.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # supabase-orm
13
+
14
+ Lightweight async ORM on top of `supabase-py` with Pydantic validation.
15
+
16
+ ```python
17
+ from uuid import UUID
18
+ from typing import Annotated
19
+ from supabase_orm import SupabaseModel, Relation, lifespan
20
+
21
+
22
+ class User(SupabaseModel, table="users"):
23
+ id: UUID
24
+ name: str
25
+
26
+
27
+ class Pet(SupabaseModel, table="pets"):
28
+ id: UUID
29
+ name: str
30
+ species: str
31
+ adopted: bool
32
+
33
+
34
+ class PetWithOwner(SupabaseModel, table="pets"):
35
+ id: UUID
36
+ name: str
37
+ species: str
38
+ owner: Annotated[User, Relation(join="inner")]
39
+
40
+
41
+ # In FastAPI lifespan:
42
+ async with lifespan(SUPABASE_URL, SUPABASE_KEY):
43
+ # Read
44
+ rows = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
45
+ one = await PetWithOwner.get(some_id)
46
+
47
+ # Write
48
+ p = await Pet.create(name="Whiskers", species="cat", adopted=False)
49
+ p.name = "Mr. Whiskers"
50
+ await p.save()
51
+
52
+ # Bulk
53
+ await Pet.query.eq("adopted", False).update(adopted=True)
54
+ await Pet.query.eq("species", "fish").lt("created_at", cutoff).delete()
55
+ ```
56
+
57
+ ## Status
58
+
59
+ In-repo workspace package. Will be lifted to its own repo and published to
60
+ PyPI later. Until then, depend on it via `uv` workspace.
@@ -0,0 +1,49 @@
1
+ # supabase-orm
2
+
3
+ Lightweight async ORM on top of `supabase-py` with Pydantic validation.
4
+
5
+ ```python
6
+ from uuid import UUID
7
+ from typing import Annotated
8
+ from supabase_orm import SupabaseModel, Relation, lifespan
9
+
10
+
11
+ class User(SupabaseModel, table="users"):
12
+ id: UUID
13
+ name: str
14
+
15
+
16
+ class Pet(SupabaseModel, table="pets"):
17
+ id: UUID
18
+ name: str
19
+ species: str
20
+ adopted: bool
21
+
22
+
23
+ class PetWithOwner(SupabaseModel, table="pets"):
24
+ id: UUID
25
+ name: str
26
+ species: str
27
+ owner: Annotated[User, Relation(join="inner")]
28
+
29
+
30
+ # In FastAPI lifespan:
31
+ async with lifespan(SUPABASE_URL, SUPABASE_KEY):
32
+ # Read
33
+ rows = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
34
+ one = await PetWithOwner.get(some_id)
35
+
36
+ # Write
37
+ p = await Pet.create(name="Whiskers", species="cat", adopted=False)
38
+ p.name = "Mr. Whiskers"
39
+ await p.save()
40
+
41
+ # Bulk
42
+ await Pet.query.eq("adopted", False).update(adopted=True)
43
+ await Pet.query.eq("species", "fish").lt("created_at", cutoff).delete()
44
+ ```
45
+
46
+ ## Status
47
+
48
+ In-repo workspace package. Will be lifted to its own repo and published to
49
+ PyPI later. Until then, depend on it via `uv` workspace.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "supabase-orm"
3
+ description = "Lightweight async ORM on top of supabase-py with Pydantic validation."
4
+ readme = "README.md"
5
+ requires-python = ">=3.11"
6
+ authors = [{ name = "Adnan Ahmad", email = "viperadnan@gmail.com" }]
7
+ license = { text = "MIT" }
8
+ dynamic = ["version"]
9
+ dependencies = [
10
+ "pydantic>=2.13.4",
11
+ "supabase>=2.30.0",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["hatchling", "hatch-vcs"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.version]
19
+ source = "vcs"
20
+ fallback-version = "0.0.0"
21
+
22
+ [tool.hatch.build.hooks.vcs]
23
+ version-file = "src/supabase_orm/_version.py"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/supabase_orm"]
@@ -0,0 +1,48 @@
1
+ """ORM public API.
2
+
3
+ from supabase_orm import SupabaseModel, Relation, lifespan, rpc
4
+
5
+ class Pet(SupabaseModel, table="pets"):
6
+ id: UUID
7
+ name: str
8
+ species: str
9
+ adopted: bool
10
+
11
+ Internals live in underscore-prefixed modules; only names re-exported here
12
+ are part of the supported surface.
13
+ """
14
+
15
+ from ._base import SupabaseModel
16
+ from ._client import get_client, lifespan, set_client
17
+ from ._embed import Relation
18
+ from ._exceptions import (
19
+ SupabaseORMUsageError,
20
+ SupabaseORMDoesNotExist,
21
+ SupabaseORMError,
22
+ SupabaseORMMultipleObjectsReturned,
23
+ )
24
+ from ._filters import register_op
25
+ from ._query import QueryBuilder
26
+ from ._rpc import rpc, rpc_maybe_one, rpc_one, rpc_scalar
27
+ from ._serializers import register_serializer
28
+ from ._version import __version__
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "SupabaseModel",
33
+ "Relation",
34
+ "QueryBuilder",
35
+ "lifespan",
36
+ "get_client",
37
+ "set_client",
38
+ "rpc",
39
+ "rpc_one",
40
+ "rpc_maybe_one",
41
+ "rpc_scalar",
42
+ "register_op",
43
+ "register_serializer",
44
+ "SupabaseORMError",
45
+ "SupabaseORMDoesNotExist",
46
+ "SupabaseORMMultipleObjectsReturned",
47
+ "SupabaseORMUsageError",
48
+ ]
@@ -0,0 +1,264 @@
1
+ """SupabaseModel — Pydantic + PostgREST ORM base.
2
+
3
+ Subclass with ``table=``:
4
+
5
+ class Pet(SupabaseModel, table="pets"):
6
+ id: UUID
7
+ name: str
8
+ species: str
9
+ adopted: bool
10
+
11
+ Then chain queries off ``Model.query``:
12
+
13
+ rows = await Pet.query.eq("species", "cat").order_by("-created_at").limit(10).all()
14
+ one = await Pet.get(id)
15
+ p = await Pet.create(name="Whiskers", species="cat", adopted=False)
16
+ p.name = "Mr. Whiskers"; await p.save()
17
+ await p.delete()
18
+ await Pet.query.eq("adopted", False).delete() # bulk
19
+ await Pet.query.eq("adopted", False).update(adopted=True) # bulk
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, ClassVar, Self, TypeVar
25
+
26
+ from pydantic import BaseModel, ConfigDict, TypeAdapter
27
+
28
+ from ._client import get_client
29
+ from ._embed import Relation, build_select, collect_relations
30
+ from ._exceptions import SupabaseORMUsageError, SupabaseORMDoesNotExist
31
+ from ._query import QueryBuilder
32
+ from ._serializers import serialize
33
+
34
+ _TABLE_REGISTRY: dict[str, list[type["SupabaseModel"]]] = {}
35
+
36
+ _T = TypeVar("_T", bound="SupabaseModel")
37
+
38
+
39
+ class _QueryDescriptor:
40
+ """Returns a fresh ``QueryBuilder[Owner]`` per access.
41
+
42
+ Typed via the ``owner`` parameter, so ``Pet.query`` is inferred as
43
+ ``QueryBuilder[Pet]`` by static checkers — every operator on the chain
44
+ has a real signature and works with autocomplete.
45
+ """
46
+
47
+ def __get__(
48
+ self, instance: Any, owner: type[_T]
49
+ ) -> "QueryBuilder[_T]":
50
+ if not owner.__table__:
51
+ raise SupabaseORMUsageError(
52
+ f"{owner.__name__} has no __table__. Declare with "
53
+ f'`class {owner.__name__}(SupabaseModel, table="..."):`'
54
+ )
55
+ return QueryBuilder(owner)
56
+
57
+
58
+ class SupabaseModel(BaseModel):
59
+ """Base class for tables. Configure per-subclass via class kwargs.
60
+
61
+ Class kwargs:
62
+ table: PostgREST table / view name. Required on concrete subclasses.
63
+ pk: Primary key field name. Default ``"id"``.
64
+ select: Override the auto-derived select string (escape hatch).
65
+ """
66
+
67
+ model_config = ConfigDict(
68
+ validate_assignment=True,
69
+ extra="ignore",
70
+ )
71
+
72
+ __table__: ClassVar[str] = ""
73
+ __pk__: ClassVar[str] = "id"
74
+ __select__: ClassVar[str] = ""
75
+ __select_override__: ClassVar[str | None] = None
76
+ # Populated by ``__pydantic_init_subclass__`` so hot paths don't reflect
77
+ # over ``model_fields`` on every query.
78
+ __relations__: ClassVar[dict[str, tuple[type, bool, Relation]]] = {}
79
+ __list_adapter__: ClassVar[TypeAdapter | None] = None
80
+
81
+ query: ClassVar[_QueryDescriptor] = _QueryDescriptor()
82
+
83
+ def __init_subclass__(
84
+ cls,
85
+ *,
86
+ table: str | None = None,
87
+ pk: str = "id",
88
+ select: str | None = None,
89
+ **kw: Any,
90
+ ) -> None:
91
+ # Pydantic populates model_fields AFTER this hook runs, so we just
92
+ # capture the table-level kwargs here. ``__pydantic_init_subclass__``
93
+ # below builds the cached metadata once fields are available.
94
+ super().__init_subclass__(**kw)
95
+ if table is None:
96
+ return
97
+ cls.__table__ = table
98
+ cls.__pk__ = pk
99
+ cls.__select_override__ = select
100
+
101
+ @classmethod
102
+ def __pydantic_init_subclass__(cls, **kw: Any) -> None:
103
+ if not cls.__table__:
104
+ return
105
+ cls.__select__ = (
106
+ cls.__select_override__
107
+ if cls.__select_override__ is not None
108
+ else build_select(cls)
109
+ )
110
+ cls.__relations__ = collect_relations(cls)
111
+ cls.__list_adapter__ = TypeAdapter(list[cls])
112
+ _TABLE_REGISTRY.setdefault(cls.__table__, []).append(cls)
113
+
114
+ # ─── Builder entry point ───────────────────────────────────────────────
115
+
116
+ @classmethod
117
+ def _validate_column(cls, name: str) -> None:
118
+ """Surface typos at call time. Allows scalar fields and any
119
+ ``relation.column`` form (PostgREST embed-filter syntax)."""
120
+ head, _, _ = name.partition(".")
121
+ if head in cls.model_fields:
122
+ return
123
+ raise AttributeError(
124
+ f"{cls.__name__} has no column {head!r}. "
125
+ f"Known: {sorted(cls.model_fields)}"
126
+ )
127
+
128
+ # ─── PK shortcuts ──────────────────────────────────────────────────────
129
+
130
+ @classmethod
131
+ async def get(cls, pk_value: Any) -> Self:
132
+ """Fetch by primary key. Raises ``SupabaseORMDoesNotExist`` on miss."""
133
+ row = await cls.query.eq(cls.__pk__, pk_value).maybe_one()
134
+ if row is None:
135
+ raise SupabaseORMDoesNotExist(
136
+ f"{cls.__name__}({cls.__pk__}={pk_value!r}) not found"
137
+ )
138
+ return row
139
+
140
+ @classmethod
141
+ async def find(cls, pk_value: Any) -> Self | None:
142
+ """Fetch by primary key. Returns ``None`` on miss."""
143
+ return await cls.query.eq(cls.__pk__, pk_value).maybe_one()
144
+
145
+ # ─── Writes ────────────────────────────────────────────────────────────
146
+
147
+ @classmethod
148
+ async def create(cls, **values: Any) -> Self:
149
+ """Insert a row in a single round-trip.
150
+
151
+ Uses postgrest's ``insert(...).execute()`` which returns the inserted
152
+ row. For models with relations we still need a second round-trip to
153
+ fetch embeds, since ``insert`` only returns columns from the
154
+ inserted table.
155
+ """
156
+ payload = {k: serialize(v) for k, v in values.items()}
157
+ client = get_client()
158
+ ins = await client.table(cls.__table__).insert(payload).execute()
159
+ if not ins.data:
160
+ raise ValueError(f"{cls.__name__}.create returned no rows")
161
+ if cls.__relations__:
162
+ return await cls.get(ins.data[0][cls.__pk__])
163
+ return cls.model_validate(ins.data[0])
164
+
165
+ @classmethod
166
+ async def bulk_create(cls, rows: list[dict[str, Any]]) -> list[Self]:
167
+ if not rows:
168
+ return []
169
+ payload = [{k: serialize(v) for k, v in r.items()} for r in rows]
170
+ client = get_client()
171
+ ins = await client.table(cls.__table__).insert(payload).execute()
172
+ data = ins.data or []
173
+ if not data:
174
+ return []
175
+ if cls.__relations__:
176
+ ids = [r[cls.__pk__] for r in data]
177
+ return await cls.query.in_(cls.__pk__, ids).all()
178
+ adapter = cls.__list_adapter__
179
+ return (
180
+ adapter.validate_python(data)
181
+ if adapter
182
+ else [cls.model_validate(r) for r in data]
183
+ )
184
+
185
+ async def update(self, **values: Any) -> Self:
186
+ """Assign the given fields and persist in one call.
187
+
188
+ Equivalent to setting each attribute and calling :meth:`save`. Reads
189
+ more naturally for short updates::
190
+
191
+ await user.update(email="new@example.com", name="New Name")
192
+
193
+ Each assignment runs through Pydantic's validator (because
194
+ ``validate_assignment=True`` is set on ``SupabaseModel``), so
195
+ type errors surface before the round-trip. Raises
196
+ ``SupabaseORMUsageError`` if no values are passed.
197
+ """
198
+ if not values:
199
+ raise SupabaseORMUsageError(
200
+ "instance.update() requires at least one key=value."
201
+ )
202
+ cls = type(self)
203
+ for k in values:
204
+ if k == cls.__pk__:
205
+ raise SupabaseORMUsageError(
206
+ f"Cannot update primary key {cls.__pk__!r} via .update()."
207
+ )
208
+ if k in cls.__relations__:
209
+ raise SupabaseORMUsageError(
210
+ f"{k!r} is a relation, not a column on {cls.__table__!r}."
211
+ )
212
+ for k, v in values.items():
213
+ setattr(self, k, v)
214
+ return await self.save()
215
+
216
+ async def save(self) -> Self:
217
+ """Persist dirty fields and refresh local state in one round-trip.
218
+
219
+ For flat models the UPDATE returns the new row directly. For models
220
+ with relations we still need a follow-up GET to populate embeds.
221
+ """
222
+ cls = type(self)
223
+ dirty = self.__pydantic_fields_set__ - {cls.__pk__} - cls.__relations__.keys()
224
+ if not dirty:
225
+ return self
226
+ payload = {f: serialize(getattr(self, f)) for f in dirty}
227
+ client = get_client()
228
+ pk_val = serialize(getattr(self, cls.__pk__))
229
+ resp = await (
230
+ client.table(cls.__table__)
231
+ .update(payload)
232
+ .eq(cls.__pk__, pk_val)
233
+ .execute()
234
+ )
235
+ if not resp.data:
236
+ raise SupabaseORMDoesNotExist(
237
+ f"{cls.__name__}({cls.__pk__}={getattr(self, cls.__pk__)!r}) "
238
+ "not found during save"
239
+ )
240
+ if cls.__relations__:
241
+ fresh = await cls.get(getattr(self, cls.__pk__))
242
+ else:
243
+ fresh = cls.model_validate(resp.data[0])
244
+ self.__dict__.update(fresh.__dict__)
245
+ object.__setattr__(self, "__pydantic_fields_set__", set())
246
+ return self
247
+
248
+ async def delete(self) -> None:
249
+ """Delete this single row by primary key."""
250
+ cls = type(self)
251
+ client = get_client()
252
+ await (
253
+ client.table(cls.__table__)
254
+ .delete()
255
+ .eq(cls.__pk__, serialize(getattr(self, cls.__pk__)))
256
+ .execute()
257
+ )
258
+
259
+ async def refresh(self) -> Self:
260
+ cls = type(self)
261
+ fresh = await cls.get(getattr(self, cls.__pk__))
262
+ self.__dict__.update(fresh.__dict__)
263
+ object.__setattr__(self, "__pydantic_fields_set__", set())
264
+ return self
@@ -0,0 +1,78 @@
1
+ """AsyncClient lifecycle.
2
+
3
+ This module is framework-agnostic. It owns a single ``AsyncClient`` reference
4
+ that models read via ``get_client()``. Wiring into a web framework (FastAPI's
5
+ lifespan, Starlette events, a CLI entrypoint, a test fixture, ...) is the
6
+ caller's job.
7
+
8
+ Typical FastAPI use::
9
+
10
+ from contextlib import asynccontextmanager
11
+ from src.lib import orm
12
+
13
+ @asynccontextmanager
14
+ async def lifespan(app):
15
+ async with orm.lifespan(SUPABASE_URL, SUPABASE_KEY):
16
+ yield
17
+
18
+ app = FastAPI(lifespan=lifespan)
19
+
20
+ Tests / scripts::
21
+
22
+ orm.set_client(await acreate_client(url, key))
23
+ ...
24
+ orm.set_client(None)
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from contextlib import asynccontextmanager
30
+ from typing import AsyncIterator
31
+
32
+ from supabase import AsyncClient, acreate_client
33
+
34
+ from ._exceptions import SupabaseORMUsageError
35
+
36
+ _client: AsyncClient | None = None
37
+
38
+
39
+ def get_client() -> AsyncClient:
40
+ if _client is None:
41
+ raise SupabaseORMUsageError(
42
+ "Supabase AsyncClient not initialized. "
43
+ "Wrap your app startup in `async with orm.lifespan(url, key):` "
44
+ "or call `orm.set_client(client)` directly."
45
+ )
46
+ return _client
47
+
48
+
49
+ def set_client(client: AsyncClient | None) -> None:
50
+ global _client
51
+ _client = client
52
+
53
+
54
+ @asynccontextmanager
55
+ async def lifespan(url: str, key: str) -> AsyncIterator[AsyncClient]:
56
+ """Async context manager that owns one AsyncClient for its lifetime.
57
+
58
+ Yields the client so callers can stash it on app state if they want;
59
+ most code should just import ``get_client`` from inside request handlers.
60
+ """
61
+ client = await acreate_client(url, key)
62
+ set_client(client)
63
+ try:
64
+ yield client
65
+ finally:
66
+ set_client(None)
67
+ # Best-effort teardown of the underlying httpx pools. supabase-py's
68
+ # AsyncClient doesn't expose a top-level close as of 2.x; we close
69
+ # each subclient that does so connection pools drain cleanly on
70
+ # graceful shutdown.
71
+ for sub in ("postgrest", "auth", "storage", "functions"):
72
+ obj = getattr(client, sub, None)
73
+ close = getattr(obj, "aclose", None)
74
+ if close is not None:
75
+ try:
76
+ await close()
77
+ except Exception: # noqa: BLE001 — shutdown is best-effort
78
+ pass