1
0
Fork 0
forked from sr2/cloud-api

Compare commits

...

6 commits

49 changed files with 974 additions and 572 deletions

View file

@ -1,81 +0,0 @@
"""initial database model
Revision ID: 8fe51426321d
Revises:
Create Date: 2026-04-06 12:36:46.877760
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8fe51426321d'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('contact',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('phonenumber', sa.String(), nullable=True),
sa.Column('vat_number', sa.String(), nullable=True),
sa.Column('street_address', sa.String(), nullable=True),
sa.Column('street_address_line_2', sa.String(), nullable=True),
sa.Column('post_office_box_number', sa.String(), nullable=True),
sa.Column('locality', sa.String(), nullable=True),
sa.Column('country_code', sa.String(), nullable=True),
sa.Column('address_region', sa.String(), nullable=True),
sa.Column('postal_code', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('oidc_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_oidc_id'), 'user', ['oidc_id'], unique=True)
op.create_table('organisation',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('intake_questionnaire', sa.JSON(), nullable=True),
sa.Column('billing_contact_id', sa.Integer(), nullable=True),
sa.Column('security_contact_id', sa.Integer(), nullable=True),
sa.Column('owner_contact_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['billing_contact_id'], ['contact.id'], ),
sa.ForeignKeyConstraint(['owner_contact_id'], ['contact.id'], ),
sa.ForeignKeyConstraint(['security_contact_id'], ['contact.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('orgusers',
sa.Column('org_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('org_id', 'user_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('orgusers')
op.drop_table('organisation')
op.drop_index(op.f('ix_user_oidc_id'), table_name='user')
op.drop_table('user')
op.drop_table('contact')
# ### end Alembic commands ###

View file

@ -1,83 +0,0 @@
"""Init IAM
Revision ID: a147965e644e
Revises: 8fe51426321d
Create Date: 2026-05-22 15:59:36.469374
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a147965e644e'
down_revision: Union[str, Sequence[str], None] = '8fe51426321d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('service',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('api_key', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('api_key'),
sa.UniqueConstraint('name')
)
op.create_table('permission',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('resource', sa.String(), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('service_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['service_id'], ['service.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('group',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('org_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('group_permissions',
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('permission_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['group.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('group_id', 'permission_id')
)
op.create_table('user_groups',
sa.Column('org_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['group.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('org_id', 'user_id', 'group_id')
)
op.add_column('organisation', sa.Column('root_user_id', sa.Integer(), nullable=True))
op.create_unique_constraint("organisation_name_key", 'organisation', ['name'])
op.create_foreign_key("organisation_root_user_fkey", 'organisation', 'user', ['root_user_id'], ['id'])
op.drop_column('orgusers', 'is_admin')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('orgusers', sa.Column('is_admin', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))
op.drop_constraint("organisation_root_user_fkey", 'organisation', type_='foreignkey')
op.drop_constraint("organisation_name_key", 'organisation', type_='unique')
op.drop_column('organisation', 'root_user_id')
op.drop_table('user_groups')
op.drop_table('group_permissions')
op.drop_table('group')
op.drop_table('permission')
op.drop_table('service')
# ### end Alembic commands ###

View file

@ -6,7 +6,7 @@ on:
- main - main
jobs: jobs:
lint_and_test: ruff:
runs-on: docker runs-on: docker
continue-on-error: true continue-on-error: true
container: container:
@ -17,9 +17,40 @@ jobs:
with: with:
submodules: true submodules: true
- run: uv python install # Gets Python version from pyproject.toml - run: uv python install # Gets Python version from pyproject.toml
- run: uv sync - run: uv sync --dev
- run: uv run ruff check - run: uv run ruff check
- run: uv run ruff format - run: uv run ruff format
ty:
runs-on: docker
continue-on-error: true
container:
image: ghcr.io/astral-sh/uv:alpine
steps:
- run: apk add --no-cache nodejs npm git
- uses: actions/checkout@v4
with:
submodules: true
- run: uv python install # Gets Python version from pyproject.toml
- run: uv sync --dev
- run: uv run ty check
- run: uv run ruff format
- run: uv run pytest test
env:
ENVIRONMENT: testing
tests:
runs-on: docker
continue-on-error: true
container:
image: ghcr.io/astral-sh/uv:alpine
steps:
- run: apk add --no-cache nodejs npm git
- uses: actions/checkout@v4
with:
submodules: true
- run: uv python install # Gets Python version from pyproject.toml
- run: uv sync --dev
- run: uv run pytest test - run: uv run pytest test
env: env:
ENVIRONMENT: testing ENVIRONMENT: testing

22
LICENCE Normal file
View file

@ -0,0 +1,22 @@
Copyright 2026 SR2 Communications Limited.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list
of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this
list of conditions and the following disclaimer in the documentation and/or other
materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.

View file

@ -5,7 +5,7 @@
# this is typically a path given in POSIX (e.g. forward slashes) # this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this # format, relative to the token %(here)s which refers to the location of this
# ini file # ini file
script_location = %(here)s/.alembic script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # 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 # Uncomment the line below if you want the files to be prepended with date and time

View file

@ -12,7 +12,7 @@ from src.organisation.models import Organisation, OrgUsers
from src.user.models import User from src.user.models import User
from src.service.models import Service from src.service.models import Service
from src.iam.models import Permission, Group, GroupPermissions, UserGroups from src.iam.models import Permission, Group, GroupPermissions, UserGroups
from src.database import Base from src.models import CustomBase
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # access to the values within the .ini file in use.
@ -27,13 +27,14 @@ if config.config_file_name is not None:
# for 'autogenerate' support # for 'autogenerate' support
# from myapp import mymodel # from myapp import mymodel
# target_metadata = mymodel.Base.metadata # target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata target_metadata = CustomBase.metadata
# other values from the config, defined by the needs of env.py, # other values from the config, defined by the needs of env.py,
# can be acquired: # can be acquired:
# my_important_option = config.get_main_option("my_important_option") # my_important_option = config.get_main_option("my_important_option")
# ... etc. # ... etc.
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode. """Run migrations in 'offline' mode.
@ -68,9 +69,7 @@ def run_migrations_online() -> None:
connectable = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value()) connectable = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( context.configure(connection=connection, target_metadata=target_metadata)
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View file

@ -0,0 +1,97 @@
"""initial database model
Revision ID: 8fe51426321d
Revises:
Create Date: 2026-04-06 12:36:46.877760
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "8fe51426321d"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"contact",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(), nullable=True),
sa.Column("first_name", sa.String(), nullable=True),
sa.Column("last_name", sa.String(), nullable=True),
sa.Column("phonenumber", sa.String(), nullable=True),
sa.Column("vat_number", sa.String(), nullable=True),
sa.Column("street_address", sa.String(), nullable=True),
sa.Column("street_address_line_2", sa.String(), nullable=True),
sa.Column("post_office_box_number", sa.String(), nullable=True),
sa.Column("locality", sa.String(), nullable=True),
sa.Column("country_code", sa.String(), nullable=True),
sa.Column("address_region", sa.String(), nullable=True),
sa.Column("postal_code", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(), nullable=True),
sa.Column("first_name", sa.String(), nullable=True),
sa.Column("last_name", sa.String(), nullable=True),
sa.Column("oidc_id", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_oidc_id"), "user", ["oidc_id"], unique=True)
op.create_table(
"organisation",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("status", sa.String(), nullable=True),
sa.Column("intake_questionnaire", sa.JSON(), nullable=True),
sa.Column("billing_contact_id", sa.Integer(), nullable=True),
sa.Column("security_contact_id", sa.Integer(), nullable=True),
sa.Column("owner_contact_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["billing_contact_id"],
["contact.id"],
),
sa.ForeignKeyConstraint(
["owner_contact_id"],
["contact.id"],
),
sa.ForeignKeyConstraint(
["security_contact_id"],
["contact.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"orgusers",
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"is_admin", sa.Boolean(), server_default=sa.text("false"), nullable=False
),
sa.ForeignKeyConstraint(["org_id"], ["organisation.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("org_id", "user_id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("orgusers")
op.drop_table("organisation")
op.drop_index(op.f("ix_user_oidc_id"), table_name="user")
op.drop_table("user")
op.drop_table("contact")
# ### end Alembic commands ###

View file

@ -0,0 +1,100 @@
"""Init IAM
Revision ID: a147965e644e
Revises: 8fe51426321d
Create Date: 2026-05-22 15:59:36.469374
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a147965e644e"
down_revision: Union[str, Sequence[str], None] = "8fe51426321d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"service",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("api_key", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("api_key"),
sa.UniqueConstraint("name"),
)
op.create_table(
"permission",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("resource", sa.String(), nullable=False),
sa.Column("action", sa.String(), nullable=False),
sa.Column("service_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["service_id"], ["service.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"group",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("org_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["organisation.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"group_permissions",
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("permission_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["group_id"], ["group.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["permission_id"], ["permission.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("group_id", "permission_id"),
)
op.create_table(
"user_groups",
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("group_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["group_id"], ["group.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["org_id"], ["organisation.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("org_id", "user_id", "group_id"),
)
op.add_column("organisation", sa.Column("root_user_id", sa.Integer(), nullable=True))
op.create_unique_constraint("organisation_name_key", "organisation", ["name"])
op.create_foreign_key(
"organisation_root_user_fkey", "organisation", "user", ["root_user_id"], ["id"]
)
op.drop_column("orgusers", "is_admin")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"orgusers",
sa.Column(
"is_admin",
sa.BOOLEAN(),
server_default=sa.text("false"),
autoincrement=False,
nullable=False,
),
)
op.drop_constraint("organisation_root_user_fkey", "organisation", type_="foreignkey")
op.drop_constraint("organisation_name_key", "organisation", type_="unique")
op.drop_column("organisation", "root_user_id")
op.drop_table("user_groups")
op.drop_table("group_permissions")
op.drop_table("group")
op.drop_table("permission")
op.drop_table("service")
# ### end Alembic commands ###

View file

@ -5,6 +5,7 @@ Revises: a147965e644e
Create Date: 2026-05-25 13:09:22.635058 Create Date: 2026-05-25 13:09:22.635058
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@ -12,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '8132c4b88665' revision: str = "8132c4b88665"
down_revision: Union[str, Sequence[str], None] = 'a147965e644e' down_revision: Union[str, Sequence[str], None] = "a147965e644e"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -21,14 +22,16 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('contact', sa.Column('org_id', sa.Integer(), nullable=False)) op.add_column("contact", sa.Column("org_id", sa.Integer(), nullable=False))
op.create_foreign_key(None, 'contact', 'organisation', ['org_id'], ['id'], ondelete='CASCADE') op.create_foreign_key(
None, "contact", "organisation", ["org_id"], ["id"], ondelete="CASCADE"
)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'contact', type_='foreignkey') op.drop_constraint(None, "contact", type_="foreignkey")
op.drop_column('contact', 'org_id') op.drop_column("contact", "org_id")
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -5,6 +5,7 @@ Revises: 8132c4b88665
Create Date: 2026-05-29 16:10:00.320982 Create Date: 2026-05-29 16:10:00.320982
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@ -12,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'd9dc6986fe38' revision: str = "d9dc6986fe38"
down_revision: Union[str, Sequence[str], None] = '8132c4b88665' down_revision: Union[str, Sequence[str], None] = "8132c4b88665"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -21,14 +22,24 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('user_groups_org_id_fkey'), 'user_groups', type_='foreignkey') op.drop_constraint(op.f("user_groups_org_id_fkey"), "user_groups", type_="foreignkey")
op.drop_column('user_groups', 'org_id') op.drop_column("user_groups", "org_id")
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.add_column('user_groups', sa.Column('org_id', sa.INTEGER(), autoincrement=False, nullable=False)) op.add_column(
op.create_foreign_key(op.f('user_groups_org_id_fkey'), 'user_groups', 'organisation', ['org_id'], ['id'], ondelete='CASCADE') "user_groups",
sa.Column("org_id", sa.INTEGER(), autoincrement=False, nullable=False),
)
op.create_foreign_key(
op.f("user_groups_org_id_fkey"),
"user_groups",
"organisation",
["org_id"],
["id"],
ondelete="CASCADE",
)
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -5,6 +5,7 @@ Revises: d9dc6986fe38
Create Date: 2026-06-08 16:00:27.533099 Create Date: 2026-06-08 16:00:27.533099
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@ -12,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'b6c8614ef799' revision: str = "b6c8614ef799"
down_revision: Union[str, Sequence[str], None] = 'd9dc6986fe38' down_revision: Union[str, Sequence[str], None] = "d9dc6986fe38"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -21,12 +22,16 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint('uniq_permission_resource_and_action', 'permission', ['service_id', 'resource', 'action']) op.create_unique_constraint(
"uniq_permission_resource_and_action",
"permission",
["service_id", "resource", "action"],
)
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uniq_permission_resource_and_action', 'permission', type_='unique') op.drop_constraint("uniq_permission_resource_and_action", "permission", type_="unique")
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -5,6 +5,7 @@ Revises: b6c8614ef799
Create Date: 2026-06-15 11:05:16.673658 Create Date: 2026-06-15 11:05:16.673658
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@ -12,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '98e20aae555c' revision: str = "98e20aae555c"
down_revision: Union[str, Sequence[str], None] = 'b6c8614ef799' down_revision: Union[str, Sequence[str], None] = "b6c8614ef799"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -21,14 +22,16 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(op.f('group_name_key'), 'group', type_='unique') op.drop_constraint(op.f("group_name_key"), "group", type_="unique")
op.create_unique_constraint('uniq_group_name_org_id', 'group', ['name', 'org_id']) op.create_unique_constraint("uniq_group_name_org_id", "group", ["name", "org_id"])
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uniq_group_name_org_id', 'group', type_='unique') op.drop_constraint("uniq_group_name_org_id", "group", type_="unique")
op.create_unique_constraint(op.f('group_name_key'), 'group', ['name'], postgresql_nulls_not_distinct=False) op.create_unique_constraint(
op.f("group_name_key"), "group", ["name"], postgresql_nulls_not_distinct=False
)
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -5,6 +5,7 @@ Revises: 98e20aae555c
Create Date: 2026-06-16 13:31:57.427953 Create Date: 2026-06-16 13:31:57.427953
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
@ -12,8 +13,8 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '85edbf9a176c' revision: str = "85edbf9a176c"
down_revision: Union[str, Sequence[str], None] = '98e20aae555c' down_revision: Union[str, Sequence[str], None] = "98e20aae555c"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -21,12 +22,13 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('org_permissions', op.create_table(
sa.Column('org_id', sa.Integer(), nullable=False), "org_permissions",
sa.Column('permission_id', sa.Integer(), nullable=False), sa.Column("org_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'), sa.Column("permission_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(["org_id"], ["organisation.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint('org_id', 'permission_id') sa.ForeignKeyConstraint(["permission_id"], ["permission.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("org_id", "permission_id"),
) )
# ### end Alembic commands ### # ### end Alembic commands ###
@ -34,5 +36,5 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('org_permissions') op.drop_table("org_permissions")
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -0,0 +1,100 @@
"""mapped columns
Revision ID: 869d48618a1c
Revises: 85edbf9a176c
Create Date: 2026-06-22 11:18:34.592199
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '869d48618a1c'
down_revision: Union[str, Sequence[str], None] = '85edbf9a176c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('group', 'org_id',
existing_type=sa.INTEGER(),
nullable=False)
op.alter_column('organisation', 'name',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('organisation', 'status',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('organisation', 'root_user_id',
existing_type=sa.INTEGER(),
nullable=False)
op.drop_constraint(op.f('organisation_name_key'), 'organisation', type_='unique')
op.alter_column('permission', 'service_id',
existing_type=sa.INTEGER(),
nullable=False)
op.alter_column('service', 'name',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('service', 'api_key',
existing_type=sa.VARCHAR(),
nullable=False)
op.drop_constraint(op.f('service_api_key_key'), 'service', type_='unique')
op.alter_column('user', 'email',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('user', 'first_name',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('user', 'last_name',
existing_type=sa.VARCHAR(),
nullable=False)
op.alter_column('user', 'oidc_id',
existing_type=sa.VARCHAR(),
nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'oidc_id',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('user', 'last_name',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('user', 'first_name',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('user', 'email',
existing_type=sa.VARCHAR(),
nullable=True)
op.create_unique_constraint(op.f('service_api_key_key'), 'service', ['api_key'], postgresql_nulls_not_distinct=False)
op.alter_column('service', 'api_key',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('service', 'name',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('permission', 'service_id',
existing_type=sa.INTEGER(),
nullable=True)
op.create_unique_constraint(op.f('organisation_name_key'), 'organisation', ['name'], postgresql_nulls_not_distinct=False)
op.alter_column('organisation', 'root_user_id',
existing_type=sa.INTEGER(),
nullable=True)
op.alter_column('organisation', 'status',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('organisation', 'name',
existing_type=sa.VARCHAR(),
nullable=True)
op.alter_column('group', 'org_id',
existing_type=sa.INTEGER(),
nullable=True)
# ### end Alembic commands ###

View file

@ -7,3 +7,5 @@ DATABASE_NAME="cloud-api"
DATABASE_PORT="5432" DATABASE_PORT="5432"
DATABASE_HOSTNAME="localhost" DATABASE_HOSTNAME="localhost"
DATABASE_CREDENTIALS="user:password" DATABASE_CREDENTIALS="user:password"
LETTERMINT_API_TOKEN=""

View file

@ -1,22 +1,10 @@
[tool.uv]
add-bounds = "major"
exclude-newer = "P2W"
[tool.ruff]
exclude = [
".alembic"
]
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
[project] [project]
name = "cloud-api" name = "cloud-api"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "IAM and accounting microservice for SR2 Cloud"
license = "BSD-2"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.18.4", "alembic>=1.18.4",
"email-validator>=2.3.0", "email-validator>=2.3.0",
@ -26,10 +14,10 @@ dependencies = [
"itsdangerous>=2.2.0", "itsdangerous>=2.2.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"joserfc>=1.6.7", "joserfc>=1.6.7",
"lettermint>=2.0.0,<3.0.0",
"psycopg[binary]>=3.3.4", "psycopg[binary]>=3.3.4",
"pydantic>=2.13.4", "pydantic>=2.13.4",
"pydantic-settings>=2.14.1", "pydantic-settings>=2.14.1",
"pytest>=9.0.3",
"python-dotenv>=1.2.2", "python-dotenv>=1.2.2",
"requests>=2.34.2", "requests>=2.34.2",
"ruff>=0.15.14,<0.16.0", "ruff>=0.15.14,<0.16.0",
@ -38,3 +26,25 @@ dependencies = [
"uvicorn>=0.48.0", "uvicorn>=0.48.0",
"uvloop>=0.22.1 ; sys_platform != 'win32'", "uvloop>=0.22.1 ; sys_platform != 'win32'",
] ]
[tool.ruff]
exclude = ["alembic"]
target-version = "py312"
line-length = 92
[tool.ruff.format]
quote-style = "double"
indent-style = "tab"
[tool.uv]
add-bounds = "major"
exclude-newer = "P2W"
[dependency-groups]
dev = [
"pytest>=9.0.3",
"ty>=0.0.44,<0.0.45",
]
[tool.ty.src]
exclude = ["alembic"]

View file

@ -34,6 +34,33 @@ async def org_query_user_claims(
org_query_user_claims_dependency = Annotated[bool, Depends(org_query_user_claims)] org_query_user_claims_dependency = Annotated[bool, Depends(org_query_user_claims)]
def get_super_admin_list():
return []
def empty_su_list():
return []
def testing_su_list():
return ["admin@test.com"]
su_list_dependency = Annotated[list[str | None], Depends(get_super_admin_list)]
async def user_model_super_admin(
user_model: user_model_claims_dependency, super_admin_emails: su_list_dependency
):
if user_model.email in super_admin_emails:
return user_model
raise ForbiddenException(message="Must be super admin")
super_admin_dependency = Annotated[User, Depends(user_model_super_admin)]
async def org_query_root_claims( async def org_query_root_claims(
user_model: user_model_claims_dependency, user_model: user_model_claims_dependency,
org_model: org_model_query_dependency, org_model: org_model_query_dependency,
@ -54,9 +81,7 @@ async def org_query_root_claims(
raise ForbiddenException(message="Must be the org's root user") raise ForbiddenException(message="Must be the org's root user")
org_model_root_claim_query_dependency = Annotated[ org_model_root_claim_query_dependency = Annotated[Org, Depends(org_query_root_claims)]
type[Org], Depends(org_query_root_claims)
]
async def org_body_root_claims( async def org_body_root_claims(
@ -79,33 +104,4 @@ async def org_body_root_claims(
raise ForbiddenException(message="Must be the org's root user") raise ForbiddenException(message="Must be the org's root user")
org_model_root_claim_body_dependency = Annotated[ org_model_root_claim_body_dependency = Annotated[Org, Depends(org_body_root_claims)]
type[Org], Depends(org_body_root_claims)
]
def get_super_admin_list():
return []
def empty_su_list():
return []
def testing_su_list():
return ["admin@test.com"]
su_list_dependency = Annotated[list[User], Depends(get_super_admin_list)]
async def user_model_super_admin(
user_model: user_model_claims_dependency, super_admin_emails: su_list_dependency
):
if user_model.email in super_admin_emails:
return user_model
raise ForbiddenException(message="Must be super admin")
super_admin_dependency = Annotated[type[User], Depends(user_model_super_admin)]

View file

@ -22,7 +22,7 @@ from src.organisation.exceptions import AwaitingApprovalException
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.exceptions import UnauthorizedException, ForbiddenException from src.exceptions import UnauthorizedException, ForbiddenException
from src.auth.config import auth_settings from src.auth.config import auth_settings
from src.user.service import add_user_to_db from src.user.service import add_user
from src.database import db_dependency from src.database import db_dependency
@ -43,20 +43,17 @@ async def get_current_user(
key_response = requests.get(jwks_uri) key_response = requests.get(jwks_uri)
jwk_keys = KeySet.import_key_set(key_response.json()) jwk_keys = KeySet.import_key_set(key_response.json())
claims_options = {
"exp": {"essential": True},
"iss": {"essential": True, "value": auth_settings.OIDC_ISSUER},
}
token = jwt.decode(oidc_auth_string.replace("Bearer ", ""), jwk_keys) token = jwt.decode(oidc_auth_string.replace("Bearer ", ""), jwk_keys)
claims_requests = jwt.JWTClaimsRegistry(**claims_options) claims_requests = jwt.JWTClaimsRegistry(
exp={"essential": True}, iss={"essential": True, "value": auth_settings.OIDC_ISSUER}
)
try: try:
claims_requests.validate(token.claims) claims_requests.validate(token.claims)
except ExpiredTokenError: except ExpiredTokenError:
raise UnauthorizedException(message="Token is expired") raise UnauthorizedException(message="Token is expired")
db_id = await add_user_to_db(db, token.claims) db_id = await add_user(db, token.claims)
token.claims["db_id"] = db_id token.claims["db_id"] = db_id

View file

@ -24,7 +24,7 @@ class CustomBaseSettings(BaseSettings):
class Config(CustomBaseSettings): class Config(CustomBaseSettings):
APP_VERSION: str = "0.1" APP_VERSION: str = "0.1"
ENVIRONMENT: Environment = Environment.PRODUCTION ENVIRONMENT: Environment = Environment.PRODUCTION
SECRET_KEY: SecretStr = "" SECRET_KEY: SecretStr = SecretStr("")
DISABLE_AUTH: bool = False DISABLE_AUTH: bool = False
CORS_ORIGINS: list[str] = ["*"] CORS_ORIGINS: list[str] = ["*"]
@ -34,7 +34,9 @@ class Config(CustomBaseSettings):
DATABASE_NAME: str = "fastapi-exp" DATABASE_NAME: str = "fastapi-exp"
DATABASE_PORT: str = "5432" DATABASE_PORT: str = "5432"
DATABASE_HOSTNAME: str = "localhost" DATABASE_HOSTNAME: str = "localhost"
DATABASE_CREDENTIALS: SecretStr = ":" DATABASE_CREDENTIALS: SecretStr = SecretStr(":")
LETTERMINT_API_TOKEN: SecretStr = SecretStr("")
settings = Config() settings = Config()
@ -44,9 +46,9 @@ DATABASE_PORT = settings.DATABASE_PORT
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value() DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
# this will support special chars for credentials # this will support special chars for credentials
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str( _DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(DATABASE_CREDENTIALS).split(
DATABASE_CREDENTIALS ":"
).split(":") )
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD)) _QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
SQLALCHEMY_DATABASE_URI = SecretStr( SQLALCHEMY_DATABASE_URI = SecretStr(

View file

@ -13,10 +13,10 @@ class Environment(StrEnum):
Enumeration of environments. Enumeration of environments.
Attributes: Attributes:
LOCAL (str): Application is running locally LOCAL (str): Application is running locally
TESTING (str): Application is running in testing mode TESTING (str): Application is running in testing mode
STAGING (str): Application is running in staging mode (ie not testing) STAGING (str): Application is running in staging mode (ie not testing)
PRODUCTION (str): Application is running in production mode PRODUCTION (str): Application is running in production mode
""" """
LOCAL = auto() LOCAL = auto()

View file

@ -6,29 +6,30 @@ Models:
street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code
""" """
from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import mapped_column, Mapped
from src.database import Base from src.models import CustomBase
class Contact(Base): class Contact(CustomBase):
__tablename__ = "contact" __tablename__ = "contact"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email = Column(String) email: Mapped[str] = mapped_column(default=None, nullable=True)
first_name = Column(String) first_name: Mapped[str] = mapped_column(default=None, nullable=True)
last_name = Column(String) last_name: Mapped[str] = mapped_column(default=None, nullable=True)
phonenumber = Column(String) phonenumber: Mapped[str] = mapped_column(default=None, nullable=True)
vat_number = Column(String, default=None, nullable=True) vat_number: Mapped[str | None] = mapped_column(default=None, nullable=True)
street_address = Column(String) street_address: Mapped[str] = mapped_column(default=None, nullable=True)
street_address_line_2 = Column(String) street_address_line_2: Mapped[str] = mapped_column(default=None, nullable=True)
post_office_box_number = Column(String, default=None, nullable=True) post_office_box_number: Mapped[str | None] = mapped_column(default=None, nullable=True)
locality = Column(String) # Ie City locality: Mapped[str] = mapped_column(default=None, nullable=True) # Ie City
country_code = Column(String) # Eg GB country_code: Mapped[str] = mapped_column(default=None, nullable=True) # Eg GB
address_region = Column(String, default=None, nullable=True) address_region: Mapped[str | None] = mapped_column(default=None, nullable=True)
postal_code = Column(String) postal_code: Mapped[str] = mapped_column(default=None, nullable=True)
org_id = Column( org_id: Mapped[int] = mapped_column(
Integer, ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False
) )

View file

@ -8,7 +8,7 @@ Exports:
from typing import Annotated from typing import Annotated
from sqlalchemy import create_engine, StaticPool from sqlalchemy import create_engine, StaticPool
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from fastapi import Depends from fastapi import Depends
@ -41,7 +41,3 @@ def get_db():
db_dependency = Annotated[Session, Depends(get_db)] db_dependency = Annotated[Session, Depends(get_db)]
class Base(DeclarativeBase):
pass

View file

@ -20,7 +20,7 @@ from src.iam.schemas import GroupIDMixin, PermIDMixin
def get_group_model_query( def get_group_model_query(
db: db_dependency, group_id: Annotated[int, Query(gt=0)] db: db_dependency, group_id: Annotated[int, Query(gt=0)]
) -> type[Group]: ) -> Group:
group_model = db.get(Group, group_id) group_model = db.get(Group, group_id)
if group_model is None: if group_model is None:
raise GroupNotFoundException(group_id) raise GroupNotFoundException(group_id)
@ -28,12 +28,12 @@ def get_group_model_query(
return group_model return group_model
group_model_query_dependency = Annotated[type[Group], Depends(get_group_model_query)] group_model_query_dependency = Annotated[Group, Depends(get_group_model_query)]
def get_group_model_body( def get_group_model_body(
db: db_dependency, request_model: Optional[GroupIDMixin] = None db: db_dependency, request_model: Optional[GroupIDMixin] = None
) -> type[Group]: ) -> Group:
group_id = getattr(request_model, "group_id", None) group_id = getattr(request_model, "group_id", None)
if group_id is None: if group_id is None:
raise GroupNotFoundException() raise GroupNotFoundException()
@ -44,12 +44,12 @@ def get_group_model_body(
return group_model return group_model
group_model_body_dependency = Annotated[type[Group], Depends(get_group_model_body)] group_model_body_dependency = Annotated[Group, Depends(get_group_model_body)]
def get_perm_model_body( def get_perm_model_body(
db: db_dependency, request_model: Optional[PermIDMixin] = None db: db_dependency, request_model: Optional[PermIDMixin] = None
) -> type[Permission]: ) -> Permission:
perm_id = getattr(request_model, "permission_id", None) perm_id = getattr(request_model, "permission_id", None)
if perm_id is None: if perm_id is None:
raise PermNotFoundException raise PermNotFoundException
@ -60,12 +60,12 @@ def get_perm_model_body(
return perm_model return perm_model
perm_model_body_dependency = Annotated[type[Permission], Depends(get_perm_model_body)] perm_model_body_dependency = Annotated[Permission, Depends(get_perm_model_body)]
def get_perm_model_query( def get_perm_model_query(
db: db_dependency, perm_id: Annotated[int, Query(gt=0)] db: db_dependency, perm_id: Annotated[int, Query(gt=0)]
) -> type[Permission]: ) -> Permission:
perm_model = db.get(Permission, perm_id) perm_model = db.get(Permission, perm_id)
if perm_model is None: if perm_model is None:
raise PermNotFoundException(perm_id) raise PermNotFoundException(perm_id)
@ -73,4 +73,4 @@ def get_perm_model_query(
return perm_model return perm_model
perm_model_query_dependency = Annotated[type[Permission], Depends(get_perm_model_query)] perm_model_query_dependency = Annotated[Permission, Depends(get_perm_model_query)]

View file

@ -18,20 +18,20 @@ Models:
- org_id[FK][PK], user_id[FK][PK], group_id[FK][PK] - org_id[FK][PK], user_id[FK][PK], group_id[FK][PK]
""" """
from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.database import Base from src.models import CustomBase
class Permission(Base): class Permission(CustomBase):
__tablename__ = "permission" __tablename__ = "permission"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
resource = Column(String, nullable=False) resource: Mapped[str]
action = Column(String, nullable=False) action: Mapped[str]
service_id = Column(Integer, ForeignKey("service.id", ondelete="CASCADE")) service_id: Mapped[int] = mapped_column(ForeignKey("service.id", ondelete="CASCADE"))
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@ -43,13 +43,11 @@ class Permission(Base):
) )
service_rel = relationship( service_rel = relationship(
"Service", back_populates="permission_rel", foreign_keys="Permission.service_id" "Service",
back_populates="permission_rel",
foreign_keys="Permission.service_id",
) )
@property
def service_name(self):
return self.service_rel.name
group_rel = relationship( group_rel = relationship(
"Group", secondary="group_permissions", back_populates="permission_rel" "Group", secondary="group_permissions", back_populates="permission_rel"
) )
@ -58,13 +56,17 @@ class Permission(Base):
"Organisation", secondary="org_permissions", back_populates="permission_rel" "Organisation", secondary="org_permissions", back_populates="permission_rel"
) )
@property
def service_name(self):
return self.service_rel.name
class Group(Base):
class Group(CustomBase):
__tablename__ = "group" __tablename__ = "group"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name = Column(String, nullable=False) name: Mapped[str]
org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE")) org_id: Mapped[int] = mapped_column(ForeignKey("organisation.id", ondelete="CASCADE"))
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
@ -83,31 +85,31 @@ class Group(Base):
) )
class GroupPermissions(Base): class GroupPermissions(CustomBase):
__tablename__ = "group_permissions" __tablename__ = "group_permissions"
group_id = Column( group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("group.id", ondelete="CASCADE"), primary_key=True ForeignKey("group.id", ondelete="CASCADE"), primary_key=True
) )
permission_id = Column( permission_id: Mapped[int] = mapped_column(
Integer, ForeignKey("permission.id", ondelete="CASCADE"), primary_key=True ForeignKey("permission.id", ondelete="CASCADE"), primary_key=True
) )
class UserGroups(Base): class UserGroups(CustomBase):
__tablename__ = "user_groups" __tablename__ = "user_groups"
user_id = Column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
) )
group_id = Column( group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("group.id", ondelete="CASCADE"), primary_key=True ForeignKey("group.id", ondelete="CASCADE"), primary_key=True
) )
class OrgPermissions(Base): class OrgPermissions(CustomBase):
__tablename__ = "org_permissions" __tablename__ = "org_permissions"
org_id = Column( org_id: Mapped[int] = mapped_column(
Integer, ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True
) )
permission_id = Column( permission_id: Mapped[int] = mapped_column(
Integer, ForeignKey("permission.id", ondelete="CASCADE"), primary_key=True ForeignKey("permission.id", ondelete="CASCADE"), primary_key=True
) )

View file

@ -207,9 +207,7 @@ async def can_act_on_resource(
"content": { "content": {
"application/json": { "application/json": {
"examples": { "examples": {
"db_id": { "db_id": {"summary": "User not found in db when checking claims."},
"summary": "User not found in db when checking claims."
},
"user_model": {"summary": "User model not found in db."}, "user_model": {"summary": "User model not found in db."},
"org_model": {"summary": "Org model not found in db."}, "org_model": {"summary": "Org model not found in db."},
"group_model": {"summary": "Group model not found in db."}, "group_model": {"summary": "Group model not found in db."},
@ -268,9 +266,7 @@ async def get_group_users(
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=IAMPostGroupResponse, response_model=IAMPostGroupResponse,
responses={ responses={
status.HTTP_409_CONFLICT: { status.HTTP_409_CONFLICT: {"description": "Group with this name already exists"},
"description": "Group with this name already exists"
},
}, },
) )
async def create_group( async def create_group(
@ -568,9 +564,7 @@ async def permissions_search(
) )
if not (request_model.resource is None or request_model.resource == ""): if not (request_model.resource is None or request_model.resource == ""):
permission_query = permission_query.filter( permission_query = permission_query.filter(Perm.resource == request_model.resource)
Perm.resource == request_model.resource
)
if not (request_model.action is None or request_model.action == ""): if not (request_model.action is None or request_model.action == ""):
permission_query = permission_query.filter(Perm.action == request_model.action) permission_query = permission_query.filter(Perm.action == request_model.action)
@ -633,9 +627,7 @@ async def invitation(
response_model=IAMPutGroupInvitationAcceptResponse, response_model=IAMPutGroupInvitationAcceptResponse,
responses={ responses={
status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"}, status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"},
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {"description": "Group and organisation do not match"},
"description": "Group and organisation do not match"
},
status.HTTP_409_CONFLICT: {"description": "User is already in the group"}, status.HTTP_409_CONFLICT: {"description": "User is already in the group"},
}, },
) )
@ -647,9 +639,7 @@ async def accept_invitation(
""" """
Accepts an invitation to join an org's group Accepts an invitation to join an org's group
""" """
email_claims = await verify_email_token( email_claims = await verify_email_token(token=request_model.jwt, user_model=user_model)
token=request_model.jwt, user_model=user_model
)
org_model = db.get(Org, email_claims["org_id"]) org_model = db.get(Org, email_claims["org_id"])
if org_model is None: if org_model is None:

View file

@ -1,3 +1,16 @@
""" """
Global database models Global database models
""" """
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, JSON
from sqlalchemy.orm import DeclarativeBase
class CustomBase(DeclarativeBase):
type_annotation_map = {
datetime: DateTime(timezone=True),
dict[str, Any]: JSON,
}

View file

@ -14,12 +14,12 @@ class Status(StrEnum):
Enumeration of organisation statuses. Enumeration of organisation statuses.
Attributes: Attributes:
PARTIAL(str): Organisation has been created but questionnaire hasn't been submitted. PARTIAL(str): Organisation has been created but questionnaire hasn't been submitted.
SUBMITTED (str): Questionnaire submitted but not approved. SUBMITTED (str): Questionnaire submitted but not approved.
REMEDIATION (str): Questionnaire submitted but requires revisions. REMEDIATION (str): Questionnaire submitted but requires revisions.
APPROVED (str): Questionnaire has been approved by an admin. APPROVED (str): Questionnaire has been approved by an admin.
REJECTED (str): Questionnaire has been rejected by an admin. REJECTED (str): Questionnaire has been rejected by an admin.
REMOVED (str): Organisation has been removed. REMOVED (str): Organisation has been removed.
""" """
PARTIAL = auto() PARTIAL = auto()
@ -47,9 +47,9 @@ class ContactType(StrEnum):
Enumeration of organisation contact types. Enumeration of organisation contact types.
Attributes: Attributes:
BILLING(str): Billing contact. BILLING(str): Billing contact.
SECURITY (str): Security contact. SECURITY (str): Security contact.
OWNER (str): Owner contact. OWNER (str): Owner contact.
""" """
BILLING = auto() BILLING = auto()

View file

@ -17,19 +17,17 @@ from src.organisation.models import Organisation as Org
from src.organisation.exceptions import OrgNotFoundException from src.organisation.exceptions import OrgNotFoundException
def get_org_model_query( def get_org_model_query(db: db_dependency, org_id: Annotated[int, Query(gt=0)]) -> Org:
db: db_dependency, org_id: Annotated[int, Query(gt=0)]
) -> type[Org]:
org_model = db.get(Org, org_id) org_model = db.get(Org, org_id)
if org_model is None: if org_model is None:
raise OrgNotFoundException(org_id) raise OrgNotFoundException(org_id)
return org_model return org_model
org_model_query_dependency = Annotated[type[Org], Depends(get_org_model_query)] org_model_query_dependency = Annotated[Org, Depends(get_org_model_query)]
def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> type[Org]: def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> Org:
org_id: Optional[int] = getattr(request_model, "organisation_id", None) org_id: Optional[int] = getattr(request_model, "organisation_id", None)
if org_id is None: if org_id is None:
raise OrgNotFoundException() raise OrgNotFoundException()
@ -41,4 +39,4 @@ def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> type[Org
return org_model return org_model
org_model_body_dependency = Annotated[type[Org], Depends(get_org_model_body)] org_model_body_dependency = Annotated[Org, Depends(get_org_model_body)]

View file

@ -14,37 +14,36 @@ Models:
- OrgUsers: org_id[FK][PK], user_id[FK][PK] - OrgUsers: org_id[FK][PK], user_id[FK][PK]
""" """
from sqlalchemy import Column, Integer, String, ForeignKey, JSON from typing import Any
from sqlalchemy.orm import relationship
from src.database import Base from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column
from src.models import CustomBase
class Organisation(Base): class Organisation(CustomBase):
__tablename__ = "organisation" __tablename__ = "organisation"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name = Column(String, unique=True) name: Mapped[str]
status = Column(String, default="partial") status: Mapped[str] = mapped_column(default="partial")
intake_questionnaire = Column(JSON) intake_questionnaire: Mapped[dict[str, Any] | None]
root_user_id = Column(Integer, ForeignKey("user.id")) root_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
billing_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=True)
billing_contact_id = Column(Integer, ForeignKey("contact.id")) security_contact_id: Mapped[int] = mapped_column(
security_contact_id = Column(Integer, ForeignKey("contact.id")) ForeignKey("contact.id"), nullable=True
owner_contact_id = Column(Integer, ForeignKey("contact.id"))
user_rel = relationship(
"User", secondary="orgusers", back_populates="organisation_rel"
) )
owner_contact_id: Mapped[int] = mapped_column(ForeignKey("contact.id"), nullable=True)
group_rel = relationship("Group", back_populates="org_rel") user_rel = relationship("User", secondary="orgusers", back_populates="organisation_rel")
group_rel = relationship(
"Group", back_populates="org_rel", cascade="all, delete-orphan"
)
root_user_rel = relationship("User", foreign_keys="Organisation.root_user_id") root_user_rel = relationship("User", foreign_keys="Organisation.root_user_id")
@property
def root_user_email(self):
return self.root_user_rel.email if self.root_user_rel else None
billing_contact_rel = relationship( billing_contact_rel = relationship(
"Contact", foreign_keys="Organisation.billing_contact_id" "Contact", foreign_keys="Organisation.billing_contact_id"
) )
@ -59,13 +58,17 @@ class Organisation(Base):
"Permission", secondary="org_permissions", back_populates="org_rel" "Permission", secondary="org_permissions", back_populates="org_rel"
) )
@property
def root_user_email(self) -> str:
return self.root_user_rel.email if self.root_user_rel else ""
class OrgUsers(Base):
class OrgUsers(CustomBase):
__tablename__ = "orgusers" __tablename__ = "orgusers"
org_id = Column( org_id: Mapped[int] = mapped_column(
Integer, ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True
) )
user_id = Column( user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True ForeignKey("user.id", ondelete="CASCADE"), primary_key=True
) )

View file

@ -133,9 +133,7 @@ async def get_org_by_id(
response_model=OrgPostOrgResponse, response_model=OrgPostOrgResponse,
responses={ responses={
status.HTTP_201_CREATED: {"description": "Successfully created organisation."}, status.HTTP_201_CREATED: {"description": "Successfully created organisation."},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
status.HTTP_401_UNAUTHORIZED: { status.HTTP_401_UNAUTHORIZED: {
"description": "User must be logged in with OIDC to create organisation." "description": "User must be logged in with OIDC to create organisation."
}, },
@ -169,6 +167,7 @@ async def create_org(
org_model = Org( org_model = Org(
name=request_model.name, name=request_model.name,
intake_questionnaire=intake_questionnaire.model_dump(mode="json"), intake_questionnaire=intake_questionnaire.model_dump(mode="json"),
root_user_id=user_model.id,
) )
org_model.status = "partial" org_model.status = "partial"
@ -181,13 +180,10 @@ async def create_org(
isinstance(e.orig, UniqueViolation) # Postgres unique violation isinstance(e.orig, UniqueViolation) # Postgres unique violation
or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation or "UNIQUE constraint failed" in str(e.orig) # SQLite unique violation
): ):
raise ConflictException( raise ConflictException(message="Organisation with this name already exists")
message="Organisation with this name already exists"
)
raise raise
# Adds currently logged-in user to org users list and sets them as root_user # Adds currently logged-in user to org users list and sets them as root_user
org_model.user_rel.append(user_model) org_model.user_rel.append(user_model)
org_model.root_user_rel = user_model
background_tasks.add_task( background_tasks.add_task(
assign_defaults, db, org_id=org_model.id, user_id=user_model.id assign_defaults, db, org_id=org_model.id, user_id=user_model.id
@ -214,9 +210,7 @@ async def create_org(
response_model=OrgPatchQuestionnaireResponse, response_model=OrgPatchQuestionnaireResponse,
responses={ responses={
status.HTTP_200_OK: {"description": "Successfully updated questionnaire."}, status.HTTP_200_OK: {"description": "Successfully updated questionnaire."},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be org root user." "description": "Not authorised. Must be org root user."
}, },
@ -234,12 +228,22 @@ async def update_questionnaire(
""" """
org_status = StatusEnum(org_model.status) org_status = StatusEnum(org_model.status)
if not org_status.is_pre_submission: if not org_status.is_pre_submission:
raise ForbiddenException( raise ForbiddenException("Questionnaire may only be modified prior to submission.")
"Questionnaire may only be modified prior to submission." update_data: dict = request_model.intake_questionnaire.model_dump(exclude_none=True)
)
update_data = request_model.intake_questionnaire.model_dump(exclude_none=True)
questionnaire = org_model.intake_questionnaire questionnaire = org_model.intake_questionnaire
questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"]) if questionnaire is None:
questionnaire_questions = QuestionnaireQuestionsVersion0().model_dump()
questionnaire_metadata = QuestionnaireMetadata(version=0, submission_date=None)
questionnaire = Questionnaire(
metadata=questionnaire_metadata,
questions=questionnaire_questions,
).model_dump()
questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"])
else:
questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"])
for key, value in update_data.items(): for key, value in update_data.items():
if hasattr(questions_model, key): if hasattr(questions_model, key):
setattr(questions_model, key, value) setattr(questions_model, key, value)
@ -271,15 +275,9 @@ async def update_questionnaire(
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
response_model=OrgPatchStatusResponse, response_model=OrgPatchStatusResponse,
responses={ responses={
status.HTTP_200_OK: { status.HTTP_200_OK: {"description": "Successfully updated organisation status."},
"description": "Successfully updated organisation status." status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
}, status.HTTP_403_FORBIDDEN: {"description": "Not authorised. Must be super admin."},
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"description": "Invalid data in request."
},
status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be super admin."
},
}, },
) )
async def update_status( async def update_status(
@ -329,15 +327,11 @@ async def get_users(org_model: org_model_root_claim_query_dependency):
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
response_model=OrgPostUserResponse, response_model=OrgPostUserResponse,
responses={ responses={
status.HTTP_200_OK: { status.HTTP_200_OK: {"description": "Successfully added user to the organisation."},
"description": "Successfully added user to the organisation."
},
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be org root user." "description": "Not authorised. Must be org root user."
}, },
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
status.HTTP_409_CONFLICT: { status.HTTP_409_CONFLICT: {
"description": "User is already a member of the organisation." "description": "User is already a member of the organisation."
}, },
@ -378,12 +372,8 @@ async def add_user_to_org(
summary="Delete organisation from the hub.", summary="Delete organisation from the hub.",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
responses={ responses={
status.HTTP_204_NO_CONTENT: { status.HTTP_204_NO_CONTENT: {"description": "Successfully deleted organisation."},
"description": "Successfully deleted organisation." status.HTTP_403_FORBIDDEN: {"description": "Not authorised. Must be super admin."},
},
status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be super admin."
},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {
"description": "Org ID missing or invalid." "description": "Org ID missing or invalid."
}, },
@ -406,9 +396,7 @@ async def delete_organisation_by_id(
summary="Delete organisation from the hub as root user before it has been approved.", summary="Delete organisation from the hub as root user before it has been approved.",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
responses={ responses={
status.HTTP_204_NO_CONTENT: { status.HTTP_204_NO_CONTENT: {"description": "Successfully deleted organisation."},
"description": "Successfully deleted organisation."
},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {
"description": "Unprocessable content.", "description": "Unprocessable content.",
"content": { "content": {
@ -452,9 +440,7 @@ async def delete_organisation_by_id(
"content": { "content": {
"application/json": { "application/json": {
"examples": { "examples": {
"db_id": { "db_id": {"summary": "User not found in db when checking claims."},
"summary": "User not found in db when checking claims."
},
"user_model": {"summary": "User model not found in db."}, "user_model": {"summary": "User model not found in db."},
"org_model": {"summary": "Org model not found in db."}, "org_model": {"summary": "Org model not found in db."},
} }
@ -472,9 +458,7 @@ async def delete_preapproved_organisation_by_id(
""" """
org_status = StatusEnum(org_model.status) org_status = StatusEnum(org_model.status)
if not org_status.is_pre_approval: if not org_status.is_pre_approval:
raise ForbiddenException( raise ForbiddenException(message="Organisation is no longer in pre-approval state.")
message="Organisation is no longer in pre-approval state."
)
db.delete(org_model) db.delete(org_model)
db.commit() db.commit()
@ -487,9 +471,7 @@ async def delete_preapproved_organisation_by_id(
response_model=OrgPatchRootResponse, response_model=OrgPatchRootResponse,
responses={ responses={
status.HTTP_200_OK: {"description": "Successfully updated root user."}, status.HTTP_200_OK: {"description": "Successfully updated root user."},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
status.HTTP_401_UNAUTHORIZED: { status.HTTP_401_UNAUTHORIZED: {
"description": "Not authorised. Must be super admin." "description": "Not authorised. Must be super admin."
}, },
@ -539,9 +521,7 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency):
""" """
return { return {
"organisation": org_model, "organisation": org_model,
"groups": [ "groups": [{"id": group.id, "name": group.name} for group in org_model.group_rel],
{"id": group.id, "name": group.name} for group in org_model.group_rel
],
} }
@ -554,9 +534,7 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency):
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be org root user." "description": "Not authorised. Must be org root user."
}, },
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
}, },
) )
async def remove_user_from_org( async def remove_user_from_org(
@ -581,9 +559,7 @@ async def remove_user_from_org(
response_model=OrgGetContactResponse, response_model=OrgGetContactResponse,
responses={ responses={
status.HTTP_200_OK: {"description": "Successful retrieval of contact."}, status.HTTP_200_OK: {"description": "Successful retrieval of contact."},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be org root user." "description": "Not authorised. Must be org root user."
}, },
@ -626,9 +602,7 @@ async def get_contact(
response_model=OrgPatchContactResponse, response_model=OrgPatchContactResponse,
responses={ responses={
status.HTTP_200_OK: {"description": "Successfully updated contact."}, status.HTTP_200_OK: {"description": "Successfully updated contact."},
status.HTTP_422_UNPROCESSABLE_CONTENT: { status.HTTP_422_UNPROCESSABLE_CONTENT: {"description": "Invalid data in request."},
"description": "Invalid data in request."
},
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"description": "Not authorised. Must be org root user." "description": "Not authorised. Must be org root user."
}, },

View file

@ -3,7 +3,6 @@ Reusable business logic functions for the organisation module
""" """
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import cast
from src.iam.service import assign_default_group from src.iam.service import assign_default_group
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
@ -50,9 +49,6 @@ async def assign_defaults(
print("User not found while adding defaults") print("User not found while adding defaults")
return return
org_model = cast(Org, org_model)
user_model = cast(User, user_model)
await add_default_org_permissions(db, org_model, default_org_permissions) await add_default_org_permissions(db, org_model, default_org_permissions)
await assign_default_group( await assign_default_group(
db=db, db=db,

View file

@ -26,9 +26,7 @@ async def get_service_model_query(
return service_model return service_model
service_model_query_dependency = Annotated[ service_model_query_dependency = Annotated[Service, Depends(get_service_model_query)]
type[Service], Depends(get_service_model_query)
]
async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixin): async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixin):
@ -39,6 +37,4 @@ async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixi
return service_model return service_model
service_model_body_dependency = Annotated[ service_model_body_dependency = Annotated[Service, Depends(get_service_model_body)]
type[Service], Depends(get_service_model_body)
]

View file

@ -6,17 +6,18 @@ Models:
- id[PK], name[U], api_key[U] - id[PK], name[U], api_key[U]
""" """
from sqlalchemy import Column, Integer, String from sqlalchemy.orm import relationship, mapped_column, Mapped
from sqlalchemy.orm import relationship
from src.database import Base from src.models import CustomBase
class Service(Base): class Service(CustomBase):
__tablename__ = "service" __tablename__ = "service"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name = Column(String, unique=True) name: Mapped[str] = mapped_column(unique=True)
api_key = Column(String, unique=True) api_key: Mapped[str]
permission_rel = relationship("Permission", back_populates="service_rel") permission_rel = relationship(
"Permission", back_populates="service_rel", cascade="all, delete-orphan"
)

View file

@ -95,9 +95,7 @@ async def get_all_services(
responses={ responses={
status.HTTP_200_OK: {"description": "Successfully registered a new service"}, status.HTTP_200_OK: {"description": "Successfully registered a new service"},
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"}, status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
status.HTTP_409_CONFLICT: { status.HTTP_409_CONFLICT: {"description": "Service with this name already exists"},
"description": "Service with this name already exists"
},
}, },
) )
async def register_service( async def register_service(
@ -159,9 +157,7 @@ async def regenerate_api_key(
summary="Remove a service.", summary="Remove a service.",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
responses={ responses={
status.HTTP_204_NO_CONTENT: { status.HTTP_204_NO_CONTENT: {"description": "Successfully removed service from db"},
"description": "Successfully removed service from db"
},
status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"}, status.HTTP_401_UNAUTHORIZED: {"description": "Unauthorized"},
}, },
) )

View file

@ -30,7 +30,7 @@ async def get_user_model_claims(claims: claims_dependency, db: db_dependency):
return user_model return user_model
user_model_claims_dependency = Annotated[type[User], Depends(get_user_model_claims)] user_model_claims_dependency = Annotated[User, Depends(get_user_model_claims)]
async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(gt=0)]): async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(gt=0)]):
@ -41,7 +41,7 @@ async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(
return user_model return user_model
user_model_query_dependency = Annotated[type[User], Depends(get_user_model_query)] user_model_query_dependency = Annotated[User, Depends(get_user_model_query)]
async def get_user_model_body(db: db_dependency, request_model: UserIDMixin): async def get_user_model_body(db: db_dependency, request_model: UserIDMixin):
@ -52,4 +52,4 @@ async def get_user_model_body(db: db_dependency, request_model: UserIDMixin):
return user_model return user_model
user_model_body_dependency = Annotated[type[User], Depends(get_user_model_body)] user_model_body_dependency = Annotated[User, Depends(get_user_model_body)]

View file

@ -12,33 +12,30 @@ Models:
from collections import defaultdict from collections import defaultdict
from sqlalchemy import Column, Integer, String from sqlalchemy.orm import relationship, mapped_column, Mapped
from sqlalchemy.orm import relationship
from src.database import Base from src.models import CustomBase
class User(Base): class User(CustomBase):
__tablename__ = "user" __tablename__ = "user"
id = Column(Integer, primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email = Column(String) email: Mapped[str]
first_name = Column(String) first_name: Mapped[str]
last_name = Column(String) last_name: Mapped[str]
oidc_id = Column(String, index=True, unique=True) oidc_id: Mapped[str] = mapped_column(index=True, unique=True)
organisation_rel = relationship( organisation_rel = relationship(
"Organisation", secondary="orgusers", back_populates="user_rel" "Organisation", secondary="orgusers", back_populates="user_rel"
) )
group_rel = relationship("Group", secondary="user_groups", back_populates="user_rel")
@property @property
def organisations(self): def organisations(self):
return [{"name": org.name, "id": org.id} for org in self.organisation_rel] return [{"name": org.name, "id": org.id} for org in self.organisation_rel]
group_rel = relationship(
"Group", secondary="user_groups", back_populates="user_rel"
)
@property @property
def groups(self): def groups(self):
result = defaultdict(list) result = defaultdict(list)

View file

@ -190,9 +190,7 @@ async def accept_invitation(
user_model: user_model_claims_dependency, user_model: user_model_claims_dependency,
request_model: UserPostInvitationAcceptRequest, request_model: UserPostInvitationAcceptRequest,
): ):
email_claims = await verify_email_token( email_claims = await verify_email_token(token=request_model.jwt, user_model=user_model)
token=request_model.jwt, user_model=user_model
)
org_model = db.get(Org, email_claims["org_id"]) org_model = db.get(Org, email_claims["org_id"])
if org_model is None: if org_model is None:

View file

@ -17,7 +17,7 @@ from src.user.schemas import OIDCUser
from src.user.models import User from src.user.models import User
async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int: async def add_user(db: Session, user_claims: dict[str, Any]) -> int:
try: try:
valid_user = OIDCUser( valid_user = OIDCUser(
first_name=user_claims["given_name"], first_name=user_claims["given_name"],
@ -26,7 +26,7 @@ async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int:
oidc_id=user_claims["sub"], oidc_id=user_claims["sub"],
) )
except Exception as e: except Exception as e:
print(e) logging.exception(e)
raise UnprocessableContentException("Invalid or missing OIDC data") raise UnprocessableContentException("Invalid or missing OIDC data")
db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first() db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first()
@ -37,19 +37,12 @@ async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int:
user_id = user_model.id user_id = user_model.id
db.commit() db.commit()
return user_id return user_id
else:
user_id = db_user.id user_id = db_user.id
change = False db_user.first_name = valid_user.first_name
if db_user.first_name != valid_user.first_name: db_user.last_name = valid_user.last_name
db_user.first_name = valid_user.first_name db.commit()
change = True return user_id
if db_user.last_name != valid_user.last_name:
db_user.last_name = valid_user.last_name
change = True
if change:
db.add(db_user)
db.commit()
return user_id
async def send_invitation(user_email: str, org_name: str, org_id: int): async def send_invitation(user_email: str, org_name: str, org_id: int):

View file

@ -1,3 +1,4 @@
from lettermint import Lettermint, ValidationError
from datetime import datetime, timezone from datetime import datetime, timezone
from joserfc import jwt, jwk, errors from joserfc import jwt, jwk, errors
@ -38,6 +39,21 @@ async def verify_email_token(user_model, token):
async def send_email(recipient: str, subject: str, body: str): async def send_email(recipient: str, subject: str, body: str):
print(recipient) lettermint = Lettermint(api_token=settings.LETTERMINT_API_TOKEN.get_secret_value())
print(subject)
print(body) if settings.ENVIRONMENT.is_testing or settings.ENVIRONMENT == "local":
recipient = "ok@testing.lettermint.co"
try:
response = (
lettermint.email.from_("noreply@sr2.uk")
.to(recipient)
.subject(subject)
.text(body)
.send()
)
print(response.status_code)
except ValidationError:
# Error thrown if domain not approved for project
print("Lettermint validation error")

View file

@ -14,16 +14,16 @@ from src.iam.models import Group, Permission, OrgPermissions
from src.auth.service import get_current_user, get_dev_user from src.auth.service import get_current_user, get_dev_user
from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list
from src.main import app # inited FastAPI app from src.main import app # inited FastAPI app
from src.database import engine, Base, get_db from src.database import engine, get_db
from src.models import CustomBase
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@pytest.fixture() @pytest.fixture()
def db_session(): def db_session():
Base.metadata.drop_all(bind=engine) CustomBase.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine) CustomBase.metadata.create_all(bind=engine)
db = SessionLocal() db = SessionLocal()
try: try:
_seed(db) # extracted seeding logic into a plain function _seed(db) # extracted seeding logic into a plain function

View file

@ -165,9 +165,7 @@ async def test_post_user_invitation_auth_approval(no_su_client: AsyncClient):
@pytest.mark.anyio @pytest.mark.anyio
async def test_delete_group_permissions_auth_approval(no_su_client: AsyncClient): async def test_delete_group_permissions_auth_approval(no_su_client: AsyncClient):
resp = await no_su_client.delete( resp = await no_su_client.delete("/iam/group/permission?org_id=3&group_id=1&perm_id=1")
"/iam/group/permission?org_id=3&group_id=1&perm_id=1"
)
assert resp.status_code != 422 assert resp.status_code != 422
assert "has not been approved." in resp.json()["detail"] assert "has not been approved." in resp.json()["detail"]

View file

@ -69,9 +69,7 @@ async def test_post_perm_auth_su(no_su_client: AsyncClient):
@pytest.mark.anyio @pytest.mark.anyio
async def test_post_org_user_auth_su(no_su_client: AsyncClient): async def test_post_org_user_auth_su(no_su_client: AsyncClient):
resp = await no_su_client.post( resp = await no_su_client.post("/org/user", json={"organisation_id": 1, "user_id": 2})
"/org/user", json={"organisation_id": 1, "user_id": 2}
)
assert resp.status_code != 422 assert resp.status_code != 422
assert resp.status_code == 403 assert resp.status_code == 403
assert "Must be super admin" in resp.json()["detail"] assert "Must be super admin" in resp.json()["detail"]

View file

@ -25,9 +25,7 @@ async def test_post_act_on_resource_endpoint_success(default_client: AsyncClient
"Authorization": "Bearer not_checked_when_auth_is_disabled", "Authorization": "Bearer not_checked_when_auth_is_disabled",
"X-API-Key": "123456789", "X-API-Key": "123456789",
} }
resp = await default_client.post( resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
"/iam/can_act_on_resource", json=body, headers=headers
)
data = resp.json() data = resp.json()
assert resp.status_code == 200 assert resp.status_code == 200
@ -56,9 +54,7 @@ async def test_act_on_resource_wrong_key(
"Authorization": "Bearer not_checked_when_auth_is_disabled", "Authorization": "Bearer not_checked_when_auth_is_disabled",
"X-API-Key": api_key, "X-API-Key": api_key,
} }
resp = await default_client.post( resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
"/iam/can_act_on_resource", json=body, headers=headers
)
data = resp.json() data = resp.json()
assert resp.status_code == 401 assert resp.status_code == 401
@ -110,9 +106,7 @@ async def test_act_on_resource_endpoint_status_checks(
"Authorization": "Bearer not_checked_when_auth_is_disabled", "Authorization": "Bearer not_checked_when_auth_is_disabled",
"X-API-Key": "123456789", "X-API-Key": "123456789",
} }
resp = await default_client.post( resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
"/iam/can_act_on_resource", json=body, headers=headers
)
assert resp.status_code == expected_status assert resp.status_code == expected_status
@ -143,9 +137,7 @@ async def test_act_on_resource_logic(
"Authorization": "Bearer not_checked_when_auth_is_disabled", "Authorization": "Bearer not_checked_when_auth_is_disabled",
"X-API-Key": "123456789", "X-API-Key": "123456789",
} }
resp = await default_client.post( resp = await default_client.post("/iam/can_act_on_resource", json=body, headers=headers)
"/iam/can_act_on_resource", json=body, headers=headers
)
data = resp.json() data = resp.json()
assert resp.status_code == 200 assert resp.status_code == 200
@ -414,9 +406,7 @@ async def test_get_permissions_success(default_client: AsyncClient):
assert permission["action"] == "read" assert permission["action"] == "read"
@pytest.mark.parametrize( @pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
"query, expected_status", generate_query_and_status(["org_id"])
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_permissions_status_checks( async def test_get_permissions_status_checks(
default_client: AsyncClient, query: str, expected_status: int default_client: AsyncClient, query: str, expected_status: int

View file

@ -35,9 +35,7 @@ async def test_get_org_success(default_client: AsyncClient):
assert org["security_contact"]["email"] == "security@orgone.com" assert org["security_contact"]["email"] == "security@orgone.com"
@pytest.mark.parametrize( @pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
"query, expected_status", generate_query_and_status(["org_id"])
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_org_status_checks( async def test_get_org_status_checks(
default_client: AsyncClient, query: str, expected_status: int default_client: AsyncClient, query: str, expected_status: int
@ -60,7 +58,6 @@ async def test_post_org_success(default_client: AsyncClient):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"body, expected_status", "body, expected_status",
[ [
({"name": "Org One"}, 409),
({"name": 42}, 422), ({"name": 42}, 422),
({}, 422), ({}, 422),
({"name": "New Test Org", "intake_questionnaire": {"question_one": 42}}, 422), ({"name": "New Test Org", "intake_questionnaire": {"question_one": 42}}, 422),
@ -229,9 +226,7 @@ async def test_get_org_users_success(default_client: AsyncClient):
assert data["organisation"]["id"] == 1 assert data["organisation"]["id"] == 1
@pytest.mark.parametrize( @pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
"query, expected_status", generate_query_and_status(["org_id"])
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_org_users_status_checks( async def test_get_org_users_status_checks(
default_client: AsyncClient, query: str, expected_status: int default_client: AsyncClient, query: str, expected_status: int
@ -243,9 +238,7 @@ async def test_get_org_users_status_checks(
@pytest.mark.anyio @pytest.mark.anyio
async def test_post_org_user_success(default_client: AsyncClient): async def test_post_org_user_success(default_client: AsyncClient):
resp = await default_client.post( resp = await default_client.post("/org/user", json={"organisation_id": 1, "user_id": 3})
"/org/user", json={"organisation_id": 1, "user_id": 3}
)
assert resp.status_code == 200 assert resp.status_code == 200
@ -258,9 +251,7 @@ async def test_post_org_user_success(default_client: AsyncClient):
assert "users" in data assert "users" in data
assert isinstance(data["users"], list) assert isinstance(data["users"], list)
assert ( assert len([user for user in data["users"] if user["email"] == "root@orgtwo.com"]) == 1
len([user for user in data["users"] if user["email"] == "root@orgtwo.com"]) == 1
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -348,9 +339,7 @@ async def test_get_org_groups_success(default_client: AsyncClient):
assert group["name"] == "Org One Group" assert group["name"] == "Org One Group"
@pytest.mark.parametrize( @pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
"query, expected_status", generate_query_and_status(["org_id"])
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_org_groups_status_checks( async def test_get_org_groups_status_checks(
default_client: AsyncClient, query: str, expected_status: int default_client: AsyncClient, query: str, expected_status: int
@ -363,9 +352,7 @@ async def test_get_org_groups_status_checks(
@pytest.mark.parametrize("contact_type", ["billing", "security", "owner"]) @pytest.mark.parametrize("contact_type", ["billing", "security", "owner"])
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_org_contact_success(default_client: AsyncClient, contact_type: str): async def test_get_org_contact_success(default_client: AsyncClient, contact_type: str):
resp = await default_client.get( resp = await default_client.get(f"/org/contact?org_id=1&contact_type={contact_type}")
f"/org/contact?org_id=1&contact_type={contact_type}"
)
data = resp.json() data = resp.json()
assert resp.status_code == 200 assert resp.status_code == 200
@ -437,9 +424,7 @@ async def test_get_org_contact_status_checks(
], ],
) )
@pytest.mark.anyio @pytest.mark.anyio
async def test_patch_org_contact_success( async def test_patch_org_contact_success(default_client: AsyncClient, key: str, value: str):
default_client: AsyncClient, key: str, value: str
):
resp = await default_client.patch( resp = await default_client.patch(
"/org/contact", "/org/contact",
json={"organisation_id": 1, "contact_type": "billing", key: value}, json={"organisation_id": 1, "contact_type": "billing", key: value},

View file

@ -24,9 +24,7 @@ async def test_get_services_success(default_client: AsyncClient):
assert data["services"][0]["name"] == "Test Service" assert data["services"][0]["name"] == "Test Service"
@pytest.mark.parametrize( @pytest.mark.parametrize("query, expected_status", generate_query_and_status(["org_id"]))
"query, expected_status", generate_query_and_status(["org_id"])
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_get_services_status_checks( async def test_get_services_status_checks(
default_client: AsyncClient, query: str, expected_status: int default_client: AsyncClient, query: str, expected_status: int
@ -49,9 +47,7 @@ async def test_post_service_success(default_client: AsyncClient):
assert isinstance(data["service"]["api_key"], str) assert isinstance(data["service"]["api_key"], str)
@pytest.mark.parametrize( @pytest.mark.parametrize("body, expected_status", generate_body_and_status({"name": "str"}))
"body, expected_status", generate_body_and_status({"name": "str"})
)
@pytest.mark.anyio @pytest.mark.anyio
async def test_post_service_status_checks( async def test_post_service_status_checks(
default_client: AsyncClient, body: dict[str, str], expected_status: int default_client: AsyncClient, body: dict[str, str], expected_status: int

View file

@ -46,9 +46,7 @@ async def test_get_user_success(default_client: AsyncClient):
@pytest.mark.anyio @pytest.mark.anyio
@pytest.mark.parametrize( @pytest.mark.parametrize("query, expected_status", generate_query_and_status(["user_id"]))
"query, expected_status", generate_query_and_status(["user_id"])
)
async def test_get_user_status_checks( async def test_get_user_status_checks(
default_client: AsyncClient, query: str, expected_status: int default_client: AsyncClient, query: str, expected_status: int
): ):
@ -184,10 +182,8 @@ async def test_get_self_orgs_dynamic(default_client: AsyncClient):
route = next( route = next(
route route
for route in default_client._transport.app.routes for route in default_client._transport.app.routes # ty:ignore[unresolved-attribute]
if isinstance(route, APIRoute) if isinstance(route, APIRoute) and path in route.path and method in route.methods
and path in route.path
and method in route.methods
) )
assert resp.status_code == route.status_code assert resp.status_code == route.status_code

259
uv.lock generated
View file

@ -1,6 +1,6 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.12"
[options] [options]
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
@ -44,6 +44,7 @@ version = "4.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [ wheels = [
@ -68,6 +69,30 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
@ -98,6 +123,38 @@ version = "3.4.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
@ -158,10 +215,10 @@ dependencies = [
{ name = "itsdangerous" }, { name = "itsdangerous" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "joserfc" }, { name = "joserfc" },
{ name = "lettermint" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "pytest" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "requests" }, { name = "requests" },
{ name = "ruff" }, { name = "ruff" },
@ -171,6 +228,12 @@ dependencies = [
{ name = "uvloop", marker = "sys_platform != 'win32'" }, { name = "uvloop", marker = "sys_platform != 'win32'" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ty" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" }, { name = "alembic", specifier = ">=1.18.4" },
@ -181,10 +244,10 @@ requires-dist = [
{ name = "itsdangerous", specifier = ">=2.2.0" }, { name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "joserfc", specifier = ">=1.6.7" }, { name = "joserfc", specifier = ">=1.6.7" },
{ name = "lettermint", specifier = ">=2.0.0,<3.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
{ name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic", specifier = ">=2.13.4" },
{ name = "pydantic-settings", specifier = ">=2.14.1" }, { name = "pydantic-settings", specifier = ">=2.14.1" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "requests", specifier = ">=2.34.2" }, { name = "requests", specifier = ">=2.34.2" },
{ name = "ruff", specifier = ">=0.15.14,<0.16.0" }, { name = "ruff", specifier = ">=0.15.14,<0.16.0" },
@ -194,6 +257,12 @@ requires-dist = [
{ name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" }, { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.22.1" },
] ]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "ty", specifier = ">=0.0.44,<0.0.45" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@ -300,6 +369,22 @@ version = "3.5.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
@ -361,6 +446,20 @@ version = "0.7.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
@ -436,6 +535,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/e4/bcf6718b5662894c6831f46296b73cd4b1a2e90c20b6d437e20c4997388c/joserfc-1.6.7-py3-none-any.whl", hash = "sha256:9e51e4a64840aa1734a058258e80a4480e2ff2d5686e480e7c92c954a92fbe05", size = 70603, upload-time = "2026-05-23T01:46:42.129Z" }, { url = "https://files.pythonhosted.org/packages/c5/e4/bcf6718b5662894c6831f46296b73cd4b1a2e90c20b6d437e20c4997388c/joserfc-1.6.7-py3-none-any.whl", hash = "sha256:9e51e4a64840aa1734a058258e80a4480e2ff2d5686e480e7c92c954a92fbe05", size = 70603, upload-time = "2026-05-23T01:46:42.129Z" },
] ]
[[package]]
name = "lettermint"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/31/cf1ab1f066e48a40139bf5d90a12d657ffd90440f4f726ca4199f73f0275/lettermint-2.0.0.tar.gz", hash = "sha256:399d17c3a707a2515402245e0785534171c482cf83f9e763a6878aa4bf974e1a", size = 29415, upload-time = "2026-05-11T08:00:50.054Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/b5/13978fc95fdaf977c287ef6a956795717c6d74f74504dc3958ebaf8338f1/lettermint-2.0.0-py3-none-any.whl", hash = "sha256:39859fd9a66ef2c1729889befd3c261b29641e187247c7a0334c421961c88b47", size = 21303, upload-time = "2026-05-11T08:00:48.625Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.12" version = "1.3.12"
@ -454,6 +565,39 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
@ -501,6 +645,7 @@ name = "psycopg"
version = "3.3.4" version = "3.3.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" }
@ -518,6 +663,28 @@ name = "psycopg-binary"
version = "3.3.4" version = "3.3.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" },
{ url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" },
{ url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" },
{ url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" },
{ url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" },
{ url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" },
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" }, { url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" }, { url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" }, { url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
@ -564,6 +731,36 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
@ -594,6 +791,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
] ]
[[package]] [[package]]
@ -694,6 +895,20 @@ dependencies = [
] ]
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" },
{ url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" },
{ url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" },
{ url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
@ -717,12 +932,38 @@ version = "1.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
] ]
[[package]]
name = "ty"
version = "0.0.44"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/f4/fbb120226e4f239652525a664bad976a23fea58c646d1323f2296fee8a61/ty-0.0.44.tar.gz", hash = "sha256:5886229830ab77022842a1c55d2ef57405621a91fc465969fa6d538661898173", size = 5803665, upload-time = "2026-06-05T03:33:48.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/c6/b5b8c4762efb4d85401652658786506867553ecfc2beac3bcf361a15937f/ty-0.0.44-py3-none-linux_armv6l.whl", hash = "sha256:272d31e7ad49b1dc5e8465a9fe700354e14c755b40d9c75f08f031d786903df3", size = 11607267, upload-time = "2026-06-05T03:33:27.154Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5c/f4b405570737f44ab0fc4214117fe43353f8f0825a1823d9e99e9c8e57be/ty-0.0.44-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b92c4ddd7a3daf2049715edec9dc70cf6fd31a5a318ee647258f90dd75495eed", size = 11382826, upload-time = "2026-06-05T03:33:54.374Z" },
{ url = "https://files.pythonhosted.org/packages/9d/aa/fb9835aa492b148d7754cb4c3db07f31a7e2e09f0d8e0e8e297f01125dd2/ty-0.0.44-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d42cfd84a690f6654b2a4f0515027c21b692cf2512d32e6433f754893a95609", size = 10809741, upload-time = "2026-06-05T03:33:33.22Z" },
{ url = "https://files.pythonhosted.org/packages/47/f5/0b20ba6b66837a5a37bab7f74ac0732c66e766b5f0b2d55b30816b15f348/ty-0.0.44-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc47ae87e4cb7db2a9166bb23b78a905c3626e523296ec5bccf36b5e89bda6b", size = 11318153, upload-time = "2026-06-05T03:34:09.403Z" },
{ url = "https://files.pythonhosted.org/packages/ca/bb/b82ea730774a4f950f06d355fbc120d51eac7da23b57fc79ef6ff7c79cbb/ty-0.0.44-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46d867e80f16f421ac72c9a85240dbf050d62d9b3fbd10a8b5b082fb21679e0b", size = 11403108, upload-time = "2026-06-05T03:33:57.745Z" },
{ url = "https://files.pythonhosted.org/packages/8b/41/e2c83856165291049c702eda4e2ef3d3ebd875e8a0a77b8cc4ef3156aa1c/ty-0.0.44-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:411f5de0f96a4e4e5cccc3e0d55954c41f6a99ee6ca1fe5a7226cbc68406e053", size = 11944815, upload-time = "2026-06-05T03:34:15.793Z" },
{ url = "https://files.pythonhosted.org/packages/66/95/1fa6a101eb9d5bec042b87e5ca9c8fc349b75961beca6306f95af5cd5539/ty-0.0.44-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b15f01ecb4e2b46c05a1769293f9d32c3d4a1e4e7dfccf37c604d705dc3e3f4", size = 12476121, upload-time = "2026-06-05T03:33:51.529Z" },
{ url = "https://files.pythonhosted.org/packages/72/6a/da4b45b1229d39207c6140681c2aaf4f5691bcb1dc830b84450ca25c8f57/ty-0.0.44-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd32b7467af509c99c0244c2226a4e4c03400699003ec33373282ab931654d9", size = 12091340, upload-time = "2026-06-05T03:33:36.289Z" },
{ url = "https://files.pythonhosted.org/packages/16/c7/e1c9260ea5188195962ff1214ace418b5d69187e8fa7c0a1ec4994b8071b/ty-0.0.44-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:503a585f4007387c3afc58bae23a7ca1b9f236cbdb1a881dc36110655ceb1937", size = 11986201, upload-time = "2026-06-05T03:34:00.624Z" },
{ url = "https://files.pythonhosted.org/packages/92/f9/312bb112da9b1a7da295bb0426be85e72ad48da4e4266c36d77256b4058d/ty-0.0.44-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d28bcfa83243d77c2316944e8cf197f73597bf17d1ddc047d0b10a762531252", size = 12168475, upload-time = "2026-06-05T03:33:30.386Z" },
{ url = "https://files.pythonhosted.org/packages/02/de/64978d603f6c3e5dd7cb97eca2214567d8ad0c85fa4a7435b7852ae4b779/ty-0.0.44-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:56fd2dd0192def189715b25f5338f6222fb827884dc34111e50aa1c4e061cee5", size = 11292937, upload-time = "2026-06-05T03:34:06.448Z" },
{ url = "https://files.pythonhosted.org/packages/64/63/a625d8a3c71dcaa01988d330f849c465fe72ead4b0bbab44fe4bd6e672b5/ty-0.0.44-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7f8d990489032de1984e73c159f3e760d754cf83a602b874827d943821f63595", size = 11421560, upload-time = "2026-06-05T03:33:23.995Z" },
{ url = "https://files.pythonhosted.org/packages/99/96/61aeba0e629b0c91bd316ff94d00e38817ec493ae4316f39508988daa287/ty-0.0.44-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f61ffe72996a755432922fe90b28db593f572eb5cbf48e3ef4e67b282533d1b0", size = 11580282, upload-time = "2026-06-05T03:34:03.308Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f7/256e1538ce21cab67b381201444c42454de69d310059c4929d92a0ee9c48/ty-0.0.44-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2b237a143bac4f30cec9257d45f01e72da97030a80a09a2b69cfef065f09c37f", size = 12085723, upload-time = "2026-06-05T03:33:45.953Z" },
{ url = "https://files.pythonhosted.org/packages/d3/76/ec3957c10872643a98db7a7895101ad89c5b7cba4bc6c4aebbbfc91756cc/ty-0.0.44-py3-none-win32.whl", hash = "sha256:6a24586c65419223ac5bab4822d49ab493a5d19ea2a897514284c232b9d6166a", size = 10892978, upload-time = "2026-06-05T03:34:12.603Z" },
{ url = "https://files.pythonhosted.org/packages/a5/7d/ba24050432196e7d7f03945e5c379951593c48e04e5c5d5275cfc4624791/ty-0.0.44-py3-none-win_amd64.whl", hash = "sha256:8cccb27e348c89a9733fbad1b2efadfbad79b107c7e52adb52dfd8a70156a38d", size = 11987058, upload-time = "2026-06-05T03:33:42.692Z" },
{ url = "https://files.pythonhosted.org/packages/71/34/16ec3f1fec75292d9c56a8b5fef037ceaba85a5c30562206c1a245a00a67/ty-0.0.44-py3-none-win_arm64.whl", hash = "sha256:58049504e7a12bf1957f24a5384a332c94d5590127083a80db5e5a1bed34190b", size = 11329961, upload-time = "2026-06-05T03:33:39.427Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@ -781,6 +1022,18 @@ version = "0.22.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },