Skip to content

chore: include functions-py into monorepo #1204

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 21, 2025
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
3 changes: 2 additions & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"src/supabase": "2.18.1",
"src/realtime": "2.7.0"
"src/realtime": "2.7.0",
"src/functions": "0.10.1"
}
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
.PHONY: ci, default, pre-commit

default:
@echo "Available targets are: ci, pre-commit"
@echo "Available targets are: ci, pre-commit, publish"

ci: pre-commit
make -C src/realtime tests
make -C src/functions tests
make -C src/supabase tests

publish:
uv build --project realtime
uv build --project supabase
uv build --project functions
uv publish

pre-commit:
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
[tool.uv.workspace]
members = [
"src/realtime",
"src/functions",
"src/supabase"
]

[tool.uv.sources]
realtime = { workspace = true }
supabase_functions = { workspace = true }
supabase = { workspace = true }

[tool.pytest.ini_options]
Expand Down
6 changes: 5 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
"changelog-path": "src/realtime/CHANGELOG.md",
"release-type": "python"
},
"src/functions": {
"changelog-path": "src/functions/CHANGELOG.md",
"release-type": "python"
}
"src/supabase": {
"changelog-path": "src/supabase/CHANGELOG.md",
"release-type": "python"
}
},
}
}
574 changes: 574 additions & 0 deletions src/functions/CHANGELOG.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/functions/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
tests: pytest

pytest:
uv run --package supabase_functions pytest --cov=./ --cov-report=xml --cov-report=html -vv

unasync:
uv run --package supabase_functions run-unasync.py

build-sync: unasync
sed -i '0,/SyncMock, /{s/SyncMock, //}' tests/_sync/test_function_client.py
sed -i 's/SyncMock/Mock/g' tests/_sync/test_function_client.py
sed -i 's/SyncClient/Client/g' src/supabase_functions/_sync/functions_client.py tests/_sync/test_function_client.py
22 changes: 22 additions & 0 deletions src/functions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Functions-py


## Installation

`pip3 install supabase_functions`

## Usage

Deploy your function as per documentation.


```python3
import asyncio
from supabase_functions import AsyncFunctionsClient
async def run_func():
fc = AsyncFunctionsClient("https://<project_ref>.functions.supabase.co", {})
res = await fc.invoke("payment-sheet", {"responseType": "json"})

if __name__ == "__main__":
asyncio.run(run_func())
```
51 changes: 51 additions & 0 deletions src/functions/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from typing import Dict, Tuple

import pytest

# store history of failures per test class name and per index
# in parametrize (if parametrize used)
_test_failed_incremental: Dict[str, Dict[Tuple[int, ...], str]] = {}


def pytest_runtest_makereport(item, call):
if "incremental" in item.keywords:
# incremental marker is used
if call.excinfo is not None:
# the test has failed
# retrieve the class name of the test
cls_name = str(item.cls)
# retrieve the index of the test (if parametrize is used
# in combination with incremental)
parametrize_index = (
tuple(item.callspec.indices.values())
if hasattr(item, "callspec")
else ()
)
# retrieve the name of the test function
test_name = item.originalname or item.name
# store in _test_failed_incremental the original name of the failed test
_test_failed_incremental.setdefault(cls_name, {}).setdefault(
parametrize_index, test_name
)


def pytest_runtest_setup(item):
if "incremental" in item.keywords:
# retrieve the class name of the test
cls_name = str(item.cls)
# check if a previous test has failed for this class
if cls_name in _test_failed_incremental:
# retrieve the index of the test (if parametrize is used
# in combination with incremental)
parametrize_index = (
tuple(item.callspec.indices.values())
if hasattr(item, "callspec")
else ()
)
# retrieve the name of the first test function to
# fail for this class name and index
test_name = _test_failed_incremental[cls_name].get(parametrize_index, None)
# if name found, test has failed for the combination of
# class name & test name
if test_name is not None:
pytest.xfail(f"previous test failed ({test_name})")
44 changes: 44 additions & 0 deletions src/functions/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
[project]
name = "supabase_functions"
version = "0.10.1" # {x-release-please-version}
description = "Library for Supabase Functions"
authors = [
{ name = "Joel Lee", email = "[email protected]" },
{ name = "Andrew Smith", email = "[email protected]" },
]
license = "MIT"
readme = "README.md"
repository = "https://github.com/supabase/supabase-py"
requires-python = ">=3.9"
dependencies = [
"httpx[http2] >=0.26,<0.29",
"strenum >=0.4.15",
]

[dependency-groups]
tests = [
"pyjwt >=2.8.0",
"pytest >=7.4.2,<9.0.0",
"pytest-cov >=4,<7",
"pytest-asyncio >=0.21.1,<1.2.0",
]
lints = [
"unasync>=0.6.0",
"ruff >=0.12.1",
"pre-commit >=3.4,<5.0"
]
dev = [{ include-group = "lints" }, {include-group = "tests" }]

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

