16 Commits

Author SHA1 Message Date
3246081b81 Add pytest-cov 2026-05-18 21:15:12 +02:00
3e86fe223e Explain problem; will fix later maybe 2026-05-18 21:04:17 +02:00
56c8d38cde almost all tests run now 2026-05-18 21:03:47 +02:00
1caffff30d Vscode test suite 2026-05-18 21:03:05 +02:00
46e883200e Fix get_current_user and auth_is_admin creating their own db session instead of getting from get_session 2026-05-16 17:53:42 +02:00
6daf2345be Error handling in following functions 2026-05-16 17:33:06 +02:00
e4b405cdbd Revert "Merge get_user into authenticate_user, it was only doing one db call"
ups, das war ja doch von mehr verwendet...
This reverts commit 0337a90f15.
2026-05-16 17:16:26 +02:00
a820431707 Fix accessToken with custom expiration 2026-05-16 17:12:13 +02:00
5c2e58d5d0 Fix deprecation warning by changing on_event to asynccontesxtmanager 2026-05-16 17:10:40 +02:00
0337a90f15 Merge get_user into authenticate_user, it was only doing one db call 2026-05-16 17:03:33 +02:00
235420bc3e Fix testing harness 2026-05-16 17:02:40 +02:00
aafcdcc6de Needed to load 2026-05-16 16:27:25 +02:00
4e2467c45b Add ai generated tests 2026-05-16 16:27:06 +02:00
436f27ef09 Add test deps 2026-05-16 16:26:47 +02:00
5941f38d2a Secure all endpoints behind auth 2026-05-15 22:22:12 +02:00
cbc2526c14 Squashed commit of the following:
commit a6a5de4a35
Author: ahtlon <git@ahtlon.de>
Date:   Fri May 15 20:47:45 2026 +0200

    Auth working :)

commit 6ad50df3c2
Author: ahtlon <git@ahtlon.de>
Date:   Fri May 8 13:17:32 2026 +0200

    Implement auth
    I basically copied this article https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt

commit 6243618abb
Author: ahtlon <git@ahtlon.de>
Date:   Fri May 8 12:18:56 2026 +0200

    Add auth deps
2026-05-15 22:01:57 +02:00
23 changed files with 1173 additions and 44 deletions

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"test"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

View File

@@ -2,13 +2,13 @@
Status: WIP - not prod ready Status: WIP - not prod ready
Dev with `nix develop` Dev with `nix develop`
sync python deps with `uv sync`
start dev server `uv run fastapi dev` start dev server `uv run fastapi dev`
Swagger UI @ http://127.0.0.1:8000/docs Swagger UI @ http://127.0.0.1:8000/docs
start prod server `uv run fastapi run` start prod server `uv run fastapi run`
Issues: Issues:
- `nix run` currently broken - `nix run` currently broken
- no auth
- no door state - no door state
- no door operations - no door operations
- card system is dummy until I get hardware - card system is dummy until I get hardware

View File

View File

