From e7539278fbcbef3a05971856d20a2a1587a9049e Mon Sep 17 00:00:00 2001 From: leonfedden Date: Fri, 12 Feb 2021 11:41:08 +0000 Subject: [PATCH 01/23] bringing closer to pairity with gotrue js --- gotrue/__init__.py | 4 +- gotrue/api.py | 166 ++++++++++++++++++++++++++++ gotrue/client.py | 261 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 376 insertions(+), 55 deletions(-) create mode 100644 gotrue/api.py diff --git a/gotrue/__init__.py b/gotrue/__init__.py index b00597a1..c339e12e 100644 --- a/gotrue/__init__.py +++ b/gotrue/__init__.py @@ -1,3 +1,5 @@ -__version__ = '0.1.0' +__version__ = '0.2.0' +from . import client +from . import api from .client import Client diff --git a/gotrue/api.py b/gotrue/api.py new file mode 100644 index 00000000..9edda603 --- /dev/null +++ b/gotrue/api.py @@ -0,0 +1,166 @@ +import json +from typing import Any, Dict + +import requests + +from gotrue.lib.constants import COOKIE_OPTIONS + + +class GoTrueApi: + def __init__( + self, url: str, headers: Dict[str, Any], cookie_options: Dict[str, Any] + ): + """Initialise API class.""" + self.url = url + self.headers = headers + self.cookie_options = {**COOKIE_OPTIONS, **cookie_options} + + def sign_up_with_email(self, email: str, password: str) -> Dict[str, Any]: + """Creates a new user using their email address + + Parameters + --------- + email : str + The user's email address. + password : str + The user's password. + """ + credentials = {"email": email, "password": password} + request = requests.post( + f"{self.url}/signup", json.dumps(credentials), headers=self.headers + ) + return request.json() + + def sign_in_with_email(self, email: str, password: str) -> Dict[str, Any]: + """Logs in an existing user using their email address. + + Parameters + --------- + email : str + The user's email address. + password : str + The user's password. + """ + credentials = {"email": email, "password": password} + request = requests.post( + f"{self.url}/token?grant_type=password", + json.dumps(credentials), + headers=self.headers, + ) + return request.json() + + def send_magic_link_email(self, email: str) -> Dict[str, Any]: + """Sends a magic login link to an email address. + + Parameters + --------- + email : str + The user's email address. + """ + credentials = {"email": email} + request = requests.post( + f"{self.url}/magiclink", json.dumps(credentials), headers=self.headers + ) + return request.json() + + def invite_user_by_email(self, email: str) -> Dict[str, Any]: + """Sends an invite link to an email address. + + Parameters + --------- + email : str + The user's email address. + """ + credentials = {"email": email} + request = requests.post( + f"{self.url}/invite", json.dumps(credentials), headers=self.headers + ) + return request.json() + + def reset_password_for_email(self, email: str) -> Dict[str, Any]: + """Sends a reset request to an email address. + + Parameters + --------- + email : str + The user's email address. + """ + credentials = {"email": email} + request = requests.post( + f"{self.url}/recover", json.dumps(credentials), headers=self.headers + ) + return request.json() + + def _create_request_headers(self, jwt: str) -> Dict[str, str]: + """Create temporary object. + + Create a temporary object with all configured headers and adds the + Authorization token to be used on request methods. + + Parameters + ---------- + jwt : str + A valid, logged-in JWT. + """ + headers = {**self.headers} + headers["Authorization"] = f"Bearer {jwt}" + return headers + + def sign_out(self, jwt: str): + """Removes a logged-in session. + + Parameters + ---------- + jwt : str + A valid, logged-in JWT. + """ + requests.post(f"{self.url}/logout", headers=self._create_request_headers(jwt)) + + def get_url_for_provider(self, provider: str) -> str: + """Generates the relevant login URL for a third-party provider.""" + return f"{self.url}/authorize?provider={provider}" + + def get_user(self, jwt: str) -> Dict[str, Any]: + """Gets the user details + + Parameters + ---------- + jwt : str + A valid, logged-in JWT. + """ + request = requests.get( + f"{self.url}/user", headers=self._create_request_headers(jwt) + ) + return request.json() + + def update_user(self, jwt: str, **attributes) -> Dict[str, Any]: + """Updates the user data through the attributes kwargs.""" + request = requests.put( + f"{self.url}/user", + json.dumps(attributes), + headers=self._create_request_headers(jwt), + ) + return request.json() + + def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]: + """Generates a new JWT. + + Parameters + ---------- + refresh_token : str + A valid refresh token that was returned on login. + """ + request = requests.post( + f"{self.url}/token?grant_type=refresh_token", + json.dumps({"refresh_token": refresh_token}), + headers=self.headers, + ) + return request.json() + + def set_auth_cookie(req, res): + """Stub for pairty with JS api.""" + raise NotImplementedError("set_auth_cookie not implemented.") + + def get_user_by_cookie(req): + """Stub for pairty with JS api.""" + raise NotImplementedError("get_user_by_cookie not implemented.") diff --git a/gotrue/client.py b/gotrue/client.py index 5a33dfce..2e6bbd6b 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -3,9 +3,12 @@ ==================================== core module of the project """ +import functools +import json import requests import re -import json +import uuid +from typing import Any, Callable, Dict, Optional from urllib.parse import quote HTTPRegexp = "/^http://" @@ -17,61 +20,211 @@ def jsonify(dictionary: dict): class Client: - def __init__(self, url, audience="", setCookie=False, headers={}): + def __init__( + self, + headers: Dict[str, str], + url: str = GOTRUE_URL, + detect_session_in_url: bool = True, + auto_refresh_token: bool = True, + persist_session: bool = True, + local_storage: Dict[str, Any] = {}, + cookie_options: Dict[str, Any] = {}, + ): + """Create a new client for use in the browser. + + url + The URL of the GoTrue server. + headers + Any additional headers to send to the GoTrue server. + detectSessionInUrl + Set to "true" if you want to automatically detects OAuth grants in + the URL and signs in the user. + autoRefreshToken + Set to "true" if you want to automatically refresh the token before + expiring. + persistSession + Set to "true" if you want to automatically save the user session + into local storage. + localStorage + """ if re.match(HTTPRegexp, url): - # TODO: Decide whether to convert this to a logging statement print( - "Warning:\n\nDO NOT USE HTTP IN PRODUCTION FOR GOTRUE EVER!\nGoTrue REQUIRES HTTPS to work securely." + "Warning:\n\nDO NOT USE HTTP IN PRODUCTION FOR GOTRUE EVER!\n" + "GoTrue REQUIRES HTTPS to work securely." ) - self.BASE_URL = url - self.headers = headers - - def settings(self): - """Get environment settings for the server""" - return requests.get(f"{self.BASE_URL}/settings", headers=self.headers) - - def sign_up(self, credentials: dict): - return requests.post( - f"{self.BASE_URL}/signup", jsonify(credentials), headers=self.headers - ) - - def sign_in(self, credentials: dict): - """Sign in with email and password""" - return self.grant_token("password", credentials, headers=self.headers) - - def refresh_access_token(self, refresh_token: str): - return grant_token("refresh_token", {"refresh_token": refresh_token}) - - def grant_token(self, type: str, data: dict): - return requests.post( - f"{self.BASE_URL}/token?grant_type=#{type}/", jsonify(data) + self.state_change_emitters: Dict[str, Any] = {} + self.current_user = None + self.current_session = None + self.auto_refresh_token = auto_refresh_token + self.persist_session = persist_session + self.local_storage: Dict[str, Any] = {} + self.api = GoTrueApi(url=url, headers=headers, cookie_options=cookie_options) + self._recover_session() + + def sign_up(self, email: str, password: str): + """Creates a new user. + + Paramters + --------- + email : str + The user's email address. + password : str + The user's password. + """ + self._remove_session() + data = self.api.sign_up(email, password) + if data.user.confirmed_at: + self._save_session(data) + self._notify_all_subscribers("SIGNED_IN") + return data + + def sign_in( + self, + email: Optional[str] = None, + password: Optional[str] = None, + provider: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """Log in an exisiting user, or login via a third-party provider.""" + self._remove_session() + if email is not None and password is None: + self.api.send_magic_link_email(email) + data = None + elif email is not None and password is not None: + data = self._handle_email_sign_in(email, password) + elif provider is not None: + data = self._handle_provider_sign_in(provider) + else: + raise ValueError("Email or provider must not be None.") + return data + + def user(self) -> Optional[Dict[str, Any]]: + """Returns the user data, if there is a logged in user.""" + return self.current_user + + def session(self) -> Optional[Dict[str, Any]]: + """Returns the session data, if there is an active session.""" + return self.current_session + + def refresh_session(self) -> Dict[str, Any]: + """Force refreshes the session. + + Force refreshes the session including the user data incase it was + updated in a different session. + """ + if self.current_session is None or not self.current_session.access_token: + raise ValueError("Not logged in.") + self._call_refresh_token() + data = self.api.get_user(self.current_session.access_token) + self.current_user = data + return data + + def update(self, **attributes) -> Dict[str, Any]: + """Updates user data, if there is a logged in user.""" + if self.current_session is None or not self.current_session.get("access_token"): + raise ValueError("Not logged in.") + data = self.api.update_user(self.current_session["access_token"], **attributes) + self.current_user = data + self._notify_all_subscribers("USER_UPDATED") + return data + + def get_session_from_url(self, store_session: bool): + """Gets the session data from a URL string.""" + raise NotImplementedError( + "This method is a stub and is only required by the JS client." ) - def sign_out(jwt: str): - """Sign out user using a valid JWT""" - return requests.post(f"{self.BASE_URL}/logout", auth=jwt) - - def recover(self, email: str): - """ Send a recovery email """ - data = {"email": email} - return requests.post(f"{self.BASE_URL}/recover", jsonify(data)) - - def get_user(self, jwt: str): - """Get user info using a valid JWT""" - return requests.get(f"{self.BASE_URL}/user", auth=jwt) - - def update_user(self, jwt: str, info: dict): - """Update user info using a valid JWT""" - return requests.put(f"{self.BASE_URL}/user", auth=jwt, data=info) - - def send_magic_link(self, email: str): - """Send a magic link for passwordless login""" - data = json.dumps({"email": email}) - return requests.post(f"{self.BASE_URL}/magiclink", data=data) - - def url_for_provider(self, provider: str) -> str: - return f"{self.BASE_URL}/authorize?provider=#{urllib.parse.quote(provider)}" - - def invite(self, invitation: dict): - """Invite a new user to join""" - return requests.post(f"{self.BASE_URL}/invite", jsonify(invitation)) + def sign_out(self): + """Log the user out.""" + if self.current_session is not None and "access_token" in self.current_session: + self.api.sign_out(self.current_session["access_token"]) + self._remove_session() + self._notify_all_subscribers("SIGNED_OUT") + + def on_auth_state_change( + self, callback: Callable[[str, Optional[Dict[str, Any]]], Any], + ): + """""" + unique_id: str = str(uuid.uuid4()) + subscription: Dict[str, Any] = { + "id": unique_id, + "callback": callback, + "unsubscribe": functools.partial( + self.state_change_emitters.pop, id=unique_id + ), + } + self.state_change_emitters[unique_id] = subscription + return subscription + + def _handle_email_sign_in(self, email: str, password: str) -> Dict[str, Any]: + """Sign in with email and password.""" + data = self.api.sign_in_with_email(email, password) + if ( + data is not None + and data.get("user") is not None + and "confirmed_at" in data["user"] + ): + self._save_session(data) + self._notify_all_subscribers("SIGNED_IN") + return data + + def _handle_provider_sign_in(self, provider): + """Sign in with provider.""" + raise NotImplementedError("Not implemeted for Python client.") + + def _save_session(self, session): + """Save session to client.""" + self.current_session = session + self.current_user = session.user + token_expiry_seconds = session.get("expires_in") + if self.auto_refresh_token and token_expiry_seconds is not None: + self._set_timeout( + self._call_refresh_token, (token_expiry_seconds - 60) * 1000 + ) + if self.persist_session: + self._persist_session(self.current_session, token_expiry_seconds) + + def _persist_session(self, current_session, seconds_to_expiry: int): + timenow_seconds: int = datetime.datetime.now() + expires_at: int = timenow_seconds + seconds_to_expiry + data = { + "current_session": current_session, + "expires_at": expires_at, + } + self.local_storage[STORAGE_KEY] = json.dumps(data) + + def _remove_session(self): + """Remove the session.""" + self.current_session = None + self.current_user = None + self.local_storage.pop(STORAGE_KEY, None) + + def _recover_session(self): + """Kept as a stub for pairity with the JS client. Only required for + React Native. + """ + pass + + def _call_refresh_token(self, refresh_token: Optional[str] = None): + logged_in: bool = self.current_session is not None and "access_token" in self.current_session + if refresh_token is None and logged_in: + refresh_token = self.current_session["refresh_token"] + elif refresh_token is None: + raise ValueError("No current session and refresh_token not supplied.") + data = self.api.refresh_access_token(refresh_token) + if data is not None and "access_token" in data: + self.current_session = data + self.current_user = data.user + self._notify_all_subscribers("SIGNED_IN") + token_expiry_seconds = data.get("expires_in") + if self.auto_refresh_token and token_expiry_seconds is not None: + self._set_timeout( + self._call_refresh_token, (token_expiry_seconds - 60) * 1000 + ) + if self.persist_session: + self._persist_session(self.current_session, token_expiry_seconds) + return data + + def _notify_all_subscribers(self, event: str): + """Notify all subscribers that auth event happened.""" + for value in self.state_change_emitters.values(): + value["callback"](event, self.current_session) From 645136a73558070331157928ab90bdca0dae7960 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Fri, 12 Feb 2021 11:57:25 +0000 Subject: [PATCH 02/23] add contstants --- gotrue/client.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/gotrue/client.py b/gotrue/client.py index 2e6bbd6b..278720cf 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -3,20 +3,19 @@ ==================================== core module of the project """ +import datetime import functools import json -import requests import re import uuid from typing import Any, Callable, Dict, Optional -from urllib.parse import quote -HTTPRegexp = "/^http://" -defaultApiURL = "/.netlify/identity" +from gotrue.api import GoTrueApi +from gotrue.libs.contants import GOTRUE_URL, STORAGE_KEY -def jsonify(dictionary: dict): - return json.dumps(dictionary) +HTTPRegexp = "/^http://" +defaultApiURL = "/.netlify/identity" class Client: @@ -72,8 +71,8 @@ def sign_up(self, email: str, password: str): The user's password. """ self._remove_session() - data = self.api.sign_up(email, password) - if data.user.confirmed_at: + data = self.api.sign_up_with_email(email, password) + if "confirmed_at" in data.get("user", {}): self._save_session(data) self._notify_all_subscribers("SIGNED_IN") return data @@ -184,7 +183,7 @@ def _save_session(self, session): self._persist_session(self.current_session, token_expiry_seconds) def _persist_session(self, current_session, seconds_to_expiry: int): - timenow_seconds: int = datetime.datetime.now() + timenow_seconds: int = int(round(datetime.datetime.now().timestamp())) expires_at: int = timenow_seconds + seconds_to_expiry data = { "current_session": current_session, @@ -211,11 +210,11 @@ def _call_refresh_token(self, refresh_token: Optional[str] = None): elif refresh_token is None: raise ValueError("No current session and refresh_token not supplied.") data = self.api.refresh_access_token(refresh_token) - if data is not None and "access_token" in data: + if "access_token" in data: self.current_session = data self.current_user = data.user self._notify_all_subscribers("SIGNED_IN") - token_expiry_seconds = data.get("expires_in") + token_expiry_seconds: int = data["expires_in"] if self.auto_refresh_token and token_expiry_seconds is not None: self._set_timeout( self._call_refresh_token, (token_expiry_seconds - 60) * 1000 @@ -228,3 +227,8 @@ def _notify_all_subscribers(self, event: str): """Notify all subscribers that auth event happened.""" for value in self.state_change_emitters.values(): value["callback"](event, self.current_session) + + def _set_timeout(*args, **kwargs): + """""" + # TODO(fedden): Implement JS equivalent of setTimeout method. + pass From 1a3bef2dd75128b008206cfb9596f591902c8718 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Fri, 12 Feb 2021 12:00:53 +0000 Subject: [PATCH 03/23] sort typos --- gotrue/.client.py.swp | Bin 0 -> 16384 bytes gotrue/__init__.py | 3 ++- gotrue/client.py | 2 +- tests/__pycache__/__init__.cpython-37.pyc | Bin 0 -> 131 bytes .../test_gotrue.cpython-37-pytest-5.4.3.pyc | Bin 0 -> 3738 bytes tests/test_gotrue.py | 8 ++++---- 6 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 gotrue/.client.py.swp create mode 100644 tests/__pycache__/__init__.cpython-37.pyc create mode 100644 tests/__pycache__/test_gotrue.cpython-37-pytest-5.4.3.pyc diff --git a/gotrue/.client.py.swp b/gotrue/.client.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..abdb8882016955a5e8d2ec7abbc81260b01be7e8 GIT binary patch literal 16384 zcmeHNO^h5z6)wl&&m<-S2_Y0q<&KohI7c1D6~F2EJGQKQp_VMIC9wDTDTr*HJ@a7qa)o}TN+u8m1on58M zcNwSo``)|#=H=8iY6fZsY6fZsY6fZsY6fZsY6fZs{%09b!EyFQXn#xE13$eUo4Niw zO=g-eXPzHV?@y)IU(GyEZ|h6VK+QnSK+QnSK+QnSK+QnSK+QnSK+QnSK+V8^fdLCI z?carVZ_tJx@Bg#?|3BZu*lWNqfgb@s1fB!F0$d07fnA^td<6Ia@TZfE{SmkU`~vt6 z@Eq`M;2Gc=Ac0L_1vn1;{&vQG0(=?R0s6ox;7;HU;IFqa_8Z_?;BnwyfB~<(o3U>K z*MY0RDd6S_#$E+p23`QZ3-o~x0=EIbMn``PJOg|dxEJ`xI~n^P@HFrg&;#xV?gKb* z5_tU`s0;iY_!jUr;8Ea1z%9Uyw=?zw;2Xf#f$Km7Yyx)!|9Tr^e*nG;NZ>)>1n}Ce zjQtdN9C!q{6F3RHgu|2G~7l8(F4?s42IXk!^U*c(c zNQSoHp%{iD8jFalvEY%2B3Js{^=&V9Tz{l<<4Aj&u zL2|?}Ys^fWX+**sF7f4$@^#l%JCO>T{G30%(k>*2LdBsEy}V(|j>8aNgJiX}RFaI= zd%9S)52SFDyF=^Zf~hyI6!kyXgCm8O3)@|?yjXO?P=*boGqGHc%a8}5+;bgRc@js8 z`%>|N;G5DHEn_K{A-qVm^E;BLePS)Kjybk`#}j5I%8cBRZ@PXX%P0$iRkCa{3^hot z9W7mu#<^&H*W`f}Mf);zW?Ils%i`exPF}eDU}3wYP^Gup39Z0wMZ$N?i8XR<({ue@ zQ#aJmzl93u;?W9JJFOcqLotziBJ=V_Ara2{upE((z>&pcZsaN&q2wP;G9r8k&-Pr4 zTkv@|be01vRMVX4+^h7EJJ^xPvXn9lwRv6^L-Yz?)3$0dU5Lu1^dD7D({WTG;vf;N z$7Je5a{*z_xfAZ-NbYcIS{{5P9z=HN4n!C=jIE6eo1JxYW3%MWq<&H>vPyiDglZVXz0u@PTw5w!s5~gNw@@GAz}$D$xa=y6{Pt)MV5D}I z`Uu&i!aY%Lge2Hffy^wt&0#i{)uC&tUgD#c<6stuipoiLwdQh`a#RrM>k3oq;2y12Zwtd~sh+egc#@K#H4^QaSqkkvajmLl zfR@}SpP^7dS_+GHx?F%6f9)g2-zb6 zpL7)&@$e3Eq=Fv0qhutM^IqrE+Z(;k7XNs^e{qW|$q|ruF}|RR@TRR~noa5?zs4h0 z=p%l$FZ-AM^)BD+_WAZ!M+@K^oBU$0yS{zCztP>~4|RL|Lbu=B?(oi~PVe+(zbY;W zU0*d=CLLoo6s?J+>~VwC>4}keENF80Lur_1Q$N+xcHTFB^!V42#K)6zwH$*e5TnG*8RI?cT$B?xZO_q6kQtC!-#e5=gCEK!rOGzjH>PE^VLs&QxG&?{!xqvKFfb|&Jq z`9Mk!L4YD-QEEQQ98fSZL>6|jo-;wh0cG_Q%r$5&8LlQME!s?ZFZY$!6GZ`vUCA9y z6($;Um1#DZ?O}z%&+Bi&F)=VawPH^p&XdX3xUtgmh4L`-uV4}DD_2boR*ZaI;&WrA zg7(Uaf%?NxPI&UIdCPMLElltTh}r`ufeckyd%o50_Rd}Cn4j!?rc#k+95*eVO*^S* zH4SKCmOh2AHu-tP0Bhihrk+Ha`HG6Ah2q$Cvi}&FY3|iX`q}?s?Ar>ajIs=@GlX&{ zf>9*1F`oHEIF#w75> z1gm`hi7hT3jom%VqdV-}BBACvk)RJ!KixU#s2kNS{6V#_N3lCjd zx=hq4Z4II5ras5yXp)!CF|nz&6de$axmJOXtsoG-aPpH~-M0e{LG|t`sW^AWmcC}8 zqf178E+?(KIkTg`kf;CY}2_X70h=2h`Aj1KOi&=m~3PUi1CZpd2KczG$)edCJXCP(( E0MwBkXaE2J literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_gotrue.cpython-37-pytest-5.4.3.pyc b/tests/__pycache__/test_gotrue.cpython-37-pytest-5.4.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd362534b1ce1fca7eebeb657204bafef7ab5823 GIT binary patch literal 3738 zcmeHKOLH4V5T4n$RxihSkdRQAM?w~e70ItS6{id-iky7lanWV7(X8aPS6VT%3b9yS zh>J5PP8_Tw2mS{?f!ZUS@)vTVdq%R?sicx3N04j2d34Y8%=Glv>(8rI_i*pp16wEL zZ&LUf(B6h3_5c`RG$w5-Ypt(qgp$6|HmQeuf8oNn9Y@|sH(gq73HPoCN!-GZ|id0DS)!_2*Ku*Rh69`1BXmX^3 zTf(0`EAkY-KnHeCres1hx<@A(qZ2*TGJRj07?~k{O0^@MX_?WbOpbT8slklYI5MYX z-xBvhZe?^z`B%`gu{F>@j%{s9WevB&%wP2jWMZNhGBcvkwo(VArOcYrC-g_~r$Htf z6l0<@0=MYwJpBW9=h0`{)|N-dD_5@%4_cmZf0#9vwwADU(-X@g4bqYDI|&Qj$VGf#mU1U_6I>f%uBuqx*@KR z*FKZ`h?2nvt304^lsTJdA@ukb=asrTaL#Eia=jwfSbd&{-`?R+L07wXP(p zb4$J`1^77fsyx^H*ku?JivWZ=)B=AvPj#}qD1@IT`{7^#6a_wiLit;Or;OnEHWcwGKq#ZW4KI{w5TiQNnE^G# zH?yENnav!iEmnfp}qEC#n8M*wd9rQ4rHU*Rb7rpoH3qTXOM#JiYn zd3@!0E6WANH?ru;h2o?a4K5W7UVgDbsrN?q)CnJjJn9}?DHwdo36;AoDHe{%HraQH?s{_dgL19&<}nGC3ILVG}3k~o1RfiU&V0OB+=b7Ezt zxGN<#(?a0Z>QwmAR1WSvcirA%YBsTL6+_Zq9Sg{&sE>ZuFTa#k-8y)`3y9cmLvABqj7G;t)C zoY@(l6_EDAeh|gmJ;Y@oTyBB~qTj&-5T+Ki=Xqguo)@4o%L|YyUYr+z@g*m6w8l2(lTDQnHeu-v>^E2DYh!D+c(eJk)tl=cWSrc9iA{+s64%F$T;!W%$HkgN^W<2DMXJUOa!M}0xuMMf6Lqz+jmj$X4J K(=jg89rGWFWo|G4 literal 0 HcmV?d00001 diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index e75fb6a7..4ea82cbf 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -1,16 +1,16 @@ -from gotrue import __version__ import pytest @pytest.fixture def client(): from gotrue import client + return client.Client("http://localhost:9999") def test_settings(client): res = client.settings() - assert (res.status_code == 200) + assert res.status_code == 200 def test_refresh_access_token(): @@ -34,9 +34,9 @@ def test_logout(self): def test_send_magic_link(client): res = client.send_magic_link("someemail@gmail.com") - assert (res.status_code == 200 or res.status_code == 429) + assert res.status_code == 200 or res.status_code == 429 def test_recover_email(client): res = client.recover("someemail@gmail.com") - assert (res.status_code == 200 or res.status_code == 429) + assert res.status_code == 200 or res.status_code == 429 From 93f984bdcbb5ee9754a168028ba93b016950fbaa Mon Sep 17 00:00:00 2001 From: leonfedden Date: Fri, 12 Feb 2021 12:01:31 +0000 Subject: [PATCH 04/23] rm crap --- gotrue/.client.py.swp | Bin 16384 -> 0 bytes tests/__pycache__/__init__.cpython-37.pyc | Bin 131 -> 0 bytes tests/__pycache__/__init__.cpython-39.pyc | Bin 147 -> 0 bytes .../test_gotrue.cpython-37-pytest-5.4.3.pyc | Bin 3738 -> 0 bytes .../test_gotrue.cpython-39-pytest-6.2.1.pyc | Bin 763 -> 0 bytes 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 gotrue/.client.py.swp delete mode 100644 tests/__pycache__/__init__.cpython-37.pyc delete mode 100644 tests/__pycache__/__init__.cpython-39.pyc delete mode 100644 tests/__pycache__/test_gotrue.cpython-37-pytest-5.4.3.pyc delete mode 100644 tests/__pycache__/test_gotrue.cpython-39-pytest-6.2.1.pyc diff --git a/gotrue/.client.py.swp b/gotrue/.client.py.swp deleted file mode 100644 index abdb8882016955a5e8d2ec7abbc81260b01be7e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeHNO^h5z6)wl&&m<-S2_Y0q<&KohI7c1D6~F2EJGQKQp_VMIC9wDTDTr*HJ@a7qa)o}TN+u8m1on58M zcNwSo``)|#=H=8iY6fZsY6fZsY6fZsY6fZsY6fZs{%09b!EyFQXn#xE13$eUo4Niw zO=g-eXPzHV?@y)IU(GyEZ|h6VK+QnSK+QnSK+QnSK+QnSK+QnSK+QnSK+V8^fdLCI z?carVZ_tJx@Bg#?|3BZu*lWNqfgb@s1fB!F0$d07fnA^td<6Ia@TZfE{SmkU`~vt6 z@Eq`M;2Gc=Ac0L_1vn1;{&vQG0(=?R0s6ox;7;HU;IFqa_8Z_?;BnwyfB~<(o3U>K z*MY0RDd6S_#$E+p23`QZ3-o~x0=EIbMn``PJOg|dxEJ`xI~n^P@HFrg&;#xV?gKb* z5_tU`s0;iY_!jUr;8Ea1z%9Uyw=?zw;2Xf#f$Km7Yyx)!|9Tr^e*nG;NZ>)>1n}Ce zjQtdN9C!q{6F3RHgu|2G~7l8(F4?s42IXk!^U*c(c zNQSoHp%{iD8jFalvEY%2B3Js{^=&V9Tz{l<<4Aj&u zL2|?}Ys^fWX+**sF7f4$@^#l%JCO>T{G30%(k>*2LdBsEy}V(|j>8aNgJiX}RFaI= zd%9S)52SFDyF=^Zf~hyI6!kyXgCm8O3)@|?yjXO?P=*boGqGHc%a8}5+;bgRc@js8 z`%>|N;G5DHEn_K{A-qVm^E;BLePS)Kjybk`#}j5I%8cBRZ@PXX%P0$iRkCa{3^hot z9W7mu#<^&H*W`f}Mf);zW?Ils%i`exPF}eDU}3wYP^Gup39Z0wMZ$N?i8XR<({ue@ zQ#aJmzl93u;?W9JJFOcqLotziBJ=V_Ara2{upE((z>&pcZsaN&q2wP;G9r8k&-Pr4 zTkv@|be01vRMVX4+^h7EJJ^xPvXn9lwRv6^L-Yz?)3$0dU5Lu1^dD7D({WTG;vf;N z$7Je5a{*z_xfAZ-NbYcIS{{5P9z=HN4n!C=jIE6eo1JxYW3%MWq<&H>vPyiDglZVXz0u@PTw5w!s5~gNw@@GAz}$D$xa=y6{Pt)MV5D}I z`Uu&i!aY%Lge2Hffy^wt&0#i{)uC&tUgD#c<6stuipoiLwdQh`a#RrM>k3oq;2y12Zwtd~sh+egc#@K#H4^QaSqkkvajmLl zfR@}SpP^7dS_+GHx?F%6f9)g2-zb6 zpL7)&@$e3Eq=Fv0qhutM^IqrE+Z(;k7XNs^e{qW|$q|ruF}|RR@TRR~noa5?zs4h0 z=p%l$FZ-AM^)BD+_WAZ!M+@K^oBU$0yS{zCztP>~4|RL|Lbu=B?(oi~PVe+(zbY;W zU0*d=CLLoo6s?J+>~VwC>4}keENF80Lur_1Q$N+xcHTFB^!V42#K)6zwH$*e5TnG*8RI?cT$B?xZO_q6kQtC!-#e5=gCEK!rOGzjH>PE^VLs&QxG&?{!xqvKFfb|&Jq z`9Mk!L4YD-QEEQQ98fSZL>6|jo-;wh0cG_Q%r$5&8LlQME!s?ZFZY$!6GZ`vUCA9y z6($;Um1#DZ?O}z%&+Bi&F)=VawPH^p&XdX3xUtgmh4L`-uV4}DD_2boR*ZaI;&WrA zg7(Uaf%?NxPI&UIdCPMLElltTh}r`ufeckyd%o50_Rd}Cn4j!?rc#k+95*eVO*^S* zH4SKCmOh2AHu-tP0Bhihrk+Ha`HG6Ah2q$Cvi}&FY3|iX`q}?s?Ar>ajIs=@GlX&{ zf>9*1F`oHEIF#w75> z1gm`hi7hT3jom%VqdV-}BBACvk)RJ!KixU#s2kNS{6V#_N3lCjd zx=hq4Z4II5ras5yXp)!CF|nz&6de$axmJOXtsoG-aPpH~-M0e{LG|t`sW^AWmcC}8 zqf178E+?(KIkTg`kf;CY}2_X70h=2h`Aj1KOi&=m~3PUi1CZpd2KczG$)edCJXCP(( E0MwBkXaE2J diff --git a/tests/__pycache__/__init__.cpython-39.pyc b/tests/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index ce43f3b125684964011c629095354f6c48322836..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 147 zcmYe~<>g`kg6Hvk2_X70h(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o6v4KeRZts8~NM zKQ%|+CAB!aB)>qvpeR2pHMyi%KRv&ss5Di#pi;jiwHU~ckI&4@EQycTE2zB1VUwGm PQks)$2Qu+95HkP(uGS)b diff --git a/tests/__pycache__/test_gotrue.cpython-37-pytest-5.4.3.pyc b/tests/__pycache__/test_gotrue.cpython-37-pytest-5.4.3.pyc deleted file mode 100644 index dd362534b1ce1fca7eebeb657204bafef7ab5823..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3738 zcmeHKOLH4V5T4n$RxihSkdRQAM?w~e70ItS6{id-iky7lanWV7(X8aPS6VT%3b9yS zh>J5PP8_Tw2mS{?f!ZUS@)vTVdq%R?sicx3N04j2d34Y8%=Glv>(8rI_i*pp16wEL zZ&LUf(B6h3_5c`RG$w5-Ypt(qgp$6|HmQeuf8oNn9Y@|sH(gq73HPoCN!-GZ|id0DS)!_2*Ku*Rh69`1BXmX^3 zTf(0`EAkY-KnHeCres1hx<@A(qZ2*TGJRj07?~k{O0^@MX_?WbOpbT8slklYI5MYX z-xBvhZe?^z`B%`gu{F>@j%{s9WevB&%wP2jWMZNhGBcvkwo(VArOcYrC-g_~r$Htf z6l0<@0=MYwJpBW9=h0`{)|N-dD_5@%4_cmZf0#9vwwADU(-X@g4bqYDI|&Qj$VGf#mU1U_6I>f%uBuqx*@KR z*FKZ`h?2nvt304^lsTJdA@ukb=asrTaL#Eia=jwfSbd&{-`?R+L07wXP(p zb4$J`1^77fsyx^H*ku?JivWZ=)B=AvPj#}qD1@IT`{7^#6a_wiLit;Or;OnEHWcwGKq#ZW4KI{w5TiQNnE^G# zH?yENnav!iEmnfp}qEC#n8M*wd9rQ4rHU*Rb7rpoH3qTXOM#JiYn zd3@!0E6WANH?ru;h2o?a4K5W7UVgDbsrN?q)CnJjJn9}?DHwdo36;AoDHe{%HraQH?s{_dgL19&<}nGC3ILVG}3k~o1RfiU&V0OB+=b7Ezt zxGN<#(?a0Z>QwmAR1WSvcirA%YBsTL6+_Zq9Sg{&sE>ZuFTa#k-8y)`3y9cmLvABqj7G;t)C zoY@(l6_EDAeh|gmJ;Y@oTyBB~qTj&-5T+Ki=Xqguo)@4o%L|YyUYr+z@g*m6w8l2(lTDQnHeu-v>^E2DYh!D+c(eJk)tl=cWSrc9iA{+s64%F$T;!W%$HkgN^W<2DMXJUOa!M}0xuMMf6Lqz+jmj$X4J K(=jg89rGWFWo|G4 diff --git a/tests/__pycache__/test_gotrue.cpython-39-pytest-6.2.1.pyc b/tests/__pycache__/test_gotrue.cpython-39-pytest-6.2.1.pyc deleted file mode 100644 index a80af2fa2f95f6228fbddacf6bb3214a56caf07d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 763 zcmYjO&2AGh5VpOW-K0$-0TO~+FCg|nHfWjig2HW%PA zI5bCI$yW}DSLlhcw?!Gr<8S60&!1VZw}n9d(EjI7aR2tkrVucW;Fvv79C0jBfx}J8 zh#-v0xM*R|5Sf%|kphl*`~wwjjz6HR^%vfulMK6UN-u>rQ#GS>?45TK14!Y#gJWt? z9Iuc?HM$@xTw_yNd`&p1@dWe8`zK@_^Vmk$@fuwsa|nFPqBYjXfD(^p1bDzTVBY#k z9-#egH50^6>mHU5sj!8kEmK~0XJ;r7Y0H5}{?h8eygqfv&J_n$ng7Q@W! zj7B>i?q^2(wPd7-fbeR6*=NQGZ9~98X1wzt>x4MBbyO{^FqSgy?$I%ml4?=uu_`O3 zh3f)MPv=uZ{;U3dwPlKja v=zXBYm7ZFd`M=Ahw?6F?7D-brEIOnOZEvWa&)j}pDn6Iu4F)ST@ih7eHo? Date: Fri, 12 Feb 2021 12:02:39 +0000 Subject: [PATCH 05/23] add gitignoire --- .gitignore | 162 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..960ddc32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +.mypy_cache/ +__pycache__/ +tags + +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ From 29e35ed9115328c57dd89b7601b2c3e8dd411a4d Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 14:54:06 +0000 Subject: [PATCH 06/23] wrap up response cleanly into a dict for users --- gotrue/api.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/gotrue/api.py b/gotrue/api.py index 9edda603..237bcc4e 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -6,6 +6,11 @@ from gotrue.lib.constants import COOKIE_OPTIONS +def to_dict(request_response) -> Dict[str, Any]: + """Wrap up request_response to user-friendly dict.""" + return {**request_response.json(), "status_code": request_response.status_code} + + class GoTrueApi: def __init__( self, url: str, headers: Dict[str, Any], cookie_options: Dict[str, Any] @@ -29,7 +34,7 @@ def sign_up_with_email(self, email: str, password: str) -> Dict[str, Any]: request = requests.post( f"{self.url}/signup", json.dumps(credentials), headers=self.headers ) - return request.json() + return to_dict(request) def sign_in_with_email(self, email: str, password: str) -> Dict[str, Any]: """Logs in an existing user using their email address. @@ -47,7 +52,7 @@ def sign_in_with_email(self, email: str, password: str) -> Dict[str, Any]: json.dumps(credentials), headers=self.headers, ) - return request.json() + return to_dict(request) def send_magic_link_email(self, email: str) -> Dict[str, Any]: """Sends a magic login link to an email address. @@ -61,7 +66,7 @@ def send_magic_link_email(self, email: str) -> Dict[str, Any]: request = requests.post( f"{self.url}/magiclink", json.dumps(credentials), headers=self.headers ) - return request.json() + return to_dict(request) def invite_user_by_email(self, email: str) -> Dict[str, Any]: """Sends an invite link to an email address. @@ -75,7 +80,7 @@ def invite_user_by_email(self, email: str) -> Dict[str, Any]: request = requests.post( f"{self.url}/invite", json.dumps(credentials), headers=self.headers ) - return request.json() + return to_dict(request) def reset_password_for_email(self, email: str) -> Dict[str, Any]: """Sends a reset request to an email address. @@ -89,7 +94,7 @@ def reset_password_for_email(self, email: str) -> Dict[str, Any]: request = requests.post( f"{self.url}/recover", json.dumps(credentials), headers=self.headers ) - return request.json() + return to_dict(request) def _create_request_headers(self, jwt: str) -> Dict[str, str]: """Create temporary object. @@ -131,7 +136,7 @@ def get_user(self, jwt: str) -> Dict[str, Any]: request = requests.get( f"{self.url}/user", headers=self._create_request_headers(jwt) ) - return request.json() + return to_dict(request) def update_user(self, jwt: str, **attributes) -> Dict[str, Any]: """Updates the user data through the attributes kwargs.""" @@ -140,7 +145,7 @@ def update_user(self, jwt: str, **attributes) -> Dict[str, Any]: json.dumps(attributes), headers=self._create_request_headers(jwt), ) - return request.json() + return to_dict(request) def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]: """Generates a new JWT. @@ -155,7 +160,7 @@ def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]: json.dumps({"refresh_token": refresh_token}), headers=self.headers, ) - return request.json() + return to_dict(request) def set_auth_cookie(req, res): """Stub for pairty with JS api.""" From 8b3dc488deff62f4acdde54ff63260c83fd65aea Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 14:54:31 +0000 Subject: [PATCH 07/23] updates to get tests passing --- gotrue/client.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/gotrue/client.py b/gotrue/client.py index 392a6c15..369304f0 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -72,7 +72,9 @@ def sign_up(self, email: str, password: str): """ self._remove_session() data = self.api.sign_up_with_email(email, password) - if "confirmed_at" in data.get("user", {}): + if "expires_in" in data and "user" in data: + # The user has confirmed their email or the underlying DB doesn't + # require email confirmation. self._save_session(data) self._notify_all_subscribers("SIGNED_IN") return data @@ -82,18 +84,17 @@ def sign_in( email: Optional[str] = None, password: Optional[str] = None, provider: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: + ) -> Dict[str, Any]: """Log in an exisiting user, or login via a third-party provider.""" self._remove_session() if email is not None and password is None: - self.api.send_magic_link_email(email) - data = None + data = self.api.send_magic_link_email(email) elif email is not None and password is not None: data = self._handle_email_sign_in(email, password) elif provider is not None: data = self._handle_provider_sign_in(provider) else: - raise ValueError("Email or provider must not be None.") + raise ValueError("Email or provider must be defined, both can't be None.") return data def user(self) -> Optional[Dict[str, Any]]: @@ -172,9 +173,14 @@ def _handle_provider_sign_in(self, provider): def _save_session(self, session): """Save session to client.""" + required_keys = ["user", "expires_in"] + if any(key not in session for key in required_keys): + raise ValueError( + f"Session not defined as expected, one of {required_keys} not " + f"present in session dict..") self.current_session = session - self.current_user = session.user - token_expiry_seconds = session.get("expires_in") + self.current_user = session["user"] + token_expiry_seconds = session["expires_in"] if self.auto_refresh_token and token_expiry_seconds is not None: self._set_timeout( self._call_refresh_token, (token_expiry_seconds - 60) * 1000 From c970dfe99e498d18d434f257c37f469e5133e438 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 14:54:45 +0000 Subject: [PATCH 08/23] add for working tests --- tests/test_gotrue.py | 72 ++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index 4ea82cbf..b9a0017c 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -1,40 +1,68 @@ -import pytest +import os +import random +import string +from typing import Any, Dict +import pytest -@pytest.fixture -def client(): - from gotrue import client - return client.Client("http://localhost:9999") +def _random_string(length: int = 10) -> str: + """Generate random string.""" + return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) -def test_settings(client): - res = client.settings() - assert res.status_code == 200 +def _assert_authenticated_user(data: Dict[str, Any]): + """Raise assertion error if user is not logged in correctly.""" + assert "access_token" in data + assert "refresh_token" in data + assert data.get("status_code") == 200 + user = data.get("user") + assert user is not None + assert user.get("id") is not None + assert user.get("aud") == "authenticated" -def test_refresh_access_token(): - pass +@pytest.fixture +def client(): + from gotrue import Client + supabase_url: str = os.environ.get("SUPABASE_TEST_URL") + supabase_key: str = os.environ.get("SUPABASE_TEST_KEY") + url: str = f"{supabase_url}/auth/v1" + return Client( + url=url, + headers={"apiKey": supabase_key, "Authorization": f"Bearer {supabase_key}"}, + ) -@pytest.mark.incremental -class TestUserHandling: - def test_signup(client): - pass - def test_login(client): - pass +def test_refresh_access_token(): + pass - def test_verify(client): - pass - def test_logout(self): - pass +def test_user_auth_flow(client): + """Ensures user can sign up, log out and log into their account.""" + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + user = client.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None + # Sign user out. + client.sign_out() + assert client.current_user is None + assert client.current_session is None + user = client.sign_in(email=random_email, password=random_password) + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None def test_send_magic_link(client): - res = client.send_magic_link("someemail@gmail.com") - assert res.status_code == 200 or res.status_code == 429 + """Tests client can send a magic link to email address.""" + random_email: str = f"{_random_string(10)}@supamail.com" + # We send a magic link if no password is supplied with the email. + data = client.sign_in(email=random_email) + assert data.get("status_code") == 200 def test_recover_email(client): From 4808a3481b7b790410815a7362423f3ef3e0c446 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 15:02:28 +0000 Subject: [PATCH 09/23] cleanup tests etc --- gotrue/client.py | 2 +- tests/test_gotrue.py | 37 ++++++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/gotrue/client.py b/gotrue/client.py index 369304f0..de166e6d 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -111,7 +111,7 @@ def refresh_session(self) -> Dict[str, Any]: Force refreshes the session including the user data incase it was updated in a different session. """ - if self.current_session is None or not self.current_session.access_token: + if self.current_session is None or "access_token" not in self.current_session: raise ValueError("Not logged in.") self._call_refresh_token() data = self.api.get_user(self.current_session.access_token) diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index b9a0017c..7a7cb44c 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -35,10 +35,6 @@ def client(): ) -def test_refresh_access_token(): - pass - - def test_user_auth_flow(client): """Ensures user can sign up, log out and log into their account.""" random_email: str = f"{_random_string(10)}@supamail.com" @@ -57,14 +53,37 @@ def test_user_auth_flow(client): assert client.current_session is not None +def test_get_user_and_session_methods(client): + """Ensure we can get the current user and session via the getters.""" + # Create a random user. + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + user = client.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + # Test that we get not null users and sessions. + assert client.user() is not None + assert client.session() is not None + + +def test_refresh_session(): + """Test user can signup/in and refresh their session.""" + # Create a random user. + random_email: str = f"{_random_string(10)}@supamail.com" + random_password: str = _random_string(20) + user = client.sign_up(email=random_email, password=random_password) + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None + # Refresh users session + user = client.refresh_session() + _assert_authenticated_user(user) + assert client.current_user is not None + assert client.current_session is not None + + def test_send_magic_link(client): """Tests client can send a magic link to email address.""" random_email: str = f"{_random_string(10)}@supamail.com" # We send a magic link if no password is supplied with the email. data = client.sign_in(email=random_email) assert data.get("status_code") == 200 - - -def test_recover_email(client): - res = client.recover("someemail@gmail.com") - assert res.status_code == 200 or res.status_code == 429 From 33096840e7545977a8237ba25b107dc3d6648ef0 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 15:25:12 +0000 Subject: [PATCH 10/23] add some basic documentation in README --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b371b504..dbaa9253 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,75 @@ - # Gotrue-py - -## Status: POC -This is a hacky `gotrue-py` client conceived during a very draggy class. It was developed against the [supabase](https://github.com/supabase/gotrue) fork of netlify's gotrue. The design mirrors that of [GoTrue-elixir](https://github.com/joshnuss/gotrue-elixir) +This is a Python port of the supabase js gotrue client. The current status is that there is not complete feature pairity when compared with the js-client, but this something we are working on. ## Installation +We are still working on making the go-true python library more user-friendly. For now here are some sparse notes on how to install the module -Here's how you'd install the library with gotrue -### With Poetry +### Poetry +```bash +poetry add gotrue +``` -`poetry add gotrue` +### Pip +```bash +pip install gotrue +``` -### With pip -`pip3 install gotrue` +## Differences to the JS client +It should be noted there are differences to the JS client. If you feel particulaly strongly about them and want to motivate a change, feel free to make a GitHub issue and we can discuss it there. +Firstly, feature pairity is not 100% with the JS client. In most cases we match the methods and attributes of the JS client and api classes, but is some places (e.g for browser specific code) it didn't make sense to port the code line for line. -### Usage +There is also a divergence in terms of how errors are raised. In the JS client, the errors are returned as part of the object, which the user can choose to process in whatever way they see fit. In this Python client, we raise the errors directly where they originate, as it was felt this was more Pythonic and adhered to the idioms of the language more directly. + +In JS we return the error, but in Python we just raise it. +```js +const { data, error } = client.sign_up(...) ``` -import gotrue -client = gotrue.Client("www.genericauthwebsite.com") -credentials = {"email": "anemail@gmail.com", "password": "gmebbnok"} -client.sign_up(credentials) -client.sign_in(credentials) +The other key difference is we do not use pascalCase to encode variable and method names. Instead we use the snake_case convention adopted in the Python language. + +## Usage +To instanciate the client, you'll need the URL and any request headers at a minimum. +```python +from gotrue import Client +headers = { + "apiKey": "my-mega-awesome-api-key", + # ... any other headers you might need. +} +client: Client = Client(url="www.genericauthwebsite.com", headers=headers) ``` +To send a magic email link to the user, just provide the email kwarg to the `sign_in` method: +```python +user: Dict[str, Any] = client.sign_up(email="example@gmail.com") +``` + +To login with email and password, provide both to the `sign_in` method: +```python +user: Dict[str, Any] = client.sign_up(email="example@gmail.com", password="*********") +``` + +To sign out of the logged in user, call the `sign_out` method. We can then assert that the session and user are null values. +```python +client.sign_out() +assert client.user() is None +assert client.session() is None +``` + +We can refesh a users session. +```python +# The user should already be signed in at this stage. +user = client.refresh_session() +assert client.user() is not None +assert client.session() is not None +``` -### Development/TODOs -- Figure out to use either Sessions to manage headers or allow passing in of headers -- [] Add Documentation +## Contributions +We would be immensely grateful for any contributions to this project. In particular are the following items: +- [x] Figure out to use either Sessions to manage headers or allow passing in of headers +- [ ] Add documentation. +- [ ] Add more tests. +- [ ] Ensuring feature-parity with the js-client. +- [ ] Supporting 3rd party provider authentication. From 44fc9da6d387b61ed2167ff8bf4a748fcce6bcf1 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 15:26:41 +0000 Subject: [PATCH 11/23] link to js-client --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index dbaa9253..1ede286b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Gotrue-py -This is a Python port of the supabase js gotrue client. The current status is that there is not complete feature pairity when compared with the js-client, but this something we are working on. +This is a Python port of the [supabase js gotrue client](https://github.com/supabase/gotrue-js/). The current status is that there is not complete feature pairity when compared with the js-client, but this something we are working on. ## Installation We are still working on making the go-true python library more user-friendly. For now here are some sparse notes on how to install the module @@ -15,11 +15,11 @@ pip install gotrue ``` ## Differences to the JS client -It should be noted there are differences to the JS client. If you feel particulaly strongly about them and want to motivate a change, feel free to make a GitHub issue and we can discuss it there. +It should be noted there are differences to the [JS client](https://github.com/supabase/gotrue-js/). If you feel particulaly strongly about them and want to motivate a change, feel free to make a GitHub issue and we can discuss it there. -Firstly, feature pairity is not 100% with the JS client. In most cases we match the methods and attributes of the JS client and api classes, but is some places (e.g for browser specific code) it didn't make sense to port the code line for line. +Firstly, feature pairity is not 100% with the [JS client](https://github.com/supabase/gotrue-js/). In most cases we match the methods and attributes of the [JS client](https://github.com/supabase/gotrue-js/) and api classes, but is some places (e.g for browser specific code) it didn't make sense to port the code line for line. -There is also a divergence in terms of how errors are raised. In the JS client, the errors are returned as part of the object, which the user can choose to process in whatever way they see fit. In this Python client, we raise the errors directly where they originate, as it was felt this was more Pythonic and adhered to the idioms of the language more directly. +There is also a divergence in terms of how errors are raised. In the [JS client](https://github.com/supabase/gotrue-js/), the errors are returned as part of the object, which the user can choose to process in whatever way they see fit. In this Python client, we raise the errors directly where they originate, as it was felt this was more Pythonic and adhered to the idioms of the language more directly. In JS we return the error, but in Python we just raise it. ```js From 7dea9002f11286a7945128453d8a93afb32eeb1f Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 15:32:45 +0000 Subject: [PATCH 12/23] hopefully ensure the test ci works --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbadb078..4bca21aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,15 +9,16 @@ on: jobs: test: + env: + SUPABASE_TEST_URL: "https://tfsatoopsijgjhrqplra.supabase.co" + SUPABASE_TEST_KEY: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMjYwOTMyMiwiZXhwIjoxOTI4MTg1MzIyfQ.XL9W5I_VRQ4iyQHVQmjG0BkwRfx6eVyYB3uAKcesukg" name: Test / OS ${{ matrix.os }} / Node ${{ matrix.node }} strategy: matrix: os: [ubuntu-latest] python-version: [3.6, 3.7, 3.8, 3.9] - - runs-on: ${{ matrix.os }} - + # TODO(fedden): We need to discuss these steps: We could just use a test-supabase instance or we could update the docker image and use that for the tests. steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -45,4 +46,5 @@ jobs: time: "5s" - name: Test with pytest run: | - pytest \ No newline at end of file + pytest -sx + From 2b74646a7206d8aad08aa16e0f2f84a956636cdf Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 15:34:04 +0000 Subject: [PATCH 13/23] document the tests --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 1ede286b..04bf253b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ assert client.user() is not None assert client.session() is not None ``` +## Tests +At the moment we use a pre-defined supabase instance to test the functionality. This may change over time. You can run the tests like so: +```bash +SUPABASE_TEST_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMjYwOTMyMiwiZXhwIjoxOTI4MTg1MzIyfQ.XL9W5I_VRQ4iyQHVQmjG0BkwRfx6eVyYB3uAKcesukg" \ +SUPABASE_TEST_URL="https://tfsatoopsijgjhrqplra.supabase.co" \ +pytest -sx +``` + ## Contributions We would be immensely grateful for any contributions to this project. In particular are the following items: - [x] Figure out to use either Sessions to manage headers or allow passing in of headers From 337746cc82f0b256a823464f262b15bbd4741be1 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 15:47:26 +0000 Subject: [PATCH 14/23] ensuring tests pass --- gotrue/client.py | 4 ++-- tests/test_gotrue.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gotrue/client.py b/gotrue/client.py index de166e6d..199418e0 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -114,7 +114,7 @@ def refresh_session(self) -> Dict[str, Any]: if self.current_session is None or "access_token" not in self.current_session: raise ValueError("Not logged in.") self._call_refresh_token() - data = self.api.get_user(self.current_session.access_token) + data = self.api.get_user(self.current_session["access_token"]) self.current_user = data return data @@ -218,7 +218,7 @@ def _call_refresh_token(self, refresh_token: Optional[str] = None): data = self.api.refresh_access_token(refresh_token) if "access_token" in data: self.current_session = data - self.current_user = data.user + self.current_user = data["user"] self._notify_all_subscribers("SIGNED_IN") token_expiry_seconds: int = data["expires_in"] if self.auto_refresh_token and token_expiry_seconds is not None: diff --git a/tests/test_gotrue.py b/tests/test_gotrue.py index 7a7cb44c..cdb85d95 100644 --- a/tests/test_gotrue.py +++ b/tests/test_gotrue.py @@ -65,7 +65,7 @@ def test_get_user_and_session_methods(client): assert client.session() is not None -def test_refresh_session(): +def test_refresh_session(client): """Test user can signup/in and refresh their session.""" # Create a random user. random_email: str = f"{_random_string(10)}@supamail.com" @@ -75,8 +75,8 @@ def test_refresh_session(): assert client.current_user is not None assert client.current_session is not None # Refresh users session - user = client.refresh_session() - _assert_authenticated_user(user) + data = client.refresh_session() + assert data["status_code"] == 200 assert client.current_user is not None assert client.current_session is not None From 94470db34e564fce3cff1e7808ed6757fbb2b199 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sat, 13 Feb 2021 16:00:33 +0000 Subject: [PATCH 15/23] add another todo --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 04bf253b..9f3c76c8 100644 --- a/README.md +++ b/README.md @@ -80,4 +80,5 @@ We would be immensely grateful for any contributions to this project. In particu - [ ] Add more tests. - [ ] Ensuring feature-parity with the js-client. - [ ] Supporting 3rd party provider authentication. +- [ ] Implement a js port of setTimeout for the refresh session code. From a7be2da61269bb63382e9a18bc46e0767536fee0 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sun, 14 Feb 2021 00:17:08 +0000 Subject: [PATCH 16/23] updating the pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ba6f3e7e..21f3ca7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [tool.poetry] name = "gotrue" -version = "0.1.1" +version = "2.0.0" description = "Python Client Library for GoTrue" -authors = ["Joel Lee "] +authors = ["Joel Lee ", "Leon Fedden "] [tool.poetry.dependencies] python = "^3.7.1" From 7cdf3261fedbe0e279fc44d272a614c935e54926 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sun, 14 Feb 2021 01:02:13 +0000 Subject: [PATCH 17/23] enable simple install that works for non-poetry envs --- gotrue/__init__.py | 4 ++-- pyproject.toml | 18 ------------------ requirements.txt | 4 ++++ setup.py | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 20 deletions(-) delete mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.py diff --git a/gotrue/__init__.py b/gotrue/__init__.py index 76fab00c..3bf3386f 100644 --- a/gotrue/__init__.py +++ b/gotrue/__init__.py @@ -1,6 +1,6 @@ -__version__ = '0.2.0' +__version__ = '2.0.0' +from . import lib from . import api from . import client -from . import lib from .client import Client diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 21f3ca7a..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[tool.poetry] -name = "gotrue" -version = "2.0.0" -description = "Python Client Library for GoTrue" -authors = ["Joel Lee ", "Leon Fedden "] - -[tool.poetry.dependencies] -python = "^3.7.1" - -[tool.poetry.dev-dependencies] -pytest = "^4.6" -requests = "^2.25" -sphinx = "*" -recommonmark = "*" - -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a790c7d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pytest==^4.6 +requests==^2.25 +sphinx +recommonmark diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..7341dbbe --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +import glob +import setuptools +from typing import List + +import gotrue + + +def get_scripts_from_bin() -> List[str]: + """Get all local scripts from bin so they are included in the package.""" + return glob.glob("bin/*") + + +def get_package_description() -> str: + """Returns a description of this package from the markdown files.""" + with open("README.md", "r") as stream: + readme: str = stream.read() + return readme + + +setuptools.setup( + name="gotrue", + version=gotrue.__version__, + author="Joel Lee", + author_email="joel@joellee.org", + description="Python Client Library for GoTrue", + long_description=get_package_description(), + long_description_content_type="text/markdown", + url="https://github.com/supabase/gotrue-py", + packages=setuptools.find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + scripts=get_scripts_from_bin(), + python_requires=">=3.7", +) From 68ed44b0e379b9884becc5fbfd4d6a6a7c8768f4 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Sun, 14 Feb 2021 01:13:58 +0000 Subject: [PATCH 18/23] correct version --- gotrue/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gotrue/__init__.py b/gotrue/__init__.py index 3bf3386f..db4869e2 100644 --- a/gotrue/__init__.py +++ b/gotrue/__init__.py @@ -1,4 +1,4 @@ -__version__ = '2.0.0' +__version__ = '0.2.0' from . import lib from . import api From 1828117d628078ed4e8d96083e5050a2f85394a5 Mon Sep 17 00:00:00 2001 From: Leon Fedden Date: Tue, 16 Feb 2021 09:15:15 +0000 Subject: [PATCH 19/23] Update gotrue/api.py Co-authored-by: Lee Yi Jie Joel --- gotrue/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gotrue/api.py b/gotrue/api.py index 237bcc4e..36818a31 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -163,7 +163,7 @@ def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]: return to_dict(request) def set_auth_cookie(req, res): - """Stub for pairty with JS api.""" + """Stub for parity with JS api.""" raise NotImplementedError("set_auth_cookie not implemented.") def get_user_by_cookie(req): From f0e77aaa9a1dd6a6862879be1b53cb38ec3a2a6d Mon Sep 17 00:00:00 2001 From: Leon Fedden Date: Tue, 16 Feb 2021 09:15:30 +0000 Subject: [PATCH 20/23] Update gotrue/api.py Co-authored-by: Lee Yi Jie Joel --- gotrue/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gotrue/api.py b/gotrue/api.py index 36818a31..d36cb8b9 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -167,5 +167,5 @@ def set_auth_cookie(req, res): raise NotImplementedError("set_auth_cookie not implemented.") def get_user_by_cookie(req): - """Stub for pairty with JS api.""" + """Stub for parity with JS api.""" raise NotImplementedError("get_user_by_cookie not implemented.") From 289b1846a4e9bd5b88528ca2a0aff71a42d43b1e Mon Sep 17 00:00:00 2001 From: Leon Fedden Date: Tue, 16 Feb 2021 09:15:41 +0000 Subject: [PATCH 21/23] Update gotrue/client.py Co-authored-by: Lee Yi Jie Joel --- gotrue/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gotrue/client.py b/gotrue/client.py index 199418e0..ab135b75 100644 --- a/gotrue/client.py +++ b/gotrue/client.py @@ -63,7 +63,7 @@ def __init__( def sign_up(self, email: str, password: str): """Creates a new user. - Paramters + Parameters --------- email : str The user's email address. From 3ad5781db4dddf79824d3bdf15cfbea0835a8303 Mon Sep 17 00:00:00 2001 From: leonfedden Date: Tue, 16 Feb 2021 09:20:36 +0000 Subject: [PATCH 22/23] add return statements in documentation --- gotrue/api.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/gotrue/api.py b/gotrue/api.py index d36cb8b9..d591c27a 100644 --- a/gotrue/api.py +++ b/gotrue/api.py @@ -24,11 +24,16 @@ def sign_up_with_email(self, email: str, password: str) -> Dict[str, Any]: """Creates a new user using their email address Parameters - --------- + ---------- email : str The user's email address. password : str The user's password. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. """ credentials = {"email": email, "password": password} request = requests.post( @@ -40,11 +45,16 @@ def sign_in_with_email(self, email: str, password: str) -> Dict[str, Any]: """Logs in an existing user using their email address. Parameters - --------- + ---------- email : str The user's email address. password : str The user's password. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. """ credentials = {"email": email, "password": password} request = requests.post( @@ -58,9 +68,14 @@ def send_magic_link_email(self, email: str) -> Dict[str, Any]: """Sends a magic login link to an email address. Parameters - --------- + ---------- email : str The user's email address. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. """ credentials = {"email": email} request = requests.post( @@ -72,9 +87,14 @@ def invite_user_by_email(self, email: str) -> Dict[str, Any]: """Sends an invite link to an email address. Parameters - --------- + ---------- email : str The user's email address. + + Returns + ------- + request : dict of any + The invite or error message returned by the supabase backend. """ credentials = {"email": email} request = requests.post( @@ -86,9 +106,15 @@ def reset_password_for_email(self, email: str) -> Dict[str, Any]: """Sends a reset request to an email address. Parameters - --------- + ---------- email : str The user's email address. + + Returns + ------- + request : dict of any + The password reset status or error message returned by the supabase + backend. """ credentials = {"email": email} request = requests.post( @@ -106,6 +132,12 @@ def _create_request_headers(self, jwt: str) -> Dict[str, str]: ---------- jwt : str A valid, logged-in JWT. + + Returns + ------- + headers : dict of str + The headers required for a successful request statement with the + supabase backend. """ headers = {**self.headers} headers["Authorization"] = f"Bearer {jwt}" @@ -132,6 +164,11 @@ def get_user(self, jwt: str) -> Dict[str, Any]: ---------- jwt : str A valid, logged-in JWT. + + Returns + ------- + request : dict of any + The user or error message returned by the supabase backend. """ request = requests.get( f"{self.url}/user", headers=self._create_request_headers(jwt) From 608c2b19d675361d54a4a03dfa0ff9116cc0d1ea Mon Sep 17 00:00:00 2001 From: leonfedden Date: Tue, 16 Feb 2021 09:24:47 +0000 Subject: [PATCH 23/23] remove env vars --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bca21aa..952a15c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,6 @@ on: jobs: test: - env: - SUPABASE_TEST_URL: "https://tfsatoopsijgjhrqplra.supabase.co" - SUPABASE_TEST_KEY: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMjYwOTMyMiwiZXhwIjoxOTI4MTg1MzIyfQ.XL9W5I_VRQ4iyQHVQmjG0BkwRfx6eVyYB3uAKcesukg" name: Test / OS ${{ matrix.os }} / Node ${{ matrix.node }} strategy: matrix: