worked out how to migrate and got the database working

This commit is contained in:
Linus Vogel 2025-12-27 12:15:50 +01:00
parent 39ba464237
commit 5393ea5669
20 changed files with 334 additions and 95 deletions

View File

@ -11,7 +11,7 @@ script_location = %(here)s/pillar_tool/db/migrations
# 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
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens # for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present. # sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator # defaults to the current working directory. for multiple paths, the path separator

View File

@ -1,46 +0,0 @@
# load config so everything else can work
from pillar_tool.util import load_config, config
load_config()
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from starlette.responses import JSONResponse
from pillar_tool.middleware.basicauth_backend import BasicAuthBackend
from pillar_tool.db import run_db_migrations
@asynccontextmanager
async def app_lifespan(app: FastAPI):
run_db_migrations()
yield
def on_auth_error(request: Request, exc: Exception):
response = PlainTextResponse(str(exc), status_code=401)
response.headers["WWW-Authenticate"] = "Basic"
return response
def on_general_error(request: Request, exc: Exception):
print("wtf?")
response = PlainTextResponse(str(exc), status_code=500)
app = FastAPI(lifespan=app_lifespan)
app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend(), on_error=on_auth_error)
app.exception_handler(Exception)(on_general_error)
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/pillar/{host}")
async def pillar(host: str):
return JSONResponse(content=collect_pillar_data(host))

View File

@ -1,26 +1 @@
from os import lockf, F_LOCK
from os.path import dirname, realpath
from alembic.config import Config
from alembic.command import upgrade, check, util
from pillar_tool.util import config
from .models import * from .models import *
cfg = config()
user = cfg.db.user
password = cfg.db.password
host = cfg.db.host
port = cfg.db.port
database = cfg.db.database
alembic_cfg = Config()
alembic_cfg.set_main_option('script_location', f'{dirname(realpath(__file__))}/migrations')
alembic_cfg.set_main_option('sqlalchemy.url', f'postgresql://{user}:{password}@{host}:{port}/{database}')
alembic_cfg.set_main_option('prepend_sys_path', '.')
def run_db_migrations():
with open(realpath(__file__), 'a') as f:
lockf(f.fileno(), F_LOCK, 0)
upgrade(config=alembic_cfg, revision='head')

View File

@ -0,0 +1,4 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

View File

@ -1,9 +1,32 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker
from sqlalchemy.sql.annotation import Annotated
from pillar_tool.util import config from pillar_tool.util import config
from os import lockf, F_LOCK
from os.path import dirname, realpath
from alembic.config import Config
from alembic.command import upgrade, check, util
from pillar_tool.util import config
def run_db_migrations():
cfg = config()
user = cfg.db.user
password = cfg.db.password
host = cfg.db.host
port = cfg.db.port
database = cfg.db.database
alembic_cfg = Config()
alembic_cfg.set_main_option('script_location', f'{dirname(realpath(__file__))}/migrations')
alembic_cfg.set_main_option('sqlalchemy.url', f'postgresql://{user}:{password}@{host}:{port}/{database}')
alembic_cfg.set_main_option('prepend_sys_path', '.')
with open(realpath(__file__), 'a') as f:
lockf(f.fileno(), F_LOCK, 0)
upgrade(config=alembic_cfg, revision='head')
cfg = config() cfg = config()
SessionLocal = sessionmaker( SessionLocal = sessionmaker(
@ -14,20 +37,7 @@ SessionLocal = sessionmaker(
) )
) )
Base = declarative_base()
def get_connection(): def get_connection():
session = SessionLocal() return SessionLocal()
# noinspection PyBroadException
try:
yield session
except:
session.rollback()
session.close()
else:
session.commit()
session.close()
DB = Annotated[Session, get_connection()]

View File

@ -1,4 +1,5 @@
import tomllib import tomllib
from inspect import stack
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
@ -6,7 +7,6 @@ from sqlalchemy import pool
from alembic import context from alembic import context
from pillar_tool.db.database import Base
# 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.
@ -21,6 +21,7 @@ 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
from pillar_tool.db.base_model import Base
target_metadata = Base.metadata target_metadata = Base.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,

View File

