diff --git a/.github/workflows/pr-build-merge.yaml b/.github/workflows/pr-build-merge.yaml index 979242d..a46b9a4 100644 --- a/.github/workflows/pr-build-merge.yaml +++ b/.github/workflows/pr-build-merge.yaml @@ -12,6 +12,21 @@ permissions: jobs: build: + services: + db: + image: postgres:17 + env: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: mysecurepass + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + runs-on: ubuntu-latest timeout-minutes: 10 @@ -41,17 +56,18 @@ jobs: - name: Run linting run: uv run ruff check . - - name: Run type checks - run: uv run mypy . - - name: Run security checks run: uv run bandit -c pyproject.toml -r . - name: Run tests run: uv run pytest + env: + FFC_OPERATIONS_POSTGRES_HOST: localhost + FFC_OPERATIONS_POSTGRES_PORT: 5432 - name: Save code coverage report in the artefacts uses: actions/upload-artifact@v4 + if: ${{ !env.ACT }} with: name: coverage-report path: htmlcov @@ -62,6 +78,7 @@ jobs: - name: Save openapi.json the artefacts uses: actions/upload-artifact@v4 + if: ${{ !env.ACT }} with: name: openapi-spec path: openapi.json diff --git a/.gitignore b/.gitignore index 79150fe..a1969a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,165 @@ -# Python-generated files +# Byte-compiled / optimized / DLL files __pycache__/ -*.py[oc] +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python build/ +develop-eggs/ dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ wheels/ -*.egg-info +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py -# Virtual environments -.venv/ +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version -# Dev environment +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments .env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject -# Third party tools +# mkdocs documentation +/site + +# mypy .mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# ruff .ruff_cache/ -.pytest_cache/ -.coverage +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Makefile b/Makefile deleted file mode 100644 index 36acbc0..0000000 --- a/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -.env: - cp env.example .env - -.PHONY: dev-server -dev-server: - uv run fastapi dev app/main.py - -.PHONY: lint -lint: - uv run ruff check . - uv run ruff format --check --diff . - -.PHONY: fix -fix: - uv run ruff check . --fix --fix-only --show-fixes - uv run ruff format . - -.PHONY: types -types: - uv run mypy . - -.PHONY: security-checks -security-checks: - uv run bandit -c pyproject.toml -r . - -.PHONY: tests -tests: - uv run pytest diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..8a941e6 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,115 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py index e69de29..16bbab4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1,4 @@ +from app import models # noqa: F401 +from app.conf import Settings + +settings = Settings() diff --git a/app/collections.py b/app/collections.py new file mode 100644 index 0000000..525911e --- /dev/null +++ b/app/collections.py @@ -0,0 +1,103 @@ +from collections.abc import Sequence +from uuid import UUID + +from fastapi import HTTPException +from fastapi import status as http_status +from fastapi_pagination.ext.sqlmodel import paginate +from fastapi_pagination.limit_offset import LimitOffsetPage, LimitOffsetParams +from sqlmodel import SQLModel, col, delete, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models import Entitlement, EntitlementCreate, EntitlementUpdate, UUIDModel + + +class BaseCollection[ModelT: UUIDModel, ModelCreateT: SQLModel, ModelUpdateT: SQLModel]: + def __init__(self, session: AsyncSession): + self.session = session + + @classmethod + def _get_generic_cls_args(cls): + return next( + base_cls.__args__ + for base_cls in cls.__orig_bases__ + if base_cls.__origin__ is BaseCollection + ) + + @property + def model_cls(self) -> type[ModelT]: + return self._get_generic_cls_args()[0] + + @property + def model_create_cls(self) -> type[ModelCreateT]: + return self._get_generic_cls_args()[1] + + @property + def model_update_cls(self) -> type[ModelCreateT]: + return self._get_generic_cls_args()[2] + + async def create(self, data: ModelCreateT) -> ModelT: + obj = self.model_cls(**data.model_dump()) + self.session.add(obj) + await self.session.commit() + await self.session.refresh(obj) + + return obj + + async def get(self, id: str | UUID) -> ModelT: + obj = await self.session.get(self.model_cls, id) + + if obj is None: + raise HTTPException( + status_code=http_status.HTTP_404_NOT_FOUND, + detail=f"{self.model_cls.__name__} with ID {str(id)} wasn't found", + ) + + return obj + + async def fetch_all(self) -> Sequence[ModelT]: + results = await self.session.exec(select(self.model_cls)) + return results.all() + + async def fetch_page( + self, pagination_params: LimitOffsetParams | None = None + ) -> LimitOffsetPage[ModelT]: + return await paginate(self.session, self.model_cls, pagination_params) + + async def update(self, id: str | UUID, data: ModelUpdateT) -> ModelT: + statement = select(self.model_cls).where(self.model_cls.id == id) + results = await self.session.exec(statement) + + obj: ModelT | None = results.first() + + if obj is None: + raise HTTPException( + status_code=http_status.HTTP_404_NOT_FOUND, + detail=f"{self.model_cls.__name__} with ID {str(id)} wasn't found", + ) + + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(obj, key, value) + + self.session.add(obj) + await self.session.commit() + await self.session.refresh(obj) + + return obj + + async def delete(self, id: str | UUID) -> bool: + statement = delete(self.model_cls).where(col(self.model_cls.id) == id) + + await self.session.execute(statement=statement) + await self.session.commit() + + return True + + +class EntitlementCollection(BaseCollection[Entitlement, EntitlementCreate, EntitlementUpdate]): + pass + # async def terminate(self, id: str | UUID) -> Entitlement: + # async with self.updating(id=id) as entitlement: + # entitlement.terminated_at = datetime.datetime.now(datetime.UTC) + # entitlement.terminated_by = ... + # + # return entitlement diff --git a/app/conf.py b/app/conf.py new file mode 100644 index 0000000..04349d7 --- /dev/null +++ b/app/conf.py @@ -0,0 +1,33 @@ +import pathlib + +from pydantic import PostgresDsn, computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + +PROJECT_ROOT = pathlib.Path(__file__).parent.parent + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=PROJECT_ROOT / ".env", + env_file_encoding="utf-8", + env_prefix="ffc_operations_", + ) + + postgres_db: str + postgres_user: str + postgres_password: str + postgres_host: str + postgres_port: int + + debug: bool = False + + @computed_field + def postgres_async_url(self) -> PostgresDsn: + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=self.postgres_user, + password=self.postgres_password, + host=self.postgres_host, + port=self.postgres_port, + path=self.postgres_db, + ) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..3046bc5 --- /dev/null +++ b/app/db.py @@ -0,0 +1,27 @@ +import sys +from collections.abc import AsyncIterator +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import sessionmaker +from sqlmodel.ext.asyncio.session import AsyncSession + +from app import settings + +running_tests = "pytest" in sys.modules + +db_engine = create_async_engine( + str(settings.postgres_async_url), + echo=False if running_tests else settings.debug, + future=True, +) + + +async def get_db_session() -> AsyncIterator[AsyncSession]: + async_session = sessionmaker(bind=db_engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + yield session + + +DBSession = Annotated[AsyncSession, Depends(get_db_session)] diff --git a/app/main.py b/app/main.py index feea701..2a610fe 100644 --- a/app/main.py +++ b/app/main.py @@ -1,337 +1,28 @@ -import datetime -import uuid -from decimal import Decimal -from enum import Enum -from typing import Annotated, Generic, TypeVar +import fastapi_pagination +from fastapi import FastAPI -from fastapi import Depends, FastAPI, status -from fastapi_camelcase import CamelModel -from pydantic import BaseModel, EmailStr, Field, HttpUrl -from pydantic_extra_types.currency_code import Currency - -LimitField = Annotated[ - int, - Field(gt=0, le=100, default=100, description="Number of records to return"), -] - -OffsetField = Annotated[ - int, - Field(ge=0, default=0, description="Number of records to skip"), -] - - -T = TypeVar("T") - - -class PaginationParams(BaseModel): - limit: LimitField - offset: OffsetField - - -class PaginationData(BaseModel): - limit: LimitField - offset: OffsetField - total: Annotated[int, Field(description="Total number of records available", ge=0)] - - -class PaginatedResponse(BaseModel, Generic[T]): - pagination: PaginationData - items: list[T] = Field(description="List of items for the current page") - - -# Organization Models -class OrganizationBase(CamelModel): - name: str = Field( - description="The name of the organization", - examples=["Apple Inc."], - ) - currency: Currency = Field( - description="The primary currency used by the organization for financial operations", - examples=["USD"], - ) - - -class OrganizationCreate(OrganizationBase): - pass - - -class OrganizationUpdate(OrganizationBase): - pass - - -class Organization(OrganizationBase): - id: uuid.UUID - limit: Decimal = Field( - description="Maximum spending limit set for the organization", - examples=["50000.00"], - ) - expenses_last_month: Decimal = Field( - description="Total expenses from the previous month", - examples=["42350.75"], - ) - expenses_this_month: Decimal = Field( - description="Current month's accumulated expenses", - examples=["23150.25"], - ) - expenses_month_forecast: Decimal = Field( - description="Predicted total expenses for the current month", - examples=["45000.00"], - ) - possible_monthly_savings: Decimal = Field( - description="Estimated amount that could be saved based on current spending patterns", - examples=["5000.00"], - ) - - -# User Models - - -class UserRole(str, Enum): - ORGANISATION_MANAGER = "organisation_manager" - MANAGER = "manager" - ENGINEER = "engineer" - - -class UserBase(CamelModel): - email: EmailStr - - -class UserCreate(UserBase): - organisation_id: uuid.UUID - role: UserRole - - -class UserUpdate(UserBase): - pass - - -class User(UserBase): - id: uuid.UUID - organisation_id: uuid.UUID - role: UserRole - created_at: datetime.datetime - last_login_at: datetime.datetime - - -# Entitlement Models -class EntitlementBase(CamelModel): - sponsor_name: str = Field(description="Name of the sponsor for this entitlement", examples=["AWS"]) - sponsor_external_id: str = Field(description="Vendor account number from MPT", examples=["ACC-1234-5678"]) - sponsor_container_id: str = Field( - description="Azure Sub ID, AWS Account number, GCP Project ID etc.", - examples=["d4500e78-ac78-4445-89e8-5b8a4d482035"], - ) - - -class EntitlementCreate(EntitlementBase): - pass - - -class Entitlement(EntitlementBase): - entitlement_id: uuid.UUID = Field( - description="Unique identifier for the entitlement", - examples=["123e4567-e89b-12d3-a456-426614174000"], - ) - activated_at: datetime.datetime | None = Field( - description="Timestamp when the entitlement was activated", - ) - activated_by: uuid.UUID | None = Field( - description="User ID who activated the entitlement", - ) - terminated_at: datetime.datetime | None = Field( - description="Timestamp when the entitlement was terminated", - examples=["2023-12-31T12:00:00Z"], - ) - terminated_by: uuid.UUID | None = Field( - description="User ID who terminated the entitlement", - ) - - -# Data source models -class DataSourceType(str, Enum): - AWS_ROOT = "aws_root" - AWS_LINKED = "aws_linked" - AZURE_TENANT = "azure_tenant" - AZURE_SUBSCRIPTION = "azure_subscription" - GCP = "gcp" - - -class DataSource(CamelModel): - organisation_id: uuid.UUID = Field( - description="ID of the organization this data source belongs to", - examples=["123e4567-e89b-12d3-a456-426614174000"], - ) - type: DataSourceType = Field(description="Type of the data source", examples=["aws_root"]) - resources_changed_this_month: int = Field( - description="Number of resources that changed during the current month", - examples=[42], - ) - expenses_so_far_this_month: Decimal = Field(description="Current month's expenses up to now", examples=["1234.56"]) - expenses_forecast_this_month: Decimal = Field( - description="Forecasted expenses for the current month", examples=["2500.00"] - ) - icon_url: HttpUrl = Field( - description="URL to the icon representing this data source", - examples=["https://example.com/icons/aws.png"], - ) - - -app = FastAPI( - title="Optscale Operations API", - description="API to be used by Operators to manage Optscale", -) +from app import settings +from app.routers import entitlements tags_metadata = [ - { - "name": "Organizations", - "description": "Operations with organizations", - }, - { - "name": "Users", - "description": "Operations with users", - }, { "name": "Entitlements", "description": "Operations with entitlements", }, - { - "name": "Data Sources", - "description": "Read-only operations with data sources", - }, ] -app = FastAPI(openapi_tags=tags_metadata) - - -# Organization endpoints -@app.post( - "/v1/organizations/", - response_model=Organization, - status_code=status.HTTP_201_CREATED, - tags=["Organizations"], -) -async def create_organization(organization: OrganizationCreate): - pass - - -@app.get( - "/v1/organizations/", - response_model=PaginatedResponse[Organization], - tags=["Organizations"], -) -async def list_organizations(pagination: PaginationParams = Depends()): - pass - - -@app.get( - "/v1/organizations/{organization_id}", - response_model=Organization, - tags=["Organizations"], -) -async def get_organization(organization_id: uuid.UUID): - pass - - -@app.put( - "/v1/organizations/{organization_id}", - response_model=Organization, - tags=["Organizations"], -) -async def update_organization(organization_id: uuid.UUID, organization: OrganizationUpdate): - pass - - -# User endpoints -@app.post( - "/v1/users/", - response_model=User, - status_code=status.HTTP_201_CREATED, - tags=["Users"], -) -async def create_user(user: UserCreate): - pass - - -@app.get("/v1/users/{user_id}", response_model=User, tags=["Users"]) -async def get_user(user_id: uuid.UUID): - pass - -@app.put("/v1/users/{user_id}", response_model=User, tags=["Users"]) -async def update_user(user_id: uuid.UUID, user: UserUpdate): - pass - - -@app.get( - "/v1/organizations/{organization_id}/users/", - response_model=PaginatedResponse[User], - tags=["Users"], -) -async def list_organization_users(organization_id: uuid.UUID, pagination: PaginationParams = Depends()): - pass - - -@app.post( - "/v1/organizations/{organization_id}/users/{user_id}/make-admin", - response_model=User, - tags=["Users"], -) -async def user_make_admin(organization_id: uuid.UUID, user_id: uuid.UUID): - pass - - -# Entitlement endpoints -@app.post( - "/v1/entitlements/", - response_model=Entitlement, - status_code=status.HTTP_201_CREATED, - tags=["Entitlements"], -) -async def create_entitlement(entitlement: EntitlementCreate): - pass - - -@app.get( - "/v1/entitlements/", - response_model=PaginatedResponse[Entitlement], - tags=["Entitlements"], -) -async def list_entitlements(pagination: PaginationParams = Depends()): - pass - - -@app.get( - "/v1/entitlements/{entitlement_id}", - response_model=Entitlement, - tags=["Entitlements"], -) -async def get_entitlement(entitlement_id: uuid.UUID): - pass - - -@app.post( - "/v1/entitlements/{entitlement_id}/terminate", - response_model=Entitlement, - tags=["Entitlements"], +app = FastAPI( + title="Optscale Operations API", + description="API to be used by Operators to manage Optscale", + openapi_tags=tags_metadata, + root_path="/v1", + debug=settings.debug, ) -async def terminate_entitlement(entitlement_id: uuid.UUID): - pass +fastapi_pagination.add_pagination(app) -# Data Source endpoints -@app.get( - "/v1/data-sources/{data_source_id}", - response_model=DataSource, - tags=["Data Sources"], -) -async def get_data_source(data_source_id: uuid.UUID): - pass +# TODO: Add healthcheck -@app.get( - "/v1/organizations/{organization_id}/data-sources/", - response_model=PaginatedResponse[DataSource], - tags=["Data Sources"], -) -async def list_organization_data_sources(organization_id: uuid.UUID, pagination: PaginationParams = Depends()): - pass +app.include_router(entitlements.router, prefix="/entitlements", tags=["Entitlements"]) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..429658f --- /dev/null +++ b/app/models.py @@ -0,0 +1,79 @@ +import datetime +import uuid + +import sqlalchemy as sa +from sqlmodel import Field, SQLModel + + +class UUIDModel(SQLModel): + id: uuid.UUID = Field( + primary_key=True, + nullable=False, + default_factory=uuid.uuid4, + index=True, + sa_column_kwargs={ + "server_default": sa.text("gen_random_uuid()"), + "unique": True, + }, + ) + + +class TimestampModel(SQLModel): + created_at: datetime.datetime = Field( + nullable=False, + default_factory=lambda: datetime.datetime.now(datetime.UTC), + sa_type=sa.DateTime(timezone=True), + sa_column_kwargs={"server_default": sa.text("current_timestamp(0)")}, + ) + + updated_at: datetime.datetime = Field( + nullable=False, + default_factory=lambda: datetime.datetime.now(datetime.UTC), + sa_type=sa.DateTime(timezone=True), + sa_column_kwargs={ + "server_default": sa.text("current_timestamp(0)"), + "onupdate": sa.text("current_timestamp(0)"), + }, + ) + + +class SoftDeletedModel(SQLModel): + soft_deleted: bool = Field( + nullable=False, + index=True, + sa_column_kwargs={"server_default": sa.sql.false()}, + ) + + +class EntitlementBase(SQLModel): + sponsor_name: str = Field(max_length=255, nullable=False) + sponsor_external_id: str = Field(max_length=255, nullable=False) + sponsor_container_id: str = Field(max_length=255, nullable=False) + # activated_at: datetime.datetime | None = None + # activated_by: uuid.UUID | None = None + # terminated_at: datetime.datetime | None = None + # terminated_by: uuid.UUID | None = None + + +class Entitlement(EntitlementBase, TimestampModel, UUIDModel, table=True): + __tablename__ = "entitlements" + + activated_at: datetime.datetime | None = Field( + default=None, + nullable=True, + sa_type=sa.DateTime(timezone=True), + ) + + +class EntitlementRead(EntitlementBase, UUIDModel): + activated_at: datetime.datetime | None + + +class EntitlementCreate(EntitlementBase): + pass + + +class EntitlementUpdate(EntitlementBase): + sponsor_name: str | None = None + sponsor_external_id: str | None = None + sponsor_container_id: str | None = None diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/entitlements.py b/app/routers/entitlements.py new file mode 100644 index 0000000..d2f1fe3 --- /dev/null +++ b/app/routers/entitlements.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, status +from fastapi_pagination.limit_offset import LimitOffsetPage + +from app.collections import EntitlementCollection +from app.db import DBSession +from app.models import EntitlementCreate, EntitlementRead, EntitlementUpdate + +router = APIRouter() + + +@router.get("/", response_model=LimitOffsetPage[EntitlementRead]) +async def get_entitlements(session: DBSession): + entitlements = EntitlementCollection(session=session) + return await entitlements.fetch_page() + + +@router.get("/{id}", response_model=EntitlementRead) +async def get_entitlement_by_id(id: str, session: DBSession): + entitlements = EntitlementCollection(session=session) + return await entitlements.get(id=id) + + +@router.post("/", response_model=EntitlementRead, status_code=status.HTTP_201_CREATED) +async def create_entitlement(data: EntitlementCreate, session: DBSession): + entitlements = EntitlementCollection(session=session) + return await entitlements.create(data=data) + + +@router.patch("/{id}", response_model=EntitlementRead) +async def update_entitlement(id: str, data: EntitlementUpdate, session: DBSession): + entitlements = EntitlementCollection(session=session) + return await entitlements.update(id=id, data=data) diff --git a/Dockerfile b/dev.Dockerfile similarity index 67% rename from Dockerfile rename to dev.Dockerfile index 6900803..f3b568b 100644 --- a/Dockerfile +++ b/dev.Dockerfile @@ -1,10 +1,11 @@ -# NOTE: Based on astral-sh example Dockerfile for uv-based projects: -# https://github.com/astral-sh/uv-docker-example/blob/main/Dockerfile - -FROM python:3.12-slim-bookworm +FROM python:3.12 # The uv installer requires curl (and certificates) to download the release archive -RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates +RUN apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl vim; \ + apt-get autoremove --purge -y; \ + apt-get clean -y; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* # Download the latest installer ADD https://astral.sh/uv/install.sh /uv-installer.sh @@ -28,19 +29,15 @@ ENV UV_LINK_MODE=copy RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --frozen --no-install-project --no-dev + uv sync --frozen --no-install-project + +RUN echo 'alias pip="uv pip"' >> ~/.bashrc # Then, add the rest of the project source code and install it # Installing separately from its dependencies allows optimal layer caching -ADD . /app +COPY . /app RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev + uv sync --frozen # Place executables in the environment at the front of the path ENV PATH="/app/.venv/bin:$PATH" - -# Reset the entrypoint, don't invoke `uv` -ENTRYPOINT [] - -# Run the service using using uvicorn -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker-compose.yaml b/docker-compose.yaml index e5cd9df..3ef70e7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,22 @@ +networks: + docker_network: + driver: bridge + services: app: - build: . + build: + context: . + dockerfile: dev.Dockerfile + working_dir: /app restart: always + networks: + - docker_network + depends_on: + db: + condition: "service_healthy" + command: bash -c "uv run fastapi dev --host 0.0.0.0 --port 8000" + environment: + FFC_OPERATIONS_POSTGRES_HOST: db env_file: - .env ports: @@ -10,29 +25,79 @@ services: db: image: postgres:17 restart: unless-stopped - env_file: - - .env + environment: + POSTGRES_DB: "${FFC_OPERATIONS_POSTGRES_DB}" + POSTGRES_USER: "${FFC_OPERATIONS_POSTGRES_USER}" + POSTGRES_PASSWORD: "${FFC_OPERATIONS_POSTGRES_PASSWORD}" + POSTGRES_HOST: "${FFC_OPERATIONS_POSTGRES_HOST}" + POSTGRES_PORT: "${FFC_OPERATIONS_POSTGRES_PORT}" ports: - - "5433:5432" + - "${FFC_OPERATIONS_POSTGRES_PORT}:5432" + networks: + - docker_network healthcheck: test: ["CMD-SHELL", "pg_isready --dbname=$${POSTGRES_DB} --username=$${POSTRGRES_USER}"] interval: 10s timeout: 5s retries: 5 - pgadmin: - image: dcagatay/pwless-pgadmin4 + app_test: + build: + context: . + dockerfile: dev.Dockerfile depends_on: db: condition: "service_healthy" + networks: + - docker_network + command: > + bash -c " + set -e + + # Run Ruff to check code style + uv run ruff check . + + # Check formatting with Ruff + uv run ruff format --check --diff . + + # Run tests with pytest + uv run pytest + " + environment: + FFC_OPERATIONS_POSTGRES_HOST: db + env_file: + - .env + + bash: + build: + context: . + dockerfile: dev.Dockerfile + command: bash + stdin_open: true + tty: true + env_file: + - .env + + format: + build: + context: . + dockerfile: dev.Dockerfile + command: > + bash -c " + set -e + # Run Ruff to fix code style + uv run ruff check . --fix --fix-only --show-fixes + + # Run Ruff to format code + uv run ruff format . + " + env_file: + - .env + + bandit: + build: + context: . + dockerfile: dev.Dockerfile + command: bash -c "uv run bandit -c pyproject.toml -r ." env_file: - .env - ports: - - "8001:80" - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "nc -z localhost 80"] - start_period: 5s - interval: 2s - timeout: 2s - retries: 15 diff --git a/env.example b/env.example index e6b7c68..1ad52de 100644 --- a/env.example +++ b/env.example @@ -1,5 +1,5 @@ -POSTGRES_DB: app -POSTGRES_USER: postgres -POSTGRES_PASSWORD: mysecurepass -POSTGRES_HOST: "*" -POSTGRES_PORT: "5432" +FFC_OPERATIONS_POSTGRES_DB=postgres +FFC_OPERATIONS_POSTGRES_USER=postgres +FFC_OPERATIONS_POSTGRES_PASSWORD=mysecurepass +FFC_OPERATIONS_POSTGRES_HOST=0.0.0.0 +FFC_OPERATIONS_POSTGRES_PORT=5432 diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..9e39b1d --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncEngine +from sqlmodel import SQLModel + +from app import settings as app_settings +from app.models import * # noqa: F403 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = SQLModel.metadata + +target_metadata.naming_convention = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + + context.configure( + url=str(app_settings.postgres_async_url), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online(): + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + config_section = config.get_section(config.config_ini_section) + config_section["sqlalchemy.url"] = str(app_settings.postgres_async_url) + + connectable = AsyncEngine( + engine_from_config( + config_section, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..6ce3351 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/092806354b57_add_entitlement_activated_at.py b/migrations/versions/092806354b57_add_entitlement_activated_at.py new file mode 100644 index 0000000..fb07ba6 --- /dev/null +++ b/migrations/versions/092806354b57_add_entitlement_activated_at.py @@ -0,0 +1,31 @@ +"""add_entitlement_activated_at + +Revision ID: 092806354b57 +Revises: f7798efd5439 +Create Date: 2024-12-11 14:34:34.273236 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '092806354b57' +down_revision: Union[str, None] = 'f7798efd5439' +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('entitlements', sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('entitlements', 'activated_at') + # ### end Alembic commands ### diff --git a/migrations/versions/f7798efd5439_create_entitlements.py b/migrations/versions/f7798efd5439_create_entitlements.py new file mode 100644 index 0000000..89fa52f --- /dev/null +++ b/migrations/versions/f7798efd5439_create_entitlements.py @@ -0,0 +1,41 @@ +"""create_entitlements + +Revision ID: f7798efd5439 +Revises: +Create Date: 2024-12-11 13:22:26.882990 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'f7798efd5439' +down_revision: Union[str, None] = None +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.create_table('entitlements', + sa.Column('id', sa.Uuid(), server_default=sa.text('gen_random_uuid()'), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('current_timestamp(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('current_timestamp(0)'), nullable=False), + sa.Column('sponsor_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('sponsor_external_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('sponsor_container_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_entitlements')) + ) + op.create_index(op.f('ix_entitlements_id'), 'entitlements', ['id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_entitlements_id'), table_name='entitlements') + op.drop_table('entitlements') + # ### end Alembic commands ### diff --git a/prod.Dockerfile b/prod.Dockerfile new file mode 100644 index 0000000..37aa8cc --- /dev/null +++ b/prod.Dockerfile @@ -0,0 +1,55 @@ +FROM python:3.12-slim + +# The uv installer requires curl (and certificates) to download the release archive +RUN apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates curl vim; \ + apt-get autoremove --purge -y; \ + apt-get clean -y; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +# Download the latest installer +ADD https://astral.sh/uv/install.sh /uv-installer.sh + +# Run the uv installer then remove it +RUN sh /uv-installer.sh && rm /uv-installer.sh + +# Ensure the installed binary is on the `PATH` +ENV PATH="/root/.local/bin/:$PATH" + +# Install the project into `/app` +WORKDIR /app + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a mounted volume +ENV UV_LINK_MODE=copy + +# Install the project's dependencies using the lockfile and settings +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project --no-dev + +RUN echo 'alias pip="uv pip"' >> ~/.bashrc + +# Then, add the rest of the project source code and install it +# Installing separately from its dependencies allows optimal layer caching +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" + +# Running gunicorn with Uvicorn workers +CMD [ \ + "gunicorn", \ + "-b", ":8000", \ + "--capture-output", \ + "--error-logfile", "-", \ + "--access-logfile", "-", \ + "--workers", "4", \ + "--worker-class", "uvicorn_worker.UvicornWorker", \ + "app.main:app" \ +] diff --git a/pyproject.toml b/pyproject.toml index aeb911c..ee55fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,29 +5,39 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "fastapi-camelcase>=2.0.0", - "fastapi[standard]>=0.115.5", - "pycountry>=24.6.1", - "pydantic-extra-types>=2.10.0", - "pytest-cov>=6.0.0", + "alembic==1.14.*", + "asyncpg==0.30.*", + "fastapi-async-sqlalchemy==0.6.*", + "fastapi-pagination==0.12.*", + "fastapi[standard]==0.115.*", + "pycountry==24.6.*", + "pydantic-extra-types==2.10.*", + "pydantic-settings==2.6.*", + "python-dotenv==1.0.*", + "sqlalchemy[asyncio]==2.0.*", + "sqlmodel==0.0.*", ] [dependency-groups] dev = [ - "bandit>=1.8.0", - "httpx>=0.27.2", - "mypy>=1.13.0", - "pytest>=8.3.3", - "pytest-asyncio>=0.24.0", - "ruff>=0.8.0", - "typer>=0.13.1", + "bandit>=1.8.0,<2.0", + "httpx>=0.27.2,<1.0", + "ipython>=8.30.0,<9.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", ] [tool.ruff] -line-length = 120 +line-length = 100 target-version = "py312" output-format = "full" +extend-exclude = [ + "migrations/versions/", +] [tool.ruff.lint] select = [ @@ -53,10 +63,6 @@ ignore = [ quote-style = "double" docstring-code-format = true -[tool.mypy] -warn_no_return = false -ignore_missing_imports = true - [tool.bandit] exclude_dirs = ["tests", ".venv"] @@ -76,4 +82,3 @@ source = ["app"] [tool.coverage.report] show_missing = true -fail_under = 50 diff --git a/tests/conftest.py b/tests/conftest.py index 933dbfd..8c6c2c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,80 @@ +import uuid +from collections.abc import AsyncGenerator + +import fastapi_pagination import pytest +from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from pytest_asyncio import is_async_test +from sqlalchemy.orm import sessionmaker +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession +from app.collections import EntitlementCollection +from app.db import db_engine from app.main import app +from app.models import Entitlement, EntitlementCreate + + +def pytest_collection_modifyitems(items): + pytest_asyncio_tests = (item for item in items if is_async_test(item)) + session_scope_marker = pytest.mark.asyncio(loop_scope="session") + for async_test in pytest_asyncio_tests: + async_test.add_marker(session_scope_marker, append=False) + + +@pytest.fixture(scope="session", autouse=True) +def fastapi_app() -> FastAPI: + fastapi_pagination.add_pagination(app) + return app + + +@pytest.fixture(autouse=True) +async def db_session() -> AsyncGenerator[AsyncSession]: + session = sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False) + + async with session() as s: + async with db_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + yield s + + async with db_engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + + await db_engine.dispose() @pytest.fixture -async def api_client(): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost") as client: +async def api_client(fastapi_app: FastAPI) -> AsyncGenerator[AsyncClient]: + async with AsyncClient( + transport=ASGITransport(app=fastapi_app), base_url="http://v1/" + ) as client: yield client + + +@pytest.fixture +def entitlements_collection(db_session: AsyncSession) -> EntitlementCollection: + return EntitlementCollection(db_session) + + +@pytest.fixture +async def entitlement_aws(entitlements_collection: EntitlementCollection) -> Entitlement: + return await entitlements_collection.create( + EntitlementCreate( + sponsor_name="AWS", + sponsor_external_id=f"EXTERNAL_ID_{uuid.uuid4().hex[:8]}", + sponsor_container_id=f"CONTAINER_ID_{uuid.uuid4().hex[:8]}", + ) + ) + + +@pytest.fixture +async def entitlement_gcp(entitlements_collection: EntitlementCollection) -> Entitlement: + return await entitlements_collection.create( + EntitlementCreate( + sponsor_name="GCP", + sponsor_external_id=f"EXTERNAL_ID_{uuid.uuid4().hex[:8]}", + sponsor_container_id=f"CONTAINER_ID_{uuid.uuid4().hex[:8]}", + ) + ) diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 4e07449..0000000 --- a/tests/test_api.py +++ /dev/null @@ -1,3 +0,0 @@ -async def test_has_openapi_json(api_client): - response = await api_client.get("/openapi.json") - assert response.status_code == 200 diff --git a/tests/test_entitlements_api.py b/tests/test_entitlements_api.py new file mode 100644 index 0000000..4ed3fa1 --- /dev/null +++ b/tests/test_entitlements_api.py @@ -0,0 +1,85 @@ +from httpx import AsyncClient +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models import Entitlement +from tests.utils import assert_json_contains_model + + +async def test_can_create_entitlements(api_client: AsyncClient, db_session: AsyncSession): + response = await api_client.post( + "/entitlements/", + json={ + "sponsor_name": "AWS", + "sponsor_external_id": "EXTERNAL_ID_987123", + "sponsor_container_id": "SPONSOR_CONTAINER_ID_1234", + }, + ) + + assert response.status_code == 201 + data = response.json() + + assert data["id"] is not None + assert data["activated_at"] is None + assert data["sponsor_name"] == "AWS" + assert data["sponsor_external_id"] == "EXTERNAL_ID_987123" + assert data["sponsor_container_id"] == "SPONSOR_CONTAINER_ID_1234" + + result = await db_session.exec(select(Entitlement).where(Entitlement.id == data["id"])) + assert result.one_or_none() is not None + + +async def test_get_all_entitlements_empty_db(api_client: AsyncClient): + response = await api_client.get("/entitlements/") + + assert response.status_code == 200 + assert response.json()["total"] == 0 + assert response.json()["items"] == [] + + +async def test_get_all_entitlements_single_page( + entitlement_aws, entitlement_gcp, api_client: AsyncClient +): + response = await api_client.get("/entitlements/") + + assert response.status_code == 200 + data = response.json() + + assert data["total"] == 2 + assert len(data["items"]) == data["total"] + + assert_json_contains_model(data, entitlement_aws) + assert_json_contains_model(data, entitlement_gcp) + + +async def test_can_update_entitlements(entitlement_aws, api_client): + assert entitlement_aws.sponsor_name == "AWS" + + update_response = await api_client.patch( + f"/entitlements/{entitlement_aws.id}", + json={"sponsor_name": "GCP"}, + ) + + assert update_response.status_code == 200 + update_data = update_response.json() + + assert update_data["sponsor_name"] == "GCP" + + get_response = await api_client.get(f"/entitlements/{entitlement_aws.id}") + assert get_response.json()["sponsor_name"] == "GCP" + + +async def test_create_entitlement_with_incomplete_data(api_client: AsyncClient): + response = await api_client.post( + "/entitlements/", + json={ + "sponsor_name": "AWS", + "sponsor_external_id": "EXTERNAL_ID_987123", + }, + ) + + assert response.status_code == 422 + [detail] = response.json()["detail"] + + assert detail["type"] == "missing" + assert detail["loc"] == ["body", "sponsor_container_id"] diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..b3fe907 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,20 @@ +from typing import Any + +from app.models import UUIDModel + + +def assert_json_contains_model(json: dict[str, Any], expected_model: UUIDModel) -> None: + assert all("id" in item for item in json["items"]) + + items_by_id = {item["id"]: item for item in json["items"]} + + assert str(expected_model.id) in list(items_by_id.keys()) + + expected_dict = expected_model.model_dump(mode="json") + actual_dict = items_by_id[str(expected_model.id)] + + for key, actual_value in actual_dict.items(): + if key not in expected_dict: + raise AssertionError(f"{expected_model} has no attribute {key}") + + assert expected_dict[key] == actual_value diff --git a/uv.lock b/uv.lock index 5599b97..1057e02 100644 --- a/uv.lock +++ b/uv.lock @@ -5,6 +5,20 @@ resolution-markers = [ "python_full_version >= '3.13'", ] +[[package]] +name = "alembic" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/1e/8cb8900ba1b6360431e46fb7a89922916d3a1b017a8908a7c0499cc7e5f6/alembic-1.14.0.tar.gz", hash = "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b", size = 1916172 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/06/8b505aea3d77021b18dcbd8133aa1418f1a1e37e432a465b14c46b2c0eaa/alembic-1.14.0-py3-none-any.whl", hash = "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25", size = 233482 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -27,6 +41,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, ] +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162 }, + { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025 }, + { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243 }, + { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059 }, + { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596 }, + { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632 }, + { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186 }, + { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064 }, + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373 }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745 }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103 }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471 }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253 }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720 }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404 }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 }, +] + [[package]] name = "bandit" version = "1.8.0" @@ -110,6 +157,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, ] +[[package]] +name = "decorator" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -132,6 +188,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, ] +[[package]] +name = "executing" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/7d45f492c2c4a0e8e0fad57d081a7c8a0286cdd86372b070cca1ec0caa1e/executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab", size = 977485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf", size = 25805 }, +] + [[package]] name = "fastapi" version = "0.115.5" @@ -157,14 +222,17 @@ standard = [ ] [[package]] -name = "fastapi-camelcase" -version = "2.0.0" +name = "fastapi-async-sqlalchemy" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, - { name = "pyhumps" }, + { 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 }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/d3/aa53e6b3447bec5326dfc246b5705eac3d0ada961fc60144f7a482358765/fastapi_camelcase-2.0.0.tar.gz", hash = "sha256:96925a604778b36784a68aeae4ecbbc04936cf4b7f4a09a26ca6292ab2849929", size = 3520 } [[package]] name = "fastapi-cli" @@ -184,6 +252,52 @@ standard = [ { name = "uvicorn", extra = ["standard"] }, ] +[[package]] +name = "fastapi-pagination" +version = "0.12.32" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/b6/b431ddef4bec5011231ac3deb2da3a334207ef110987bc4971ac40f59865/fastapi_pagination-0.12.32.tar.gz", hash = "sha256:b808b5b8af493c51d96ae0091b60532b25688cbca1350f39cb72f10d4d69a6ab", size = 26531 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/23/0d57a0a67e3bf51b5d2d91a52c7bb7421bd8006aea694be01e37e9830901/fastapi_pagination-0.12.32-py3-none-any.whl", hash = "sha256:38e7e72abf252cbebbc1beff9081e4929762756c04959c471b2a5866bb7f0aaf", size = 42816 }, +] + +[[package]] +name = "greenlet" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/ff/df5fede753cc10f6a5be0931204ea30c35fa2f2ea7a35b25bdaf4fe40e46/greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467", size = 186022 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ec/bad1ac26764d26aa1353216fcbfa4670050f66d445448aafa227f8b16e80/greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d", size = 274260 }, + { url = "https://files.pythonhosted.org/packages/66/d4/c8c04958870f482459ab5956c2942c4ec35cac7fe245527f1039837c17a9/greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79", size = 649064 }, + { url = "https://files.pythonhosted.org/packages/51/41/467b12a8c7c1303d20abcca145db2be4e6cd50a951fa30af48b6ec607581/greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa", size = 663420 }, + { url = "https://files.pythonhosted.org/packages/27/8f/2a93cd9b1e7107d5c7b3b7816eeadcac2ebcaf6d6513df9abaf0334777f6/greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441", size = 658035 }, + { url = "https://files.pythonhosted.org/packages/57/5c/7c6f50cb12be092e1dccb2599be5a942c3416dbcfb76efcf54b3f8be4d8d/greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36", size = 660105 }, + { url = "https://files.pythonhosted.org/packages/f1/66/033e58a50fd9ec9df00a8671c74f1f3a320564c6415a4ed82a1c651654ba/greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9", size = 613077 }, + { url = "https://files.pythonhosted.org/packages/19/c5/36384a06f748044d06bdd8776e231fadf92fc896bd12cb1c9f5a1bda9578/greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0", size = 1135975 }, + { url = "https://files.pythonhosted.org/packages/38/f9/c0a0eb61bdf808d23266ecf1d63309f0e1471f284300ce6dac0ae1231881/greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942", size = 1163955 }, + { url = "https://files.pythonhosted.org/packages/43/21/a5d9df1d21514883333fc86584c07c2b49ba7c602e670b174bd73cfc9c7f/greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01", size = 299655 }, + { url = "https://files.pythonhosted.org/packages/f3/57/0db4940cd7bb461365ca8d6fd53e68254c9dbbcc2b452e69d0d41f10a85e/greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1", size = 272990 }, + { url = "https://files.pythonhosted.org/packages/1c/ec/423d113c9f74e5e402e175b157203e9102feeb7088cee844d735b28ef963/greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff", size = 649175 }, + { url = "https://files.pythonhosted.org/packages/a9/46/ddbd2db9ff209186b7b7c621d1432e2f21714adc988703dbdd0e65155c77/greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a", size = 663425 }, + { url = "https://files.pythonhosted.org/packages/bc/f9/9c82d6b2b04aa37e38e74f0c429aece5eeb02bab6e3b98e7db89b23d94c6/greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e", size = 657736 }, + { url = "https://files.pythonhosted.org/packages/d9/42/b87bc2a81e3a62c3de2b0d550bf91a86939442b7ff85abb94eec3fc0e6aa/greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4", size = 660347 }, + { url = "https://files.pythonhosted.org/packages/37/fa/71599c3fd06336cdc3eac52e6871cfebab4d9d70674a9a9e7a482c318e99/greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e", size = 615583 }, + { url = "https://files.pythonhosted.org/packages/4e/96/e9ef85de031703ee7a4483489b40cf307f93c1824a02e903106f2ea315fe/greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1", size = 1133039 }, + { url = "https://files.pythonhosted.org/packages/87/76/b2b6362accd69f2d1889db61a18c94bc743e961e3cab344c2effaa4b4a25/greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c", size = 1160716 }, + { url = "https://files.pythonhosted.org/packages/1f/1b/54336d876186920e185066d8c3024ad55f21d7cc3683c856127ddb7b13ce/greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761", size = 299490 }, + { url = "https://files.pythonhosted.org/packages/5f/17/bea55bf36990e1638a2af5ba10c1640273ef20f627962cf97107f1e5d637/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011", size = 643731 }, + { url = "https://files.pythonhosted.org/packages/78/d2/aa3d2157f9ab742a08e0fd8f77d4699f37c22adfbfeb0c610a186b5f75e0/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13", size = 649304 }, + { url = "https://files.pythonhosted.org/packages/f1/8e/d0aeffe69e53ccff5a28fa86f07ad1d2d2d6537a9506229431a2a02e2f15/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475", size = 646537 }, + { url = "https://files.pythonhosted.org/packages/05/79/e15408220bbb989469c8871062c97c6c9136770657ba779711b90870d867/greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b", size = 642506 }, + { url = "https://files.pythonhosted.org/packages/18/87/470e01a940307796f1d25f8167b551a968540fbe0551c0ebb853cb527dd6/greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822", size = 602753 }, + { url = "https://files.pythonhosted.org/packages/e2/72/576815ba674eddc3c25028238f74d7b8068902b3968cbe456771b166455e/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01", size = 1122731 }, + { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -262,6 +376,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "ipython" +version = "8.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/8b/710af065ab8ed05649afa5bd1e07401637c9ec9fb7cfda9eac7e91e9fbd4/ipython-8.30.0.tar.gz", hash = "sha256:cb0a405a306d2995a5cbb9901894d240784a9f341394c6ba3f4fe8c6eb89ff6e", size = 5592205 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/f3/1332ba2f682b07b304ad34cad2f003adcfeb349486103f4b632335074a7c/ipython-8.30.0-py3-none-any.whl", hash = "sha256:85ec56a7e20f6c38fce7727dcca699ae4ffc85985aa7b23635a8008f918ae321", size = 820765 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + [[package]] name = "jinja2" version = "3.1.4" @@ -274,6 +420,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, ] +[[package]] +name = "mako" +version = "1.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/0b/29bc5a230948bf209d3ed3165006d257e547c02c3c2a96f6286320dfe8dc/mako-1.3.6.tar.gz", hash = "sha256:9ec3a1583713479fae654f83ed9fa8c9a4c16b7bb0daba0e6bbebff50c0d983d", size = 390206 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/22/bc14c6f02e6dccaafb3eba95764c8f096714260c2aa5f76f654fd16a23dd/Mako-1.3.6-py3-none-any.whl", hash = "sha256:a91198468092a2f1a0de86ca92690fb0cfc43ca90ee17e15d93662b4c04b241a", size = 78557 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -324,6 +482,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -338,92 +508,95 @@ name = "mpt-finops-operations" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, - { name = "fastapi-camelcase" }, + { name = "fastapi-async-sqlalchemy" }, + { name = "fastapi-pagination" }, { name = "pycountry" }, { name = "pydantic-extra-types" }, - { name = "pytest-cov" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "sqlmodel" }, ] [package.dev-dependencies] dev = [ { name = "bandit" }, { name = "httpx" }, - { name = "mypy" }, + { name = "ipython" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "typer" }, ] [package.metadata] requires-dist = [ - { name = "fastapi", extras = ["standard"], specifier = ">=0.115.5" }, - { name = "fastapi-camelcase", specifier = ">=2.0.0" }, - { name = "pycountry", specifier = ">=24.6.1" }, - { name = "pydantic-extra-types", specifier = ">=2.10.0" }, - { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "alembic", specifier = "==1.14.*" }, + { 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 = "pycountry", specifier = "==24.6.*" }, + { name = "pydantic-extra-types", specifier = "==2.10.*" }, + { name = "pydantic-settings", specifier = "==2.6.*" }, + { name = "python-dotenv", specifier = "==1.0.*" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.*" }, + { name = "sqlmodel", specifier = "==0.0.*" }, ] [package.metadata.requires-dev] dev = [ - { name = "bandit", specifier = ">=1.8.0" }, - { name = "httpx", specifier = ">=0.27.2" }, - { name = "mypy", specifier = ">=1.13.0" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "pytest-asyncio", specifier = ">=0.24.0" }, - { name = "ruff", specifier = ">=0.8.0" }, - { name = "typer", specifier = ">=0.13.1" }, + { name = "bandit", specifier = ">=1.8.0,<2.0" }, + { name = "httpx", specifier = ">=0.27.2,<1.0" }, + { name = "ipython", specifier = ">=8.30.0,<9.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 = "ruff", specifier = ">=0.8.0,<1.0" }, + { name = "typer", specifier = ">=0.13.1,<1.0" }, ] [[package]] -name = "mypy" -version = "1.13.0" +name = "packaging" +version = "24.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, - { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, - { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, - { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, - { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, - { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, - { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, - { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, - { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, - { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, - { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] [[package]] -name = "mypy-extensions" -version = "1.0.0" +name = "parso" +version = "0.8.4" 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 } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, ] [[package]] -name = "packaging" -version = "24.2" +name = "pbr" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/b2/35/80cf8f6a4f34017a7fe28242dc45161a1baa55c41563c354d8147e8358b2/pbr-6.1.0.tar.gz", hash = "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24", size = 124032 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/1d/44/6a65ecd630393d47ad3e7d5354768cb7f9a10b3a0eb2cd8c6f52b28211ee/pbr-6.1.0-py2.py3-none-any.whl", hash = "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a", size = 108529 }, ] [[package]] -name = "pbr" -version = "6.1.0" +name = "pexpect" +version = "4.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/35/80cf8f6a4f34017a7fe28242dc45161a1baa55c41563c354d8147e8358b2/pbr-6.1.0.tar.gz", hash = "sha256:788183e382e3d1d7707db08978239965e8b9e4e5ed42669bf4758186734d5f24", size = 124032 } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/44/6a65ecd630393d47ad3e7d5354768cb7f9a10b3a0eb2cd8c6f52b28211ee/pbr-6.1.0-py2.py3-none-any.whl", hash = "sha256:a776ae228892d8013649c0aeccbb3d5f99ee15e005a4cbb7e61d55a067b28a2a", size = 108529 }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, ] [[package]] @@ -435,6 +608,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + [[package]] name = "pycountry" version = "24.6.1" @@ -507,21 +710,25 @@ wheels = [ ] [[package]] -name = "pygments" -version = "2.18.0" +name = "pydantic-settings" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 }, ] [[package]] -name = "pyhumps" -version = "3.8.0" +name = "pygments" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/83/fa6f8fb7accb21f39e8f2b6a18f76f6d90626bdb0a5e5448e5cc9b8ab014/pyhumps-3.8.0.tar.gz", hash = "sha256:498026258f7ee1a8e447c2e28526c0bea9407f9a59c03260aee4bd6c04d681a3", size = 9018 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/11/a1938340ecb32d71e47ad4914843775011e6e9da59ba1229f181fef3119e/pyhumps-3.8.0-py3-none-any.whl", hash = "sha256:060e1954d9069f428232a1adda165db0b9d8dfdce1d265d36df7fbff540acfd6", size = 6095 }, + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] [[package]] @@ -664,6 +871,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.13' and platform_machine == 'AMD64') or (python_full_version < '3.13' and platform_machine == 'WIN32') or (python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'ppc64le') or (python_full_version < '3.13' and platform_machine == 'win32') or (python_full_version < '3.13' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/bf/005dc47f0e57556e14512d5542f3f183b94fde46e15ff1588ec58ca89555/SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4", size = 2092378 }, + { url = "https://files.pythonhosted.org/packages/94/65/f109d5720779a08e6e324ec89a744f5f92c48bd8005edc814bf72fbb24e5/SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855", size = 2082778 }, + { url = "https://files.pythonhosted.org/packages/60/f6/d9aa8c49c44f9b8c9b9dada1f12fa78df3d4c42aa2de437164b83ee1123c/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53", size = 3232191 }, + { url = "https://files.pythonhosted.org/packages/8a/ab/81d4514527c068670cb1d7ab62a81a185df53a7c379bd2a5636e83d09ede/SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a", size = 3243044 }, + { url = "https://files.pythonhosted.org/packages/35/b4/f87c014ecf5167dc669199cafdb20a7358ff4b1d49ce3622cc48571f811c/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686", size = 3178511 }, + { url = "https://files.pythonhosted.org/packages/ea/09/badfc9293bc3ccba6ede05e5f2b44a760aa47d84da1fc5a326e963e3d4d9/SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588", size = 3205147 }, + { url = "https://files.pythonhosted.org/packages/c8/60/70e681de02a13c4b27979b7b78da3058c49bacc9858c89ba672e030f03f2/SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e", size = 2062709 }, + { url = "https://files.pythonhosted.org/packages/b7/ed/f6cd9395e41bfe47dd253d74d2dfc3cab34980d4e20c8878cb1117306085/SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5", size = 2088433 }, + { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651 }, + { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132 }, + { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559 }, + { url = "https://files.pythonhosted.org/packages/7b/c5/07f18a897b997f6d6b234fab2bf31dccf66d5d16a79fe329aefc95cd7461/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", size = 3177897 }, + { url = "https://files.pythonhosted.org/packages/b3/cd/e16f3cbefd82b5c40b33732da634ec67a5f33b587744c7ab41699789d492/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", size = 3111289 }, + { url = "https://files.pythonhosted.org/packages/15/85/5b8a3b0bc29c9928aa62b5c91fcc8335f57c1de0a6343873b5f372e3672b/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", size = 3139491 }, + { url = "https://files.pythonhosted.org/packages/a1/95/81babb6089938680dfe2cd3f88cd3fd39cccd1543b7cb603b21ad881bff1/SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", size = 2060439 }, + { url = "https://files.pythonhosted.org/packages/c1/ce/5f7428df55660d6879d0522adc73a3364970b5ef33ec17fa125c5dbcac1d/SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", size = 2084574 }, + { url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787 }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/39/8641040ab0d5e1d8a1c2325ae89a01ae659fc96c61a43d158fb71c9a0bf0/sqlmodel-0.0.22.tar.gz", hash = "sha256:7d37c882a30c43464d143e35e9ecaf945d88035e20117bf5ec2834a23cbe505e", size = 116392 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + [[package]] name = "starlette" version = "0.41.3" @@ -688,6 +956,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/73/d0091d22a65b55e8fb6aca7b3b6713b5a261dd01cec4cfd28ed127ac0cfc/stevedore-5.4.0-py3-none-any.whl", hash = "sha256:b0be3c4748b3ea7b854b265dcb4caa891015e442416422be16f8b31756107857", size = 49534 }, ] +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + [[package]] name = "typer" version = "0.13.1" @@ -792,6 +1069,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/e9/6e1bd83a08d254b0394500a2bb691b7940f09fcd849f400d01491932f641/watchfiles-1.0.0-cp313-none-win_amd64.whl", hash = "sha256:d562a6114ddafb09c33246c6ace7effa71ca4b6a2324a47f4b09b6445ea78941", size = 284809 }, ] +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + [[package]] name = "websockets" version = "14.1"