From 554e96847fff851193f1a1cf5dce5640d4733aef Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 08:39:01 -0500 Subject: [PATCH 01/42] feat(api.py): add support for token-based authentication and background token refresh feat(auth.py): add method to create User instance from an id token --- src/otf_api/api.py | 46 +++++++++++++++++++++++++++++++++++--- src/otf_api/models/auth.py | 7 ++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 82b9400..db7e84f 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -1,6 +1,7 @@ import asyncio import contextlib import json +import signal import typing from datetime import date, datetime from math import ceil @@ -78,11 +79,18 @@ async def main(): user: User session: aiohttp.ClientSession - def __init__(self, username: str, password: str): + def __init__(self, username: str | None = None, password: str | None = None, token: str | None = None): self.member: MemberDetail self.home_studio: StudioDetail - self.user = User.login(username, password) + # Handle shutdown + signal.signal(signal.SIGINT, self.shutdown) + signal.signal(signal.SIGTERM, self.shutdown) + + if username and password: + self.user = User.login(username, password) + elif token: + self.user = User.from_token(token) headers = { "Authorization": f"Bearer {self.user.cognito.id_token}", @@ -99,6 +107,8 @@ def __init__(self, username: str, password: str): "koji-member-email": self.user.id_claims_data.email, } + self.start_background_refresh() + @classmethod async def create(cls, username: str, password: str) -> "Api": """Create a new API instance. The username and password are required arguments because even though @@ -113,11 +123,41 @@ async def create(cls, username: str, password: str) -> "Api": self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) return self + @classmethod + async def create_with_token(cls, token: str) -> "Api": + """Create a new API instance. The username and password are required arguments because even though + we cache the token, they expire so quickly that we usually end up needing to re-authenticate. + + Args: + token (str): The token of the user. + """ + self = cls(token=token) + self.member = await self.get_member_detail() + self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + return self + + def start_background_refresh(self) -> None: + """Start the background task for refreshing the token.""" + self._refresh_task = asyncio.create_task(self._run_refresh_on_loop()) + + async def _run_refresh_on_loop(self) -> None: + """Run the refresh token method on a loop to keep the token fresh.""" + try: + while True: + await asyncio.sleep(300) + self.user = self.user.refresh_token() + except asyncio.CancelledError: + pass + + def shutdown(self, *_args) -> None: + """Shutdown the background task and event loop.""" + if self._refresh_task: + self._refresh_task.cancel() + def __del__(self) -> None: if not hasattr(self, "session"): return try: - loop = asyncio.get_event_loop() asyncio.create_task(self._close_session()) # noqa except RuntimeError: loop = asyncio.new_event_loop() diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index f77ff15..13d89e5 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -140,6 +140,13 @@ def login(cls, username: str, password: str) -> "User": user.save_to_disk() return user + @classmethod + def from_token(cls, id_token: str) -> "User": + """Create a User instance from an id token.""" + cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, id_token=id_token) + cognito_user.check_token() + return cls(cognito=cognito_user) + def refresh_token(self) -> "User": """Refresh the user's access token.""" self.cognito.check_token() From b55df47fe825a9e1fac253f463667e7caf535765 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 08:44:28 -0500 Subject: [PATCH 02/42] fix(pyproject.toml): reorder dependencies alphabetically for better readability feat(pyproject.toml): add new development dependencies for testing and code quality fix(api.py): wrap signal handling in try-except block to prevent crashes on unsupported platforms --- poetry.lock | 2 +- pyproject.toml | 41 ++++++++++++++++++++--------------------- src/otf_api/api.py | 7 +++++-- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8924d9e..47ab350 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3236,4 +3236,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d1a5514d076a835f5d2c3477c3383a055deab498d1a6c4766677dcfa00905e55" +content-hash = "68d3c18e5b7f31d6cf08a8a5651343f4a899b20c6a72e3319265603bec837708" diff --git a/pyproject.toml b/pyproject.toml index 08a50af..b9ee2db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,36 +23,37 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.10" aiohttp = "3.9.5" -loguru = "0.7.2" -pydantic = "2.7.3" -pycognito = "2024.5.1" -typer = { version = "^0.12.3", extras = ["cli"] } -pendulum = { version = "^3.0.0", extras = ["cli"] } - - -readchar = { version = "^4.1.0", extras = ["cli"] } humanize = "^4.9.0" -python-box = "^7.2.0" inflection = "0.5.*" +loguru = "0.7.2" +pendulum = "^3.0.0" pint = "0.24.*" +pycognito = "2024.5.1" +pydantic = "2.7.3" +python-box = "^7.2.0" +readchar = "^4.1.0" +typer = "^0.12.3" + [tool.poetry.group.dev.dependencies] +aioresponses = "0.7.6" +black = "^24.4.2" +build = "1.2.1" +bump-my-version = "^0.23.0" +httpx = "^0.27.0" +mypy = "1.10.0" +pre-commit = "3.7.1" pytest = "8.2.2" -pytest-loguru = "0.4.0" pytest-asyncio = "0.23.7" -aioresponses = "0.7.6" -tox = "4.15.1" pytest-cov = "5.0.0" -build = "1.2.1" +pytest-loguru = "0.4.0" ruff = "0.4.9" -pre-commit = "3.7.1" -mypy = "1.10.0" +tox = "4.15.1" twine = "5.1.0" -black = "^24.4.2" -httpx = "^0.27.0" -bump-my-version = "^0.23.0" [tool.poetry.group.docs.dependencies] +griffe-fieldz = "0.1.2" +mike = "2.1.1" mkdocs = "1.6.0" mkdocs-autorefs = "1.0.1" mkdocs-gen-files = "0.5.0" @@ -64,10 +65,8 @@ mkdocs-material-extensions = "1.3.1" mkdocs-section-index = "0.3.9" mkdocstrings = "0.25.1" mkdocstrings-python = "1.10.3" -griffe-fieldz = "0.1.2" -mike = "2.1.1" -setuptools = "^70.0.0" pkginfo = "^1.11.1" +setuptools = "^70.0.0" virtualenv = "^20.26.2" diff --git a/src/otf_api/api.py b/src/otf_api/api.py index db7e84f..9ff025c 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -84,8 +84,11 @@ def __init__(self, username: str | None = None, password: str | None = None, tok self.home_studio: StudioDetail # Handle shutdown - signal.signal(signal.SIGINT, self.shutdown) - signal.signal(signal.SIGTERM, self.shutdown) + try: + signal.signal(signal.SIGINT, self.shutdown) + signal.signal(signal.SIGTERM, self.shutdown) + except Exception: + pass if username and password: self.user = User.login(username, password) From 4eee152cc67bb780d209e71909c3c2fc204a590b Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 08:48:06 -0500 Subject: [PATCH 03/42] refactor(api.py, auth.py): update token handling to use both access_token and id_token for user authentication --- src/otf_api/api.py | 21 +++++++++++++++------ src/otf_api/models/auth.py | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 9ff025c..ccd60bb 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -79,7 +79,13 @@ async def main(): user: User session: aiohttp.ClientSession - def __init__(self, username: str | None = None, password: str | None = None, token: str | None = None): + def __init__( + self, + username: str | None = None, + password: str | None = None, + access_token: str | None = None, + id_token: str | None = None, + ): self.member: MemberDetail self.home_studio: StudioDetail @@ -92,8 +98,10 @@ def __init__(self, username: str | None = None, password: str | None = None, tok if username and password: self.user = User.login(username, password) - elif token: - self.user = User.from_token(token) + elif access_token and id_token: + self.user = User.from_token(access_token, id_token) + else: + raise ValueError("Either username and password or access_token and id_token must be provided.") headers = { "Authorization": f"Bearer {self.user.cognito.id_token}", @@ -127,14 +135,15 @@ async def create(cls, username: str, password: str) -> "Api": return self @classmethod - async def create_with_token(cls, token: str) -> "Api": + async def create_with_token(cls, access_token: str, id_token: str) -> "Api": """Create a new API instance. The username and password are required arguments because even though we cache the token, they expire so quickly that we usually end up needing to re-authenticate. Args: - token (str): The token of the user. + access_token (str): The access token. + id_token (str): The id token. """ - self = cls(token=token) + self = cls(access_token=access_token, id_token=id_token) self.member = await self.get_member_detail() self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) return self diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index 13d89e5..fc5f88e 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -141,9 +141,9 @@ def login(cls, username: str, password: str) -> "User": return user @classmethod - def from_token(cls, id_token: str) -> "User": + def from_token(cls, access_token: str, id_token: str) -> "User": """Create a User instance from an id token.""" - cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, id_token=id_token) + cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.check_token() return cls(cognito=cognito_user) From 0690fef7c377acd285474344c51e6d04c82e8fda Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 08:58:34 -0500 Subject: [PATCH 04/42] fix(auth.py): add token verification to ensure tokens are valid before creating User instance chore(auth.py): add debug print statement for cognito_user to aid in debugging --- src/otf_api/models/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index fc5f88e..93f7c99 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -144,7 +144,9 @@ def login(cls, username: str, password: str) -> "User": def from_token(cls, access_token: str, id_token: str) -> "User": """Create a User instance from an id token.""" cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) + cognito_user.verify_tokens() cognito_user.check_token() + print(f"{cognito_user=}") return cls(cognito=cognito_user) def refresh_token(self) -> "User": From 0c31d988f3cdcffd3f6458915079ada2abc5dd30 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 08:59:20 -0500 Subject: [PATCH 05/42] refactor(auth.py): remove debug print statement for cognito_user to clean up code --- src/otf_api/models/auth.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index 93f7c99..beb01b8 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -146,7 +146,6 @@ def from_token(cls, access_token: str, id_token: str) -> "User": cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.verify_tokens() cognito_user.check_token() - print(f"{cognito_user=}") return cls(cognito=cognito_user) def refresh_token(self) -> "User": From 85185eb5d62ae37dbb443c90b362524126b00541 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 09:06:08 -0500 Subject: [PATCH 06/42] feat(api.py): extend Api.create method to support token-based authentication docs(api.py): add docstring to Api.create method to describe new parameters and return type --- src/otf_api/api.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index ccd60bb..5473547 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -121,7 +121,32 @@ def __init__( self.start_background_refresh() @classmethod - async def create(cls, username: str, password: str) -> "Api": + async def create( + cls, + username: str | None = None, + password: str | None = None, + access_token: str | None = None, + id_token: str | None = None, + ) -> "Api": + """Create a new API instance. Accepts either a username and password or an access token and id token. + + Args: + username (str, None): The username of the user. Default is None. + password (str, None): The password of the user. Default is None. + access_token (str, None): The access token. Default is None. + id_token (str, None): The id token. Default is None. + + Returns: + Api: The API instance. + """ + + self = cls(username=username, password=password, access_token=access_token, id_token=id_token) + self.member = await self.get_member_detail() + self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + return self + + @classmethod + async def create_with_username(cls, username: str, password: str) -> "Api": """Create a new API instance. The username and password are required arguments because even though we cache the token, they expire so quickly that we usually end up needing to re-authenticate. From 12b6f9fed343db8974237728e63829a3a81fc068 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 09:06:36 -0500 Subject: [PATCH 07/42] feat(api.py): add logging and print statement when starting background token refresh task --- src/otf_api/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 5473547..d49aba3 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -175,6 +175,8 @@ async def create_with_token(cls, access_token: str, id_token: str) -> "Api": def start_background_refresh(self) -> None: """Start the background task for refreshing the token.""" + logger.info("Starting background task for refreshing token.") + print("starting background refresh") self._refresh_task = asyncio.create_task(self._run_refresh_on_loop()) async def _run_refresh_on_loop(self) -> None: From 6cdcc29dfb7d5daeea19107b5487d10bb803aacd Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 09:08:26 -0500 Subject: [PATCH 08/42] refactor(api.py): change log level from info to debug for background refresh task --- src/otf_api/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index d49aba3..9a42320 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -175,8 +175,7 @@ async def create_with_token(cls, access_token: str, id_token: str) -> "Api": def start_background_refresh(self) -> None: """Start the background task for refreshing the token.""" - logger.info("Starting background task for refreshing token.") - print("starting background refresh") + logger.debug("Starting background task for refreshing token.") self._refresh_task = asyncio.create_task(self._run_refresh_on_loop()) async def _run_refresh_on_loop(self) -> None: From 11922017384864e6c33c9d2034ad789e6f031441 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 09:09:34 -0500 Subject: [PATCH 09/42] feat(auth.py): add logging for token refresh to improve debugging and monitoring --- src/otf_api/models/auth.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index beb01b8..e2aed31 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import ClassVar +from loguru import logger from pycognito import Cognito, TokenVerificationException from pydantic import Field @@ -145,7 +146,10 @@ def from_token(cls, access_token: str, id_token: str) -> "User": """Create a User instance from an id token.""" cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.verify_tokens() - cognito_user.check_token() + + if cognito_user.check_token(): + logger.debug("Refreshed tokens") + return cls(cognito=cognito_user) def refresh_token(self) -> "User": From 82baf91c0bdf54390d9208adef7133810a2d0602 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 09:38:08 -0500 Subject: [PATCH 10/42] refactor(api.py): move home_studio assignment to a separate async method to improve code readability and maintainability fix(api.py): initialize _ref attribute to None to avoid potential attribute errors --- src/otf_api/api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 9a42320..223ae9e 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -88,6 +88,7 @@ def __init__( ): self.member: MemberDetail self.home_studio: StudioDetail + self._ref = None # Handle shutdown try: @@ -142,7 +143,7 @@ async def create( self = cls(username=username, password=password, access_token=access_token, id_token=id_token) self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + self._ref = asyncio.create_task(self._assign_home_studio(self.member.home_studio_id)) return self @classmethod @@ -154,9 +155,7 @@ async def create_with_username(cls, username: str, password: str) -> "Api": username (str): The username of the user. password (str): The password of the user. """ - self = cls(username, password) - self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + self = cls.create(username=username, password=password) return self @classmethod @@ -168,9 +167,7 @@ async def create_with_token(cls, access_token: str, id_token: str) -> "Api": access_token (str): The access token. id_token (str): The id token. """ - self = cls(access_token=access_token, id_token=id_token) - self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + self = cls.create(access_token=access_token, id_token=id_token) return self def start_background_refresh(self) -> None: @@ -205,6 +202,9 @@ async def _close_session(self) -> None: if not self.session.closed: await self.session.close() + async def _assign_home_studio(self, studio_uuid): + self.home_studio = await self.get_studio_detail(studio_uuid) + async def _do( self, method: str, From 5b7332247c83ccd0391aefe106910e9315d9a52d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 09:39:45 -0500 Subject: [PATCH 11/42] Revert "refactor(api.py): move home_studio assignment to a separate async method to improve code readability and maintainability" This reverts commit 82baf91c0bdf54390d9208adef7133810a2d0602. --- src/otf_api/api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 223ae9e..9a42320 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -88,7 +88,6 @@ def __init__( ): self.member: MemberDetail self.home_studio: StudioDetail - self._ref = None # Handle shutdown try: @@ -143,7 +142,7 @@ async def create( self = cls(username=username, password=password, access_token=access_token, id_token=id_token) self.member = await self.get_member_detail() - self._ref = asyncio.create_task(self._assign_home_studio(self.member.home_studio_id)) + self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) return self @classmethod @@ -155,7 +154,9 @@ async def create_with_username(cls, username: str, password: str) -> "Api": username (str): The username of the user. password (str): The password of the user. """ - self = cls.create(username=username, password=password) + self = cls(username, password) + self.member = await self.get_member_detail() + self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) return self @classmethod @@ -167,7 +168,9 @@ async def create_with_token(cls, access_token: str, id_token: str) -> "Api": access_token (str): The access token. id_token (str): The id token. """ - self = cls.create(access_token=access_token, id_token=id_token) + self = cls(access_token=access_token, id_token=id_token) + self.member = await self.get_member_detail() + self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) return self def start_background_refresh(self) -> None: @@ -202,9 +205,6 @@ async def _close_session(self) -> None: if not self.session.closed: await self.session.close() - async def _assign_home_studio(self, studio_uuid): - self.home_studio = await self.get_studio_detail(studio_uuid) - async def _do( self, method: str, From a8f04acb39255d297f9d7cc41f56ed0cc28890c9 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 11:59:21 -0500 Subject: [PATCH 12/42] feat(api.py): add print statement to debug background task for refreshing token fix(auth.py): change log level from debug to info for token refresh message --- src/otf_api/api.py | 1 + src/otf_api/models/auth.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 9a42320..7c84fd0 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -177,6 +177,7 @@ def start_background_refresh(self) -> None: """Start the background task for refreshing the token.""" logger.debug("Starting background task for refreshing token.") self._refresh_task = asyncio.create_task(self._run_refresh_on_loop()) + print(self._refresh_task) async def _run_refresh_on_loop(self) -> None: """Run the refresh token method on a loop to keep the token fresh.""" diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index e2aed31..f3f9087 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -148,7 +148,7 @@ def from_token(cls, access_token: str, id_token: str) -> "User": cognito_user.verify_tokens() if cognito_user.check_token(): - logger.debug("Refreshed tokens") + logger.info("Refreshed tokens") return cls(cognito=cognito_user) From f23865ef9b22a3c66ecc3bc9bc9c63d30b53065c Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 12:01:00 -0500 Subject: [PATCH 13/42] fix(auth.py): move token refresh log message to correct location and ensure save_to_disk is called only when tokens are refreshed --- src/otf_api/models/auth.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index f3f9087..a610623 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -146,14 +146,13 @@ def from_token(cls, access_token: str, id_token: str) -> "User": """Create a User instance from an id token.""" cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.verify_tokens() - - if cognito_user.check_token(): - logger.info("Refreshed tokens") + cognito_user.check_token() return cls(cognito=cognito_user) def refresh_token(self) -> "User": """Refresh the user's access token.""" - self.cognito.check_token() - self.save_to_disk() + if self.cognito.check_token(): + logger.info("Refreshed tokens") + self.save_to_disk() return self From 63c21997bbefc7ef5f59b805aea6d6534467e31a Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 12:01:30 -0500 Subject: [PATCH 14/42] refactor(api.py): remove unnecessary print statement for refresh task feat(auth.py): add logging to track token refresh process --- src/otf_api/api.py | 1 - src/otf_api/models/auth.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 7c84fd0..9a42320 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -177,7 +177,6 @@ def start_background_refresh(self) -> None: """Start the background task for refreshing the token.""" logger.debug("Starting background task for refreshing token.") self._refresh_task = asyncio.create_task(self._run_refresh_on_loop()) - print(self._refresh_task) async def _run_refresh_on_loop(self) -> None: """Run the refresh token method on a loop to keep the token fresh.""" diff --git a/src/otf_api/models/auth.py b/src/otf_api/models/auth.py index a610623..a894b1a 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/models/auth.py @@ -152,6 +152,7 @@ def from_token(cls, access_token: str, id_token: str) -> "User": def refresh_token(self) -> "User": """Refresh the user's access token.""" + logger.info("Checking tokens...") if self.cognito.check_token(): logger.info("Refreshed tokens") self.save_to_disk() From 264fc8a2a7467a9108488bc59683ef6f7bd2fdfd Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:05:26 -0500 Subject: [PATCH 15/42] fix(api.py): add hasattr check for _refresh_task in shutdown method to prevent attribute error --- src/otf_api/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 9a42320..45e4442 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -189,7 +189,7 @@ async def _run_refresh_on_loop(self) -> None: def shutdown(self, *_args) -> None: """Shutdown the background task and event loop.""" - if self._refresh_task: + if hasattr(self, "_refresh_task") and self._refresh_task: self._refresh_task.cancel() def __del__(self) -> None: From 0ffb3132172dec639fe7ae9de5eb3579933aeaca Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:18:20 -0500 Subject: [PATCH 16/42] refactor: rename Api class to Otf for better clarity fix: correct import paths for User and Otf classes docs: update example scripts to use Otf class instead of Api refactor(bookings.py): rename Api to Otf to reflect updated class name test(test_api.py): update import and usage of Api to Otf to match refactor --- examples/challenge_tracker_examples.py | 4 +- examples/class_bookings_examples.py | 4 +- examples/studio_examples.py | 4 +- examples/workout_examples.py | 4 +- src/otf_api/__init__.py | 6 +-- src/otf_api/api.py | 10 ++--- src/otf_api/{models => }/auth.py | 52 +++++++++++++------------- src/otf_api/cli/app.py | 6 +-- src/otf_api/cli/bookings.py | 12 +++--- tests/test_api.py | 4 +- 10 files changed, 53 insertions(+), 53 deletions(-) rename src/otf_api/{models => }/auth.py (100%) diff --git a/examples/challenge_tracker_examples.py b/examples/challenge_tracker_examples.py index 43b83fa..b640261 100644 --- a/examples/challenge_tracker_examples.py +++ b/examples/challenge_tracker_examples.py @@ -1,7 +1,7 @@ import asyncio import os -from otf_api import Api +from otf_api import Otf from otf_api.models.responses import ChallengeType, EquipmentType USERNAME = os.getenv("OTF_EMAIL") @@ -9,7 +9,7 @@ async def main(): - otf = await Api.create(USERNAME, PASSWORD) + otf = await Otf.create(USERNAME, PASSWORD) # challenge tracker content is an overview of the challenges OTF runs # and your participation in them diff --git a/examples/class_bookings_examples.py b/examples/class_bookings_examples.py index e2ca1f4..9f0990d 100644 --- a/examples/class_bookings_examples.py +++ b/examples/class_bookings_examples.py @@ -2,7 +2,7 @@ import os from collections import Counter -from otf_api import Api +from otf_api import Otf from otf_api.models.responses.bookings import BookingStatus from otf_api.models.responses.classes import ClassType @@ -11,7 +11,7 @@ async def main(): - otf = await Api.create(USERNAME, PASSWORD) + otf = await Otf.create(USERNAME, PASSWORD) resp = await otf.get_member_purchases() print(resp.model_dump_json(indent=4)) diff --git a/examples/studio_examples.py b/examples/studio_examples.py index d9fd8bd..bbf6c8c 100644 --- a/examples/studio_examples.py +++ b/examples/studio_examples.py @@ -1,14 +1,14 @@ import asyncio import os -from otf_api import Api +from otf_api import Otf USERNAME = os.getenv("OTF_EMAIL") PASSWORD = os.getenv("OTF_PASSWORD") async def main(): - otf = await Api.create(USERNAME, PASSWORD) + otf = await Otf.create(USERNAME, PASSWORD) # if you need to figure out what studios are in an area, you can call `search_studios_by_geo` # which takes latitude, longitude, distance, page_index, and page_size as arguments diff --git a/examples/workout_examples.py b/examples/workout_examples.py index a2e84b9..a20d802 100644 --- a/examples/workout_examples.py +++ b/examples/workout_examples.py @@ -1,14 +1,14 @@ import asyncio import os -from otf_api import Api +from otf_api import Otf USERNAME = os.getenv("OTF_EMAIL") PASSWORD = os.getenv("OTF_PASSWORD") async def main(): - otf = await Api.create(USERNAME, PASSWORD) + otf = await Otf.create(USERNAME, PASSWORD) resp = await otf.get_member_lifetime_stats() print(resp.model_dump_json(indent=4)) diff --git a/src/otf_api/__init__.py b/src/otf_api/__init__.py index efac0da..1122557 100644 --- a/src/otf_api/__init__.py +++ b/src/otf_api/__init__.py @@ -3,13 +3,13 @@ from loguru import logger -from .api import Api -from .models.auth import User +from .api import Otf +from .auth import User __version__ = "0.3.0" -__all__ = ["Api", "User"] +__all__ = ["Otf", "User"] logger.remove() logger.add(sink=sys.stdout, level=os.getenv("OTF_LOG_LEVEL", "INFO")) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 45e4442..93d107d 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -11,7 +11,7 @@ from loguru import logger from yarl import URL -from otf_api.models.auth import User +from otf_api.auth import User from otf_api.models.responses.body_composition_list import BodyCompositionList from otf_api.models.responses.book_class import BookClass from otf_api.models.responses.cancel_booking import CancelBooking @@ -57,7 +57,7 @@ class AlreadyBookedError(Exception): REQUEST_HEADERS = {"Authorization": None, "Content-Type": "application/json", "Accept": "application/json"} -class Api: +class Otf: """The main class of the otf-api library. Create an instance using the async method `create`. Example: @@ -127,7 +127,7 @@ async def create( password: str | None = None, access_token: str | None = None, id_token: str | None = None, - ) -> "Api": + ) -> "Otf": """Create a new API instance. Accepts either a username and password or an access token and id token. Args: @@ -146,7 +146,7 @@ async def create( return self @classmethod - async def create_with_username(cls, username: str, password: str) -> "Api": + async def create_with_username(cls, username: str, password: str) -> "Otf": """Create a new API instance. The username and password are required arguments because even though we cache the token, they expire so quickly that we usually end up needing to re-authenticate. @@ -160,7 +160,7 @@ async def create_with_username(cls, username: str, password: str) -> "Api": return self @classmethod - async def create_with_token(cls, access_token: str, id_token: str) -> "Api": + async def create_with_token(cls, access_token: str, id_token: str) -> "Otf": """Create a new API instance. The username and password are required arguments because even though we cache the token, they expire so quickly that we usually end up needing to re-authenticate. diff --git a/src/otf_api/models/auth.py b/src/otf_api/auth.py similarity index 100% rename from src/otf_api/models/auth.py rename to src/otf_api/auth.py index a894b1a..637f803 100644 --- a/src/otf_api/models/auth.py +++ b/src/otf_api/auth.py @@ -66,32 +66,6 @@ class User: def __init__(self, cognito: Cognito): self.cognito = cognito - @property - def member_id(self) -> str: - return self.id_claims_data.cognito_username - - @property - def member_uuid(self) -> str: - return self.access_claims_data.sub - - @property - def access_claims_data(self) -> AccessClaimsData: - return AccessClaimsData(**self.cognito.access_claims) - - @property - def id_claims_data(self) -> IdClaimsData: - return IdClaimsData(**self.cognito.id_claims) - - def save_to_disk(self) -> None: - self.token_path.parent.mkdir(parents=True, exist_ok=True) - data = { - "username": self.cognito.username, - "id_token": self.cognito.id_token, - "access_token": self.cognito.access_token, - "refresh_token": self.cognito.refresh_token, - } - self.token_path.write_text(json.dumps(data)) - @classmethod def cache_file_exists(cls) -> bool: return cls.token_path.exists() @@ -157,3 +131,29 @@ def refresh_token(self) -> "User": logger.info("Refreshed tokens") self.save_to_disk() return self + + @property + def member_id(self) -> str: + return self.id_claims_data.cognito_username + + @property + def member_uuid(self) -> str: + return self.access_claims_data.sub + + @property + def access_claims_data(self) -> AccessClaimsData: + return AccessClaimsData(**self.cognito.access_claims) + + @property + def id_claims_data(self) -> IdClaimsData: + return IdClaimsData(**self.cognito.id_claims) + + def save_to_disk(self) -> None: + self.token_path.parent.mkdir(parents=True, exist_ok=True) + data = { + "username": self.cognito.username, + "id_token": self.cognito.id_token, + "access_token": self.cognito.access_token, + "refresh_token": self.cognito.refresh_token, + } + self.token_path.write_text(json.dumps(data)) diff --git a/src/otf_api/cli/app.py b/src/otf_api/cli/app.py index ea405bb..5269657 100644 --- a/src/otf_api/cli/app.py +++ b/src/otf_api/cli/app.py @@ -11,11 +11,11 @@ from rich.theme import Theme import otf_api +from otf_api.auth import User from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling -from otf_api.models.auth import User if typing.TYPE_CHECKING: - from otf_api.api import Api + from otf_api import Otf class OutputType(str, Enum): @@ -79,7 +79,7 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any): self.console = Console(highlight=False, theme=theme, color_system="auto") # TODO: clean these up later, just don't want warnings everywhere that these could be None - self.api: Api = None # type: ignore + self.api: Otf = None # type: ignore self.username: str = None # type: ignore self.password: str = None # type: ignore self.output: OutputType = None # type: ignore diff --git a/src/otf_api/cli/bookings.py b/src/otf_api/cli/bookings.py index 01be9af..206d9ec 100644 --- a/src/otf_api/cli/bookings.py +++ b/src/otf_api/cli/bookings.py @@ -56,7 +56,7 @@ async def list_bookings( bk_status = BookingStatus.get_from_key_insensitive(status.value) if status else None if not base_app.api: - base_app.api = await otf_api.Api.create(base_app.username, base_app.password) + base_app.api = await otf_api.Otf.create(base_app.username, base_app.password) bookings = await base_app.api.get_bookings(start_date, end_date, bk_status, limit, exclude_cancelled) if base_app.output == "json": @@ -82,7 +82,7 @@ async def book(class_uuid: str = typer.Option(help="Class UUID to cancel")) -> N logger.info(f"Booking class {class_uuid}") if not base_app.api: - base_app.api = await otf_api.Api.create(base_app.username, base_app.password) + base_app.api = await otf_api.Otf.create(base_app.username, base_app.password) booking = await base_app.api.book_class(class_uuid) base_app.console.print(booking) @@ -115,7 +115,7 @@ async def book_interactive( class_type_enums = None if not base_app.api: - base_app.api = await otf_api.Api.create(base_app.username, base_app.password) + base_app.api = await otf_api.Otf.create(base_app.username, base_app.password) classes = await base_app.api.get_classes( studio_uuids, @@ -152,7 +152,7 @@ async def cancel_interactive() -> None: with base_app.console.status("Getting bookings...", spinner="arc"): if not base_app.api: - base_app.api = await otf_api.Api.create(base_app.username, base_app.password) + base_app.api = await otf_api.Otf.create(base_app.username, base_app.password) bookings = await base_app.api.get_bookings() result = prompt_select_from_table( @@ -177,7 +177,7 @@ async def cancel(booking_uuid: str = typer.Option(help="Booking UUID to cancel") logger.info(f"Cancelling booking {booking_uuid}") if not base_app.api: - base_app.api = await otf_api.Api.create(base_app.username, base_app.password) + base_app.api = await otf_api.Otf.create(base_app.username, base_app.password) booking = await base_app.api.cancel_booking(booking_uuid) base_app.console.print(booking) @@ -211,7 +211,7 @@ async def list_classes( class_type_enum = ClassType.get_from_key_insensitive(class_type.value) if class_type else None if not base_app.api: - base_app.api = await otf_api.Api.create(base_app.username, base_app.password) + base_app.api = await otf_api.Otf.create(base_app.username, base_app.password) classes = await base_app.api.get_classes( studio_uuids, include_home_studio, start_date, end_date, limit, class_type_enum, exclude_cancelled ) diff --git a/tests/test_api.py b/tests/test_api.py index fdee331..4f9c1f6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,8 @@ import pytest -from otf_api.api import Api +from otf_api.api import Otf @pytest.mark.asyncio async def test_api_raises_error_if_no_username_password(): with pytest.raises(TypeError): - await Api.create() + await Otf.create() From c520e392dc2f219182cdeacd20cde222be4ffbac Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:21:31 -0500 Subject: [PATCH 17/42] refactor(auth): rename User class to OtfUser for better clarity fix(imports): update all references to User to OtfUser to match new class name docs(auth): update __all__ to reflect the renamed OtfUser class feat(auth): add get_tokens method to OtfUser class to retrieve tokens --- src/otf_api/__init__.py | 4 ++-- src/otf_api/api.py | 8 ++++---- src/otf_api/auth.py | 17 ++++++++++++----- src/otf_api/cli/app.py | 6 +++--- src/otf_api/models/__init__.py | 2 -- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/otf_api/__init__.py b/src/otf_api/__init__.py index 1122557..4a15070 100644 --- a/src/otf_api/__init__.py +++ b/src/otf_api/__init__.py @@ -4,12 +4,12 @@ from loguru import logger from .api import Otf -from .auth import User +from .auth import OtfUser __version__ = "0.3.0" -__all__ = ["Otf", "User"] +__all__ = ["Otf", "OtfUser"] logger.remove() logger.add(sink=sys.stdout, level=os.getenv("OTF_LOG_LEVEL", "INFO")) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 93d107d..90ae170 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -11,7 +11,7 @@ from loguru import logger from yarl import URL -from otf_api.auth import User +from otf_api.auth import OtfUser from otf_api.models.responses.body_composition_list import BodyCompositionList from otf_api.models.responses.book_class import BookClass from otf_api.models.responses.cancel_booking import CancelBooking @@ -76,7 +76,7 @@ async def main(): """ logger: "Logger" = logger - user: User + user: OtfUser session: aiohttp.ClientSession def __init__( @@ -97,9 +97,9 @@ def __init__( pass if username and password: - self.user = User.login(username, password) + self.user = OtfUser.login(username, password) elif access_token and id_token: - self.user = User.from_token(access_token, id_token) + self.user = OtfUser.from_token(access_token, id_token) else: raise ValueError("Either username and password or access_token and id_token must be provided.") diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 637f803..e0365a1 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -59,7 +59,7 @@ def member_uuid(self) -> str: return self.username -class User: +class OtfUser: token_path: ClassVar[Path] = Path("~/.otf/.tokens").expanduser() cognito: Cognito @@ -76,7 +76,7 @@ def username_from_disk(cls) -> str: return val @classmethod - def load_from_disk(cls, username: str, password: str) -> "User": + def load_from_disk(cls, username: str, password: str) -> "OtfUser": """Load a User instance from disk. If the token is invalid, reauthenticate with the provided credentials. Args: @@ -98,7 +98,7 @@ def load_from_disk(cls, username: str, password: str) -> "User": return user @classmethod - def login(cls, username: str, password: str) -> "User": + def login(cls, username: str, password: str) -> "OtfUser": """Login and return a User instance. After a successful login, the user is saved to disk. Args: @@ -116,7 +116,7 @@ def login(cls, username: str, password: str) -> "User": return user @classmethod - def from_token(cls, access_token: str, id_token: str) -> "User": + def from_token(cls, access_token: str, id_token: str) -> "OtfUser": """Create a User instance from an id token.""" cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.verify_tokens() @@ -124,7 +124,7 @@ def from_token(cls, access_token: str, id_token: str) -> "User": return cls(cognito=cognito_user) - def refresh_token(self) -> "User": + def refresh_token(self) -> "OtfUser": """Refresh the user's access token.""" logger.info("Checking tokens...") if self.cognito.check_token(): @@ -148,6 +148,13 @@ def access_claims_data(self) -> AccessClaimsData: def id_claims_data(self) -> IdClaimsData: return IdClaimsData(**self.cognito.id_claims) + def get_tokens(self) -> dict[str, str]: + return { + "id_token": self.cognito.id_token, + "access_token": self.cognito.access_token, + "refresh_token": self.cognito.refresh_token, + } + def save_to_disk(self) -> None: self.token_path.parent.mkdir(parents=True, exist_ok=True) data = { diff --git a/src/otf_api/cli/app.py b/src/otf_api/cli/app.py index 5269657..ac4def6 100644 --- a/src/otf_api/cli/app.py +++ b/src/otf_api/cli/app.py @@ -11,7 +11,7 @@ from rich.theme import Theme import otf_api -from otf_api.auth import User +from otf_api.auth import OtfUser from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling if typing.TYPE_CHECKING: @@ -91,8 +91,8 @@ def set_username(self, username: str | None = None) -> None: self.username = username return - if User.cache_file_exists(): - self.username = User.username_from_disk() + if OtfUser.cache_file_exists(): + self.username = OtfUser.username_from_disk() return raise ValueError("Username not provided and not found in cache") diff --git a/src/otf_api/models/__init__.py b/src/otf_api/models/__init__.py index 3d5a4d6..19ebbb6 100644 --- a/src/otf_api/models/__init__.py +++ b/src/otf_api/models/__init__.py @@ -1,4 +1,3 @@ -from .auth import User from .responses import ( BookClass, BookingList, @@ -29,7 +28,6 @@ ) __all__ = [ - "User", "ChallengeType", "BookingStatus", "EquipmentType", From a081fe2c38c738c1be6d7015d3b2be264e5c44ac Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:28:37 -0500 Subject: [PATCH 18/42] feat(api.py): add support for user object and refresh callback in Otf class refactor(api.py): extract member details population to a separate method fix(auth.py): change refresh_token method to return a boolean indicating success --- src/otf_api/api.py | 74 +++++++++++++++++++++++++++++++++------------ src/otf_api/auth.py | 7 +++-- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 90ae170..6cd9a21 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -3,6 +3,7 @@ import json import signal import typing +from collections.abc import Callable from datetime import date, datetime from math import ceil from typing import Any @@ -85,7 +86,26 @@ def __init__( password: str | None = None, access_token: str | None = None, id_token: str | None = None, + user: OtfUser | None = None, + refresh_callback: typing.Callable | None = None, ): + """Create a new API instance. + + Authentication methods: + --- + - Provide a username and password. + - Provide an access token and id token. + - Provide a user object. + + Args: + username (str, optional): The username of the user. Default is None. + password (str, optional): The password of the user. Default is None. + access_token (str, optional): The access token. Default is None. + id_token (str, optional): The id token. Default is None. + user (OtfUser, optional): A user object. Default is None. + refresh_callback (Callable, optional): A callback function to run when the token is refreshed. Callable + should accept the user object as an argument. Default is None. + """ self.member: MemberDetail self.home_studio: StudioDetail @@ -96,12 +116,14 @@ def __init__( except Exception: pass - if username and password: + if user: + self.user = user + elif username and password: self.user = OtfUser.login(username, password) elif access_token and id_token: self.user = OtfUser.from_token(access_token, id_token) else: - raise ValueError("Either username and password or access_token and id_token must be provided.") + raise ValueError("No valid authentication method provided.") headers = { "Authorization": f"Bearer {self.user.cognito.id_token}", @@ -118,7 +140,12 @@ def __init__( "koji-member-email": self.user.id_claims_data.email, } - self.start_background_refresh() + self.start_background_refresh(refresh_callback) + + async def populate_member_details(self) -> None: + """Populate the member and home studio details.""" + self.member = await self.get_member_detail() + self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) @classmethod async def create( @@ -141,8 +168,7 @@ async def create( """ self = cls(username=username, password=password, access_token=access_token, id_token=id_token) - self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + await self.populate_member_details() return self @classmethod @@ -154,10 +180,7 @@ async def create_with_username(cls, username: str, password: str) -> "Otf": username (str): The username of the user. password (str): The password of the user. """ - self = cls(username, password) - self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) - return self + return cls.create(username=username, password=password) @classmethod async def create_with_token(cls, access_token: str, id_token: str) -> "Otf": @@ -168,22 +191,35 @@ async def create_with_token(cls, access_token: str, id_token: str) -> "Otf": access_token (str): The access token. id_token (str): The id token. """ - self = cls(access_token=access_token, id_token=id_token) - self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) - return self + return cls.create(access_token=access_token, id_token=id_token) + + def start_background_refresh(self, callback: Callable | None = None) -> None: + """Start the background task for refreshing the token. - def start_background_refresh(self) -> None: - """Start the background task for refreshing the token.""" + Args: + callback (Callable, None): A callback function to run when the token is refreshed, + the callable should accept the user object as an argument. Defaults to None. + + """ logger.debug("Starting background task for refreshing token.") - self._refresh_task = asyncio.create_task(self._run_refresh_on_loop()) + self._refresh_task = asyncio.create_task(self._run_refresh_on_loop(callback)) + + async def _run_refresh_on_loop(self, callback: Callable | None = None) -> None: + """Run the refresh token method on a loop to keep the token fresh. - async def _run_refresh_on_loop(self) -> None: - """Run the refresh token method on a loop to keep the token fresh.""" + Args: + callback (Callable, None): A callback function to run when the token is refreshed, + the callable should accept the user object as an argument. Defaults to None. + """ try: while True: await asyncio.sleep(300) - self.user = self.user.refresh_token() + refreshed = self.user.refresh_token() + if refreshed and callback: + if asyncio.iscoroutinefunction(callback): + await callback(self.user) + else: + callback(self.user) except asyncio.CancelledError: pass diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index e0365a1..6d878c4 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -124,13 +124,14 @@ def from_token(cls, access_token: str, id_token: str) -> "OtfUser": return cls(cognito=cognito_user) - def refresh_token(self) -> "OtfUser": + def refresh_token(self) -> bool: """Refresh the user's access token.""" logger.info("Checking tokens...") - if self.cognito.check_token(): + refreshed = self.cognito.check_token() + if refreshed: logger.info("Refreshed tokens") self.save_to_disk() - return self + return refreshed @property def member_id(self) -> str: From e4dacc270285254f97a74ea28972d8bf029841ba Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:38:06 -0500 Subject: [PATCH 19/42] feat(api.py, auth.py): add refresh callback functionality to handle token refresh events refactor(api.py): simplify API instance creation and background refresh logic fix(auth.py): ensure refresh callback is called after token refresh --- src/otf_api/api.py | 72 +++++++++++++-------------------------------- src/otf_api/auth.py | 55 +++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 62 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 6cd9a21..f7ce529 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -87,7 +87,7 @@ def __init__( access_token: str | None = None, id_token: str | None = None, user: OtfUser | None = None, - refresh_callback: typing.Callable | None = None, + refresh_callback: Callable[["OtfUser"], None] | None = None, ): """Create a new API instance. @@ -103,8 +103,8 @@ def __init__( access_token (str, optional): The access token. Default is None. id_token (str, optional): The id token. Default is None. user (OtfUser, optional): A user object. Default is None. - refresh_callback (Callable, optional): A callback function to run when the token is refreshed. Callable - should accept the user object as an argument. Default is None. + refresh_callback (Callable[["OtfUser"], None], optional): A callback function to run when the token is + refreshed. Callable should accept the user object as an argument. Default is None. """ self.member: MemberDetail self.home_studio: StudioDetail @@ -119,9 +119,9 @@ def __init__( if user: self.user = user elif username and password: - self.user = OtfUser.login(username, password) + self.user = OtfUser.login(username, password, refresh_callback=refresh_callback) elif access_token and id_token: - self.user = OtfUser.from_token(access_token, id_token) + self.user = OtfUser.from_token(access_token, id_token, refresh_callback=refresh_callback) else: raise ValueError("No valid authentication method provided.") @@ -140,7 +140,7 @@ def __init__( "koji-member-email": self.user.id_claims_data.email, } - self.start_background_refresh(refresh_callback) + self._refresh_task = asyncio.create_task(self.start_background_refresh()) async def populate_member_details(self) -> None: """Populate the member and home studio details.""" @@ -154,6 +154,7 @@ async def create( password: str | None = None, access_token: str | None = None, id_token: str | None = None, + refresh_callback: Callable[["OtfUser"], None] | None = None, ) -> "Otf": """Create a new API instance. Accepts either a username and password or an access token and id token. @@ -162,64 +163,31 @@ async def create( password (str, None): The password of the user. Default is None. access_token (str, None): The access token. Default is None. id_token (str, None): The id token. Default is None. + refresh_callback (Callable[["OtfUser"], None], optional): A callback function to run when the token is + refreshed. Callable should accept the user object as an argument. Default is None. Returns: Api: The API instance. """ - self = cls(username=username, password=password, access_token=access_token, id_token=id_token) + self = cls( + username=username, + password=password, + access_token=access_token, + id_token=id_token, + refresh_callback=refresh_callback, + ) await self.populate_member_details() return self - @classmethod - async def create_with_username(cls, username: str, password: str) -> "Otf": - """Create a new API instance. The username and password are required arguments because even though - we cache the token, they expire so quickly that we usually end up needing to re-authenticate. - - Args: - username (str): The username of the user. - password (str): The password of the user. - """ - return cls.create(username=username, password=password) - - @classmethod - async def create_with_token(cls, access_token: str, id_token: str) -> "Otf": - """Create a new API instance. The username and password are required arguments because even though - we cache the token, they expire so quickly that we usually end up needing to re-authenticate. - - Args: - access_token (str): The access token. - id_token (str): The id token. - """ - return cls.create(access_token=access_token, id_token=id_token) - - def start_background_refresh(self, callback: Callable | None = None) -> None: - """Start the background task for refreshing the token. - - Args: - callback (Callable, None): A callback function to run when the token is refreshed, - the callable should accept the user object as an argument. Defaults to None. - - """ + async def start_background_refresh(self) -> None: + """Start the background task for refreshing the token.""" logger.debug("Starting background task for refreshing token.") - self._refresh_task = asyncio.create_task(self._run_refresh_on_loop(callback)) - - async def _run_refresh_on_loop(self, callback: Callable | None = None) -> None: - """Run the refresh token method on a loop to keep the token fresh. - - Args: - callback (Callable, None): A callback function to run when the token is refreshed, - the callable should accept the user object as an argument. Defaults to None. - """ + """Run the refresh token method on a loop to keep the token fresh.""" try: while True: await asyncio.sleep(300) - refreshed = self.user.refresh_token() - if refreshed and callback: - if asyncio.iscoroutinefunction(callback): - await callback(self.user) - else: - callback(self.user) + self.user.refresh_token() except asyncio.CancelledError: pass diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 6d878c4..3c96ee8 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,4 +1,6 @@ +import asyncio import json +from collections.abc import Callable from pathlib import Path from typing import ClassVar @@ -63,8 +65,16 @@ class OtfUser: token_path: ClassVar[Path] = Path("~/.otf/.tokens").expanduser() cognito: Cognito - def __init__(self, cognito: Cognito): + def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], None] | None = None): + """Create a new User instance. + + Args: + cognito (Cognito): The Cognito instance to use. + refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. + Callable should accept the user instance as an argument. Defaults to None. + """ self.cognito = cognito + self.refresh_callback = refresh_callback @classmethod def cache_file_exists(cls) -> bool: @@ -76,15 +86,19 @@ def username_from_disk(cls) -> str: return val @classmethod - def load_from_disk(cls, username: str, password: str) -> "OtfUser": + def load_from_disk( + cls, username: str, password: str, refresh_callback: Callable[["OtfUser"], None] | None = None + ) -> "OtfUser": """Load a User instance from disk. If the token is invalid, reauthenticate with the provided credentials. Args: username (str): The username to reauthenticate with. password (str): The password to reauthenticate with. + refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. + Callable should accept the user instance as an argument. Defaults to None. Returns: - User: The loaded user. + OtfUser: The loaded user. """ attr_dict = json.loads(cls.token_path.read_text()) @@ -92,37 +106,53 @@ def load_from_disk(cls, username: str, password: str) -> "OtfUser": cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, **attr_dict) try: cognito_user.verify_tokens() - return cls(cognito=cognito_user) + return cls(cognito=cognito_user, refresh_callback=refresh_callback) except TokenVerificationException: user = cls.login(username, password) return user @classmethod - def login(cls, username: str, password: str) -> "OtfUser": + def login( + cls, username: str, password: str, refresh_callback: Callable[["OtfUser"], None] | None = None + ) -> "OtfUser": """Login and return a User instance. After a successful login, the user is saved to disk. Args: username (str): The username to login with. password (str): The password to login with. + refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. + Callable should accept the user instance as an argument. Defaults to None. Returns: - User: The logged in user. + OtfUser: The logged in user. """ cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, username=username) cognito_user.authenticate(password) cognito_user.check_token() - user = cls(cognito=cognito_user) + user = cls(cognito=cognito_user, refresh_callback=refresh_callback) user.save_to_disk() return user @classmethod - def from_token(cls, access_token: str, id_token: str) -> "OtfUser": - """Create a User instance from an id token.""" + def from_token( + cls, access_token: str, id_token: str, refresh_callback: Callable[["OtfUser"], None] | None = None + ) -> "OtfUser": + """Create a User instance from an id token. + + Args: + access_token (str): The access token. + id_token (str): The id token. + refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. + Callable should accept the user instance as an argument. Defaults to None. + + Returns: + OtfUser: The user instance + """ cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.verify_tokens() cognito_user.check_token() - return cls(cognito=cognito_user) + return cls(cognito=cognito_user, refresh_callback=refresh_callback) def refresh_token(self) -> bool: """Refresh the user's access token.""" @@ -131,6 +161,11 @@ def refresh_token(self) -> bool: if refreshed: logger.info("Refreshed tokens") self.save_to_disk() + if self.refresh_callback: + if asyncio.iscoroutinefunction(self.refresh_callback): + asyncio.create_task(self.refresh_callback(self)) # noqa + else: + self.refresh_callback(self) return refreshed @property From 15a4dd41e4a2eb1af055555523fa021e55d010a0 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:45:21 -0500 Subject: [PATCH 20/42] refactor(api.py): remove background refresh task from Otf class to simplify class responsibilities fix(auth.py): move background refresh task to OtfUser class to ensure token refresh logic is encapsulated within the user class feat(auth.py): add validation for refresh_callback to ensure it is a callable function with one argument --- src/otf_api/api.py | 13 ------------- src/otf_api/auth.py | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index f7ce529..9142327 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -140,8 +140,6 @@ def __init__( "koji-member-email": self.user.id_claims_data.email, } - self._refresh_task = asyncio.create_task(self.start_background_refresh()) - async def populate_member_details(self) -> None: """Populate the member and home studio details.""" self.member = await self.get_member_detail() @@ -180,17 +178,6 @@ async def create( await self.populate_member_details() return self - async def start_background_refresh(self) -> None: - """Start the background task for refreshing the token.""" - logger.debug("Starting background task for refreshing token.") - """Run the refresh token method on a loop to keep the token fresh.""" - try: - while True: - await asyncio.sleep(300) - self.user.refresh_token() - except asyncio.CancelledError: - pass - def shutdown(self, *_args) -> None: """Shutdown the background task and event loop.""" if hasattr(self, "_refresh_task") and self._refresh_task: diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 3c96ee8..8649c68 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,4 +1,5 @@ import asyncio +import inspect import json from collections.abc import Callable from pathlib import Path @@ -74,8 +75,18 @@ def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], Non Callable should accept the user instance as an argument. Defaults to None. """ self.cognito = cognito + + if refresh_callback: + if not asyncio.iscoroutinefunction(refresh_callback) and not callable(refresh_callback): + raise ValueError("refresh_callback must be a callable function.") + sig = inspect.signature(refresh_callback) + if len(sig.parameters) != 1: + raise ValueError("refresh_callback must accept one argument.") + self.refresh_callback = refresh_callback + self._refresh_task = asyncio.create_task(self.start_background_refresh()) + @classmethod def cache_file_exists(cls) -> bool: return cls.token_path.exists() @@ -155,17 +166,16 @@ def from_token( return cls(cognito=cognito_user, refresh_callback=refresh_callback) def refresh_token(self) -> bool: - """Refresh the user's access token.""" + """Refresh the user's access token. + + Returns: + bool: True if the token was refreshed, False otherwise. + """ logger.info("Checking tokens...") refreshed = self.cognito.check_token() if refreshed: logger.info("Refreshed tokens") self.save_to_disk() - if self.refresh_callback: - if asyncio.iscoroutinefunction(self.refresh_callback): - asyncio.create_task(self.refresh_callback(self)) # noqa - else: - self.refresh_callback(self) return refreshed @property @@ -200,3 +210,19 @@ def save_to_disk(self) -> None: "refresh_token": self.cognito.refresh_token, } self.token_path.write_text(json.dumps(data)) + + async def start_background_refresh(self) -> None: + """Start the background task for refreshing the token.""" + logger.debug("Starting background task for refreshing token.") + """Run the refresh token method on a loop to keep the token fresh.""" + try: + while True: + await asyncio.sleep(300) + refreshed = self.refresh_token() + if refreshed and self.refresh_callback: + if asyncio.iscoroutinefunction(self.refresh_callback): + await self.refresh_callback(self) + elif self.refresh_callback: + self.refresh_callback(self) + except asyncio.CancelledError: + pass From afe8959393f56418417853bbe87ba8917ccf1513 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 15:47:41 -0500 Subject: [PATCH 21/42] docs(api.py, auth.py): simplify and clarify the description of refresh_callback parameter in docstrings --- src/otf_api/api.py | 6 ++---- src/otf_api/auth.py | 3 --- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 9142327..5ea9895 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -103,8 +103,7 @@ def __init__( access_token (str, optional): The access token. Default is None. id_token (str, optional): The id token. Default is None. user (OtfUser, optional): A user object. Default is None. - refresh_callback (Callable[["OtfUser"], None], optional): A callback function to run when the token is - refreshed. Callable should accept the user object as an argument. Default is None. + refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. """ self.member: MemberDetail self.home_studio: StudioDetail @@ -161,8 +160,7 @@ async def create( password (str, None): The password of the user. Default is None. access_token (str, None): The access token. Default is None. id_token (str, None): The id token. Default is None. - refresh_callback (Callable[["OtfUser"], None], optional): A callback function to run when the token is - refreshed. Callable should accept the user object as an argument. Default is None. + refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. Returns: Api: The API instance. diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 8649c68..9f8f6c9 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -72,7 +72,6 @@ def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], Non Args: cognito (Cognito): The Cognito instance to use. refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. - Callable should accept the user instance as an argument. Defaults to None. """ self.cognito = cognito @@ -106,7 +105,6 @@ def load_from_disk( username (str): The username to reauthenticate with. password (str): The password to reauthenticate with. refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. - Callable should accept the user instance as an argument. Defaults to None. Returns: OtfUser: The loaded user. @@ -132,7 +130,6 @@ def login( username (str): The username to login with. password (str): The password to login with. refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. - Callable should accept the user instance as an argument. Defaults to None. Returns: OtfUser: The logged in user. From a60ef55abcfb0da2aae3c6cfdab47eff9bbefc3a Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 16:10:24 -0500 Subject: [PATCH 22/42] refactor(auth.py): remove disk caching functionality for OtfUser class refactor(auth.py): remove unused imports and ClassVar token_path refactor(cli/app.py): remove references to OtfUser disk caching methods --- src/otf_api/auth.py | 56 +++--------------------------------------- src/otf_api/cli/app.py | 5 ---- 2 files changed, 3 insertions(+), 58 deletions(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 9f8f6c9..39a67b7 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,12 +1,9 @@ import asyncio import inspect -import json from collections.abc import Callable -from pathlib import Path -from typing import ClassVar from loguru import logger -from pycognito import Cognito, TokenVerificationException +from pycognito import Cognito from pydantic import Field from otf_api.models.base import OtfItemBase @@ -62,8 +59,7 @@ def member_uuid(self) -> str: return self.username -class OtfUser: - token_path: ClassVar[Path] = Path("~/.otf/.tokens").expanduser() +class OtfUser(OtfItemBase): cognito: Cognito def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], None] | None = None): @@ -86,45 +82,11 @@ def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], Non self._refresh_task = asyncio.create_task(self.start_background_refresh()) - @classmethod - def cache_file_exists(cls) -> bool: - return cls.token_path.exists() - - @classmethod - def username_from_disk(cls) -> str: - val: str = json.loads(cls.token_path.read_text())["username"] - return val - - @classmethod - def load_from_disk( - cls, username: str, password: str, refresh_callback: Callable[["OtfUser"], None] | None = None - ) -> "OtfUser": - """Load a User instance from disk. If the token is invalid, reauthenticate with the provided credentials. - - Args: - username (str): The username to reauthenticate with. - password (str): The password to reauthenticate with. - refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. - - Returns: - OtfUser: The loaded user. - - """ - attr_dict = json.loads(cls.token_path.read_text()) - - cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, **attr_dict) - try: - cognito_user.verify_tokens() - return cls(cognito=cognito_user, refresh_callback=refresh_callback) - except TokenVerificationException: - user = cls.login(username, password) - return user - @classmethod def login( cls, username: str, password: str, refresh_callback: Callable[["OtfUser"], None] | None = None ) -> "OtfUser": - """Login and return a User instance. After a successful login, the user is saved to disk. + """Login and return a User instance. Args: username (str): The username to login with. @@ -138,7 +100,6 @@ def login( cognito_user.authenticate(password) cognito_user.check_token() user = cls(cognito=cognito_user, refresh_callback=refresh_callback) - user.save_to_disk() return user @classmethod @@ -172,7 +133,6 @@ def refresh_token(self) -> bool: refreshed = self.cognito.check_token() if refreshed: logger.info("Refreshed tokens") - self.save_to_disk() return refreshed @property @@ -198,16 +158,6 @@ def get_tokens(self) -> dict[str, str]: "refresh_token": self.cognito.refresh_token, } - def save_to_disk(self) -> None: - self.token_path.parent.mkdir(parents=True, exist_ok=True) - data = { - "username": self.cognito.username, - "id_token": self.cognito.id_token, - "access_token": self.cognito.access_token, - "refresh_token": self.cognito.refresh_token, - } - self.token_path.write_text(json.dumps(data)) - async def start_background_refresh(self) -> None: """Start the background task for refreshing the token.""" logger.debug("Starting background task for refreshing token.") diff --git a/src/otf_api/cli/app.py b/src/otf_api/cli/app.py index ac4def6..0830a27 100644 --- a/src/otf_api/cli/app.py +++ b/src/otf_api/cli/app.py @@ -11,7 +11,6 @@ from rich.theme import Theme import otf_api -from otf_api.auth import OtfUser from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling if typing.TYPE_CHECKING: @@ -91,10 +90,6 @@ def set_username(self, username: str | None = None) -> None: self.username = username return - if OtfUser.cache_file_exists(): - self.username = OtfUser.username_from_disk() - return - raise ValueError("Username not provided and not found in cache") def set_log_level(self, level: str) -> None: From 8136a74a4e98fd8853fb83719db44edc83f7796f Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 16:11:57 -0500 Subject: [PATCH 23/42] refactor(api.py): remove signal handling for shutdown to simplify code and avoid potential issues with signal handling in certain environments --- src/otf_api/api.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 5ea9895..b59a68f 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -1,7 +1,6 @@ import asyncio import contextlib import json -import signal import typing from collections.abc import Callable from datetime import date, datetime @@ -108,13 +107,6 @@ def __init__( self.member: MemberDetail self.home_studio: StudioDetail - # Handle shutdown - try: - signal.signal(signal.SIGINT, self.shutdown) - signal.signal(signal.SIGTERM, self.shutdown) - except Exception: - pass - if user: self.user = user elif username and password: @@ -176,14 +168,10 @@ async def create( await self.populate_member_details() return self - def shutdown(self, *_args) -> None: - """Shutdown the background task and event loop.""" - if hasattr(self, "_refresh_task") and self._refresh_task: - self._refresh_task.cancel() - def __del__(self) -> None: if not hasattr(self, "session"): return + try: asyncio.create_task(self._close_session()) # noqa except RuntimeError: From 11d38691334b855f3f74ddcb0f8943557579e55d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 16:17:13 -0500 Subject: [PATCH 24/42] refactor(auth.py): remove inspect module and simplify OtfUser initialization using super() feat(auth.py): add model_config to OtfUser to allow arbitrary types in pydantic model --- src/otf_api/auth.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 39a67b7..5c0008e 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,10 +1,10 @@ import asyncio -import inspect from collections.abc import Callable from loguru import logger from pycognito import Cognito from pydantic import Field +from pydantic.config import ConfigDict from otf_api.models.base import OtfItemBase @@ -60,7 +60,9 @@ def member_uuid(self) -> str: class OtfUser(OtfItemBase): + model_config = ConfigDict(arbitrary_types_allowed=True) cognito: Cognito + refresh_callback: Callable[["OtfUser"], None] | None = None def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], None] | None = None): """Create a new User instance. @@ -69,16 +71,7 @@ def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], Non cognito (Cognito): The Cognito instance to use. refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. """ - self.cognito = cognito - - if refresh_callback: - if not asyncio.iscoroutinefunction(refresh_callback) and not callable(refresh_callback): - raise ValueError("refresh_callback must be a callable function.") - sig = inspect.signature(refresh_callback) - if len(sig.parameters) != 1: - raise ValueError("refresh_callback must accept one argument.") - - self.refresh_callback = refresh_callback + super().__init__(cognito=cognito, refresh_callback=refresh_callback) self._refresh_task = asyncio.create_task(self.start_background_refresh()) From 7508b09c1aa33fe5d138874557db65c805210847 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 16:21:52 -0500 Subject: [PATCH 25/42] feat(api.py): add user parameter to Otf class constructor to allow passing a user object --- src/otf_api/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index b59a68f..f775ba1 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -143,6 +143,7 @@ async def create( password: str | None = None, access_token: str | None = None, id_token: str | None = None, + user: OtfUser | None = None, refresh_callback: Callable[["OtfUser"], None] | None = None, ) -> "Otf": """Create a new API instance. Accepts either a username and password or an access token and id token. @@ -152,6 +153,7 @@ async def create( password (str, None): The password of the user. Default is None. access_token (str, None): The access token. Default is None. id_token (str, None): The id token. Default is None. + user (OtfUser, None): A user object. Default is None. refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. Returns: @@ -163,6 +165,7 @@ async def create( password=password, access_token=access_token, id_token=id_token, + user=user, refresh_callback=refresh_callback, ) await self.populate_member_details() From c1d931555e6b6e96018e0235c972e766ad2ab3fe Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 19:01:12 -0500 Subject: [PATCH 26/42] refactor(api.py): replace home_studio with home_studio_uuid for simplicity refactor(api.py): update imports to use a single import statement for models feat(api.py): add main function to initialize Otf instance using env vars fix(models): update __init__.py to include missing imports and reorder fix(responses): update __init__.py to include missing imports and reorder --- src/otf_api/api.py | 68 +++++++++++++++--------- src/otf_api/models/__init__.py | 43 +++++++++------ src/otf_api/models/responses/__init__.py | 42 +++++++++------ 3 files changed, 95 insertions(+), 58 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index f775ba1..9fc9b68 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -1,6 +1,7 @@ import asyncio import contextlib import json +import os import typing from collections.abc import Callable from datetime import date, datetime @@ -12,32 +13,36 @@ from yarl import URL from otf_api.auth import OtfUser -from otf_api.models.responses.body_composition_list import BodyCompositionList -from otf_api.models.responses.book_class import BookClass -from otf_api.models.responses.cancel_booking import CancelBooking -from otf_api.models.responses.classes import ClassType, DoW, OtfClassList -from otf_api.models.responses.favorite_studios import FavoriteStudioList -from otf_api.models.responses.lifetime_stats import StatsResponse, StatsTime -from otf_api.models.responses.performance_summary_detail import PerformanceSummaryDetail -from otf_api.models.responses.performance_summary_list import PerformanceSummaryList -from otf_api.models.responses.studio_detail import Pagination, StudioDetail, StudioDetailList -from otf_api.models.responses.telemetry import Telemetry -from otf_api.models.responses.telemetry_hr_history import TelemetryHrHistory -from otf_api.models.responses.telemetry_max_hr import TelemetryMaxHr - -from .models import ( +from otf_api.models import ( + BodyCompositionList, + BookClass, BookingList, BookingStatus, + CancelBooking, ChallengeTrackerContent, ChallengeTrackerDetailList, ChallengeType, + ClassType, + DoW, EquipmentType, + FavoriteStudioList, LatestAgreement, MemberDetail, MemberMembership, MemberPurchaseList, + OtfClassList, OutOfStudioWorkoutHistoryList, + Pagination, + PerformanceSummaryDetail, + PerformanceSummaryList, + StatsResponse, + StatsTime, + StudioDetail, + StudioDetailList, StudioServiceList, + Telemetry, + TelemetryHrHistory, + TelemetryMaxHr, TotalClasses, WorkoutList, ) @@ -105,7 +110,7 @@ def __init__( refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. """ self.member: MemberDetail - self.home_studio: StudioDetail + self.home_studio_uuid: str if user: self.user = user @@ -133,8 +138,8 @@ def __init__( async def populate_member_details(self) -> None: """Populate the member and home studio details.""" - self.member = await self.get_member_detail() - self.home_studio = await self.get_studio_detail(self.member.home_studio.studio_uuid) + self.member = await self.get_member_detail(False, False, False) + self.home_studio_uuid = self.member.home_studio.studio_uuid @classmethod async def create( @@ -300,9 +305,9 @@ class types can be provided, if there are multiple there will be a call per clas """ if not studio_uuids: - studio_uuids = [self.home_studio.studio_uuid] - elif include_home_studio and self.home_studio.studio_uuid not in studio_uuids: - studio_uuids.append(self.home_studio.studio_uuid) + studio_uuids = [self.home_studio_uuid] + elif include_home_studio and self.home_studio_uuid not in studio_uuids: + studio_uuids.append(self.home_studio_uuid) path = "/v1/classes" @@ -338,7 +343,7 @@ class types can be provided, if there are multiple there will be a call per clas classes_list.classes = [c for c in classes_list.classes if not c.canceled] for otf_class in classes_list.classes: - otf_class.is_home_studio = otf_class.studio.id == self.home_studio.studio_uuid + otf_class.is_home_studio = otf_class.studio.id == self.home_studio_uuid if day_of_week: classes_list.classes = [c for c in classes_list.classes if c.day_of_week_enum in day_of_week] @@ -473,7 +478,7 @@ async def get_bookings( for booking in data.bookings: if not booking.otf_class: continue - if booking.otf_class.studio.studio_uuid == self.home_studio.studio_uuid: + if booking.otf_class.studio.studio_uuid == self.home_studio_uuid: booking.is_home_studio = True else: booking.is_home_studio = False @@ -718,7 +723,7 @@ async def get_studio_services(self, studio_uuid: str | None = None) -> StudioSer Returns: StudioServiceList: The services available at the studio. """ - studio_uuid = studio_uuid or self.home_studio.studio_uuid + studio_uuid = studio_uuid or self.home_studio_uuid data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services") return StudioServiceList(data=data["data"]) @@ -769,7 +774,7 @@ async def get_studio_detail(self, studio_uuid: str | None = None) -> StudioDetai Returns: StudioDetail: Detailed information about the studio. """ - studio_uuid = studio_uuid or self.home_studio.studio_uuid + studio_uuid = studio_uuid or self.home_studio_uuid path = f"/mobile/v1/studios/{studio_uuid}" params = {"include": "locations"} @@ -805,8 +810,11 @@ async def search_studios_by_geo( """ path = "/mobile/v1/studios" - latitude = latitude or self.home_studio.studio_location.latitude - longitude = longitude or self.home_studio.studio_location.longitude + if not latitude and not longitude: + home_studio = await self.get_studio_detail() + + latitude = home_studio.studio_location.latitude + longitude = home_studio.studio_location.longitude if page_size > 50: self.logger.warning("The API does not support more than 50 results per page, limiting to 50.") @@ -957,3 +965,11 @@ async def get_body_composition_list(self) -> BodyCompositionList: def active_time_to_data_points(active_time: int) -> float: return active_time / 60 * 2 + + +async def main(): + _ = await Otf.create(os.getenv("OTF_USERNAME"), os.getenv("OTF_PASSWORD")) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/otf_api/models/__init__.py b/src/otf_api/models/__init__.py index 19ebbb6..e3387d7 100644 --- a/src/otf_api/models/__init__.py +++ b/src/otf_api/models/__init__.py @@ -1,4 +1,5 @@ from .responses import ( + BodyCompositionList, BookClass, BookingList, BookingStatus, @@ -6,6 +7,8 @@ ChallengeTrackerContent, ChallengeTrackerDetailList, ChallengeType, + ClassType, + DoW, EquipmentType, FavoriteStudioList, HistoryClassStatus, @@ -15,8 +18,11 @@ MemberPurchaseList, OtfClassList, OutOfStudioWorkoutHistoryList, + Pagination, PerformanceSummaryDetail, PerformanceSummaryList, + StatsResponse, + StatsTime, StudioDetail, StudioDetailList, StudioServiceList, @@ -28,30 +34,37 @@ ) __all__ = [ - "ChallengeType", - "BookingStatus", - "EquipmentType", - "HistoryClassStatus", + "BodyCompositionList", + "BookClass", "BookingList", + "BookingStatus", + "CancelBooking", "ChallengeTrackerContent", "ChallengeTrackerDetailList", + "ChallengeType", + "ClassType", + "DoW", + "EquipmentType", + "FavoriteStudioList", + "HistoryClassStatus", "LatestAgreement", "MemberDetail", "MemberMembership", "MemberPurchaseList", + "OtfClassList", "OutOfStudioWorkoutHistoryList", + "Pagination", + "PerformanceSummaryDetail", + "PerformanceSummaryList", + "StatsResponse", + "StatsTime", + "StudioDetail", + "StudioDetailList", "StudioServiceList", - "TotalClasses", - "WorkoutList", - "FavoriteStudioList", - "OtfClassList", - "TelemetryHrHistory", + "StudioStatus", "Telemetry", + "TelemetryHrHistory", "TelemetryMaxHr", - "StudioDetail", - "StudioDetailList", - "PerformanceSummaryDetail", - "PerformanceSummaryList", - "BookClass", - "CancelBooking", + "TotalClasses", + "WorkoutList", ] diff --git a/src/otf_api/models/responses/__init__.py b/src/otf_api/models/responses/__init__.py index 13265e1..f5309d1 100644 --- a/src/otf_api/models/responses/__init__.py +++ b/src/otf_api/models/responses/__init__.py @@ -1,19 +1,21 @@ +from .body_composition_list import BodyCompositionList from .book_class import BookClass from .bookings import BookingList, BookingStatus from .cancel_booking import CancelBooking from .challenge_tracker_content import ChallengeTrackerContent from .challenge_tracker_detail import ChallengeTrackerDetailList -from .classes import OtfClassList +from .classes import ClassType, DoW, OtfClassList from .enums import ChallengeType, EquipmentType, HistoryClassStatus from .favorite_studios import FavoriteStudioList from .latest_agreement import LatestAgreement +from .lifetime_stats import StatsResponse, StatsTime from .member_detail import MemberDetail from .member_membership import MemberMembership from .member_purchases import MemberPurchaseList from .out_of_studio_workout_history import OutOfStudioWorkoutHistoryList from .performance_summary_detail import PerformanceSummaryDetail from .performance_summary_list import PerformanceSummaryList -from .studio_detail import StudioDetail, StudioDetailList +from .studio_detail import Pagination, StudioDetail, StudioDetailList from .studio_services import StudioServiceList from .telemetry import Telemetry from .telemetry_hr_history import TelemetryHrHistory @@ -22,31 +24,37 @@ from .workouts import WorkoutList __all__ = [ + "BodyCompositionList", + "BookClass", "BookingList", + "BookingStatus", + "CancelBooking", "ChallengeTrackerContent", "ChallengeTrackerDetailList", + "ChallengeType", + "ClassType", + "DoW", + "EquipmentType", + "FavoriteStudioList", + "HistoryClassStatus", "LatestAgreement", "MemberDetail", "MemberMembership", "MemberPurchaseList", + "OtfClassList", "OutOfStudioWorkoutHistoryList", + "Pagination", + "PerformanceSummaryDetail", + "PerformanceSummaryList", + "StatsResponse", + "StatsTime", + "StudioDetail", + "StudioDetailList", "StudioServiceList", - "TotalClasses", - "WorkoutList", - "ChallengeType", - "BookingStatus", - "EquipmentType", - "HistoryClassStatus", "StudioStatus", - "FavoriteStudioList", - "OtfClassList", - "TelemetryHrHistory", "Telemetry", + "TelemetryHrHistory", "TelemetryMaxHr", - "StudioDetail", - "StudioDetailList", - "PerformanceSummaryDetail", - "PerformanceSummaryList", - "BookClass", - "CancelBooking", + "TotalClasses", + "WorkoutList", ] From 00fa86fc4af94c7fa9e2a2014dfc93aceadfee2f Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sat, 13 Jul 2024 19:01:35 -0500 Subject: [PATCH 27/42] refactor(api.py): remove unused imports and main function to clean up the code --- src/otf_api/api.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 9fc9b68..0131fba 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -1,7 +1,6 @@ import asyncio import contextlib import json -import os import typing from collections.abc import Callable from datetime import date, datetime @@ -965,11 +964,3 @@ async def get_body_composition_list(self) -> BodyCompositionList: def active_time_to_data_points(active_time: int) -> float: return active_time / 60 * 2 - - -async def main(): - _ = await Otf.create(os.getenv("OTF_USERNAME"), os.getenv("OTF_PASSWORD")) - - -if __name__ == "__main__": - asyncio.run(main()) From 9e9de81ff7b08a91118d1e887acd2498b8dd193f Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 09:47:39 -0500 Subject: [PATCH 28/42] refactor(api.py): remove unused Callable import and refresh_callback parameter refactor(auth.py): remove unused Callable import and background token refresh logic feat(auth.py): add OtfCognito class to handle device metadata and token renewal refactor(api.py): move headers logic to a property method for dynamic token checking --- src/otf_api/api.py | 44 ++++++++-------- src/otf_api/auth.py | 125 ++++++++++++++++++++++++-------------------- 2 files changed, 90 insertions(+), 79 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 0131fba..12a1adf 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -2,7 +2,6 @@ import contextlib import json import typing -from collections.abc import Callable from datetime import date, datetime from math import ceil from typing import Any @@ -90,7 +89,6 @@ def __init__( access_token: str | None = None, id_token: str | None = None, user: OtfUser | None = None, - refresh_callback: Callable[["OtfUser"], None] | None = None, ): """Create a new API instance. @@ -106,7 +104,6 @@ def __init__( access_token (str, optional): The access token. Default is None. id_token (str, optional): The id token. Default is None. user (OtfUser, optional): A user object. Default is None. - refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. """ self.member: MemberDetail self.home_studio_uuid: str @@ -114,18 +111,13 @@ def __init__( if user: self.user = user elif username and password: - self.user = OtfUser.login(username, password, refresh_callback=refresh_callback) + self.user = OtfUser.login(username, password) elif access_token and id_token: - self.user = OtfUser.from_token(access_token, id_token, refresh_callback=refresh_callback) + self.user = OtfUser.from_token(access_token, id_token) else: raise ValueError("No valid authentication method provided.") - headers = { - "Authorization": f"Bearer {self.user.cognito.id_token}", - "Content-Type": "application/json", - "Accept": "application/json", - } - self.session = aiohttp.ClientSession(headers=headers) + self.session = aiohttp.ClientSession(headers=self.headers) # simplify access to member_id and member_uuid self._member_id = self.user.member_id @@ -135,6 +127,19 @@ def __init__( "koji-member-email": self.user.id_claims_data.email, } + @property + def headers(self) -> dict[str, str]: + """Get the headers for the API request.""" + + # check the token before making a request in case it has expired + self.user.cognito.check_token() + + return { + "Authorization": f"Bearer {self.user.cognito.id_token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + async def populate_member_details(self) -> None: """Populate the member and home studio details.""" self.member = await self.get_member_detail(False, False, False) @@ -148,7 +153,6 @@ async def create( access_token: str | None = None, id_token: str | None = None, user: OtfUser | None = None, - refresh_callback: Callable[["OtfUser"], None] | None = None, ) -> "Otf": """Create a new API instance. Accepts either a username and password or an access token and id token. @@ -158,20 +162,12 @@ async def create( access_token (str, None): The access token. Default is None. id_token (str, None): The id token. Default is None. user (OtfUser, None): A user object. Default is None. - refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. Returns: Api: The API instance. """ - self = cls( - username=username, - password=password, - access_token=access_token, - id_token=id_token, - user=user, - refresh_callback=refresh_callback, - ) + self = cls(username=username, password=password, access_token=access_token, id_token=id_token, user=user) await self.populate_member_details() return self @@ -207,6 +203,12 @@ async def _do( logger.debug(f"Making {method!r} request to {full_url}, params: {params}") + # ensure we have headers that contain the most up-to-date token + if not headers: + headers = self.headers + else: + headers.update(self.headers) + text = None async with self.session.request(method, full_url, headers=headers, params=params, **kwargs) as response: with contextlib.suppress(Exception): diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 5c0008e..8998fef 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,8 +1,6 @@ -import asyncio -from collections.abc import Callable +from typing import Any -from loguru import logger -from pycognito import Cognito +from pycognito import AWSSRP, Cognito, MFAChallengeException from pydantic import Field from pydantic.config import ConfigDict @@ -12,6 +10,64 @@ USER_POOL_ID = "us-east-1_dYDxUeyL1" +class OtfCognito(Cognito): + device_metadata: dict[str, Any] + + def _set_tokens(self, tokens: dict[str, Any]): + """Set the tokens and device metadata from the response. + + Args: + tokens (dict): The response from the Cognito service. + """ + super()._set_tokens(tokens) + + if new_metadata := tokens["AuthenticationResult"].get("NewDeviceMetadata"): + self.device_metadata = new_metadata + elif not hasattr(self, "device_metadata"): + self.device_metadata = {} + + def authenticate(self, password: str, client_metadata: dict[str, Any] | None = None): + """ + Authenticate the user using the SRP protocol. Overridden to add `confirm_device` call. + + Args: + password (str): The user's password + client_metadata (dict, optional): Any additional client metadata to send to Cognito + """ + aws = AWSSRP( + username=self.username, + password=password, + pool_id=self.user_pool_id, + client_id=self.client_id, + client=self.client, + client_secret=self.client_secret, + ) + try: + tokens = aws.authenticate_user(client_metadata=client_metadata) + except MFAChallengeException as mfa_challenge: + self.mfa_tokens = mfa_challenge.get_tokens() + raise mfa_challenge + + # Set the tokens and device metadata + self._set_tokens(tokens) + + # Confirm the device so we can use the refresh token + aws.confirm_device(tokens) + + def renew_access_token(self): + """Sets a new access token on the User using the cached refresh token and device metadata.""" + auth_params = {"REFRESH_TOKEN": self.refresh_token} + self._add_secret_hash(auth_params, "SECRET_HASH") + + if self.device_metadata: + auth_params["DEVICE_KEY"] = self.device_metadata["DeviceKey"] + + refresh_response = self.client.initiate_auth( + ClientId=self.client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params + ) + self._set_tokens(refresh_response) + + class IdClaimsData(OtfItemBase): sub: str email_verified: bool @@ -61,72 +117,41 @@ def member_uuid(self) -> str: class OtfUser(OtfItemBase): model_config = ConfigDict(arbitrary_types_allowed=True) - cognito: Cognito - refresh_callback: Callable[["OtfUser"], None] | None = None - - def __init__(self, cognito: Cognito, refresh_callback: Callable[["OtfUser"], None] | None = None): - """Create a new User instance. - - Args: - cognito (Cognito): The Cognito instance to use. - refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. - """ - super().__init__(cognito=cognito, refresh_callback=refresh_callback) - - self._refresh_task = asyncio.create_task(self.start_background_refresh()) + cognito: OtfCognito @classmethod - def login( - cls, username: str, password: str, refresh_callback: Callable[["OtfUser"], None] | None = None - ) -> "OtfUser": + def login(cls, username: str, password: str) -> "OtfUser": """Login and return a User instance. Args: username (str): The username to login with. password (str): The password to login with. - refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. Returns: OtfUser: The logged in user. """ - cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, username=username) + cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, username=username) cognito_user.authenticate(password) cognito_user.check_token() - user = cls(cognito=cognito_user, refresh_callback=refresh_callback) + user = cls(cognito=cognito_user) return user @classmethod - def from_token( - cls, access_token: str, id_token: str, refresh_callback: Callable[["OtfUser"], None] | None = None - ) -> "OtfUser": + def from_token(cls, access_token: str, id_token: str) -> "OtfUser": """Create a User instance from an id token. Args: access_token (str): The access token. id_token (str): The id token. - refresh_callback (Callable[[OtfUser], None], optional): The callback to call when the tokens are refreshed. - Callable should accept the user instance as an argument. Defaults to None. Returns: OtfUser: The user instance """ - cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) + cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) cognito_user.verify_tokens() cognito_user.check_token() - return cls(cognito=cognito_user, refresh_callback=refresh_callback) - - def refresh_token(self) -> bool: - """Refresh the user's access token. - - Returns: - bool: True if the token was refreshed, False otherwise. - """ - logger.info("Checking tokens...") - refreshed = self.cognito.check_token() - if refreshed: - logger.info("Refreshed tokens") - return refreshed + return cls(cognito=cognito_user) @property def member_id(self) -> str: @@ -150,19 +175,3 @@ def get_tokens(self) -> dict[str, str]: "access_token": self.cognito.access_token, "refresh_token": self.cognito.refresh_token, } - - async def start_background_refresh(self) -> None: - """Start the background task for refreshing the token.""" - logger.debug("Starting background task for refreshing token.") - """Run the refresh token method on a loop to keep the token fresh.""" - try: - while True: - await asyncio.sleep(300) - refreshed = self.refresh_token() - if refreshed and self.refresh_callback: - if asyncio.iscoroutinefunction(self.refresh_callback): - await self.refresh_callback(self) - elif self.refresh_callback: - self.refresh_callback(self) - except asyncio.CancelledError: - pass From ecae9094f2b30b0791f8fc28f5dcfe7dd061fea7 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 11:09:52 -0500 Subject: [PATCH 29/42] fix(api.py): enhance error message to include provided authentication kwargs --- src/otf_api/api.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 12a1adf..4989bd5 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -115,7 +115,15 @@ def __init__( elif access_token and id_token: self.user = OtfUser.from_token(access_token, id_token) else: - raise ValueError("No valid authentication method provided.") + kwargs = { + "username": username, + "password": password, + "access_token": access_token, + "id_token": id_token, + "user": user, + } + provided_kwargs = [k for k, v in kwargs.items() if v] + raise ValueError("No valid authentication method provided: Provided kwargs: " + ", ".join(provided_kwargs)) self.session = aiohttp.ClientSession(headers=self.headers) From a938c062ffe63cbb6ad1be291538ae32bfa4b69e Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 11:21:07 -0500 Subject: [PATCH 30/42] feat(auth.py): add device key support to OtfCognito class for enhanced security fix(auth.py): handle TokenVerificationException during access token renewal refactor(auth.py): replace device_metadata with device_key for better clarity docs(auth.py): update docstrings to reflect changes in token handling and device key usage --- src/otf_api/auth.py | 80 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 8998fef..e0f6aa8 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,6 +1,8 @@ from typing import Any +from loguru import logger from pycognito import AWSSRP, Cognito, MFAChallengeException +from pycognito.exceptions import TokenVerificationException from pydantic import Field from pydantic.config import ConfigDict @@ -11,7 +13,41 @@ class OtfCognito(Cognito): - device_metadata: dict[str, Any] + device_key: str | None = None + + def __init__( + self, + user_pool_id, + client_id, + user_pool_region=None, + username=None, + id_token=None, + refresh_token=None, + access_token=None, + client_secret=None, + access_key=None, + secret_key=None, + session=None, + botocore_config=None, + boto3_client_kwargs=None, + device_key: str | None = None, + ): + super().__init__( + user_pool_id, + client_id, + user_pool_region=user_pool_region, + username=username, + id_token=id_token, + refresh_token=refresh_token, + access_token=access_token, + client_secret=client_secret, + access_key=access_key, + secret_key=secret_key, + session=session, + botocore_config=botocore_config, + boto3_client_kwargs=boto3_client_kwargs, + ) + self.device_key = device_key def _set_tokens(self, tokens: dict[str, Any]): """Set the tokens and device metadata from the response. @@ -22,11 +58,9 @@ def _set_tokens(self, tokens: dict[str, Any]): super()._set_tokens(tokens) if new_metadata := tokens["AuthenticationResult"].get("NewDeviceMetadata"): - self.device_metadata = new_metadata - elif not hasattr(self, "device_metadata"): - self.device_metadata = {} + self.device_key = new_metadata["DeviceKey"] - def authenticate(self, password: str, client_metadata: dict[str, Any] | None = None): + def authenticate(self, password: str, client_metadata: dict[str, Any] | None = None, device_key: str | None = None): """ Authenticate the user using the SRP protocol. Overridden to add `confirm_device` call. @@ -51,16 +85,25 @@ def authenticate(self, password: str, client_metadata: dict[str, Any] | None = N # Set the tokens and device metadata self._set_tokens(tokens) - # Confirm the device so we can use the refresh token - aws.confirm_device(tokens) + if not device_key: + # Confirm the device so we can use the refresh token + aws.confirm_device(tokens) + else: + self.device_key = device_key + try: + self.renew_access_token() + except TokenVerificationException: + logger.error("Failed to renew access token. Confirming device.") + self.device_key = None + aws.confirm_device(tokens) def renew_access_token(self): """Sets a new access token on the User using the cached refresh token and device metadata.""" auth_params = {"REFRESH_TOKEN": self.refresh_token} self._add_secret_hash(auth_params, "SECRET_HASH") - if self.device_metadata: - auth_params["DEVICE_KEY"] = self.device_metadata["DeviceKey"] + if self.device_key: + auth_params["DEVICE_KEY"] = self.device_key refresh_response = self.client.initiate_auth( ClientId=self.client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params @@ -137,7 +180,9 @@ def login(cls, username: str, password: str) -> "OtfUser": return user @classmethod - def from_token(cls, access_token: str, id_token: str) -> "OtfUser": + def from_token( + cls, access_token: str, id_token: str, refresh_token: str | None = None, device_key: str | None = None + ) -> "OtfUser": """Create a User instance from an id token. Args: @@ -147,7 +192,14 @@ def from_token(cls, access_token: str, id_token: str) -> "OtfUser": Returns: OtfUser: The user instance """ - cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, access_token=access_token, id_token=id_token) + cognito_user = OtfCognito( + USER_POOL_ID, + CLIENT_ID, + access_token=access_token, + id_token=id_token, + refresh_token=refresh_token, + device_key=device_key, + ) cognito_user.verify_tokens() cognito_user.check_token() @@ -170,8 +222,12 @@ def id_claims_data(self) -> IdClaimsData: return IdClaimsData(**self.cognito.id_claims) def get_tokens(self) -> dict[str, str]: - return { + tokens = { "id_token": self.cognito.id_token, "access_token": self.cognito.access_token, "refresh_token": self.cognito.refresh_token, } + if self.cognito.device_metadata: + tokens["device_key"] = self.cognito.device_metadata["DeviceKey"] + + return tokens From 2f9bc992f565f7c824410fc20cf568df891ae6d9 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 11:24:17 -0500 Subject: [PATCH 31/42] refactor(auth.py): add type hints for better code clarity and type checking fix(auth.py): correct attribute name from device_metadata to device_key for token generation --- src/otf_api/auth.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index e0f6aa8..b9b0f57 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,3 +1,4 @@ +import typing from typing import Any from loguru import logger @@ -8,6 +9,10 @@ from otf_api.models.base import OtfItemBase +if typing.TYPE_CHECKING: + from boto3.session import Session + from botocore.config import Config + CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive USER_POOL_ID = "us-east-1_dYDxUeyL1" @@ -17,19 +22,19 @@ class OtfCognito(Cognito): def __init__( self, - user_pool_id, - client_id, - user_pool_region=None, - username=None, - id_token=None, - refresh_token=None, - access_token=None, - client_secret=None, - access_key=None, - secret_key=None, - session=None, - botocore_config=None, - boto3_client_kwargs=None, + user_pool_id: str, + client_id: str, + user_pool_region: str | None = None, + username: str | None = None, + id_token: str | None = None, + refresh_token: str | None = None, + access_token: str | None = None, + client_secret: str | None = None, + access_key: str | None = None, + secret_key: str | None = None, + session: "Session|None" = None, + botocore_config: "Config|None" = None, + boto3_client_kwargs: dict[str, Any] | None = None, device_key: str | None = None, ): super().__init__( @@ -227,7 +232,7 @@ def get_tokens(self) -> dict[str, str]: "access_token": self.cognito.access_token, "refresh_token": self.cognito.refresh_token, } - if self.cognito.device_metadata: - tokens["device_key"] = self.cognito.device_metadata["DeviceKey"] + if self.cognito.device_key: + tokens["device_key"] = self.cognito.device_key return tokens From f03b6a5f9b135e6334f9cb5a94334b0d98eb5014 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 11:42:25 -0500 Subject: [PATCH 32/42] feat(api.py): add home_studio_uuid parameter to Otf class for better user context management refactor(api.py): rename session attribute to _session and add session property for lazy initialization docs(api.py): update docstrings to include home_studio_uuid parameter and new methods --- src/otf_api/api.py | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 4989bd5..d1ece69 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -80,7 +80,7 @@ async def main(): logger: "Logger" = logger user: OtfUser - session: aiohttp.ClientSession + _session: aiohttp.ClientSession def __init__( self, @@ -89,6 +89,7 @@ def __init__( access_token: str | None = None, id_token: str | None = None, user: OtfUser | None = None, + home_studio_uuid: str | None = None, ): """Create a new API instance. @@ -104,6 +105,7 @@ def __init__( access_token (str, optional): The access token. Default is None. id_token (str, optional): The id token. Default is None. user (OtfUser, optional): A user object. Default is None. + home_studio_uuid (str, optional): The home studio UUID. Default is None. """ self.member: MemberDetail self.home_studio_uuid: str @@ -125,8 +127,6 @@ def __init__( provided_kwargs = [k for k, v in kwargs.items() if v] raise ValueError("No valid authentication method provided: Provided kwargs: " + ", ".join(provided_kwargs)) - self.session = aiohttp.ClientSession(headers=self.headers) - # simplify access to member_id and member_uuid self._member_id = self.user.member_id self._member_uuid = self.user.member_uuid @@ -134,6 +134,7 @@ def __init__( "koji-member-id": self._member_id, "koji-member-email": self.user.id_claims_data.email, } + self.home_studio_uuid = home_studio_uuid @property def headers(self) -> dict[str, str]: @@ -148,11 +149,35 @@ def headers(self) -> dict[str, str]: "Accept": "application/json", } + @property + def session(self) -> aiohttp.ClientSession: + """Get the aiohttp session.""" + if not getattr(self, "_session", None): + self._session = aiohttp.ClientSession(headers=self.headers) + + return self._session + async def populate_member_details(self) -> None: """Populate the member and home studio details.""" + if self.home_studio_uuid is not None: + logger.debug("Home studio UUID already set, skipping member details population.") + return + self.member = await self.get_member_detail(False, False, False) self.home_studio_uuid = self.member.home_studio.studio_uuid + def get_hydration_dict(self) -> dict[str, Any]: + """Get the hydration dictionary to store the user's tokens and home studio UUID. + + This allows the Otf object to be re-created without needing to re-authenticate. + + Returns: + dict: The hydration dictionary. + """ + data = self.user.get_tokens() + data["home_studio_uuid"] = self.home_studio_uuid + return data + @classmethod async def create( cls, @@ -161,6 +186,7 @@ async def create( access_token: str | None = None, id_token: str | None = None, user: OtfUser | None = None, + home_studio_uuid: str | None = None, ) -> "Otf": """Create a new API instance. Accepts either a username and password or an access token and id token. @@ -170,12 +196,20 @@ async def create( access_token (str, None): The access token. Default is None. id_token (str, None): The id token. Default is None. user (OtfUser, None): A user object. Default is None. + home_studio_uuid (str, None): The home studio UUID. Default is None. Returns: Api: The API instance. """ - self = cls(username=username, password=password, access_token=access_token, id_token=id_token, user=user) + self = cls( + username=username, + password=password, + access_token=access_token, + id_token=id_token, + user=user, + home_studio_uuid=home_studio_uuid, + ) await self.populate_member_details() return self From 8577ad154a1e46764a422ddc0478abdd15d0719b Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 11:43:56 -0500 Subject: [PATCH 33/42] feat(api.py): add hydrate method to Otf class to create instances from a dictionary --- src/otf_api/api.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index d1ece69..e4ae265 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -178,6 +178,21 @@ def get_hydration_dict(self) -> dict[str, Any]: data["home_studio_uuid"] = self.home_studio_uuid return data + @classmethod + def hydrate(cls, data: dict[str, Any]) -> "Otf": + """Create a new API instance from a hydration dictionary. + + Args: + data (dict): The hydration dictionary. + + Returns: + Otf: The API instance. + """ + user = OtfUser.from_token( + data["access_token"], data["id_token"], data.get("refresh_token"), data.get("device_key") + ) + return cls(user=user, home_studio_uuid=data["home_studio_uuid"]) + @classmethod async def create( cls, From c5e25605eb2b3f2beba8b5929fc9a221c71801f0 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 13:33:21 -0500 Subject: [PATCH 34/42] feat(api.py): add optional device_key parameter to hydrate method to allow more flexible instantiation refactor(auth.py): move device_key inclusion to a property method for cleaner token retrieval --- src/otf_api/api.py | 4 ++-- src/otf_api/auth.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index e4ae265..3d3ff0a 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -179,7 +179,7 @@ def get_hydration_dict(self) -> dict[str, Any]: return data @classmethod - def hydrate(cls, data: dict[str, Any]) -> "Otf": + def hydrate(cls, data: dict[str, Any], device_key: str | None = None) -> "Otf": """Create a new API instance from a hydration dictionary. Args: @@ -189,7 +189,7 @@ def hydrate(cls, data: dict[str, Any]) -> "Otf": Otf: The API instance. """ user = OtfUser.from_token( - data["access_token"], data["id_token"], data.get("refresh_token"), data.get("device_key") + data["access_token"], data["id_token"], data.get("refresh_token"), device_key=device_key ) return cls(user=user, home_studio_uuid=data["home_studio_uuid"]) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index b9b0f57..90efc1a 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -227,12 +227,12 @@ def id_claims_data(self) -> IdClaimsData: return IdClaimsData(**self.cognito.id_claims) def get_tokens(self) -> dict[str, str]: - tokens = { + return { "id_token": self.cognito.id_token, "access_token": self.cognito.access_token, "refresh_token": self.cognito.refresh_token, } - if self.cognito.device_key: - tokens["device_key"] = self.cognito.device_key - return tokens + @property + def device_key(self) -> str: + return self.cognito.device_key From 1538fecc50dfc47366dc96857da5bc6d9e6d8d8a Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 22:49:49 -0500 Subject: [PATCH 35/42] refactor(auth.py): rename device_key to _device_key to follow encapsulation feat(auth.py): add property and setter for device_key with logging for security --- src/otf_api/auth.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 90efc1a..4174d94 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -18,7 +18,7 @@ class OtfCognito(Cognito): - device_key: str | None = None + _device_key: str | None = None def __init__( self, @@ -54,6 +54,16 @@ def __init__( ) self.device_key = device_key + @property + def device_key(self) -> str: + return self._device_key + + @device_key.setter + def device_key(self, value: str): + redacted_value = value[:4] + "*" * (len(value) - 8) + value[-4:] + logger.info(f"Setting device key: {redacted_value}") + self._device_key = value + def _set_tokens(self, tokens: dict[str, Any]): """Set the tokens and device metadata from the response. @@ -108,6 +118,7 @@ def renew_access_token(self): self._add_secret_hash(auth_params, "SECRET_HASH") if self.device_key: + logger.info("Using device key for refresh token") auth_params["DEVICE_KEY"] = self.device_key refresh_response = self.client.initiate_auth( From 2503728cff9ce7ce252be1d673a2413cdb1a87c2 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 22:52:07 -0500 Subject: [PATCH 36/42] fix(auth.py): update device_key property to handle None type values --- src/otf_api/auth.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 4174d94..01ec68c 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -55,11 +55,15 @@ def __init__( self.device_key = device_key @property - def device_key(self) -> str: + def device_key(self) -> str | None: return self._device_key @device_key.setter - def device_key(self, value: str): + def device_key(self, value: str | None): + if not value: + self._device_key = value + return + redacted_value = value[:4] + "*" * (len(value) - 8) + value[-4:] logger.info(f"Setting device key: {redacted_value}") self._device_key = value From 101f195c7329a4850d22046b84b7a5dd1cf7f578 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 22:53:01 -0500 Subject: [PATCH 37/42] feat(auth.py): add logging when clearing device key to improve traceability --- src/otf_api/auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 01ec68c..c12ad0f 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -61,6 +61,7 @@ def device_key(self) -> str | None: @device_key.setter def device_key(self, value: str | None): if not value: + logger.info("Clearing device key") self._device_key = value return From 30faeb6267a0bb1850798785badf8bc501437fdd Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 14 Jul 2024 22:56:23 -0500 Subject: [PATCH 38/42] feat(auth.py): add check_token method to verify and optionally renew access tokens --- src/otf_api/auth.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index c12ad0f..1d053b3 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -1,6 +1,8 @@ import typing from typing import Any +import jwt +import pendulum from loguru import logger from pycognito import AWSSRP, Cognito, MFAChallengeException from pycognito.exceptions import TokenVerificationException @@ -117,6 +119,27 @@ def authenticate(self, password: str, client_metadata: dict[str, Any] | None = N self.device_key = None aws.confirm_device(tokens) + def check_token(self, renew=True): + """ + Checks the exp attribute of the access_token and either refreshes + the tokens by calling the renew_access_tokens method or does nothing + :param renew: bool indicating whether to refresh on expiration + :return: bool indicating whether access_token has expired + """ + if not self.access_token: + raise AttributeError("Access Token Required to Check Token") + now = pendulum.now() + dec_access_token = jwt.decode(self.access_token, options={"verify_signature": False}) + + exp = pendulum.DateTime.fromtimestamp(dec_access_token["exp"]) + if now > exp.subtract(minutes=15): + expired = True + if renew: + self.renew_access_token() + else: + expired = False + return expired + def renew_access_token(self): """Sets a new access token on the User using the cached refresh token and device metadata.""" auth_params = {"REFRESH_TOKEN": self.refresh_token} From 04c429468f45a9023c3c9cae2fe595f63d7d88cd Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 18 Jul 2024 21:07:41 -0500 Subject: [PATCH 39/42] refactor(api.py): remove async create method and related hydration methods to simplify class structure feat(api.py): add support for refresh_token and device_key in Otf class initialization fix(api.py): add synchronous member details retrieval to avoid async initialization issues --- src/otf_api/api.py | 141 +++++++++++---------------------------------- 1 file changed, 33 insertions(+), 108 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 3d3ff0a..6fef960 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -7,6 +7,7 @@ from typing import Any import aiohttp +import requests from loguru import logger from yarl import URL @@ -61,23 +62,6 @@ class AlreadyBookedError(Exception): class Otf: - """The main class of the otf-api library. Create an instance using the async method `create`. - - Example: - --- - ```python - import asyncio - from otf_api import Api - - async def main(): - otf = await Api.create("username", "password") - print(otf.member) - - if __name__ == "__main__": - asyncio.run(main()) - ``` - """ - logger: "Logger" = logger user: OtfUser _session: aiohttp.ClientSession @@ -88,10 +72,11 @@ def __init__( password: str | None = None, access_token: str | None = None, id_token: str | None = None, + refresh_token: str | None = None, + device_key: str | None = None, user: OtfUser | None = None, - home_studio_uuid: str | None = None, ): - """Create a new API instance. + """Create a new Otf instance. Authentication methods: --- @@ -104,28 +89,27 @@ def __init__( password (str, optional): The password of the user. Default is None. access_token (str, optional): The access token. Default is None. id_token (str, optional): The id token. Default is None. + refresh_token (str, optional): The refresh token. Default is None. + device_key (str, optional): The device key. Default is None. user (OtfUser, optional): A user object. Default is None. - home_studio_uuid (str, optional): The home studio UUID. Default is None. """ + self.member: MemberDetail self.home_studio_uuid: str if user: self.user = user - elif username and password: - self.user = OtfUser.login(username, password) - elif access_token and id_token: - self.user = OtfUser.from_token(access_token, id_token) + elif username and password or (access_token and id_token): + self.user = OtfUser( + username=username, + password=password, + access_token=access_token, + id_token=id_token, + refresh_token=refresh_token, + device_key=device_key, + ) else: - kwargs = { - "username": username, - "password": password, - "access_token": access_token, - "id_token": id_token, - "user": user, - } - provided_kwargs = [k for k, v in kwargs.items() if v] - raise ValueError("No valid authentication method provided: Provided kwargs: " + ", ".join(provided_kwargs)) + raise ValueError("No valid authentication method provided") # simplify access to member_id and member_uuid self._member_id = self.user.member_id @@ -134,15 +118,29 @@ def __init__( "koji-member-id": self._member_id, "koji-member-email": self.user.id_claims_data.email, } - self.home_studio_uuid = home_studio_uuid + self.member = self._get_member_details_sync() + self.home_studio_uuid = self.member.home_studio.studio_uuid + + def _get_member_details_sync(self) -> MemberDetail: + """Get the member details synchronously. + + This is used to get the member details when the API is first initialized, to let use initialize + without needing to await the member details. + + Returns: + MemberDetail: The member details. + """ + url = f"https://{API_BASE_URL}/member/members/{self._member_id}" + resp = requests.get(url, headers=self.headers) + return MemberDetail(**resp.json()["data"]) @property def headers(self) -> dict[str, str]: """Get the headers for the API request.""" # check the token before making a request in case it has expired - self.user.cognito.check_token() + self.user.cognito.check_token() return { "Authorization": f"Bearer {self.user.cognito.id_token}", "Content-Type": "application/json", @@ -157,77 +155,6 @@ def session(self) -> aiohttp.ClientSession: return self._session - async def populate_member_details(self) -> None: - """Populate the member and home studio details.""" - if self.home_studio_uuid is not None: - logger.debug("Home studio UUID already set, skipping member details population.") - return - - self.member = await self.get_member_detail(False, False, False) - self.home_studio_uuid = self.member.home_studio.studio_uuid - - def get_hydration_dict(self) -> dict[str, Any]: - """Get the hydration dictionary to store the user's tokens and home studio UUID. - - This allows the Otf object to be re-created without needing to re-authenticate. - - Returns: - dict: The hydration dictionary. - """ - data = self.user.get_tokens() - data["home_studio_uuid"] = self.home_studio_uuid - return data - - @classmethod - def hydrate(cls, data: dict[str, Any], device_key: str | None = None) -> "Otf": - """Create a new API instance from a hydration dictionary. - - Args: - data (dict): The hydration dictionary. - - Returns: - Otf: The API instance. - """ - user = OtfUser.from_token( - data["access_token"], data["id_token"], data.get("refresh_token"), device_key=device_key - ) - return cls(user=user, home_studio_uuid=data["home_studio_uuid"]) - - @classmethod - async def create( - cls, - username: str | None = None, - password: str | None = None, - access_token: str | None = None, - id_token: str | None = None, - user: OtfUser | None = None, - home_studio_uuid: str | None = None, - ) -> "Otf": - """Create a new API instance. Accepts either a username and password or an access token and id token. - - Args: - username (str, None): The username of the user. Default is None. - password (str, None): The password of the user. Default is None. - access_token (str, None): The access token. Default is None. - id_token (str, None): The id token. Default is None. - user (OtfUser, None): A user object. Default is None. - home_studio_uuid (str, None): The home studio UUID. Default is None. - - Returns: - Api: The API instance. - """ - - self = cls( - username=username, - password=password, - access_token=access_token, - id_token=id_token, - user=user, - home_studio_uuid=home_studio_uuid, - ) - await self.populate_member_details() - return self - def __del__(self) -> None: if not hasattr(self, "session"): return @@ -276,10 +203,8 @@ async def _do( except aiohttp.ClientResponseError as e: logger.exception(f"Error making request: {e}") logger.exception(f"Response: {text}") - # raise except Exception as e: logger.exception(f"Error making request: {e}") - # raise return await response.json() From 80a64ac567cdcc7a0e42dbc36884ed6acabea0f1 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 18 Jul 2024 21:07:53 -0500 Subject: [PATCH 40/42] feat(auth.py): add class methods for creating OtfCognito instances from tokens and login credentials refactor(auth.py): update OtfUser class to use new OtfCognito methods and improve initialization logic fix(auth.py): add type annotations and return types to check_token method for better clarity and type safety --- src/otf_api/auth.py | 109 +++++++++++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/src/otf_api/auth.py b/src/otf_api/auth.py index 1d053b3..ce11d9d 100644 --- a/src/otf_api/auth.py +++ b/src/otf_api/auth.py @@ -119,7 +119,7 @@ def authenticate(self, password: str, client_metadata: dict[str, Any] | None = N self.device_key = None aws.confirm_device(tokens) - def check_token(self, renew=True): + def check_token(self, renew: bool = True) -> bool: """ Checks the exp attribute of the access_token and either refreshes the tokens by calling the renew_access_tokens method or does nothing @@ -154,6 +154,49 @@ def renew_access_token(self): ) self._set_tokens(refresh_response) + @classmethod + def from_token( + cls, access_token: str, id_token: str, refresh_token: str | None = None, device_key: str | None = None + ) -> "OtfCognito": + """Create an OtfCognito instance from an id token. + + Args: + access_token (str): The access token. + id_token (str): The id token. + refresh_token (str, optional): The refresh token. Defaults to None. + device_key (str, optional): The device key. Defaults + + Returns: + OtfCognito: The user instance + """ + cognito = OtfCognito( + USER_POOL_ID, + CLIENT_ID, + access_token=access_token, + id_token=id_token, + refresh_token=refresh_token, + device_key=device_key, + ) + cognito.verify_tokens() + cognito.check_token() + return cognito + + @classmethod + def login(cls, username: str, password: str) -> "OtfCognito": + """Create an OtfCognito instance from a username and password. + + Args: + username (str): The username to login with. + password (str): The password to login with. + + Returns: + OtfCognito: The logged in user. + """ + cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, username=username) + cognito_user.authenticate(password) + cognito_user.check_token() + return cognito_user + class IdClaimsData(OtfItemBase): sub: str @@ -206,48 +249,42 @@ class OtfUser(OtfItemBase): model_config = ConfigDict(arbitrary_types_allowed=True) cognito: OtfCognito - @classmethod - def login(cls, username: str, password: str) -> "OtfUser": - """Login and return a User instance. + def __init__( + self, + username: str | None = None, + password: str | None = None, + id_token: str | None = None, + access_token: str | None = None, + refresh_token: str | None = None, + device_key: str | None = None, + cognito: OtfCognito | None = None, + ): + """Create a User instance. Args: - username (str): The username to login with. - password (str): The password to login with. + username (str, optional): The username to login with. Defaults to None. + password (str, optional): The password to login with. Defaults to None. + id_token (str, optional): The id token. Defaults to None. + access_token (str, optional): The access token. Defaults to None. + refresh_token (str, optional): The refresh token. Defaults to None. + device_key (str, optional): The device key. Defaults to None. + cognito (OtfCognito, optional): A Cognito instance. Defaults to None. - Returns: - OtfUser: The logged in user. - """ - cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, username=username) - cognito_user.authenticate(password) - cognito_user.check_token() - user = cls(cognito=cognito_user) - return user + Raises: + ValueError: Must provide either username and password or id token - @classmethod - def from_token( - cls, access_token: str, id_token: str, refresh_token: str | None = None, device_key: str | None = None - ) -> "OtfUser": - """Create a User instance from an id token. - - Args: - access_token (str): The access token. - id_token (str): The id token. - Returns: - OtfUser: The user instance """ - cognito_user = OtfCognito( - USER_POOL_ID, - CLIENT_ID, - access_token=access_token, - id_token=id_token, - refresh_token=refresh_token, - device_key=device_key, - ) - cognito_user.verify_tokens() - cognito_user.check_token() + if cognito: + cognito = cognito + elif username and password: + cognito = OtfCognito.login(username, password) + elif access_token and id_token: + cognito = OtfCognito.from_token(access_token, id_token, refresh_token, device_key) + else: + raise ValueError("Must provide either username and password or id token.") - return cls(cognito=cognito_user) + super().__init__(cognito=cognito) @property def member_id(self) -> str: From 3adcd6cdda37d9b89ea51d6eddd2be389dd261dc Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 18 Jul 2024 21:08:15 -0500 Subject: [PATCH 41/42] docs(README.md, usage.md): update class name from `Api` to `Otf` for accuracy fix(examples): correct instantiation of `Otf` class in examples to remove async creation method --- README.md | 2 +- docs/usage.md | 2 +- examples/challenge_tracker_examples.py | 2 +- examples/class_bookings_examples.py | 2 +- examples/studio_examples.py | 2 +- examples/workout_examples.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6f53d76..cb03249 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pip install otf-api ## Overview -To use the API, you need to create an instance of the `Api` class, providing your email address and password. This will authenticate you with the API and allow you to make requests. When the `Api` object is created it automatically grabs your member details and home studio, to simplify the process of making requests. +To use the API, you need to create an instance of the `Otf` class, providing your email address and password. This will authenticate you with the API and allow you to make requests. When the `Otf` object is created it automatically grabs your member details and home studio, to simplify the process of making requests. See the [examples](./examples) for more information on how to use the API. diff --git a/docs/usage.md b/docs/usage.md index 050fe3a..c972615 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,7 +2,7 @@ ## Overview -To use the API, you need to create an instance of the `Api` class, providing your email address and password. This will authenticate you with the API and allow you to make requests. When the `Api` object is created it automatically grabs your member details and home studio, to simplify the process of making requests. +To use the API, you need to create an instance of the `Otf` class, providing your email address and password. This will authenticate you with the API and allow you to make requests. When the `Otf` object is created it automatically grabs your member details and home studio, to simplify the process of making requests. ### Data diff --git a/examples/challenge_tracker_examples.py b/examples/challenge_tracker_examples.py index b640261..bb40a4f 100644 --- a/examples/challenge_tracker_examples.py +++ b/examples/challenge_tracker_examples.py @@ -9,7 +9,7 @@ async def main(): - otf = await Otf.create(USERNAME, PASSWORD) + otf = Otf(USERNAME, PASSWORD) # challenge tracker content is an overview of the challenges OTF runs # and your participation in them diff --git a/examples/class_bookings_examples.py b/examples/class_bookings_examples.py index 9f0990d..61257da 100644 --- a/examples/class_bookings_examples.py +++ b/examples/class_bookings_examples.py @@ -11,7 +11,7 @@ async def main(): - otf = await Otf.create(USERNAME, PASSWORD) + otf = otf = Otf(USERNAME, PASSWORD) resp = await otf.get_member_purchases() print(resp.model_dump_json(indent=4)) diff --git a/examples/studio_examples.py b/examples/studio_examples.py index bbf6c8c..f2194b4 100644 --- a/examples/studio_examples.py +++ b/examples/studio_examples.py @@ -8,7 +8,7 @@ async def main(): - otf = await Otf.create(USERNAME, PASSWORD) + otf = otf = Otf(USERNAME, PASSWORD) # if you need to figure out what studios are in an area, you can call `search_studios_by_geo` # which takes latitude, longitude, distance, page_index, and page_size as arguments diff --git a/examples/workout_examples.py b/examples/workout_examples.py index a20d802..7baa630 100644 --- a/examples/workout_examples.py +++ b/examples/workout_examples.py @@ -8,7 +8,7 @@ async def main(): - otf = await Otf.create(USERNAME, PASSWORD) + otf = otf = Otf(USERNAME, PASSWORD) resp = await otf.get_member_lifetime_stats() print(resp.model_dump_json(indent=4)) From 1184ad523d5d7dab7972af2e940f8224c0eff508 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Thu, 18 Jul 2024 21:08:45 -0500 Subject: [PATCH 42/42] bump --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- src/otf_api/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index c47ea01..c85a6e9 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.3.0" +current_version = "0.4.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(\\.(?Pdev)\\d+\\+[-_a-zA-Z0-9]+)?" diff --git a/pyproject.toml b/pyproject.toml index b9ee2db..9931c20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "otf-api" -version = "0.3.0" +version = "0.4.0" description = "Python OrangeTheory Fitness API Client" authors = ["Jessica Smith "] license = "MIT" diff --git a/src/otf_api/__init__.py b/src/otf_api/__init__.py index 4a15070..bbcbbb6 100644 --- a/src/otf_api/__init__.py +++ b/src/otf_api/__init__.py @@ -6,7 +6,7 @@ from .api import Otf from .auth import OtfUser -__version__ = "0.3.0" +__version__ = "0.4.0" __all__ = ["Otf", "OtfUser"]