@ -0,0 +1,32 @@
"""added email to user
Revision ID: f6c806bab641
Revises: e1f390264396
Create Date: 2025-12-27 11:52:45.372890
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f6c806bab641'
down_revision: Union[str, Sequence[str], None] = 'e1f390264396'
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.add_column('pillar_tool_user', sa.Column('email', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('pillar_tool_user', 'email')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""username is now unique
Revision ID: ae8de58aa10c
Revises: f6c806bab641
Create Date: 2025-12-27 11:58:25.636598
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ae8de58aa10c'
down_revision: Union[str, Sequence[str], None] = 'f6c806bab641'
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_unique_constraint('pillar_tool_user_unique_constraint_username', 'pillar_tool_user', ['username'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('pillar_tool_user_unique_constraint_username', 'pillar_tool_user', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""added unique to permission and role name
Revision ID: 4cc7f4e295f1
Revises: ae8de58aa10c
Create Date: 2025-12-27 11:59:54.246224
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4cc7f4e295f1'
down_revision: Union[str, Sequence[str], None] = 'ae8de58aa10c'
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_unique_constraint('pillar_tool_permission_unique_constraint_name', 'pillar_tool_permission', ['name'])
op.create_unique_constraint('pillar_tool_role_unique_constraint_name', 'pillar_tool_role', ['name'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('pillar_tool_role_unique_constraint_name', 'pillar_tool_role', type_='unique')
op.drop_constraint('pillar_tool_permission_unique_constraint_name', 'pillar_tool_permission', type_='unique')
# ### end Alembic commands ###

View File

@ -1,6 +1,6 @@
from sqlalchemy import Column, String, UUID, ForeignKey from sqlalchemy import Column, String, UUID, ForeignKey, UniqueConstraint
from pillar_tool.db.database import Base from pillar_tool.db.base_model import Base
@ -10,17 +10,30 @@ class User(Base):
username = Column(String, nullable=False) username = Column(String, nullable=False)
pw_hash = Column(String, nullable=False) pw_hash = Column(String, nullable=False)
pw_salt = Column(String, nullable=False) pw_salt = Column(String, nullable=False)
email = Column(String, nullable=True)
__table_args__ = (
UniqueConstraint('username', name='pillar_tool_user_unique_constraint_username'),
)
class Role(Base): class Role(Base):
__tablename__ = 'pillar_tool_role' __tablename__ = 'pillar_tool_role'
id = Column(UUID, primary_key=True) id = Column(UUID, primary_key=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
__table_args__ = (
UniqueConstraint('name', name='pillar_tool_role_unique_constraint_name'),
)
class Permission(Base): class Permission(Base):
__tablename__ = 'pillar_tool_permission' __tablename__ = 'pillar_tool_permission'
id = Column(UUID, primary_key=True) id = Column(UUID, primary_key=True)
name = Column(String, nullable=False) name = Column(String, nullable=False)
__table_args__ = (
UniqueConstraint('name', name='pillar_tool_permission_unique_constraint_name'),
)
class RolePermission(Base): class RolePermission(Base):
__tablename__ = 'pillar_tool_role_permission' __tablename__ = 'pillar_tool_role_permission'
role_id = Column(UUID, ForeignKey("pillar_tool_role.id"), primary_key=True) role_id = Column(UUID, ForeignKey("pillar_tool_role.id"), primary_key=True)

View File

View File

@ -0,0 +1,45 @@
import pwd
import uuid
from sqlalchemy import select, insert
from sqlalchemy.orm import Session
from pillar_tool.db.models.user import User
from Crypto.Hash import SHA3_256
from secrets import token_bytes
def create_user(db: Session, username: str, password: str) -> None:
pw_salt = token_bytes(32).hex()
pw_hash = compute_password_hash(password, pw_salt)
user_id = uuid.uuid4()
db.execute(insert(User).values(id=user_id, username=username, pw_hash=pw_hash, pw_salt=pw_salt))
def compute_password_hash(password: str, salt: str) -> str:
full_salted_password = salt + password
digest = SHA3_256.new(full_salted_password.encode('ascii')).digest()
digest_output = digest.hex()
return digest_output
def verify_user(db: Session, user: str, password: str) -> User | None:
# noinspection PyTypeChecker
selected_users = db.execute(select(User).where(User.username == user)).fetchall()
assert len(selected_users) < 2
if len(selected_users) == 0:
return None
# get the first user from the result
user: User = selected_users[0][0]
pw_hash = compute_password_hash(password, user.pw_salt)
if pw_hash == user.pw_hash:
return user
return None

70
pillar_tool/main.py Normal file
View File

@ -0,0 +1,70 @@
# load config so everything else can work
from pillar_tool.util import load_config, config
load_config()
from starlette.middleware.base import BaseHTTPMiddleware
from pillar_tool.db.database import get_connection
from pillar_tool.db.queries.auth_queries import create_user
from fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import Request
from fastapi.responses import HTMLResponse, PlainTextResponse
from starlette.responses import JSONResponse
from pillar_tool.middleware.basicauth_backend import BasicAuthBackend
from pillar_tool.middleware.db_connection import db_connection_middleware
from pillar_tool.db.database import run_db_migrations
# run any pending migrations
run_db_migrations()
# get a database connection
db = get_connection()
# create default user if it does not exist
# noinspection PyBroadException
try:
create_user(db, "admin", "admin")
except:
pass
# commit and close the db
db.commit()
db.close()
def on_auth_error(request: Request, exc: Exception):
response = PlainTextResponse(str(exc), status_code=401)
response.headers["WWW-Authenticate"] = "Basic"
return response
def on_db_error(request: Request, exc: Exception):
response = PlainTextResponse(str(exc), status_code=500)
return response
def on_general_error(request: Request, exc: Exception):
print("wtf?")
response = PlainTextResponse(str(exc), status_code=500)
return response
app = FastAPI()
app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend(), on_error=on_auth_error)
app.add_middleware(BaseHTTPMiddleware, dispatch=db_connection_middleware)
app.exception_handler(Exception)(on_general_error)
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/pillar/{host}")
async def pillar(req: Request, host: str):
print(req.headers)
#return JSONResponse(content=collect_pillar_data(host))
return JSONResponse({})

View File

@ -1,12 +1,17 @@
import base64 import base64
import binascii import binascii
import hypercorn.logging import hypercorn.logging
from starlette.authentication import AuthenticationBackend, AuthenticationError, AuthCredentials, SimpleUser from starlette.authentication import AuthenticationBackend, AuthenticationError, AuthCredentials, SimpleUser
from pillar_tool.db.queries.auth_queries import verify_user
class BasicAuthBackend(AuthenticationBackend): class BasicAuthBackend(AuthenticationBackend):
async def authenticate(self, conn): async def authenticate(self, conn):
print("test 2")
if "Authorization" not in conn.headers: if "Authorization" not in conn.headers:
raise AuthenticationError('No Authorization Header') raise AuthenticationError('No Authorization Header')
@ -21,7 +26,10 @@ class BasicAuthBackend(AuthenticationBackend):
username, _, password = decoded.partition(":") username, _, password = decoded.partition(":")
if username == 'admin' and password == 'password': user = verify_user(conn.state.db, username, password)
return AuthCredentials(["authenticated"]), SimpleUser('admin')
if user is None:
raise AuthenticationError('Invalid basic auth credentials')
conn.state.user = user
raise AuthenticationError('Invalid basic auth credentials')

View File

@ -0,0 +1,28 @@
from typing import Callable
from starlette.requests import Request
from starlette.responses import Response
from pillar_tool.db.database import get_connection
async def db_connection_middleware(request: Request, call_next: Callable) -> Response:
session = get_connection()
print("test 1")
request.state.db = session
try:
response: Response = await call_next(request)
if 200 <= response.status_code <= 299:
session.commit()
session.close()
else:
session.rollback()
session.close()
except Exception as e:
session.rollback()
session.close()
raise e
return response

View File

@ -9,6 +9,7 @@ dependencies = [
"hypercorn>=0.18.0", "hypercorn>=0.18.0",
"jinja2>=3.1.6", "jinja2>=3.1.6",
"psycopg2>=2.9.11", "psycopg2>=2.9.11",
"pycryptodome>=3.23.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"sqlalchemy>=2.0.45", "sqlalchemy>=2.0.45",
] ]

32
uv.lock generated
View File

@ -242,6 +242,7 @@ dependencies = [
{ name = "hypercorn" }, { name = "hypercorn" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "psycopg2" }, { name = "psycopg2" },
{ name = "pycryptodome" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
] ]
@ -253,6 +254,7 @@ requires-dist = [
{ name = "hypercorn", specifier = ">=0.18.0" }, { name = "hypercorn", specifier = ">=0.18.0" },
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "psycopg2", specifier = ">=2.9.11" }, { name = "psycopg2", specifier = ">=2.9.11" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "sqlalchemy", specifier = ">=2.0.45" }, { name = "sqlalchemy", specifier = ">=2.0.45" },
] ]
@ -276,6 +278,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" },
] ]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"