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.
Files changed (42) hide show
  1. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/PKG-INFO +2 -2
  2. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/README.md +1 -1
  3. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_client.py +35 -16
  4. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_version.py +2 -2
  5. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_client.py +60 -6
  6. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/.env.example +0 -0
  7. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/.github/workflows/publish.yml +0 -0
  8. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/.gitignore +0 -0
  9. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/LICENSE +0 -0
  10. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/pyproject.toml +0 -0
  11. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/__init__.py +0 -0
  12. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_base.py +0 -0
  13. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_embed.py +0 -0
  14. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_exceptions.py +0 -0
  15. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_filters.py +0 -0
  16. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_predicates.py +0 -0
  17. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_query.py +0 -0
  18. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_rpc.py +0 -0
  19. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/src/supabase_orm/_serializers.py +0 -0
  20. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/__init__.py +0 -0
  21. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/conftest.py +0 -0
  22. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/README.md +0 -0
  23. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/__init__.py +0 -0
  24. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/conftest.py +0 -0
  25. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/schema.sql +0 -0
  26. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_embeds.py +0 -0
  27. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_filters.py +0 -0
  28. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_iter.py +0 -0
  29. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_predicates.py +0 -0
  30. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_rpc.py +0 -0
  31. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/integration/test_writes_and_terminals.py +0 -0
  32. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_base.py +0 -0
  33. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_embed.py +0 -0
  34. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_exceptions.py +0 -0
  35. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_filters.py +0 -0
  36. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_iter.py +0 -0
  37. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_predicates.py +0 -0
  38. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_query.py +0 -0
  39. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_rpc.py +0 -0
  40. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_serializers.py +0 -0
  41. {supabase_orm-0.1.2 → supabase_orm-0.1.3}/tests/test_wire.py +0 -0
  42. {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.2
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 stored in a `ContextVar`, so each FastAPI request runs in its own copied context. Pair `use_client()` with a per-request authenticated client to isolate the JWT and therefore the RLS identity to that request only:
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 stored in a `ContextVar`, so each FastAPI request runs in its own copied context. Pair `use_client()` with a per-request authenticated client to isolate the JWT and therefore the RLS identity to that request only:
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
- _client: ContextVar[AsyncClient | None] = ContextVar(
70
- "supabase_orm_client", default=None
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 = _client.get()
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`` in the current async context.
102
+ """Bind ``client`` as the app-wide default.
87
103
 
88
- Call from app startup (or use :func:`lifespan`) to set the
89
- app-wide default; per-request overrides should prefer
90
- :func:`use_client` so the previous binding is restored on exit.
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
- _client.set(client)
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 copied context, so the
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 = _client.set(client)
123
+ token = _client_override.set(client)
107
124
  try:
108
125
  yield client
109
126
  finally:
110
- _client.reset(token)
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
- Yields the client so callers can stash it on app state if they want;
118
- most code should just import ``get_client`` from inside request handlers.
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
- token = _client.set(client)
140
+ set_client(client)
122
141
  try:
123
142
  yield client
124
143
  finally:
125
- _client.reset(token)
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.2'
22
- __version_tuple__ = version_tuple = (0, 1, 2)
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 _client
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 test_client_is_a_contextvar():
91
- """Sanity check: the storage backend is a ContextVar (not a module
92
- global). Documents the design assumption that per-request isolation
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(_client, ContextVar)
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