supabase-orm 0.1.2__tar.gz → 0.1.3__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.
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/PKG-INFO +2 -2
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/README.md +1 -1
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_client.py +35 -16
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_version.py +2 -2
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_client.py +60 -6
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/.env.example +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/.github/workflows/publish.yml +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/.gitignore +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/LICENSE +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/pyproject.toml +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/__init__.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_base.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_embed.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_exceptions.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_filters.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_predicates.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_query.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_rpc.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_serializers.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/__init__.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/conftest.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/README.md +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/__init__.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/conftest.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/schema.sql +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_embeds.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_filters.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_iter.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_predicates.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_rpc.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_writes_and_terminals.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_base.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_embed.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_exceptions.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_filters.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_iter.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_predicates.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_query.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_rpc.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_serializers.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_wire.py +0 -0
- {supabase_orm-0.1.2 → supabase_orm-0.1.3}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supabase-orm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Lightweight async ORM on top of supabase-py with Pydantic validation.
|
|
5
5
|
Project-URL: Homepage, https://github.com/viperadnan-git/supabase-orm
|
|
6
6
|
Project-URL: Repository, https://github.com/viperadnan-git/supabase-orm
|
|
@@ -502,7 +502,7 @@ app = FastAPI(lifespan=lifespan)
|
|
|
502
502
|
|
|
503
503
|
### Per-request RLS via JWT
|
|
504
504
|
|
|
505
|
-
The client is
|
|
505
|
+
The default client is a module-level reference set by `lifespan()` (visible to every task — required because ASGI servers spawn each request handler as a sibling task of the lifespan task, not a child). Per-request overrides go through `use_client()`, which uses a `ContextVar` so concurrent requests don't see each other's clients. Pair the two for safe per-request RLS:
|
|
506
506
|
|
|
507
507
|
```python
|
|
508
508
|
from supabase import acreate_client
|
|
@@ -468,7 +468,7 @@ app = FastAPI(lifespan=lifespan)
|
|
|
468
468
|
|
|
469
469
|
### Per-request RLS via JWT
|
|
470
470
|
|
|
471
|
-
The client is
|
|
471
|
+
The default client is a module-level reference set by `lifespan()` (visible to every task — required because ASGI servers spawn each request handler as a sibling task of the lifespan task, not a child). Per-request overrides go through `use_client()`, which uses a `ContextVar` so concurrent requests don't see each other's clients. Pair the two for safe per-request RLS:
|
|
472
472
|
|
|
473
473
|
```python
|
|
474
474
|
from supabase import acreate_client
|
|
@@ -66,13 +66,29 @@ from ._exceptions import SupabaseORMUsageError
|
|
|
66
66
|
|
|
67
67
|
_log = logging.getLogger("supabase_orm")
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
# Two-level lookup:
|
|
70
|
+
#
|
|
71
|
+
# * ``_default_client`` is a module global, set by ``set_client()`` /
|
|
72
|
+
# ``lifespan()``. Visible to every task on the loop — the right
|
|
73
|
+
# semantics for "app-wide default set at startup," which under ASGI
|
|
74
|
+
# servers (uvicorn/hypercorn) means a *different* task than the one
|
|
75
|
+
# handling each request. ContextVar inheritance only works for child
|
|
76
|
+
# tasks of the setter; ASGI request handlers are siblings, not
|
|
77
|
+
# children, so a ContextVar-only design silently sees ``None``.
|
|
78
|
+
#
|
|
79
|
+
# * ``_client_override`` is a ``ContextVar``, set by ``use_client()``.
|
|
80
|
+
# Per-task isolated for safe per-request RLS overrides without leaking
|
|
81
|
+
# across concurrent requests.
|
|
82
|
+
#
|
|
83
|
+
# ``get_client()`` checks the override first, falls back to the default.
|
|
84
|
+
_default_client: AsyncClient | None = None
|
|
85
|
+
_client_override: ContextVar[AsyncClient | None] = ContextVar(
|
|
86
|
+
"supabase_orm_client_override", default=None
|
|
71
87
|
)
|
|
72
88
|
|
|
73
89
|
|
|
74
90
|
def get_client() -> AsyncClient:
|
|
75
|
-
c =
|
|
91
|
+
c = _client_override.get() or _default_client
|
|
76
92
|
if c is None:
|
|
77
93
|
raise SupabaseORMUsageError(
|
|
78
94
|
"Supabase AsyncClient not initialized. "
|
|
@@ -83,13 +99,14 @@ def get_client() -> AsyncClient:
|
|
|
83
99
|
|
|
84
100
|
|
|
85
101
|
def set_client(client: AsyncClient | None) -> None:
|
|
86
|
-
"""Bind ``client``
|
|
102
|
+
"""Bind ``client`` as the app-wide default.
|
|
87
103
|
|
|
88
|
-
Call from app startup (or use :func:`lifespan`)
|
|
89
|
-
|
|
90
|
-
:func:`use_client`
|
|
104
|
+
Visible to every task. Call from app startup (or use :func:`lifespan`).
|
|
105
|
+
For per-request overrides that must not leak across concurrent requests,
|
|
106
|
+
use :func:`use_client` instead.
|
|
91
107
|
"""
|
|
92
|
-
|
|
108
|
+
global _default_client
|
|
109
|
+
_default_client = client
|
|
93
110
|
|
|
94
111
|
|
|
95
112
|
@asynccontextmanager
|
|
@@ -97,32 +114,34 @@ async def use_client(client: AsyncClient) -> AsyncIterator[AsyncClient]:
|
|
|
97
114
|
"""Bind ``client`` for the duration of the ``async with`` block only.
|
|
98
115
|
|
|
99
116
|
Restores the previous binding on exit. Safe under concurrent FastAPI
|
|
100
|
-
requests: each request runs in its own
|
|
101
|
-
override never leaks across requests::
|
|
117
|
+
requests: each request runs in its own task with its own ContextVar
|
|
118
|
+
snapshot, so the override never leaks across requests::
|
|
102
119
|
|
|
103
120
|
async with use_client(per_request_client):
|
|
104
121
|
row = await Pet.get(some_id)
|
|
105
122
|
"""
|
|
106
|
-
token =
|
|
123
|
+
token = _client_override.set(client)
|
|
107
124
|
try:
|
|
108
125
|
yield client
|
|
109
126
|
finally:
|
|
110
|
-
|
|
127
|
+
_client_override.reset(token)
|
|
111
128
|
|
|
112
129
|
|
|
113
130
|
@asynccontextmanager
|
|
114
131
|
async def lifespan(url: str, key: str) -> AsyncIterator[AsyncClient]:
|
|
115
132
|
"""Async context manager that owns one AsyncClient for its lifetime.
|
|
116
133
|
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
Sets the client as the app-wide default via :func:`set_client`, so
|
|
135
|
+
request handlers spawned in sibling tasks (the standard ASGI shape)
|
|
136
|
+
can find it. Yields the client so callers can stash it on app state
|
|
137
|
+
if they want.
|
|
119
138
|
"""
|
|
120
139
|
client = await acreate_client(url, key)
|
|
121
|
-
|
|
140
|
+
set_client(client)
|
|
122
141
|
try:
|
|
123
142
|
yield client
|
|
124
143
|
finally:
|
|
125
|
-
|
|
144
|
+
set_client(None)
|
|
126
145
|
# Best-effort teardown of the underlying httpx pools. supabase-py's
|
|
127
146
|
# AsyncClient doesn't expose a top-level close as of 2.x; we close
|
|
128
147
|
# each subclient that does so connection pools drain cleanly on
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -12,7 +12,7 @@ from supabase_orm import (
|
|
|
12
12
|
set_client,
|
|
13
13
|
use_client,
|
|
14
14
|
)
|
|
15
|
-
from supabase_orm._client import
|
|
15
|
+
from supabase_orm._client import _client_override
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def test_get_client_without_init_raises():
|
|
@@ -87,13 +87,67 @@ async def test_use_client_restores_even_on_exception():
|
|
|
87
87
|
# ─── ContextVar semantics ────────────────────────────────────────────────
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def
|
|
91
|
-
"""Sanity check: the
|
|
92
|
-
|
|
93
|
-
relies on."""
|
|
90
|
+
def test_override_is_a_contextvar():
|
|
91
|
+
"""Sanity check: the per-request override backend is a ContextVar so
|
|
92
|
+
use_client() doesn't leak across concurrent requests."""
|
|
94
93
|
from contextvars import ContextVar
|
|
95
94
|
|
|
96
|
-
assert isinstance(
|
|
95
|
+
assert isinstance(_client_override, ContextVar)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ─── Regression: lifespan task vs request task (the FastAPI bug) ─────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_set_client_visible_across_independent_tasks():
|
|
102
|
+
"""Reproduces the bug a ContextVar-only design has under ASGI servers.
|
|
103
|
+
|
|
104
|
+
Uvicorn runs ``lifespan`` in one task and each request handler in a
|
|
105
|
+
separate, sibling task. ``ContextVar.set()`` only mutates the
|
|
106
|
+
setter's own context; sibling tasks (request handlers) inherit
|
|
107
|
+
uvicorn's root context, where the var is still its default.
|
|
108
|
+
|
|
109
|
+
set_client() must therefore propagate to ALL tasks — not just
|
|
110
|
+
descendants of the setter — by storing in a module global. This test
|
|
111
|
+
spawns the setter as a finished sibling task, then a separate reader
|
|
112
|
+
task; the reader must see what the setter wrote."""
|
|
113
|
+
sentinel = object()
|
|
114
|
+
|
|
115
|
+
async def setter() -> None:
|
|
116
|
+
set_client(sentinel) # type: ignore[arg-type]
|
|
117
|
+
|
|
118
|
+
async def reader():
|
|
119
|
+
return get_client()
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
await asyncio.create_task(setter()) # completes before we read
|
|
123
|
+
result = await asyncio.create_task(reader())
|
|
124
|
+
assert result is sentinel
|
|
125
|
+
finally:
|
|
126
|
+
set_client(None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def test_use_client_override_still_isolated_per_task():
|
|
130
|
+
"""The default is a global, but per-request use_client() overrides
|
|
131
|
+
must STILL be ContextVar-isolated — otherwise concurrent requests
|
|
132
|
+
overwrite each other's RLS-scoped client."""
|
|
133
|
+
default = object()
|
|
134
|
+
set_client(default) # type: ignore[arg-type]
|
|
135
|
+
|
|
136
|
+
async def request(label: str) -> tuple[object, object]:
|
|
137
|
+
per_request = f"client-{label}"
|
|
138
|
+
async with use_client(per_request): # type: ignore[arg-type]
|
|
139
|
+
await asyncio.sleep(0) # let other tasks run between set & read
|
|
140
|
+
return get_client(), per_request # type: ignore[return-value]
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
results = await asyncio.gather(request("a"), request("b"), request("c"))
|
|
144
|
+
# Each task saw its own override, not anyone else's.
|
|
145
|
+
for got, expected in results:
|
|
146
|
+
assert got == expected
|
|
147
|
+
# After all overrides exit, default is back.
|
|
148
|
+
assert get_client() is default
|
|
149
|
+
finally:
|
|
150
|
+
set_client(None)
|
|
97
151
|
|
|
98
152
|
|
|
99
153
|
# ─── QueryBuilder resolves client at terminal time, not chain start ──────
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|