Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions .github/scripts/ci_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
"supabase",
"utils",
]
PACKAGES_WITHOUT_COVERAGE = {"utils"}
IMPORT_NAMES = {
"functions": "supabase_functions",
"realtime": "realtime",
Expand Down Expand Up @@ -101,11 +100,7 @@ def main() -> None:
[line.strip() for line in sys.stdin if line.strip()]
)
entries = matrix_entries(packages)
coverage_flags = [
flag_name(entry)
for entry in matrix_entries(set(PACKAGES))
if entry["package"] not in PACKAGES_WITHOUT_COVERAGE
]
coverage_flags = [flag_name(entry) for entry in matrix_entries(set(PACKAGES))]
print(f"matrix={json.dumps({'include': entries})}")
print(f"has-tests={'true' if entries else 'false'}")
print(f"coverage-flags={','.join(coverage_flags)}")
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,7 @@ jobs:
| xargs docker image save \
| zstd -T0 > /tmp/docker-images/images.tar.zst

# utils has no pytest suite, so its job produces no coverage report.
- name: Upload coverage to Coveralls
if: ${{ matrix.package != 'utils' }}
uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
Expand Down
7 changes: 6 additions & 1 deletion src/utils/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ help::
@echo "Available commands"
@echo " help -- (default) print this message"

tests: pyrefly
tests: pyrefly pytest
help::
@echo " tests -- run all tests for supabase_utils package"

pytest:
uv run --package supabase_utils pytest --cov=./ --cov-report=xml --cov-report=html -vv
help::
@echo " pytest -- run pytest on supabase_utils package"

pyrefly:
uv run --package supabase_utils pyrefly check
help::
Expand Down
163 changes: 163 additions & 0 deletions src/utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# supabase_utils

The shared HTTP layer of the supabase-py packages. `supabase_functions`, `supabase_auth`, `postgrest`, `storage3` and `supabase` all build on it, so each of them can be used with the HTTP client of your choice, synchronously or asynchronously, without depending on that client.

Most users do not use this package directly. Install it with the extra for the HTTP client you want to use next to the package you use:

```bash
pip install supabase_functions "supabase_utils[httpx]"
# or
pip install supabase_functions "supabase_utils[aiohttp]"
```

| Extra | Adapters | Mode |
| --------- | ----------------------------------- | ----------- |
| `httpx` | `HttpxSession`, `AsyncHttpxSession` | sync, async |
| `aiohttp` | `AsyncAiohttpSession` | async |
| `all` | all of the above | sync, async |

## Using an adapter

Wrap a client of the HTTP library in an adapter and hand it to a package. Leaving the adapter's context manager closes the client:

```python
from httpx import AsyncClient, Client
from supabase_functions import AsyncFunctionsClient, SyncFunctionsClient
from supabase_utils.http.adapters.httpx import AsyncHttpxSession, HttpxSession

with HttpxSession(Client()) as session:
functions = SyncFunctionsClient(
"https://<project_ref>.functions.supabase.co",
{"Authorization": "Bearer <key>"},
http_session=session,
)
functions.invoke("hello")

async with AsyncHttpxSession(AsyncClient()) as session:
functions = AsyncFunctionsClient(
"https://<project_ref>.functions.supabase.co",
{"Authorization": "Bearer <key>"},
http_session=session,
)
await functions.invoke("hello")
```

The aiohttp adapter works the same way:

```python
from aiohttp import ClientSession
from supabase_utils.http.adapters.aiohttp import AsyncAiohttpSession

async with AsyncAiohttpSession(ClientSession()) as session:
...
```

## How it works

The packages never call an HTTP library themselves. Each operation is a generator that yields a description of the request it wants to send, receives the `Response`, and returns its result:

