diff --git a/Makefile b/Makefile index 0ad307af..178db11b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: ci, default, pre-commit, clean, start-infra, stop-infra +.PHONY: ci, default, clean, start-infra, stop-infra PACKAGES := functions realtime storage auth postgrest supabase FORALL_PKGS = $(foreach pkg, $(PACKAGES), $(pkg).$(1)) diff --git a/README.md b/README.md index 2920be8b..028fea20 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ cd supabase-py This repository relies on the following dependencies for development: - `uv` for python project management. -- `make` for command running. +- `make` for running project commands. - `docker` for both `postgrest` and `auth` test containers. - `supabase-cli` for both `storage` and `realtime` test containers. @@ -41,7 +41,7 @@ All of these dependencies are included in the nix shell environment, through `fl ### Use a Virtual Environment -We recommend using a virtual environment, preferrably through `uv`, given it is currently the only tool that understands the workspace setup (you can read more about it in [the uv docs](https://docs.astral.sh/uv/concepts/projects/workspaces/)). +We recommend using a virtual environment, preferably through `uv`, given it is currently the only tool that understands the workspace setup (you can read more about it in [the uv docs](https://docs.astral.sh/uv/concepts/projects/workspaces/)). ``` uv venv supabase-py @@ -53,7 +53,7 @@ If you're using nix, the generated `python` executable should have the correct d ### Running tests and other commands -We use `make` to store and run the relevant commands. The structure is setup such that each sub package can individually set its command in its own `Makefile`, and the job of the main `Makefile` is just coordinate calling each of them. +We use `make` to store and run the relevant commands. The structure is set up such that each sub package can individually set its command in its own `Makefile`, and the job of the main `Makefile` is just coordinate calling each of them. For instance, in order to run all tests of all packages, you should use the following root command ```bash @@ -70,11 +70,11 @@ To run each of the packages' tests in parallel. This should be generally faster Other relevant commands include ```bash make install-hooks # install all commit hooks into the local .git folder -make stop-infra # stops all running containers from all packages -make clean # delete all intermediary files created by testing +make stop-infra # stops all running containers from all packages +make clean # delete all intermediary files created by testing ``` -All the sub packages command are available from the main root by prefixing the command with `{package_name}.`. Examples: +All the subpackages command are available from the main root by prefixing the command with `{package_name}.`. Examples: ```bash make realtime.tests # run only realtime tests -make storage.clean # delete temporary files only in the storage package +make storage.clean # delete temporary files only in the storage package ``` diff --git a/src/functions/pyproject.toml b/src/functions/pyproject.toml index 3cd6d367..67b63151 100644 --- a/src/functions/pyproject.toml +++ b/src/functions/pyproject.toml @@ -32,7 +32,6 @@ tests = [ lints = [ "unasync>=0.6.0", "ruff >=0.12.1", - "pre-commit >=3.4,<5.0", "python-lsp-server (>=1.12.2,<2.0.0)", "pylsp-mypy (>=0.7.0,<0.8.0)", "python-lsp-ruff (>=2.2.2,<3.0.0)", diff --git a/src/postgrest/pyproject.toml b/src/postgrest/pyproject.toml index a67a4cd2..235d5e33 100644 --- a/src/postgrest/pyproject.toml +++ b/src/postgrest/pyproject.toml @@ -41,7 +41,6 @@ test = [ "unasync >= 0.6.0", ] lints = [ - "pre-commit >=4.2.0", "ruff >=0.12.1", "python-lsp-server (>=1.12.2,<2.0.0)", "pylsp-mypy (>=0.7.0,<0.8.0)", diff --git a/src/realtime/Makefile b/src/realtime/Makefile index 8e11fc75..b2c3f288 100644 --- a/src/realtime/Makefile +++ b/src/realtime/Makefile @@ -1,22 +1,38 @@ -.PHONY: pytest pre-commit mypy tests infra stop-infra +help:: + @echo "Available commands" + @echo " help -- (default) print this message" + +tests: mypy pytest +help:: + @echo " tests -- run all tests for realtime package" mypy: - uv run --package realtime mypy src/realtime + uv run --package realtime mypy src/realtime tests +help:: + @echo " pytest -- run mypy on realtime package" + +pytest: start-infra + uv run --package realtime pytest --cov=realtime --cov-report=xml --cov-report=html -vv +help:: + @echo " pytest -- run pytest on realtime package" start-infra: supabase start --workdir infra -x studio,mailpit,edge-runtime,logflare,vector,supavisor,imgproxy,storage-api +help:: + @echo " stop-infra -- start containers for tests" stop-infra: supabase --workdir infra stop - -tests: mypy pytest - -pytest: start-infra - uv run --package realtime pytest --cov=realtime --cov-report=xml --cov-report=html -vv +help:: + @echo " stop-infra -- stop containers for tests" clean: rm -rf htmlcov .pytest_cache .mypy_cache .ruff_cache rm -f .coverage coverage.xml +help:: + @echo " clean -- clean intermediary files generated by tests" build: uv build --package realtime +help:: + @echo " build -- invoke uv build on realtime package" diff --git a/src/realtime/pyproject.toml b/src/realtime/pyproject.toml index 768f15cc..9b00a169 100644 --- a/src/realtime/pyproject.toml +++ b/src/realtime/pyproject.toml @@ -33,7 +33,6 @@ tests = [ "pytest-asyncio >= 1.0.0", ] lints = [ - "pre-commit >= 4.2.0", "ruff >= 0.12.1", "python-lsp-server (>=1.12.2,<2.0.0)", "pylsp-mypy (>=0.7.0,<0.8.0)", diff --git a/src/realtime/src/realtime/_async/timer.py b/src/realtime/src/realtime/_async/timer.py index bd18ca0b..eeee4b01 100644 --- a/src/realtime/src/realtime/_async/timer.py +++ b/src/realtime/src/realtime/_async/timer.py @@ -6,7 +6,7 @@ class AsyncTimer: - def __init__(self, callback: Callable, timer_calc: Callable[[int], int]): + def __init__(self, callback: Callable, timer_calc: Callable[[int], float]): self.callback = callback self.timer_calc = timer_calc self.timer: Optional[asyncio.Task] = None diff --git a/src/realtime/src/realtime/py.typed b/src/realtime/src/realtime/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/realtime/tests/test_connection.py b/src/realtime/tests/test_connection.py index 77560983..ed9ab733 100644 --- a/src/realtime/tests/test_connection.py +++ b/src/realtime/tests/test_connection.py @@ -5,8 +5,14 @@ import aiohttp import pytest from dotenv import load_dotenv +from pydantic import BaseModel -from realtime import AsyncRealtimeChannel, AsyncRealtimeClient, RealtimeSubscribeStates +from realtime import ( + AsyncRealtimeChannel, + AsyncRealtimeClient, + RealtimePostgresChangesListenEvent, + RealtimeSubscribeStates, +) from realtime.message import Message from realtime.types import DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_TIMEOUT, ChannelEvents @@ -27,6 +33,14 @@ def socket() -> AsyncRealtimeClient: return AsyncRealtimeClient(url, key) +class SignupMessageResponse(BaseModel): + access_token: str + token_type: str + expires_in: int + expires_at: int + refresh_token: str + + async def access_token() -> str: url = f"{URL}/auth/v1/signup" headers = {"apikey": ANON_KEY, "Content-Type": "application/json"} @@ -39,8 +53,11 @@ async def access_token() -> str: async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=data) as response: if response.status == 200: - json_response = await response.json() - return json_response.get("access_token") + response_content = await response.read() + signup_response = SignupMessageResponse.model_validate_json( + response_content + ) + return signup_response.access_token else: raise Exception( f"Failed to get access token. Status: {response.status}" @@ -67,14 +84,20 @@ async def test_broadcast_events(socket: AsyncRealtimeClient): await socket.connect() channel = socket.channel( - "test-broadcast", params={"config": {"broadcast": {"self": True}}} + "test-broadcast", + params={ + "config": { + "broadcast": {"self": True, "ack": True}, + "presence": {"enabled": True, "key": ""}, + "private": False, + } + }, ) received_events = [] semaphore = asyncio.Semaphore(0) def broadcast_callback(payload): - print("broadcast: ", payload) received_events.append(payload) semaphore.release() @@ -111,30 +134,31 @@ async def test_postgrest_changes(socket: AsyncRealtimeClient): await socket.set_auth(token) channel: AsyncRealtimeChannel = socket.channel("test-postgres-changes") - received_events = {"all": [], "insert": [], "update": [], "delete": []} + received_events: dict[str, list[dict]] = { + "all": [], + "insert": [], + "update": [], + "delete": [], + } def all_changes_callback(payload): - print("all_changes_callback: ", payload) received_events["all"].append(payload) insert_event = asyncio.Event() def insert_callback(payload): - print("insert_callback: ", payload) received_events["insert"].append(payload) insert_event.set() update_event = asyncio.Event() def update_callback(payload): - print("update_callback: ", payload) received_events["update"].append(payload) update_event.set() delete_event = asyncio.Event() def delete_callback(payload): - print("delete_callback: ", payload) received_events["delete"].append(payload) delete_event.set() @@ -142,10 +166,18 @@ def delete_callback(payload): system_event = asyncio.Event() await ( - channel.on_postgres_changes("*", all_changes_callback, table="todos") - .on_postgres_changes("INSERT", insert_callback, table="todos") - .on_postgres_changes("UPDATE", update_callback, table="todos") - .on_postgres_changes("DELETE", delete_callback, table="todos") + channel.on_postgres_changes( + RealtimePostgresChangesListenEvent.All, all_changes_callback, table="todos" + ) + .on_postgres_changes( + RealtimePostgresChangesListenEvent.Insert, insert_callback, table="todos" + ) + .on_postgres_changes( + RealtimePostgresChangesListenEvent.Update, update_callback, table="todos" + ) + .on_postgres_changes( + RealtimePostgresChangesListenEvent.Delete, delete_callback, table="todos" + ) .on_system(lambda _: system_event.set()) .subscribe( lambda state, _: ( @@ -206,16 +238,14 @@ async def test_postgrest_changes_on_different_tables(socket: AsyncRealtimeClient await socket.set_auth(token) channel: AsyncRealtimeChannel = socket.channel("test-postgres-changes") - received_events = {"all": [], "insert": []} + received_events: dict[str, list[dict]] = {"all": [], "insert": []} def all_changes_callback(payload): - print("all_changes_callback: ", payload) received_events["all"].append(payload) insert_event = asyncio.Event() def insert_callback(payload): - print("insert_callback: ", payload) received_events["insert"].append(payload) insert_event.set() @@ -223,9 +253,15 @@ def insert_callback(payload): system_event = asyncio.Event() await ( - channel.on_postgres_changes("*", all_changes_callback, table="todos") - .on_postgres_changes("INSERT", insert_callback, table="todos") - .on_postgres_changes("INSERT", insert_callback, table="messages") + channel.on_postgres_changes( + RealtimePostgresChangesListenEvent.All, all_changes_callback, table="todos" + ) + .on_postgres_changes( + RealtimePostgresChangesListenEvent.Insert, insert_callback, table="todos" + ) + .on_postgres_changes( + RealtimePostgresChangesListenEvent.Insert, insert_callback, table="messages" + ) .on_system(lambda _: system_event.set()) .subscribe( lambda state, _: ( @@ -273,6 +309,10 @@ def insert_callback(payload): await socket.close() +class CreateTodoResponse(BaseModel): + id: str + + async def create_todo(access_token: str, todo: dict) -> str: url = f"{URL}/rest/v1/todos?select=id" headers = { @@ -286,8 +326,11 @@ async def create_todo(access_token: str, todo: dict) -> str: async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=todo) as response: if response.status == 201: - json_response = await response.json() - return json_response.get("id") + response_content = await response.read() + create_todo_response = CreateTodoResponse.model_validate_json( + response_content + ) + return create_todo_response.id else: raise Exception(f"Failed to create todo. Status: {response.status}") @@ -306,7 +349,11 @@ async def update_todo(access_token: str, id: str, todo: dict): raise Exception(f"Failed to update todo. Status: {response.status}") -async def create_message(access_token: str, message: dict) -> str: +class CreateMsgResponse(BaseModel): + id: int + + +async def create_message(access_token: str, message: dict) -> int: url = f"{URL}/rest/v1/messages?select=id" headers = { "apikey": ANON_KEY, @@ -319,8 +366,11 @@ async def create_message(access_token: str, message: dict) -> str: async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=message) as response: if response.status == 201: - json_response = await response.json() - return json_response.get("id") + response_content = await response.read() + create_msg_response = CreateMsgResponse.model_validate_json( + response_content + ) + return create_msg_response.id else: raise Exception(f"Failed to create message. Status: {response.status}") diff --git a/src/realtime/tests/test_presence.py b/src/realtime/tests/test_presence.py index c0e8c62b..96ba5a2b 100644 --- a/src/realtime/tests/test_presence.py +++ b/src/realtime/tests/test_presence.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from realtime import AsyncRealtimeChannel, AsyncRealtimeClient, AsyncRealtimePresence -from realtime.types import RawPresenceState +from realtime.types import ChannelStates, Presence, RawPresenceState load_dotenv() @@ -30,18 +30,20 @@ async def test_presence(socket: AsyncRealtimeClient): channel: AsyncRealtimeChannel = socket.channel("room") - join_events: List[Tuple[str, List[Dict], List[Dict]]] = [] - leave_events: List[Tuple[str, List[Dict], List[Dict]]] = [] + join_events: List[Tuple[str, List[Dict], List[Presence]]] = [] + leave_events: List[Tuple[str, List[Dict], List[Presence]]] = [] sync_event = asyncio.Event() def on_sync(): sync_event.set() - def on_join(key: str, current_presences: List[Dict], new_presences: List[Dict]): + def on_join(key: str, current_presences: List[Dict], new_presences: List[Presence]): join_events.append((key, current_presences, new_presences)) - def on_leave(key: str, current_presences: List[Dict], left_presences: List[Dict]): + def on_leave( + key: str, current_presences: List[Dict], left_presences: List[Presence] + ): leave_events.append((key, current_presences, left_presences)) await ( @@ -67,14 +69,14 @@ def on_leave(key: str, current_presences: List[Dict], left_presences: List[Dict] assert len(presences) == 1 assert len(presences[0][1]) == 1 - assert presences[0][1][0]["user_id"] == user1["user_id"] - assert presences[0][1][0]["online_at"] == user1["online_at"] + assert presences[0][1][0]["user_id"] == user1["user_id"] # type: ignore + assert presences[0][1][0]["online_at"] == user1["online_at"] # type: ignore assert "presence_ref" in presences[0][1][0] assert len(join_events) == 1 assert len(join_events[0][2]) == 1 - assert join_events[0][2][0]["user_id"] == user1["user_id"] - assert join_events[0][2][0]["online_at"] == user1["online_at"] + assert join_events[0][2][0]["user_id"] == user1["user_id"] # type: ignore + assert join_events[0][2][0]["online_at"] == user1["online_at"] # type: ignore assert "presence_ref" in join_events[0][2][0] # Track second user @@ -85,16 +87,15 @@ def on_leave(key: str, current_presences: List[Dict], left_presences: List[Dict] sync_event.clear() # Assert both users are in the presence state - presences = channel.presence.state - for key, value in presences.items(): + for key, value in channel.presence.state.items(): assert len(value) == 1 - assert value[0]["user_id"] in ["1", "2"] + assert value[0]["user_id"] in ["1", "2"] # type: ignore assert "online_at" in value[0] assert "presence_ref" in value[0] assert len(join_events) == 2 assert len(join_events[1][2]) == 1 - assert join_events[1][2][0]["user_id"] == user2["user_id"] - assert join_events[1][2][0]["online_at"] == user2["online_at"] + assert join_events[1][2][0]["user_id"] == user2["user_id"] # type: ignore + assert join_events[1][2][0]["online_at"] == user2["online_at"] # type: ignore assert "presence_ref" in join_events[1][2][0] # Untrack all users @@ -110,12 +111,12 @@ def on_leave(key: str, current_presences: List[Dict], left_presences: List[Dict] await socket.close() -def test_transform_state_raw_presence_state(): +def test_transform_state_raw_presence_state() -> None: raw_state: RawPresenceState = { "user1": { "metas": [ - {"phx_ref": "ABC123", "user_id": "user1", "status": "online"}, - { + {"phx_ref": "ABC123", "user_id": "user1", "status": "online"}, # type: ignore + { # type: ignore "phx_ref": "DEF456", "phx_ref_prev": "ABC123", "user_id": "user1", @@ -124,7 +125,7 @@ def test_transform_state_raw_presence_state(): ] }, "user2": { - "metas": [{"phx_ref": "GHI789", "user_id": "user2", "status": "offline"}] + "metas": [{"phx_ref": "GHI789", "user_id": "user2", "status": "offline"}] # type: ignore }, } @@ -140,17 +141,17 @@ def test_transform_state_raw_presence_state(): assert result == expected_output -def test_transform_state_empty_input(): - empty_state = {} +def test_transform_state_empty_input() -> None: + empty_state: RawPresenceState = {} result = AsyncRealtimePresence._transform_state(empty_state) assert result == {} -def test_transform_state_additional_fields(): - state_with_additional_fields = { +def test_transform_state_additional_fields() -> None: + state_with_additional_fields: RawPresenceState = { "user1": { "metas": [ - { + { # type: ignore "phx_ref": "ABC123", "user_id": "user1", "status": "online", @@ -197,7 +198,7 @@ def test_presence_has_callback_attached(): assert presence._has_callback_attached -def test_presence_config_includes_enabled_field(): +def test_presence_config_includes_enabled_field() -> None: """Test that presence config correctly includes enabled flag.""" from realtime.types import RealtimeChannelPresenceConfig @@ -213,7 +214,7 @@ def test_presence_config_includes_enabled_field(): @pytest.mark.asyncio -async def test_presence_enabled_when_callbacks_attached(): +async def test_presence_enabled_when_callbacks_attached() -> None: """Test that presence.enabled is set correctly based on callback attachment.""" from unittest.mock import AsyncMock, Mock @@ -230,7 +231,7 @@ async def test_presence_enabled_when_callbacks_attached(): # Mock socket connection by setting _ws_connection mock_ws = Mock() socket._ws_connection = mock_ws - socket._leave_open_topic = AsyncMock() + socket._leave_open_topic = AsyncMock() # type: ignore # Add presence callback before subscription channel.on_presence_sync(lambda: None) @@ -249,7 +250,7 @@ async def test_presence_enabled_when_callbacks_attached(): @pytest.mark.asyncio -async def test_resubscribe_on_presence_callback_addition(): +async def test_resubscribe_on_presence_callback_addition() -> None: """Test that channel resubscribes when presence callbacks are added after joining.""" import asyncio from unittest.mock import AsyncMock @@ -258,11 +259,11 @@ async def test_resubscribe_on_presence_callback_addition(): channel = socket.channel("test") # Mock the channel as joined - channel.state = "joined" + channel.state = ChannelStates.JOINED channel._joined_once = True # Mock resubscribe method - channel._resubscribe = AsyncMock() + channel._resubscribe = AsyncMock() # type: ignore # Add presence callbacks after joining channel.on_presence_sync(lambda: None) diff --git a/src/realtime/tests/test_timer.py b/src/realtime/tests/test_timer.py index 7a78abd9..e683639e 100644 --- a/src/realtime/tests/test_timer.py +++ b/src/realtime/tests/test_timer.py @@ -5,7 +5,7 @@ from realtime._async.timer import AsyncTimer -def linear_backoff(tries: int) -> int: +def linear_backoff(tries: int) -> float: return tries * 0.1 @@ -94,6 +94,7 @@ async def callback(): timer = AsyncTimer(callback, linear_backoff) timer.schedule_timeout() + assert timer.timer is not None # Wait for the timer to complete await timer.timer @@ -113,6 +114,7 @@ async def callback(): timer.schedule_timeout() # Cancel the timer + assert timer.timer is not None timer.timer.cancel() # Wait a bit to ensure the timer doesn't fire diff --git a/src/storage/pyproject.toml b/src/storage/pyproject.toml index 5fb2b20d..235bc2c4 100644 --- a/src/storage/pyproject.toml +++ b/src/storage/pyproject.toml @@ -34,7 +34,6 @@ repository = "https://github.com/supabase/supabase-py" [dependency-groups] lints = [ - "pre-commit >=4.2.0", "ruff >=0.12.1", "unasync >= 0.6.0", "python-lsp-server (>=1.12.2,<2.0.0)", diff --git a/src/supabase/pyproject.toml b/src/supabase/pyproject.toml index b5b320df..2ee88b2c 100644 --- a/src/supabase/pyproject.toml +++ b/src/supabase/pyproject.toml @@ -37,15 +37,9 @@ documentation = "https://github.com/supabase/supabase-py/src/supabase" [dependency-groups] dev = [ - { include-group = "pre-commit" }, { include-group = "tests" }, { include-group = "lints" }, ] -pre-commit = [ - "pre-commit >= 4.1.0", - "commitizen >=4.8.3", - "unasync >=0.6.0", -] tests = [ "pytest >= 8.4.1", "pytest-cov >= 6.2.1", @@ -53,6 +47,7 @@ tests = [ "python-dotenv >= 1.1.1", ] lints = [ + "unasync >=0.6.0", "ruff >=0.12.1", ]