Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b98e65a41
|
@@ -1,33 +0,0 @@
|
||||
name: Pull-Request Vibe Check
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened ]
|
||||
|
||||
jobs:
|
||||
ci-test:
|
||||
runs-on: node-current
|
||||
steps:
|
||||
- name: Setup PRBot
|
||||
uses: imgurbot12/prbot@v2
|
||||
with:
|
||||
username: ${{ secrets.prbot_username }}
|
||||
api_token: ${{ secrets.prbot_token }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up UV
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- run: |
|
||||
apt update
|
||||
apt install libpcsclite-dev python3-dev -y
|
||||
|
||||
- name: Install deps
|
||||
run: uv sync --locked --all-extras --dev
|
||||
|
||||
- name: Run ruff check
|
||||
run: uv run ruff check --output-format=github | sed 's|/workspace/malobeo/gatekeeper/||' | prbot
|
||||
continue-on-error: true
|
||||
|
||||
- name: Post PR Feedback
|
||||
if: always()
|
||||
run: prbot commit
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Test python action
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: node-current
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up UV
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- run: |
|
||||
apt update
|
||||
apt install libpcsclite-dev python3-dev -y
|
||||
|
||||
- name: Install deps
|
||||
run: uv sync --locked --all-extras --dev
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest test
|
||||
+1
-1
@@ -86,7 +86,7 @@ path_separator = os
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
# sqlalchemy.url = sqlite:///./gatekeeper.db
|
||||
sqlalchemy.url = sqlite:///./gatekeeper.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
+2
-8
@@ -5,8 +5,6 @@ from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.services.settings import settings
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
@@ -24,10 +22,6 @@ from app.model import models
|
||||
|
||||
target_metadata = models.Base.metadata
|
||||
|
||||
|
||||
def _database_url() -> str:
|
||||
return config.get_main_option("sqlalchemy.url") or settings.sqlalchemy_database_url
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
@@ -46,7 +40,7 @@ def run_migrations_offline() -> None:
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = _database_url()
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
@@ -66,7 +60,7 @@ def run_migrations_online() -> None:
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
{"sqlalchemy.url": _database_url()},
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""remove timetable access authorizations
|
||||
|
||||
Revision ID: 93e8dda267e5
|
||||
Revises: 4a40fa23e086
|
||||
Create Date: 2026-11-05 15:12:41.908153
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers used by Alembic.
|
||||
revision: str = '93e8dda267e5'
|
||||
down_revision: Union[str, Sequence[str], None] = '4a40fa23e086'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Drop the timetable table and the authorizations that used it.
|
||||
|
||||
create_db_and_tables() only bootstraps an empty database, so existing
|
||||
deployments keep the timetable table and their type='timetable' rows until
|
||||
this migration is applied with `alembic upgrade head`. Such rows can no
|
||||
longer be read: AccessAuthorizationDB has no timetables relationship
|
||||
anymore, door.checkAccess() ignores the type, and AccessAuthorizationResponse
|
||||
rejects the value, which turns every GET on them into a ResponseValidationError.
|
||||
"""
|
||||
# Delete the authorizations before the table so the FK ordering stays clean.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"DELETE FROM accessauthorizationdb WHERE type = 'timetable'"
|
||||
)
|
||||
)
|
||||
op.drop_table('timetable')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Recreate the timetable table.
|
||||
|
||||
The deleted type='timetable' authorizations and their timetable rows are
|
||||
gone for good; a downgrade only restores the schema.
|
||||
"""
|
||||
op.create_table(
|
||||
'timetable',
|
||||
sa.Column('weekday', sa.INTEGER(), nullable=False),
|
||||
sa.Column('starttime', sa.TIME(), nullable=False),
|
||||
sa.Column('duration', sa.INTEGER(), nullable=False),
|
||||
sa.Column('id', sa.INTEGER(), nullable=False),
|
||||
sa.Column('accessauth_id', sa.INTEGER(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(
|
||||
['accessauth_id'], ['accessauthorizationdb.id'],
|
||||
),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.model.models import (
|
||||
@@ -11,6 +12,7 @@ from app.model.models import (
|
||||
GroupDB,
|
||||
GroupResponse,
|
||||
OneShotAccess,
|
||||
Timetable,
|
||||
)
|
||||
from app.services.auth import auth_is_admin
|
||||
from app.services.database import add_and_refresh, get_session
|
||||
@@ -28,6 +30,10 @@ def add_accessauth(
|
||||
admin: bool = Depends(auth_is_admin),
|
||||
):
|
||||
logger.info(f"Creating accessauth with data: {aa}")
|
||||
if aa.timetables != []:
|
||||
timetables = [Timetable.model_validate(t) for t in aa.timetables]
|
||||
else:
|
||||
timetables = []
|
||||
if aa.oneshot is not None:
|
||||
oneshot = OneShotAccess.model_validate(aa.oneshot)
|
||||
else:
|
||||
@@ -36,6 +42,7 @@ def add_accessauth(
|
||||
name=aa.name,
|
||||
type=aa.type,
|
||||
is_active=aa.is_active,
|
||||
timetables=timetables,
|
||||
oneshot=oneshot,
|
||||
)
|
||||
return add_and_refresh(db, db_aa)
|
||||
@@ -45,7 +52,11 @@ def add_accessauth(
|
||||
def get_all_accessauths(
|
||||
db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
|
||||
):
|
||||
return db.exec(select(AccessAuthorizationDB)).all()
|
||||
return db.exec(
|
||||
select(AccessAuthorizationDB).options(
|
||||
selectinload(AccessAuthorizationDB.timetables)
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
@aa_router.get("/{aa_id}", response_model=AccessAuthorizationResponse)
|
||||
@@ -115,6 +126,11 @@ def change_accessauth(
|
||||
if db_aa is None:
|
||||
raise HTTPException(status_code=404, detail="AccessAuthorization not found")
|
||||
aa_data = aa.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if "timetables" in aa_data and aa_data["timetables"] is not None:
|
||||
db_aa.timetables.clear()
|
||||
timetables = [Timetable.model_validate(t) for t in aa_data["timetables"]]
|
||||
db_aa.timetables = timetables
|
||||
aa_data.pop("timetables")
|
||||
if "oneshot" in aa_data and aa_data["oneshot"] is not None:
|
||||
oneshot = OneShotAccess.model_validate(aa_data["oneshot"])
|
||||
db_aa.oneshot = oneshot
|
||||
|
||||
@@ -42,10 +42,8 @@ def add_card(
|
||||
db.exec(select(Card).where(Card.name == cardInput.name)).one_or_none()
|
||||
is None
|
||||
)
|
||||
except AssertionError as err:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, detail="Name already used!"
|
||||
) from err
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!")
|
||||
try:
|
||||
assert (
|
||||
db.exec(
|
||||
@@ -53,10 +51,8 @@ def add_card(
|
||||
).one_or_none()
|
||||
is not None
|
||||
)
|
||||
except AssertionError as err:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail="GroupID not found!"
|
||||
) from err
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!")
|
||||
card = register_card(cardInput)
|
||||
return add_and_refresh(db, card)
|
||||
|
||||
@@ -69,12 +65,12 @@ def del_card(
|
||||
logger.info(key)
|
||||
try:
|
||||
card = db.exec(select(Card).where(Card.key == key)).one()
|
||||
except NoResultFound as err:
|
||||
except NoResultFound:
|
||||
logger.info(f"The key:'{key}' was not found in db!")
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Key on card not found in DB. KEY={key}",
|
||||
) from err
|
||||
detail="Key on card not found in DB. Please tell an admin about this. KEY={key}", # noqa: E501
|
||||
)
|
||||
db.delete(card)
|
||||
db.commit()
|
||||
return {"message": "Card deleted successfully"}
|
||||
@@ -110,9 +106,7 @@ def update_card(
|
||||
).one_or_none()
|
||||
is not None
|
||||
)
|
||||
except AssertionError as err:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, detail="GroupID not found!"
|
||||
) from err
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!")
|
||||
db_card.sqlmodel_update(card_data)
|
||||
return add_and_refresh(db, db_card)
|
||||
|
||||
@@ -6,7 +6,6 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.model.models import Card
|
||||
from app.services.auth import auth_is_admin
|
||||
from app.services.caldav import get_caldav_handler
|
||||
from app.services.database import add_and_refresh, get_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,10 +44,8 @@ def add_card_manually(
|
||||
def remove_card_manually(card_id: str, db: Session = Depends(get_session)):
|
||||
try:
|
||||
card = db.exec(select(Card).where(Card.id == card_id)).one()
|
||||
except exc.NoResultFound as err:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="No card with that id found."
|
||||
) from err
|
||||
except exc.NoResultFound:
|
||||
raise HTTPException(status_code=500, detail="No card with that id found.")
|
||||
logger.critical(f"Manual db change: removing a card with attrs: {card}")
|
||||
db.delete(card)
|
||||
db.commit()
|
||||
@@ -64,27 +61,3 @@ def list_all_cards(db: Session = Depends(get_session)):
|
||||
for i in cards:
|
||||
out.append({i.key: i.group.name})
|
||||
return out
|
||||
|
||||
|
||||
@debug_router.get("/calGetActiveEvents")
|
||||
def cal_get_active_events(db: Session = Depends(get_session)):
|
||||
handler = get_caldav_handler()
|
||||
if handler is None:
|
||||
raise HTTPException(503, "Caldav handler not initialized")
|
||||
return handler.get_active_events()
|
||||
|
||||
|
||||
@debug_router.get("/calGetFutureEvents")
|
||||
def cal_get_future_events(db: Session = Depends(get_session)):
|
||||
handler = get_caldav_handler()
|
||||
if handler is None:
|
||||
raise HTTPException(503, "Caldav handler not initialized")
|
||||
return handler.get_future_events()
|
||||
|
||||
|
||||
@debug_router.get("/calGetActiveGroups")
|
||||
def cal_get_active_groups(db: Session = Depends(get_session)):
|
||||
handler = get_caldav_handler()
|
||||
if handler is None:
|
||||
raise HTTPException(503, "Caldav handler not initialized")
|
||||
return handler.get_active_groups()
|
||||
|
||||
@@ -26,10 +26,8 @@ def create_user(
|
||||
db.exec(select(UserDB).where(UserDB.name == user.name)).one_or_none()
|
||||
is None
|
||||
)
|
||||
except AssertionError as err:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, detail="Name already used!"
|
||||
) from err
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!")
|
||||
db_user = UserDB.model_validate(user, update=hashed_password)
|
||||
return add_and_refresh(db, db_user)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.controllers import (
|
||||
userManager,
|
||||
)
|
||||
from app.services.auth import create_first_user, token_router
|
||||
from app.services.caldav import init_caldav_handler, stop_caldav_handler
|
||||
from app.services.database import create_db_and_tables, get_db_session
|
||||
from app.services.door import DoorController, init_controller
|
||||
from app.services.scanner import BackgroundScanner
|
||||
@@ -41,13 +40,10 @@ async def lifespan(app: FastAPI):
|
||||
if not settings.disable_cards:
|
||||
scanner.start()
|
||||
|
||||
init_caldav_handler()
|
||||
|
||||
logger.info("-" * 63)
|
||||
logger.info("---- Documentation is at http://127.0.0.1:8000/api/v1/docs ----")
|
||||
logger.info("-" * 63)
|
||||
yield
|
||||
stop_caldav_handler()
|
||||
# scanner.stop()
|
||||
|
||||
|
||||
|
||||
+36
-32
@@ -1,8 +1,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, time
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from sqlalchemy import DateTime, TypeDecorator
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
|
||||
@@ -10,32 +9,6 @@ class Base(SQLModel):
|
||||
pass
|
||||
|
||||
|
||||
#### Extra Column Types
|
||||
class UTCDateTime(TypeDecorator):
|
||||
"""Modified datetime for timezones
|
||||
Input time gets read as given tz or local tz, then converted to UTC
|
||||
Output everything as UTC
|
||||
"""
|
||||
|
||||
impl = DateTime(timezone=True)
|
||||
LOCAL_TIMEZONE = datetime.now(UTC).astimezone().tzinfo
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.astimezone(self.LOCAL_TIMEZONE)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
#### User
|
||||
class UserBase(Base):
|
||||
name: str = Field(index=True, unique=True)
|
||||
@@ -109,7 +82,7 @@ class GroupResponse(GroupBase):
|
||||
#### AccessAuthorization
|
||||
class AccessAuthorizationBase(Base):
|
||||
name: str = Field(index=True)
|
||||
type: Literal["oneshot", "somefuturespec"]
|
||||
type: Literal["timetable", "oneshot", "somefuturespec"]
|
||||
is_active: bool
|
||||
|
||||
|
||||
@@ -119,6 +92,9 @@ class AccessAuthorizationDB(AccessAuthorizationBase, table=True):
|
||||
groups: list["GroupDB"] = Relationship(
|
||||
back_populates="accessauths", link_model=AaGroupLink
|
||||
)
|
||||
timetables: list["Timetable"] = Relationship(
|
||||
back_populates="accessauth", cascade_delete=True
|
||||
)
|
||||
oneshot: "OneShotAccess" = Relationship(
|
||||
back_populates="accessauth", cascade_delete=True
|
||||
)
|
||||
@@ -126,7 +102,7 @@ class AccessAuthorizationDB(AccessAuthorizationBase, table=True):
|
||||
|
||||
class OneShotAccessBase(Base):
|
||||
uses: int = 1
|
||||
ends_at: datetime = Field(sa_type=UTCDateTime)
|
||||
ends_at: datetime
|
||||
|
||||
|
||||
class OneShotAccess(OneShotAccessBase, table=True):
|
||||
@@ -136,13 +112,23 @@ class OneShotAccess(OneShotAccessBase, table=True):
|
||||
|
||||
|
||||
class AccessAuthorizationCreate(AccessAuthorizationBase):
|
||||
timetables: list["TimetableCreate"] = []
|
||||
oneshot: OneShotAccessBase | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_type(self):
|
||||
if self.type == "oneshot":
|
||||
if self.type == "timetable":
|
||||
if not self.timetables:
|
||||
raise ValueError(
|
||||
"timetable auths require at least one timetable object"
|
||||
)
|
||||
if self.oneshot is not None:
|
||||
raise ValueError("timetable auths are not allowed oneshot objects")
|
||||
elif self.type == "oneshot":
|
||||
if not self.oneshot:
|
||||
raise ValueError("oneshot auths require a oneshot object")
|
||||
if self.timetables:
|
||||
raise ValueError("oneshot auths are not allowed timetable objects")
|
||||
elif self.type == "somefuturespec":
|
||||
raise ValueError("somefuturespec is not jet implemented. Please do not use")
|
||||
return self
|
||||
@@ -150,14 +136,16 @@ class AccessAuthorizationCreate(AccessAuthorizationBase):
|
||||
|
||||
class AccessAuthorizationResponse(AccessAuthorizationBase):
|
||||
id: int
|
||||
timetables: list["Timetable"] = []
|
||||
oneshot: OneShotAccessBase | None = None
|
||||
groups: list["GroupDB"]
|
||||
|
||||
|
||||
class AccessAuthorizationUpdate(Base):
|
||||
name: str | None = None
|
||||
type: Literal["oneshot", "somefuturespec"] | None = None
|
||||
type: Literal["timetable", "oneshot", "somefuturespec"] | None = None
|
||||
is_active: bool | None = None
|
||||
timetables: list["TimetableCreate"] | None = None
|
||||
oneshot: OneShotAccessBase | None = None
|
||||
|
||||
|
||||
@@ -182,3 +170,19 @@ class CardUpdate(Base):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class TimetableBase(Base):
|
||||
weekday: int = Field(le=6, ge=0)
|
||||
starttime: time
|
||||
duration: int = Field(gt=0, lt=1440)
|
||||
|
||||
|
||||
class Timetable(TimetableBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
accessauth_id: int = Field(default=None, foreign_key="accessauthorizationdb.id")
|
||||
accessauth: AccessAuthorizationDB = Relationship(back_populates="timetables")
|
||||
|
||||
|
||||
class TimetableCreate(TimetableBase):
|
||||
pass
|
||||
|
||||
@@ -76,8 +76,8 @@ def get_current_user(
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except InvalidTokenError as err:
|
||||
raise credentials_exception from err
|
||||
except InvalidTokenError:
|
||||
raise credentials_exception
|
||||
user = get_user(db, username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from caldav import get_calendars
|
||||
|
||||
from app.services.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CaldavEventCache:
|
||||
def __init__(self, url, username, password, calendarNames):
|
||||
self.url = url
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.calendarNames = calendarNames
|
||||
self.events = []
|
||||
self.is_running = False
|
||||
self.thread: threading.Thread | None = None
|
||||
self._stopped = threading.Event()
|
||||
|
||||
def start(self):
|
||||
if self.is_running:
|
||||
logger.error("CaldavEventCache already running")
|
||||
return
|
||||
self.is_running = True
|
||||
self._stopped.clear()
|
||||
self.thread = threading.Thread(target=self._refresh_loop, daemon=True)
|
||||
self.thread.start()
|
||||
logger.info("CaldavEventCache started")
|
||||
|
||||
def stop(self):
|
||||
self.is_running = False
|
||||
self._stopped.set()
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
logger.info("CaldavEventCache stopped")
|
||||
|
||||
def _refresh_loop(self):
|
||||
while self.is_running:
|
||||
try:
|
||||
self.refresh()
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating CaldavEventCache: {e}", exc_info=True)
|
||||
self._stopped.wait(settings.caldav_refresh_interval)
|
||||
|
||||
def refresh(self, daysBack=1, daysAhead=7):
|
||||
calendars = get_calendars(
|
||||
url=self.url,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
calendar_name=self.calendarNames,
|
||||
)
|
||||
|
||||
cached = []
|
||||
now = datetime.now(UTC)
|
||||
for cal in calendars:
|
||||
my_events = cal.search(
|
||||
event=True,
|
||||
start=now - timedelta(days=daysBack),
|
||||
end=now + timedelta(days=daysAhead),
|
||||
expand=True,
|
||||
)
|
||||
for event in my_events:
|
||||
component = event.component
|
||||
start, end = component.start, component.end
|
||||
all_day = isinstance(start, date) and not isinstance(start, datetime)
|
||||
|
||||
if not all_day:
|
||||
# Treat timezone-naive event times as system-local time.
|
||||
if start.tzinfo is None:
|
||||
start = start.astimezone()
|
||||
if end.tzinfo is None:
|
||||
end = end.astimezone()
|
||||
|
||||
cached.append(
|
||||
{
|
||||
"uid": str(component.get("uid", "")),
|
||||
"summary": str(component.get("summary", "")),
|
||||
"groups": str(component.get("description", "")).splitlines(),
|
||||
"start": start,
|
||||
"end": end,
|
||||
"all_day": all_day,
|
||||
}
|
||||
)
|
||||
|
||||
# dont cache empty array.
|
||||
# it most probably means there was error during caldav connection
|
||||
# TODO: handle error properly
|
||||
if len(cached) == 0:
|
||||
return
|
||||
|
||||
self.events = cached
|
||||
|
||||
def get_active_events(self, active_time=None):
|
||||
active_time = active_time or datetime.now().astimezone()
|
||||
if active_time.tzinfo is None:
|
||||
active_time = active_time.astimezone()
|
||||
|
||||
today = active_time.date()
|
||||
active = []
|
||||
|
||||
for event in self.events:
|
||||
start, end = event["start"], event["end"]
|
||||
|
||||
if event["all_day"]:
|
||||
# All-day event end dates are exclusive.
|
||||
is_active = start <= today < end
|
||||
else:
|
||||
is_active = start <= active_time < end
|
||||
|
||||
if is_active:
|
||||
active.append(event)
|
||||
|
||||
return active
|
||||
|
||||
def get_future_events(self, active_time=None):
|
||||
active_time = active_time or datetime.now().astimezone()
|
||||
if active_time.tzinfo is None:
|
||||
active_time = active_time.astimezone()
|
||||
|
||||
today = active_time.date()
|
||||
future = []
|
||||
|
||||
for event in self.events:
|
||||
start = event["start"]
|
||||
|
||||
if event["all_day"]: # noqa: SIM108
|
||||
# All-day event end dates are exclusive.
|
||||
is_active = start <= today
|
||||
else:
|
||||
is_active = start <= active_time
|
||||
|
||||
if is_active:
|
||||
future.append(event)
|
||||
|
||||
return future
|
||||
|
||||
def get_active_groups(self, active_time=None):
|
||||
active_events = self.get_active_events(active_time)
|
||||
|
||||
# combine all groups into a list of lists
|
||||
active_groups = []
|
||||
for active_event in active_events:
|
||||
active_groups.append(active_event["groups"])
|
||||
|
||||
# flatten the lists, make every group unique
|
||||
return set(group for groups in active_groups for group in groups)
|
||||
|
||||
def is_group_active(self, group_name, active_time=None):
|
||||
return group_name in self.get_active_groups(active_time)
|
||||
|
||||
|
||||
_caldav_handler: CaldavEventCache | None = None
|
||||
|
||||
|
||||
def init_caldav_handler() -> CaldavEventCache | None:
|
||||
"""Build and start the event cache from settings.
|
||||
Returns None when caldav handling is disabled
|
||||
"""
|
||||
global _caldav_handler
|
||||
if settings.disable_caldav:
|
||||
logger.info("Caldav handling disabled")
|
||||
return None
|
||||
|
||||
handler = CaldavEventCache(
|
||||
url=settings.caldav_url,
|
||||
username=settings.caldav_user,
|
||||
password=settings.caldav_password,
|
||||
calendarNames=settings.caldav_calendars,
|
||||
)
|
||||
handler.start()
|
||||
_caldav_handler = handler
|
||||
return handler
|
||||
|
||||
|
||||
def get_caldav_handler() -> CaldavEventCache | None:
|
||||
return _caldav_handler
|
||||
|
||||
|
||||
def stop_caldav_handler() -> None:
|
||||
global _caldav_handler
|
||||
if _caldav_handler is not None:
|
||||
_caldav_handler.stop()
|
||||
_caldav_handler = None
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
@@ -7,64 +8,25 @@ from app.services.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# the lifespan startup (create_db_and_tables, create_first_user),
|
||||
# get_db_session(), the module level scanner session and
|
||||
# the get_session() FastAPI dependency resolve their engine
|
||||
# through get_engine(). That makes configure_engine() the single
|
||||
# injection point for tests
|
||||
_engine = None
|
||||
_engine_is_default = False
|
||||
|
||||
|
||||
def _build_engine_from_settings():
|
||||
@lru_cache
|
||||
def get_engine():
|
||||
return create_engine(
|
||||
settings.sqlalchemy_database_url, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Return the engine, building it from settings on first use."""
|
||||
global _engine, _engine_is_default
|
||||
if _engine is None:
|
||||
_engine = _build_engine_from_settings()
|
||||
_engine_is_default = True
|
||||
return _engine
|
||||
|
||||
|
||||
def configure_engine(engine) -> None:
|
||||
"""Install *engine* as the engine used by this module from now on.
|
||||
|
||||
The replaced engine is not closed because the scanner (or other long-lived session)
|
||||
could still hold connections
|
||||
"""
|
||||
global _engine, _engine_is_default
|
||||
_engine = engine
|
||||
_engine_is_default = False
|
||||
|
||||
|
||||
def reset_engine() -> None:
|
||||
"""Drop a configured engine and rebuild from settings on the next call.
|
||||
|
||||
Only an engine this module built itself is closed.
|
||||
"""
|
||||
global _engine, _engine_is_default
|
||||
if _engine is not None and _engine_is_default:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_engine_is_default = False
|
||||
|
||||
|
||||
def create_db_and_tables():
|
||||
engine = get_engine()
|
||||
existing_tables = inspect(engine).get_table_names()
|
||||
inspector = inspect(get_engine())
|
||||
existing_tables = inspector.get_table_names()
|
||||
if not existing_tables:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
SQLModel.metadata.create_all(get_engine())
|
||||
from alembic.config import Config
|
||||
|
||||
from alembic import command
|
||||
|
||||
alembic_cfg = Config(settings.alembic_config)
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", str(engine.url))
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", str(get_engine().url))
|
||||
alembic_cfg.attributes["sqlalchemy.url"] = settings.sqlalchemy_database_url
|
||||
command.stamp(alembic_cfg, "head")
|
||||
logger.info("Database created and tables initialized.")
|
||||
|
||||
+28
-25
@@ -1,11 +1,11 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from sqlalchemy import exc
|
||||
from sqlmodel import select
|
||||
|
||||
from app.model.models import Card, OneShotAccess
|
||||
from app.services.caldav import get_caldav_handler
|
||||
from app.services.database import Session, add_and_refresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,6 +19,7 @@ class DoorController:
|
||||
mqtt_host: str = "localhost",
|
||||
mqtt_port: int = 1883,
|
||||
mock_factory: bool = False,
|
||||
|
||||
):
|
||||
self._is_open: bool = False
|
||||
self._mqtt = None
|
||||
@@ -27,17 +28,13 @@ class DoorController:
|
||||
|
||||
if not mock_factory:
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
self._mqtt = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
||||
self._mqtt.connect(
|
||||
mqtt_host, mqtt_port
|
||||
) # TODO: add login with username+pw, tls
|
||||
self._mqtt.connect(mqtt_host, mqtt_port) #TODO: add login with username+pw, tls
|
||||
self._mqtt.loop_start()
|
||||
logger.info("Mqtt client connect to %s:%s", mqtt_host, mqtt_port)
|
||||
|
||||
logger.info(
|
||||
"DoorController started. topic=%s mock=%s",
|
||||
self._mqtt_topic,
|
||||
"DoorController started. topic=%s mock=%s", self._mqtt_topic,
|
||||
mock_factory,
|
||||
)
|
||||
|
||||
@@ -101,33 +98,39 @@ def decrementOneshot(db: Session, oneshot: OneShotAccess):
|
||||
|
||||
def checkAccess(key: str, db: Session):
|
||||
try:
|
||||
current_weekday = datetime.weekday(date.today())
|
||||
current_time = datetime.now()
|
||||
card = db.exec(select(Card).where(Card.key == key)).one()
|
||||
if not card.enabled:
|
||||
logger.info("Card Inactive!")
|
||||
return False
|
||||
|
||||
caldav_handler = get_caldav_handler()
|
||||
if caldav_handler and caldav_handler.is_group_active(card.group.name):
|
||||
logger.info("Access Valid!")
|
||||
return True
|
||||
|
||||
current_time = datetime.now().astimezone()
|
||||
for auth in card.group.accessauths:
|
||||
logger.info(f"checking auth: {auth.name}")
|
||||
if not auth.is_active:
|
||||
logger.info("AA inactive!")
|
||||
continue
|
||||
if auth.type == "timetable":
|
||||
for timetable in auth.timetables:
|
||||
logger.info(f" checking timetable {timetable.id}")
|
||||
logger.info(
|
||||
f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}"
|
||||
)
|
||||
if current_weekday == timetable.weekday:
|
||||
starttime = datetime.combine(date.today(), timetable.starttime)
|
||||
endtime = starttime + timedelta(minutes=timetable.duration)
|
||||
logger.info(
|
||||
f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}"
|
||||
)
|
||||
if starttime < current_time < endtime:
|
||||
logger.info("Access Valid!")
|
||||
return True
|
||||
if auth.type == "oneshot":
|
||||
logger.info(f" oneshot auth found: {auth.oneshot}")
|
||||
ends_at = auth.oneshot.ends_at
|
||||
if ends_at.tzinfo is None:
|
||||
ends_at = ends_at.astimezone()
|
||||
if current_time < ends_at and auth.oneshot.uses > 0:
|
||||
decrementOneshot(db, auth.oneshot)
|
||||
return True
|
||||
if current_time < auth.oneshot.ends_at:
|
||||
if auth.oneshot.uses > 0:
|
||||
decrementOneshot(db, auth.oneshot)
|
||||
return True
|
||||
logger.info("No more auths found")
|
||||
return False
|
||||
except exc.NoResultFound as err:
|
||||
raise Exception(
|
||||
"No Access with that key found, this might be a db error"
|
||||
) from err
|
||||
except exc.NoResultFound:
|
||||
raise Exception("No AccessAuth with that key found!")
|
||||
|
||||
+21
-24
@@ -77,7 +77,7 @@ def readFileOnCard(desfire: DESFire):
|
||||
assert len(applications) == 1
|
||||
assert applications[0] == get_list(MIFARE_APP_ID)
|
||||
except AssertionError:
|
||||
logger.error("No application found!")
|
||||
logger.error("No application found! The card must be registered first!")
|
||||
time.sleep(4)
|
||||
return
|
||||
# Then use the key derivation with that uid, the appid, the sysid
|
||||
@@ -125,23 +125,13 @@ def DeleteCard():
|
||||
aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
|
||||
|
||||
desfire.select_application(0x0)
|
||||
|
||||
try:
|
||||
try:
|
||||
logger.debug("Auth1")
|
||||
desfire.authenticate(0x0, aes_master_key)
|
||||
except (Exception, AssertionError):
|
||||
logger.debug("Auth2")
|
||||
desfire.authenticate(0x0, aes_null_key)
|
||||
except (Exception, AssertionError):
|
||||
logger.debug("Auth3")
|
||||
desfire.authenticate(0x0, desKey)
|
||||
desfire.authenticate(0x0, desKey)
|
||||
|
||||
applications = desfire.get_application_ids()
|
||||
logger.debug(f"Applications: {applications}")
|
||||
if len(applications) == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE, detail="No applications on card"
|
||||
status_code=status.HTTP_410_GONE, detail="Card is empty."
|
||||
)
|
||||
|
||||
desfire.select_application(MIFARE_APP_ID)
|
||||
@@ -154,11 +144,11 @@ def DeleteCard():
|
||||
pass
|
||||
scannerThread.start()
|
||||
return rdata
|
||||
except (Exception, AssertionError) as err:
|
||||
logger.error(f"Error in deletion function: {err}", exc_info=True)
|
||||
except (Exception, AssertionError) as e:
|
||||
logger.error(f"Error in deletion function: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}"
|
||||
) from err
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}"
|
||||
)
|
||||
|
||||
|
||||
def WriteNewCard():
|
||||
@@ -185,6 +175,13 @@ def WriteNewCard():
|
||||
# get uid
|
||||
uid = desfire.get_real_uid()
|
||||
|
||||
applications = desfire.get_application_ids()
|
||||
logger.debug(f"Applications: {applications}")
|
||||
if len(applications) >= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="This Card already has a key!"
|
||||
)
|
||||
|
||||
# Set default key
|
||||
logger.debug("Setting default key...")
|
||||
desfire.change_default_key(aes_null_key, 0x0)
|
||||
@@ -269,12 +266,12 @@ def WriteNewCard():
|
||||
scannerThread.start()
|
||||
return key, to_hex_string(data=uid, separator=":")
|
||||
|
||||
except Exception as err:
|
||||
logger.error(f"Error in write function: {err}", exc_info=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in write function: {e}", exc_info=False)
|
||||
scannerThread.start()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}"
|
||||
) from err
|
||||
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}"
|
||||
)
|
||||
|
||||
class BackgroundScanner:
|
||||
def __init__(self, db):
|
||||
@@ -310,7 +307,7 @@ class BackgroundScanner:
|
||||
logger.debug("READY after timout")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan function: {e}", exc_info=True)
|
||||
logger.error(f"Error in function _scan_loop: {e}", exc_info=False)
|
||||
time.sleep(6)
|
||||
|
||||
def _read_card(self):
|
||||
@@ -326,7 +323,7 @@ class BackgroundScanner:
|
||||
rdata = readFileOnCard(desfire=desfire)
|
||||
return rdata
|
||||
except Exception as e:
|
||||
logger.error(f"something went wrong: {e}")
|
||||
logger.error(f"Error in function _read_card: {e}", exc_info=False)
|
||||
time.sleep(5)
|
||||
|
||||
def _check_db(self, key):
|
||||
|
||||
@@ -21,13 +21,6 @@ class Settings(BaseSettings):
|
||||
mifare_acl_read_base_key: str | None = None
|
||||
mifare_acl_write_base_key: str | None = None
|
||||
|
||||
disable_caldav: bool = True
|
||||
caldav_url: str | None = None
|
||||
caldav_user: str | None = None
|
||||
caldav_password: str | None = None
|
||||
caldav_calendars: list[str] | None = None
|
||||
caldav_refresh_interval: int | None = None
|
||||
|
||||
|
||||
@lru_cache(1)
|
||||
def _create_settings():
|
||||
@@ -44,31 +37,11 @@ def _create_settings():
|
||||
]
|
||||
if missing:
|
||||
logger.critical(
|
||||
"Missing environment variable for scanner start: %s "
|
||||
"Missing environment variable for scanner start: %s"
|
||||
"Card scanner and related funcionality is disabled!",
|
||||
", ".join(missing),
|
||||
)
|
||||
settings.disable_cards = True
|
||||
|
||||
if not settings.disable_caldav:
|
||||
missing = [
|
||||
name.upper()
|
||||
for name in (
|
||||
"caldav_url",
|
||||
"caldav_user",
|
||||
"caldav_password",
|
||||
"caldav_calendars",
|
||||
"caldav_refresh_interval",
|
||||
)
|
||||
if not getattr(settings, name)
|
||||
]
|
||||
if missing:
|
||||
logger.critical(
|
||||
"Missing environment variable for caldav handling: %s "
|
||||
"Caldav handling and related funcionality is disabled!",
|
||||
", ".join(missing),
|
||||
)
|
||||
settings.disable_caldav = True
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
pythonSet = pythonSets.${system}.overrideScope editableOverlay;
|
||||
virtualenv = (pythonSet.mkVirtualEnv "gatekeeper" workspace.deps.all).overrideAttrs(old: { venvIgnoreCollisions = ["*"];});
|
||||
virtualenv = pythonSet.mkVirtualEnv "gatekeeper" workspace.deps.all;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
@@ -108,7 +108,7 @@
|
||||
);
|
||||
|
||||
packages = forAllSystems (system: {
|
||||
default = (pythonSets.${system}.mkVirtualEnv "gatekeeper" workspace.deps.default).overrideAttrs(old: { venvIgnoreCollisions = ["*"];});
|
||||
default = pythonSets.${system}.mkVirtualEnv "gatekeeper" workspace.deps.default;
|
||||
});
|
||||
|
||||
apps = forAllSystems (system: {
|
||||
|
||||
@@ -17,7 +17,6 @@ dependencies = [
|
||||
"alembic>=1.18.5",
|
||||
"pydantic-settings>=2.13.1",
|
||||
"paho-mqtt>=2.1.0",
|
||||
"caldav>=3.7.0",
|
||||
]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -56,15 +55,3 @@ select = [
|
||||
"I",
|
||||
]
|
||||
ignore = ["B008"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["test"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
relative_files = true
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 65
|
||||
show_missing = false
|
||||
skip_covered = true
|
||||
+35
-135
@@ -1,144 +1,37 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from time import tzset
|
||||
from datetime import time
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
# TEST_ENV must cover every Settings field
|
||||
# test_settings.py fails with the missing name if it does not.
|
||||
TEST_ENV = {
|
||||
"SECRET_KEY": "ff" * 16,
|
||||
"SQLALCHEMY_DATABASE_URL": "sqlite://",
|
||||
"MQTT_HOST": "localhost",
|
||||
"MQTT_PORT": "1883",
|
||||
"MOCK": "True",
|
||||
"ALEMBIC_CONFIG": "./alembic.ini",
|
||||
"DISABLE_CARDS": "True",
|
||||
"MIFARE_APP_MASTER_KEY": "0" * 16,
|
||||
"MIFARE_ACL_READ_BASE_KEY": "1" * 16,
|
||||
"MIFARE_ACL_WRITE_BASE_KEY": "2" * 16,
|
||||
"DISABLE_CALDAV": "True",
|
||||
"CALDAV_URL": "http://caldav.invalid/dav.php",
|
||||
"CALDAV_USER": "test-user",
|
||||
"CALDAV_PASSWORD": "test-password",
|
||||
"CALDAV_CALENDARS": '["test-calendar"]',
|
||||
"CALDAV_REFRESH_INTERVAL": "3600",
|
||||
}
|
||||
os.environ["SECRET_KEY"] = "ff" * 16
|
||||
|
||||
|
||||
for _name in TEST_ENV:
|
||||
os.environ.pop(_name, None)
|
||||
os.environ.update(TEST_ENV)
|
||||
|
||||
import app.services.database as database_service # noqa: E402
|
||||
|
||||
TEST_ADMIN_PASSWORD = "admin123"
|
||||
|
||||
engine = create_engine(
|
||||
TEST_ENV["SQLALCHEMY_DATABASE_URL"],
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
# inject custom engine
|
||||
database_service.configure_engine(engine)
|
||||
|
||||
# import main after the custom engine is injected
|
||||
from app.main import app # noqa: E402
|
||||
from app.model.models import ( # noqa: E402
|
||||
from app.main import app
|
||||
from app.model.models import (
|
||||
AccessAuthorizationDB,
|
||||
Card,
|
||||
GroupDB,
|
||||
OneShotAccess,
|
||||
Timetable,
|
||||
UserDB,
|
||||
)
|
||||
from app.services.settings import settings # noqa: E402
|
||||
from app.services.database import get_session
|
||||
|
||||
# Use in-memory SQLite for testing
|
||||
TEST_SQLALCHEMY_DATABASE_URL = "sqlite://"
|
||||
|
||||
def alembic_head() -> str:
|
||||
"""Head revision of the migration scripts."""
|
||||
alembic_cfg = Config(settings.alembic_config)
|
||||
return ScriptDirectory.from_config(alembic_cfg).get_current_head()
|
||||
|
||||
|
||||
def _seed_alembic_version() -> None:
|
||||
"""Mark the test database as already migrated.
|
||||
|
||||
Without a version row create_db_and_tables() treats the database as new and
|
||||
runs an alembic stamp, which builds its own engine from the configured URL
|
||||
instead of using this shared in-memory engine.
|
||||
"""
|
||||
with engine.connect() as connection:
|
||||
if "alembic_version" not in inspect(connection).get_table_names():
|
||||
connection.exec_driver_sql(
|
||||
"CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)"
|
||||
)
|
||||
connection.exec_driver_sql(
|
||||
"INSERT OR IGNORE INTO alembic_version (version_num) VALUES (?)",
|
||||
(_HEAD_REVISION,),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _seed_default_admin() -> None:
|
||||
"""Seed the admin user the fixtures log in with.
|
||||
TestClient runs lifespan, that calls create_first_user()
|
||||
which blocks the admin_user fixture. Pre-creating the admin stops that
|
||||
"""
|
||||
from app.services.auth import get_password_hash
|
||||
|
||||
with Session(engine) as session:
|
||||
if session.exec(select(UserDB).where(UserDB.name == "admin")).first() is None:
|
||||
session.add(
|
||||
UserDB(
|
||||
name="admin",
|
||||
passwordhash=get_password_hash(TEST_ADMIN_PASSWORD),
|
||||
is_admin=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
_HEAD_REVISION = alembic_head()
|
||||
_seed_alembic_version()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_tz(request):
|
||||
"""Run a test with the host-local timezone forced to a POSIX TZ string."""
|
||||
previous_tz = os.environ.get("TZ")
|
||||
os.environ["TZ"] = request.param
|
||||
tzset()
|
||||
try:
|
||||
yield request.param
|
||||
finally:
|
||||
if previous_tz is None:
|
||||
os.environ.pop("TZ", None)
|
||||
else:
|
||||
os.environ["TZ"] = previous_tz
|
||||
tzset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _installed_engine():
|
||||
"""Undo configure_engine() leaks between tests."""
|
||||
previous = database_service.get_engine()
|
||||
yield
|
||||
database_service.configure_engine(previous)
|
||||
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)
|
||||
_seed_alembic_version()
|
||||
_seed_default_admin()
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
@@ -146,19 +39,27 @@ def db_session():
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db_session):
|
||||
"""Create a test client bound to the test engine.
|
||||
"""Create a test client with a database session override."""
|
||||
|
||||
No FastAPI dependency override is needed: get_session() resolves its engine
|
||||
through get_engine(), which already points at the test engine.
|
||||
"""
|
||||
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):
|
||||
"""Get the admin user the seeded database already contains."""
|
||||
admin = db_session.exec(select(UserDB).where(UserDB.name == "admin")).one()
|
||||
"""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
|
||||
|
||||
@@ -181,8 +82,7 @@ def regular_user(db_session):
|
||||
def auth_headers(client, admin_user):
|
||||
"""Get authentication headers for admin user."""
|
||||
response = client.post(
|
||||
"/api/v1/token",
|
||||
data={"username": admin_user.name, "password": TEST_ADMIN_PASSWORD},
|
||||
"/api/v1/token", data={"username": admin_user.name, "password": "admin123"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -225,12 +125,12 @@ def test_card(db_session, test_group):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_aa_os(db_session):
|
||||
oneshot = OneShotAccess(uses=1, ends_at=datetime.now() + timedelta(days=1))
|
||||
db_session.add(oneshot)
|
||||
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=True, type="oneshot")
|
||||
aa.oneshot = oneshot
|
||||
|
||||
def test_aa_tt(db_session):
|
||||
"""Create a test access authorization with timetable."""
|
||||
tt = Timetable(weekday=1, starttime=time(1, 0, 0, 0), duration=50)
|
||||
aa = AccessAuthorizationDB(
|
||||
name="Test AA", is_active=True, type="timetable", timetables=[tt]
|
||||
)
|
||||
db_session.add(aa)
|
||||
db_session.commit()
|
||||
db_session.refresh(aa)
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
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_router_includes():
|
||||
"""Test that all routers are included in the app."""
|
||||
from app.main import app
|
||||
|
||||
+89
-22
@@ -1,35 +1,102 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
import datetime
|
||||
|
||||
from app.model.models import AccessAuthorizationCreate, UserUpdate
|
||||
from app.model.models import (
|
||||
AaGroupLink,
|
||||
AccessAuthorizationBase,
|
||||
AccessAuthorizationCreate,
|
||||
Card,
|
||||
GroupBase,
|
||||
GroupCreate,
|
||||
TimetableCreate,
|
||||
Token,
|
||||
TokenData,
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
|
||||
def test_user_update_fields_are_optional():
|
||||
"""Partial update models must leave unspecified fields as None."""
|
||||
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_access_authorization_type_is_validated():
|
||||
"""AA validation test"""
|
||||
base = {"name": "AA", "is_active": True}
|
||||
def test_group_models():
|
||||
"""Test group model creation and validation."""
|
||||
# Test GroupBase
|
||||
group_base = GroupBase(name="Test Group")
|
||||
assert group_base.name == "Test Group"
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AccessAuthorizationCreate(type="timetable", **base)
|
||||
assert exc_info.value.errors()[0]["type"] == "literal_error"
|
||||
# Test GroupCreate
|
||||
group_create = GroupCreate(name="New Group")
|
||||
assert group_create.name == "New Group"
|
||||
|
||||
# A oneshot type without its oneshot payload is incomplete.
|
||||
with pytest.raises(ValueError, match="require a oneshot object"):
|
||||
AccessAuthorizationCreate(type="oneshot", **base)
|
||||
|
||||
with pytest.raises(ValueError, match="not jet implemented"):
|
||||
AccessAuthorizationCreate(type="somefuturespec", **base)
|
||||
def test_access_authorization_models():
|
||||
"""Test access authorization model creation and validation."""
|
||||
# Test AccessAuthorizationBase
|
||||
aa_base = AccessAuthorizationBase(name="Test AA", is_active=True, type="timetable")
|
||||
assert aa_base.name == "Test AA"
|
||||
assert aa_base.is_active is True
|
||||
|
||||
valid = AccessAuthorizationCreate(
|
||||
type="oneshot",
|
||||
name="AA",
|
||||
is_active=True,
|
||||
oneshot={"uses": 1, "ends_at": "2029-07-27"},
|
||||
# Test AccessAuthorizationCreate with timetables
|
||||
timetable_create = TimetableCreate(weekday=1, starttime="08:00", duration=60)
|
||||
aa_create = AccessAuthorizationCreate(
|
||||
name="New AA", is_active=False, type="timetable", timetables=[timetable_create]
|
||||
)
|
||||
assert valid.type == "oneshot"
|
||||
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(key="test-key", group_id=1)
|
||||
assert card.key == "test-key"
|
||||
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 == datetime.time(9, 0)
|
||||
assert timetable.duration == 120
|
||||
|
||||
# Test boundary values
|
||||
max_duration = TimetableCreate(weekday=6, starttime="23:59", duration=1439)
|
||||
assert max_duration.duration == 1439
|
||||
assert max_duration.weekday == 6
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
from datetime import UTC, datetime
|
||||
def test_create_access_auth_tt(client, auth_headers):
|
||||
"""Test creating a new access authorization."""
|
||||
aa_data = {
|
||||
"name": "New tt_AA",
|
||||
"type": "timetable",
|
||||
"is_active": True,
|
||||
"timetables": [
|
||||
{"weekday": 1, "starttime": "08:00", "duration": 60},
|
||||
{"weekday": 2, "starttime": "09:00", "duration": 90},
|
||||
],
|
||||
}
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import select
|
||||
response = client.post("/api/v1/aa/", json=aa_data, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
from app.model.models import OneShotAccess
|
||||
|
||||
# Berlin's DST rules as a POSIX TZ string: +01:00 in winter, +02:00 in summer.
|
||||
BERLIN_TZ = "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
|
||||
|
||||
def _post_oneshot_aa(client, auth_headers, name: str, ends_at: str):
|
||||
"""Create a oneshot access authorization and return the response."""
|
||||
return client.post(
|
||||
"/api/v1/aa/",
|
||||
json={
|
||||
"name": name,
|
||||
"type": "oneshot",
|
||||
"is_active": True,
|
||||
"oneshot": {"uses": 1, "ends_at": ends_at},
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
data = response.json()
|
||||
assert data["name"] == "New tt_AA"
|
||||
assert data["is_active"] is True
|
||||
assert data["type"] == "timetable"
|
||||
assert "id" in data
|
||||
assert len(data["timetables"]) == 2
|
||||
|
||||
|
||||
def test_create_access_auth_os(client, auth_headers):
|
||||
@@ -56,49 +53,26 @@ def test_create_wrong_aa_type(client, auth_headers):
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_timetable_aa_is_rejected(client, auth_headers):
|
||||
"""The deprecated timetable type must be refused"""
|
||||
response = client.post(
|
||||
"/api/v1/aa/",
|
||||
json={"name": "Legacy timetable AA", "type": "timetable", "is_active": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_update_access_auth_to_timetable_is_rejected(client, auth_headers, test_aa_os):
|
||||
"""PATCH must refuse type="timetable" """
|
||||
response = client.patch(
|
||||
f"/api/v1/aa/{test_aa_os.id}",
|
||||
json={"type": "timetable"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
still_fine = client.get(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
|
||||
assert still_fine.status_code == 200
|
||||
assert still_fine.json()["type"] == "oneshot"
|
||||
|
||||
|
||||
def test_get_all_access_auths(client, auth_headers, test_aa_os):
|
||||
def test_get_all_access_auths(client, auth_headers, test_aa_tt):
|
||||
"""Test retrieving all access authorizations."""
|
||||
response = client.get("/api/v1/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_os.name in aa_names
|
||||
assert test_aa_tt.name in aa_names
|
||||
|
||||
|
||||
def test_get_access_auth_by_id(client, auth_headers, test_aa_os):
|
||||
def test_get_access_auth_by_id(client, auth_headers, test_aa_tt):
|
||||
"""Test retrieving a specific access authorization by ID."""
|
||||
response = client.get(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
|
||||
response = client.get(f"/api/v1/aa/{test_aa_tt.id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["id"] == test_aa_os.id
|
||||
assert data["name"] == test_aa_os.name
|
||||
assert data["id"] == test_aa_tt.id
|
||||
assert data["name"] == test_aa_tt.name
|
||||
|
||||
|
||||
def test_get_nonexistent_access_auth(client, auth_headers):
|
||||
@@ -107,10 +81,10 @@ def test_get_nonexistent_access_auth(client, auth_headers):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa_os):
|
||||
def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa_tt):
|
||||
"""Test assigning an access authorization to a group."""
|
||||
response = client.put(
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -121,49 +95,49 @@ def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa_o
|
||||
|
||||
|
||||
def test_assign_already_assigned_access_auth(
|
||||
client, auth_headers, test_group, test_aa_os
|
||||
client, auth_headers, test_group, test_aa_tt
|
||||
):
|
||||
"""Test assigning an already assigned access authorization."""
|
||||
# First assignment
|
||||
client.put(
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
|
||||
# Second assignment should indicate it's already assigned
|
||||
response = client.put(
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
# According to the code, this returns 409 with "already assigned" message
|
||||
assert response.status_code == 409
|
||||
assert "already assigned" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa_os):
|
||||
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa_tt):
|
||||
"""Test unassigning an access authorization from a group."""
|
||||
# First assign
|
||||
client.put(
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
|
||||
# Then unassign
|
||||
response = client.put(
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa_os):
|
||||
def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa_tt):
|
||||
"""Test unassigning a non-existent assignment."""
|
||||
response = client.put(
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_assign_to_nonexistent_group(client, auth_headers, test_aa_os):
|
||||
def test_assign_to_nonexistent_group(client, auth_headers, test_aa_tt):
|
||||
"""Test assigning an AA to a non-existent group."""
|
||||
response = client.put(
|
||||
f"/api/v1/aa/assign/99999/{test_aa_os.id}", headers=auth_headers
|
||||
f"/api/v1/aa/assign/99999/{test_aa_tt.id}", headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -176,12 +150,12 @@ def test_assign_nonexistent_aa(client, auth_headers, test_group):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_access_auth(client, auth_headers, test_aa_os):
|
||||
def test_update_access_auth(client, auth_headers, test_aa_tt):
|
||||
"""Test updating an access authorization."""
|
||||
update_data = {"name": "Updated AA", "is_active": False}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/aa/{test_aa_os.id}", json=update_data, headers=auth_headers
|
||||
f"/api/v1/aa/{test_aa_tt.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -190,6 +164,23 @@ def test_update_access_auth(client, auth_headers, test_aa_os):
|
||||
assert data["is_active"] is False
|
||||
|
||||
|
||||
def test_update_access_auth_with_timetables(client, auth_headers, test_aa_tt):
|
||||
"""Test updating an access authorization with new timetables."""
|
||||
update_data = {
|
||||
"timetables": [{"weekday": 5, "starttime": "10:00", "duration": 120}]
|
||||
}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/aa/{test_aa_tt.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
jresponse = response.json()
|
||||
assert len(jresponse["timetables"]) == 1
|
||||
assert jresponse["timetables"][0]["weekday"] == 5
|
||||
assert jresponse["timetables"][0]["starttime"] == "10:00:00"
|
||||
assert jresponse["timetables"][0]["duration"] == 120
|
||||
|
||||
|
||||
def test_update_nonexistent_access_auth(client, auth_headers):
|
||||
"""Test updating a non-existent access authorization."""
|
||||
update_data = {"name": "Updated"}
|
||||
@@ -197,14 +188,14 @@ def test_update_nonexistent_access_auth(client, auth_headers):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_access_auth(client, auth_headers, test_aa_os):
|
||||
def test_delete_access_auth(client, auth_headers, test_aa_tt):
|
||||
"""Test deleting an access authorization."""
|
||||
response = client.delete(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
|
||||
response = client.delete(f"/api/v1/aa/{test_aa_tt.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"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
|
||||
response = client.get(f"/api/v1/aa/{test_aa_tt.id}", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -214,12 +205,12 @@ def test_delete_nonexistent_access_auth(client, auth_headers):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_aa_os_operations_by_non_admin(client, test_aa_os, user_auth_headers):
|
||||
def test_aa_tt_operations_by_non_admin(client, test_aa_tt, user_auth_headers):
|
||||
"""Test that non-admin users cannot perform AA operations."""
|
||||
# Try to create an AA
|
||||
response = client.post(
|
||||
"/api/v1/aa/",
|
||||
json={"name": "test", "is_active": True},
|
||||
json={"name": "test", "is_active": True, "timetables": []},
|
||||
headers=user_auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -230,147 +221,6 @@ def test_aa_os_operations_by_non_admin(client, test_aa_os, user_auth_headers):
|
||||
|
||||
# Try to assign AA
|
||||
response = client.put(
|
||||
f"/api/v1/aa/assign/1/{test_aa_os.id}", headers=user_auth_headers
|
||||
f"/api/v1/aa/assign/1/{test_aa_tt.id}", headers=user_auth_headers
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ, "UTC-2"], indirect=True)
|
||||
def test_create_access_auth_os_returns_timezone(client, auth_headers, system_tz):
|
||||
"""The oneshot end time must come back with a timezone, not as a naive value."""
|
||||
response = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
ends_at = response.json()["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed.utcoffset() is not None, f"response has no timezone: {ends_at}"
|
||||
assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC), (
|
||||
f"the +02:00 offset was not normalised to UTC: {ends_at}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_access_auth_os_returns_timezone(client, auth_headers):
|
||||
"""Reading the value back from the database keeps it timezone aware."""
|
||||
created = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert created.status_code == 200
|
||||
aa_id = created.json()["id"]
|
||||
|
||||
for endpoint in (f"/api/v1/aa/{aa_id}", "/api/v1/aa/"):
|
||||
response = client.get(endpoint, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
items = body if isinstance(body, list) else [body]
|
||||
auths = [aa for aa in items if aa.get("oneshot")]
|
||||
assert auths, f"no oneshot auth in {endpoint}: {body}"
|
||||
for auth in auths:
|
||||
ends_at = auth["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed.utcoffset() is not None, (
|
||||
f"{endpoint} has no timezone: {ends_at}"
|
||||
)
|
||||
assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_oneshot_timezone_is_stored_in_db(client, auth_headers, db_session):
|
||||
"""The submitted offset is normalized before the value reaches the database."""
|
||||
response = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
db_session.expire_all()
|
||||
stored = db_session.exec(select(OneShotAccess)).one()
|
||||
assert stored.ends_at == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
# The column itself must hold the UTC wall clock, not the submitted local time.
|
||||
raw = db_session.exec(text("SELECT ends_at FROM oneshotaccess")).one()[0]
|
||||
assert datetime.fromisoformat(raw) == datetime(2029, 7, 27, 10, 0), (
|
||||
f"unexpected value in the database: {raw}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_oneshot_without_offset_is_treated_as_local_time(
|
||||
client, auth_headers, db_session, system_tz
|
||||
):
|
||||
"""A datetime without an offset is wall-clock time on the host, not UTC."""
|
||||
response = _post_oneshot_aa(
|
||||
client, auth_headers, "naive os_AA", "2029-07-27T12:00:00"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 2029-07-27 is summer in Berlin, so 12:00 local is 10:00 UTC.
|
||||
parsed = datetime.fromisoformat(response.json()["oneshot"]["ends_at"])
|
||||
assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
db_session.expire_all()
|
||||
stored = db_session.exec(select(OneShotAccess)).one()
|
||||
assert stored.ends_at == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_oneshot_date_only_is_local_midnight(client, auth_headers, system_tz):
|
||||
"""A bare date is local midnight, which is not the same instant as 00:00 UTC."""
|
||||
response = _post_oneshot_aa(client, auth_headers, "date os_AA", "2029-07-27")
|
||||
assert response.status_code == 200
|
||||
|
||||
ends_at = response.json()["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed == datetime(2029, 7, 26, 22, 0, tzinfo=UTC), (
|
||||
f"local midnight was not converted to UTC: {ends_at}"
|
||||
)
|
||||
|
||||
|
||||
def test_update_access_auth_os_returns_timezone(client, auth_headers, db_session):
|
||||
"""PATCHing the oneshot keeps the timezone in the response and in the DB."""
|
||||
created = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert created.status_code == 200
|
||||
aa_id = created.json()["id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/aa/{aa_id}",
|
||||
json={"oneshot": {"uses": 2, "ends_at": "2030-01-01T05:00:00+05:00"}},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
ends_at = response.json()["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed.utcoffset() is not None, f"response has no timezone: {ends_at}"
|
||||
assert parsed == datetime(2030, 1, 1, 0, 0, tzinfo=UTC)
|
||||
|
||||
db_session.expire_all()
|
||||
stored = db_session.exec(select(OneShotAccess)).one()
|
||||
assert stored.ends_at == datetime(2030, 1, 1, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_oneshot_local_time_uses_the_dst_rule_of_its_own_date(
|
||||
client, auth_headers, system_tz
|
||||
):
|
||||
"""Naive local times must pick the offset in force on that date.
|
||||
|
||||
Without this the host's *current* offset gets applied to every value, so a
|
||||
summer date entered in winter is stored an hour off.
|
||||
"""
|
||||
summer = _post_oneshot_aa(
|
||||
client, auth_headers, "summer os_AA", "2029-07-27T12:00:00"
|
||||
)
|
||||
winter = _post_oneshot_aa(
|
||||
client, auth_headers, "winter os_AA", "2029-12-27T12:00:00"
|
||||
)
|
||||
assert summer.status_code == 200 and winter.status_code == 200
|
||||
|
||||
# CEST is UTC+2, CET is UTC+1.
|
||||
assert datetime.fromisoformat(summer.json()["oneshot"]["ends_at"]) == datetime(
|
||||
2029, 7, 27, 10, 0, tzinfo=UTC
|
||||
)
|
||||
assert datetime.fromisoformat(winter.json()["oneshot"]["ends_at"]) == datetime(
|
||||
2029, 12, 27, 11, 0, tzinfo=UTC
|
||||
)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from app.services.caldav import CaldavEventCache
|
||||
|
||||
|
||||
def create_cache():
|
||||
"""Create the instance without calling __init__, so tests need no CalDAV server."""
|
||||
cache = CaldavEventCache.__new__(CaldavEventCache)
|
||||
cache.events = [
|
||||
{
|
||||
"uid": "1",
|
||||
"summary": "Timed event",
|
||||
"groups": ["team-a", "shared"],
|
||||
"start": datetime(2026, 11, 2, 10, 0, tzinfo=UTC),
|
||||
"end": datetime(2026, 11, 2, 11, 0, tzinfo=UTC),
|
||||
"all_day": False,
|
||||
},
|
||||
{
|
||||
"uid": "2",
|
||||
"summary": "All-day event",
|
||||
"groups": ["team-b", "shared"],
|
||||
"start": date(2026, 11, 2),
|
||||
"end": date(2026, 11, 3), # Exclusive end date
|
||||
"all_day": True,
|
||||
},
|
||||
]
|
||||
return cache
|
||||
|
||||
|
||||
def test_active_during_timed_event():
|
||||
cache = create_cache()
|
||||
during_event = datetime(2026, 11, 2, 10, 30, tzinfo=UTC)
|
||||
assert cache.is_group_active("team-a", during_event)
|
||||
assert cache.is_group_active("shared", during_event)
|
||||
assert cache.is_group_active("team-b", during_event) # warum 'not'?
|
||||
|
||||
|
||||
def test_timed_event_end_exclusive():
|
||||
cache = create_cache()
|
||||
at_timed_event_end = datetime(2026, 11, 2, 11, 0, tzinfo=UTC)
|
||||
assert not cache.is_group_active("team-a", at_timed_event_end)
|
||||
assert cache.is_group_active("team-b", at_timed_event_end)
|
||||
assert cache.is_group_active("shared", at_timed_event_end)
|
||||
|
||||
|
||||
def test_unknown_group():
|
||||
cache = create_cache()
|
||||
during_event = datetime(2026, 11, 2, 10, 30, tzinfo=UTC)
|
||||
assert not cache.is_group_active("unknown", during_event)
|
||||
|
||||
|
||||
def test_all_day_event_exclusive():
|
||||
cache = create_cache()
|
||||
next_day = datetime(2026, 11, 3, 9, 0, tzinfo=UTC)
|
||||
assert not cache.is_group_active("team-b", next_day)
|
||||
@@ -1,46 +1,42 @@
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.model.models import UserDB
|
||||
from app.services.database import (
|
||||
add_and_refresh,
|
||||
configure_engine,
|
||||
create_db_and_tables,
|
||||
get_engine,
|
||||
reset_engine,
|
||||
)
|
||||
from app.services.settings import settings
|
||||
from app.services.database import add_and_refresh, create_db_and_tables
|
||||
|
||||
|
||||
def test_configure_engine_and_reset(tmp_path, db_session):
|
||||
"""configure_engine() is the single injection point for the whole app.
|
||||
reset_engine() drops the engine again and rebuilds from settings
|
||||
conftest's autouse _installed_engine fixture puts the shared test engine back.
|
||||
"""
|
||||
from app.services import database as database_service
|
||||
def test_create_db_and_tables():
|
||||
"""Test database and tables creation."""
|
||||
# This is primarily an integration test
|
||||
from sqlalchemy import inspect
|
||||
|
||||
url = f"sqlite:///{tmp_path / 'injected.db'}"
|
||||
injected = create_engine(url, connect_args={"check_same_thread": False})
|
||||
configure_engine(injected)
|
||||
assert get_engine() is injected
|
||||
from app.services.database import get_engine
|
||||
|
||||
# Clean database: create_db_and_tables() must create the file and stamp
|
||||
# *the configured engine*, not the URL from alembic.ini/settings.
|
||||
create_db_and_tables()
|
||||
assert tmp_path.joinpath("injected.db").exists()
|
||||
with Session(injected) as session:
|
||||
stamped = session.exec(text("SELECT version_num FROM alembic_version")).all()
|
||||
head = ScriptDirectory.from_config(
|
||||
Config(settings.alembic_config)
|
||||
).get_current_head()
|
||||
assert [row[0] for row in stamped] == [head]
|
||||
inspector = inspect(get_engine())
|
||||
|
||||
reset_engine()
|
||||
rebuilt = get_engine()
|
||||
assert rebuilt is not injected
|
||||
assert str(rebuilt.url) == str(database_service.settings.sqlalchemy_database_url)
|
||||
# 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
|
||||
assert isinstance(db_session, Session)
|
||||
|
||||
# Test that session works
|
||||
user = UserDB(name="Test_User", passwordhash="hash")
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
retrieved_user = db_session.get(UserDB, user.id)
|
||||
assert retrieved_user is not None
|
||||
assert retrieved_user.name == "Test_User"
|
||||
|
||||
|
||||
def test_add_and_refresh(db_session):
|
||||
|
||||
+101
-78
@@ -7,10 +7,75 @@ from app.model.models import (
|
||||
Card,
|
||||
GroupDB,
|
||||
OneShotAccess,
|
||||
Timetable,
|
||||
)
|
||||
from app.services.door import checkAccess
|
||||
|
||||
|
||||
def test_check_access_with_valid_timetable(db_session):
|
||||
# Setup: create card with valid access
|
||||
group = GroupDB(name="Test Group")
|
||||
db_session.add(group)
|
||||
db_session.commit()
|
||||
|
||||
card = Card(
|
||||
key="test-key-123",
|
||||
group_id=group.id,
|
||||
enabled=True,
|
||||
name="test_card",
|
||||
card_serial="00:00:00:00:00:00:00",
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
timetable = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.datetime.now().time(),
|
||||
duration=120, # 2 hours
|
||||
)
|
||||
db_session.add(timetable)
|
||||
|
||||
aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable")
|
||||
db_session.add(aa)
|
||||
aa.timetables = [timetable]
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
|
||||
# Test: access should be granted within time window
|
||||
assert checkAccess("test-key-123", db_session)
|
||||
|
||||
|
||||
def test_check_access_outside_hours(db_session):
|
||||
# Test when current time is outside valid hours
|
||||
group = GroupDB(name="Test Group")
|
||||
db_session.add(group)
|
||||
db_session.commit()
|
||||
|
||||
card = Card(
|
||||
key="test-key-123",
|
||||
group_id=group.id,
|
||||
enabled=True,
|
||||
name="test_card",
|
||||
card_serial="00:00:00:00:00:00:00",
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
timetable = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.time(1, 0),
|
||||
duration=1, # 2 hours
|
||||
)
|
||||
db_session.add(timetable)
|
||||
|
||||
aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable")
|
||||
db_session.add(aa)
|
||||
aa.timetables = [timetable]
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
assert not checkAccess("test-key-123", db_session)
|
||||
|
||||
|
||||
def test_check_access_with_valid_oneshot(db_session):
|
||||
# Setup: create card with valid access
|
||||
group = GroupDB(name="Test Group")
|
||||
@@ -45,10 +110,9 @@ def test_check_access_with_valid_oneshot(db_session):
|
||||
|
||||
def test_check_access_invalid_card(db_session):
|
||||
# Should raise exception for non-existent card
|
||||
with pytest.raises(Exception, match="No Access"):
|
||||
with pytest.raises(Exception):
|
||||
checkAccess("non-existent-key", db_session)
|
||||
|
||||
|
||||
def test_check_access_with_inactive_aa(db_session):
|
||||
# Setup: create card with valid access
|
||||
group = GroupDB(name="Test Group")
|
||||
@@ -64,19 +128,21 @@ def test_check_access_with_inactive_aa(db_session):
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
oneshot = OneShotAccess(
|
||||
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
timetable = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.datetime.now().time(),
|
||||
duration=120, # 2 hours
|
||||
)
|
||||
db_session.add(oneshot)
|
||||
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=False, type="oneshot")
|
||||
db_session.add(timetable)
|
||||
|
||||
aa = AccessAuthorizationDB(name="Test AA", is_active=False, type="timetable")
|
||||
db_session.add(aa)
|
||||
aa.oneshot = oneshot
|
||||
aa.timetables = [timetable]
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
|
||||
assert not checkAccess("test-key-123", db_session)
|
||||
|
||||
assert checkAccess("test-key-123", db_session) == False
|
||||
|
||||
def test_check_access_with_inactive_card(db_session):
|
||||
# Setup: create card with valid access
|
||||
@@ -93,19 +159,21 @@ def test_check_access_with_inactive_card(db_session):
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
oneshot = OneShotAccess(
|
||||
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
timetable = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.datetime.now().time(),
|
||||
duration=120, # 2 hours
|
||||
)
|
||||
db_session.add(oneshot)
|
||||
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=True, type="oneshot")
|
||||
db_session.add(timetable)
|
||||
|
||||
aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable")
|
||||
db_session.add(aa)
|
||||
aa.oneshot = oneshot
|
||||
aa.timetables = [timetable]
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
|
||||
assert not checkAccess("test-key-123", db_session)
|
||||
|
||||
assert checkAccess("test-key-123", db_session) == False
|
||||
|
||||
def test_regression_check_access_issue_28(db_session):
|
||||
# Having a active aa after a inactive/invalid one denies access
|
||||
@@ -122,75 +190,30 @@ def test_regression_check_access_issue_28(db_session):
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
# Creating oneshot with inactive AA
|
||||
oneshot1 = OneShotAccess(
|
||||
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
#Creating timetable with inactive AA
|
||||
timetable1 = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.datetime.now().time(),
|
||||
duration=120, # 2 hours
|
||||
)
|
||||
db_session.add(oneshot1)
|
||||
aa1 = AccessAuthorizationDB(name="First AA", is_active=False, type="oneshot")
|
||||
db_session.add(timetable1)
|
||||
aa1 = AccessAuthorizationDB(name="First AA", is_active=False, type="timetable")
|
||||
db_session.add(aa1)
|
||||
aa1.oneshot = oneshot1
|
||||
aa1.timetables = [timetable1]
|
||||
|
||||
# Creating oneshot with active AA
|
||||
oneshot2 = OneShotAccess(
|
||||
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
#Creating timetable with active AA
|
||||
timetable2 = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.datetime.now().time(),
|
||||
duration=120, # 2 hours
|
||||
)
|
||||
db_session.add(oneshot2)
|
||||
aa2 = AccessAuthorizationDB(name="Second AA", is_active=True, type="oneshot")
|
||||
db_session.add(timetable2)
|
||||
aa2 = AccessAuthorizationDB(name="Second AA", is_active=True, type="timetable")
|
||||
db_session.add(aa2)
|
||||
aa2.oneshot = oneshot2
|
||||
aa2.timetables = [timetable2]
|
||||
|
||||
group.accessauths = [aa1, aa2]
|
||||
|
||||
db_session.commit()
|
||||
|
||||
assert checkAccess("test-key-123", db_session)
|
||||
|
||||
|
||||
# Berlin's DST rules as a POSIX TZ string: +01:00 in winter, +02:00 in summer.
|
||||
BERLIN_TZ = "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||
|
||||
|
||||
def _create_card_with_oneshot(db_session, ends_at):
|
||||
"""Create a card whose only authorization is a oneshot ending at ends_at."""
|
||||
group = GroupDB(name="Test Group")
|
||||
db_session.add(group)
|
||||
db_session.commit()
|
||||
|
||||
card = Card(
|
||||
key="test-key-123",
|
||||
group_id=group.id,
|
||||
enabled=True,
|
||||
name="test_card",
|
||||
card_serial="00:00:00:00:00:00:00",
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
oneshot = OneShotAccess(uses=1, ends_at=ends_at)
|
||||
db_session.add(oneshot)
|
||||
|
||||
aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="oneshot")
|
||||
db_session.add(aa)
|
||||
aa.oneshot = oneshot
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_check_access_oneshot_end_time_is_compared_in_utc(db_session, system_tz):
|
||||
"""A oneshot ending in 30 minutes is valid even though the local clock is
|
||||
ahead of UTC: the stored end time is UTC and must not be read as local time."""
|
||||
ends_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=30)
|
||||
_create_card_with_oneshot(db_session, ends_at)
|
||||
|
||||
assert checkAccess("test-key-123", db_session)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_check_access_oneshot_expired(db_session, system_tz):
|
||||
"""A oneshot that already ended must be rejected whatever the local clock is."""
|
||||
ends_at = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=30)
|
||||
_create_card_with_oneshot(db_session, ends_at)
|
||||
|
||||
assert not checkAccess("test-key-123", db_session)
|
||||
assert checkAccess("test-key-123", db_session) == True
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Checks that the suite never resolves settings from .env or the ambient OS env."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from app.services.settings import Settings, settings
|
||||
from test.conftest import TEST_ENV
|
||||
|
||||
# Values a developer's .env / shell would plausibly contribute.
|
||||
HOSTILE_ENV = {
|
||||
"MOCK": "False",
|
||||
"DISABLE_CALDAV": "False",
|
||||
"DISABLE_CARDS": "False",
|
||||
"CALDAV_URL": "https://caldav.example.com/dav.php",
|
||||
"CALDAV_USER": "real-user",
|
||||
"CALDAV_PASSWORD": "real-password",
|
||||
"CALDAV_CALENDARS": '["Family"]',
|
||||
"CALDAV_REFRESH_INTERVAL": "1",
|
||||
"MIFARE_APP_MASTER_KEY": "a" * 16,
|
||||
"MIFARE_ACL_READ_BASE_KEY": "b" * 16,
|
||||
"MIFARE_ACL_WRITE_BASE_KEY": "c" * 16,
|
||||
"SQLALCHEMY_DATABASE_URL": "postgresql://db.example.com:5432/gatekeeper",
|
||||
"MQTT_HOST": "mqtt.example.com",
|
||||
"SECRET_KEY": "leaked-secret",
|
||||
}
|
||||
|
||||
|
||||
def test_every_settings_field_is_pinned_by_the_test_env():
|
||||
ambient = {name.upper() for name in Settings.model_fields}
|
||||
|
||||
missing = sorted(ambient - set(TEST_ENV))
|
||||
assert not missing, (
|
||||
f"Settings fields not pinned by conftest.TEST_ENV: {missing} — their "
|
||||
"value could come from .env or the OS environment"
|
||||
)
|
||||
|
||||
stale = [name for name in TEST_ENV if name not in ambient]
|
||||
assert not stale, f"TEST_ENV names that are no longer Settings fields: {stale}"
|
||||
|
||||
|
||||
def test_ambient_env_and_dotenv_could_not_supply_any_value():
|
||||
"""No field is left to .env: each one has a pinned value in os.environ."""
|
||||
ambient = {name.upper() for name in Settings.model_fields}
|
||||
unpinned = sorted(ambient - set(TEST_ENV))
|
||||
assert not unpinned, f"unpinned Settings fields: {unpinned}"
|
||||
|
||||
for name, expected in TEST_ENV.items():
|
||||
assert os.environ.get(name) == expected, (
|
||||
f"conftest did not pin {name} (os.environ={os.environ.get(name)!r})"
|
||||
)
|
||||
|
||||
for name, hostile in HOSTILE_ENV.items():
|
||||
leaked = os.environ.get(name)
|
||||
assert leaked != hostile, f"{name} leaked from the ambient env ({leaked!r})"
|
||||
|
||||
|
||||
def test_shared_settings_come_from_the_test_env():
|
||||
for name, expected in TEST_ENV.items():
|
||||
actual = getattr(settings, name.lower())
|
||||
assert _matches(actual, expected), (
|
||||
f"{name}: settings={actual!r} != test value {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
def _matches(actual, expected: str) -> bool:
|
||||
"""Compare a pydantic-parsed value against the raw string pinned in TEST_ENV."""
|
||||
if str(actual) == expected:
|
||||
return True
|
||||
try:
|
||||
return actual == json.loads(expected)
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
|
||||
|
||||
def test_env_vars_outrank_env_file(monkeypatch):
|
||||
"""Why the scrub matters: an injected env value wins over TEST_ENV/.env.
|
||||
|
||||
Scoped to this test (monkeypatch restores os.environ on teardown), which is how
|
||||
a test that needs other settings must do it.
|
||||
"""
|
||||
for name, value in HOSTILE_ENV.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
leaked = Settings()
|
||||
assert leaked.mock is False
|
||||
assert leaked.disable_caldav is False
|
||||
assert leaked.sqlalchemy_database_url.startswith("postgresql://")
|
||||
assert leaked.mqtt_host == "mqtt.example.com"
|
||||
|
||||
# The shared settings object the rest of the suite uses is untouched.
|
||||
assert settings.mock is True
|
||||
assert settings.disable_caldav is True
|
||||
assert settings.disable_cards is True
|
||||
@@ -134,38 +134,6 @@ filecache = [
|
||||
{ name = "filelock" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caldav"
|
||||
version = "3.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "calendaring-config" },
|
||||
{ name = "dnspython" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "icalendar-searcher" },
|
||||
{ name = "lxml" },
|
||||
{ name = "niquests" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "recurring-ical-events" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/04/d083c70cbdf6a201fd4a9c6184b3470bda55f14b13979ade80a5909fa623/caldav-3.7.0.tar.gz", hash = "sha256:e121600e858e62c3631b9460167a099685f78f4ffda98b1a96e460c7b252fb45", size = 804207, upload-time = "2026-10-29T21:32:39.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/55/152109585ebb2779e864db486a7d750bcd9af5c23fec82b0af0f778a9b46/caldav-3.7.0-py3-none-any.whl", hash = "sha256:41d052f535e3f3e733b84cc455b2f62145aa70097eb7e345697fbe451b5e3db7", size = 292739, upload-time = "2026-10-29T21:32:37.237Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calendaring-config"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/ce/bd49e6a64ade7fa09d872eea80601281b1b4167b737ceb0de2b16d29b329/calendaring_config-0.5.0.tar.gz", hash = "sha256:ed68ae96a5d9790cdf0ea6a933ddafd617523759a8a3fd273c63a4f3c3dcc41c", size = 37968, upload-time = "2026-10-22T14:44:50.337Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/17/3e4e04e3a344b2260139e64f53d34e95163e02b128b6066753540d5df524/calendaring_config-0.5.0-py3-none-any.whl", hash = "sha256:c5e1f6ffaf428a59b0e1373d951eb8e7fd9f49924912e65548cc2803e73a8646", size = 28571, upload-time = "2026-10-22T14:44:49.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cattrs"
|
||||
version = "26.1.0"
|
||||
@@ -690,8 +658,8 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "caldav" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "lgpio", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "poetry" },
|
||||
{ name = "pwdlib", extra = ["argon2"] },
|
||||
@@ -715,8 +683,8 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.5" },
|
||||
{ name = "caldav", specifier = ">=3.7.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
|
||||
{ name = "lgpio", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'", specifier = ">=0.2.2.0" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||
{ name = "poetry", specifier = ">=2.3.4" },
|
||||
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
|
||||
@@ -827,32 +795,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "7.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2b/1bbf82d316df18c3331d9a06228819c8a5814ceda545a3e9980e52ffce1b/icalendar-7.3.0.tar.gz", hash = "sha256:7bd001c8e648205e1bde5c6a5b77096598e8d0893dcf57755c6c597635620132", size = 525811, upload-time = "2026-08-19T15:10:21.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/82/50bff78b0bb0c7d7c0cb39e0ee189b92f611fff6bd7cf57f25e92d5a7551/icalendar-7.3.0-py3-none-any.whl", hash = "sha256:8355acfe17be81b368f0b1e3740817cea9b56ea889931f8f1a87c62f2d28db0b", size = 542540, upload-time = "2026-08-19T15:10:19.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar-searcher"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
{ name = "recurring-ical-events" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6f/42fd52fe3d216d8681ac3b05ddf76ff735ed518a289189558400ca676ee1/icalendar_searcher-1.3.0.tar.gz", hash = "sha256:9cf9b881c54b596bf5867e226fd7044603a124d6bb3cf879171854d39027475e", size = 117981, upload-time = "2026-10-21T10:48:10.537Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/f2/11841ab790fb9cea568159b793275aad3ec2e87712fb15cb3a70e9b772e3/icalendar_searcher-1.3.0-py3-none-any.whl", hash = "sha256:3492a5d3c30705bd5386b05760bb8ec63b855aa6766ff230431b13b4c39f34b6", size = 51179, upload-time = "2026-10-21T10:48:09.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -962,75 +904,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jh2"
|
||||
version = "5.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/fa/a3ba1417800b142d6777c48657f6b01fc46bd1a4777aba979463e36d78c5/jh2-5.0.15.tar.gz", hash = "sha256:53fb377caca50441fd1892f60745c44d3ba8c43844c3792e73c34bf9f841adaf", size = 7336589, upload-time = "2026-09-21T09:26:30.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/accbdb5274bc4bee11fc7d8a1b94fd8ffcfd8efbee02765afb6023373f84/jh2-5.0.15-cp311-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:855dea668a30eab826ecdbc07b69b19e8daedeb1f0519e02759a6b37426354c5", size = 631133, upload-time = "2026-09-21T09:23:08.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a7/2b63b9ad01c4d9e7742a528501ab9d57503dbc968b1e40cc0084f9c8dba3/jh2-5.0.15-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd129c07f30387463e634d7aec629da31b041219e17c6d42fc7a7641e1e7ef8b", size = 401835, upload-time = "2026-09-21T09:23:10.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/2a/31f3046c4ca641aa462deb35477e3eb88ad74c52fa0a8003b4c2514d5369/jh2-5.0.15-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f702abcd2e3666d54f721629ec921a1e698357e28f6348af7b4d9d95b89fe37", size = 404159, upload-time = "2026-09-21T09:23:12.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/e3/b13b1fcb66c8668cd642849aa7858b9a33a1df99d96a5dd68115a7b96d58/jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9698673b2cccac7a27dd4ae48d6f4936c998a7962043049783dab4fd03e65084", size = 529332, upload-time = "2026-09-21T09:23:14.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ad/5a903ea3cef3d75751878ce46f2e49f1b32de8fba7a26bebfcaed1d6f8fc/jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9695b0f3c696cb87b851b3f28d5dc81d8932c81a1547713913266b88ee99e37a", size = 521994, upload-time = "2026-09-21T09:23:16.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a3/44195ec39fa7e38b182c3db025b8dd99799cad96bdb50f999f509b16cc10/jh2-5.0.15-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d71f6aa2c97f8042669102200a1a2dc1241e0fb5044cc14032d0321936f85cd0", size = 421130, upload-time = "2026-09-21T09:23:18.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/fa/d80e721e77714ccda44437da86b8424dd03ce07977c8f7f0dc3d6efd805c/jh2-5.0.15-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4ac921ddd2d519646df9b94f96e2cd55529f92f6d7ff4c65508ca2f65399ef", size = 407364, upload-time = "2026-09-21T09:23:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/06/f3ec546c42ce68476b33113d799bce960cc1cd05cfc05e7e352d10a16ec3/jh2-5.0.15-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f0257484827212950bb4624dbfdf70d713dbaed5b1c489151b41f525fea3c1", size = 424773, upload-time = "2026-09-21T09:23:22.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/f8/0939b4cfc3ea9b5c7e372b8c9c8302f2d30ec20d8f92525874135f5395ff/jh2-5.0.15-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f1f45e3a7f4b13ec10e9a55d1188fb64f090a24fd847ad124fde4da3682e8ab", size = 578499, upload-time = "2026-09-21T09:23:24.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/82/ac555673b970e536621d27b6864c097f34a186fef24056dae4c4df9ba1c6/jh2-5.0.15-cp311-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:1a7f6ee93c57672f7bd7c3e2c2f841a35c1a0c3571c39abc674e125f344242fa", size = 680124, upload-time = "2026-09-21T09:23:26.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/21/2761e9fad9a436ffdde17ff29c7816c511caca22f1749f342b21ab270f0d/jh2-5.0.15-cp311-abi3-musllinux_1_1_i686.whl", hash = "sha256:7bddf19067d411e900cc19fdfee566cc3bc0aa1b76d07666202a8fcbf2c980e5", size = 641709, upload-time = "2026-09-21T09:23:27.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fc/2a601d517907fc65ca6958874fed2a6ea0b6300d628ec6afdfed7f636bde/jh2-5.0.15-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:bfd24d34ed13b5943d5d548a86fd0efaa5029aaa6615932e677ea14d1a47324b", size = 610414, upload-time = "2026-09-21T09:23:29.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/85fb291a6c971417c6793c57f7cd76e2f70bef4d9930b5fcf9efbc618002/jh2-5.0.15-cp311-abi3-win32.whl", hash = "sha256:03ef4cea5c924a89d3747fa5eca768763e727326ddf8d2ee20b66f4cd2942161", size = 254216, upload-time = "2026-09-21T09:23:31.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e2/55d6701b78285b8b6d595700b5c7179752941e4212ef873960e5e700a566/jh2-5.0.15-cp311-abi3-win_amd64.whl", hash = "sha256:5a32cd4bb2fc7f03315b3415b0d1f37b204f4334b352e68c25e3e31f0750b7cc", size = 261552, upload-time = "2026-09-21T09:23:32.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bd/f2fc94e23a2eabd613c83b8fddcfd7e1be20c91545cc90762a4deb088fa9/jh2-5.0.15-cp311-abi3-win_arm64.whl", hash = "sha256:4d0b98c5c2f27d7dfcec835960f9e4c76c7936f4bad1c94ab323f8f0831295c6", size = 258453, upload-time = "2026-09-21T09:23:34.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b7/08fc98d041e3054aa412e8a596994450e2df21c7bf8630d58e44773caf30/jh2-5.0.15-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3d4a44a6e28b1fc653795b5e34bd910ae90022f8bd25320a0e9e70851864a154", size = 634869, upload-time = "2026-09-21T09:23:35.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d8/01da7a605dff616785e941f8b1486efc6134e81b93dedafdeb1bd6a650ff/jh2-5.0.15-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43ae82fbeada5b1a8c9460d83ed2a527e3284b14538f0f9336ebdee137d59ef1", size = 401920, upload-time = "2026-09-21T09:23:37.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/69/7554341fd992b3e1ae64a20a8f3cc07ad6c1371c25656b6650a2c3862f65/jh2-5.0.15-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8c934fc618a663796f104f6f10519e27a45020f4163e4ff9ff3ae2bb9254cf2", size = 406607, upload-time = "2026-09-21T09:23:38.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/66bc2b44fe444180fc847633882d9f1d7f69fe48929c2e8f53dbb295830d/jh2-5.0.15-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:249b38e59fa0e5e4c1f9dfb935e30fa2f3a20bbeb547f9725494fc2c62f59d58", size = 528423, upload-time = "2026-09-21T09:23:40.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ef/dcfb83b72a15731e92f1b06ecea85e71052f362433adabecfde158931022/jh2-5.0.15-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c69436326bb70f759695b58f9630628c5e54d3c2c3efda819ae402df30644f28", size = 519933, upload-time = "2026-09-21T09:23:42.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/be/bf04f4bf6f5314760aaf3536d0e7137bad96822a8428e42ec19a9432f90e/jh2-5.0.15-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56746b5542a0181e2717af4ae151f4c51761de9999e227d7022d98593b0d3e8d", size = 423079, upload-time = "2026-09-21T09:23:43.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/8b/0404495d2e201a0a1461be9ed9e33d586a53975e6e383b3144f70bf95de7/jh2-5.0.15-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d9e7387e7d123f42afa3ce6867c71d071a73342c359263bf29b3328e48b7c", size = 407070, upload-time = "2026-09-21T09:23:45.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/41/6d8a16b983aad43d471022ab7ccb98e953e8a8f64c4f20bf54da1fe01fd7/jh2-5.0.15-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:727859bf2579af904b70294a5f8e096776396be8410459c635e87742ba3ac23a", size = 425687, upload-time = "2026-09-21T09:23:46.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ae/c643cd0cc0260557dc6b1b3c59389b5edb1166e30ed170b757424029a424/jh2-5.0.15-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:778e72cc6a7ea3273ce290c0a71ad907cb3482d70df33e625c76f180754ff2d3", size = 578424, upload-time = "2026-09-21T09:23:48.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5b/cf84e2563f25faf777beeec1bc4b7b44e353b252a87354951d91d98ceeca/jh2-5.0.15-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:835c5aab444a7bd3694c1941b27e0da85ff7476629812b7d6b91efafcf1cdd28", size = 683048, upload-time = "2026-09-21T09:23:49.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/7a/5edc713b7b86587f33c209cf911f8b9139967310a55f32ab92840fcf3c1e/jh2-5.0.15-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:e33d7d2213b74c860f0ec5731484059e00e729debb3a385f8120f0b92af7a277", size = 642329, upload-time = "2026-09-21T09:23:51.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/72/f1e910a74ddb000f20353f5ba59e3dd5ee3d133f4d45ad4378a9ae55eb7e/jh2-5.0.15-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ba3ad560f3f0b9efbc4f3d8ee0b28fe010ae39918ae46203ff1f9446b33491c0", size = 610518, upload-time = "2026-09-21T09:23:53.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/c4/92a57264dc432556c5fb90b75d7d3202bc2128ad6d5f14aebf18fdb72c96/jh2-5.0.15-cp313-cp313t-win32.whl", hash = "sha256:faa0733a4c2dd0b38200d665bc2f33f9dc33d0fcb353e00744da555570816527", size = 255378, upload-time = "2026-09-21T09:23:54.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/79/1cd633016cbdeb12c4743e8d1d54bef2f669f5c5514ab6b483e3351901aa/jh2-5.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:4fbc6e3784e778a834a6201735c3b71505393a471c336e9f518c07ecbf65f07d", size = 263494, upload-time = "2026-09-21T09:23:56.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fa/fd28891b1a2eaf3291738381683aa1971479e366a0ecbcadd41a21c3bce4/jh2-5.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:5364883ba707e689167e37e795f2e5b04cb69379cd716cdef00c6137437679b8", size = 260108, upload-time = "2026-09-21T09:23:57.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ac/594165f15b860cca56264eda0a174e01d1ae857b954ea748cd378f0a4db4/jh2-5.0.15-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:136f1f06d644e105ace8e830a55c66bb6be15355af427c013cefabbb4360889c", size = 634614, upload-time = "2026-09-21T09:23:59.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ea/519be71bc4188ee72becdf4c0dbd1c30db5d0d4d9121d3bdcba9ebd9f948/jh2-5.0.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7aae38ee0dea5f3af2a918597df28f7659d1e82d1795605ee46c5d291d7e8ff", size = 401257, upload-time = "2026-09-21T09:24:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f4/c27efd5a931f172fbdf69af1a75abd0433798cef646eddd5f47673f04774/jh2-5.0.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34f864522d4fafd8a61bf40dc01e2a93840e6c1a17dba37db008529be22ef40a", size = 405697, upload-time = "2026-09-21T09:24:02.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1a/ae7cf874b48babe926fc1b3b861892e291bee7b59387a921dfcacc88f146/jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:783463574dcac3a6d8ae948e2110bb1b46099c79b919912c88360d21b7c91b5c", size = 527107, upload-time = "2026-09-21T09:24:04.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/50/e2dafa5435a02265eabe747b58c4addcc917b0f8b76fbabf2a0a1627f697/jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d6069850eb0febdcdd649a4f5dbe74560ea6219ddce467808b4b4bf532c14e3", size = 519280, upload-time = "2026-09-21T09:24:06.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/95/ef4cc29497ddd194c681af013dc47875c807bb20dbf66d8235c4261b1adb/jh2-5.0.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6af18cc6b061b5830b121fff1cc876e864e881909c446fd606903e64a9796567", size = 422291, upload-time = "2026-09-21T09:24:07.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c5/67eb11e17093e2ac5c5efe118a48ce0ab94eac8a66213721406c67a1517c/jh2-5.0.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aaf2dd05d85289cf83641f3c4f54947a0168fa81c46fc9038ea9d241caad50e", size = 406956, upload-time = "2026-09-21T09:24:09.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/6456d56f796304fb3ead01125b9eff8301e03c8277f505b5e34cc8a50b08/jh2-5.0.15-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bad01872ec803e67d91228fe5f040175c86bbdb0b345be4ce0c5ccd620a220a0", size = 425105, upload-time = "2026-09-21T09:24:11.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e2/9cb3bc8a54a646d837fef8427728417d252aff3c470725acdc41a89394a9/jh2-5.0.15-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c201b89f9d15f563fe4d6dad0738ce02641636c13b141096ad7497aa7bd95b3c", size = 577255, upload-time = "2026-09-21T09:24:12.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/c7/8a71b2f0bd38b94208c34396108310a42d71b214a8c6a960771a79804cc0/jh2-5.0.15-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:b43d69d715f77d840d5600842a179d8175625bcb5f5d8dbe9bc7ed71dcb43696", size = 681961, upload-time = "2026-09-21T09:24:14.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/87/727968778eebb3e328c37c24435c7cca91443ace85a83da964ad35787017/jh2-5.0.15-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:4a511057fb3d8bc6cb76eed0645a8a446e870640d9250144f0d403007383e2c3", size = 641875, upload-time = "2026-09-21T09:24:16.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d2/422129114ce5a243a33d27b93a9c67893400ef648c1e5b0b7472ca1de516/jh2-5.0.15-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c4a2c16fae658a2f7e67d1af3f1cfaf03003cb2f6d9a8c1d3f237b337614d568", size = 609990, upload-time = "2026-09-21T09:24:17.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/13/ad4e1ea235fa5f79ffafb9ff7992758c4400cadf8931df63069ddb7162d5/jh2-5.0.15-cp314-cp314t-win32.whl", hash = "sha256:36aeaa24c9af2a2ac0830523721d0fadad5602842d662aa0c5391b89ea3e7d1e", size = 255313, upload-time = "2026-09-21T09:24:19.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f9/d5642a254e2f6526c4ed09354d495451f4db2840e69a16afbecc2654620c/jh2-5.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:a88320003a21783b5ab5c605ba4b687f836b46dadedc55cfc3e64ca88c41b81d", size = 262881, upload-time = "2026-09-21T09:24:20.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/a3/663a4a10b42e2603b14fe7655ceaab716afec6cbf45584dbac4b409c0188/jh2-5.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:7eb050ada76d078cf27b7660451c0ee7b38e3f770222d852cc34d2f4cf0bc782", size = 259576, upload-time = "2026-09-21T09:24:22.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9e/a9a50a5176a720d9ea6752bfa36b4ee9655b26ff9f4870bd71cabb1ec63d/jh2-5.0.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:06cd5acc4ff28ac27cca5c2f0985d259e81bc29b845c521aae04440a369a00ae", size = 647460, upload-time = "2026-09-21T09:24:24.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/3e/df15495c118ae1749c74df00c4355dab7c44cd5859f3bd648573e1df2581/jh2-5.0.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8912f22c97cdcb00edce434b61fa1d05af41ad07deb6bb1ed9e06348df11eea", size = 409994, upload-time = "2026-09-21T09:24:25.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/4f/3e1ce6fb6728de964ff87ced5574988bea1ae7306f087cad5cef17466bda/jh2-5.0.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d28771bccaa6944c4e606a44b1035ea11b2d684bd2c7c195ee3c3a9afd84443f", size = 414812, upload-time = "2026-09-21T09:24:27.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/21/988a70b062dfc1a78f210ebc17a98fce8592b4245b6c3d81cd9b2d865f02/jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e0fda6e8a7b7364077a03749321f3bb9f01aa369ab659f63d241860f4b66bbb", size = 536476, upload-time = "2026-09-21T09:24:29.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/83/81cd4dcd7b24d010e525f25c6be71ffa019138585da62a8edc0753d2d0c8/jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6596a9d05f049f783326e927b5363ebe0b1da6716835237ae32b642529b405ac", size = 527785, upload-time = "2026-09-21T09:24:31.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/f2/e6bbd8667dd3516764026fef86367c9c6f79a8b4611b53324d7088dd99e1/jh2-5.0.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29a0014ee53ffc8bf0581dd77270e25329397c55f4724bb18fddef9a105177e5", size = 429758, upload-time = "2026-09-21T09:24:33.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0b/3118b778d2cbc2eccac1ee6007d47fb0030398f4a6df3425656b1a624347/jh2-5.0.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cd7e4d259eab50e076e4b30ecbd937e55b319a58ad75642bdffb401455520b7", size = 416188, upload-time = "2026-09-21T09:24:35.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/cf/67c69906bae910700fc02acd0865a4ac3d247a50adc5e6ebc070cbfcc223/jh2-5.0.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97299e27743ebf52d9544e596165700407b8b46394870dc374d6fd49c3251084", size = 434214, upload-time = "2026-09-21T09:24:36.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/15/ba3848b2ef78203f6d1fc570e41067fbd2494950dc1a94c580d10c3e6380/jh2-5.0.15-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:95443c602d6e7fa8aa5e49d19381cb5a39d53b79a1678e90a4c39f4f0f0542e5", size = 587932, upload-time = "2026-09-21T09:24:38.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b7/dd90d99d86381f4df44bf69ddc9a8b5e68c27ad795587b563fdcb9306d9f/jh2-5.0.15-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:13fa1a5e98a8c8f55405e2e7c6f6a1e005d01a48f7510826ff34939f624b1ad6", size = 691155, upload-time = "2026-09-21T09:24:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/cf/8d771ac927d49cc279841a592f4ae4bb2d962dcf9ca6952973e0dfc4bef2/jh2-5.0.15-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:aa99840f9222928b0c9c0dd2b0c0308f5d1be8f6815bbf6f820746c51de54dd7", size = 651868, upload-time = "2026-09-21T09:24:41.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ed/0c7328a574efe79203af639e37072f25cdd70386a3d1ddd31dc44d92259e/jh2-5.0.15-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:723a0a4c5ac0e61c94cb275118c7a6a6aaf5fd56084b02632f4b080cc83c6691", size = 618661, upload-time = "2026-09-21T09:24:42.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/2d/10348c52fc0660729ad4fb5285c2546e2c879b326a6ff332764bd837b468/jh2-5.0.15-cp37-abi3-win32.whl", hash = "sha256:fa1c3885ed090c65054e4bbd983a407df0436121b1a841ba7fe1f678a7982c65", size = 261264, upload-time = "2026-09-21T09:24:44.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/94/d702bcbf81730301bbac04c93e82e533bc59dcdd608b9ffd0e99717518be/jh2-5.0.15-cp37-abi3-win_amd64.whl", hash = "sha256:8c5850335fe11078b42fab606e5ac4a68c6ee8c6799864badc464f7839f06ec2", size = 269034, upload-time = "2026-09-21T09:24:46.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/1d/c7cb221314fbe3c119a8c8e463a38bd70c3d9bfb2fa09f107399843dd353/jh2-5.0.15-cp37-abi3-win_arm64.whl", hash = "sha256:9493d5a20f6ba04dc285e3580bcffe64c0d61660c85aee7fc19e9adbaea55ef7", size = 266111, upload-time = "2026-09-21T09:24:48.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/9f/f94e7a18e880824bef49a1f80b4ce081570a3c19e8a3937ad2fea0544651/jh2-5.0.15-py3-none-any.whl", hash = "sha256:0ed7246846e68109fef261fdb00534ca5af3363d48dbf881d06a2d0e3a814e99", size = 99140, upload-time = "2026-09-21T09:26:28.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -1060,6 +933,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lgpio"
|
||||
version = "0.2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/33/26ec2e8049eaa2f077bf23a12dc61ca559fbfa7bea0516bf263d657ae275/lgpio-0.2.2.0.tar.gz", hash = "sha256:11372e653b200f76a0b3ef8a23a0735c85ec678a9f8550b9893151ed0f863fff", size = 90087, upload-time = "2024-03-29T21:59:55.901Z" }
|
||||
|
||||
[[package]]
|
||||
name = "lsprotocol"
|
||||
version = "2025.0.0"
|
||||
@@ -1073,98 +952,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/05/3ef45db776baea068044c799bbba68f3ca00a440c0e930a17c572f3d9639/lxml-6.1.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3a48093cdb058a93af842ede9703520e810b05dcd0fc6d7190a06376c3bfb6bd", size = 8590357, upload-time = "2026-09-02T14:48:17.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a5/eee2fc77eee5ea68e4a4334b1def1781a3beaeefd3d98e81b4a38dc447b7/lxml-6.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:887c021d9a977cff89cb273047c1352997b772a8908a25c21836861f69b92be1", size = 4632616, upload-time = "2026-09-02T14:48:20.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/42/df27b56848acd29d8a720acc28977911aab36f2a09df4208d5502e887415/lxml-6.1.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:611a51e61c92f62345a50b0035df6fc0d678f9299f33728826d831598862f59d", size = 4936186, upload-time = "2026-09-02T14:48:22.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/8d/8a7b91df0b54d09d25f5f44885d6b3e0a6d6643a8c070191580318d20c42/lxml-6.1.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b477912f42c5c33405a10c759d22f80cf5af043ae02d95b9d8e5e5bc555739ed", size = 5093324, upload-time = "2026-09-02T14:48:25.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7e/8f340ddcd43790332fb0de8a26628d571a492da3300cd191821698407c96/lxml-6.1.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cffe18571ccc51d742cd08cbb3f8b756de9311d18c7ea98f5d92f37b8fb60c2", size = 4998850, upload-time = "2026-09-02T14:48:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/c1/9c5bb572f1f09ec9e4322bd4a4e9f4ad48347fc56ef94cf4df58a5279dc8/lxml-6.1.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75cc6569e86be5785b6188ef1642670c6adbc984e81ec35e224842ecd9eefcc8", size = 5626813, upload-time = "2026-09-02T14:48:29.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/7d/8bf1fd8bae8247743968bb76d027a1ac5bd2c4b44495fba6a71b30d10706/lxml-6.1.3-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d85dfab42dd672f87a7f76e9de7172962aee69fa12044f0d6e1a23cbd53fb80e", size = 5232385, upload-time = "2026-09-02T14:48:31.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2e/6cef69ed81cb7df0d03b0dd09d08e6e2cf5061a743ff6f42f0b741548e9b/lxml-6.1.3-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:42632b4024ab24a6b488f559ac851312509888b6b80ae2aa11cf29a646a0d245", size = 5347088, upload-time = "2026-09-02T14:48:34.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e1/8e5fd8ddc8c7d685badb0f2db149e3c9da84eefc2827c01c658df2c4e3cb/lxml-6.1.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:febd35ef45f603c2d74b74655efdbf45e14f55fc0aef4ac82b663ca829b283e0", size = 4707227, upload-time = "2026-09-02T14:48:36.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/7e/00041382a11be40a88bf405ebff11c8efabd3de79f2691e1638b1c47a8a0/lxml-6.1.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a43b3bdf11e477dc7770609d3477316f974354dfc8425d596f64f471cc8daf6e", size = 5240208, upload-time = "2026-09-02T14:48:38.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fe/316538b5cff0936fa63d45d421c655730fcbb5a28dcac728c175083002bc/lxml-6.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d582042c69857c364e8153de6e18e0da9b7b515a6a8113caf69a6ec8e0520f2", size = 5050271, upload-time = "2026-09-02T14:48:41.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/91/455bcccb3ac725373007344d351151810cd19762d1673b64b811f4359a42/lxml-6.1.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e49a646acfab83c68974f4aa1d0a2acca9e88d7d627ae0fc13201b14b76d310", size = 4780433, upload-time = "2026-09-02T14:48:43.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/f6/580440e2f52cf00bba5c5e1080bfa88cdfcde73be71a11d95170ddbb663f/lxml-6.1.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0dee106e9aa97fb00541b1ed7827070564d0549c3d3fba8920e6b20fd980f748", size = 5645928, upload-time = "2026-09-02T14:48:46.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/dc/d123c1f244306543d545f62443f794959e4f1ea709fe100f8740d514e74a/lxml-6.1.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd5e90f34cffcfed97f36cf066325773d2b6021c60c29942e53a18b028501b1d", size = 5231184, upload-time = "2026-09-02T14:48:48.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/3c/fe55b2bd5c6113c906511cd88f6a470195c5fbff1124f19970ab706c3477/lxml-6.1.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9b3e7d71bf6acff341233417abbdface29c647e3113892d9aaedc02eb4aa2bc", size = 5255814, upload-time = "2026-09-02T14:48:50.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a7/485df55acf55dc35e4ca89d2f48f03889e5a3241826b18b85102b32ce9d8/lxml-6.1.3-cp313-cp313-win32.whl", hash = "sha256:160fcf381f76c3aeac28a756bec44f48942a8f7245a87aa28e3a523b4d90cd87", size = 3602214, upload-time = "2026-09-02T14:48:53.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/28/e46a7702bd95e9043291f7c3539b6184cba66f96cea9936f20939b284eeb/lxml-6.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:e477aca0bc0d19f3b4ae9e4f2a1cfd687c31bf772d78734910658186b40b2477", size = 4004091, upload-time = "2026-09-02T14:48:55.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1d/154c78e20479a43916e63f19cb720d83f44f024b03228be44c92d9a97b24/lxml-6.1.3-cp313-cp313-win_arm64.whl", hash = "sha256:b1cc980905221a5d8b3c476330730b3adb40ff80add71ffbdb6215ba055656f1", size = 3665468, upload-time = "2026-09-02T14:48:57.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/15/fc75a70b0af6021d0ea16811f1fc71cc42cd06ce90fe10f007a69b2eed84/lxml-6.1.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2bec13085dc8ef48a3fe62f7dfcacfeda2c785cdf19cc8eeda2bb9ed081da165", size = 8609725, upload-time = "2026-09-02T14:49:00.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/398fcf9018f881ec9aeaafae1ddd6586dfb13314a35d35e899de373dcae0/lxml-6.1.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f4db7c7e954d289d71878938348b3d91b904a3e8210a11939359fb758a58e7d", size = 4639629, upload-time = "2026-09-02T14:49:02.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/2d/49b6a6ad7ce8f64b07b9fe852ff0c6d3fcbb26db61bee4f63d4120180a1c/lxml-6.1.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2cae5d5c90a62d9139c512a0cb1aad1d182b022b5740daea2617eb5bf7fc658e", size = 4965074, upload-time = "2026-09-02T14:49:05.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/bc/6230cf80e4331c33383b0b6b73dc31a393dd76edd4cb73d761de5123034d/lxml-6.1.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c6c0c13128a32eb04a51357e56a094e13aa8e6d3d1884de2e9ae923f6915e1a8", size = 5099355, upload-time = "2026-09-02T14:49:07.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/cf/d1143d9b7717e07a82f158a1fc9ce6e581fdad1226734950af869e3ffde4/lxml-6.1.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2221e88679d1351e9a40aaee54bc65679b9795bbd0160bc3d5e36b163344eb75", size = 5036795, upload-time = "2026-09-02T14:49:09.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6f/194bb00ffb89712c30f5a7e1b8e685590e140fad6c8261fec172c09a3dc0/lxml-6.1.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfb398886a7eb4c719161c3efcff2a1248febc53a4d8e5072d2d8a87fed84ac9", size = 5658740, upload-time = "2026-09-02T14:49:11.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/27e3cee3dcdb3b7bc09727b642bdbfcd098490ea77df04611db9060d7722/lxml-6.1.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7eb78ba28b187e1e9203a55c60fcf70df2d22cb205fe6d51b9383d6097419f0", size = 5245991, upload-time = "2026-09-02T14:49:14.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e9/8312560579fc980bbd2233a8a673cc46f7d613d3633f2bf08a21e8f4ad13/lxml-6.1.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:ea6b1e9105b4b24a34c722432d9fb578f9ed83af21fa1abda639011e0f22bbb6", size = 5354136, upload-time = "2026-09-02T14:49:16.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d8/eda60f4f73a9c780b5d6e1175484f66e6c81a2c93346e2906a1fec9c7a02/lxml-6.1.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e8b17e23df3e827a69d25af70990ca2420e92668aaffaeeb3cd2351d7916a023", size = 4704379, upload-time = "2026-09-02T14:49:19.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c8/c9cc60057be78ac34bd2b842e45e6e88edbfe5e532e82c3b82381b7aab49/lxml-6.1.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b7c37339d7e75cab9a123a04248e243cefefb302ad6db566ea0c77cbcde421e", size = 5258676, upload-time = "2026-09-02T14:49:21.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/7b/66894008fee8d1785b8db129747ae963fd427b68f456918df7f2f24a8b98/lxml-6.1.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83e3a51e7933db700a0da0db31849db3a24022d9970da9bb73001e1d0326fd92", size = 5090069, upload-time = "2026-09-02T14:49:23.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/31/c1b60404859f4c3cd1f41f29c65a24e25cea78fde822d9574a21f66810be/lxml-6.1.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9bde9ae026a55b9a192078dfa6e27dd0ca4a050171ab6272e92f97b757dfdf48", size = 4741958, upload-time = "2026-09-02T14:49:26.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/b8/6285f0cf546f14da2554cabdeaf7c2c2ff3190c74807f0de2e8810a786f9/lxml-6.1.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a635e837b50a1819bebfedaac5916498ea024120969da8790500148fb0a894d", size = 5683245, upload-time = "2026-09-02T14:49:28.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f6/2168cab44336dcb15fed0f0b78577225b83297cdf0dee349c95420c3dcb0/lxml-6.1.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d0c5c362bc94f1929dc7e96e715bbe7bd17037f802e6d8f0d1545df9133c0559", size = 5246087, upload-time = "2026-09-02T14:49:30.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/89/32f5de69a0a31f30e6164981851f87b37ecb2c4ee838e504b88d49d4818e/lxml-6.1.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c59e4265608da6a041f54646ecc0c9ecdbb19aaf14c4c684bb6c2114998cc415", size = 5269352, upload-time = "2026-09-02T14:49:33.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/a1/741d952ed3a7ef7a50055c6415aec3f067015e97f72f4389ce77b09657ba/lxml-6.1.3-cp314-cp314-win32.whl", hash = "sha256:2e62c569ec7531b679b184cbfe335c501c1d13c4b363560013019962eb630e6d", size = 3662783, upload-time = "2026-09-02T14:50:23.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/bc/5811cc73cac05e324e05ba9b0924e1a163a317a167ede8a9c748b11db30a/lxml-6.1.3-cp314-cp314-win_amd64.whl", hash = "sha256:66299564c046bc7e0cc5de5106601eae907e9fa5904cd68a323380a8502f7861", size = 4073951, upload-time = "2026-09-02T14:50:26.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/18/3768c8b01ac3a9bed1914715e6011711b00e2a11628ffa6f7fa37f8e0269/lxml-6.1.3-cp314-cp314-win_arm64.whl", hash = "sha256:ebd054ad1737a68fb7c5c073d405cef2b88bb824e294de3b4a4e995b47f0e376", size = 3749279, upload-time = "2026-09-02T14:50:28.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/38/84684784738d9451db2b330de2483f496690c3a5c642071df24135739b37/lxml-6.1.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5a143e6207579de8baeded4eaac9134413200359f1969d636f0bfb98ee8c3c8f", size = 8860296, upload-time = "2026-09-02T14:49:36.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b7/fc4c50bb1b38e864010ea396046cabe85129bf9e65b11edcfbc37d356241/lxml-6.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1cec0f99b9b914d39176347a93b7610dc09324491aee1cbc57cd291a41a1d55", size = 4755190, upload-time = "2026-09-02T14:49:39.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/e2/ee9aa6ed2b666b2db1f6f7fd48964ff9da39ebe827ef5eac0ab881f639d9/lxml-6.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6b9d2aad499c769ee8287609ab0e6de99d8bcea99c6e6c2e64945259fd52fb2", size = 4979517, upload-time = "2026-09-02T14:49:42.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/e3/e7763d1661b283ddd4fa36f91b9a497db6b8d2aff55028b16c7f642e0755/lxml-6.1.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a23fefdb345b2d4d0ff2860571b5ff9a89a28b6a120f720e8fb0324d346626", size = 5115270, upload-time = "2026-09-02T14:49:44.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cd/22205d5b4d177e3f4156f780412426ee7c7f8107809f119f0dcc40fa51e3/lxml-6.1.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:545ccc14fb05485f48b4439ec35beb16d5b5280eb6c81c658bd4707a2a119414", size = 5032449, upload-time = "2026-09-02T14:49:46.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/43/06a4626c3bb79ef8c501b674afab8100d64e798665bb2a97d1c960636a49/lxml-6.1.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:93476b6514b373fc6ca67d26c442784f7807c86f00635bfe79f935c3eab2af17", size = 5603325, upload-time = "2026-09-02T14:49:49.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9c/733682a0c2de9f5779ba207bbb3f3f6be8c6bda863fc01739b186b38783a/lxml-6.1.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8db38ff3fb7aee7d6a82ae4da2eef1178656fe1216841fbd24870062a9d60473", size = 5229023, upload-time = "2026-09-02T14:49:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/8a/e69cdaca3fd33a647942925664f01b20908d41a6968c182305be9c38fb11/lxml-6.1.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:25f4118c438f96bb466e83108506d03d5c31b1bd2387e83e5b070bda6ded9c37", size = 5317811, upload-time = "2026-09-02T14:49:55.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b2/0c397588174403c2ab68fc464abf97e03e7324f9c6cb6a99023104707195/lxml-6.1.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:1beb0f9909b26cee938df9ba56b15252a84429b1fc30ce6fca161390b9789a70", size = 4646516, upload-time = "2026-09-02T14:49:57.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/7e/cfea25afafbe49db8b225764f7f74bb37c2a7f5e717d917d3d4a5e098ed4/lxml-6.1.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a27ac6c780c8b8a1cd231b58407634cafc1c4cc28cd6c7141362df0f36351e7", size = 5240626, upload-time = "2026-09-02T14:50:00.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/75/7a587771bb52ebb0e2c57b6dbe9fd96a70fbb54d72ddd97d54c5f8ec18d5/lxml-6.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1932d7ce78a561367512c594fe66eac2b2ec9b9264cfd9b5f950622f4a116e2", size = 5086619, upload-time = "2026-09-02T14:50:03.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/01/94c0ebe6d831861542d251e038052e52bf6d33f1d18f1cfffdc82851065a/lxml-6.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7d0f5976aa2701996f759b30172925829867547bb073af0ae67d1307a0f0262c", size = 4758828, upload-time = "2026-09-02T14:50:05.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f1/938d67bd0e5b1fdfa52be28aefdffbad57e1f6b8e921c2aab88542c75f40/lxml-6.1.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e7ce578aa8a80910a72a8ca0bbea3baae10100827249001999726a788456d8", size = 5627083, upload-time = "2026-09-02T14:50:08.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/65/4e51522f6c214650db0abb7b16ccd11b1238b8a05a8d59aa4ebed59c9f67/lxml-6.1.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d97c5227621af74b111882a290b10f371780a38eef9d9e730408fba2259b52fb", size = 5235170, upload-time = "2026-09-02T14:50:11.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c2/e73d19365665f6b16ef84df21199befc3b06e4c539046ad2d9595f6fb9ea/lxml-6.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:da707f14ea3c35ee463d50acd596d6488e4b2b4ae7cf77a5bf93f55c023d63e8", size = 5252273, upload-time = "2026-09-02T14:50:13.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a9/7f386c84c9fe2854e1ca6e231c285e1c8f392971ac353c6865e6ec49faff/lxml-6.1.3-cp314-cp314t-win32.whl", hash = "sha256:9efe56a68179f3adc4de41861c9358931db03837c48dd5e1c78077b84dd07f3a", size = 3902712, upload-time = "2026-09-02T14:50:16.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a6/8a3eb793f7900ef01c7f99e6f5fcbcfbdff35251cfaef66b32a4c16352d6/lxml-6.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c9389b3784b56c58d933b5e0aecdf28f901b073ff385358d8a7d40907f6e14b2", size = 4400979, upload-time = "2026-09-02T14:50:18.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/c4/3807bea283b4fe9e9d9f5dde46a73df91178472b335d2778e10b2a37aa22/lxml-6.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32a409be3190b088f960ac92bfedfbef2f86c49ff940765e1548177592d20026", size = 3823401, upload-time = "2026-09-02T14:50:21.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/8e/4614fcd65496054cfb7172662f3576a59200278739506433b8c241ea422a/lxml-6.1.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:6ea2f13dce778ca072ccee598bca46a092ce192e8fd907b6c1f0e52c800529a0", size = 8609378, upload-time = "2026-09-02T14:50:31.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/51/2cdce3c65fa99a6195dd8fbd512d33407c1000ad99f63e0a285b63d7a8eb/lxml-6.1.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c581b1d68b3845fb86c6b2983e755b29bf001461c59fa411d2c26a911b6559a9", size = 4640022, upload-time = "2026-09-02T14:50:34.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/09/0b30084e9eb1c546a4be3d9c56df70058d116b1a320400a59b0f7da87bf0/lxml-6.1.3-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e01125896585139453cab8cb235893644d8815d7509520da95ae3ee8d1c1f79", size = 5037928, upload-time = "2026-09-02T14:50:37.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0e/5c37275a3e361f6138dc06db748ea565c1fe8a5f4ee5e2ddd80047c81a89/lxml-6.1.3-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:290f66b97ede0e552e1cb44a0fd8a74f9753ee635b50830a0b122fb72788d015", size = 5661932, upload-time = "2026-09-02T14:50:39.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c5/b71ffb289b15e2642e2a3cf6d468c44da39ea119061a99e5b05e3d10f217/lxml-6.1.3-cp315-cp315-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73fc05988ed20809450474ba760a87c8ad4e455fc09783c02195e56ec634b41a", size = 5249209, upload-time = "2026-09-02T14:50:42.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/ea/9910da149a23932f9301652e57661cd9e42b0df18f12be21159b7255f92b/lxml-6.1.3-cp315-cp315-manylinux_2_31_armv7l.whl", hash = "sha256:dc3a44689eea43eab836e5c98a8ab015dc2419987d1ea6eafc7c590cdff86bed", size = 4704543, upload-time = "2026-09-02T14:50:44.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/07/9290329cd188c62e22021f79df04ee0cc33d9a93b0d38bd65ccd452ad9d0/lxml-6.1.3-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:209c3ccbfe35a04ac6d24f0611f9d1cbf8025d49991b14acd935236234d6c156", size = 5261298, upload-time = "2026-09-02T14:50:47.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0c/aba78bd3401cd99b73a0aed8e2b9b43e14be94fab3603d4bbc8a62365f2a/lxml-6.1.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2f5b2a2b9811b853b39bfa41367c6d78747b8e3e80e07fc5a24aae295c1a4d7d", size = 5090453, upload-time = "2026-09-02T14:50:49.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/dc/fa4426c3355aa0216cbeb3911495b5f65a26e0df85859a89928fe28f0396/lxml-6.1.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6a406d0b3cb207b0fa460ed4dc93e866f44f105da0169361cb18ff998a44c7f0", size = 4744709, upload-time = "2026-09-02T14:50:52.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2b/224fe7918658ab7c532ac2412f3c1eb28f71e6364fb07566262d0cc6a7b6/lxml-6.1.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:53258656846f5c48996b882fb4b135885e088a3ad3d96b4bc0530f95124d1f69", size = 5685802, upload-time = "2026-09-02T14:50:55.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/44/7d480819b9adcae5f84dd8ac529132c6b7a578544398225cd20321adcd91/lxml-6.1.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:aa633613ff907ea91b9b0489a1f0da1b8725d8c6ccec6b77e8a1c9c235044bb0", size = 5249019, upload-time = "2026-09-02T14:50:57.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/83/385a267ea1b6b283f2249dd827ef360a295e9db14e13ef4665a120c60d64/lxml-6.1.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90f709b9accab6b2e4d14f5c8718203877a0486bcb3afd74d8b539ecd1e961d4", size = 5271886, upload-time = "2026-09-02T14:51:01.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/f967b0eb172ae876855a402d6d9b11fa86e3e0c89ca9bbfeadf7ffbfa719/lxml-6.1.3-cp315-cp315-win32.whl", hash = "sha256:b4fc6b03b9d9d90557274f571ab30e7fbbfc527955536935d96f98b6817a86e4", size = 3662894, upload-time = "2026-09-02T14:51:45.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/48/d8a8c4160a29e663109ad520bac2deb37fcd014756d024561e8bc3e611ec/lxml-6.1.3-cp315-cp315-win_amd64.whl", hash = "sha256:33cadd956b667997e4de1635fce9541f2e8ede2038fcde8cf55aa14d571d1bad", size = 4074626, upload-time = "2026-09-02T14:51:47.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/20/3e1395d34d19f9254625d0b567b81cf70d37d3417be074f4d63b94a2be3c/lxml-6.1.3-cp315-cp315-win_arm64.whl", hash = "sha256:8a330c0ee5fa318c7b5cbbaad882baeca3f570357e7eb25ab34bf31008150758", size = 3749495, upload-time = "2026-09-02T14:51:50.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/c6/7465ffd9c43883526a382df6fa4846c9d8d419214f7effbf65270e795471/lxml-6.1.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:0bf5a3e397df2ec4258eb5eea4c1ac6cf013ca1abd04a176903bff20a70021fe", size = 8857677, upload-time = "2026-09-02T14:51:05.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/eb/1f3a917e299df43c8162c3e6f64fc2cea3bcf277910f35bff5b8e5d39901/lxml-6.1.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:13d22c0d57355366b393936acf6b98a5e0edeadddd3fccbc6a846c50a76b8741", size = 4754522, upload-time = "2026-09-02T14:51:08.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f9/f81b4bdb6efb7a596be29603d8758154d00a5f545db9f3cef9d9041c8f64/lxml-6.1.3-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad7617727a96d189bd6f979d0fadf765198c7934e85f4edaba9bf3ad919a300", size = 5033744, upload-time = "2026-09-02T14:51:10.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/0f/26d9bfaacb319c86e0eca8a1a0bf1130d36a7afbd318883e23caea63763d/lxml-6.1.3-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cae82b5ca24b0c2beedb269f6e2a96f466acd926879ab00ae19f1a65cbf9ffb0", size = 5615269, upload-time = "2026-09-02T14:51:13.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/90/73675f3f4141350ed65d6fec533b107d4e802c5caa340cf111771edd86e0/lxml-6.1.3-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69cafd61aea04ebb3502c93c2aaa568b12931ca0802231e0b5de76bf8b6e74bd", size = 5236280, upload-time = "2026-09-02T14:51:16.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/be/ed260767e7977de463a0f91f3f4fffcab85c0a2a024a21ffe1fa442c2c79/lxml-6.1.3-cp315-cp315t-manylinux_2_31_armv7l.whl", hash = "sha256:dc205732d593118cf701d986f40e9de7801bb2e371cb189ddbda9b7348f4d97e", size = 4650718, upload-time = "2026-09-02T14:51:19.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/fd/e9839d03b1e767f2725cf7d7d81b80d5f3f9fdc10ad8827e2479311b046e/lxml-6.1.3-cp315-cp315t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88e719b9437f148f7e1465df845c758dd1598618cbea3a2fd1e61a715542f2b2", size = 5243376, upload-time = "2026-09-02T14:51:21.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a5/4606e347e2788c301f677004aa83e28d24da9fe663a24380122af57be6fc/lxml-6.1.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:40983eabefd13da003e68170928c7acc011f0d095eefce5871a3c71c9385fb9a", size = 5092340, upload-time = "2026-09-02T14:51:24.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/99/3314a8661cdf30f493c55a87db283961dfaae08451976a2ca418958e1804/lxml-6.1.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:fad67b12ffe0f71e02b4932b04883cbc76a9072bbd30731409d3523cf058b011", size = 4758768, upload-time = "2026-09-02T14:51:26.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/58/3bdc577f78ea8b7d72d39a84506f7001d5b28728f43e5b84891e3b7d9a4a/lxml-6.1.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd11e7550d89e551a87dcec30f04b1fca32e86b68708aa01a4daa455d8605e5", size = 5649546, upload-time = "2026-09-02T14:51:29.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/e4/652633de1a2395949ebb7a8fc7d089aba12a2b45f0fefbc9d29e3e3ab3cf/lxml-6.1.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ca0ec532ad2f5ba1e5ec120ac157769c57f01855b3d8bf37213f5d88abd9ba0a", size = 5234874, upload-time = "2026-09-02T14:51:32.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/a6/c4581d171de30449304b4859bbd3607e9b40da13c0f88b68e6097c8d785e/lxml-6.1.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e99e09ab7741f1281e2677f4c0058c7f5267d182530b09c87e4f6aa26adf3887", size = 5260043, upload-time = "2026-09-02T14:51:34.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d7/ed6ee6186a89e69ca4ea9658b2a278f46a5efe8b5d4db56c7197f18653fe/lxml-6.1.3-cp315-cp315t-win32.whl", hash = "sha256:ace1d2c83b2bd24db5940600541140e87a325e119cb32d5fa9ad720d7e76648e", size = 3901093, upload-time = "2026-09-02T14:51:37.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9d/11d10257a4a048d04195d638bb61f0246ce2448eb05f682bcbab25a257a8/lxml-6.1.3-cp315-cp315t-win_amd64.whl", hash = "sha256:b49638355ea3bebba70da783ccbc630fd72afa16bc46c54474bfa1f9a915bbc6", size = 4395446, upload-time = "2026-09-02T14:51:39.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/b7/44edd7de434181c582892e68d1ffe6775ca403ce14aea07cb5a218a936cf/lxml-6.1.3-cp315-cp315t-win_arm64.whl", hash = "sha256:5a721a98c649855963811b59b55755b30566e7f7fc40bdc9803d66dee9f811cf", size = 3822836, upload-time = "2026-09-02T14:51:42.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.12"
|
||||
@@ -1294,20 +1081,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "niquests"
|
||||
version = "3.21.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "urllib3-future" },
|
||||
{ name = "wassima", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/2a/e368ec88a02c6bfc56c031ba6da9dd8099ad998fb818c28b687bee74c91a/niquests-3.21.2.tar.gz", hash = "sha256:586d6b9475c9018a42f19f1e4688077b8158101cbf40673c63d2c1a096cc7665", size = 1094494, upload-time = "2026-09-23T06:23:46.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/51/eb07449eb9e7ecae4350b5a40bfcb002d6d26d58cf2cfd2f928ae7c2f559/niquests-3.21.2-py3-none-any.whl", hash = "sha256:73e52614a712ee2f4d410bdd29789ca6a4ed031f8961410156e395d42b091208", size = 229060, upload-time = "2026-09-23T06:23:44.056Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.1"
|
||||
@@ -1665,18 +1438,6 @@ 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]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-desfire"
|
||||
version = "0.1.6"
|
||||
@@ -1761,82 +1522,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qh3"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/c3/777d963d852a19c1777eb74ab543d00d8b95592a849174d9a4f3b1c3f51a/qh3-2.1.0.tar.gz", hash = "sha256:622e3c63f2417039785f61d6af2cb26d0f0e5935ac2b84ef85a2bf76862fe6f3", size = 416198, upload-time = "2026-10-25T18:34:36.611Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/15/e463e025469339d2959357430a4bc38edd6d564919077762b6bf0549c7b1/qh3-2.1.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1393780b16de50f4f43221bcd5eb850ecaacbcd026494f896048651f72d29303", size = 4689485, upload-time = "2026-10-25T18:30:53.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/9b/1d69545cb12858fb4b2d16a43aba0e30c2ec75335269bdeadcf5f05d310d/qh3-2.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0b5b980799ea1a859b9ec5de1070afc1076937f953139b38f64bae3dd449aed", size = 2292143, upload-time = "2026-10-25T18:30:56.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/dc/553b308b42ab7afa4e0c0f5443eb574cb546ed4b25bd4cd2225beb0a5edf/qh3-2.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9cd8b52cbbff5c3e671956eadd8251bed35a54d8d6404a532008636b2518cdee", size = 2023144, upload-time = "2026-10-25T18:30:58.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/39/1fb49e744cda75081edb821b5fa2c849ba6880555b930b88da755cd811fd/qh3-2.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cdf8de2c41a0d2f7164434c77f0559da055a709c51825ce2cc47d3b552982f", size = 2207365, upload-time = "2026-10-25T18:31:00.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f4/eaf89fddaf5d22d2bae583704055d5cdea69f5d2ebca79c718cae578ca38/qh3-2.1.0-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a52c076b41ac91cc7d55aefcc8136bea39bcaa5c9d77adb7b23c9274410e8c68", size = 2202629, upload-time = "2026-10-25T18:31:01.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/87/8f5b5db604ecea59d5a490c93dc11aec831a5d809fcc873baedccfb428b1/qh3-2.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b84f5cc0da6e526b2c4ea57b7c5353d3084046c2eeb34c78014347b5dedce4de", size = 2221082, upload-time = "2026-10-25T18:31:03.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/38/9b2ec85ba6778eaba4128dc1a11805e3a70de582fa8c32b5c82f8674592c/qh3-2.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34cdba8a19c7b1e38483bb24442a40608a84b91723be15b25edc6107a180c23d", size = 2255718, upload-time = "2026-10-25T18:31:05.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5d/4b7059625ae90d4deab530f8a31ed343a7749023eb8d0ede201e07214b97/qh3-2.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb0ae31f0ae168b2d9d18fed0b42b8ef0a7fa6ea3ff5b41addafefebd77e7f1f", size = 2520917, upload-time = "2026-10-25T18:31:07.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8a/8658f6d5fc5ff1285ceb856fa0b72bb6ef17d8c6b68ac7785fa988a89f34/qh3-2.1.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:700391db7767c52833b60500f9d03b8e929a654534a6721b998250e4ebb7f638", size = 2106012, upload-time = "2026-10-25T18:31:08.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/3d/b712bbbf9c29aa4f688c51825e6f2d751f9b81934404f5e72f3a9616a7d9/qh3-2.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:72ed04a190f1b99cc10ebf360d6d6d0ea967f343566f49341b56899f39c494c0", size = 2488815, upload-time = "2026-10-25T18:31:10.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/fe/2d75fde70de88f7a58fbbcfd67075dc8d3dad273834d270affb19049443a/qh3-2.1.0-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:b9c9f4602d138c89696ccbf570be119c294727864d66b91554bce5e28e0a054a", size = 2284543, upload-time = "2026-10-25T18:31:12.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/71/b82e2626adc445b1a9df122fa05126bd7480a3881b2df0fc44fb9b9a7f1d/qh3-2.1.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:d81103c015c27b1fbf7ba768e922ff8176eaa6461a00c6c99f166295db030987", size = 2397166, upload-time = "2026-10-25T18:31:13.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/04/82dd427c2f4648a0c855dae60271352653b35dd627ad6db60c6bd31bd73a/qh3-2.1.0-cp313-cp313t-musllinux_1_1_riscv64.whl", hash = "sha256:8cbadc13df9e529f18ed507c0a0d32a424c3247e5b4895377872aade8d1d880b", size = 2210993, upload-time = "2026-10-25T18:31:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/8d/1bd332607c3f46bac06b1294b887c8fe40cc9ad9c5bab4c68187775f0822/qh3-2.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d88639721d5b0bfd90bb440242f570be778e2841799a68bfc29ed725b324a28e", size = 2744521, upload-time = "2026-10-25T18:31:17.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/14/d2226d640ab86d9b20798cd9409fd43e07a279faed84dff63d4a45c43318/qh3-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:f116c53985220f1a06f84b317cbd2e66024d16bfd41dcf168df6890e4198b1f3", size = 2025197, upload-time = "2026-10-25T18:31:18.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/65b436400a3dda2e33817d24703792e93e348ddd8971714f1e99614d0c9d/qh3-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:df3d5c5dcc590b642f1c43e5987cd9ae0886bd2cd214e7908dc62e79c4deafd8", size = 2291651, upload-time = "2026-10-25T18:31:20.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/97/f4c9bb99bcad8b14cc22ab40f3994f9ab60e547f2663735a0fa114b58711/qh3-2.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:135e7f722b7523ea22d36b7eab9873d19f25c3d3f585d24ded6673bd31ebe593", size = 2104967, upload-time = "2026-10-25T18:31:22.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/92/d264ccf9fe2a54d0dea3cb5e621e3bbab8c78cacf413d0bfd0eaf964468e/qh3-2.1.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ba62fe5a0a8394e3cff6b1e205d4cdb21f18d9191193d963fc887a1f7339d3cf", size = 4690342, upload-time = "2026-10-25T18:31:23.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/59/28d92e577656b5abe63a99a058c8ecc4a238f09ab4d7e3588c2b6d315d78/qh3-2.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962cdfab72c26b5fa9549c5bd1fe645ca437a449ee2c1528aff226e3264452ff", size = 2292764, upload-time = "2026-10-25T18:31:25.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/c64a50433891f34b28cd5bf8d171ee50f88a436861c3309904df3eb384da/qh3-2.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ec9b4cc8796a7a158f05667200fbc9906ccf8ab6804bf007c61b17901035ebe", size = 2022524, upload-time = "2026-10-25T18:31:27.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/13/eb6cec78c284cb87337d4cbac4b995c1defad2217efe8608f9de2b0a5ee0/qh3-2.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65b2cb6aba980a5821223969d15452e102e01f1b656485b401c40dd0d4d93c8c", size = 2206837, upload-time = "2026-10-25T18:31:29.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/63/83c16002bef8b09fa4162e388b8ff6199ffb84ad7f09fdc8a1a2164a2edc/qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82505dbb6e5a5aa814fac47789b185b21020692c37ccfd96d428d32c9822ee5d", size = 2202427, upload-time = "2026-10-25T18:31:30.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/9f/27291cb15e4d664ec7359db5988c1af6358d0f428ed189a9a80289b5f45e/qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d68107e1c2bb6538bfeddd0238cfd309396acb51e0d3bd0fd7ae87390219b6", size = 2221164, upload-time = "2026-10-25T18:31:32.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/a6/d52444f7e286cb33da29688d6356dcff638cd238f18555f65d9918ee4c0b/qh3-2.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e856c5c9393b1fb4c9e7458165ad2560ec8b0c14e6092c4e8f456dcba8ce3f1f", size = 2255435, upload-time = "2026-10-25T18:31:34.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/45/ead5d22957caab56e12e12a2f45ca04fa4710d890e1557ca847e9c96d933/qh3-2.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f647ef9f76bd289a28baab858dcfd34efd2255a7da274a9e8f45a020be36b795", size = 2520651, upload-time = "2026-10-25T18:31:35.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f3/684f31bc55b46dbc79ef0ee2461cce34d2967e62f2b3b108fab4db6c71ba/qh3-2.1.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:86ebca1a4d4874c619ef7d3eee4d699968b236e4292bb4cbd498c7054cdc5319", size = 2106061, upload-time = "2026-10-25T18:31:37.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6a/656bf639c92dcc9ff158434a798e845ce479cdf2372461e66ee6a67c40f8/qh3-2.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:4c21a3133460449abd93a61cc675dbde893f1094f18bc6f286d44d3d3ae5d462", size = 2488422, upload-time = "2026-10-25T18:31:39.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/95/727d54837a2cde950d4480d50943d1c8935133f0d72699e5d77a975e74c5/qh3-2.1.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0b3d1ddf862bb9ffde56d4ef4477a0bb86fe60983d6b03b7e8869aa026c4c3d4", size = 2284266, upload-time = "2026-10-25T18:31:41.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/87/bdc71bdca9b9ad7b8fd034c7e7ae8683c87ceed76f7646664bc5d0a7d4fb/qh3-2.1.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:b812694d3884bf8f92ba05b02889f41ea519821fc98c2634010e5d595ec2c330", size = 2396753, upload-time = "2026-10-25T18:31:42.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/69/799473c877c003cbb3455198d43168eaebabfc5c14d492530fe748c93acb/qh3-2.1.0-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:a80118168ebf68413b089930fcef0d6c7ba2907d61273a4fcd72414322281d75", size = 2210620, upload-time = "2026-10-25T18:31:44.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/66/9145d587aca2da40cb57118c810d878238169758b3b5f0b9a2321dda7fba/qh3-2.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a6013c83b0988544ca084feea6c91b4dceb9b9a835a4c2eec0407faf820ba4d2", size = 2744036, upload-time = "2026-10-25T18:31:45.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/c6/465bbceca908f614039b0ecf662b62d95b72ce366108bd4d838a9ae2938f/qh3-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:358f66b86c6ff4ac515aaf8c6848bf0e61939b075f8fa455b977d0aa3e7e6860", size = 2025220, upload-time = "2026-10-25T18:31:47.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/55/02c0f01f9048c79ba0782b658d91f92cbd0fbfe25dccd73f4b09e695c1a3/qh3-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1bd9d323d3a059d79c3586c00f973d64ba803234218d47ca2e8a364ec885b9dd", size = 2291442, upload-time = "2026-10-25T18:31:49.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/5a/4189ea71debe60246ac6b212ddc316c73ba4e44f56002538915f3e3cd4f2/qh3-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7644fc608c6d28012f567c5ce060b281c5a014ff60ddf4167ba9b6507ef3e576", size = 2104825, upload-time = "2026-10-25T18:31:50.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/01/b6f10b2a365b68526b68e44a864981c1d50df16eb92d9b304340aeb92998/qh3-2.1.0-cp315-cp315t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a07c074dcb9d58b41d1d73207357bd70e2617ed9816f36bbc5824eeef4465c1b", size = 4680306, upload-time = "2026-10-25T18:31:52.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/32/81b9a3e61c6ad8badf4a5ebc0fb7e7b19db95cf964a1a58ee29d1b6700fc/qh3-2.1.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd04f2718a3412bdebd580e35ff465128ba3d6dfa967c0ce4186f60a2df060b", size = 2286357, upload-time = "2026-10-25T18:31:54.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/91/74120b53de2ef4f5b13462c0e255b2e85834a4e59d4997b6550cf283af87/qh3-2.1.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2304539fee9c70d385bd834402ae3fbfff58fcb2793adf7fd44bd53cc983be0", size = 2018498, upload-time = "2026-10-25T18:31:55.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/41/fc04957e2080c056c56687a0799162376e25b7be4a5f615f95e9f9cdf805/qh3-2.1.0-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:451ae203b2670e256333170afc2ccc558fa15de172412ec02c2ea3e03854645f", size = 2203739, upload-time = "2026-10-25T18:31:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9c/aaa5d1a1484a66ec7a0117c8af575da7059106d4da7a63ceeb9ed4525d06/qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:129ff9821720341c847dffe99f8fe599914f583cfe0c725061ecfd5d578ee2b8", size = 2195664, upload-time = "2026-10-25T18:31:58.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/92/fd0860779e5bddd01898a6acb5ac290bcf09c9cd48d82c49cdf9408132f8/qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a648d7ef66894113a23b0b197b9c164e8d6cd6a1175e10c1ba5bfc1d5f02eab1", size = 2218418, upload-time = "2026-10-25T18:32:00.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/6d/2e7d6136a59cf1f0dec5e32b89e23da480e538159027e1708f251c25b41e/qh3-2.1.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:833a93ff543f4e98b71b497841c5c3ac4c7f255d56f544f5223ffd413dca2efb", size = 2251483, upload-time = "2026-10-25T18:32:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/05/4f131be92d11407586fd916eba3dfcf6a7e48bd408129cec0db89692829a/qh3-2.1.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e649419464428547e314c8048e5257d9a8cd09e15643405cc9fbe177e10cad37", size = 2517617, upload-time = "2026-10-25T18:32:03.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/dc/18d5ac783c9dbb330b393139975381726cbc5c4e2f6f6012e81786bb5022/qh3-2.1.0-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1d6e9364a537785755b5b2724c330ed59fbae8bc29df28a1a74cb83ef1defd4c", size = 2100897, upload-time = "2026-10-25T18:32:05.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a9/bef0c2408c3c11933aba0f53f3c1ed6244ad9a9b890fb7484201bd2c8253/qh3-2.1.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:4c3fcb8ec0da9ad2a349a2a0b96902e0432830e6740163863911d1ea88439fad", size = 2482641, upload-time = "2026-10-25T18:32:06.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f4/22b90ebf44bb427822498c21f76679ac97f835f946b60c610cf0fdd8bb0a/qh3-2.1.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d15f365e295d1926bb283324c72dfb2568cdb145a2d521495b562c765a9e3507", size = 2278962, upload-time = "2026-10-25T18:32:08.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/74/2e691ff9a2e4a987bf9980ccc4196ae54e9f4603a0f5b70b6ad904bccf02/qh3-2.1.0-cp315-cp315t-musllinux_1_1_i686.whl", hash = "sha256:ba8598d3ee4c72325d099714920f23c35844a0e2f0bf20dd56b51d61545bc097", size = 2392700, upload-time = "2026-10-25T18:32:10.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/f9/b626778b70d08fe5b9b8569a98ae3485a75854896a3a7ee1ce505e934c4b/qh3-2.1.0-cp315-cp315t-musllinux_1_1_riscv64.whl", hash = "sha256:67e6377dad202507d50e4af854a6a464975ed310f4ecc272f83cda73811e9b71", size = 2204995, upload-time = "2026-10-25T18:32:11.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f0/18d65ae7ae4655a11d8ff59d023259f2b8dc20f10194c6d5fce900b11b65/qh3-2.1.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:9915fd34cf3010f186c783a492c9f3ec31c0e5cad8c5e16a6fe08d2dda1defee", size = 2740263, upload-time = "2026-10-25T18:32:14.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/76/182804ba95ef16f1e17e788eb60547c8a2fc50c24734ef02e6fc2dac9797/qh3-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:71ffe60e5a8dc67b7ecd3b2ba6a105fe4cda8e770d6fae2ff6445b557d0b4d38", size = 2023027, upload-time = "2026-10-25T18:32:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/c7/d778b06705e274f0dbf152b0cffd371a459217aa254f7f7450bec58d9ee8/qh3-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8a1a95c687bd2d26e7cb55dc14095b95ab55f1afcd8adfe1c885fa7818582ad7", size = 2287300, upload-time = "2026-10-25T18:32:17.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/af/d0518e0c21afaacf104f8849c048d192b4da3bf4c3b728f3a7539a70a4b0/qh3-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2eefd993b952bb2bff34396b7abf4cffa46f25667cba85de0dff6e083d1e83ac", size = 2098935, upload-time = "2026-10-25T18:32:20.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/db/28b5f12044af0a284b91938ae0f567b3625f13c6b316be9e360de023b735/qh3-2.1.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:94412f03ec7b2d27e9f238404de0ab0a293ef4eb172ce1e6f6f6fd11ab857e58", size = 4702601, upload-time = "2026-10-25T18:32:21.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ab/1b9bd5f83fe3b1de0600853b90b7eec8cb338f57a91c742c02cc18618635/qh3-2.1.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b66c2ff3d8c5bad514f7f9362e0c9e3e4664209db5c0167278f1d90918bbd12b", size = 2299659, upload-time = "2026-10-25T18:32:23.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b8/7b1bbed622b40c55fca01929d948e83b6a446ff491fee65fad24a9ef5b8a/qh3-2.1.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8982eae1cd88e5d7700802b82f29a7cb834d453bb557827010199a037d4062f9", size = 2027280, upload-time = "2026-10-25T18:32:24.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/bf8963ec468dc5ce793966e8d6f48b47fa85e3e8be29959a34ebb5259acf/qh3-2.1.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbdfefe22dcc6222b853be72f062fc7e84d6c333294310502bf0090e20ac0cf3", size = 2212422, upload-time = "2026-10-25T18:32:26.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/b2/1cfdf7f7f3d3368c6a0c194b6e4eaca0e56d121beb4cd14ad51946282dab/qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:341793106cb0f803985031690bc2e4e184bbeecbaed63d902921c7dfd07f1808", size = 2210619, upload-time = "2026-10-25T18:32:27.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4d/fbe50cff7df4b081133f3947351005444dec1027189591c60b5e5aa1ce66/qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b205fd9b5fffcf467331b82ae1f4e360581bd60886d37adf626197e75e0e0bc", size = 2229445, upload-time = "2026-10-25T18:32:29.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a1/308e91843b74e67bee948f65da454b4391c26e6c7bdc34827cd5690a91d6/qh3-2.1.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bd2e3a03985be5e6e6a04faa1f53106c75ad607f662d54b57585a0e52121c1", size = 2264523, upload-time = "2026-10-25T18:32:30.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/46/2c509a0237bc1f2469a53ae5c4dfa68b924baccaed37d99cbbb33542517e/qh3-2.1.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1808e123760e78482948b8e6bf5547a8a91637573b89caf165ffe948850d2ba5", size = 2524346, upload-time = "2026-10-25T18:32:32.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fc/afee6c2b6d76f5c21df9a9f2ed4a3abfb3bd6449820dc6823e3aac32cca2/qh3-2.1.0-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:343780d1f7e8000a6e06458e6940d0a80e29cc2be7e47b60f24f714dd17da690", size = 2109918, upload-time = "2026-10-25T18:32:34.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/21/aa9725997d3d321b4fa31b21ba211eb25781131a59f48a4960446ba4fb90/qh3-2.1.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3cb6649dee2f4d3dba008dc9d0e15a75fdb0c16b864535725f2266adc55a54e9", size = 2496917, upload-time = "2026-10-25T18:32:35.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ee/33f719e6f37b4b7d45b11bbbb7a91be8be9d3dd5a632bbf22743e2c956e3/qh3-2.1.0-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:23ff27b10d88fae56fd3fb530bd93a19dcce7b3ee921b9cebc4d75767b2b71a1", size = 2286839, upload-time = "2026-10-25T18:32:37.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/7c/fc0ee6da0ff2f3a96ba5afab913166c39860d1f04279f0639fe4a8ee912d/qh3-2.1.0-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:a51ef02b036b2a06293ce87d48105a516a31694a77746062d16016a5aca6a013", size = 2400938, upload-time = "2026-10-25T18:32:38.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/44/bb8511574c859f4f831728ae5251bc8d155bfc8f061aa4c736b5e60dbf59/qh3-2.1.0-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:81a03727a5ddf001fdb57251a86b950ffea47c7a3473641e2707d647d941b398", size = 2219264, upload-time = "2026-10-25T18:32:40.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9e/0e78319e7be595e32ab815cfcf4cac73c453f69c5a901c457e991d9fa481/qh3-2.1.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b7e7300dc9352f7d31b4204e6bad6c0a310a12c4b40e36a0ff347f64c8d793f", size = 2748521, upload-time = "2026-10-25T18:32:42.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/66/5df8eb3e6593dbc3dacd0ce54eccf64f258298c8671e922ab25a5b07a6a2/qh3-2.1.0-cp37-abi3-win32.whl", hash = "sha256:19d1b93d669177215675bd21a303763d2222bc9ba1c9c3850e1e9a5bdfe868aa", size = 2033904, upload-time = "2026-10-25T18:32:43.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/19/8e4e9ac60b5e3f83a4f702b0094533de8c86d8d6baa77cb3ba6f7ef4d8a9/qh3-2.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:85be2ef6a536a687d12c018f4605a5517b151d02ea7d42a3d9f4386985c735a5", size = 2299731, upload-time = "2026-10-25T18:32:45.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d2/922f63131bf1f72848198d0ded9dde9dbd7245a59c14c18dda07ba4d153f/qh3-2.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c257cef34cf990da943ffbf12c4ee2ab44d131f4b4024383edae84d19428056", size = 2117482, upload-time = "2026-10-25T18:32:47.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.14.5"
|
||||
@@ -1889,21 +1574,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recurring-ical-events"
|
||||
version = "3.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
{ name = "x-wr-timezone" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/f5/898abd8fbb25766ec6cb17b244730ebf192f47963c71eeacd61aadd903a1/recurring_ical_events-3.8.2.tar.gz", hash = "sha256:e731af31d0b7dec5cd47a1defacd8549e2f36fab1c1995e8b9f042822a0acf8e", size = 604992, upload-time = "2026-04-30T19:21:46.2Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/f7/d3eb4f545baf5d6daaa5881694517626fb7ad04036d94168197a4f021384/recurring_ical_events-3.8.2-py3-none-any.whl", hash = "sha256:9ad605e27b4fbeb70ee1c66205ade32550be15a57cedc579606522f1c67aef6d", size = 241358, upload-time = "2026-04-30T19:21:44.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
@@ -2080,15 +1750,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.49"
|
||||
@@ -2208,15 +1869,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
@@ -2226,20 +1878,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.25.905"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "jh2" },
|
||||
{ name = "qh3", marker = "(platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/28/765b30ba80b434803d287f873453463d9eef9d5cb4e28004196460166976/urllib3_future-2.25.905.tar.gz", hash = "sha256:d5bd25dad01d7358b33f4c7b3f225506231158086ff790028c1de5b38c26e89e", size = 1704064, upload-time = "2026-10-28T13:20:47.275Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/78/e4f6952a009df5f65b3af31b6221d2d660e0a705245676b5fc73508d88ea/urllib3_future-2.25.905-py3-none-any.whl", hash = "sha256:c8e3629be86cf3c074769a23beb2b99993cc3454d2049bd3c0643952f6bfbafb", size = 890513, upload-time = "2026-10-28T13:20:45.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
@@ -2305,15 +1943,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wassima"
|
||||
version = "2.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/8e/9167e3e39e921833d35119849422cfbbba68c90ad0723c2a9c7965303195/wassima-2.1.5.tar.gz", hash = "sha256:b02c1985e527c9539396cc85b909857d810dafaaa318a30266e585a0e7135f54", size = 141994, upload-time = "2026-10-22T07:24:05.966Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0e/9bc13dc706f86752a6afe36228f452be797c50183bddba513efad60a0272/wassima-2.1.5-py3-none-any.whl", hash = "sha256:53d9a56cf55fc969f8cdd1993f8dc69f857d1c32229fb9084dc2b267044b6dde", size = 132848, upload-time = "2026-10-22T07:24:04.368Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
@@ -2407,20 +2036,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x-wr-timezone"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212, upload-time = "2025-02-06T17:10:40.913Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102, upload-time = "2025-02-06T17:10:39.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.3.0"
|
||||
|
||||
Reference in New Issue
Block a user