```python
from typing import Generic

from supabase_utils.http.headers import Headers
from supabase_utils.http.io import HttpIO, HttpMethod, handle_http_io
from supabase_utils.http.request import EmptyRequest
from yarl import URL


class ItemsClient(Generic[HttpIO]):
def __init__(self, executor: HttpIO, base_url: URL, default_headers: Headers) -> None:
self.executor: HttpIO = executor
self.base_url = base_url
self.default_headers = default_headers

@handle_http_io
def get_item(self, item_id: str) -> HttpMethod[bytes]:
response = yield EmptyRequest(path=["items", item_id], method="GET")
return response.content
```

`handle_http_io` hands the generator to the client's `executor`, which joins each request path to `base_url` and merges `default_headers` into its headers. `SyncHttpIO` runs it with an `HttpSession` and returns the result, `AsyncHttpIO` runs it with an `AsyncHttpSession` and returns an awaitable. The same method therefore backs both the `Sync*` and the `Async*` client of a package.

The main modules are:

| Module | Contents |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `supabase_utils.http.request` | `Request` and `Response`, and the request builders `EmptyRequest`, `BytesRequest`, `JSONRequest`, `TextRequest` and `MultipartFormDataRequest`. |
| `supabase_utils.http.headers` | `Headers`, an immutable, case insensitive multi map of header values. |
| `supabase_utils.http.query` | `URLQuery`, an immutable multi map of query parameters. |
| `supabase_utils.http.io` | The `HttpSession` and `AsyncHttpSession` protocols, the `SyncHttpIO` and `AsyncHttpIO` executors and `handle_http_io`. |
| `supabase_utils.http.adapters` | The adapters for httpx and aiohttp. |

## Writing an adapter

An adapter connects another HTTP library. It implements one of two protocols from `supabase_utils.http.io`:

- `HttpSession`: a `send(request) -> Response` method and the `__enter__` and `__exit__` methods of a context manager.
- `AsyncHttpSession`: an `async send(request) -> Response` method and the `__aenter__` and `__aexit__` methods of an async context manager.

`send` gets a fully built `Request`:

| Field | Type | Meaning |
| --------- | --------------- | --------------------------------------------------------------------------- |
| `url` | `yarl.URL` | The absolute URL, query string included. |
| `method` | `str` | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` or `HEAD`. |
| `headers` | `Headers` | The headers to send. `iter_items()` yields each value of a repeated header. |
| `content` | `bytes \| None` | The body, already encoded, or `None` for no body. |
| `delay` | `float \| None` | Seconds to wait before sending. The executor waits, not the adapter. |

It returns a `Response` with the status code, the body as `bytes`, the response headers and the request it answers. `send` must not raise for error statuses: the packages read `response.status` and raise their own errors. Errors of the transport itself, such as a refused connection, should propagate.

Leaving the context manager should close the underlying client.

A minimal adapter for the `requests` library:

```python
from types import TracebackType

import requests
from supabase_utils.http.headers import Headers
from supabase_utils.http.request import Request, Response


class RequestsSession:
def __init__(self, session: requests.Session) -> None:
self.session = session

def send(self, request: Request) -> Response:
response = self.session.request(
method=request.method,
url=str(request.url),
headers=dict(request.headers),
data=request.content,
)
return Response(
status=response.status_code,
content=response.content,
headers=Headers.from_mapping(response.headers),
request=request,
)

def __enter__(self) -> "RequestsSession":
return self

