Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit b79e2a8

Browse files
authored
Merge pull request #148 from supabase-community/next
next release - feature parity - high testing coverage
2 parents 52d4c8d + 66f3f3a commit b79e2a8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+4868
-2358
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
strategy:
99
matrix:
1010
os: [ubuntu-latest]
11-
python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
11+
python-version: [3.8, 3.9, "3.10", "3.11"]
1212
runs-on: ${{ matrix.os }}
1313
steps:
1414
- name: Clone Repository

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ repos:
99
args: ["--fix=lf"]
1010

1111
- repo: https://github.com/pycqa/isort
12-
rev: 5.10.1
12+
rev: 5.12.0
1313
hooks:
1414
- id: isort
1515
args:
@@ -40,13 +40,13 @@ repos:
4040
- id: black
4141

4242
- repo: https://github.com/asottile/pyupgrade
43-
rev: v2.34.0
43+
rev: v2.37.3
4444
hooks:
4545
- id: pyupgrade
4646
args: ["--py37-plus", "--keep-runtime-typing"]
4747

4848
- repo: https://github.com/commitizen-tools/commitizen
49-
rev: v2.28.0
49+
rev: v2.32.1
5050
hooks:
5151
- id: commitizen
5252
stages: [commit-msg]

.sourcery.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
refactor:
2+
python_version: '3.7'

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ clean_infra:
2222
docker-compose down --remove-orphans &&\
2323
docker system prune -a --volumes -f
2424

25+
sync_infra:
26+
python scripts/gh-download.py --repo=supabase/gotrue-js --branch=master --folder=infra
27+
2528
run_tests: run_infra sleep tests
2629

2730
build_sync:

gotrue/__init__.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
__version__ = "0.5.4"
44

5-
from ._async.api import AsyncGoTrueAPI
6-
from ._async.client import AsyncGoTrueClient
7-
from ._async.storage import AsyncMemoryStorage, AsyncSupportedStorage
8-
from ._sync.api import SyncGoTrueAPI
9-
from ._sync.client import SyncGoTrueClient
10-
from ._sync.storage import SyncMemoryStorage, SyncSupportedStorage
11-
from .types import *
12-
13-
Client = SyncGoTrueClient
14-
GoTrueAPI = SyncGoTrueAPI
5+
from ._async.gotrue_admin_api import AsyncGoTrueAdminAPI # type: ignore # noqa: F401
6+
from ._async.gotrue_client import AsyncGoTrueClient # type: ignore # noqa: F401
7+
from ._async.storage import AsyncMemoryStorage # type: ignore # noqa: F401
8+
from ._async.storage import AsyncSupportedStorage # type: ignore # noqa: F401
9+
from ._sync.gotrue_admin_api import SyncGoTrueAdminAPI # type: ignore # noqa: F401
10+
from ._sync.gotrue_client import SyncGoTrueClient # type: ignore # noqa: F401
11+
from ._sync.storage import SyncMemoryStorage # type: ignore # noqa: F401
12+
from ._sync.storage import SyncSupportedStorage # type: ignore # noqa: F401
13+
from .types import * # type: ignore # noqa: F401, F403