@@ -5,12 +5,13 @@ from typing import List
from ..model.models import * from ..model.models import *
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin
import uuid as gen_uuid import uuid as gen_uuid
aa_router = APIRouter(prefix="/aa", tags=["AccessAuth"]) aa_router = APIRouter(prefix="/aa", tags=["AccessAuth"])
@aa_router.post("/", response_model=AccessAuthorizationResponse) @aa_router.post("/", response_model=AccessAuthorizationResponse)
def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate): def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate, admin: bool = Depends(auth_is_admin)):
print("Creating accessauth with data: ", aa) print("Creating accessauth with data: ", aa)
timetables = [Timetable.model_validate(t) for t in aa.timetables] timetables = [Timetable.model_validate(t) for t in aa.timetables]
db_aa = AccessAuthorizationDB( db_aa = AccessAuthorizationDB(
@@ -21,21 +22,21 @@ def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizatio
return add_and_refresh(db, db_aa) return add_and_refresh(db, db_aa)
@aa_router.get("/", response_model=List[AccessAuthorizationResponse]) @aa_router.get("/", response_model=List[AccessAuthorizationResponse])
def get_all_accessauths(db: Session = Depends(get_session)): def get_all_accessauths(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
return db.exec( return db.exec(
select(AccessAuthorizationDB) select(AccessAuthorizationDB)
.options(selectinload(AccessAuthorizationDB.timetables)) .options(selectinload(AccessAuthorizationDB.timetables))
).all() ).all()
@aa_router.get("/{aa_id}", response_model=AccessAuthorizationResponse) @aa_router.get("/{aa_id}", response_model=AccessAuthorizationResponse)
def get_one_accessauth(*, db: Session = Depends(get_session), aa_id: int): def get_one_accessauth(*, db: Session = Depends(get_session), aa_id: int, admin: bool = Depends(auth_is_admin)):
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AA not found") raise HTTPException(status_code=404, detail="AA not found")
return db_aa return db_aa
@aa_router.put("/assign/{group_id}/{aa_id}", response_model=GroupResponse) @aa_router.put("/assign/{group_id}/{aa_id}", response_model=GroupResponse)
def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_id: int): def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_id: int, admin: bool = Depends(auth_is_admin)):
db_group = db.get(GroupDB, group_id) db_group = db.get(GroupDB, group_id)
if db_group is None: if db_group is None:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
@@ -48,7 +49,7 @@ def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_i
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@aa_router.put("/unassign/{group_id}/{aa_id}", response_model=GroupResponse) @aa_router.put("/unassign/{group_id}/{aa_id}", response_model=GroupResponse)
def unassign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_id: int): def unassign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_id: int, admin: bool = Depends(auth_is_admin)):
db_group = db.get(GroupDB, group_id) db_group = db.get(GroupDB, group_id)
if db_group is None: if db_group is None:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
@@ -61,7 +62,7 @@ def unassign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@aa_router.patch("/{aa_id}", response_model=AccessAuthorizationResponse) @aa_router.patch("/{aa_id}", response_model=AccessAuthorizationResponse)
def change_accessauth(*, db: Session = Depends(get_session), aa_id: int, aa: AccessAuthorizationUpdate): def change_accessauth(*, db: Session = Depends(get_session), aa_id: int, aa: AccessAuthorizationUpdate, admin: bool = Depends(auth_is_admin)):
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AccessAuthorization not found") raise HTTPException(status_code=404, detail="AccessAuthorization not found")
@@ -70,7 +71,7 @@ def change_accessauth(*, db: Session = Depends(get_session), aa_id: int, aa: Acc
return add_and_refresh(db, db_aa) return add_and_refresh(db, db_aa)
@aa_router.delete("/{aa_id}") @aa_router.delete("/{aa_id}")
def delete_accessauth(*, db: Session = Depends(get_session), aa_id: int): def delete_accessauth(*, db: Session = Depends(get_session), aa_id: int, admin: bool = Depends(auth_is_admin)):
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AccessAuthorization not found") raise HTTPException(status_code=404, detail="AccessAuthorization not found")

View File

@@ -4,6 +4,7 @@ from typing import List
from ..model.models import Card from ..model.models import Card
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin
import uuid as gen_uuid import uuid as gen_uuid
card_router = APIRouter(prefix="/cards", tags=["Card"]) card_router = APIRouter(prefix="/cards", tags=["Card"])
@@ -14,12 +15,12 @@ def register_card(group_id: int):
return card return card
@card_router.post("/{group_id}", response_model=Card) @card_router.post("/{group_id}", response_model=Card)
def add_card(*, db: Session = Depends(get_session), group_id: int): def add_card(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)):
card = register_card(group_id) card = register_card(group_id)
return add_and_refresh(db, card) return add_and_refresh(db, card)
@card_router.delete("/{card_id}") @card_router.delete("/{card_id}")
def del_card(*, db: Session = Depends(get_session), card_id: int): def del_card(*, db: Session = Depends(get_session), card_id: int, admin: bool = Depends(auth_is_admin)):
card = db.get(Card, card_id) card = db.get(Card, card_id)
if card is None: if card is None:
raise HTTPException(status_code=404, detail="Card not found") raise HTTPException(status_code=404, detail="Card not found")
@@ -28,7 +29,7 @@ def del_card(*, db: Session = Depends(get_session), card_id: int):
return {"message": "Card deleted successfully"} return {"message": "Card deleted successfully"}
##TBH not a big fan of having creation using group_id but deletion using card_id ##TBH not a big fan of having creation using group_id but deletion using card_id
@card_router.get("/{group_id}", response_model=List[Card]) @card_router.get("/{group_id}", response_model=List[Card])
def get_cards(*, db: Session = Depends(get_session), group_id: int): def get_cards(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)):
cards = db.exec(select(Card).where(Card.group_id == group_id)).all() cards = db.exec(select(Card).where(Card.group_id == group_id)).all()
return cards return cards

View File

@@ -1,24 +1,28 @@
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from ..model.models import GroupDB, GroupResponse, GroupCreate from ..model.models import GroupDB, GroupResponse, GroupCreate
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin
group_router = APIRouter(prefix="/groups", tags=["Group"]) group_router = APIRouter(prefix="/groups", tags=["Group"])
@group_router.get("/", response_model=List[GroupResponse]) @group_router.get("/", response_model=List[GroupResponse])
def get_groups(*, db: Session = Depends(get_session)): def get_groups(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
groups = db.exec(select(GroupDB)).all() groups = db.exec(select(GroupDB)).all()
return groups return groups
@group_router.post("/", response_model=GroupResponse) @group_router.post("/", response_model=GroupResponse)
def create_group(*, db: Session = Depends(get_session), group: GroupCreate): def create_group(*, db: Session = Depends(get_session), group: GroupCreate, admin: bool = Depends(auth_is_admin)):
db_group = GroupDB.model_validate(group) db_group = GroupDB.model_validate(group)
group = db.exec(select(GroupDB).where(GroupDB.name == db_group.name)).first()
if group is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists!")
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@group_router.delete("/{group_id}") @group_router.delete("/{group_id}")
def delete_group(*, db: Session = Depends(get_session), group_id: int): def delete_group(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)):
db_group = db.get(GroupDB, group_id) db_group = db.get(GroupDB, group_id)
if db_group is None: if db_group is None:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")