[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "tests"
filterwarnings = [
"ignore::DeprecationWarning", # ignore deprecation warnings globally
]

[build-system]
requires = ["uv_build>=0.8.3,<0.9.0"]
build-backend = "uv_build"
12 changes: 12 additions & 0 deletions src/functions/run-unasync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import unasync
from pathlib import Path

paths = Path("src/functions").glob("**/*.py")
tests = Path("tests").glob("**/*.py")

rules = (unasync._DEFAULT_RULE,)

files = [str(p) for p in list(paths) + list(tests)]

if __name__ == "__main__":
unasync.unasync_files(files, rules=rules)
39 changes: 39 additions & 0 deletions src/functions/src/supabase_functions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from __future__ import annotations

from typing import Literal, Union, overload

from ._async.functions_client import AsyncFunctionsClient
from ._sync.functions_client import SyncFunctionsClient
from .utils import FunctionRegion

__all__ = [
"create_client",
"FunctionRegion",
"AsyncFunctionsClient",
"SyncFunctionsClient",
]


@overload
def create_client(
url: str, headers: dict[str, str], *, is_async: Literal[True], verify: bool
) -> AsyncFunctionsClient: ...


@overload
def create_client(
url: str, headers: dict[str, str], *, is_async: Literal[False], verify: bool
) -> SyncFunctionsClient: ...


def create_client(
url: str,
headers: dict[str, str],
*,
is_async: bool,
verify: bool = True,
) -> Union[AsyncFunctionsClient, SyncFunctionsClient]:
if is_async:
return AsyncFunctionsClient(url, headers, verify)
else:
return SyncFunctionsClient(url, headers, verify)
1 change: 1 addition & 0 deletions src/functions/src/supabase_functions/_async/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from __future__ import annotations
155 changes: 155 additions & 0 deletions src/functions/src/supabase_functions/_async/functions_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
from typing import Any, Dict, Literal, Optional, Union
from warnings import warn

from httpx import AsyncClient, HTTPError, Response

from ..errors import FunctionsHttpError, FunctionsRelayError
from ..utils import (
FunctionRegion,
is_http_url,
is_valid_str_arg,
)
from ..version import __version__


class AsyncFunctionsClient:
def __init__(
self,
url: str,
headers: Dict,
timeout: Optional[int] = None,
verify: Optional[bool] = None,
proxy: Optional[str] = None,
http_client: Optional[AsyncClient] = None,
):
if not is_http_url(url):
raise ValueError("url must be a valid HTTP URL string")
self.url = url
self.headers = {
"User-Agent": f"supabase-py/functions-py v{__version__}",
**headers,
}

if timeout is not None:
warn(
"The 'timeout' parameter is deprecated. Please configure it in the http client instead.",
DeprecationWarning,
stacklevel=2,
)
if verify is not None:
warn(
"The 'verify' parameter is deprecated. Please configure it in the http client instead.",
DeprecationWarning,
stacklevel=2,
)
if proxy is not None:
warn(
"The 'proxy' parameter is deprecated. Please configure it in the http client instead.",
DeprecationWarning,
stacklevel=2,
)

self.verify = bool(verify) if verify is not None else True
self.timeout = int(abs(timeout)) if timeout is not None else 60

if http_client is not None:
http_client.base_url = self.url
http_client.headers.update({**self.headers})
self._client = http_client
else:
self._client = AsyncClient(
base_url=self.url,
headers=self.headers,
verify=self.verify,
timeout=self.timeout,
proxy=proxy,
follow_redirects=True,
http2=True,
)

async def _request(
self,
method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
url: str,
headers: Optional[Dict[str, str]] = None,
json: Optional[Dict[Any, Any]] = None,
) -> Response:
user_data = {"data": json} if isinstance(json, str) else {"json": json}
response = await self._client.request(method, url, **user_data, headers=headers)

try:
response.raise_for_status()
except HTTPError as exc:
status_code = None
if hasattr(response, "status_code"):
status_code = response.status_code

raise FunctionsHttpError(
response.json().get("error")
or f"An error occurred while requesting your edge function at {exc.request.url!r}.",
status_code,
) from exc

return response

def set_auth(self, token: str) -> None:
"""Updates the authorization header

Parameters
----------
token : str
the new jwt token sent in the authorization header
"""

self.headers["Authorization"] = f"Bearer {token}"

async def invoke(
self, function_name: str, invoke_options: Optional[Dict] = None
) -> Union[Dict, bytes]:
"""Invokes a function

Parameters
----------
function_name : the name of the function to invoke
invoke_options : object with the following properties
`headers`: object representing the headers to send with the request
`body`: the body of the request
`responseType`: how the response should be parsed. The default is `json`
"""
if not is_valid_str_arg(function_name):
raise ValueError("function_name must a valid string value.")
headers = self.headers
body = None
response_type = "text/plain"
if invoke_options is not None:
headers.update(invoke_options.get("headers", {}))
response_type = invoke_options.get("responseType", "text/plain")

region = invoke_options.get("region")
if region:
if not isinstance(region, FunctionRegion):
warn(f"Use FunctionRegion({region})")
region = FunctionRegion(region)

if region.value != "any":
headers["x-region"] = region.value

body = invoke_options.get("body")
if isinstance(body, str):
headers["Content-Type"] = "text/plain"
elif isinstance(body, dict):
headers["Content-Type"] = "application/json"

response = await self._request(
"POST", f"{self.url}/{function_name}", headers=headers, json=body
)
is_relay_error = response.headers.get("x-relay-header")

if is_relay_error and is_relay_error == "true":
raise FunctionsRelayError(response.json().get("error"))

if response_type == "json":
data = response.json()
else:
data = response.content
return data
Loading