From 1f02815ce09a5b312ab23ed048df4abbdb91e76c Mon Sep 17 00:00:00 2001 From: Francesco Faraone Date: Fri, 27 Dec 2024 12:18:45 +0100 Subject: [PATCH] MPT-4905 add create organization endpoint --- app/conf.py | 3 + app/constants.py | 4 + app/db/handlers.py | 17 ++- app/models.py | 12 +- app/routers/organizations.py | 44 +++++- app/utils.py | 28 ++++ env.example | 2 + .../7915164f66be_added_organization_id.py | 33 +++++ pyproject.toml | 10 +- tests/conftest.py | 13 ++ tests/test_organizations_api.py | 126 ++++++++++++++++++ tests/test_utils.py | 32 +++++ uv.lock | 115 +++++++++++++--- 13 files changed, 407 insertions(+), 32 deletions(-) create mode 100644 app/constants.py create mode 100644 app/utils.py create mode 100644 migrations/versions/7915164f66be_added_organization_id.py create mode 100644 tests/test_utils.py diff --git a/app/conf.py b/app/conf.py index 04349d7..072276d 100644 --- a/app/conf.py +++ b/app/conf.py @@ -19,6 +19,9 @@ class Settings(BaseSettings): postgres_host: str postgres_port: int + api_modifier_base_url: str + api_modifier_jwt_secret: str + debug: bool = False @computed_field diff --git a/app/constants.py b/app/constants.py new file mode 100644 index 0000000..6561eca --- /dev/null +++ b/app/constants.py @@ -0,0 +1,4 @@ +API_MODIFIER_JWT_ALGORITHM = "HS256" +API_MODIFIER_JWT_ISSUER = "SWO" +API_MODIFIER_JWT_AUDIENCE = "modifier" +API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS = 300 diff --git a/app/db/handlers.py b/app/db/handlers.py index a0fd373..ba2230a 100644 --- a/app/db/handlers.py +++ b/app/db/handlers.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from sqlalchemy import func -from sqlalchemy.exc import NoResultFound +from sqlalchemy.exc import IntegrityError, NoResultFound from sqlmodel import select, update from sqlmodel.ext.asyncio.session import AsyncSession @@ -18,16 +18,23 @@ class NotFoundError(DBError): pass +class ConstraintViolationError(DBError): + pass + + class ModelHandler[T: UUIDModel]: def __init__(self, model_cls: type[T], session: AsyncSession) -> None: self.model_cls: type[T] = model_cls self.session: AsyncSession = session async def create(self, data: BaseModel) -> T: - obj = self.model_cls(**data.model_dump()) - self.session.add(obj) - await self.session.commit() - await self.session.refresh(obj) + try: + obj = self.model_cls(**data.model_dump()) + self.session.add(obj) + await self.session.commit() + await self.session.refresh(obj) + except IntegrityError as e: + raise ConstraintViolationError(f"Failed to create {self.model_cls.__name__}: {e}") return obj diff --git a/app/models.py b/app/models.py index 0d1aeda..b5242c9 100644 --- a/app/models.py +++ b/app/models.py @@ -76,6 +76,7 @@ class Entitlement(EntitlementBase, TimestampModel, UUIDModel, table=True): class EntitlementRead(EntitlementBase, UUIDModel): created_at: datetime.datetime + updated_at: datetime.datetime activated_at: datetime.datetime | None status: EntitlementStatus @@ -98,6 +99,15 @@ class OrganizationBase(SQLModel): class Organization(OrganizationBase, TimestampModel, UUIDModel, table=True): __tablename__ = "organizations" + organization_id: str | None = Field(max_length=255, nullable=True, index=True) + class OrganizationRead(OrganizationBase, UUIDModel): - pass + created_at: datetime.datetime + updated_at: datetime.datetime + organization_id: str | None + + +class OrganizationCreate(OrganizationBase): + user_id: str + currency: str diff --git a/app/routers/organizations.py b/app/routers/organizations.py index 66d7879..d41076d 100644 --- a/app/routers/organizations.py +++ b/app/routers/organizations.py @@ -1,9 +1,13 @@ -from fastapi import APIRouter +import httpx +from fastapi import APIRouter, HTTPException, status from fastapi_pagination.limit_offset import LimitOffsetPage -from app.models import OrganizationRead +from app import settings +from app.db.handlers import ConstraintViolationError +from app.models import Organization, OrganizationCreate, OrganizationRead from app.pagination import paginate from app.repositories import OrganizationRepository +from app.utils import get_api_modifier_jwt_token router = APIRouter() @@ -11,3 +15,39 @@ @router.get("/", response_model=LimitOffsetPage[OrganizationRead]) async def get_organizations(organization_repo: OrganizationRepository): return await paginate(organization_repo) + + +@router.post("/", response_model=OrganizationRead, status_code=status.HTTP_201_CREATED) +async def create_organization(data: OrganizationCreate, organization_repo: OrganizationRepository): + organization: Organization | None = None + try: + organization = await organization_repo.create(data) + except ConstraintViolationError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Organization with this external ID already exists: {data.external_id}.", + ) + + try: + async with httpx.AsyncClient(base_url=settings.api_modifier_base_url) as client: + response = await client.post( + "/admin/organizations", + headers={"Authorization": f"Bearer {get_api_modifier_jwt_token()}"}, + json={ + "org_name": data.name, + "user_id": data.user_id, + "currency": data.currency, + }, + ) + response.raise_for_status() + ffc_organization = response.json() + organization.organization_id = ffc_organization["id"] + return await organization_repo.update(organization.id, organization) + except httpx.HTTPStatusError as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=( + "Error creating organization in FinOps for Cloud: " + f"{e.response.status_code} - {e.response.text}.", + ), + ) diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..16719e3 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,28 @@ +from datetime import UTC, datetime, timedelta + +import jwt + +from app import settings +from app.constants import ( + API_MODIFIER_JWT_ALGORITHM, + API_MODIFIER_JWT_AUDIENCE, + API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS, + API_MODIFIER_JWT_ISSUER, +) + + +def get_api_modifier_jwt_token() -> str: + now = datetime.now(UTC) + return jwt.encode( + { + "iss": API_MODIFIER_JWT_ISSUER, + "aud": API_MODIFIER_JWT_AUDIENCE, + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int( + (now + timedelta(seconds=API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS)).timestamp() + ), + }, + settings.api_modifier_jwt_secret, + algorithm=API_MODIFIER_JWT_ALGORITHM, + ) diff --git a/env.example b/env.example index 1ad52de..d35a36b 100644 --- a/env.example +++ b/env.example @@ -3,3 +3,5 @@ FFC_OPERATIONS_POSTGRES_USER=postgres FFC_OPERATIONS_POSTGRES_PASSWORD=mysecurepass FFC_OPERATIONS_POSTGRES_HOST=0.0.0.0 FFC_OPERATIONS_POSTGRES_PORT=5432 +FFC_OPERATIONS_API_MODIFIER_BASE_URL=https://api-modifier.ffc.com +FFC_OPERATIONS_API_MODIFIER_JWT_TOKEN=test_jwt_token diff --git a/migrations/versions/7915164f66be_added_organization_id.py b/migrations/versions/7915164f66be_added_organization_id.py new file mode 100644 index 0000000..5300e92 --- /dev/null +++ b/migrations/versions/7915164f66be_added_organization_id.py @@ -0,0 +1,33 @@ +"""Added organization_id + +Revision ID: 7915164f66be +Revises: 8a5758ec8e50 +Create Date: 2024-12-27 10:27:37.156714 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '7915164f66be' +down_revision: Union[str, None] = '8a5758ec8e50' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('organizations', sa.Column('organization_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.create_index(op.f('ix_organizations_organization_id'), 'organizations', ['organization_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_organizations_organization_id'), table_name='organizations') + op.drop_column('organizations', 'organization_id') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index 1bfa252..f728722 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ requires-python = ">=3.12,<4" dependencies = [ "alembic==1.14.*", "asyncpg==0.30.*", - "fastapi-async-sqlalchemy==0.6.*", "fastapi-pagination==0.12.*", "fastapi[standard]==0.115.*", "pycountry==24.6.*", @@ -20,21 +19,26 @@ dependencies = [ "python-dotenv==1.0.*", "sqlalchemy[asyncio]==2.0.*", "sqlmodel==0.0.*", + "alembic-postgresql-enum==1.4.*", + "httpx==0.27.*", + "pyjwt==2.10.*", ] [dependency-groups] dev = [ - "alembic-postgresql-enum>=1.4.0", "bandit>=1.8.0,<2.0", "faker>=33.1.0,<34.0", - "httpx>=0.27.2,<1.0", "ipython>=8.30.0,<9.0", + "mypy>=1.14.0,<2.0", "pre-commit>=4.0.1,<5.0", + "pytest-httpx>=0.34.0,<1.0", "pytest>=8.3.3,<9.0", "pytest-asyncio>=0.24.0,<1.0", "pytest-cov>=6.0.0,<7.0", "ruff>=0.8.0,<1.0", "typer>=0.13.1,<1.0", + "pytest-mock>=3.14.0", + "freezegun>=1.5.1,<2.0", ] diff --git a/tests/conftest.py b/tests/conftest.py index d24505d..84cfd8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os from collections.abc import AsyncGenerator, Awaitable, Callable import fastapi_pagination @@ -6,6 +7,7 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from pytest_asyncio import is_async_test +from pytest_mock import MockerFixture from sqlalchemy.ext.asyncio import async_sessionmaker from sqlmodel import SQLModel from sqlmodel.ext.asyncio.session import AsyncSession @@ -110,3 +112,14 @@ async def _organization( ) return _organization + + +@pytest.fixture +def mock_settings(mocker: MockerFixture) -> None: + mocker.patch.dict( + os.environ, + { + "FFC_OPERATIONS_API_MODIFIER_BASE_URL": "https://api-modifier.ffc.com", + "FFC_OPERATIONS_API_MODIFIER_JWT_TOKEN": "test_jwt_token", + }, + ) diff --git a/tests/test_organizations_api.py b/tests/test_organizations_api.py index 6e9cb50..b0210c9 100644 --- a/tests/test_organizations_api.py +++ b/tests/test_organizations_api.py @@ -1,4 +1,9 @@ +import pytest from httpx import AsyncClient +from pytest_httpx import HTTPXMock +from pytest_mock import MockerFixture +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession from app.models import Organization from tests.conftest import ModelFactory @@ -75,3 +80,124 @@ async def test_get_all_organizations_multiple_pages( all_external_ids = {item["external_id"] for item in all_items} assert len(all_items) == 10 assert all_external_ids == {f"EXTERNAL_ID_{index}" for index in range(10)} + + +# ==================== +# Create Organizations +# ==================== + + +async def test_can_create_organizations( + mocker: MockerFixture, + httpx_mock: HTTPXMock, + mock_settings: None, + api_client: AsyncClient, + db_session: AsyncSession, +): + mocker.patch("app.routers.organizations.get_api_modifier_jwt_token", return_value="test_token") + + httpx_mock.add_response( + method="POST", + url="https://api-modifier.ffc.com/admin/organizations", + json={"id": "UUID-yyyy-yyyy-yyyy-yyyy"}, + match_headers={"Authorization": "Bearer test_token"}, + ) + + response = await api_client.post( + "/organizations/", + json={ + "name": "My Organization", + "external_id": "ACC-1234-5678", + "user_id": "UUID-xxxx-xxxx-xxxx-xxxx", + "currency": "USD", + }, + ) + + assert response.status_code == 201 + data = response.json() + + assert data["id"] is not None + assert data["name"] == "My Organization" + assert data["external_id"] == "ACC-1234-5678" + assert data["organization_id"] == "UUID-yyyy-yyyy-yyyy-yyyy" + assert data["created_at"] is not None + + result = await db_session.exec(select(Organization).where(Organization.id == data["id"])) + assert result.one_or_none() is not None + + +@pytest.mark.parametrize("missing_field", ["name", "external_id", "user_id", "currency"]) +async def test_create_organization_with_incomplete_data( + api_client: AsyncClient, missing_field: str +): + payload = { + "name": "My Organization", + "external_id": "ACC-1234-5678", + "user_id": "UUID-xxxx-xxxx-xxxx-xxxx", + "currency": "USD", + } + payload.pop(missing_field) + + response = await api_client.post( + "/organizations/", + json=payload, + ) + + assert response.status_code == 422 + [detail] = response.json()["detail"] + + assert detail["type"] == "missing" + assert detail["loc"] == ["body", missing_field] + + +async def test_create_organization_with_existing_external_id( + api_client: AsyncClient, organization_factory: ModelFactory[Organization] +): + payload = { + "name": "My Organization", + "external_id": "ACC-1234-5678", + "user_id": "UUID-xxxx-xxxx-xxxx-xxxx", + "currency": "USD", + } + + await organization_factory(external_id="ACC-1234-5678") + + response = await api_client.post( + "/organizations/", + json=payload, + ) + + assert response.status_code == 400 + detail = response.json()["detail"] + assert detail == "Organization with this external ID already exists: ACC-1234-5678." + + +async def test_create_organization_api_modifier_error( + mocker: MockerFixture, + httpx_mock: HTTPXMock, + mock_settings: None, + api_client: AsyncClient, +): + mocker.patch("app.routers.organizations.get_api_modifier_jwt_token", return_value="test_token") + + httpx_mock.add_response( + method="POST", + url="https://api-modifier.ffc.com/admin/organizations", + status_code=500, + text="Internal Server Error", + ) + + response = await api_client.post( + "/organizations/", + json={ + "name": "My Organization", + "external_id": "ACC-1234-5678", + "user_id": "UUID-xxxx-xxxx-xxxx-xxxx", + "currency": "USD", + }, + ) + + assert response.status_code == 502 + + [detail] = response.json()["detail"] + assert detail == "Error creating organization in FinOps for Cloud: 500 - Internal Server Error." diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..5c839fd --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,32 @@ +from datetime import UTC, datetime + +import jwt +from freezegun import freeze_time + +from app import settings +from app.constants import ( + API_MODIFIER_JWT_ALGORITHM, + API_MODIFIER_JWT_AUDIENCE, + API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS, + API_MODIFIER_JWT_ISSUER, +) +from app.utils import get_api_modifier_jwt_token + + +@freeze_time("2024-01-01T00:00:00Z") +def test_get_api_modifier_jwt_token(mock_settings: None): + token = get_api_modifier_jwt_token() + decoded_token = jwt.decode( + token, + settings.api_modifier_jwt_secret, + audience=API_MODIFIER_JWT_AUDIENCE, + algorithms=[API_MODIFIER_JWT_ALGORITHM], + ) + assert decoded_token["iss"] == API_MODIFIER_JWT_ISSUER + assert decoded_token["aud"] == API_MODIFIER_JWT_AUDIENCE + + timestamp = int(datetime.now(UTC).timestamp()) + + assert decoded_token["iat"] == timestamp + assert decoded_token["nbf"] == timestamp + assert decoded_token["exp"] == timestamp + API_MODIFIER_JWT_EXPIRE_AFTER_SECONDS diff --git a/uv.lock b/uv.lock index b538986..fefa274 100644 --- a/uv.lock +++ b/uv.lock @@ -92,7 +92,7 @@ name = "bandit" version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "pyyaml" }, { name = "rich" }, { name = "stevedore" }, @@ -125,7 +125,7 @@ name = "click" version = "8.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } wheels = [ @@ -265,19 +265,6 @@ standard = [ { name = "uvicorn", extra = ["standard"] }, ] -[[package]] -name = "fastapi-async-sqlalchemy" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sqlalchemy" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/99/05/e8797d147815f246995bd70a4a3736fbb3500ce3e706e660baf895091dd9/fastapi-async-sqlalchemy-0.6.1.tar.gz", hash = "sha256:c4e0c9832e5e7ef9d647e7eb134e6d326945dca28323e503a21f3d4ab2dee160", size = 6818 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/e3/ec3b6c68209e7dd36b58aba4e7f1b52ad01ba9c4f4c37f16b887518d76fe/fastapi_async_sqlalchemy-0.6.1-py3-none-any.whl", hash = "sha256:0f4edfbc7b0f5fc2e0017cd903a953f4e0b01870f09e86cd0bc79087f3606bc4", size = 6424 }, -] - [[package]] name = "fastapi-cli" version = "0.0.5" @@ -318,6 +305,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, ] +[[package]] +name = "freezegun" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, +] + [[package]] name = "greenlet" version = "3.1.1" @@ -571,13 +570,15 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "alembic-postgresql-enum" }, { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, - { name = "fastapi-async-sqlalchemy" }, { name = "fastapi-pagination" }, + { name = "httpx" }, { name = "pycountry" }, { name = "pydantic-extra-types" }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "python-dotenv" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlmodel" }, @@ -585,15 +586,17 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "alembic-postgresql-enum" }, { name = "bandit" }, { name = "faker" }, - { name = "httpx" }, + { name = "freezegun" }, { name = "ipython" }, + { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-httpx" }, + { name = "pytest-mock" }, { name = "ruff" }, { name = "typer" }, ] @@ -601,13 +604,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = "==1.14.*" }, + { name = "alembic-postgresql-enum", specifier = "==1.4.*" }, { name = "asyncpg", specifier = "==0.30.*" }, { name = "fastapi", extras = ["standard"], specifier = "==0.115.*" }, - { name = "fastapi-async-sqlalchemy", specifier = "==0.6.*" }, { name = "fastapi-pagination", specifier = "==0.12.*" }, + { name = "httpx", specifier = "==0.27.*" }, { name = "pycountry", specifier = "==24.6.*" }, { name = "pydantic-extra-types", specifier = "==2.10.*" }, { name = "pydantic-settings", specifier = "==2.6.*" }, + { name = "pyjwt", specifier = "==2.10.*" }, { name = "python-dotenv", specifier = "==1.0.*" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.*" }, { name = "sqlmodel", specifier = "==0.0.*" }, @@ -615,19 +620,53 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "alembic-postgresql-enum", specifier = ">=1.4.0" }, { name = "bandit", specifier = ">=1.8.0,<2.0" }, { name = "faker", specifier = ">=33.1.0,<34.0" }, - { name = "httpx", specifier = ">=0.27.2,<1.0" }, + { name = "freezegun", specifier = ">=1.5.1" }, { name = "ipython", specifier = ">=8.30.0,<9.0" }, + { name = "mypy", specifier = ">=1.14.0,<2.0" }, { name = "pre-commit", specifier = ">=4.0.1,<5.0" }, { name = "pytest", specifier = ">=8.3.3,<9.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0,<1.0" }, { name = "pytest-cov", specifier = ">=6.0.0,<7.0" }, + { name = "pytest-httpx", specifier = ">=0.34.0,<1.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "ruff", specifier = ">=0.8.0,<1.0" }, { name = "typer", specifier = ">=0.13.1,<1.0" }, ] +[[package]] +name = "mypy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/7b/08046ef9330735f536a09a2e31b00f42bccdb2795dcd979636ba43bb2d63/mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6", size = 3215684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d8/0e72175ee0253217f5c44524f5e95251c02e95ba9749fb87b0e2074d203a/mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd", size = 11269011 }, + { url = "https://files.pythonhosted.org/packages/e9/6d/4ea13839dabe5db588dc6a1b766da16f420d33cf118a7b7172cdf6c7fcb2/mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1", size = 10253076 }, + { url = "https://files.pythonhosted.org/packages/3e/38/7db2c5d0f4d290e998f7a52b2e2616c7bbad96b8e04278ab09d11978a29e/mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63", size = 12862786 }, + { url = "https://files.pythonhosted.org/packages/bf/4b/62d59c801b34141040989949c2b5c157d0408b45357335d3ec5b2845b0f6/mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d", size = 12971568 }, + { url = "https://files.pythonhosted.org/packages/f1/9c/e0f281b32d70c87b9e4d2939e302b1ff77ada4d7b0f2fb32890c144bc1d6/mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba", size = 9879477 }, + { url = "https://files.pythonhosted.org/packages/13/33/8380efd0ebdfdfac7fc0bf065f03a049800ca1e6c296ec1afc634340d992/mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741", size = 11251509 }, + { url = "https://files.pythonhosted.org/packages/15/6d/4e1c21c60fee11af7d8e4f2902a29886d1387d6a836be16229eb3982a963/mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7", size = 10244282 }, + { url = "https://files.pythonhosted.org/packages/8b/cf/7a8ae5c0161edae15d25c2c67c68ce8b150cbdc45aefc13a8be271ee80b2/mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8", size = 12867676 }, + { url = "https://files.pythonhosted.org/packages/9c/d0/71f7bbdcc7cfd0f2892db5b13b1e8857673f2cc9e0c30e3e4340523dc186/mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc", size = 12964189 }, + { url = "https://files.pythonhosted.org/packages/a7/40/fb4ad65d6d5f8c51396ecf6305ec0269b66013a5bf02d0e9528053640b4a/mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f", size = 9888247 }, + { url = "https://files.pythonhosted.org/packages/39/32/0214608af400cdf8f5102144bb8af10d880675c65ed0b58f7e0e77175d50/mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab", size = 2752803 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -833,6 +872,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pytest" version = "8.3.3" @@ -873,6 +921,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, ] +[[package]] +name = "pytest-httpx" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/08/d0be3fe5645c6cd9396093a9ddf97d60814a3b066fd5b38ddced34a13d14/pytest_httpx-0.34.0.tar.gz", hash = "sha256:3ca4b0975c0f93b985f17df19e76430c1086b5b0cce32b1af082d8901296a735", size = 54108 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/72/7138a0faf5d780d6b9ceedef22da0b66ae8e22a676a12fd55a05c0cdd979/pytest_httpx-0.34.0-py3-none-any.whl", hash = "sha256:42cf0a66f7b71b9111db2897e8b38a903abd33a27b11c48aff4a3c7650313af2", size = 19440 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"