View File

@@ -4,31 +4,31 @@ from typing import List
from ..model.models import UserResponse, UserCreate, UserDB, UserUpdate from ..model.models import UserResponse, UserCreate, UserDB, UserUpdate
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import get_password_hash, get_current_user from ..services.auth import get_password_hash, get_current_user, auth_is_admin
user_router = APIRouter(tags=["Users"]) user_router = APIRouter(tags=["Users"])
@user_router.post("/users/", response_model=UserResponse) @user_router.post("/users/", response_model=UserResponse)
def create_user(*, db: Session = Depends(get_session), user: UserCreate): def create_user(*, db: Session = Depends(get_session), user: UserCreate, admin: bool = Depends(auth_is_admin)):
print("creating user with data ", user) print("creating user with data ", user)
hashed_password = {"passwordhash": get_password_hash(user.password)} hashed_password = {"passwordhash": get_password_hash(user.password)}
db_user = UserDB.model_validate(user, update=hashed_password) db_user = UserDB.model_validate(user, update=hashed_password)
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)
@user_router.get("/users/", response_model=List[UserResponse]) @user_router.get("/users/", response_model=List[UserResponse])
def read_users(*, db: Session = Depends(get_session)): def read_users(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
users = db.exec(select(UserDB)).all() users = db.exec(select(UserDB)).all()
return users return users
@user_router.get("/users/{user_id}", response_model=UserResponse) @user_router.get("/users/{user_id}", response_model=UserResponse)
def read_user(*, db: Session = Depends(get_session), user_id: int): def read_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return db_user return db_user
@user_router.patch("/users/{user_id}", response_model=UserResponse) @user_router.patch("/users/{user_id}", response_model=UserResponse)
def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserUpdate): def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserUpdate, admin: bool = Depends(auth_is_admin)):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -41,7 +41,7 @@ def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserU
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)
@user_router.delete("/users/{user_id}") @user_router.delete("/users/{user_id}")
def delete_user(*, db: Session = Depends(get_session), user_id: int): def delete_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")

View File

@@ -1,17 +1,21 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from contextlib import asynccontextmanager
from .controllers import userManager, cardManager, groupManager, aaManager from .controllers import userManager, cardManager, groupManager, aaManager
from .services.database import create_db_and_tables from .services.database import create_db_and_tables
from .services.auth import token_router, create_first_user from .services.auth import token_router, create_first_user
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI() @asynccontextmanager
@app.on_event("startup") async def lifespan(app: FastAPI):
def on_startup():
create_db_and_tables() create_db_and_tables()
create_first_user() create_first_user()
print("Database created and tables initialized.") print("Database created and tables initialized.")
yield
app = FastAPI(lifespan=lifespan)
app.include_router(token_router) app.include_router(token_router)

0
app/services/__init__.py Normal file
View File

View File

@@ -28,8 +28,6 @@ def get_password_hash(password):
def get_user(db, username: str): def get_user(db, username: str):
user = db.exec(select(UserDB).where(UserDB.name == username)).first() user = db.exec(select(UserDB).where(UserDB.name == username)).first()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Username not found in get_user, this shouldn't happen")
return user return user
def authenticate_user(db, username: str, password: str): def authenticate_user(db, username: str, password: str):
@@ -45,30 +43,45 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(timezone.utc) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + expires_delta(minutes=15) expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt return encoded_jwt
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): def get_current_user(
with Session(engine) as db: token: Annotated[str, Depends(oauth2_scheme)],
credentials_exception = HTTPException( db: Session = Depends(get_session),
status_code=status.HTTP_401_UNAUTHORIZED, ):
detail="Could not validate credentials", credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(db, username=token_data.username)
if user is None:
raise credentials_exception
return user
def auth_is_admin(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_session),
):
user = get_current_user(token=token, db=db)
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to perform this action",
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"}
) )
try: return True
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(db, username=token_data.username)
if user is None:
raise credentials_exception
return user
def create_first_user(): def create_first_user():
print("Checking for admin user") print("Checking for admin user")

View File

@@ -12,6 +12,9 @@ dependencies = [
"paho-mqtt>=2.1.0", "paho-mqtt>=2.1.0",
"pyjwt[crypto]>=2.12.1", "pyjwt[crypto]>=2.12.1",
"pwdlib[argon2]>=0.3.0", "pwdlib[argon2]>=0.3.0",
"pytest>=9.0.3",
"requests>=2.33.1",
"pytest-cov>=7.1.0",
] ]
[tool.uv.sources] [tool.uv.sources]

0
test/__init__.py Normal file
View File

119
test/conftest.py Normal file
View File