gotrue/_async/gotrue_admin_api.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from __future__ import annotations
2+
3+
from typing import Dict, List, Union
4+
5+
from ..helpers import parse_link_response, parse_user_response
6+
from ..http_clients import AsyncClient
7+
from ..types import (
8+
AdminUserAttributes,
9+
AuthMFAAdminDeleteFactorParams,
10+
AuthMFAAdminDeleteFactorResponse,
11+
AuthMFAAdminListFactorsParams,
12+
AuthMFAAdminListFactorsResponse,
13+
GenerateLinkParams,
14+
GenerateLinkResponse,
15+
Options,
16+
User,
17+
UserResponse,
18+
)
19+
from .gotrue_admin_mfa_api import AsyncGoTrueAdminMFAAPI
20+
from .gotrue_base_api import AsyncGoTrueBaseAPI
21+
22+
23+
class AsyncGoTrueAdminAPI(AsyncGoTrueBaseAPI):
24+
def __init__(
25+
self,
26+
*,
27+
url: str = "",
28+
headers: Dict[str, str] = {},
29+
http_client: Union[AsyncClient, None] = None,
30+
) -> None:
31+
AsyncGoTrueBaseAPI.__init__(
32+
self,
33+
url=url,
34+
headers=headers,
35+
http_client=http_client,
36+
)
37+
self.mfa = AsyncGoTrueAdminMFAAPI()
38+
self.mfa.list_factors = self._list_factors
39+
self.mfa.delete_factor = self._delete_factor
40+
41+
async def sign_out(self, jwt: str) -> None:
42+
"""
43+
Removes a logged-in session.
44+
"""
45+
return await self._request(
46+
"POST",
47+
"logout",
48+
jwt=jwt,
49+
no_resolve_json=True,
50+
)
51+
52+
async def invite_user_by_email(
53+
self,
54+
email: str,
55+
options: Options = {},
56+
) -> UserResponse:
57+
"""
58+
Sends an invite link to an email address.
59+
"""
60+
return await self._request(
61+
"POST",
62+
"invite",
63+
body={"email": email, "data": options.get("data")},
64+
redirect_to=options.get("redirect_to"),
65+
xform=parse_user_response,
66+
)
67+
68+
async def generate_link(self, params: GenerateLinkParams) -> GenerateLinkResponse:
69+
"""
70+
Generates email links and OTPs to be sent via a custom email provider.
71+
"""
72+
return await self._request(
73+
"POST",
74+
"admin/generate_link",
75+
body={
76+
"type": params.get("type"),
77+
"email": params.get("email"),
78+
"password": params.get("password"),
79+
"new_email": params.get("new_email"),
80+
"data": params.get("options", {}).get("data"),
81+
},
82+
redirect_to=params.get("options", {}).get("redirect_to"),
83+
xform=parse_link_response,
84+
)
85+
86+
# User Admin API
87+
88+
async def create_user(self, attributes: AdminUserAttributes) -> UserResponse:
89+
"""
90+
Creates a new user.
91+
92+
This function should only be called on a server.
93+
Never expose your `service_role` key in the browser.
94+
"""
95+
return await self._request(
96+
"POST",
97+
"admin/users",
98+
body=attributes,
99+
xform=parse_user_response,
100+
)
101+
102+
async def list_users(self) -> List[User]:
103+
"""
104+
Get a list of users.
105+
106+
This function should only be called on a server.
107+
Never expose your `service_role` key in the browser.
108+
"""
109+
return await self._request(
110+
"GET",
111+
"admin/users",
112+
xform=lambda data: [User.parse_obj(user) for user in data["users"]]
113+
if "users" in data
114+
else [],
115+
)
116+
117+
async def get_user_by_id(self, uid: str) -> UserResponse:
118+
"""
119+
Get user by id.
120+
121+
This function should only be called on a server.
122+
Never expose your `service_role` key in the browser.
123+
"""
124+
return await self._request(
125+
"GET",
126+
f"admin/users/{uid}",
127+
xform=parse_user_response,
128+
)
129+
130+
async def update_user_by_id(
131+
self,
132+
uid: str,
133+
attributes: AdminUserAttributes,
134+
) -> UserResponse:
135+
"""
136+
Updates the user data.
137+
138+
This function should only be called on a server.
139+
Never expose your `service_role` key in the browser.
140+
"""
141+
return await self._request(
142+
"PUT",
143+
f"admin/users/{uid}",
144+
body=attributes,
145+
xform=parse_user_response,
146+
)
147+
148+
async def delete_user(self, id: str) -> None:
149+
"""
150+
Delete a user. Requires a `service_role` key.
151+
152+
This function should only be called on a server.
153+
Never expose your `service_role` key in the browser.
154+
"""
155+
return await self._request("DELETE", f"admin/users/{id}")
156+
157+
async def _list_factors(
158+
self,
159+
params: AuthMFAAdminListFactorsParams,
160+
) -> AuthMFAAdminListFactorsResponse:
161+
return await self._request(
162+
"GET",
163+
f"admin/users/{params.get('user_id')}/factors",
164+
xform=AuthMFAAdminListFactorsResponse.parse_obj,
165+
)
166+
167+
async def _delete_factor(
168+
self,
169+
params: AuthMFAAdminDeleteFactorParams,
170+
) -> AuthMFAAdminDeleteFactorResponse:
171+
return await self._request(
172+
"DELETE",
173+
f"admin/users/{params.get('user_id')}/factors/{params.get('factor_id')}",
174+
xform=AuthMFAAdminDeleteFactorResponse.parse_obj,
175+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from ..types import (
2+
AuthMFAAdminDeleteFactorParams,
3+
AuthMFAAdminDeleteFactorResponse,
4+
AuthMFAAdminListFactorsParams,
5+
AuthMFAAdminListFactorsResponse,
6+
)
7+
8+
9+
class AsyncGoTrueAdminMFAAPI:
10+
"""
11+
Contains the full multi-factor authentication administration API.
12+
"""
13+
14+
async def list_factors(
15+
self,
16+
params: AuthMFAAdminListFactorsParams,
17+
) -> AuthMFAAdminListFactorsResponse:
18+
"""
19+
Lists all factors attached to a user.
20+
"""
21+
raise NotImplementedError() # pragma: no cover
22+
23+
async def delete_factor(
24+
self,
25+
params: AuthMFAAdminDeleteFactorParams,
26+
) -> AuthMFAAdminDeleteFactorResponse:
27+
"""
28+
Deletes a factor on a user. This will log the user out of all active
29+
sessions (if the deleted factor was verified). There's no need to delete
30+
unverified factors.
31+
"""
32+
raise NotImplementedError() # pragma: no cover

gotrue/_async/gotrue_base_api.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Callable, Dict, TypeVar, Union, overload
4+
5+
from httpx import Response
6+
from pydantic import BaseModel
7+
from typing_extensions import Literal, Self
8+
9+
from ..helpers import handle_exception
10+
from ..http_clients import AsyncClient
11+
12+
T = TypeVar("T")
13+
14+
15+
class AsyncGoTrueBaseAPI:
16+
def __init__(
17+
self,
18+
*,
19+
url: str,
20+
headers: Dict[str, str],
21+
http_client: Union[AsyncClient, None],
22+
):
23+
self._url = url
24+
self._headers = headers
25+
self._http_client = http_client or AsyncClient()
26+
27+
async def __aenter__(self) -> Self:
28+
return self
29+
30+
async def __aexit__(self, exc_t, exc_v, exc_tb) -> None:
31+
await self.close()
32+
33+
async def close(self) -> None:
34+
await self._http_client.aclose()
35+
36+
@overload
37+
async def _request(
38+
self,
39+
method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
40+
path: str,
41+
*,
42+
jwt: Union[str, None] = None,
43+
redirect_to: Union[str, None] = None,
44+
headers: Union[Dict[str, str], None] = None,
45+
query: Union[Dict[str, str], None] = None,
46+
body: Union[Any, None] = None,
47+
no_resolve_json: Literal[False] = False,
48+
xform: Callable[[Any], T],
49+
) -> T:
50+
... # pragma: no cover
51+
52+
@overload
53+
async def _request(
54+
self,
55+
method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
56+
path: str,
57+
*,
58+
jwt: Union[str, None] = None,
59+
redirect_to: Union[str, None] = None,
60+
headers: Union[Dict[str, str], None] = None,
61+
query: Union[Dict[str, str], None] = None,
62+
body: Union[Any, None] = None,
63+
no_resolve_json: Literal[True],
64+
xform: Callable[[Response], T],
65+
) -> T:
66+
... # pragma: no cover
67+
68+
@overload
69+
async def _request(
70+
self,
71+
method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
72+
path: str,
73+
*,
74+
jwt: Union[str, None] = None,
75+
redirect_to: Union[str, None] = None,
76+
headers: Union[Dict[str, str], None] = None,
77+
query: Union[Dict[str, str], None] = None,
78+
body: Union[Any, None] = None,
79+
no_resolve_json: bool = False,
80+
) -> None:
81+
... # pragma: no cover
82+
83+
async def _request(
84+
self,
85+
method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
86+
path: str,
87+
*,
88+
jwt: Union[str, None] = None,
89+
redirect_to: Union[str, None] = None,
90+
headers: Union[Dict[str, str], None] = None,
91+
query: Union[Dict[str, str], None] = None,
92+
body: Union[Any, None] = None,
93+
no_resolve_json: bool = False,
94+
xform: Union[Callable[[Any], T], None] = None,
95+
) -> Union[T, None]:
96+
url = f"{self._url}/{path}"
97+
headers = {**self._headers, **(headers or {})}
98+
if "Content-Type" not in headers:
99+
headers["Content-Type"] = "application/json;charset=UTF-8"
100+
if jwt:
101+
headers["Authorization"] = f"Bearer {jwt}"
102+
query = query or {}
103+
if redirect_to:
104+
query["redirect_to"] = redirect_to
105+
try:
106+
response = await self._http_client.request(
107+
method,
108+
url,
109+
headers=headers,
110+
params=query,
111+
json=body.dict() if isinstance(body, BaseModel) else body,
112+
)
113+
response.raise_for_status()
114+
result = response if no_resolve_json else response.json()
115+
if xform:
116+
return xform(result)
117+
except Exception as e:
118+
raise handle_exception(e)

0 commit comments

Comments
 (0)