From db2673141fcbc636aa9baf8de7076e45dd8f99a9 Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Tue, 17 Dec 2024 12:42:26 -0500 Subject: [PATCH 1/7] feat(wip): add firebase integration, auth_service --- .devcontainer/.env.example | 8 + .devcontainer/devcontainer.json | 3 +- .devcontainer/docker-compose.yml | 10 +- .devcontainer/post-create.sh | 8 - .devcontainer/post-start.sh | 1 + .gitignore | 10 +- app/auth/models.py | 35 +- app/auth/router.py | 41 +- app/auth/router_fastapi.py | 17 - app/auth/service.py | 34 +- app/auth/service_firebase.py | 48 ++ app/main.py | 7 +- app/user/models.py | 2 +- app/user/service.py | 5 + config.py | 6 + deploy/docker/Dockerfile | 6 +- firebase.json | 12 + poetry.lock | 714 +++++++++++++++++++++++++++- pyproject.toml | 2 + scripts/admin/.env.example | 2 +- scripts/firebase-start-emulators.sh | 10 + scripts/preview_docker.sh | 5 +- 22 files changed, 922 insertions(+), 64 deletions(-) delete mode 100644 app/auth/router_fastapi.py create mode 100644 app/auth/service_firebase.py create mode 100644 firebase.json create mode 100755 scripts/firebase-start-emulators.sh diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index f0e6e1a..5b6486d 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,5 +1,8 @@ # This file is used by ./docker-compose.yml. +TIME_ZONE=US/Eastern +GITHUB_TOKEN= + # Host ports # # For avoiding conflicts on the host machine. @@ -16,3 +19,8 @@ HOST_NEO4J_BROWSER_PORT=7474 STRIPE_PUBLIC_KEY=pk_test_ # From Stripe dashboard STRIPE_SECRET_KEY=sk_test_ # From Stripe dashboard STRIPE_WEBHOOK_SECRET=whsec_ # From Stripe CLI + +# Firebase config +FIREBASE_PROJECT_ID= +HOST_FIREBASE_AUTH_EMULATOR_PORT=9099 +HOST_FIREBASE_EMULATOR_UI_PORT=9090 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c4ae00a..138c4bd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,8 @@ "postCreateCommand": ".devcontainer/post-create.sh", "postStartCommand": ".devcontainer/post-start.sh", "features": { - "ghcr.io/nullcoder/devcontainer-features/stripe-cli:1": {} + "ghcr.io/nullcoder/devcontainer-features/stripe-cli:1": {}, + "ghcr.io/devcontainers-extra/features/firebase-cli:2": {} }, "customizations": { "vscode": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f295651..e7ecec2 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -7,6 +7,8 @@ services: - ${HOST_API_DEV_PORT}:8000 - ${HOST_API_DOCKER_PORT}:8001 - ${HOST_API_SAM_PORT}:8002 + - ${HOST_FIREBASE_AUTH_EMULATOR_PORT}:9099 + - ${HOST_FIREBASE_EMULATOR_UI_PORT}:9090 command: sleep infinity depends_on: - postgres @@ -14,9 +16,8 @@ services: volumes: - ..:/api-python:cached environment: - # From host machine - TZ: ${TZ} # Timezone - GITHUB_TOKEN: ${GITHUB_TOKEN} # Github CLI + TZ: ${TIME_ZONE} + GITHUB_TOKEN: ${GITHUB_TOKEN} # Python LOGGING_LEVEL: DEBUG # Postgres @@ -28,6 +29,9 @@ services: STRIPE_PUBLIC_KEY: ${STRIPE_PUBLIC_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + # Firebase + FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID} + FIREBASE_AUTH_EMULATOR_HOST: localhost:9099 postgres: image: postgres diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index b526cf1..f818d8c 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -6,14 +6,6 @@ ln -s "$(pwd)/.devcontainer/.bash_history" ~/.bash_history # Install the user's dotfiles from GitHub. gh repo clone dotfiles ~/.dotfiles && ~/.dotfiles/install.sh -# Copy .env.example to .env if necessary. -if [ ! -f ".env" ]; then - cp .env.example .env -fi -if [ ! -f ".devcontainer/.env" ]; then - cp .devcontainer/.env.example .devcontainer/.env -fi - # Create a virtual environment for the project if one doesn't exist. if [ ! -d ".venv" ]; then python3 -m venv .venv diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index f704321..3043719 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -3,3 +3,4 @@ # Make sure containerd and dockerd are running. sudo nohup containerd & sudo nohup dockerd & +sudo nohup ./scripts/firebase-start-emulators.sh & \ No newline at end of file diff --git a/.gitignore b/.gitignore index 994bad7..915199b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ __pycache__ .venv .bash_history .Trash-* -.env -.env.* -!.env.example +**/.env +**/.env.* +!**/.env.example .aws-sam -Pipfile* \ No newline at end of file +Pipfile* +firebase_emulator_data +firebase-debug.log \ No newline at end of file diff --git a/app/auth/models.py b/app/auth/models.py index 74f5b25..d4530c5 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -1,6 +1,33 @@ -from pydantic import BaseModel, EmailStr +from http import HTTPStatus +from typing import Any, Optional +from pydantic import BaseModel -class SignUpData(BaseModel): - email: EmailStr - password: str +from app.error import AppError +from app.user.models import Role + + +class SetRole(BaseModel): + user_id: str + role: Optional[Role] + + +class InvalidTokenError(AppError): + def __init__(self, exception: Exception, details: Optional[dict[str, Any]] = None): + super().__init__( + code="auth/invalid-token", + message="Invalid token", + status=HTTPStatus.UNAUTHORIZED, + exception=exception, + details=details, + ) + + +class UnauthorizedError(AppError): + def __init__(self, details: Optional[dict[str, Any]] = None): + super().__init__( + code="auth/unauthorized", + message="Unauthorized", + status=HTTPStatus.UNAUTHORIZED, + details=details, + ) diff --git a/app/auth/router.py b/app/auth/router.py index aa6e830..eb30c56 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,12 +1,41 @@ -from app.auth.models import SignUpData +from fastapi import APIRouter, Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jwt import JWT + +from app.auth.models import InvalidTokenError, SetRole, UnauthorizedError from app.auth.service import AuthService +from app.user.models import TokenUser + +_security = HTTPBearer() + +def decode_jwt( + credentials: HTTPAuthorizationCredentials = Depends(_security), +) -> TokenUser: + token = credentials.credentials + try: + claims = JWT().decode(token) + except Exception as e: + raise InvalidTokenError(exception=e) from e -class AuthRouter: - """The driving port for authentication.""" + return TokenUser(**claims) + +class AuthRouter(APIRouter): def __init__(self, service: AuthService): - self.service = service + super().__init__(prefix="/auth", tags=["auth"]) + self._service = service + + self.post("/sign-up")(self.sign_up) + self.post("/set-role")(self.set_role) + + async def sign_up(self, user: TokenUser = Depends(decode_jwt)) -> None: + await self._service.sign_up(user) - async def sign_up(self, data: SignUpData) -> str: - return await self.service.sign_up(data) + async def set_role( + self, data: SetRole, user: TokenUser = Depends(decode_jwt) + ) -> None: + if user.role == "admin" or await self._service.is_only_user(user.id): + await self._service.set_role(data.user_id, data.role) + else: + raise UnauthorizedError() diff --git a/app/auth/router_fastapi.py b/app/auth/router_fastapi.py deleted file mode 100644 index c77dfe9..0000000 --- a/app/auth/router_fastapi.py +++ /dev/null @@ -1,17 +0,0 @@ -from fastapi import APIRouter - -from app.auth.models import SignUpData -from app.auth.router import AuthRouter - - -class AuthRouterFastApi(APIRouter): - """The FastAPI adapter for the `AuthRouter` port.""" - - def __init__(self, router: AuthRouter): - super().__init__(prefix="/auth", tags=["auth"]) - self._router = router - - self.post("/sign-up")(self.sign_up) - - async def sign_up(self, data: SignUpData) -> str: - return await self._router.sign_up(data) diff --git a/app/auth/service.py b/app/auth/service.py index 904d8be..c2de650 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -1,21 +1,39 @@ -import logging +from abc import abstractmethod +from datetime import datetime from typing import Optional -import shortuuid - -from app.auth.models import SignUpData from app.service import Service from app.subscription.models import SubscriptionLevel +from app.user.models import FullUser, Role, TokenUser class AuthService(Service): """The service that contains the core business logic for authentication.""" - async def sign_up(self, data: SignUpData) -> str: - return f"user_{shortuuid.uuid()}" + async def sign_up(self, user: TokenUser) -> None: + await self._app.user.create_user( + FullUser( + id=user.id, + name=user.name, + email=user.email, + email_verified=user.email_verified, + phone=user.phone, + photo_url=user.photo_url, + sign_in_methods=[user.sign_in_method], + created_at=datetime.now(), + ) + ) + + @abstractmethod + async def set_role(self, user_id: str, role: Optional[Role]) -> None: + raise NotImplementedError() + @abstractmethod async def set_subscription_level( self, user_id: str, level: Optional[SubscriptionLevel] ) -> None: - logging.debug(f"Setting subscription level for user {user_id} to {level}") - # raise NotImplementedError() + raise NotImplementedError() + + @abstractmethod + async def is_only_user(self, user_id: str) -> bool: + raise NotImplementedError() diff --git a/app/auth/service_firebase.py b/app/auth/service_firebase.py new file mode 100644 index 0000000..cb8089e --- /dev/null +++ b/app/auth/service_firebase.py @@ -0,0 +1,48 @@ +import json +import logging +from typing import Any, Optional, override + +from firebase_admin import auth, credentials, initialize_app # type: ignore + +from app.auth.service import AuthService +from app.subscription.models import SubscriptionLevel +from app.user.models import Role +from config import ( + FIREBASE_AUTH_EMULATOR_HOST, + FIREBASE_PROJECT_ID, + GOOGLE_APPLICATION_CREDENTIALS, +) + + +class AuthServiceFirebase(AuthService): + def __init__(self): + options: dict[str, Any] = { + "projectId": FIREBASE_PROJECT_ID, + "httpTimeout": 10, + } + if FIREBASE_AUTH_EMULATOR_HOST: + logging.warning("Using Firebase Auth Emulator") + initialize_app(options=options) + else: + if not GOOGLE_APPLICATION_CREDENTIALS: + raise ValueError("GOOGLE_APPLICATION_CREDENTIALS not set") + cred = credentials.Certificate(json.loads(GOOGLE_APPLICATION_CREDENTIALS)) + initialize_app(credential=cred, options=options) + + @override + async def set_role(self, user_id: str, role: Optional[Role]) -> None: + await self.set_claims(user_id, {"role": role}) + + @override + async def set_subscription_level( + self, user_id: str, level: Optional[SubscriptionLevel] + ) -> None: + await self.set_claims(user_id, {"level": level}) + + @override + async def is_only_user(self, user_id: str) -> bool: + page: auth.ListUsersPage = auth.list_users(max_results=2) # type: ignore + return len(page.users) == 1 and page.users[0].uid == user_id # type: ignore + + async def set_claims(self, user_id: str, claims: Optional[dict[str, Any]]) -> None: + auth.set_custom_user_claims(user_id, claims) # type: ignore diff --git a/app/main.py b/app/main.py index 5cffff7..a825308 100644 --- a/app/main.py +++ b/app/main.py @@ -8,8 +8,7 @@ from app.app import App from app.auth.router import AuthRouter -from app.auth.router_fastapi import AuthRouterFastApi -from app.auth.service import AuthService +from app.auth.service_firebase import AuthServiceFirebase from app.error import AppError from app.subscription.router import SubscriptionRouter from app.subscription.router_fastapi import SubscriptionRouterFastApi @@ -22,14 +21,14 @@ logging.debug("Initializing App...") app = App( - auth=AuthService(), + auth=AuthServiceFirebase(), subscription=SubscriptionServiceStripe(), subscription_portal=SubscriptionPortalServiceStripe(), user=UserService(), ) routers: list[APIRouter] = [ - AuthRouterFastApi(router=AuthRouter(service=app.auth)), + AuthRouter(service=app.auth), SubscriptionRouterFastApi(router=SubscriptionRouter(service=app.subscription)), ] diff --git a/app/user/models.py b/app/user/models.py index 3598194..a030547 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -27,6 +27,6 @@ class TokenUser(User): class FullUser(User): sign_in_methods: list[SignInMethod] - created_at: Optional[datetime] = None + created_at: datetime stripe_customer_id: Optional[str] = None stripe_subscription_id: Optional[str] = None diff --git a/app/user/service.py b/app/user/service.py index cd29cd7..34266c2 100644 --- a/app/user/service.py +++ b/app/user/service.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import Optional from app.service import Service @@ -6,11 +7,15 @@ class UserService(Service): + async def create_user(self, user: FullUser) -> None: + logging.warning(f"Creating user {user.id}") + # raise NotImplementedError() async def get_user(self, user_id: str) -> FullUser: logging.warning(f"Returning fake user") return FullUser( id=user_id, + created_at=datetime.now(), sign_in_methods=["email_password"], ) # raise NotImplementedError() diff --git a/config.py b/config.py index 148879b..2581e1b 100644 --- a/config.py +++ b/config.py @@ -10,3 +10,9 @@ STRIPE_PUBLIC_KEY: str = os.environ.get("STRIPE_PUBLIC_KEY", "") STRIPE_SECRET_KEY: str = os.environ.get("STRIPE_SECRET_KEY", "") STRIPE_WEBHOOK_SECRET: str = os.environ.get("STRIPE_WEBHOOK_SECRET", "") + +FIREBASE_PROJECT_ID: str = os.environ.get("FIREBASE_PROJECT_ID", "") +FIREBASE_AUTH_EMULATOR_HOST: str = os.environ.get("FIREBASE_AUTH_EMULATOR_HOST", "") +GOOGLE_APPLICATION_CREDENTIALS: str = os.environ.get( + "GOOGLE_APPLICATION_CREDENTIALS", "" +) diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index c9a7dd9..71cb69c 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -16,10 +16,8 @@ RUN pip install --no-cache-dir -r requirements.txt && \ COPY config.py ./ COPY app ./app -ARG PORT=8000 -ENV PORT=$PORT -ARG WORKERS=4 -ENV WORKERS=$WORKERS +ENV PORT=8000 +ENV WORKERS=4 EXPOSE $PORT CMD gunicorn app.main:app_router -w $WORKERS -k uvicorn.workers.UvicornWorker -b 0.0.0.0:${PORT} \ No newline at end of file diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..25d0320 --- /dev/null +++ b/firebase.json @@ -0,0 +1,12 @@ +{ + "emulators": { + "auth": { + "port": 9099 + }, + "ui": { + "enabled": true, + "port": 9090 + }, + "singleProjectMode": true + } +} \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 93a0200..bcaa4de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -76,6 +76,37 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cachecontrol" +version = "0.14.1" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cachecontrol-0.14.1-py3-none-any.whl", hash = "sha256:65e3abd62b06382ce3894df60dde9e0deb92aeb734724f68fa4f3b91e97206b9"}, + {file = "cachecontrol-0.14.1.tar.gz", hash = "sha256:06ef916a1e4eb7dba9948cdfc9c76e749db2e02104a9a1277e8b642591a0f717"}, +] + +[package.dependencies] +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + [[package]] name = "certifi" version = "2024.12.14" @@ -87,6 +118,85 @@ files = [ {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "charset-normalizer" version = "3.4.0" @@ -226,6 +336,55 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "44.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, + {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, + {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"}, + {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, + {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, + {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"}, + {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "dnspython" version = "2.7.0" @@ -281,6 +440,327 @@ typing-extensions = ">=4.8.0" all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "firebase-admin" +version = "6.6.0" +description = "Firebase Admin Python SDK" +optional = false +python-versions = ">=3.7" +files = [ + {file = "firebase_admin-6.6.0-py3-none-any.whl", hash = "sha256:4a55af17e0b4e3de05ba43ad25d92af29b48263622a913faea66e9c8ba50aeef"}, + {file = "firebase_admin-6.6.0.tar.gz", hash = "sha256:9ce851ae71038acaaeffdcfc09b175dadc3b2e5aa8a0b65760e21bb10253b684"}, +] + +[package.dependencies] +cachecontrol = ">=0.12.14" +google-api-core = {version = ">=1.22.1,<3.0.0dev", extras = ["grpc"], markers = "platform_python_implementation != \"PyPy\""} +google-api-python-client = ">=1.7.8" +google-cloud-firestore = {version = ">=2.19.0", markers = "platform_python_implementation != \"PyPy\""} +google-cloud-storage = ">=1.37.1" +pyjwt = {version = ">=2.5.0", extras = ["crypto"]} + +[[package]] +name = "google-api-core" +version = "2.24.0" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_core-2.24.0-py3-none-any.whl", hash = "sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9"}, + {file = "google_api_core-2.24.0.tar.gz", hash = "sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""} +proto-plus = [ + {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-api-python-client" +version = "2.155.0" +description = "Google API Client Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_python_client-2.155.0-py2.py3-none-any.whl", hash = "sha256:83fe9b5aa4160899079d7c93a37be306546a17e6686e2549bcc9584f1a229747"}, + {file = "google_api_python_client-2.155.0.tar.gz", hash = "sha256:25529f89f0d13abcf3c05c089c423fb2858ac16e0b3727543393468d0d7af67c"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-auth-httplib2 = ">=0.2.0,<1.0.0" +httplib2 = ">=0.19.0,<1.dev0" +uritemplate = ">=3.0.1,<5" + +[[package]] +name = "google-auth" +version = "2.37.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.37.0-py2.py3-none-any.whl", hash = "sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0"}, + {file = "google_auth-2.37.0.tar.gz", hash = "sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +description = "Google Authentication Library: httplib2 transport" +optional = false +python-versions = "*" +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[package.dependencies] +google-auth = "*" +httplib2 = ">=0.19.0" + +[[package]] +name = "google-cloud-core" +version = "2.4.1" +description = "Google Cloud API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, + {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, +] + +[package.dependencies] +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" + +[package.extras] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] + +[[package]] +name = "google-cloud-firestore" +version = "2.19.0" +description = "Google Cloud Firestore API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_firestore-2.19.0-py2.py3-none-any.whl", hash = "sha256:b49f0019d7bd0d4ab5972a4cff13994b0aabe72d24242200d904db2fb49df7f7"}, + {file = "google_cloud_firestore-2.19.0.tar.gz", hash = "sha256:1b2ce6e0b791aee89a1e4f072beba1012247e89baca361eed721fb467fe054b0"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.0,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +google-cloud-core = ">=1.4.1,<3.0.0dev" +proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[[package]] +name = "google-cloud-storage" +version = "2.19.0" +description = "Google Cloud Storage API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_storage-2.19.0-py2.py3-none-any.whl", hash = "sha256:aeb971b5c29cf8ab98445082cbfe7b161a1f48ed275822f59ed3f1524ea54fba"}, + {file = "google_cloud_storage-2.19.0.tar.gz", hash = "sha256:cd05e9e7191ba6cb68934d8eb76054d9be4562aa89dbc4236feee4d7d51342b2"}, +] + +[package.dependencies] +google-api-core = ">=2.15.0,<3.0.0dev" +google-auth = ">=2.26.1,<3.0dev" +google-cloud-core = ">=2.3.0,<3.0dev" +google-crc32c = ">=1.0,<2.0dev" +google-resumable-media = ">=2.7.2" +requests = ">=2.18.0,<3.0.0dev" + +[package.extras] +protobuf = ["protobuf (<6.0.0dev)"] +tracing = ["opentelemetry-api (>=1.1.0)"] + +[[package]] +name = "google-crc32c" +version = "1.6.0" +description = "A python wrapper of the C library 'Google CRC32C'" +optional = false +python-versions = ">=3.9" +files = [ + {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa"}, + {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc"}, + {file = "google_crc32c-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42"}, + {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4"}, + {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8"}, + {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d"}, + {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f"}, + {file = "google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3"}, + {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, + {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, + {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, + {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, + {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, + {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205"}, + {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57"}, + {file = "google_crc32c-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c"}, + {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc"}, + {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d"}, + {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24"}, + {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d"}, + {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, +] + +[package.extras] +testing = ["pytest"] + +[[package]] +name = "google-resumable-media" +version = "2.7.2" +description = "Utilities for Google Media Downloads and Resumable Uploads" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa"}, + {file = "google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0"}, +] + +[package.dependencies] +google-crc32c = ">=1.0,<2.0dev" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] +requests = ["requests (>=2.18.0,<3.0.0dev)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.66.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed"}, + {file = "googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "grpcio" +version = "1.68.1" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "grpcio-1.68.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:d35740e3f45f60f3c37b1e6f2f4702c23867b9ce21c6410254c9c682237da68d"}, + {file = "grpcio-1.68.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:d99abcd61760ebb34bdff37e5a3ba333c5cc09feda8c1ad42547bea0416ada78"}, + {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:f8261fa2a5f679abeb2a0a93ad056d765cdca1c47745eda3f2d87f874ff4b8c9"}, + {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0feb02205a27caca128627bd1df4ee7212db051019a9afa76f4bb6a1a80ca95e"}, + {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:919d7f18f63bcad3a0f81146188e90274fde800a94e35d42ffe9eadf6a9a6330"}, + {file = "grpcio-1.68.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:963cc8d7d79b12c56008aabd8b457f400952dbea8997dd185f155e2f228db079"}, + {file = "grpcio-1.68.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ccf2ebd2de2d6661e2520dae293298a3803a98ebfc099275f113ce1f6c2a80f1"}, + {file = "grpcio-1.68.1-cp310-cp310-win32.whl", hash = "sha256:2cc1fd04af8399971bcd4f43bd98c22d01029ea2e56e69c34daf2bf8470e47f5"}, + {file = "grpcio-1.68.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2e743e51cb964b4975de572aa8fb95b633f496f9fcb5e257893df3be854746"}, + {file = "grpcio-1.68.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:55857c71641064f01ff0541a1776bfe04a59db5558e82897d35a7793e525774c"}, + {file = "grpcio-1.68.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4b177f5547f1b995826ef529d2eef89cca2f830dd8b2c99ffd5fde4da734ba73"}, + {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:3522c77d7e6606d6665ec8d50e867f13f946a4e00c7df46768f1c85089eae515"}, + {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d1fae6bbf0816415b81db1e82fb3bf56f7857273c84dcbe68cbe046e58e1ccd"}, + {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:298ee7f80e26f9483f0b6f94cc0a046caf54400a11b644713bb5b3d8eb387600"}, + {file = "grpcio-1.68.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cbb5780e2e740b6b4f2d208e90453591036ff80c02cc605fea1af8e6fc6b1bbe"}, + {file = "grpcio-1.68.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ddda1aa22495d8acd9dfbafff2866438d12faec4d024ebc2e656784d96328ad0"}, + {file = "grpcio-1.68.1-cp311-cp311-win32.whl", hash = "sha256:b33bd114fa5a83f03ec6b7b262ef9f5cac549d4126f1dc702078767b10c46ed9"}, + {file = "grpcio-1.68.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f20ebec257af55694d8f993e162ddf0d36bd82d4e57f74b31c67b3c6d63d8b2"}, + {file = "grpcio-1.68.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8829924fffb25386995a31998ccbbeaa7367223e647e0122043dfc485a87c666"}, + {file = "grpcio-1.68.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3aed6544e4d523cd6b3119b0916cef3d15ef2da51e088211e4d1eb91a6c7f4f1"}, + {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:4efac5481c696d5cb124ff1c119a78bddbfdd13fc499e3bc0ca81e95fc573684"}, + {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ab2d912ca39c51f46baf2a0d92aa265aa96b2443266fc50d234fa88bf877d8e"}, + {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c87ce2a97434dffe7327a4071839ab8e8bffd0054cc74cbe971fba98aedd60"}, + {file = "grpcio-1.68.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e4842e4872ae4ae0f5497bf60a0498fa778c192cc7a9e87877abd2814aca9475"}, + {file = "grpcio-1.68.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:255b1635b0ed81e9f91da4fcc8d43b7ea5520090b9a9ad9340d147066d1d3613"}, + {file = "grpcio-1.68.1-cp312-cp312-win32.whl", hash = "sha256:7dfc914cc31c906297b30463dde0b9be48e36939575eaf2a0a22a8096e69afe5"}, + {file = "grpcio-1.68.1-cp312-cp312-win_amd64.whl", hash = "sha256:a0c8ddabef9c8f41617f213e527254c41e8b96ea9d387c632af878d05db9229c"}, + {file = "grpcio-1.68.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:a47faedc9ea2e7a3b6569795c040aae5895a19dde0c728a48d3c5d7995fda385"}, + {file = "grpcio-1.68.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:390eee4225a661c5cd133c09f5da1ee3c84498dc265fd292a6912b65c421c78c"}, + {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:66a24f3d45c33550703f0abb8b656515b0ab777970fa275693a2f6dc8e35f1c1"}, + {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c08079b4934b0bf0a8847f42c197b1d12cba6495a3d43febd7e99ecd1cdc8d54"}, + {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8720c25cd9ac25dd04ee02b69256d0ce35bf8a0f29e20577427355272230965a"}, + {file = "grpcio-1.68.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:04cfd68bf4f38f5bb959ee2361a7546916bd9a50f78617a346b3aeb2b42e2161"}, + {file = "grpcio-1.68.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c28848761a6520c5c6071d2904a18d339a796ebe6b800adc8b3f474c5ce3c3ad"}, + {file = "grpcio-1.68.1-cp313-cp313-win32.whl", hash = "sha256:77d65165fc35cff6e954e7fd4229e05ec76102d4406d4576528d3a3635fc6172"}, + {file = "grpcio-1.68.1-cp313-cp313-win_amd64.whl", hash = "sha256:a8040f85dcb9830d8bbb033ae66d272614cec6faceee88d37a88a9bd1a7a704e"}, + {file = "grpcio-1.68.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:eeb38ff04ab6e5756a2aef6ad8d94e89bb4a51ef96e20f45c44ba190fa0bcaad"}, + {file = "grpcio-1.68.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a3869a6661ec8f81d93f4597da50336718bde9eb13267a699ac7e0a1d6d0bea"}, + {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:2c4cec6177bf325eb6faa6bd834d2ff6aa8bb3b29012cceb4937b86f8b74323c"}, + {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12941d533f3cd45d46f202e3667be8ebf6bcb3573629c7ec12c3e211d99cfccf"}, + {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80af6f1e69c5e68a2be529990684abdd31ed6622e988bf18850075c81bb1ad6e"}, + {file = "grpcio-1.68.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e8dbe3e00771bfe3d04feed8210fc6617006d06d9a2679b74605b9fed3e8362c"}, + {file = "grpcio-1.68.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:83bbf5807dc3ee94ce1de2dfe8a356e1d74101e4b9d7aa8c720cc4818a34aded"}, + {file = "grpcio-1.68.1-cp38-cp38-win32.whl", hash = "sha256:8cb620037a2fd9eeee97b4531880e439ebfcd6d7d78f2e7dcc3726428ab5ef63"}, + {file = "grpcio-1.68.1-cp38-cp38-win_amd64.whl", hash = "sha256:52fbf85aa71263380d330f4fce9f013c0798242e31ede05fcee7fbe40ccfc20d"}, + {file = "grpcio-1.68.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:cb400138e73969eb5e0535d1d06cae6a6f7a15f2cc74add320e2130b8179211a"}, + {file = "grpcio-1.68.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a1b988b40f2fd9de5c820f3a701a43339d8dcf2cb2f1ca137e2c02671cc83ac1"}, + {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:96f473cdacfdd506008a5d7579c9f6a7ff245a9ade92c3c0265eb76cc591914f"}, + {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:37ea3be171f3cf3e7b7e412a98b77685eba9d4fd67421f4a34686a63a65d99f9"}, + {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ceb56c4285754e33bb3c2fa777d055e96e6932351a3082ce3559be47f8024f0"}, + {file = "grpcio-1.68.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dffd29a2961f3263a16d73945b57cd44a8fd0b235740cb14056f0612329b345e"}, + {file = "grpcio-1.68.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:025f790c056815b3bf53da850dd70ebb849fd755a4b1ac822cb65cd631e37d43"}, + {file = "grpcio-1.68.1-cp39-cp39-win32.whl", hash = "sha256:1098f03dedc3b9810810568060dea4ac0822b4062f537b0f53aa015269be0a76"}, + {file = "grpcio-1.68.1-cp39-cp39-win_amd64.whl", hash = "sha256:334ab917792904245a028f10e803fcd5b6f36a7b2173a820c0b5b076555825e1"}, + {file = "grpcio-1.68.1.tar.gz", hash = "sha256:44a8502dd5de653ae6a73e2de50a401d84184f0331d0ac3daeb044e66d5c5054"}, +] + +[package.extras] +protobuf = ["grpcio-tools (>=1.68.1)"] + +[[package]] +name = "grpcio-status" +version = "1.68.1" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.8" +files = [ + {file = "grpcio_status-1.68.1-py3-none-any.whl", hash = "sha256:66f3d8847f665acfd56221333d66f7ad8927903d87242a482996bdb45e8d28fd"}, + {file = "grpcio_status-1.68.1.tar.gz", hash = "sha256:e1378d036c81a1610d7b4c7a146cd663dd13fcc915cf4d7d053929dba5bbb6e1"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.68.1" +protobuf = ">=5.26.1,<6.0dev" + [[package]] name = "h11" version = "0.14.0" @@ -292,6 +772,20 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "httplib2" +version = "0.22.0" +description = "A comprehensive HTTP client library." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + +[package.dependencies] +pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} + [[package]] name = "idna" version = "3.10" @@ -331,6 +825,19 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "jwt" +version = "1.3.1" +description = "JSON Web Token library for Python 3." +optional = false +python-versions = ">= 3.6" +files = [ + {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, +] + +[package.dependencies] +cryptography = ">=3.1,<3.4.0 || >3.4.0" + [[package]] name = "mangum" version = "0.19.0" @@ -345,6 +852,79 @@ files = [ [package.dependencies] typing-extensions = "*" +[[package]] +name = "msgpack" +version = "1.1.0" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +files = [ + {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd"}, + {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d"}, + {file = "msgpack-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:914571a2a5b4e7606997e169f64ce53a8b1e06f2cf2c3a7273aa106236d43dd5"}, + {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c921af52214dcbb75e6bdf6a661b23c3e6417f00c603dd2070bccb5c3ef499f5"}, + {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8ce0b22b890be5d252de90d0e0d119f363012027cf256185fc3d474c44b1b9e"}, + {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:73322a6cc57fcee3c0c57c4463d828e9428275fb85a27aa2aa1a92fdc42afd7b"}, + {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1f3c3d21f7cf67bcf2da8e494d30a75e4cf60041d98b3f79875afb5b96f3a3f"}, + {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64fc9068d701233effd61b19efb1485587560b66fe57b3e50d29c5d78e7fef68"}, + {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:42f754515e0f683f9c79210a5d1cad631ec3d06cea5172214d2176a42e67e19b"}, + {file = "msgpack-1.1.0-cp310-cp310-win32.whl", hash = "sha256:3df7e6b05571b3814361e8464f9304c42d2196808e0119f55d0d3e62cd5ea044"}, + {file = "msgpack-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:685ec345eefc757a7c8af44a3032734a739f8c45d1b0ac45efc5d8977aa4720f"}, + {file = "msgpack-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7"}, + {file = "msgpack-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa"}, + {file = "msgpack-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701"}, + {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6"}, + {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59"}, + {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0"}, + {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e"}, + {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6"}, + {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5"}, + {file = "msgpack-1.1.0-cp311-cp311-win32.whl", hash = "sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88"}, + {file = "msgpack-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788"}, + {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d"}, + {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2"}, + {file = "msgpack-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420"}, + {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2"}, + {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39"}, + {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f"}, + {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247"}, + {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c"}, + {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b"}, + {file = "msgpack-1.1.0-cp312-cp312-win32.whl", hash = "sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b"}, + {file = "msgpack-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f"}, + {file = "msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf"}, + {file = "msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330"}, + {file = "msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734"}, + {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e"}, + {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca"}, + {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915"}, + {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d"}, + {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434"}, + {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c"}, + {file = "msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc"}, + {file = "msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f"}, + {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c40ffa9a15d74e05ba1fe2681ea33b9caffd886675412612d93ab17b58ea2fec"}, + {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ba6136e650898082d9d5a5217d5906d1e138024f836ff48691784bbe1adf96"}, + {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0856a2b7e8dcb874be44fea031d22e5b3a19121be92a1e098f46068a11b0870"}, + {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:471e27a5787a2e3f974ba023f9e265a8c7cfd373632247deb225617e3100a3c7"}, + {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:646afc8102935a388ffc3914b336d22d1c2d6209c773f3eb5dd4d6d3b6f8c1cb"}, + {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:13599f8829cfbe0158f6456374e9eea9f44eee08076291771d8ae93eda56607f"}, + {file = "msgpack-1.1.0-cp38-cp38-win32.whl", hash = "sha256:8a84efb768fb968381e525eeeb3d92857e4985aacc39f3c47ffd00eb4509315b"}, + {file = "msgpack-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:879a7b7b0ad82481c52d3c7eb99bf6f0645dbdec5134a4bddbd16f3506947feb"}, + {file = "msgpack-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:53258eeb7a80fc46f62fd59c876957a2d0e15e6449a9e71842b6d24419d88ca1"}, + {file = "msgpack-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e7b853bbc44fb03fbdba34feb4bd414322180135e2cb5164f20ce1c9795ee48"}, + {file = "msgpack-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3e9b4936df53b970513eac1758f3882c88658a220b58dcc1e39606dccaaf01c"}, + {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46c34e99110762a76e3911fc923222472c9d681f1094096ac4102c18319e6468"}, + {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a706d1e74dd3dea05cb54580d9bd8b2880e9264856ce5068027eed09680aa74"}, + {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:534480ee5690ab3cbed89d4c8971a5c631b69a8c0883ecfea96c19118510c846"}, + {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8cf9e8c3a2153934a23ac160cc4cba0ec035f6867c8013cc6077a79823370346"}, + {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3180065ec2abbe13a4ad37688b61b99d7f9e012a535b930e0e683ad6bc30155b"}, + {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c5a91481a3cc573ac8c0d9aace09345d989dc4a0202b7fcb312c88c26d4e71a8"}, + {file = "msgpack-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f80bc7d47f76089633763f952e67f8214cb7b3ee6bfa489b3cb6a84cfac114cd"}, + {file = "msgpack-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:4d1b7ff2d6146e16e8bd665ac726a89c74163ef8cd39fa8c1087d4e52d3a2325"}, + {file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"}, +] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -409,6 +989,43 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "proto-plus" +version = "1.25.0" +description = "Beautiful, Pythonic protocol buffers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961"}, + {file = "proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<6.0.0dev" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "5.29.1" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-5.29.1-cp310-abi3-win32.whl", hash = "sha256:22c1f539024241ee545cbcb00ee160ad1877975690b16656ff87dde107b5f110"}, + {file = "protobuf-5.29.1-cp310-abi3-win_amd64.whl", hash = "sha256:1fc55267f086dd4050d18ef839d7bd69300d0d08c2a53ca7df3920cc271a3c34"}, + {file = "protobuf-5.29.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d473655e29c0c4bbf8b69e9a8fb54645bc289dead6d753b952e7aa660254ae18"}, + {file = "protobuf-5.29.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5ba1d0e4c8a40ae0496d0e2ecfdbb82e1776928a205106d14ad6985a09ec155"}, + {file = "protobuf-5.29.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ee1461b3af56145aca2800e6a3e2f928108c749ba8feccc6f5dd0062c410c0d"}, + {file = "protobuf-5.29.1-cp38-cp38-win32.whl", hash = "sha256:50879eb0eb1246e3a5eabbbe566b44b10348939b7cc1b267567e8c3d07213853"}, + {file = "protobuf-5.29.1-cp38-cp38-win_amd64.whl", hash = "sha256:027fbcc48cea65a6b17028510fdd054147057fa78f4772eb547b9274e5219331"}, + {file = "protobuf-5.29.1-cp39-cp39-win32.whl", hash = "sha256:5a41deccfa5e745cef5c65a560c76ec0ed8e70908a67cc8f4da5fce588b50d57"}, + {file = "protobuf-5.29.1-cp39-cp39-win_amd64.whl", hash = "sha256:012ce28d862ff417fd629285aca5d9772807f15ceb1a0dbd15b88f58c776c98c"}, + {file = "protobuf-5.29.1-py3-none-any.whl", hash = "sha256:32600ddb9c2a53dedc25b8581ea0f1fd8ea04956373c0c07577ce58d312522e0"}, + {file = "protobuf-5.29.1.tar.gz", hash = "sha256:683be02ca21a6ffe80db6dd02c0b5b2892322c59ca57fd6c872d652cb80549cb"}, +] + [[package]] name = "psycopg" version = "3.2.3" @@ -432,6 +1049,42 @@ docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)" pool = ["psycopg-pool"] test = ["anyio (>=4.0)", "mypy (>=1.11)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.10.3" @@ -587,6 +1240,40 @@ pycountry = ["pycountry (>=23)"] python-ulid = ["python-ulid (>=1,<2)", "python-ulid (>=1,<4)"] semver = ["semver (>=3.0.2)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pyparsing" +version = "3.2.0" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, + {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + [[package]] name = "pytest" version = "8.3.4" @@ -657,6 +1344,20 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "shortuuid" version = "1.0.13" @@ -733,6 +1434,17 @@ files = [ {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [[package]] name = "urllib3" version = "2.2.3" @@ -771,4 +1483,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "2b9ba1261ad3e32e03cf522636485cd93c19947bbe568100e6387a8414f46cb7" +content-hash = "1c8c9bf67e231011f61a5174619b62c8c1ad10c859ed98ef213e1888a24e4dc6" diff --git a/pyproject.toml b/pyproject.toml index e320d89..5bf7326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ shortuuid = "^1.0.13" email-validator = "^2.2.0" psycopg = "^3.2.3" stripe = "^11.3.0" +firebase-admin = "^6.6.0" +jwt = "^1.3.1" [tool.poetry.group.dev.dependencies] diff --git a/scripts/admin/.env.example b/scripts/admin/.env.example index 318ec9d..7a040af 100644 --- a/scripts/admin/.env.example +++ b/scripts/admin/.env.example @@ -36,4 +36,4 @@ PROD_STRIPE_WEBHOOK_SECRET='' # Firebase FIREBASE_TOKEN='' -PROD_FIREBASE_PROJECT_ID='' \ No newline at end of file +PROD_FIREBASE_PROJECT_ID='' diff --git a/scripts/firebase-start-emulators.sh b/scripts/firebase-start-emulators.sh new file mode 100755 index 0000000..7b8aa54 --- /dev/null +++ b/scripts/firebase-start-emulators.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +DATA_DIR="$SCRIPT_DIR/../firebase_emulator_data" +mkdir -p "$DATA_DIR" + +firebase emulators:start --only auth --project="$FIREBASE_PROJECT_ID" --import="$DATA_DIR" --export-on-exit="$DATA_DIR" + +sleep infinity \ No newline at end of file diff --git a/scripts/preview_docker.sh b/scripts/preview_docker.sh index 3c998ca..c54900a 100755 --- a/scripts/preview_docker.sh +++ b/scripts/preview_docker.sh @@ -5,7 +5,8 @@ set -e script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" pushd $script_dir/.. > /dev/null -docker build --build-arg PORT=8001 -f deploy/docker/Dockerfile -t api-python . -docker run -it api-python +docker build -f deploy/docker/Dockerfile -t api-python . +printenv > deploy/docker/.env +docker run --env-file=deploy/docker/.env -e PORT=8001 -e WORKERS=1 -it api-python popd > /dev/null From 9cc967fda744dec03d343e74a63337d70007792a Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Tue, 17 Dec 2024 15:47:01 -0500 Subject: [PATCH 2/7] setup http authorizor --- .devcontainer/docker-compose.yml | 1 + .devcontainer/host-init.sh | 7 ++++--- .devcontainer/post-create.sh | 2 +- app/auth/router.py | 15 +++++++++++++-- app/user/models.py | 2 +- config.py | 1 + deploy/aws_sam/template.yaml | 13 +++++++++++++ scripts/admin/.env.example | 4 +++- scripts/admin/set-aws-secrets.sh | 2 +- scripts/preview_sam.sh | 4 +++- 10 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index e7ecec2..894c837 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -32,6 +32,7 @@ services: # Firebase FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID} FIREBASE_AUTH_EMULATOR_HOST: localhost:9099 + VERIFY_TOKEN_SIGNATURE: 0 postgres: image: postgres diff --git a/.devcontainer/host-init.sh b/.devcontainer/host-init.sh index e83a3bd..1c923ea 100755 --- a/.devcontainer/host-init.sh +++ b/.devcontainer/host-init.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # Copy .env.example to .env if necessary. -if [ ! -f ".env" ]; then - cp .env.example .env -fi if [ ! -f ".devcontainer/.env" ]; then cp .devcontainer/.env.example .devcontainer/.env fi +if [ ! -f "scripts/admin/.env" ]; then + cp scripts/admin/.env.example scripts/admin/.env +fi + diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index f818d8c..521927a 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -16,5 +16,5 @@ echo "source \"$(pwd)/.venv/bin/activate\"" >> ~/.bashrc # Add bash completion for the Stripe CLI. mkdir -p ~/.local/share/bash-completion/completions && \ -stripe completion --shell bash && \ +stripe completion --shell bash > /dev/null && \ mv stripe-completion.bash ~/.local/share/bash-completion/completions/stripe \ No newline at end of file diff --git a/app/auth/router.py b/app/auth/router.py index eb30c56..d9c6cc7 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -5,6 +5,7 @@ from app.auth.models import InvalidTokenError, SetRole, UnauthorizedError from app.auth.service import AuthService from app.user.models import TokenUser +from config import VERIFY_TOKEN_SIGNATURE _security = HTTPBearer() @@ -14,11 +15,21 @@ def decode_jwt( ) -> TokenUser: token = credentials.credentials try: - claims = JWT().decode(token) + claims = JWT().decode(token, do_verify=VERIFY_TOKEN_SIGNATURE) except Exception as e: raise InvalidTokenError(exception=e) from e - return TokenUser(**claims) + return TokenUser( + id=claims["sub"], + sign_in_method=claims["firebase"]["sign_in_provider"], + name=claims.get("name", None), + email=claims.get("email", None), + email_verified=claims.get("email_verified", None), + phone=claims.get("phone_number", None), + photo_url=claims.get("photo_url", None), + role=claims.get("role", None), + level=claims.get("level", None), + ) class AuthRouter(APIRouter): diff --git a/app/user/models.py b/app/user/models.py index a030547..ab959ac 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -7,7 +7,7 @@ type Role = Literal["admin", "disabled"] -type SignInMethod = Literal["email_password"] +type SignInMethod = Literal["password", "anonymous"] class User(BaseModel): diff --git a/config.py b/config.py index 2581e1b..1599113 100644 --- a/config.py +++ b/config.py @@ -16,3 +16,4 @@ GOOGLE_APPLICATION_CREDENTIALS: str = os.environ.get( "GOOGLE_APPLICATION_CREDENTIALS", "" ) +VERIFY_TOKEN_SIGNATURE: bool = os.environ.get("VERIFY_TOKEN_SIGNATURE", "1") != "0" diff --git a/deploy/aws_sam/template.yaml b/deploy/aws_sam/template.yaml index 0bc7389..387b1c3 100644 --- a/deploy/aws_sam/template.yaml +++ b/deploy/aws_sam/template.yaml @@ -18,6 +18,15 @@ Resources: - Authorization AllowOrigins: - "*" + Auth: + Authorizers: + FirebaseAuthorizer: + IdentitySource: $request.header.Authorization + JwtConfiguration: + issuer: !Sub "https://securetoken.google.com/{{resolve:ssm:/api-python/firebase-project-id}}" + audience: + - !Sub "{{resolve:ssm:/api-python/firebase-project-id}}" + DefaultAuthorizer: "" # https://github.com/aws/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction ApiFunction: @@ -38,6 +47,8 @@ Resources: STRIPE_SECRET_KEY: !Sub "{{resolve:secretsmanager:stripe-secret-key:SecretString}}" STRIPE_WEBHOOK_SECRET: !Sub "{{resolve:secretsmanager:stripe-webhook-secret:SecretString}}" FIREBASE_PROJECT_ID: !Sub "{{resolve:ssm:/api-python/firebase-project-id}}" + GOOGLE_APPLICATION_CREDENTIALS: !Sub "{{resolve:secretmanager:google-application-credentials:SecretString}}" + VERIFY_TOKEN_SIGNATURE: 0 Events: RootRoute: Type: HttpApi @@ -51,6 +62,8 @@ Resources: ApiId: !Ref ApiGateway Path: /{proxy+} Method: ANY + Auth: + Authorizer: FirebaseAuthorizer Outputs: ApiEndpoint: diff --git a/scripts/admin/.env.example b/scripts/admin/.env.example index 7a040af..e29dc28 100644 --- a/scripts/admin/.env.example +++ b/scripts/admin/.env.example @@ -35,5 +35,7 @@ PROD_STRIPE_SECRET_KEY='' PROD_STRIPE_WEBHOOK_SECRET='' # Firebase -FIREBASE_TOKEN='' PROD_FIREBASE_PROJECT_ID='' +PROD_GOOGLE_APPLICATION_CREDENTIALS='' +# CLI only +FIREBASE_TOKEN='' \ No newline at end of file diff --git a/scripts/admin/set-aws-secrets.sh b/scripts/admin/set-aws-secrets.sh index 2585b1b..93ba7f2 100755 --- a/scripts/admin/set-aws-secrets.sh +++ b/scripts/admin/set-aws-secrets.sh @@ -35,4 +35,4 @@ set_param "stripe-public-key" "$PROD_STRIPE_PUBLIC_KEY" set_secret "stripe-secret-key" "$PROD_STRIPE_SECRET_KEY" set_secret "stripe-webhook-secret" "$PROD_STRIPE_WEBHOOK_SECRET" set_param "firebase-project-id" "$PROD_FIREBASE_PROJECT_ID" - +set_secret "google-application-credentials" "$PROD_GOOGLE_APPLICATION_CREDENTIALS" diff --git a/scripts/preview_sam.sh b/scripts/preview_sam.sh index 808e9ea..b166dbb 100755 --- a/scripts/preview_sam.sh +++ b/scripts/preview_sam.sh @@ -20,7 +20,9 @@ cat < .env.json "STRIPE_PUBLIC_KEY": "$STRIPE_PUBLIC_KEY", "STRIPE_SECRET_KEY": "$STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET": "$STRIPE_WEBHOOK_SECRET", - "FIREBASE_PROJECT_ID": "$FIREBASE_PROJECT_ID" + "FIREBASE_PROJECT_ID": "$FIREBASE_PROJECT_ID", + "GOOGLE_APPLICATION_CREDENTIALS": "", + "VERIFY_TOKEN_SIGNATURE": "0" } } } From 55fe5910916faafc63d18ef02f701b6f2b906088 Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Fri, 20 Dec 2024 21:06:33 -0500 Subject: [PATCH 3/7] dive into testing ...and touch a bunch of other stuff :\ --- .devcontainer/.env.example | 8 +- .devcontainer/Dockerfile | 3 + .devcontainer/devcontainer.json | 3 +- .devcontainer/docker-compose.yml | 2 +- .devcontainer/post-start.sh | 3 +- .vscode/settings.json | 18 +- app/app.py | 4 +- tests/conftest.py => app/app_e2e_test.py | 10 +- app/auth/models.py | 69 +++-- app/auth/repo.py | 32 +++ app/auth/repo_firebase.py | 87 +++++++ app/auth/repo_memory.py | 45 ++++ app/auth/repo_memory_test.py | 93 +++++++ app/auth/router.py | 25 +- app/auth/service.py | 66 +++-- app/auth/service_firebase.py | 27 +- app/auth/service_firebase_integration_test.py | 11 + app/database/models.py | 37 +++ app/error.py | 35 +-- app/main.py | 15 +- app/main_lambda.py | 5 + app/service.py | 4 - app/subscription/service.py | 4 +- app/subscription/service_mock.py | 30 +++ .../service_stripe_integration_test.py | 0 app/user/models.py | 39 ++- app/user/repo.py | 19 ++ app/user/repo_in_mem.py | 32 +++ app/user/service.py | 32 +-- conftest.py | 28 ++ deploy/aws_sam/template.yaml | 2 +- firebase.json | 3 +- poetry.lock | 246 +++++++++--------- pyproject.toml | 8 +- scripts/build-sam.sh | 7 +- scripts/firebase-start-emulators.sh | 2 - scripts/preview_docker.sh | 18 +- scripts/run.sh | 2 + scripts/stripe-listen.sh | 6 +- scripts/test.sh | 23 ++ tests/unit/app/auth/models_test.py | 25 -- 41 files changed, 788 insertions(+), 340 deletions(-) rename tests/conftest.py => app/app_e2e_test.py (63%) create mode 100644 app/auth/repo.py create mode 100644 app/auth/repo_firebase.py create mode 100644 app/auth/repo_memory.py create mode 100644 app/auth/repo_memory_test.py create mode 100644 app/auth/service_firebase_integration_test.py create mode 100644 app/database/models.py create mode 100644 app/main_lambda.py create mode 100644 app/subscription/service_mock.py rename tests/integration/app/subscription_portal/service_test.py => app/subscription/service_stripe_integration_test.py (100%) create mode 100644 app/user/repo_in_mem.py create mode 100644 conftest.py create mode 100755 scripts/test.sh delete mode 100644 tests/unit/app/auth/models_test.py diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 5b6486d..8943dcf 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,7 +1,7 @@ # This file is used by ./docker-compose.yml. -TIME_ZONE=US/Eastern -GITHUB_TOKEN= +TIME_ZONE=US/Eastern # Shell environment +GITHUB_TOKEN= # GitHub CLI # Host ports # @@ -21,6 +21,6 @@ STRIPE_SECRET_KEY=sk_test_ # From Stripe dashboard STRIPE_WEBHOOK_SECRET=whsec_ # From Stripe CLI # Firebase config -FIREBASE_PROJECT_ID= +FIREBASE_PROJECT_ID=api-python # Any project ID will work locally, but cannot be blank. HOST_FIREBASE_AUTH_EMULATOR_PORT=9099 -HOST_FIREBASE_EMULATOR_UI_PORT=9090 +HOST_FIREBASE_EMULATOR_UI_PORT=4000 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 64cc531..93d841a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -14,3 +14,6 @@ RUN VERSION=$(basename $(curl -Ls -o /dev/null -w %{url_effective} https://githu unzip -jo nmig.zip "*/completion/neo4j-migrations_completion" -d ~/.local/share/bash-completion/completions/ && \ mv ~/.local/share/bash-completion/completions/neo4j-migrations_completion ~/.local/share/bash-completion/completions/neo4j-migrations && \ rm nmig.zip + +# Install firebase-tools (The npm installer on https://containers.dev/features is extremely slow) +RUN curl -sL https://firebase.tools | bash \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 138c4bd..c4ae00a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,8 +8,7 @@ "postCreateCommand": ".devcontainer/post-create.sh", "postStartCommand": ".devcontainer/post-start.sh", "features": { - "ghcr.io/nullcoder/devcontainer-features/stripe-cli:1": {}, - "ghcr.io/devcontainers-extra/features/firebase-cli:2": {} + "ghcr.io/nullcoder/devcontainer-features/stripe-cli:1": {} }, "customizations": { "vscode": { diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 894c837..e9db965 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -8,7 +8,7 @@ services: - ${HOST_API_DOCKER_PORT}:8001 - ${HOST_API_SAM_PORT}:8002 - ${HOST_FIREBASE_AUTH_EMULATOR_PORT}:9099 - - ${HOST_FIREBASE_EMULATOR_UI_PORT}:9090 + - ${HOST_FIREBASE_EMULATOR_UI_PORT}:4000 command: sleep infinity depends_on: - postgres diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 3043719..44251c7 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -2,5 +2,4 @@ # Make sure containerd and dockerd are running. sudo nohup containerd & -sudo nohup dockerd & -sudo nohup ./scripts/firebase-start-emulators.sh & \ No newline at end of file +sudo nohup dockerd & \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a98a69f..2fe3eef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,7 +27,13 @@ { "name": "dev", "commands": [ - "./scripts/run.sh" + "./scripts/test.sh unit && ./scripts/run.sh" + ] + }, + { + "name": "stripe", + "commands": [ + "./scripts/stripe-listen.sh" ] }, ] @@ -48,16 +54,6 @@ }, ] }, - { - "splitTerminals": [ - { - "name": "stripe", - "commands": [ - "./scripts/stripe-listen.sh" - ] - }, - ] - }, { "splitTerminals": [ { diff --git a/app/app.py b/app/app.py index 4b402ad..f26eb8b 100644 --- a/app/app.py +++ b/app/app.py @@ -28,8 +28,8 @@ def __post_init__(self): self.user, ] for service in self._services: - service._set_app(self) # type: ignore + service._app = self # type: ignore - async def shutdown(self): + async def shutdown(self) -> None: for service in self._services: await service.destroy() diff --git a/tests/conftest.py b/app/app_e2e_test.py similarity index 63% rename from tests/conftest.py rename to app/app_e2e_test.py index a607132..a6a25ea 100644 --- a/tests/conftest.py +++ b/app/app_e2e_test.py @@ -1,18 +1,18 @@ import pytest -from app.auth.service import AuthService +from app.app import App +from app.auth.service_firebase import AuthServiceFirebase from app.subscription.service_stripe import SubscriptionServiceStripe from app.subscription_portal.service_stripe import SubscriptionPortalServiceStripe +from app.user.repo_in_mem import UserRepoInMem from app.user.service import UserService @pytest.fixture def app(): - from app.app import App - return App( - auth=AuthService(), + auth=AuthServiceFirebase(), subscription=SubscriptionServiceStripe(), subscription_portal=SubscriptionPortalServiceStripe(), - user=UserService(), + user=UserService(repo=UserRepoInMem()), ) diff --git a/app/auth/models.py b/app/auth/models.py index d4530c5..05db1e5 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -1,33 +1,62 @@ from http import HTTPStatus -from typing import Any, Optional +from typing import Literal, NewType from pydantic import BaseModel from app.error import AppError -from app.user.models import Role +from app.subscription.models import SubscriptionLevel +UserId = NewType("UserId", str) +type Role = Literal["admin"] -class SetRole(BaseModel): - user_id: str - role: Optional[Role] + +class AuthUser(BaseModel): + id: UserId + name: str | None = None + email: str | None = None + password: str | None = None + email_verified: bool = False + phone: str | None = None + avatar: str | None = None + disabled: bool = False + role: Role | None = None + level: SubscriptionLevel | None = None + + def is_verified(self) -> bool: + return self.email_verified or (self.phone != None and len(self.phone) > 0) + + def is_admin(self) -> bool: + return self.role == "admin" + + def is_subscribed(self) -> bool: + return self.level is not None class InvalidTokenError(AppError): - def __init__(self, exception: Exception, details: Optional[dict[str, Any]] = None): - super().__init__( - code="auth/invalid-token", - message="Invalid token", - status=HTTPStatus.UNAUTHORIZED, - exception=exception, - details=details, - ) + def __init__(self): + super().__init__(code="auth/invalid-token", status=HTTPStatus.UNAUTHORIZED) class UnauthorizedError(AppError): - def __init__(self, details: Optional[dict[str, Any]] = None): - super().__init__( - code="auth/unauthorized", - message="Unauthorized", - status=HTTPStatus.UNAUTHORIZED, - details=details, - ) + def __init__(self): + super().__init__(code="auth/unauthorized", status=HTTPStatus.UNAUTHORIZED) + + +class AuthUserNotFoundError(AppError): + def __init__(self): + super().__init__(code="auth/user-not-found", status=HTTPStatus.NOT_FOUND) + + +class AuthUserDisabledError(AppError): + def __init__(self): + super().__init__(code="auth/user-disabled", status=HTTPStatus.FORBIDDEN) + + +class AuthUserAlreadyExistsError(AppError): + def __init__(self): + super().__init__(code="auth/user-already-exists", status=HTTPStatus.BAD_REQUEST) + + +class AuthInvalidUpdateError(AppError): + def __init__(self): + super().__init__(code="auth/invalid-update", status=HTTPStatus.BAD_REQUEST) diff --git a/app/auth/repo.py b/app/auth/repo.py new file mode 100644 index 0000000..e49da5d --- /dev/null +++ b/app/auth/repo.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import Any + +from app.auth.models import AuthUser, UserId + + +class AuthRepo(ABC): + @abstractmethod + async def create_user(self, user: AuthUser) -> None: + raise NotImplementedError() + + @abstractmethod + async def get_user_by_id(self, id: UserId) -> AuthUser: + raise NotImplementedError() + + @abstractmethod + async def update_user(self, id: UserId, data: dict[str, Any]) -> None: + """ + Update the properties on a user. + + Only the properties that are provided will be changed. + To remove a property, set its value to None. + """ + raise NotImplementedError() + + @abstractmethod + async def delete_user(self, id: UserId) -> None: + raise NotImplementedError() + + @abstractmethod + async def is_only_user(self, id: UserId) -> bool: + raise NotImplementedError() diff --git a/app/auth/repo_firebase.py b/app/auth/repo_firebase.py new file mode 100644 index 0000000..70eefd0 --- /dev/null +++ b/app/auth/repo_firebase.py @@ -0,0 +1,87 @@ +from typing import Any, override + +from firebase_admin import auth # type: ignore + +from app.auth.models import AuthInvalidUpdateError, AuthUser, UserId +from app.auth.repo import AuthRepo + + +class AuthRepoFirebase(AuthRepo): + @override + async def create_user(self, user: AuthUser) -> None: + profile = _to_firebase_user(user) + claims = profile.pop("custom_claims", {}) + + auth.create_user(**profile) # type: ignore + if len(claims) > 0: + auth.set_custom_user_claims(user.id, claims) # type: ignore + + @override + async def get_user_by_id(self, id: UserId) -> AuthUser: + user: auth.UserRecord = auth.get_user(id) # type: ignore + return _from_firebase_user(user) # type: ignore + + @override + async def update_user(self, id: UserId, data: dict[str, Any]) -> None: + if "id" in data and data["id"] != id: + raise AuthInvalidUpdateError() + + profile = _to_firebase_user(AuthUser(id=id, **data)) + del profile["uid"] + claims = profile.pop("custom_claims", {}) + + if len(profile) > 0: + auth.update_user(id, **profile) # type: ignore + if len(claims) > 0: + auth.set_custom_user_claims(id, claims) # type: ignore + + @override + async def delete_user(self, id: UserId) -> None: + return auth.delete_user(id) # type: ignore + + @override + async def is_only_user(self, id: UserId) -> bool: + page: auth.ListUsersPage = auth.list_users(max_results=2) # type: ignore + return len(page.users) == 1 and page.users[0].uid == id # type: ignore + + +def _from_firebase_user(user: auth.UserRecord) -> AuthUser: + return AuthUser( + id=user.uid, # type: ignore + name=user.display_name, # type: ignore + email=user.email, # type: ignore + email_verified=user.email_verified, + phone=user.phone_number, # type: ignore + avatar=user.photo_url, # type: ignore + disabled=user.disabled, + role=( + None if user.custom_claims is None else user.custom_claims.get("role", None) + ), + level=( + None + if user.custom_claims is None + else user.custom_claims.get("level", None) + ), + ) + + +def _to_firebase_user(user: AuthUser) -> dict[str, Any]: + profile: dict[str, Any] = { + "uid": user.id, + "display_name": user.name, + "email": user.email, + "password": user.password, + "email_verified": user.email_verified, + "phone_number": user.phone, + "photo_url": user.avatar, + "disabled": user.disabled, + } + + if user.role is not None or user.level is not None: + profile["custom_claims"] = {} + if user.role is not None: + profile["custom_claims"]["role"] = user.role + if user.level is not None: + profile["custom_claims"]["level"] = user.level + + return profile diff --git a/app/auth/repo_memory.py b/app/auth/repo_memory.py new file mode 100644 index 0000000..551ce27 --- /dev/null +++ b/app/auth/repo_memory.py @@ -0,0 +1,45 @@ +from typing import Any, override + +from app.auth.models import ( + AuthInvalidUpdateError, + AuthUser, + AuthUserAlreadyExistsError, + AuthUserNotFoundError, + UserId, +) +from app.auth.repo import AuthRepo + + +class AuthRepoMemory(AuthRepo): + def __init__(self) -> None: + self._data: dict[UserId, dict[str, Any]] = {} + + @override + async def create_user(self, user: AuthUser) -> None: + if user.id in self._data: + raise AuthUserAlreadyExistsError() + self._data[user.id] = user.model_dump(mode="json") + + @override + async def get_user_by_id(self, id: UserId) -> AuthUser: + if not id in self._data: + raise AuthUserNotFoundError() + return AuthUser(**self._data[id]) + + @override + async def update_user(self, id: UserId, data: dict[str, Any]) -> None: + if "id" in data and data["id"] != id: + raise AuthInvalidUpdateError() + if not id in self._data: + raise AuthUserNotFoundError() + self._data[id].update(data) + + @override + async def delete_user(self, id: UserId) -> None: + if not id in self._data: + raise AuthUserNotFoundError() + del self._data[id] + + @override + async def is_only_user(self, id: UserId) -> bool: + return len(self._data) == 1 and id in self._data diff --git a/app/auth/repo_memory_test.py b/app/auth/repo_memory_test.py new file mode 100644 index 0000000..108be1f --- /dev/null +++ b/app/auth/repo_memory_test.py @@ -0,0 +1,93 @@ +import pytest + +from app.auth.models import ( + AuthInvalidUpdateError, + AuthUser, + AuthUserAlreadyExistsError, + AuthUserNotFoundError, + UserId, +) +from app.auth.repo import AuthRepo +from app.auth.repo_memory import AuthRepoMemory + + +@pytest.fixture +def repo() -> AuthRepo: + return AuthRepoMemory() + + +@pytest.fixture +def user() -> AuthUser: + return AuthUser(id=UserId("user_1")) + + +@pytest.fixture +def user2() -> AuthUser: + return AuthUser(id=UserId("user_2")) + + +@pytest.mark.asyncio +async def test_no_users(repo: AuthRepo, user: AuthUser): + with pytest.raises(AuthUserNotFoundError): + await repo.get_user_by_id(user.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.update_user(user.id, user.model_dump(mode="json")) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user.id) + + assert await repo.is_only_user(user.id) == False + + +@pytest.mark.asyncio +async def test_create_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + with pytest.raises(AuthUserAlreadyExistsError): + await repo.create_user(user) + assert await repo.is_only_user(user.id) == True + + await repo.create_user(user2) + with pytest.raises(AuthUserAlreadyExistsError): + await repo.create_user(user2) + assert await repo.is_only_user(user.id) == False + assert await repo.is_only_user(user2.id) == False + + +@pytest.mark.asyncio +async def test_read_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + assert await repo.get_user_by_id(user.id) == user + + await repo.create_user(user2) + assert await repo.get_user_by_id(user2.id) == user2 + + assert await repo.get_user_by_id(user.id) == user + + +@pytest.mark.asyncio +async def test_update_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + user.email = None + await repo.create_user(user) + + user.email = "a@b.c" + await repo.update_user(user.id, user.model_dump(mode="json")) + assert await repo.get_user_by_id(user.id) == user + + user.email = None + await repo.update_user(user.id, user.model_dump(mode="json")) + assert user == await repo.get_user_by_id(user.id) + + user2.phone = None + await repo.create_user(user2) + + user2.phone = "1234567890" + await repo.update_user(user2.id, user2.model_dump(mode="json")) + assert user2 == await repo.get_user_by_id(user2.id) + + user2.phone = None + await repo.update_user(user2.id, user2.model_dump(mode="json")) + assert user2 == await repo.get_user_by_id(user2.id) + + with pytest.raises(AuthInvalidUpdateError): + await repo.update_user(user.id, user2.model_dump(mode="json")) diff --git a/app/auth/router.py b/app/auth/router.py index d9c6cc7..1693d44 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -1,10 +1,9 @@ +import jwt from fastapi import APIRouter, Depends from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from jwt import JWT -from app.auth.models import InvalidTokenError, SetRole, UnauthorizedError +from app.auth.models import AuthUser, InvalidTokenError, Role, UnauthorizedError, UserId from app.auth.service import AuthService -from app.user.models import TokenUser from config import VERIFY_TOKEN_SIGNATURE _security = HTTPBearer() @@ -12,21 +11,15 @@ def decode_jwt( credentials: HTTPAuthorizationCredentials = Depends(_security), -) -> TokenUser: +) -> AuthUser: token = credentials.credentials try: - claims = JWT().decode(token, do_verify=VERIFY_TOKEN_SIGNATURE) + claims = jwt.decode(token, do_verify=VERIFY_TOKEN_SIGNATURE) except Exception as e: - raise InvalidTokenError(exception=e) from e + raise InvalidTokenError() from e - return TokenUser( + return AuthUser( id=claims["sub"], - sign_in_method=claims["firebase"]["sign_in_provider"], - name=claims.get("name", None), - email=claims.get("email", None), - email_verified=claims.get("email_verified", None), - phone=claims.get("phone_number", None), - photo_url=claims.get("photo_url", None), role=claims.get("role", None), level=claims.get("level", None), ) @@ -40,13 +33,13 @@ def __init__(self, service: AuthService): self.post("/sign-up")(self.sign_up) self.post("/set-role")(self.set_role) - async def sign_up(self, user: TokenUser = Depends(decode_jwt)) -> None: + async def sign_up(self, user: AuthUser = Depends(decode_jwt)) -> None: await self._service.sign_up(user) async def set_role( - self, data: SetRole, user: TokenUser = Depends(decode_jwt) + self, user_id: UserId, role: Role, user: AuthUser = Depends(decode_jwt) ) -> None: if user.role == "admin" or await self._service.is_only_user(user.id): - await self._service.set_role(data.user_id, data.role) + await self._service.set_role(user_id, role) else: raise UnauthorizedError() diff --git a/app/auth/service.py b/app/auth/service.py index c2de650..c7c2daf 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -1,39 +1,37 @@ -from abc import abstractmethod -from datetime import datetime -from typing import Optional - +from app.auth.models import AuthUser, Role, UserId +from app.auth.repo import AuthRepo from app.service import Service from app.subscription.models import SubscriptionLevel -from app.user.models import FullUser, Role, TokenUser class AuthService(Service): - """The service that contains the core business logic for authentication.""" - - async def sign_up(self, user: TokenUser) -> None: - await self._app.user.create_user( - FullUser( - id=user.id, - name=user.name, - email=user.email, - email_verified=user.email_verified, - phone=user.phone, - photo_url=user.photo_url, - sign_in_methods=[user.sign_in_method], - created_at=datetime.now(), - ) - ) - - @abstractmethod - async def set_role(self, user_id: str, role: Optional[Role]) -> None: - raise NotImplementedError() - - @abstractmethod - async def set_subscription_level( - self, user_id: str, level: Optional[SubscriptionLevel] - ) -> None: - raise NotImplementedError() - - @abstractmethod - async def is_only_user(self, user_id: str) -> bool: - raise NotImplementedError() + """Business logic around authentication and authorization.""" + + def __init__(self, repo: AuthRepo): + self._repo = repo + + async def sign_up(self, user: AuthUser) -> None: + await self._repo.create_user(user) + + async def get_user(self, id: UserId) -> AuthUser: + return await self._repo.get_user_by_id(id) + + async def set_role(self, id: UserId, role: Role): + await self._repo.update_user(id, {"role": role}) + + async def is_only_user(self, id: UserId) -> bool: + """ + Check if the user is the only user in the system. + + This is useful for bootstrapping the first admin user. + """ + return await self._repo.is_only_user(id) + + async def disable_user(self, id: UserId): + await self._repo.update_user(id, {"disabled": True}) + + async def set_subscription_level(self, id: UserId, level: SubscriptionLevel): + await self._repo.update_user(id, {"level": level}) + + async def delete_user(self, id: UserId): + await self._repo.delete_user(id) diff --git a/app/auth/service_firebase.py b/app/auth/service_firebase.py index cb8089e..3d478ca 100644 --- a/app/auth/service_firebase.py +++ b/app/auth/service_firebase.py @@ -1,12 +1,11 @@ import json import logging -from typing import Any, Optional, override +from typing import Any -from firebase_admin import auth, credentials, initialize_app # type: ignore +from firebase_admin import credentials, initialize_app # type: ignore +from app.auth.repo_firebase import AuthRepoFirebase from app.auth.service import AuthService -from app.subscription.models import SubscriptionLevel -from app.user.models import Role from config import ( FIREBASE_AUTH_EMULATOR_HOST, FIREBASE_PROJECT_ID, @@ -16,6 +15,8 @@ class AuthServiceFirebase(AuthService): def __init__(self): + super().__init__(repo=AuthRepoFirebase()) + options: dict[str, Any] = { "projectId": FIREBASE_PROJECT_ID, "httpTimeout": 10, @@ -28,21 +29,3 @@ def __init__(self): raise ValueError("GOOGLE_APPLICATION_CREDENTIALS not set") cred = credentials.Certificate(json.loads(GOOGLE_APPLICATION_CREDENTIALS)) initialize_app(credential=cred, options=options) - - @override - async def set_role(self, user_id: str, role: Optional[Role]) -> None: - await self.set_claims(user_id, {"role": role}) - - @override - async def set_subscription_level( - self, user_id: str, level: Optional[SubscriptionLevel] - ) -> None: - await self.set_claims(user_id, {"level": level}) - - @override - async def is_only_user(self, user_id: str) -> bool: - page: auth.ListUsersPage = auth.list_users(max_results=2) # type: ignore - return len(page.users) == 1 and page.users[0].uid == user_id # type: ignore - - async def set_claims(self, user_id: str, claims: Optional[dict[str, Any]]) -> None: - auth.set_custom_user_claims(user_id, claims) # type: ignore diff --git a/app/auth/service_firebase_integration_test.py b/app/auth/service_firebase_integration_test.py new file mode 100644 index 0000000..86356b6 --- /dev/null +++ b/app/auth/service_firebase_integration_test.py @@ -0,0 +1,11 @@ +import pytest + +from app.app import App +from app.auth.service_firebase import AuthServiceFirebase + + +@pytest.fixture +def app(app: App) -> App: + app.auth = AuthServiceFirebase() + app.__post_init__() + return app diff --git a/app/database/models.py b/app/database/models.py new file mode 100644 index 0000000..7ff964b --- /dev/null +++ b/app/database/models.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T", bound=BaseModel) + +# These help separate the persistence layer from the domain layer. +# The persistence layer is responsible for creating IDs and timestamps. +# The domain layer is for business logic, i.e. how non-technical people would speak about the data. + + +class Unique(BaseModel, Generic[T]): + """ + Wraps models that don't have an inherent ID field. + + In those cases, the [id] comes from the persistence layer (repositories). + """ + + model: T + id: str + + +class Created(BaseModel, Generic[T]): + """ + Wraps models that don't have an inherent creation timestamp. + + In those cases, [created_at] comes from the persistence layer (repositories). + """ + + model: T + created_at: datetime + + +class Updated(BaseModel, Generic[T]): + model: T + updated_at: datetime diff --git a/app/error.py b/app/error.py index 94cc9a3..9a6da3d 100644 --- a/app/error.py +++ b/app/error.py @@ -1,6 +1,6 @@ import logging from http import HTTPStatus -from typing import LiteralString, Optional +from typing import Any, LiteralString class AppError(Exception): @@ -8,30 +8,31 @@ class AppError(Exception): def __init__( self, - code: LiteralString = "error/internal", + code: LiteralString, status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, - message: Optional[str] = None, - exception: Optional[Exception] = None, - details: Optional[dict[str, object]] = None, + message: str | None = None, + detail: Any = None, ): super().__init__(f"{code}: {message}") + self.status = status + self.code: str = code """A string in the form `category/error-name`, e.g. `user/not-found`.""" - self.message = message - self.status = status + self.message: str = message or self.status.phrase + """A message to be sent along with the error code. Defaults to the [status] phrase.""" - self.details = details - """Additional information about the error.""" + self.detail: Any = detail + """Additional information about the error that will be logged internally.""" # Log the error - parts = [f"code: {code}"] - if message: - parts.append(f"message: {message}") - if exception: - parts.append(f"exception: {exception}") - if details: - parts.append(f"details: {details}") - parts.append(f"status={status}") + parts = [ + f"status: {status}", + f"code: {code}", + f"message: {message}", + ] + if detail: + parts.append(f"detail: {detail}") + logging.error("\n".join(parts)) diff --git a/app/main.py b/app/main.py index a825308..7f63ba1 100644 --- a/app/main.py +++ b/app/main.py @@ -3,7 +3,6 @@ from fastapi import APIRouter, FastAPI, Request from fastapi.responses import JSONResponse -from mangum import Mangum from starlette.middleware.cors import CORSMiddleware from app.app import App @@ -14,6 +13,7 @@ from app.subscription.router_fastapi import SubscriptionRouterFastApi from app.subscription.service_stripe import SubscriptionServiceStripe from app.subscription_portal.service_stripe import SubscriptionPortalServiceStripe +from app.user.repo_in_mem import UserRepoInMem from app.user.service import UserService from config import LOGGING_LEVEL @@ -24,7 +24,7 @@ auth=AuthServiceFirebase(), subscription=SubscriptionServiceStripe(), subscription_portal=SubscriptionPortalServiceStripe(), - user=UserService(), + user=UserService(repo=UserRepoInMem()), ) routers: list[APIRouter] = [ @@ -54,14 +54,17 @@ async def lifespan(_: FastAPI): app_router.include_router(router) app_router.get("/")(lambda: {"Hello": "World"}) -lambda_handler = Mangum(app_router) - @app_router.exception_handler(AppError) -async def app_error_handler(request: Request, e: AppError): +async def app_error_handler(_: Request, e: AppError): return JSONResponse( status_code=e.status, - content={"error": {"code": e.code, "message": e.message, "details": e.details}}, + content={ + "error": { + "code": e.code, + "message": e.message, + } + }, ) diff --git a/app/main_lambda.py b/app/main_lambda.py new file mode 100644 index 0000000..130bbac --- /dev/null +++ b/app/main_lambda.py @@ -0,0 +1,5 @@ +from mangum import Mangum + +from app.main import app_router + +lambda_handler = Mangum(app_router) diff --git a/app/service.py b/app/service.py index 97d5309..95b5f14 100644 --- a/app/service.py +++ b/app/service.py @@ -14,10 +14,6 @@ class Service: Services may call other services by using `self.app.`. """ - def _set_app(self, app: "App") -> None: - """Intended to be called by the `App` class only.""" - self._app = app - async def destroy(self) -> None: """Clean up resources, the app is exiting.""" pass diff --git a/app/subscription/service.py b/app/subscription/service.py index 9dc2eac..12804ce 100644 --- a/app/subscription/service.py +++ b/app/subscription/service.py @@ -4,7 +4,7 @@ from app.service import Service from app.subscription.models import SubscriptionType -from app.user.models import FullUser, User +from app.user.models import User class SubscriptionService(Service): @@ -30,7 +30,7 @@ def subscription_type(self, type_id: str) -> SubscriptionType: raise NotImplementedError() async def get_customer_id(self, user: User) -> Optional[str]: - if not isinstance(user, FullUser): + if not isinstance(user, User): user = await self._app.user.get_user(user.id) return user.stripe_customer_id diff --git a/app/subscription/service_mock.py b/app/subscription/service_mock.py new file mode 100644 index 0000000..6d1bd29 --- /dev/null +++ b/app/subscription/service_mock.py @@ -0,0 +1,30 @@ +from typing import Any, override + +from app.subscription.models import SubscriptionType +from app.subscription.service import SubscriptionService +from app.user.models import User + + +class SubscriptionServiceMock(SubscriptionService): + @override + async def create_customer_if_necessary(self, user: User) -> str: + return user.id + + @override + async def handle_webhook(self, headers: dict[str, Any], body: bytes) -> None: + pass + + @override + def is_active(self, status: str) -> bool: + return status == "True" + + @override + def type_id(self, type: SubscriptionType) -> str: + return f"{type.level}.{type.period}" + + @override + def subscription_type(self, type_id: str) -> SubscriptionType: + if "." in type_id: + level, period = type_id.split(".") + return SubscriptionType(level=level, period=period) # type: ignore + return SubscriptionType(level="pro", period="monthly") diff --git a/tests/integration/app/subscription_portal/service_test.py b/app/subscription/service_stripe_integration_test.py similarity index 100% rename from tests/integration/app/subscription_portal/service_test.py rename to app/subscription/service_stripe_integration_test.py diff --git a/app/user/models.py b/app/user/models.py index ab959ac..1c10b56 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -1,14 +1,9 @@ -from datetime import datetime -from typing import Literal, Optional +from typing import Optional, override from pydantic import BaseModel from app.subscription.models import SubscriptionLevel -type Role = Literal["admin", "disabled"] - -type SignInMethod = Literal["password", "anonymous"] - class User(BaseModel): id: str @@ -17,16 +12,44 @@ class User(BaseModel): email_verified: Optional[bool] = None phone: Optional[str] = None photo_url: Optional[str] = None + is_disabled: bool = False role: Optional[Role] = None level: Optional[SubscriptionLevel] = None + def is_admin(self) -> bool: + return self.role == "admin" + + def is_subscribed(self) -> bool: + return self.level is not None + + def to_full_user(self) -> "FullUser": + return FullUser( + id=self.id, + name=self.name, + email=self.email, + email_verified=self.email_verified, + phone=self.phone, + photo_url=self.photo_url, + role=self.role, + level=self.level, + ) + class TokenUser(User): sign_in_method: SignInMethod + @override + def to_full_user(self) -> "FullUser": + user = super().to_full_user() + user.sign_in_methods = [self.sign_in_method] + return user + class FullUser(User): - sign_in_methods: list[SignInMethod] - created_at: datetime + sign_in_methods: list[SignInMethod] = [] stripe_customer_id: Optional[str] = None stripe_subscription_id: Optional[str] = None + + @override + def to_full_user(self) -> "FullUser": + return self diff --git a/app/user/repo.py b/app/user/repo.py index e69de29..d5eb80a 100644 --- a/app/user/repo.py +++ b/app/user/repo.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod +from typing import Any + +from app.database.models import Created +from app.user.models import FullUser, User + + +class UserRepo(ABC): + @abstractmethod + async def create_user(self, user: User) -> None: + raise NotImplementedError() + + @abstractmethod + async def get_user_by_id(self, user_id: str) -> Created[FullUser]: + raise NotImplementedError() + + @abstractmethod + async def update_user(self, user_id: str, data: dict[str, Any]) -> None: + raise NotImplementedError() diff --git a/app/user/repo_in_mem.py b/app/user/repo_in_mem.py new file mode 100644 index 0000000..f707ea0 --- /dev/null +++ b/app/user/repo_in_mem.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Any, override + +from app.database.models import Created +from app.user.models import FullUser, User +from app.user.repo import UserRepo + + +class UserRepoInMem(UserRepo): + def __init__(self): + self._data: dict[str, Any] = {} + + @override + async def create_user(self, user: User) -> None: + if user.id in self._data: + raise ValueError(f"User {user.id} already exists.") + self._data[user.id] = Created[FullUser]( + created_at=datetime.now(), + model=user.to_full_user(), + ) + + @override + async def get_user_by_id(self, user_id: str) -> Created[FullUser]: + if user_id not in self._data: + raise ValueError(f"User {user_id} does not exist.") + return self._data[user_id] + + @override + async def update_user(self, user_id: str, data: dict[str, Any]) -> None: + if user_id not in self._data: + raise ValueError(f"User {user_id} does not exist.") + self._data[user_id].update(data) diff --git a/app/user/service.py b/app/user/service.py index 34266c2..a1dc628 100644 --- a/app/user/service.py +++ b/app/user/service.py @@ -1,33 +1,27 @@ -import logging -from datetime import datetime from typing import Optional +from app.database.models import Created from app.service import Service -from app.user.models import FullUser +from app.user.models import FullUser, User +from app.user.repo import UserRepo class UserService(Service): - async def create_user(self, user: FullUser) -> None: - logging.warning(f"Creating user {user.id}") - # raise NotImplementedError() + def __init__(self, repo: UserRepo) -> None: + self._repo = repo - async def get_user(self, user_id: str) -> FullUser: - logging.warning(f"Returning fake user") - return FullUser( - id=user_id, - created_at=datetime.now(), - sign_in_methods=["email_password"], - ) - # raise NotImplementedError() + async def create_user(self, user: User) -> None: + await self._repo.create_user(user) + + async def get_user(self, user_id: str) -> Created[FullUser]: + return await self._repo.get_user_by_id(user_id) async def set_stripe_customer_id(self, user_id: str, customer_id: str) -> None: - logging.debug(f"Setting Stripe customer ID for user {user_id} to {customer_id}") - # raise NotImplementedError() + await self._repo.update_user(user_id, {"stripe_customer_id": customer_id}) async def set_stripe_subscription_id( self, user_id: str, subscription_id: Optional[str] ) -> None: - logging.debug( - f"Setting subscription ID for user {user_id} to {subscription_id}" + await self._repo.update_user( + user_id, {"stripe_subscription_id": subscription_id} ) - # raise NotImplementedError() diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..f6c7dfe --- /dev/null +++ b/conftest.py @@ -0,0 +1,28 @@ +import pytest + +from app.app import App +from app.auth.service_memory import AuthServiceMock +from app.subscription.service_mock import SubscriptionServiceMock +from app.subscription_portal.service_stripe import SubscriptionPortalServiceStripe +from app.user.repo_in_mem import UserRepoInMem +from app.user.service import UserService + + +@pytest.fixture +def app(): + return App( + auth=AuthServiceMock(), + subscription=SubscriptionServiceMock(), + subscription_portal=SubscriptionPortalServiceStripe(), + user=UserService(repo=UserRepoInMem()), + ) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if "_integration_test" in str(item.fspath): + item.add_marker(pytest.mark.integration) + elif "_e2e_test" in str(item.fspath): + item.add_marker(pytest.mark.e2e) + elif "_test.py" in str(item.fspath): + item.add_marker(pytest.mark.unit) diff --git a/deploy/aws_sam/template.yaml b/deploy/aws_sam/template.yaml index 387b1c3..9ed498a 100644 --- a/deploy/aws_sam/template.yaml +++ b/deploy/aws_sam/template.yaml @@ -33,7 +33,7 @@ Resources: Type: AWS::Serverless::Function Properties: Runtime: python3.12 - Handler: app.main.lambda_handler + Handler: app.main_lambda.lambda_handler CodeUri: ./ Timeout: 30 MemorySize: 256 diff --git a/firebase.json b/firebase.json index 25d0320..6070b78 100644 --- a/firebase.json +++ b/firebase.json @@ -4,8 +4,7 @@ "port": 9099 }, "ui": { - "enabled": true, - "port": 9090 + "enabled": true }, "singleProjectMode": true } diff --git a/poetry.lock b/poetry.lock index bcaa4de..30a5456 100644 --- a/poetry.lock +++ b/poetry.lock @@ -490,13 +490,13 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.155.0" +version = "2.156.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" files = [ - {file = "google_api_python_client-2.155.0-py2.py3-none-any.whl", hash = "sha256:83fe9b5aa4160899079d7c93a37be306546a17e6686e2549bcc9584f1a229747"}, - {file = "google_api_python_client-2.155.0.tar.gz", hash = "sha256:25529f89f0d13abcf3c05c089c423fb2858ac16e0b3727543393468d0d7af67c"}, + {file = "google_api_python_client-2.156.0-py2.py3-none-any.whl", hash = "sha256:6352185c505e1f311f11b0b96c1b636dcb0fec82cd04b80ac5a671ac4dcab339"}, + {file = "google_api_python_client-2.156.0.tar.gz", hash = "sha256:b809c111ded61716a9c1c7936e6899053f13bae3defcdfda904bd2ca68065b9c"}, ] [package.dependencies] @@ -1008,22 +1008,22 @@ testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "5.29.1" +version = "5.29.2" description = "" optional = false python-versions = ">=3.8" files = [ - {file = "protobuf-5.29.1-cp310-abi3-win32.whl", hash = "sha256:22c1f539024241ee545cbcb00ee160ad1877975690b16656ff87dde107b5f110"}, - {file = "protobuf-5.29.1-cp310-abi3-win_amd64.whl", hash = "sha256:1fc55267f086dd4050d18ef839d7bd69300d0d08c2a53ca7df3920cc271a3c34"}, - {file = "protobuf-5.29.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d473655e29c0c4bbf8b69e9a8fb54645bc289dead6d753b952e7aa660254ae18"}, - {file = "protobuf-5.29.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5ba1d0e4c8a40ae0496d0e2ecfdbb82e1776928a205106d14ad6985a09ec155"}, - {file = "protobuf-5.29.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ee1461b3af56145aca2800e6a3e2f928108c749ba8feccc6f5dd0062c410c0d"}, - {file = "protobuf-5.29.1-cp38-cp38-win32.whl", hash = "sha256:50879eb0eb1246e3a5eabbbe566b44b10348939b7cc1b267567e8c3d07213853"}, - {file = "protobuf-5.29.1-cp38-cp38-win_amd64.whl", hash = "sha256:027fbcc48cea65a6b17028510fdd054147057fa78f4772eb547b9274e5219331"}, - {file = "protobuf-5.29.1-cp39-cp39-win32.whl", hash = "sha256:5a41deccfa5e745cef5c65a560c76ec0ed8e70908a67cc8f4da5fce588b50d57"}, - {file = "protobuf-5.29.1-cp39-cp39-win_amd64.whl", hash = "sha256:012ce28d862ff417fd629285aca5d9772807f15ceb1a0dbd15b88f58c776c98c"}, - {file = "protobuf-5.29.1-py3-none-any.whl", hash = "sha256:32600ddb9c2a53dedc25b8581ea0f1fd8ea04956373c0c07577ce58d312522e0"}, - {file = "protobuf-5.29.1.tar.gz", hash = "sha256:683be02ca21a6ffe80db6dd02c0b5b2892322c59ca57fd6c872d652cb80549cb"}, + {file = "protobuf-5.29.2-cp310-abi3-win32.whl", hash = "sha256:c12ba8249f5624300cf51c3d0bfe5be71a60c63e4dcf51ffe9a68771d958c851"}, + {file = "protobuf-5.29.2-cp310-abi3-win_amd64.whl", hash = "sha256:842de6d9241134a973aab719ab42b008a18a90f9f07f06ba480df268f86432f9"}, + {file = "protobuf-5.29.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a0c53d78383c851bfa97eb42e3703aefdc96d2036a41482ffd55dc5f529466eb"}, + {file = "protobuf-5.29.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:494229ecd8c9009dd71eda5fd57528395d1eacdf307dbece6c12ad0dd09e912e"}, + {file = "protobuf-5.29.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b6b0d416bbbb9d4fbf9d0561dbfc4e324fd522f61f7af0fe0f282ab67b22477e"}, + {file = "protobuf-5.29.2-cp38-cp38-win32.whl", hash = "sha256:e621a98c0201a7c8afe89d9646859859be97cb22b8bf1d8eacfd90d5bda2eb19"}, + {file = "protobuf-5.29.2-cp38-cp38-win_amd64.whl", hash = "sha256:13d6d617a2a9e0e82a88113d7191a1baa1e42c2cc6f5f1398d3b054c8e7e714a"}, + {file = "protobuf-5.29.2-cp39-cp39-win32.whl", hash = "sha256:36000f97ea1e76e8398a3f02936aac2a5d2b111aae9920ec1b769fc4a222c4d9"}, + {file = "protobuf-5.29.2-cp39-cp39-win_amd64.whl", hash = "sha256:2d2e674c58a06311c8e99e74be43e7f3a8d1e2b2fdf845eaa347fbd866f23355"}, + {file = "protobuf-5.29.2-py3-none-any.whl", hash = "sha256:fde4554c0e578a5a0bcc9a276339594848d1e89f9ea47b4427c80e5d72f90181"}, + {file = "protobuf-5.29.2.tar.gz", hash = "sha256:b2cc8e8bb7c9326996f0e160137b0861f1a82162502658df2951209d0cb0309e"}, ] [[package]] @@ -1087,18 +1087,18 @@ files = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.10.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, - {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, + {file = "pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d"}, + {file = "pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.27.1" +pydantic-core = "2.27.2" typing-extensions = ">=4.12.2" [package.extras] @@ -1107,111 +1107,111 @@ timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, - {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, - {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, - {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, - {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, - {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, - {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, - {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, - {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, - {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, - {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, - {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, - {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, - {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, - {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, - {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, - {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, ] [package.dependencies] @@ -1399,13 +1399,13 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7 [[package]] name = "stripe" -version = "11.3.0" +version = "11.4.1" description = "Python bindings for the Stripe API" optional = false python-versions = ">=3.6" files = [ - {file = "stripe-11.3.0-py2.py3-none-any.whl", hash = "sha256:9d2e86943e1e4f325835d3860c4f58aa98d49229c9caf67588f9f9b2451e8e56"}, - {file = "stripe-11.3.0.tar.gz", hash = "sha256:98e625d9ddbabcecf02666867169696e113d9eaba27979fb310a7a8dfd44097c"}, + {file = "stripe-11.4.1-py2.py3-none-any.whl", hash = "sha256:8aa47a241de0355c383c916c4ef7273ab666f096a44ee7081e357db4a36f0cce"}, + {file = "stripe-11.4.1.tar.gz", hash = "sha256:7ddd251b622d490fe57d78487855dc9f4d95b1bb113607e81fd377037a133d5a"}, ] [package.dependencies] diff --git a/pyproject.toml b/pyproject.toml index 5bf7326..05c4216 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ package-mode = false [tool.poetry.dependencies] python = "^3.12" fastapi = "^0.115.5" - mangum = "^0.19.0" pydantic = "^2.9.2" pydantic-extra-types = "^2.10.0" @@ -36,5 +35,10 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] pythonpath = "." -addopts = "--import-mode=importlib" +addopts = ["--import-mode=importlib", "--tb=short"] asyncio_default_fixture_loop_scope = "function" +markers = [ + "unit: marks tests as unit tests", + "integration: marks tests requiring external services", + "e2e: marks tests as end-to-end tests", +] diff --git a/scripts/build-sam.sh b/scripts/build-sam.sh index 8a2717f..88e1027 100755 --- a/scripts/build-sam.sh +++ b/scripts/build-sam.sh @@ -8,23 +8,18 @@ sam_dir=$script_dir/../deploy/aws_sam # Copy files pushd $root_dir > /dev/null - cp -r config.py app pyproject.toml poetry.lock $sam_dir/ - +find "$sam_dir" -type f \( -name '*_mock.py' -o -name '*_test.py' -o -name 'conftest.py' \) -delete popd > /dev/null # Build pushd $sam_dir > /dev/null - poetry export -f requirements.txt --output requirements.txt --without-hashes sam build #--debug rm -r requirements.txt config.py app pyproject.toml poetry.lock - popd > /dev/null # Remove files pushd $sam_dir/.aws-sam/build/ApiFunction > /dev/null - rm COPYING poetry.lock pyproject.toml requirements.txt samconfig.toml template.yaml - popd > /dev/null \ No newline at end of file diff --git a/scripts/firebase-start-emulators.sh b/scripts/firebase-start-emulators.sh index 7b8aa54..c5b4a1b 100755 --- a/scripts/firebase-start-emulators.sh +++ b/scripts/firebase-start-emulators.sh @@ -6,5 +6,3 @@ DATA_DIR="$SCRIPT_DIR/../firebase_emulator_data" mkdir -p "$DATA_DIR" firebase emulators:start --only auth --project="$FIREBASE_PROJECT_ID" --import="$DATA_DIR" --export-on-exit="$DATA_DIR" - -sleep infinity \ No newline at end of file diff --git a/scripts/preview_docker.sh b/scripts/preview_docker.sh index c54900a..9a83541 100755 --- a/scripts/preview_docker.sh +++ b/scripts/preview_docker.sh @@ -6,7 +6,21 @@ script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" pushd $script_dir/.. > /dev/null docker build -f deploy/docker/Dockerfile -t api-python . -printenv > deploy/docker/.env -docker run --env-file=deploy/docker/.env -e PORT=8001 -e WORKERS=1 -it api-python + +docker run \ + -e PORT=8001 \ + -e WORKERS=1 \ + -e LOGGING_LEVEL="$LOGGING_LEVEL" \ + -e POSTGRES_URI="$POSTGRES_URI" \ + -e NEO4J_URI="$NEO4J_URI" \ + -e NEO4J_PASSWORD="$NEO4J_PASSWORD" \ + -e STRIPE_PUBLIC_KEY="$STRIPE_PUBLIC_KEY" \ + -e STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" \ + -e STRIPE_WEBHOOK_SECRET="$STRIPE_WEBHOOK_SECRET" \ + -e FIREBASE_PROJECT_ID="$FIREBASE_PROJECT_ID" \ + -e FIREBASE_AUTH_EMULATOR_HOST="$FIREBASE_AUTH_EMULATOR_HOST" \ + -e GOOGLE_APPLICATION_CREDENTIALS="$GOOGLE_APPLICATION_CREDENTIALS" \ + -e VERIFY_TOKEN_SIGNATURE="$VERIFY_TOKEN_SIGNATURE" \ + -it api-python popd > /dev/null diff --git a/scripts/run.sh b/scripts/run.sh index 451a401..25a7938 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -5,6 +5,8 @@ set -e script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" pushd $script_dir/.. > /dev/null +$script_dir/firebase-start-emulators.sh & + poetry lock poetry install --no-root poetry run python -m uvicorn app.main:app_router --reload --log-level trace --host 0.0.0.0 diff --git a/scripts/stripe-listen.sh b/scripts/stripe-listen.sh index 0d86c54..f0c73e7 100755 --- a/scripts/stripe-listen.sh +++ b/scripts/stripe-listen.sh @@ -1,4 +1,6 @@ #!/usr/bin/env bash -port="${1:-8000}" # 8001 for Docker, 8002 for SAM -stripe listen --forward-to localhost:${port}/subscription/webhook --api-key "${STRIPE_SECRET_KEY}" \ No newline at end of file +PORT="${1:-8000}" # 8001 for Docker, 8002 for SAM +TARGET="localhost:${port}/subscription/webhook" +echo "Forwarding to $TARGET" +stripe listen --forward-to "$TARGET" --api-key "$STRIPE_SECRET_KEY" \ No newline at end of file diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..ed27377 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -e + +if [ "$#" -gt 1 ]; then + echo "Usage: $0 [unit|integration|e2e]" + echo "Defaults to running all tests." + exit 1 +fi + +GROUP="$1" + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +pushd "$SCRIPT_DIR/.." > /dev/null + +if [ -z "$GROUP" ]; then + poetry run pytest +else + poetry run pytest -m "$GROUP" +fi + +popd > /dev/null \ No newline at end of file diff --git a/tests/unit/app/auth/models_test.py b/tests/unit/app/auth/models_test.py deleted file mode 100644 index 3f75f98..0000000 --- a/tests/unit/app/auth/models_test.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest -from pydantic import ValidationError - -from app.auth.models import SignUpData - - -def test_signup_data_valid(): - data = SignUpData(email="test@example.com", password="strongpassword123") - assert data.email == "test@example.com" - assert data.password == "strongpassword123" - - -def test_signup_data_invalid_email(): - with pytest.raises(ValidationError): - SignUpData(email="invalid-email", password="strongpassword123") - - -def test_signup_data_missing_email(): - with pytest.raises(ValidationError): - SignUpData(password="strongpassword123") # type: ignore - - -def test_signup_data_missing_password(): - with pytest.raises(ValidationError): - SignUpData(email="test@example.com") # type: ignore From 835311e727f45c1491970183cac0a8efd8601ebd Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Sat, 21 Dec 2024 13:21:35 -0500 Subject: [PATCH 4/7] wip firebase auth integration tests are starting to work with the emulator --- .vscode/settings.json | 32 ++++- app/app_e2e_test.py | 5 +- app/auth/models.py | 16 +-- app/auth/repo.py | 5 +- app/auth/repo_firebase.py | 65 +++++++-- app/auth/repo_firebase_integration_test.py | 123 ++++++++++++++++++ app/auth/repo_memory.py | 6 +- app/auth/repo_memory_test.py | 25 +++- app/auth/router.py | 3 +- app/auth/service.py | 9 +- app/auth/service_firebase.py | 31 ----- app/auth/service_firebase_integration_test.py | 11 -- app/main.py | 5 +- app/user/models.py | 56 ++------ config.py | 1 - conftest.py | 8 +- deploy/aws_sam/.gitignore | 5 + poetry.lock | 15 +-- pyproject.toml | 2 +- scripts/firebase-reset-users.sh | 3 + 20 files changed, 277 insertions(+), 149 deletions(-) create mode 100644 app/auth/repo_firebase_integration_test.py delete mode 100644 app/auth/service_firebase.py delete mode 100644 app/auth/service_firebase_integration_test.py create mode 100644 deploy/aws_sam/.gitignore create mode 100755 scripts/firebase-reset-users.sh diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fe3eef..d8ae161 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,13 +27,29 @@ { "name": "dev", "commands": [ - "./scripts/test.sh unit && ./scripts/run.sh" + "./scripts/run.sh" + ] + } + ] + }, + { + "splitTerminals": [ + { + "name": "test unit", + "commands": [ + "./scripts/test.sh unit" + ] + }, + { + "name": "test integration", + "commands": [ + "./scripts/test.sh integration" ] }, { - "name": "stripe", + "name": "test e2e", "commands": [ - "./scripts/stripe-listen.sh" + "./scripts/test.sh e2e" ] }, ] @@ -54,6 +70,16 @@ }, ] }, + { + "splitTerminals": [ + { + "name": "stripe :8000", + "commands": [ + "./scripts/stripe-listen.sh 8000" + ] + } + ] + }, { "splitTerminals": [ { diff --git a/app/app_e2e_test.py b/app/app_e2e_test.py index a6a25ea..bab9369 100644 --- a/app/app_e2e_test.py +++ b/app/app_e2e_test.py @@ -1,7 +1,8 @@ import pytest from app.app import App -from app.auth.service_firebase import AuthServiceFirebase +from app.auth.repo_firebase import AuthRepoFirebase +from app.auth.service import AuthService from app.subscription.service_stripe import SubscriptionServiceStripe from app.subscription_portal.service_stripe import SubscriptionPortalServiceStripe from app.user.repo_in_mem import UserRepoInMem @@ -11,7 +12,7 @@ @pytest.fixture def app(): return App( - auth=AuthServiceFirebase(), + auth=AuthService(repo=AuthRepoFirebase()), subscription=SubscriptionServiceStripe(), subscription_portal=SubscriptionPortalServiceStripe(), user=UserService(repo=UserRepoInMem()), diff --git a/app/auth/models.py b/app/auth/models.py index 05db1e5..07e88c8 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -1,23 +1,17 @@ from http import HTTPStatus -from typing import Literal, NewType - -from pydantic import BaseModel +from typing import Literal from app.error import AppError from app.subscription.models import SubscriptionLevel +from app.user.models import User -UserId = NewType("UserId", str) type Role = Literal["admin"] +__all__ = ["Role"] -class AuthUser(BaseModel): - id: UserId - name: str | None = None - email: str | None = None - password: str | None = None +class AuthUser(User): email_verified: bool = False - phone: str | None = None - avatar: str | None = None + password: str | None = None disabled: bool = False role: Role | None = None level: SubscriptionLevel | None = None diff --git a/app/auth/repo.py b/app/auth/repo.py index e49da5d..a619dad 100644 --- a/app/auth/repo.py +++ b/app/auth/repo.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod from typing import Any -from app.auth.models import AuthUser, UserId +from app.auth.models import AuthUser +from app.user.models import UserId class AuthRepo(ABC): @@ -10,7 +11,7 @@ async def create_user(self, user: AuthUser) -> None: raise NotImplementedError() @abstractmethod - async def get_user_by_id(self, id: UserId) -> AuthUser: + async def get_user_by_id(self, id: UserId) -> AuthUser | None: raise NotImplementedError() @abstractmethod diff --git a/app/auth/repo_firebase.py b/app/auth/repo_firebase.py index 70eefd0..9b993f9 100644 --- a/app/auth/repo_firebase.py +++ b/app/auth/repo_firebase.py @@ -1,24 +1,55 @@ +import json +import logging +import os from typing import Any, override from firebase_admin import auth # type: ignore - -from app.auth.models import AuthInvalidUpdateError, AuthUser, UserId +from firebase_admin import credentials, initialize_app # type: ignore + +from app.auth.models import ( + AuthInvalidUpdateError, + AuthUser, + AuthUserAlreadyExistsError, + AuthUserNotFoundError, +) from app.auth.repo import AuthRepo +from app.user.models import UserId +from config import FIREBASE_PROJECT_ID, GOOGLE_APPLICATION_CREDENTIALS class AuthRepoFirebase(AuthRepo): + def __init__(self, project_id: str = FIREBASE_PROJECT_ID) -> None: + options: dict[str, Any] = { + "projectId": project_id, + "httpTimeout": 10, + } + if os.environ.get("FIREBASE_AUTH_EMULATOR_HOST", ""): + # Firebase Auth checks for that environment variable. + logging.warning("Using Firebase Auth Emulator") + initialize_app(options=options) + else: + if not GOOGLE_APPLICATION_CREDENTIALS: + raise ValueError("GOOGLE_APPLICATION_CREDENTIALS not set") + cred = credentials.Certificate(json.loads(GOOGLE_APPLICATION_CREDENTIALS)) + initialize_app(credential=cred, options=options) + @override async def create_user(self, user: AuthUser) -> None: profile = _to_firebase_user(user) claims = profile.pop("custom_claims", {}) - auth.create_user(**profile) # type: ignore + try: + auth.create_user(**profile) # type: ignore + except auth.UidAlreadyExistsError as e: + raise AuthUserAlreadyExistsError() from e if len(claims) > 0: auth.set_custom_user_claims(user.id, claims) # type: ignore @override - async def get_user_by_id(self, id: UserId) -> AuthUser: + async def get_user_by_id(self, id: UserId) -> AuthUser | None: user: auth.UserRecord = auth.get_user(id) # type: ignore + if user is None: + return None return _from_firebase_user(user) # type: ignore @override @@ -31,13 +62,22 @@ async def update_user(self, id: UserId, data: dict[str, Any]) -> None: claims = profile.pop("custom_claims", {}) if len(profile) > 0: - auth.update_user(id, **profile) # type: ignore + try: + auth.update_user(id, **profile) # type: ignore + except auth.UserNotFoundError as e: + raise AuthUserNotFoundError() from e if len(claims) > 0: - auth.set_custom_user_claims(id, claims) # type: ignore + try: + auth.set_custom_user_claims(id, claims) # type: ignore + except auth.UserNotFoundError as e: + raise AuthUserNotFoundError() from e @override async def delete_user(self, id: UserId) -> None: - return auth.delete_user(id) # type: ignore + try: + return auth.delete_user(id) # type: ignore + except auth.UserNotFoundError as e: + raise AuthUserNotFoundError() from e @override async def is_only_user(self, id: UserId) -> bool: @@ -77,11 +117,10 @@ def _to_firebase_user(user: AuthUser) -> dict[str, Any]: "disabled": user.disabled, } - if user.role is not None or user.level is not None: - profile["custom_claims"] = {} - if user.role is not None: - profile["custom_claims"]["role"] = user.role - if user.level is not None: - profile["custom_claims"]["level"] = user.level + profile["custom_claims"] = {} + if user.role is not None: + profile["custom_claims"]["role"] = user.role + if user.level is not None: + profile["custom_claims"]["level"] = user.level return profile diff --git a/app/auth/repo_firebase_integration_test.py b/app/auth/repo_firebase_integration_test.py new file mode 100644 index 0000000..00e6f55 --- /dev/null +++ b/app/auth/repo_firebase_integration_test.py @@ -0,0 +1,123 @@ +import pytest +import pytest_asyncio + +from app.auth.models import ( + AuthInvalidUpdateError, + AuthUser, + AuthUserAlreadyExistsError, + AuthUserNotFoundError, +) +from app.auth.repo import AuthRepo +from app.auth.repo_firebase import AuthRepoFirebase +from app.user.models import UserId + + +@pytest.fixture(scope="module") # type: ignore +def repo() -> AuthRepo: + return AuthRepoFirebase("test-auth-repo") + + +@pytest_asyncio.fixture(autouse=True) # type: ignore +async def reset_auth(repo: AuthRepo): + users = ["user_1", "user_2"] + for id in users: + try: + await repo.delete_user(UserId(id)) + except AuthUserNotFoundError: + continue + + +@pytest.fixture +def user() -> AuthUser: + return AuthUser(id=UserId("user_1")) + + +@pytest.fixture +def user2() -> AuthUser: + return AuthUser(id=UserId("user_2")) + + +@pytest.mark.asyncio +async def test_no_users(repo: AuthRepo, user: AuthUser): + assert await repo.get_user_by_id(user.id) is None + + with pytest.raises(AuthUserNotFoundError): + await repo.update_user(user.id, user.model_dump(mode="json")) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user.id) + + assert await repo.is_only_user(user.id) == False + + +@pytest.mark.asyncio +async def test_create_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + with pytest.raises(AuthUserAlreadyExistsError): + await repo.create_user(user) + assert await repo.is_only_user(user.id) == True + + await repo.create_user(user2) + with pytest.raises(AuthUserAlreadyExistsError): + await repo.create_user(user2) + assert await repo.is_only_user(user.id) == False + assert await repo.is_only_user(user2.id) == False + + +@pytest.mark.asyncio +async def test_read_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + assert await repo.get_user_by_id(user.id) == user + + await repo.create_user(user2) + assert await repo.get_user_by_id(user2.id) == user2 + + assert await repo.get_user_by_id(user.id) == user + + +@pytest.mark.asyncio +async def test_update_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + user.email = None + await repo.create_user(user) + + user.email = "a@b.c" + await repo.update_user(user.id, user.model_dump(mode="json")) + assert await repo.get_user_by_id(user.id) == user + + user.email = None + await repo.update_user(user.id, user.model_dump(mode="json")) + assert user == await repo.get_user_by_id(user.id) + + user2.phone = None + await repo.create_user(user2) + + user2.phone = "1234567890" + await repo.update_user(user2.id, user2.model_dump(mode="json")) + assert user2 == await repo.get_user_by_id(user2.id) + + user2.phone = None + await repo.update_user(user2.id, user2.model_dump(mode="json")) + assert user2 == await repo.get_user_by_id(user2.id) + + with pytest.raises(AuthInvalidUpdateError): + await repo.update_user(user.id, user2.model_dump(mode="json")) + + +@pytest.mark.asyncio +async def test_delete_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + await repo.create_user(user2) + + await repo.delete_user(user.id) + assert await repo.get_user_by_id(user.id) is None + assert await repo.get_user_by_id(user2.id) == user2 + + await repo.delete_user(user2.id) + assert await repo.get_user_by_id(user2.id) is None + assert await repo.get_user_by_id(user.id) is None + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user2.id) diff --git a/app/auth/repo_memory.py b/app/auth/repo_memory.py index 551ce27..77e8747 100644 --- a/app/auth/repo_memory.py +++ b/app/auth/repo_memory.py @@ -5,9 +5,9 @@ AuthUser, AuthUserAlreadyExistsError, AuthUserNotFoundError, - UserId, ) from app.auth.repo import AuthRepo +from app.user.models import UserId class AuthRepoMemory(AuthRepo): @@ -21,9 +21,9 @@ async def create_user(self, user: AuthUser) -> None: self._data[user.id] = user.model_dump(mode="json") @override - async def get_user_by_id(self, id: UserId) -> AuthUser: + async def get_user_by_id(self, id: UserId) -> AuthUser | None: if not id in self._data: - raise AuthUserNotFoundError() + return None return AuthUser(**self._data[id]) @override diff --git a/app/auth/repo_memory_test.py b/app/auth/repo_memory_test.py index 108be1f..4328498 100644 --- a/app/auth/repo_memory_test.py +++ b/app/auth/repo_memory_test.py @@ -5,10 +5,10 @@ AuthUser, AuthUserAlreadyExistsError, AuthUserNotFoundError, - UserId, ) from app.auth.repo import AuthRepo from app.auth.repo_memory import AuthRepoMemory +from app.user.models import UserId @pytest.fixture @@ -28,8 +28,7 @@ def user2() -> AuthUser: @pytest.mark.asyncio async def test_no_users(repo: AuthRepo, user: AuthUser): - with pytest.raises(AuthUserNotFoundError): - await repo.get_user_by_id(user.id) + assert await repo.get_user_by_id(user.id) is None with pytest.raises(AuthUserNotFoundError): await repo.update_user(user.id, user.model_dump(mode="json")) @@ -91,3 +90,23 @@ async def test_update_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): with pytest.raises(AuthInvalidUpdateError): await repo.update_user(user.id, user2.model_dump(mode="json")) + + +@pytest.mark.asyncio +async def test_delete_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + await repo.create_user(user2) + + await repo.delete_user(user.id) + assert await repo.get_user_by_id(user.id) is None + assert await repo.get_user_by_id(user2.id) == user2 + + await repo.delete_user(user2.id) + assert await repo.get_user_by_id(user2.id) is None + assert await repo.get_user_by_id(user.id) is None + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user2.id) diff --git a/app/auth/router.py b/app/auth/router.py index 1693d44..a6c489f 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -2,8 +2,9 @@ from fastapi import APIRouter, Depends from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from app.auth.models import AuthUser, InvalidTokenError, Role, UnauthorizedError, UserId +from app.auth.models import AuthUser, InvalidTokenError, Role, UnauthorizedError from app.auth.service import AuthService +from app.user.models import UserId from config import VERIFY_TOKEN_SIGNATURE _security = HTTPBearer() diff --git a/app/auth/service.py b/app/auth/service.py index c7c2daf..ef2f1b1 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -1,11 +1,14 @@ -from app.auth.models import AuthUser, Role, UserId +from app.auth.models import AuthUser, Role from app.auth.repo import AuthRepo from app.service import Service from app.subscription.models import SubscriptionLevel +from app.user.models import UserId class AuthService(Service): - """Business logic around authentication and authorization.""" + """ + Business logic around authentication and authorization. + """ def __init__(self, repo: AuthRepo): self._repo = repo @@ -30,7 +33,7 @@ async def is_only_user(self, id: UserId) -> bool: async def disable_user(self, id: UserId): await self._repo.update_user(id, {"disabled": True}) - async def set_subscription_level(self, id: UserId, level: SubscriptionLevel): + async def set_subscription_level(self, id: UserId, level: SubscriptionLevel | None): await self._repo.update_user(id, {"level": level}) async def delete_user(self, id: UserId): diff --git a/app/auth/service_firebase.py b/app/auth/service_firebase.py deleted file mode 100644 index 3d478ca..0000000 --- a/app/auth/service_firebase.py +++ /dev/null @@ -1,31 +0,0 @@ -import json -import logging -from typing import Any - -from firebase_admin import credentials, initialize_app # type: ignore - -from app.auth.repo_firebase import AuthRepoFirebase -from app.auth.service import AuthService -from config import ( - FIREBASE_AUTH_EMULATOR_HOST, - FIREBASE_PROJECT_ID, - GOOGLE_APPLICATION_CREDENTIALS, -) - - -class AuthServiceFirebase(AuthService): - def __init__(self): - super().__init__(repo=AuthRepoFirebase()) - - options: dict[str, Any] = { - "projectId": FIREBASE_PROJECT_ID, - "httpTimeout": 10, - } - if FIREBASE_AUTH_EMULATOR_HOST: - logging.warning("Using Firebase Auth Emulator") - initialize_app(options=options) - else: - if not GOOGLE_APPLICATION_CREDENTIALS: - raise ValueError("GOOGLE_APPLICATION_CREDENTIALS not set") - cred = credentials.Certificate(json.loads(GOOGLE_APPLICATION_CREDENTIALS)) - initialize_app(credential=cred, options=options) diff --git a/app/auth/service_firebase_integration_test.py b/app/auth/service_firebase_integration_test.py deleted file mode 100644 index 86356b6..0000000 --- a/app/auth/service_firebase_integration_test.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from app.app import App -from app.auth.service_firebase import AuthServiceFirebase - - -@pytest.fixture -def app(app: App) -> App: - app.auth = AuthServiceFirebase() - app.__post_init__() - return app diff --git a/app/main.py b/app/main.py index 7f63ba1..4dd716b 100644 --- a/app/main.py +++ b/app/main.py @@ -6,8 +6,9 @@ from starlette.middleware.cors import CORSMiddleware from app.app import App +from app.auth.repo_firebase import AuthRepoFirebase from app.auth.router import AuthRouter -from app.auth.service_firebase import AuthServiceFirebase +from app.auth.service import AuthService from app.error import AppError from app.subscription.router import SubscriptionRouter from app.subscription.router_fastapi import SubscriptionRouterFastApi @@ -21,7 +22,7 @@ logging.debug("Initializing App...") app = App( - auth=AuthServiceFirebase(), + auth=AuthService(repo=AuthRepoFirebase()), subscription=SubscriptionServiceStripe(), subscription_portal=SubscriptionPortalServiceStripe(), user=UserService(repo=UserRepoInMem()), diff --git a/app/user/models.py b/app/user/models.py index 1c10b56..63f3aae 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -1,55 +1,21 @@ -from typing import Optional, override +from typing import NewType from pydantic import BaseModel -from app.subscription.models import SubscriptionLevel +UserId = NewType("UserId", str) +__all__ = ["UserId"] class User(BaseModel): - id: str - name: Optional[str] = None - email: Optional[str] = None - email_verified: Optional[bool] = None - phone: Optional[str] = None - photo_url: Optional[str] = None - is_disabled: bool = False - role: Optional[Role] = None - level: Optional[SubscriptionLevel] = None + id: UserId + name: str | None = None + email: str | None = None + phone: str | None = None + avatar: str | None = None - def is_admin(self) -> bool: - return self.role == "admin" - - def is_subscribed(self) -> bool: - return self.level is not None - - def to_full_user(self) -> "FullUser": - return FullUser( - id=self.id, - name=self.name, - email=self.email, - email_verified=self.email_verified, - phone=self.phone, - photo_url=self.photo_url, - role=self.role, - level=self.level, - ) - - -class TokenUser(User): - sign_in_method: SignInMethod - - @override - def to_full_user(self) -> "FullUser": - user = super().to_full_user() - user.sign_in_methods = [self.sign_in_method] - return user + def is_anonymous(self) -> bool: + return self.email is None and self.phone is None class FullUser(User): - sign_in_methods: list[SignInMethod] = [] - stripe_customer_id: Optional[str] = None - stripe_subscription_id: Optional[str] = None - - @override - def to_full_user(self) -> "FullUser": - return self + pass diff --git a/config.py b/config.py index 1599113..7e0a679 100644 --- a/config.py +++ b/config.py @@ -12,7 +12,6 @@ STRIPE_WEBHOOK_SECRET: str = os.environ.get("STRIPE_WEBHOOK_SECRET", "") FIREBASE_PROJECT_ID: str = os.environ.get("FIREBASE_PROJECT_ID", "") -FIREBASE_AUTH_EMULATOR_HOST: str = os.environ.get("FIREBASE_AUTH_EMULATOR_HOST", "") GOOGLE_APPLICATION_CREDENTIALS: str = os.environ.get( "GOOGLE_APPLICATION_CREDENTIALS", "" ) diff --git a/conftest.py b/conftest.py index f6c7dfe..1c51ff7 100644 --- a/conftest.py +++ b/conftest.py @@ -1,7 +1,9 @@ import pytest from app.app import App -from app.auth.service_memory import AuthServiceMock +from app.auth.repo_memory import AuthRepoMemory +from app.auth.service import AuthService +from app.subscription.repo import SubscriptionRepo from app.subscription.service_mock import SubscriptionServiceMock from app.subscription_portal.service_stripe import SubscriptionPortalServiceStripe from app.user.repo_in_mem import UserRepoInMem @@ -11,8 +13,8 @@ @pytest.fixture def app(): return App( - auth=AuthServiceMock(), - subscription=SubscriptionServiceMock(), + auth=AuthService(repo=AuthRepoMemory()), + subscription=SubscriptionServiceMock(repo=SubscriptionRepo()), subscription_portal=SubscriptionPortalServiceStripe(), user=UserService(repo=UserRepoInMem()), ) diff --git a/deploy/aws_sam/.gitignore b/deploy/aws_sam/.gitignore new file mode 100644 index 0000000..b255e26 --- /dev/null +++ b/deploy/aws_sam/.gitignore @@ -0,0 +1,5 @@ +app +config.py +poetry.lock +pyproject.toml +requirements.txt \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 30a5456..89dfc9d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -825,19 +825,6 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] -[[package]] -name = "jwt" -version = "1.3.1" -description = "JSON Web Token library for Python 3." -optional = false -python-versions = ">= 3.6" -files = [ - {file = "jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494"}, -] - -[package.dependencies] -cryptography = ">=3.1,<3.4.0 || >3.4.0" - [[package]] name = "mangum" version = "0.19.0" @@ -1483,4 +1470,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "1c8c9bf67e231011f61a5174619b62c8c1ad10c859ed98ef213e1888a24e4dc6" +content-hash = "46b0ee900b9d1ad96d7511b89c12614e17727c2513d9de616977b4bd3c0f99be" diff --git a/pyproject.toml b/pyproject.toml index 05c4216..ff74d42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ email-validator = "^2.2.0" psycopg = "^3.2.3" stripe = "^11.3.0" firebase-admin = "^6.6.0" -jwt = "^1.3.1" +pyjwt = "^2.10.1" [tool.poetry.group.dev.dependencies] diff --git a/scripts/firebase-reset-users.sh b/scripts/firebase-reset-users.sh new file mode 100755 index 0000000..fc1365c --- /dev/null +++ b/scripts/firebase-reset-users.sh @@ -0,0 +1,3 @@ +echo "Resetting local firebase auth emulator" +curl -X DELETE "http://localhost:9099/emulator/v1/projects/${FIREBASE_PROJECT_ID}/accounts" +echo \ No newline at end of file From e842a615af714ac6a868d0d2c63cba99841e6fa9 Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Sun, 22 Dec 2024 14:09:24 -0500 Subject: [PATCH 5/7] repos share tests, add router test --- .vscode/settings.json | 4 +- app/auth/repo.py | 2 +- app/auth/repo_firebase.py | 21 ++- app/auth/repo_firebase_integration_test.py | 141 ++++-------------- app/auth/repo_memory.py | 4 +- app/auth/repo_memory_test.py | 114 +------------- app/auth/repo_test.py | 113 ++++++++++++++ app/auth/router.py | 11 +- app/auth/router_test.py | 56 +++++++ app/auth/service.py | 2 +- app/subscription/models.py | 11 +- app/subscription/repo.py | 11 ++ app/subscription/service.py | 25 ++-- .../service_stripe_integration_test.py | 40 ++--- conftest.py | 81 +++++++++- poetry.lock | 47 +++++- pyproject.toml | 11 +- scripts/test.sh | 8 +- 18 files changed, 421 insertions(+), 281 deletions(-) create mode 100644 app/auth/repo_test.py create mode 100644 app/auth/router_test.py create mode 100644 app/subscription/repo.py diff --git a/.vscode/settings.json b/.vscode/settings.json index d8ae161..0a23fb7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -110,6 +110,8 @@ "!Join sequence" ], "python.testing.pytestArgs": [ - "tests" + "." ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, } \ No newline at end of file diff --git a/app/auth/repo.py b/app/auth/repo.py index a619dad..f17d143 100644 --- a/app/auth/repo.py +++ b/app/auth/repo.py @@ -11,7 +11,7 @@ async def create_user(self, user: AuthUser) -> None: raise NotImplementedError() @abstractmethod - async def get_user_by_id(self, id: UserId) -> AuthUser | None: + async def get_user_by_id(self, id: UserId) -> AuthUser: raise NotImplementedError() @abstractmethod diff --git a/app/auth/repo_firebase.py b/app/auth/repo_firebase.py index 9b993f9..3131354 100644 --- a/app/auth/repo_firebase.py +++ b/app/auth/repo_firebase.py @@ -43,13 +43,17 @@ async def create_user(self, user: AuthUser) -> None: except auth.UidAlreadyExistsError as e: raise AuthUserAlreadyExistsError() from e if len(claims) > 0: - auth.set_custom_user_claims(user.id, claims) # type: ignore + try: + auth.set_custom_user_claims(user.id, claims) # type: ignore + except auth.UserNotFoundError as e: + raise AuthUserNotFoundError() from e @override - async def get_user_by_id(self, id: UserId) -> AuthUser | None: - user: auth.UserRecord = auth.get_user(id) # type: ignore - if user is None: - return None + async def get_user_by_id(self, id: UserId) -> AuthUser: + try: + user: auth.UserRecord = auth.get_user(id) # type: ignore + except auth.UserNotFoundError as e: + raise AuthUserNotFoundError() from e return _from_firebase_user(user) # type: ignore @override @@ -57,10 +61,14 @@ async def update_user(self, id: UserId, data: dict[str, Any]) -> None: if "id" in data and data["id"] != id: raise AuthInvalidUpdateError() - profile = _to_firebase_user(AuthUser(id=id, **data)) + profile = _to_firebase_user(AuthUser(**data)) del profile["uid"] claims = profile.pop("custom_claims", {}) + for key, value in profile.items(): + if value is None and key in ["display_name", "phone_number", "photo_url"]: + profile[key] = auth.DELETE_ATTRIBUTE + if len(profile) > 0: try: auth.update_user(id, **profile) # type: ignore @@ -86,6 +94,7 @@ async def is_only_user(self, id: UserId) -> bool: def _from_firebase_user(user: auth.UserRecord) -> AuthUser: + logging.debug(f"Translating Firebase user: {user}") return AuthUser( id=user.uid, # type: ignore name=user.display_name, # type: ignore diff --git a/app/auth/repo_firebase_integration_test.py b/app/auth/repo_firebase_integration_test.py index 00e6f55..b30ca92 100644 --- a/app/auth/repo_firebase_integration_test.py +++ b/app/auth/repo_firebase_integration_test.py @@ -1,123 +1,40 @@ +from typing import override + import pytest import pytest_asyncio -from app.auth.models import ( - AuthInvalidUpdateError, - AuthUser, - AuthUserAlreadyExistsError, - AuthUserNotFoundError, -) +from app.auth.models import AuthUser, AuthUserNotFoundError from app.auth.repo import AuthRepo from app.auth.repo_firebase import AuthRepoFirebase +from app.auth.repo_test import AuthRepoTest from app.user.models import UserId -@pytest.fixture(scope="module") # type: ignore -def repo() -> AuthRepo: - return AuthRepoFirebase("test-auth-repo") - - -@pytest_asyncio.fixture(autouse=True) # type: ignore -async def reset_auth(repo: AuthRepo): - users = ["user_1", "user_2"] - for id in users: - try: - await repo.delete_user(UserId(id)) - except AuthUserNotFoundError: - continue - - -@pytest.fixture -def user() -> AuthUser: - return AuthUser(id=UserId("user_1")) - - -@pytest.fixture -def user2() -> AuthUser: - return AuthUser(id=UserId("user_2")) - - -@pytest.mark.asyncio -async def test_no_users(repo: AuthRepo, user: AuthUser): - assert await repo.get_user_by_id(user.id) is None - - with pytest.raises(AuthUserNotFoundError): - await repo.update_user(user.id, user.model_dump(mode="json")) - - with pytest.raises(AuthUserNotFoundError): - await repo.delete_user(user.id) - - assert await repo.is_only_user(user.id) == False - - -@pytest.mark.asyncio -async def test_create_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - await repo.create_user(user) - with pytest.raises(AuthUserAlreadyExistsError): +class AuthRepoFirebaseTest(AuthRepoTest): + @override + @pytest.fixture(scope="module") + def repo(self) -> AuthRepo: + return AuthRepoFirebase("test-auth-repo") + + @pytest_asyncio.fixture(autouse=True) # type: ignore + async def reset_auth(self, repo: AuthRepo): + users = ["user_1", "user_2"] + for id in users: + try: + await repo.delete_user(UserId(id)) + except AuthUserNotFoundError: + continue + + @pytest.mark.asyncio + async def test_cannot_remove_email_yet(self, repo: AuthRepo, user: AuthUser): + user.email = None await repo.create_user(user) - assert await repo.is_only_user(user.id) == True - - await repo.create_user(user2) - with pytest.raises(AuthUserAlreadyExistsError): - await repo.create_user(user2) - assert await repo.is_only_user(user.id) == False - assert await repo.is_only_user(user2.id) == False - -@pytest.mark.asyncio -async def test_read_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - await repo.create_user(user) - assert await repo.get_user_by_id(user.id) == user - - await repo.create_user(user2) - assert await repo.get_user_by_id(user2.id) == user2 - - assert await repo.get_user_by_id(user.id) == user - - -@pytest.mark.asyncio -async def test_update_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - user.email = None - await repo.create_user(user) - - user.email = "a@b.c" - await repo.update_user(user.id, user.model_dump(mode="json")) - assert await repo.get_user_by_id(user.id) == user - - user.email = None - await repo.update_user(user.id, user.model_dump(mode="json")) - assert user == await repo.get_user_by_id(user.id) - - user2.phone = None - await repo.create_user(user2) - - user2.phone = "1234567890" - await repo.update_user(user2.id, user2.model_dump(mode="json")) - assert user2 == await repo.get_user_by_id(user2.id) - - user2.phone = None - await repo.update_user(user2.id, user2.model_dump(mode="json")) - assert user2 == await repo.get_user_by_id(user2.id) - - with pytest.raises(AuthInvalidUpdateError): - await repo.update_user(user.id, user2.model_dump(mode="json")) - - -@pytest.mark.asyncio -async def test_delete_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - await repo.create_user(user) - await repo.create_user(user2) - - await repo.delete_user(user.id) - assert await repo.get_user_by_id(user.id) is None - assert await repo.get_user_by_id(user2.id) == user2 - - await repo.delete_user(user2.id) - assert await repo.get_user_by_id(user2.id) is None - assert await repo.get_user_by_id(user.id) is None - - with pytest.raises(AuthUserNotFoundError): - await repo.delete_user(user.id) + user.email = "a@b.c" + await repo.update_user(user.id, user.model_dump(mode="json")) + assert await repo.get_user_by_id(user.id) == user - with pytest.raises(AuthUserNotFoundError): - await repo.delete_user(user2.id) + user.email = None + await repo.update_user(user.id, user.model_dump(mode="json")) + user.email = "a@b.c" # Waiting on https://github.com/firebase/firebase-admin-python/issues/678 + assert await repo.get_user_by_id(user.id) == user diff --git a/app/auth/repo_memory.py b/app/auth/repo_memory.py index 77e8747..2623148 100644 --- a/app/auth/repo_memory.py +++ b/app/auth/repo_memory.py @@ -21,9 +21,9 @@ async def create_user(self, user: AuthUser) -> None: self._data[user.id] = user.model_dump(mode="json") @override - async def get_user_by_id(self, id: UserId) -> AuthUser | None: + async def get_user_by_id(self, id: UserId) -> AuthUser: if not id in self._data: - return None + raise AuthUserNotFoundError() return AuthUser(**self._data[id]) @override diff --git a/app/auth/repo_memory_test.py b/app/auth/repo_memory_test.py index 4328498..301c155 100644 --- a/app/auth/repo_memory_test.py +++ b/app/auth/repo_memory_test.py @@ -1,112 +1,14 @@ +from typing import override + import pytest -from app.auth.models import ( - AuthInvalidUpdateError, - AuthUser, - AuthUserAlreadyExistsError, - AuthUserNotFoundError, -) from app.auth.repo import AuthRepo from app.auth.repo_memory import AuthRepoMemory -from app.user.models import UserId - - -@pytest.fixture -def repo() -> AuthRepo: - return AuthRepoMemory() - - -@pytest.fixture -def user() -> AuthUser: - return AuthUser(id=UserId("user_1")) - - -@pytest.fixture -def user2() -> AuthUser: - return AuthUser(id=UserId("user_2")) - - -@pytest.mark.asyncio -async def test_no_users(repo: AuthRepo, user: AuthUser): - assert await repo.get_user_by_id(user.id) is None - - with pytest.raises(AuthUserNotFoundError): - await repo.update_user(user.id, user.model_dump(mode="json")) - - with pytest.raises(AuthUserNotFoundError): - await repo.delete_user(user.id) - - assert await repo.is_only_user(user.id) == False - - -@pytest.mark.asyncio -async def test_create_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - await repo.create_user(user) - with pytest.raises(AuthUserAlreadyExistsError): - await repo.create_user(user) - assert await repo.is_only_user(user.id) == True - - await repo.create_user(user2) - with pytest.raises(AuthUserAlreadyExistsError): - await repo.create_user(user2) - assert await repo.is_only_user(user.id) == False - assert await repo.is_only_user(user2.id) == False - - -@pytest.mark.asyncio -async def test_read_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - await repo.create_user(user) - assert await repo.get_user_by_id(user.id) == user - - await repo.create_user(user2) - assert await repo.get_user_by_id(user2.id) == user2 - - assert await repo.get_user_by_id(user.id) == user - - -@pytest.mark.asyncio -async def test_update_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - user.email = None - await repo.create_user(user) - - user.email = "a@b.c" - await repo.update_user(user.id, user.model_dump(mode="json")) - assert await repo.get_user_by_id(user.id) == user - - user.email = None - await repo.update_user(user.id, user.model_dump(mode="json")) - assert user == await repo.get_user_by_id(user.id) - - user2.phone = None - await repo.create_user(user2) - - user2.phone = "1234567890" - await repo.update_user(user2.id, user2.model_dump(mode="json")) - assert user2 == await repo.get_user_by_id(user2.id) - - user2.phone = None - await repo.update_user(user2.id, user2.model_dump(mode="json")) - assert user2 == await repo.get_user_by_id(user2.id) - - with pytest.raises(AuthInvalidUpdateError): - await repo.update_user(user.id, user2.model_dump(mode="json")) - - -@pytest.mark.asyncio -async def test_delete_users(repo: AuthRepo, user: AuthUser, user2: AuthUser): - await repo.create_user(user) - await repo.create_user(user2) - - await repo.delete_user(user.id) - assert await repo.get_user_by_id(user.id) is None - assert await repo.get_user_by_id(user2.id) == user2 - - await repo.delete_user(user2.id) - assert await repo.get_user_by_id(user2.id) is None - assert await repo.get_user_by_id(user.id) is None +from app.auth.repo_test import AuthRepoTest - with pytest.raises(AuthUserNotFoundError): - await repo.delete_user(user.id) - with pytest.raises(AuthUserNotFoundError): - await repo.delete_user(user2.id) +class AuthRepoMemoryTest(AuthRepoTest): + @override + @pytest.fixture + def repo(self) -> AuthRepo: + return AuthRepoMemory() diff --git a/app/auth/repo_test.py b/app/auth/repo_test.py new file mode 100644 index 0000000..159ece7 --- /dev/null +++ b/app/auth/repo_test.py @@ -0,0 +1,113 @@ +from abc import ABC, abstractmethod + +import pytest + +from app.auth.models import ( + AuthInvalidUpdateError, + AuthUser, + AuthUserAlreadyExistsError, + AuthUserNotFoundError, +) +from app.auth.repo import AuthRepo +from app.user.models import UserId + + +class AuthRepoTest(ABC): + + @abstractmethod + @pytest.fixture + def repo(self) -> AuthRepo: + raise NotImplementedError() + + @pytest.fixture + def user(self) -> AuthUser: + return AuthUser(id=UserId("user_1")) + + @pytest.fixture + def user2(self) -> AuthUser: + return AuthUser(id=UserId("user_2")) + + @pytest.mark.asyncio + async def test_no_users(self, repo: AuthRepo, user: AuthUser): + with pytest.raises(AuthUserNotFoundError): + await repo.get_user_by_id(user.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.update_user(user.id, user.model_dump(mode="json")) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user.id) + + assert await repo.is_only_user(user.id) == False + + @pytest.mark.asyncio + async def test_create_users(self, repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + with pytest.raises(AuthUserAlreadyExistsError): + await repo.create_user(user) + assert await repo.is_only_user(user.id) == True + + await repo.create_user(user2) + with pytest.raises(AuthUserAlreadyExistsError): + await repo.create_user(user2) + assert await repo.is_only_user(user.id) == False + assert await repo.is_only_user(user2.id) == False + + @pytest.mark.asyncio + async def test_read_users(self, repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + assert await repo.get_user_by_id(user.id) == user + + await repo.create_user(user2) + assert await repo.get_user_by_id(user2.id) == user2 + + assert await repo.get_user_by_id(user.id) == user + + @pytest.mark.asyncio + async def test_update_users(self, repo: AuthRepo, user: AuthUser, user2: AuthUser): + user.email = None + await repo.create_user(user) + + user.email = "a@b.c" + await repo.update_user(user.id, user.model_dump(mode="json")) + assert await repo.get_user_by_id(user.id) == user + + # Waiting on https://github.com/firebase/firebase-admin-python/issues/678 + # user.email = None + # await repo.update_user(user.id, user.model_dump(mode="json")) + # assert user == await repo.get_user_by_id(user.id) + + user2.phone = None + await repo.create_user(user2) + + user2.phone = "+15555555555" + await repo.update_user(user2.id, user2.model_dump(mode="json")) + assert user2 == await repo.get_user_by_id(user2.id) + + user2.phone = None + await repo.update_user(user2.id, user2.model_dump(mode="json")) + assert user2 == await repo.get_user_by_id(user2.id) + + with pytest.raises(AuthInvalidUpdateError): + await repo.update_user(user.id, user2.model_dump(mode="json")) + + @pytest.mark.asyncio + async def test_delete_users(self, repo: AuthRepo, user: AuthUser, user2: AuthUser): + await repo.create_user(user) + await repo.create_user(user2) + + await repo.delete_user(user.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.get_user_by_id(user.id) + assert await repo.get_user_by_id(user2.id) == user2 + + await repo.delete_user(user2.id) + with pytest.raises(AuthUserNotFoundError): + await repo.get_user_by_id(user2.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user.id) + + with pytest.raises(AuthUserNotFoundError): + await repo.delete_user(user2.id) diff --git a/app/auth/router.py b/app/auth/router.py index a6c489f..d47fab1 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -15,7 +15,9 @@ def decode_jwt( ) -> AuthUser: token = credentials.credentials try: - claims = jwt.decode(token, do_verify=VERIFY_TOKEN_SIGNATURE) + claims = jwt.decode( + jwt=token, options={"verify_signature": VERIFY_TOKEN_SIGNATURE} + ) except Exception as e: raise InvalidTokenError() from e @@ -38,9 +40,12 @@ async def sign_up(self, user: AuthUser = Depends(decode_jwt)) -> None: await self._service.sign_up(user) async def set_role( - self, user_id: UserId, role: Role, user: AuthUser = Depends(decode_jwt) + self, + user_id: str, + role: Role | None = None, + user: AuthUser = Depends(decode_jwt), ) -> None: if user.role == "admin" or await self._service.is_only_user(user.id): - await self._service.set_role(user_id, role) + await self._service.set_role(UserId(user_id), role) else: raise UnauthorizedError() diff --git a/app/auth/router_test.py b/app/auth/router_test.py new file mode 100644 index 0000000..a734be2 --- /dev/null +++ b/app/auth/router_test.py @@ -0,0 +1,56 @@ +import logging +from http import HTTPStatus + +from fastapi.testclient import TestClient + + +def test_sign_up(client: TestClient, client_no_auth: TestClient): + response = client.post("/auth/sign-up") + assert response.status_code == HTTPStatus.OK + + response = client_no_auth.post("/auth/sign-up") + assert response.status_code == HTTPStatus.FORBIDDEN + + +def test_set_role( + client: TestClient, + client2: TestClient, + client_no_auth: TestClient, + client_admin: TestClient, +): + # No users + response = client.post( + "/auth/set-role", params={"user_id": "test_user", "role": "admin"} + ) + logging.debug(response.json()) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + # One user + client.post("/auth/sign-up") + response = client.post( + "/auth/set-role", params={"user_id": "test_user", "role": "admin"} + ) + assert response.status_code == HTTPStatus.OK + + # No token + response = client_no_auth.post( + "/auth/set-role", params={"user_id": "test_user", "role": "admin"} + ) + assert response.status_code == HTTPStatus.FORBIDDEN + + # Two users + client2.post("/auth/sign-up") + response = client2.post( + "/auth/set-role", params={"user_id": "test_user2", "role": "admin"} + ) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + # Add admin + response = client_admin.post( + "/auth/set-role", params={"user_id": "test_user2", "role": "admin"} + ) + assert response.status_code == HTTPStatus.OK + + # Remove admin + response = client_admin.post("/auth/set-role", params={"user_id": "test_user2"}) + assert response.status_code == HTTPStatus.OK diff --git a/app/auth/service.py b/app/auth/service.py index ef2f1b1..915c534 100644 --- a/app/auth/service.py +++ b/app/auth/service.py @@ -19,7 +19,7 @@ async def sign_up(self, user: AuthUser) -> None: async def get_user(self, id: UserId) -> AuthUser: return await self._repo.get_user_by_id(id) - async def set_role(self, id: UserId, role: Role): + async def set_role(self, id: UserId, role: Role | None): await self._repo.update_user(id, {"role": role}) async def is_only_user(self, id: UserId) -> bool: diff --git a/app/subscription/models.py b/app/subscription/models.py index 06964eb..0a0ec17 100644 --- a/app/subscription/models.py +++ b/app/subscription/models.py @@ -1,12 +1,21 @@ from http import HTTPStatus -from typing import Literal +from typing import Literal, NewType from pydantic import BaseModel from app.error import AppError +from app.user.models import UserId +CustomerId = NewType("CustomerId", str) +SubscriptionId = NewType("SubscriptionId", str) type SubscriptionLevel = Literal["plus", "pro"] type SubscriptionPeriod = Literal["monthly", "annual"] +__all__ = ["CustomerId", "SubscriptionId", "SubscriptionLevel", "SubscriptionPeriod"] + + +class Customer(BaseModel): + id: CustomerId + user_id: UserId class SubscriptionType(BaseModel): diff --git a/app/subscription/repo.py b/app/subscription/repo.py new file mode 100644 index 0000000..0bbaba5 --- /dev/null +++ b/app/subscription/repo.py @@ -0,0 +1,11 @@ +from app.subscription.models import Customer, CustomerId, SubscriptionId + + +class SubscriptionRepo: + async def create_customer(self, customer: Customer) -> None: + pass + + async def set_subscription_id( + self, customer_id: CustomerId, subscription_id: SubscriptionId | None + ) -> None: + pass diff --git a/app/subscription/service.py b/app/subscription/service.py index 12804ce..6a04af1 100644 --- a/app/subscription/service.py +++ b/app/subscription/service.py @@ -1,13 +1,16 @@ import asyncio from abc import abstractmethod -from typing import Any, Optional +from typing import Any from app.service import Service -from app.subscription.models import SubscriptionType -from app.user.models import User +from app.subscription.models import CustomerId, SubscriptionId, SubscriptionType +from app.subscription.repo import SubscriptionRepo +from app.user.models import User, UserId class SubscriptionService(Service): + def __init__(self, repo: SubscriptionRepo): + self._repo = repo @abstractmethod async def create_customer_if_necessary(self, user: User) -> str: @@ -29,22 +32,18 @@ def type_id(self, type: SubscriptionType) -> str: def subscription_type(self, type_id: str) -> SubscriptionType: raise NotImplementedError() - async def get_customer_id(self, user: User) -> Optional[str]: - if not isinstance(user, User): - user = await self._app.user.get_user(user.id) - - return user.stripe_customer_id - async def _handle_activation( - self, user_id: str, subscription_id: str, type: SubscriptionType + self, user_id: UserId, subscription_id: SubscriptionId, type: SubscriptionType ) -> None: await asyncio.gather( - self._app.user.set_stripe_subscription_id(user_id, subscription_id), + self._repo.set_subscription_id(user_id, subscription_id), self._app.auth.set_subscription_level(user_id, type.level), ) - async def _handle_deactivation(self, user_id: str) -> None: + async def _handle_deactivation( + self, user_id: UserId, customer_id: CustomerId + ) -> None: await asyncio.gather( - self._app.user.set_stripe_subscription_id(user_id, None), + self._repo.set_subscription_id(customer_id, None), self._app.auth.set_subscription_level(user_id, None), ) diff --git a/app/subscription/service_stripe_integration_test.py b/app/subscription/service_stripe_integration_test.py index f5e8ddb..4d4f0ee 100644 --- a/app/subscription/service_stripe_integration_test.py +++ b/app/subscription/service_stripe_integration_test.py @@ -1,25 +1,15 @@ -import os - -import pytest - -from app.app import App -from app.subscription.models import SubscriptionType -from app.subscription_portal.models import CheckoutStart -from app.user.models import User - - -@pytest.mark.skipif( - os.environ.get("CI") == "true" or len(os.environ.get("STRIPE_SECRET_KEY", "")) < 10, - reason="Requires running dev server running and stripe keys", -) -@pytest.mark.asyncio -async def test_start_checkout_session(app: App): - info = await app.subscription_portal.start_checkout( - User(id="1"), - CheckoutStart( - type=SubscriptionType(level="plus", period="monthly"), - success_url="http://localhost:8000/success", - cancel_url="http://localhost:8000/cancel", - ), - ) - assert "http" in info.url +# @pytest.mark.skipif( +# os.environ.get("CI") == "true" or len(os.environ.get("STRIPE_SECRET_KEY", "")) < 10, +# reason="Requires running dev server running and stripe keys", +# ) +# @pytest.mark.asyncio +# async def test_start_checkout_session(app: App): +# info = await app.subscription_portal.start_checkout( +# User(id=UserId("user_1")), +# CheckoutStart( +# type=SubscriptionType(level="plus", period="monthly"), +# success_url="http://localhost:8000/success", +# cancel_url="http://localhost:8000/cancel", +# ), +# ) +# assert "http" in info.url diff --git a/conftest.py b/conftest.py index 1c51ff7..cfb5b13 100644 --- a/conftest.py +++ b/conftest.py @@ -1,9 +1,17 @@ +import jwt import pytest +from fastapi import APIRouter, FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient from app.app import App from app.auth.repo_memory import AuthRepoMemory +from app.auth.router import AuthRouter from app.auth.service import AuthService +from app.error import AppError from app.subscription.repo import SubscriptionRepo +from app.subscription.router import SubscriptionRouter +from app.subscription.router_fastapi import SubscriptionRouterFastApi from app.subscription.service_mock import SubscriptionServiceMock from app.subscription_portal.service_stripe import SubscriptionPortalServiceStripe from app.user.repo_in_mem import UserRepoInMem @@ -11,7 +19,8 @@ @pytest.fixture -def app(): +def app() -> App: + """The default app fixture for unit tests.""" return App( auth=AuthService(repo=AuthRepoMemory()), subscription=SubscriptionServiceMock(repo=SubscriptionRepo()), @@ -20,6 +29,76 @@ def app(): ) +@pytest.fixture +def app_router(app: App) -> FastAPI: + routers: list[APIRouter] = [ + AuthRouter(service=app.auth), + SubscriptionRouterFastApi(router=SubscriptionRouter(service=app.subscription)), + ] + router = FastAPI() + for r in routers: + router.include_router(r) + + async def app_error_handler(_: Request, e: AppError): + return JSONResponse( + status_code=e.status, + content={ + "error": { + "code": e.code, + "message": e.message, + } + }, + ) + + router.exception_handler(AppError)(app_error_handler) + + return router + + +@pytest.fixture +def client(app_router: FastAPI) -> TestClient: + token = jwt.encode(payload={"sub": "test_user"}, key="test_secret") + return TestClient( + app_router, + headers={ + "Authorization": f"Bearer {token}", + }, + ) + + +@pytest.fixture +def client2(app_router: FastAPI) -> TestClient: + token = jwt.encode(payload={"sub": "test_user2"}, key="test_secret") + return TestClient( + app_router, + headers={ + "Authorization": f"Bearer {token}", + }, + ) + + +@pytest.fixture +def client_no_auth(app_router: FastAPI) -> TestClient: + return TestClient(app_router) + + +@pytest.fixture +def client_admin(app_router: FastAPI) -> TestClient: + token = jwt.encode( + payload={ + "sub": "test_user", + "role": "admin", + }, + key="test_secret", + ) + return TestClient( + app_router, + headers={ + "Authorization": f"Bearer {token}", + }, + ) + + def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: for item in items: if "_integration_test" in str(item.fspath): diff --git a/poetry.lock b/poetry.lock index 89dfc9d..02ecee3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -772,6 +772,27 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "httpcore" +version = "1.0.7" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, + {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + [[package]] name = "httplib2" version = "0.22.0" @@ -786,6 +807,30 @@ files = [ [package.dependencies] pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" @@ -1470,4 +1515,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "46b0ee900b9d1ad96d7511b89c12614e17727c2513d9de616977b4bd3c0f99be" +content-hash = "1e12e760bd7ce92bca09adfbe2bcd33c19147250a9e7377b9ed753ae86b4250b" diff --git a/pyproject.toml b/pyproject.toml index ff74d42..0dcbf3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ uvicorn = "^0.32.0" pytest-asyncio = "^0.24.0" black = "^24.10.0" requests = "^2.32.3" +httpx = "^0.28.1" [build-system] requires = ["poetry-core"] @@ -35,7 +36,15 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] pythonpath = "." -addopts = ["--import-mode=importlib", "--tb=short"] +python_classes = '*Test' +addopts = [ + "--import-mode=importlib", + "--tb=line", + '--verbosity=2', + '--no-header', + '--color=yes', +] +console_output_style = "progress" asyncio_default_fixture_loop_scope = "function" markers = [ "unit: marks tests as unit tests", diff --git a/scripts/test.sh b/scripts/test.sh index ed27377..44e97cd 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -2,12 +2,6 @@ set -e -if [ "$#" -gt 1 ]; then - echo "Usage: $0 [unit|integration|e2e]" - echo "Defaults to running all tests." - exit 1 -fi - GROUP="$1" SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" @@ -17,7 +11,7 @@ pushd "$SCRIPT_DIR/.." > /dev/null if [ -z "$GROUP" ]; then poetry run pytest else - poetry run pytest -m "$GROUP" + poetry run pytest -m "$GROUP" ${@:2} fi popd > /dev/null \ No newline at end of file From 60197b653f5c6bcd50c8d9270d88734f1c189fc7 Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Sun, 22 Dec 2024 14:14:49 -0500 Subject: [PATCH 6/7] fix(ci): only run unit tests until an env is setup. --- .github/workflows/deploy_lambda.yml | 2 +- .github/workflows/test.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy_lambda.yml b/.github/workflows/deploy_lambda.yml index b790b72..6533522 100644 --- a/.github/workflows/deploy_lambda.yml +++ b/.github/workflows/deploy_lambda.yml @@ -26,7 +26,7 @@ jobs: pip install poetry poetry install --no-interaction - name: Run tests - run: poetry run pytest + run: ./scripts/test.sh unit - uses: aws-actions/configure-aws-credentials@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39aff19..0b14757 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ on: - "app/**" - ".github/workflows/test.yml" jobs: - cargo-test: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -24,4 +24,4 @@ jobs: pip install poetry poetry install --no-interaction - name: Run tests - run: poetry run pytest + run: ./scripts/test.sh unit From 222308950fb4bbd82dfc02cd454b353ddb4c5390 Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Sun, 22 Dec 2024 14:21:13 -0500 Subject: [PATCH 7/7] fix(ci): only run unit tests until an env is setup. --- .github/workflows/deploy_lambda.yml | 2 ++ .github/workflows/test.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy_lambda.yml b/.github/workflows/deploy_lambda.yml index 6533522..f973489 100644 --- a/.github/workflows/deploy_lambda.yml +++ b/.github/workflows/deploy_lambda.yml @@ -26,6 +26,8 @@ jobs: pip install poetry poetry install --no-interaction - name: Run tests + env: + VERIFY_TOKEN_SIGNATURE: 0 run: ./scripts/test.sh unit - uses: aws-actions/configure-aws-credentials@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b14757..da54a25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,4 +24,6 @@ jobs: pip install poetry poetry install --no-interaction - name: Run tests + env: + VERIFY_TOKEN_SIGNATURE: 0 run: ./scripts/test.sh unit