def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.session.close()
```

`dict(request.headers)` joins the values of a repeated header with `, `. Pass `request.headers.iter_items()` instead if the library accepts a list of pairs, as httpx does.

The adapters in [`src/supabase_utils/http/adapters`](src/supabase_utils/http/adapters) are complete examples.

## Development

From the repository root:

```bash
make utils.tests # pyrefly and pytest
```
18 changes: 15 additions & 3 deletions src/utils/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,28 @@ documentation = "https://github.com/supabase/supabase-py/tree/main/src/utils"
changelog = "https://github.com/supabase/supabase-py/tree/main/CHANGELOG.md"

[dependency-groups]
tests = [
"pytest >=9.0.3",
"pytest-cov >=6.2.1",
"pytest-asyncio >=1.3.0",
]
lints = [
"pyrefly >=1.4.0,<2.0.0",
]
dev = [{ include-group = "lints" }, "supabase_utils[all]"]
dev = [{ include-group = "lints" }, { include-group = "tests" }, "supabase_utils[all]"]

[tool.uv]
default-groups = [ "dev" ]

[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "tests"

[build-system]
requires = ["uv_build>=0.13.7,<0.14.0"]
build-backend = "uv_build"

[tool.pyrefly]
extends = "../../pyrefly.toml"
project-includes = ["src"]
search-path = ["src"]
project-includes = ["src", "tests"]
search-path = ["src", "."]
Empty file added src/utils/tests/__init__.py
Empty file.
99 changes: 99 additions & 0 deletions src/utils/tests/test_aiohttp_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from collections.abc import AsyncIterator

import aiohttp
import pytest
from aiohttp import web
from aiohttp.test_utils import TestServer
from supabase_utils.http.adapters.aiohttp import AsyncAiohttpSession
from supabase_utils.http.headers import Headers
from supabase_utils.http.io import AsyncHttpIO, HttpMethod
from supabase_utils.http.query import URLQuery
from supabase_utils.http.request import EmptyRequest, JSONRequest, Response
from yarl import URL


async def echo(request: web.Request) -> web.Response:
return web.Response(
status=201,
body=await request.read(),
headers={
"X-Method": request.method,
"X-Query": request.query_string,
"X-Apikey": request.headers.get("apikey", ""),
"X-Content-Type": request.headers.get("Content-Type", ""),
"X-Accept": repr(request.headers.getall("Accept", [])),
},
)


async def not_found(request: web.Request) -> web.Response:
return web.Response(status=404, text="missing")


@pytest.fixture
async def server() -> AsyncIterator[TestServer]:
app = web.Application()
app.router.add_route("*", "/api/items", echo)
app.router.add_route("*", "/api/missing", not_found)
test_server = TestServer(app)
await test_server.start_server()
yield test_server
await test_server.close()


def base_url(server: TestServer) -> URL:
return server.make_url("/api")


def create_item() -> HttpMethod[Response]:
return (
yield JSONRequest(
path=["items"],
method="POST",
body={"name": "x"},
query=URLQuery.from_mapping({"select": "id"}),
)
)


def get_missing() -> HttpMethod[Response]:
return (yield EmptyRequest(path=["missing"], method="GET"))


async def test_round_trip(server: TestServer) -> None:
async with AsyncAiohttpSession(aiohttp.ClientSession()) as session:
response = await AsyncHttpIO(session).communicate(
base_url(server), Headers.from_mapping({"apikey": "key"}), create_item()
)
assert session.client.closed
assert response.status == 201
assert response.content == b'{"name":"x"}'
assert response.headers.get("x-method") == "POST"
assert response.headers.get("x-query") == "select=id"
assert response.headers.get("x-apikey") == "key"
assert response.headers.get("x-content-type") == "application/json"
assert str(response.request.url) == str(base_url(server) / "items") + "?select=id"


async def test_error_status_is_returned(server: TestServer) -> None:
async with AsyncAiohttpSession(aiohttp.ClientSession()) as session:
response = await AsyncHttpIO(session).communicate(
base_url(server), Headers.empty(), get_missing()
)
assert response.status == 404
assert response.is_error
assert response.content == b"missing"
assert response.headers.get("content-type") == "text/plain; charset=utf-8"


def get_items_with_repeated_accept() -> HttpMethod[Response]:
headers = Headers.empty().set("Accept", "a").set("Accept", "b")
return (yield EmptyRequest(path=["items"], method="GET", headers=headers))


async def test_repeated_request_headers_are_joined(server: TestServer) -> None:
async with AsyncAiohttpSession(aiohttp.ClientSession()) as session:
response = await AsyncHttpIO(session).communicate(
base_url(server), Headers.empty(), get_items_with_repeated_accept()
)
assert response.headers.get("x-accept") == "['a, b']"
Loading
Loading