@@ -0,0 +1,119 @@
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, create_engine, SQLModel
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.model.models import UserDB, Card, GroupDB, AccessAuthorizationDB, Timetable, AaGroupLink
from app.services.database import get_session
# Use in-memory SQLite for testing
TEST_SQLALCHEMY_DATABASE_URL = "sqlite://"
engine = create_engine(TEST_SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool)
@pytest.fixture(scope="function")
def db_session():
"""Create a fresh database session for each test."""
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
SQLModel.metadata.drop_all(engine)
@pytest.fixture(scope="function")
def client(db_session):
"""Create a test client with a database session override."""
def override_get_session():
yield db_session
app.dependency_overrides[get_session] = override_get_session
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest.fixture
def admin_user(db_session):
"""Create an admin user for testing."""
from app.services.auth import get_password_hash
admin = UserDB(
name="admin",
passwordhash=get_password_hash("admin123"),
is_admin=True
)
db_session.add(admin)
db_session.commit()
db_session.refresh(admin)
return admin
@pytest.fixture
def regular_user(db_session):
"""Create a regular user for testing."""
from app.services.auth import get_password_hash
user = UserDB(
name="user",
passwordhash=get_password_hash("user123"),
is_admin=False
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
@pytest.fixture
def auth_headers(client, admin_user):
"""Get authentication headers for admin user."""
response = client.post(
"/token",
data={"username": admin_user.name, "password": "admin123"}
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def user_auth_headers(client, regular_user):
"""Get authentication headers for regular user."""
response = client.post(
"/token",
data={"username": regular_user.name, "password": "user123"}
)
token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def test_group(db_session):
"""Create a test group."""
group = GroupDB(name="Test Group")
db_session.add(group)
db_session.commit()
db_session.refresh(group)
return group
@pytest.fixture
def test_card(db_session, test_group):
"""Create a test card."""
card = Card(uuid="test-uuid-123", group_id=test_group.id)
db_session.add(card)
db_session.commit()
db_session.refresh(card)
return card
@pytest.fixture
def test_aa(db_session):
"""Create a test access authorization."""
aa = AccessAuthorizationDB(
name="Test AA",
is_active=True
)
db_session.add(aa)
db_session.commit()
db_session.refresh(aa)
return aa

24
test/test_main.py Normal file
View File

@@ -0,0 +1,24 @@
def test_app_startup(client):
"""Test that the application starts up correctly."""
response = client.get("/")
# Application should respond (even if it's a 404)
assert response.status_code in [404, 200]
def test_health_check(client):
"""Test basic health check endpoint if it exists."""
# Note: This would require adding a health check endpoint
pass
def test_router_includes():
"""Test that all routers are included in the app."""
from app.main import app
routes = [route.path for route in app.routes]
# Check that router prefixes are present
assert any("/users" in route for route in routes)
assert any("/cards" in route for route in routes)
assert any("/groups" in route for route in routes)
assert any("/aa" in route for route in routes)
assert any("/token" in route for route in routes)

94
test/test_models.py Normal file
View File

@@ -0,0 +1,94 @@
import pytest
from app.model.models import (
UserBase, UserResponse, UserCreate, UserDB, UserUpdate,
GroupBase, GroupCreate, GroupDB, GroupResponse,
AccessAuthorizationBase, AccessAuthorizationCreate,
AccessAuthorizationDB, AccessAuthorizationResponse, AccessAuthorizationUpdate,
Card, Timetable, TimetableCreate, Token, TokenData, AaGroupLink
)
def test_user_models():
"""Test user model creation and validation."""
# Test UserBase
user_base = UserBase(name="Test User", email="test@example.com", is_admin=False)
assert user_base.name == "Test User"
assert user_base.email == "test@example.com"
assert user_base.is_admin is False
# Test UserCreate
user_create = UserCreate(name="New User", email="new@example.com", password="secret123")
assert user_create.password == "secret123"
# Test UserUpdate
user_update = UserUpdate(name="Updated Name")
assert user_update.name == "Updated Name"
assert user_update.email is None
def test_group_models():
"""Test group model creation and validation."""
# Test GroupBase
group_base = GroupBase(name="Test Group")
assert group_base.name == "Test Group"
# Test GroupCreate
group_create = GroupCreate(name="New Group")
assert group_create.name == "New Group"
def test_access_authorization_models():
"""Test access authorization model creation and validation."""
# Test AccessAuthorizationBase
aa_base = AccessAuthorizationBase(name="Test AA", is_active=True)
assert aa_base.name == "Test AA"
assert aa_base.is_active is True
# Test AccessAuthorizationCreate with timetables
timetable_create = TimetableCreate(weekday=1, starttime="08:00", duration=60)
aa_create = AccessAuthorizationCreate(
name="New AA",
is_active=False,
timetables=[timetable_create]
)
assert aa_create.name == "New AA"
assert aa_create.is_active is False
assert len(aa_create.timetables) == 1
def test_card_model():
"""Test card model creation and validation."""
card = Card(uuid="test-uuid", group_id=1)
assert card.uuid == "test-uuid"
assert card.group_id == 1
def test_timetable_models():
"""Test timetable model creation and validation."""
# Test TimetableBase with valid values
timetable = TimetableCreate(weekday=1, starttime="09:00", duration=120)
assert timetable.weekday == 1
assert timetable.starttime == "09:00"
assert timetable.duration == 120
# Test boundary values
max_duration = TimetableCreate(weekday=7, starttime="23:59", duration=1439)
assert max_duration.duration == 1439
assert max_duration.weekday == 7
def test_token_models():
"""Test token model creation and validation."""
token = Token(access_token="test-token", token_type="bearer")
assert token.access_token == "test-token"
assert token.token_type == "bearer"
token_data = TokenData(username="testuser")
assert token_data.username == "testuser"
def test_aa_group_link_model():
"""Test many-to-many relationship link model."""
link = AaGroupLink(group_id=1, accessauth_id=2)
assert link.group_id == 1
assert link.accessauth_id == 2

View File

View File

@@ -0,0 +1,192 @@
import pytest
from fastapi import status
def test_create_access_auth(client, auth_headers):
"""Test creating a new access authorization."""
aa_data = {
"name": "New AA",
"is_active": True,
"timetables": [
{"weekday": 1, "starttime": "08:00", "duration": 60},
{"weekday": 2, "starttime": "09:00", "duration": 90}
]
}
response = client.post("/aa/", json=aa_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["name"] == "New AA"
assert data["is_active"] is True
assert "id" in data
assert len(data["timetables"]) == 2
def test_get_all_access_auths(client, auth_headers, test_aa):
"""Test retrieving all access authorizations."""
response = client.get("/aa/", headers=auth_headers)
assert response.status_code == 200
aa_list = response.json()
assert len(aa_list) >= 1
aa_names = [aa["name"] for aa in aa_list]
assert test_aa.name in aa_names
def test_get_access_auth_by_id(client, auth_headers, test_aa):
"""Test retrieving a specific access authorization by ID."""
response = client.get(f"/aa/{test_aa.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_aa.id
assert data["name"] == test_aa.name
def test_get_nonexistent_access_auth(client, auth_headers):
"""Test retrieving a non-existent access authorization."""
response = client.get("/aa/99999", headers=auth_headers)
assert response.status_code == 404
def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa):
"""Test assigning an access authorization to a group."""
response = client.put(
f"/aa/assign/{test_group.id}/{test_aa.id}",
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["id"] == test_group.id
# The AA should now be in the group's accessauths
# Note: The response model might not include the full relationship
def test_assign_already_assigned_access_auth(client, auth_headers, test_group, test_aa):
"""Test assigning an already assigned access authorization."""
# First assignment
client.put(f"/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers)
# Second assignment should indicate it's already assigned
response = client.put(
f"/aa/assign/{test_group.id}/{test_aa.id}",
headers=auth_headers
)
# According to the code, this returns 200 with "already assigned" message
assert response.status_code == 200
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa):
"""Test unassigning an access authorization from a group."""
# First assign
client.put(f"/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers)
# Then unassign
response = client.put(
f"/aa/unassign/{test_group.id}/{test_aa.id}",
headers=auth_headers
)
assert response.status_code == 200
def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa):
"""Test unassigning a non-existent assignment."""
response = client.put(
f"/aa/unassign/{test_group.id}/{test_aa.id}",
headers=auth_headers
)
# According to the code, this returns 200 with "not assigned" message
assert response.status_code == 200
def test_assign_to_nonexistent_group(client, auth_headers, test_aa):
"""Test assigning an AA to a non-existent group."""
response = client.put(f"/aa/assign/99999/{test_aa.id}", headers=auth_headers)
assert response.status_code == 404
def test_assign_nonexistent_aa(client, auth_headers, test_group):
"""Test assigning a non-existent AA to a group."""
response = client.put(f"/aa/assign/{test_group.id}/99999", headers=auth_headers)
assert response.status_code == 404
def test_update_access_auth(client, auth_headers, test_aa):
"""Test updating an access authorization."""
update_data = {
"name": "Updated AA",
"is_active": False
}
response = client.patch(
f"/aa/{test_aa.id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated AA"
assert data["is_active"] is False
def test_update_access_auth_with_timetables(client, auth_headers, test_aa):
"""Test updating an access authorization with new timetables."""
update_data = {
"timetables": [
{"weekday": 5, "starttime": "10:00", "duration": 120}
]
}
response = client.patch(
f"/aa/{test_aa.id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
def test_update_nonexistent_access_auth(client, auth_headers):
"""Test updating a non-existent access authorization."""
update_data = {"name": "Updated"}
response = client.patch("/aa/99999", json=update_data, headers=auth_headers)
assert response.status_code == 404
def test_delete_access_auth(client, auth_headers, test_aa):
"""Test deleting an access authorization."""
response = client.delete(f"/aa/{test_aa.id}", headers=auth_headers)
assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower()
# Verify AA is deleted
response = client.get(f"/aa/{test_aa.id}", headers=auth_headers)
assert response.status_code == 404
def test_delete_nonexistent_access_auth(client, auth_headers):
"""Test deleting a non-existent access authorization."""
response = client.delete("/aa/99999", headers=auth_headers)
assert response.status_code == 404
def test_aa_operations_by_non_admin(client, test_aa, user_auth_headers):
"""Test that non-admin users cannot perform AA operations."""
# Try to create an AA
response = client.post(
"/aa/",
json={"name": "test", "is_active": True, "timetables": []},
headers=user_auth_headers
)
assert response.status_code == 403
# Try to get all AAs
response = client.get("/aa/", headers=user_auth_headers)
assert response.status_code == 403
# Try to assign AA
response = client.put(f"/aa/assign/1/{test_aa.id}", headers=user_auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,196 @@
import pytest
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from app.services.auth import (
verify_password, get_password_hash, get_user, authenticate_user,
create_access_token, get_current_user, auth_is_admin, create_first_user
)
from app.model.models import UserDB
from jwt.exceptions import InvalidTokenError
def test_password_hashing():
"""Test password hashing and verification."""
password = "test_password_123"
# Hash password
hashed = get_password_hash(password)
assert hashed != password
assert len(hashed) > 0
# Verify correct password
assert verify_password(password, hashed) is True
# Verify incorrect password
assert verify_password("wrong_password", hashed) is False
def test_get_user(db_session):
"""Test get_user function."""
from app.services.auth import get_password_hash
# Create a user
user = UserDB(name="testuser", passwordhash=get_password_hash("password"))
db_session.add(user)
db_session.commit()
# Get existing user
retrieved_user = get_user(db_session, "testuser")
assert retrieved_user is not None
assert retrieved_user.name == "testuser"
# Try to get non-existent user
retrieved_user = get_user(db_session, "nonexistent")
assert retrieved_user is None
def test_authenticate_user(db_session):
"""Test user authentication."""
from app.services.auth import get_password_hash
# Create a user
user = UserDB(name="authuser", passwordhash=get_password_hash("correctpass"))
db_session.add(user)
db_session.commit()
# Authenticate with correct credentials
authenticated = authenticate_user(db_session, "authuser", "correctpass")
assert authenticated is not False
assert authenticated.name == "authuser"
# Authenticate with wrong password
authenticated = authenticate_user(db_session, "authuser", "wrongpass")
assert authenticated is False
# Authenticate non-existent user
authenticated = authenticate_user(db_session, "nonexistent", "password")
assert authenticated is False
def test_create_access_token():
"""Test JWT token creation."""
data = {"sub": "testuser"}
# Create token with default expiration
token = create_access_token(data)
assert isinstance(token, str)
assert len(token) > 0
# Create token with custom expiration
custom_expire = timedelta(hours=1)
token = create_access_token(data, expires_delta=custom_expire)
assert isinstance(token, str)
def test_get_current_user(db_session, admin_user):
"""Test getting current user from token."""
from app.services.auth import create_access_token, get_current_user
# Create token for admin user
token = create_access_token(data={"sub": admin_user.name})
# Get user from token
user = get_current_user(token=token, db=db_session)
assert user is not None
assert user.name == admin_user.name
assert user.id == admin_user.id
# Test invalid token
with pytest.raises(HTTPException) as exc_info:
get_current_user(token="invalid_token")
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
# Test expired token (create token with past expiration)
past_expire = timedelta(minutes=-100)
expired_token = create_access_token(data={"sub": admin_user.name}, expires_delta=past_expire)
with pytest.raises(HTTPException) as exc_info:
get_current_user(token=expired_token)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
def test_auth_is_admin(db_session, admin_user, regular_user):
"""Test admin authorization check."""
from app.services.auth import create_access_token, auth_is_admin
# Create token for admin user
admin_token = create_access_token(data={"sub": admin_user.name})
# Admin should pass
result = auth_is_admin(token=admin_token, db=db_session)
assert result is True
# Create token for regular user
user_token = create_access_token(data={"sub": regular_user.name})
# Regular user should fail
with pytest.raises(HTTPException) as exc_info:
auth_is_admin(token=user_token, db=db_session)
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
def test_create_first_user(db_session):
"""Test automatic creation of first admin user."""
#Currently broken because this uses the prod db because of how i wrote the create_first_user function
# Clear any existing users
from sqlmodel import select
db_session.exec(select(UserDB)).all()
for user in db_session.exec(select(UserDB)).all():
db_session.delete(user)
db_session.commit()
# Create first user
result = create_first_user()
assert result is not None
assert result.name == "admin"
assert result.is_admin is True
# Verify user exists in database
user = db_session.exec(select(UserDB).where(UserDB.name == "admin")).first()
assert user is not None
assert user.is_admin is True
# Test that it doesn't create another admin if one exists
second_result = create_first_user()
assert second_result is None # Should print "Admin user already exists"
def test_token_endpoint(client, admin_user):
"""Test the token endpoint for login."""
# Test successful login
response = client.post(
"/token",
data={"username": admin_user.name, "password": "admin123"}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# Test failed login with wrong password
response = client.post(
"/token",
data={"username": admin_user.name, "password": "wrongpassword"}
)
assert response.status_code == 401
# Test failed login with non-existent user
response = client.post(
"/token",
data={"username": "nonexistent", "password": "password"}
)
assert response.status_code == 401
def test_test_login_endpoint(client, admin_user, auth_headers):
"""Test the test login endpoint."""
# Test with valid token
response = client.get("/test/login", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["name"] == admin_user.name
assert data["is_admin"] is True
# Test without token
response = client.get("/test/login")
assert response.status_code == 401

View File

@@ -0,0 +1,66 @@
import pytest
from fastapi import status
def test_add_card(client, auth_headers, test_group):
"""Test adding a card to a group."""
response = client.post(f"/cards/{test_group.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "uuid" in data
assert data["group_id"] == test_group.id
assert len(data["uuid"]) > 0 # UUID should be generated
def test_add_card_to_nonexistent_group(client, auth_headers):
"""Test adding a card to a non-existent group."""
response = client.post("/cards/99999", headers=auth_headers)
# This might succeed and create a card with a non-existent group_id
# or fail depending on foreign key constraints
# For now, let's assume it might fail
# assert response.status_code == 404
def test_delete_card(client, auth_headers, test_card):
"""Test deleting a card."""
response = client.delete(f"/cards/{test_card.id}", headers=auth_headers)
assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower()
def test_delete_nonexistent_card(client, auth_headers):
"""Test deleting a non-existent card."""
response = client.delete("/cards/99999", headers=auth_headers)
assert response.status_code == 404
def test_get_cards_for_group(client, auth_headers, test_group, test_card):
"""Test getting all cards for a group."""
response = client.get(f"/cards/{test_group.id}", headers=auth_headers)
assert response.status_code == 200
cards = response.json()
assert len(cards) >= 1
assert any(card["id"] == test_card.id for card in cards)
def test_get_cards_for_nonexistent_group(client, auth_headers):
"""Test getting cards for a non-existent group."""
response = client.get("/cards/99999", headers=auth_headers)
assert response.status_code == 200
cards = response.json()
assert len(cards) == 0 # Empty list for non-existent group
def test_card_operations_by_non_admin(client, test_group, user_auth_headers):
"""Test that non-admin users cannot perform card operations."""
# Try to add a card
response = client.post(f"/cards/{test_group.id}", headers=user_auth_headers)
assert response.status_code == 403
# Try to get cards
response = client.get(f"/cards/{test_group.id}", headers=user_auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,64 @@
import pytest
from sqlmodel import Session, select
from app.services.database import create_db_and_tables, get_session, add_and_refresh
from app.model.models import UserDB, GroupDB, Card
def test_create_db_and_tables():
"""Test database and tables creation."""
# This is primarily an integration test
from sqlalchemy import inspect
from app.services.database import engine
create_db_and_tables()
inspector = inspect(engine)
# Check that tables exist
tables = inspector.get_table_names()
assert "userdb" in tables
assert "groupdb" in tables
assert "card" in tables
assert "accessauthorizationdb" in tables
assert "timetable" in tables
assert "aagrouplink" in tables
def test_get_session(db_session):
"""Test database session generator."""
# Test that we can get a session
session_gen = get_session()
session = next(session_gen)
assert isinstance(session, Session)
# Test that session works
user = UserDB(name="Test", passwordhash="hash")
session.add(user)
session.commit()
retrieved_user = session.get(UserDB, user.id)
assert retrieved_user is not None
assert retrieved_user.name == "Test"
# Clean up generator
try:
next(session_gen)
except StopIteration:
pass
def test_add_and_refresh(db_session):
"""Test add_and_refresh helper function."""
user = UserDB(name="Test User", passwordhash="hashed")
# Add user
result = add_and_refresh(db_session, user)
# Assert that user is now in database with ID
assert result.id is not None
assert result.name == "Test User"
# Verify in database
db_user = db_session.get(UserDB, result.id)
assert db_user is not None
assert db_user.name == "Test User"

View File

@@ -0,0 +1,68 @@
import pytest
from fastapi import status
def test_create_group(client, auth_headers):
"""Test creating a new group."""
group_data = {"name": "New Test Group"}
response = client.post("/groups/", json=group_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["name"] == "New Test Group"
assert "id" in data
def test_create_duplicate_group(client, auth_headers, test_group):
"""Test creating a group with a duplicate name."""
group_data = {"name": test_group.name}
response = client.post("/groups/", json=group_data, headers=auth_headers)
# This should fail due to unique constraint
assert response.status_code == 409 # Validation error
def test_get_groups(client, auth_headers, test_group):
"""Test retrieving all groups."""
response = client.get("/groups/", headers=auth_headers)
assert response.status_code == 200
groups = response.json()
assert len(groups) >= 1
group_names = [group["name"] for group in groups]
assert test_group.name in group_names
def test_delete_group(client, auth_headers, test_group):
"""Test deleting a group."""
response = client.delete(f"/groups/{test_group.id}", headers=auth_headers)
assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower()
# Verify group is deleted
response = client.get("/groups/", headers=auth_headers)
groups = response.json()
assert not any(group["id"] == test_group.id for group in groups)
def test_delete_nonexistent_group(client, auth_headers):
"""Test deleting a non-existent group."""
response = client.delete("/groups/99999", headers=auth_headers)
assert response.status_code == 404
def test_group_operations_by_non_admin(client, user_auth_headers):
"""Test that non-admin users cannot perform group operations."""
# Try to create a group
response = client.post(
"/groups/",
json={"name": "test"},
headers=user_auth_headers
)
assert response.status_code == 403
# Try to get groups
response = client.get("/groups/", headers=user_auth_headers)
assert response.status_code == 403

View File

@@ -0,0 +1,150 @@
import pytest
from fastapi import status
def test_create_user(client, auth_headers):
"""Test creating a new user."""
user_data = {
"name": "newuser",
"email": "newuser@example.com",
"is_admin": False,
"password": "newpassword123"
}
response = client.post("/users/", json=user_data, headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["name"] == "newuser"
assert data["email"] == "newuser@example.com"
assert data["is_admin"] is False
assert "id" in data
assert "passwordhash" not in data # Password hash should not be in response
def test_create_user_unauthorized(client):
"""Test creating a user without admin credentials."""
user_data = {
"name": "unauthorized_user",
"email": "unauthorized@example.com",
"password": "password123"
}
response = client.post("/users/", json=user_data)
assert response.status_code == 401
def test_get_users(client, auth_headers, admin_user, regular_user):
"""Test retrieving all users."""
response = client.get("/users/", headers=auth_headers)
assert response.status_code == 200
users = response.json()
assert len(users) >= 2 # At least admin_user and regular_user
user_names = [user["name"] for user in users]
assert admin_user.name in user_names
assert regular_user.name in user_names
def test_get_user_by_id(client, auth_headers, regular_user):
"""Test retrieving a specific user by ID."""
response = client.get(f"/users/{regular_user.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["id"] == regular_user.id
assert data["name"] == regular_user.name
def test_get_nonexistent_user(client, auth_headers):
"""Test retrieving a non-existent user."""
response = client.get("/users/99999", headers=auth_headers)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_update_user(client, auth_headers, regular_user):
"""Test updating a user."""
update_data = {
"name": "updated_name",
"email": "updated@example.com"
}
response = client.patch(
f"/users/{regular_user.id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated_name"
assert data["email"] == "updated@example.com"
# Unchanged fields should remain the same
assert data["is_admin"] == regular_user.is_admin
def test_update_user_password(client, auth_headers, regular_user):
"""Test updating a user's password."""
update_data = {
"password": "new_password_456"
}
response = client.patch(
f"/users/{regular_user.id}",
json=update_data,
headers=auth_headers
)
assert response.status_code == 200
# Verify password can be used for login
login_response = client.post(
"/token",
data={"username": regular_user.name, "password": "new_password_456"}
)
assert login_response.status_code == 200
def test_update_nonexistent_user(client, auth_headers):
"""Test updating a non-existent user."""
update_data = {"name": "updated"}
response = client.patch("/users/99999", json=update_data, headers=auth_headers)
assert response.status_code == 404
def test_delete_user(client, auth_headers, regular_user):
"""Test deleting a user."""
response = client.delete(f"/users/{regular_user.id}", headers=auth_headers)
assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower()
# Verify user is deleted
response = client.get(f"/users/{regular_user.id}", headers=auth_headers)
assert response.status_code == 404
def test_delete_nonexistent_user(client, auth_headers):
"""Test deleting a non-existent user."""
response = client.delete("/users/99999", headers=auth_headers)
assert response.status_code == 404
def test_user_operations_by_non_admin(client, user_auth_headers):
"""Test that non-admin users cannot perform admin operations."""
# Try to create a user
response = client.post(
"/users/",
json={"name": "test", "password": "pass"},
headers=user_auth_headers
)
assert response.status_code == 403
# Try to get users
response = client.get("/users/", headers=user_auth_headers)
assert response.status_code == 403
# Try to delete the admin user (if ID is known)
# This would require knowing the admin user ID
# response = client.delete(f"/users/{admin_id}", headers=user_auth_headers)
# assert response.status_code == 403

123
uv.lock generated
View File

@@ -256,6 +256,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "coverage"
version = "7.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
]
[[package]] [[package]]
name = "crashtest" name = "crashtest"
version = "0.4.1" version = "0.4.1"
@@ -544,7 +613,10 @@ dependencies = [
{ name = "poetry" }, { name = "poetry" },
{ name = "pwdlib", extra = ["argon2"] }, { name = "pwdlib", extra = ["argon2"] },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "python-desfire" }, { name = "python-desfire" },
{ name = "requests" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
] ]
@@ -555,7 +627,10 @@ requires-dist = [
{ name = "poetry", specifier = ">=2.3.4" }, { name = "poetry", specifier = ">=2.3.4" },
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "python-desfire", git = "https://github.com/waza-ari/python-desfire" }, { name = "python-desfire", git = "https://github.com/waza-ari/python-desfire" },
{ name = "requests", specifier = ">=2.33.1" },
{ name = "sqlmodel", specifier = ">=0.0.38" }, { name = "sqlmodel", specifier = ">=0.0.38" },
] ]
@@ -658,6 +733,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]] [[package]]
name = "installer" name = "installer"
version = "0.7.0" version = "0.7.0"
@@ -908,6 +992,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
] ]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]] [[package]]
name = "poetry" name = "poetry"
version = "2.3.4" version = "2.3.4"
@@ -1137,6 +1230,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
] ]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]] [[package]]
name = "python-desfire" name = "python-desfire"
version = "0.1.6" version = "0.1.6"