51 Commits
Author SHA1 Message Date
ahtlon 1d37be5317 Move caldav_handler to caldav.py
Test python action / test (push) Successful in 38s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 29s
Make the sleep interruptable
Add debug endpoints for caldav - gonna make a new endpoint for them i think...
2026-11-05 22:48:26 +01:00
ahtlon 9810a4f810 Just had a bunch of problems with tests being polluted by local .env
This should fix that.
test_settings.py written by qwen3.8
Assisted-by: local-qwen3.8 <localllm@ahtlon.de>
2026-11-05 22:45:23 +01:00
ahtlon db627c34dc Dang it bender...
Test python action / test (push) Successful in 38s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 27s
also config
2026-11-05 19:38:23 +01:00
ahtlon 63ea103793 Remove timetable in favour of the new calendar stuff
Test python action / test (push) Successful in 39s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 28s
2026-11-05 19:06:37 +01:00
ahtlon db87625384 Prevent accidental opening of the db on disk while running tests
This was anoying to fix (and even debug)
so I used the local llm for some code changes

Assisted-by: local-qwen3.8 <localllm@ahtlon.de>
2026-11-05 18:48:48 +01:00
ahtlon ff170af935 Add timezone marker to requests
Test python action / test (push) Successful in 41s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 24s
Tests helpfully written by local-Qwen3.8
2026-11-05 13:01:57 +01:00
ahtlon af2f254937 Git squash from
Test python action / test (push) Successful in 45s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 27s
Ruff format

Ruff autofix

Fix the rest of the issues
2026-11-03 22:31:42 +01:00
ahtlon b31413931b Fix caldav tests
Test python action / test (push) Successful in 33s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 28s
2026-11-03 21:57:41 +01:00
ahtlon a8edc862bc Ignore venv collisions
Test python action / test (push) Failing after 32s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 28s
2026-11-03 15:54:13 +01:00
kalipso ba3e471bb9 fix CaldavEventCache not found error
Test python action / test (push) Failing after 27s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 25s
2026-11-02 20:14:20 +01:00
kalipso f7a157593b add caldav dependencies
Test python action / test (push) Failing after 1m29s
Pull-Request Vibe Check / ci-test (pull_request) Successful in 31s
2026-11-02 19:53:43 +01:00
kalipso 6666f47f1f enable caldav access check 2026-11-02 19:53:34 +01:00
kalipso 7e39fa2c1e run caldav iv enabled 2026-11-02 19:53:22 +01:00
kalipso e07013b7a4 add caldav settings 2026-11-02 19:53:13 +01:00
kalipso 1cfe331133 caldav: init 2026-11-02 19:52:35 +01:00
ahtlon 25ecbbf375 adding actions (#18)
Test python action / test (push) Successful in 37s
added with a pr bot commenting about problems

Reviewed-on: #18
Co-authored-by: ahtlon <git@ahtlon.de>
Co-committed-by: ahtlon <git@ahtlon.de>
2026-08-11 17:49:31 +02:00
ahtlon c5934d05c6 Fix #28 2026-08-07 23:41:13 +02:00
ahtlon 0bcc83871d [Door] switch gpio out to mqtt 2026-08-05 03:22:55 +02:00
ahtlon 4b797997d4 Switch lgpio for mqtt 2026-08-05 02:24:05 +02:00
ahtlon f635149d1f Check for disabled card and add tests 2026-08-05 00:52:46 +02:00
ahtlon bbb41e41ab Add active check 2026-08-05 00:27:08 +02:00
ahtlon 0497d1bbcf seperate dev/normal deps 2026-08-05 00:27:08 +02:00
ahtlon c5c428c621 Merge pull request 'door section' (#21) from dev into master
Reviewed-on: #21
2026-08-03 00:39:03 +02:00
ahtlon 023a704e36 Make scanning the keycard while open close the door 2026-08-01 23:27:54 +02:00
ahtlon 73ff546c70 Update readme 2026-08-01 14:47:14 +02:00
ahtlon 55072bcc1d Fix module 2026-08-01 14:47:08 +02:00
ahtlon c9f740b7a3 Fix lgpio build... 2026-08-01 14:46:17 +02:00
ahtlon 936b786659 Switch gpiozero(fuck you) for lgpio 2026-08-01 14:45:44 +02:00
ahtlon 158b430235 [settings] fuck it, rework the settings again
this time using pydantic-settings as a base
- removed all os.getenv calls
- removed the secret_key default option
- reworked database loading, creating tables
- prob. something else also but its 4:30 and i have to sleep
2026-07-31 04:33:18 +02:00
ahtlon bad4e8dd1b [settings] rework settings 2026-07-31 01:50:08 +02:00
ahtlon b7b07b82da [door] correct spelling, add status endpoint
the door section of the api should "just work" now
2026-07-31 00:38:40 +02:00
ahtlon 717ce951a6 [door] add doorcontroller with mock pins for testing 2026-07-31 00:36:11 +02:00
ahtlon d37526e39a [pyproject] add gpiozero 2026-07-31 00:33:13 +02:00
ahtlon 40312020c8 Merge pull request 'merge dev to master for infra pr' (#20) from dev into master
Reviewed-on: #20
2026-07-30 18:35:58 +02:00
ahtlon 04a76f91fe [flake] the module now actually starts the prod server 2026-07-30 18:34:21 +02:00
ahtlon a8e80ed19d [main] fix startup when there's no db 2026-07-29 18:37:34 +02:00
ahtlon 7a70b6b1d4 [lint] more linting changes 2026-07-29 03:30:54 +02:00
ahtlon 3f20cdeed9 just_found_out_about_linters (#17)
This changed a bunch of code but no

Reviewed-on: #17
Co-authored-by: ahtlon <git@ahtlon.de>
Co-committed-by: ahtlon <git@ahtlon.de>
2026-07-29 02:46:50 +02:00
ahtlon 683cd9a545 [main.py] move settings stuff into its own import
also correct imports to use app namespace
2026-07-29 01:24:22 +02:00
ahtlon 1dfd01fc71 [CORS] fix methods
maybe add test for cors? idk how tho
2026-07-29 01:22:29 +02:00
ahtlon 54c7fe1ed2 [alembic] automaticly stamp new database with the "head" tag so migrations are easier.
also fix nix develop due to multiple top level folders
2026-07-28 16:14:33 +02:00
ahtlon 48d272d4d6 [alembic] generate base 2026-07-28 00:17:35 +02:00
ahtlon 7e27026da9 [uv] Add alembic 2026-07-27 23:58:20 +02:00
ahtlon 29c013a121 [nix] add module.nix 2026-07-27 23:50:22 +02:00
ahtlon 5f1030c831 Merge pull request 'Change accessauth, timetable model' (#16) from change_tt_model into master
Reviewed-on: #16
Fixes #9
2026-07-27 17:19:58 +02:00
ahtlon ea2a1f916d [Tests] add new tests for oneshot type aa 2026-07-27 17:19:10 +02:00
ahtlon 1efbad1db3 [Tests] Fix tests for new model 2026-07-27 17:19:10 +02:00
ahtlon 4b87603e06 [AA] fix patching AA with oneshots 2026-07-27 17:19:10 +02:00
ahtlon 5f19409da4 [AA] Add oneshot to checkAccess function 2026-07-27 17:19:10 +02:00
ahtlon e45abb5c55 [AA] fix the db insert 2026-07-27 17:19:10 +02:00
ahtlon 47b84a097c [Models] This doesn't quite work yet but this is about the model im thinking of for the future 2026-07-27 17:19:10 +02:00
43 changed files with 3030 additions and 818 deletions
+33
View File
@@ -0,0 +1,33 @@
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
+21
View File
@@ -0,0 +1,21 @@
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
+4
View File
@@ -1,4 +1,8 @@
{ {
"[python]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff"
},
"python.testing.pytestArgs": [ "python.testing.pytestArgs": [
"test" "test"
], ],
+3 -6
View File
@@ -1,9 +1,10 @@
## Gatekeeper - Door access system ## Gatekeeper - Door access system
#### Status: WIP - getting there o.o #### Status: "WORKING" - Base functionality is there, mayor issues
Start prod server `nix run`<br> Start prod server `nix run`<br>
Start dev server `nix run .#dev` or `nix run .#dev -- {args}`<br> Start dev server `nix run .#dev` or `nix run .#dev -- {args}`<br>
Interactive dev with `nix develop`, then sync deps with `uv sync`<br> Interactive dev with `nix develop`, then sync deps with `uv sync`<br>
There is a nix module you can use by importing `inputs.gatekeeper.nixosModules.gatekeeper`<br>
Swagger UI @ http://127.0.0.1:8000/api/v1/docs<br> Swagger UI @ http://127.0.0.1:8000/api/v1/docs<br>
OpenApi @ http://127.0.0.1:8000/api/v1/openapi.json<br> OpenApi @ http://127.0.0.1:8000/api/v1/openapi.json<br>
@@ -33,17 +34,13 @@ Range: The range of the ACR1552U was much better at over 60mm (almost 70mm if yo
#### Issues: #### Issues:
- cards can only unlock, not lock
- documentation missing - documentation missing
- raspberry pi image not working
- no door state
- no door operations
- hardcoded secret key in auth.py -> centralise env var loading
- i don't like the error handling in the scanner - doesn't pass errors correctly - i don't like the error handling in the scanner - doesn't pass errors correctly
- cors for frontend: https://fastapi.tiangolo.com/tutorial/cors - cors for frontend: https://fastapi.tiangolo.com/tutorial/cors
- Load cors from env var or something - Load cors from env var or something
- BackgroundScanner shouldn't get a single session for the whole lifecycle - BackgroundScanner shouldn't get a single session for the whole lifecycle
- input validation maybe - input validation maybe
- too many imports
- inconsistent logging (request logging?) - inconsistent logging (request logging?)
- rate limiting maybe - rate limiting maybe
- pretty sure the controllers are doing too much stuff - pretty sure the controllers are doing too much stuff
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# 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
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+86
View File
@@ -0,0 +1,86 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
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
# Interpret the config file for Python logging.
# This line sets up loggers basically.
#if config.config_file_name is not None:
# fileConfig(config.config_file_name, disable_existing_loggers=False)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
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")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = _database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
{"sqlalchemy.url": _database_url()},
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
+28
View File
@@ -0,0 +1,28 @@
"""init
Revision ID: 4a40fa23e086
Revises:
Create Date: 2026-07-28 00:07:19.402303
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4a40fa23e086'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
@@ -0,0 +1,57 @@
"""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'],
),
)
+3 -1
View File
@@ -6,4 +6,6 @@ MIFARE_ACL_WRITE_BASE_KEY="f1aa99f81cca268de98d422ee0ccb65c"
# 16 bytes AES key # 16 bytes AES key
SECRET_KEY="8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85" SECRET_KEY="8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85"
#Key for oauth #Key for oauth
#THESE ARE TESTING KEYS - DO NOT USE IN PROD #THESE ARE TESTING KEYS - DO NOT USE IN PROD
SQLALCHEMY_DATABASE_PATH="./gatekeeper.db"
-6
View File
@@ -1,6 +0,0 @@
from fastapi import FastAPI
from .controllers import userManager, cardManager
app = FastAPI()
app.include_router(userManager.user_router)
app.include_router(cardManager.card_router)
+80 -29
View File
@@ -1,44 +1,74 @@
import logging import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from sqlalchemy.orm import selectinload
from typing import List
from ..model.models import * from app.model.models import (
from ..services.database import engine, get_session, add_and_refresh AccessAuthorizationCreate,
from ..services.auth import auth_is_admin AccessAuthorizationDB,
import uuid as gen_uuid AccessAuthorizationResponse,
AccessAuthorizationUpdate,
GroupDB,
GroupResponse,
OneShotAccess,
)
from app.services.auth import auth_is_admin
from app.services.database import add_and_refresh, get_session
logger = logging.getLogger(__name__)
aa_router = APIRouter(prefix="/api/v1/aa", tags=["AccessAuth"]) aa_router = APIRouter(prefix="/api/v1/aa", tags=["AccessAuth"])
@aa_router.post("/", response_model=AccessAuthorizationResponse) @aa_router.post("/", response_model=AccessAuthorizationResponse)
def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate, admin: bool = Depends(auth_is_admin)): def add_accessauth(
*,
db: Session = Depends(get_session),
aa: AccessAuthorizationCreate,
admin: bool = Depends(auth_is_admin),
):
logger.info(f"Creating accessauth with data: {aa}") logger.info(f"Creating accessauth with data: {aa}")
timetables = [Timetable.model_validate(t) for t in aa.timetables] if aa.oneshot is not None:
oneshot = OneShotAccess.model_validate(aa.oneshot)
else:
oneshot = None
db_aa = AccessAuthorizationDB( db_aa = AccessAuthorizationDB(
name=aa.name, name=aa.name,
type=aa.type,
is_active=aa.is_active, is_active=aa.is_active,
timetables=timetables oneshot=oneshot,
) )
return add_and_refresh(db, db_aa) return add_and_refresh(db, db_aa)
@aa_router.get("/", response_model=List[AccessAuthorizationResponse])
def get_all_accessauths(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): @aa_router.get("/", response_model=list[AccessAuthorizationResponse])
return db.exec( def get_all_accessauths(
select(AccessAuthorizationDB) db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
.options(selectinload(AccessAuthorizationDB.timetables)) ):
).all() return db.exec(select(AccessAuthorizationDB)).all()
@aa_router.get("/{aa_id}", response_model=AccessAuthorizationResponse) @aa_router.get("/{aa_id}", response_model=AccessAuthorizationResponse)
def get_one_accessauth(*, db: Session = Depends(get_session), aa_id: int, admin: bool = Depends(auth_is_admin)): def get_one_accessauth(
*,
db: Session = Depends(get_session),
aa_id: int,
admin: bool = Depends(auth_is_admin),
):
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AA not found") raise HTTPException(status_code=404, detail="AA not found")
return db_aa return db_aa
@aa_router.put("/assign/{group_id}/{aa_id}", response_model=GroupResponse) @aa_router.put("/assign/{group_id}/{aa_id}", response_model=GroupResponse)
def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_id: int, admin: bool = Depends(auth_is_admin)): def assign_accessauth(
*,
db: Session = Depends(get_session),
group_id: int,
aa_id: int,
admin: bool = Depends(auth_is_admin),
):
db_group = db.get(GroupDB, group_id) db_group = db.get(GroupDB, group_id)
if db_group is None: if db_group is None:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
@@ -46,12 +76,21 @@ def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_i
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AA not found") raise HTTPException(status_code=404, detail="AA not found")
if db_aa in db_group.accessauths: if db_aa in db_group.accessauths:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="AA already assigned to group") raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="AA already assigned to group"
)
db_group.accessauths.append(db_aa) db_group.accessauths.append(db_aa)
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@aa_router.put("/unassign/{group_id}/{aa_id}", response_model=GroupResponse) @aa_router.put("/unassign/{group_id}/{aa_id}", response_model=GroupResponse)
def unassign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_id: int, admin: bool = Depends(auth_is_admin)): def unassign_accessauth(
*,
db: Session = Depends(get_session),
group_id: int,
aa_id: int,
admin: bool = Depends(auth_is_admin),
):
db_group = db.get(GroupDB, group_id) db_group = db.get(GroupDB, group_id)
if db_group is None: if db_group is None:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
@@ -63,25 +102,37 @@ def unassign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa
db_group.accessauths.remove(db_aa) db_group.accessauths.remove(db_aa)
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@aa_router.patch("/{aa_id}", response_model=AccessAuthorizationResponse) @aa_router.patch("/{aa_id}", response_model=AccessAuthorizationResponse)
def change_accessauth(*, db: Session = Depends(get_session), aa_id: int, aa: AccessAuthorizationUpdate, admin: bool = Depends(auth_is_admin)): def change_accessauth(
*,
db: Session = Depends(get_session),
aa_id: int,
aa: AccessAuthorizationUpdate,
admin: bool = Depends(auth_is_admin),
):
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AccessAuthorization not found") raise HTTPException(status_code=404, detail="AccessAuthorization not found")
aa_data = aa.model_dump(exclude_unset=True) aa_data = aa.model_dump(exclude_unset=True, exclude_none=True)
if "timetables" in aa_data and aa_data["timetables"] is not None: if "oneshot" in aa_data and aa_data["oneshot"] is not None:
db_aa.timetables.clear() oneshot = OneShotAccess.model_validate(aa_data["oneshot"])
timetables = [Timetable.model_validate(t) for t in aa_data["timetables"]] db_aa.oneshot = oneshot
db_aa.timetables = timetables aa_data.pop("oneshot")
aa_data.pop("timetables")
db_aa.sqlmodel_update(aa_data) db_aa.sqlmodel_update(aa_data)
return add_and_refresh(db, db_aa) return add_and_refresh(db, db_aa)
@aa_router.delete("/{aa_id}") @aa_router.delete("/{aa_id}")
def delete_accessauth(*, db: Session = Depends(get_session), aa_id: int, admin: bool = Depends(auth_is_admin)): def delete_accessauth(
*,
db: Session = Depends(get_session),
aa_id: int,
admin: bool = Depends(auth_is_admin),
):
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AccessAuthorization not found") raise HTTPException(status_code=404, detail="AccessAuthorization not found")
db.delete(db_aa) db.delete(db_aa)
db.commit() db.commit()
return {"message": "AccessAuthorization deleted successfully"} return {"message": "AccessAuthorization deleted successfully"}
+79 -31
View File
@@ -1,70 +1,118 @@
import logging import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from typing import List
from sqlalchemy.exc import NoResultFound
from ..model.models import Card, CardCreate, CardUpdate, GroupDB from fastapi import APIRouter, Depends, HTTPException, status
from ..services.database import engine, get_session, add_and_refresh from sqlalchemy.exc import NoResultFound
from ..services.auth import auth_is_admin from sqlmodel import Session, select
import uuid as gen_uuid
from app.services.scanner import WriteNewCard, DeleteCard from app.model.models import Card, CardCreate, CardUpdate, GroupDB
from app.services.auth import auth_is_admin
from app.services.database import add_and_refresh, get_session
from app.services.scanner import DeleteCard, WriteNewCard
logger = logging.getLogger(__name__)
card_router = APIRouter(prefix="/api/v1/cards", tags=["Card"]) card_router = APIRouter(prefix="/api/v1/cards", tags=["Card"])
def register_card(cardInput: CardCreate): def register_card(cardInput: CardCreate):
key, uid = WriteNewCard() key, uid = WriteNewCard()
if key == None: if key is None:
logger.info("No card registered. Check logs!") logger.info("No card registered. Check logs!")
raise HTTPException(status.HTTP_417_EXPECTATION_FAILED, detail="No card registered. Check logs!") raise HTTPException(
status.HTTP_417_EXPECTATION_FAILED, detail="No card registered. Check logs!"
)
card = Card( card = Card(
group_id=cardInput.group_id, group_id=cardInput.group_id,
key=key, key=key,
name=cardInput.name, name=cardInput.name,
card_serial=uid, card_serial=uid,
enabled=cardInput.enabled enabled=cardInput.enabled,
) )
return card return card
@card_router.post("/", response_model=Card) @card_router.post("/", response_model=Card)
def add_card(cardInput: CardCreate, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def add_card(
try: assert db.exec(select(Card).where(Card.name == cardInput.name)).one_or_none() == None cardInput: CardCreate,
except AssertionError: db: Session = Depends(get_session),
raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!") admin: bool = Depends(auth_is_admin),
try: assert db.exec(select(GroupDB).where(GroupDB.id == cardInput.group_id)).one_or_none() is not None ):
except AssertionError: try:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!") assert (
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
try:
assert (
db.exec(
select(GroupDB).where(GroupDB.id == cardInput.group_id)
).one_or_none()
is not None
)
except AssertionError as err:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail="GroupID not found!"
) from err
card = register_card(cardInput) card = register_card(cardInput)
return add_and_refresh(db, card) return add_and_refresh(db, card)
@card_router.delete("/") @card_router.delete("/")
def del_card(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def del_card(
*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
):
key = DeleteCard() key = DeleteCard()
logger.info(key) logger.info(key)
try: try:
card = db.exec(select(Card).where(Card.key == key)).one() card = db.exec(select(Card).where(Card.key == key)).one()
except NoResultFound: except NoResultFound as err:
logger.info(f"The key:'{key}' was not found in db!") 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. Please tell an admin about this. KEY={key}") raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Key on card not found in DB. KEY={key}",
) from err
db.delete(card) db.delete(card)
db.commit() db.commit()
return {"message": "Card deleted successfully"} return {"message": "Card deleted successfully"}
@card_router.get("/{group_id}", response_model=List[Card])
def get_cards(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)): @card_router.get("/{group_id}", response_model=list[Card])
def get_cards(
*,
db: Session = Depends(get_session),
group_id: int,
admin: bool = Depends(auth_is_admin),
):
cards = db.exec(select(Card).where(Card.group_id == group_id)).all() cards = db.exec(select(Card).where(Card.group_id == group_id)).all()
return cards return cards
@card_router.patch("/{card_id}", response_model=Card) @card_router.patch("/{card_id}", response_model=Card)
def update_card(card_id: int, cardInput: CardUpdate, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def update_card(
card_id: int,
cardInput: CardUpdate,
db: Session = Depends(get_session),
admin: bool = Depends(auth_is_admin),
):
db_card = db.get(Card, card_id) db_card = db.get(Card, card_id)
if db_card is None: if db_card is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Card not found!") raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Card not found!")
card_data = cardInput.model_dump(exclude_unset=True, exclude_none=True) card_data = cardInput.model_dump(exclude_unset=True, exclude_none=True)
if "group_id" in card_data: if "group_id" in card_data:
try: assert db.exec(select(GroupDB).where(GroupDB.id == cardInput.group_id)).one_or_none() is not None try:
except AssertionError: assert (
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!") db.exec(
select(GroupDB).where(GroupDB.id == cardInput.group_id)
).one_or_none()
is not None
)
except AssertionError as err:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail="GroupID not found!"
) from err
db_card.sqlmodel_update(card_data) db_card.sqlmodel_update(card_data)
return add_and_refresh(db, db_card) return add_and_refresh(db, db_card)
+54 -14
View File
@@ -1,11 +1,15 @@
import logging import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.services.auth import auth_is_admin from sqlalchemy import exc
from sqlmodel import Session, select from sqlmodel import Session, select
import sqlalchemy.exc as exc
from app.model.models import * from app.model.models import Card
from app.services.database import get_session, add_and_refresh 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__)
debug_router = APIRouter( debug_router = APIRouter(
prefix="/api/v1/debug", prefix="/api/v1/debug",
@@ -13,38 +17,74 @@ debug_router = APIRouter(
dependencies=[Depends(auth_is_admin)], dependencies=[Depends(auth_is_admin)],
) )
@debug_router.put("/addcard/") @debug_router.put("/addcard/")
def add_card_manually(groupid: int, card_key: str, name: str, enabled: bool, db: Session=Depends(get_session)): def add_card_manually(
groupid: int,
card_key: str,
name: str,
enabled: bool,
db: Session = Depends(get_session),
):
"""Add cards manually (you also have to delete them manually)""" """Add cards manually (you also have to delete them manually)"""
logger.critical(f"Manual db change: adding a card with key: {card_key} to group: {groupid}") logger.critical(
f"Manual db change: adding a card with key: {card_key} to group: {groupid}"
)
card = Card( card = Card(
group_id=groupid, group_id=groupid,
key=card_key, key=card_key,
name=name, name=name,
enabled=enabled, enabled=enabled,
card_serial="00:00:00:00:00:00:00" card_serial="00:00:00:00:00:00:00",
) )
add_and_refresh(db, card) add_and_refresh(db, card)
return card return card
@debug_router.get("/rmcard/{card_id}") @debug_router.get("/rmcard/{card_id}")
def remove_card_manually(card_id: str, db: Session = Depends(get_session)): def remove_card_manually(card_id: str, db: Session = Depends(get_session)):
try: try:
card = db.exec(select(Card).where(Card.id == card_id)).one() card = db.exec(select(Card).where(Card.id == card_id)).one()
except exc.NoResultFound: except exc.NoResultFound as err:
raise HTTPException(status_code=500, detail="No card with that id found.") raise HTTPException(
status_code=500, detail="No card with that id found."
) from err
logger.critical(f"Manual db change: removing a card with attrs: {card}") logger.critical(f"Manual db change: removing a card with attrs: {card}")
db.delete(card) db.delete(card)
db.commit() db.commit()
return {"message": "Card deleted successfully"} return {"message": "Card deleted successfully"}
@debug_router.put("/getcards") @debug_router.put("/getcards")
def list_all_cards(db: Session = Depends(get_session)): def list_all_cards(db: Session = Depends(get_session)):
logger.info(f"Debug Setting: Getting cards.") logger.info("Debug Setting: Getting cards.")
cards = db.exec(select(Card)).all() cards = db.exec(select(Card)).all()
print(cards) print(cards)
out = [] out = []
for i in cards: for i in cards:
out.append({i.key: i.group.name}) out.append({i.key: i.group.name})
return out 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()
+20 -8
View File
@@ -1,20 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlmodel import Session from sqlmodel import Session
from app.services.database import get_session
from app.services.auth import auth_is_admin
import app.services.door as doorService import app.services.door as doorService
from app.services.auth import auth_is_admin
from app.services.database import get_session
door_router = APIRouter(prefix="/api/v1/door", tags=["Door"])
door_router = APIRouter(prefix="/api/v1/door",tags=["Door"])
@door_router.put("/open") @door_router.put("/open")
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
doorService.opendoor() doorService.openDoor()
@door_router.put("/close") @door_router.put("/close")
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def close_door(
doorService.closedoor() db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
):
doorService.closeDoor()
@door_router.put("/status")
def is_door_open(
db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
):
return doorService.isDoorOpen()
@door_router.post("/test") @door_router.post("/test")
def test_access(input: str, db: Session = Depends(get_session)): def test_access(input: str, db: Session = Depends(get_session)):
return doorService.checkAccess(input, db=db) return doorService.checkAccess(input, db=db)
+26 -10
View File
@@ -1,31 +1,47 @@
from fastapi import APIRouter, HTTPException, Depends, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List
from ..model.models import GroupDB, GroupResponse, GroupCreate from ..model.models import GroupCreate, GroupDB, GroupResponse
from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin from ..services.auth import auth_is_admin
from ..services.database import add_and_refresh, get_session
group_router = APIRouter(prefix="/api/v1/groups", tags=["Group"]) group_router = APIRouter(prefix="/api/v1/groups", tags=["Group"])
@group_router.get("/", response_model=List[GroupResponse])
def get_groups(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): @group_router.get("/", response_model=list[GroupResponse])
def get_groups(
*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
):
groups = db.exec(select(GroupDB)).all() groups = db.exec(select(GroupDB)).all()
return groups return groups
@group_router.post("/", response_model=GroupResponse) @group_router.post("/", response_model=GroupResponse)
def create_group(*, db: Session = Depends(get_session), group: GroupCreate, admin: bool = Depends(auth_is_admin)): def create_group(
*,
db: Session = Depends(get_session),
group: GroupCreate,
admin: bool = Depends(auth_is_admin),
):
db_group = GroupDB.model_validate(group) db_group = GroupDB.model_validate(group)
group = db.exec(select(GroupDB).where(GroupDB.name == db_group.name)).first() group = db.exec(select(GroupDB).where(GroupDB.name == db_group.name)).first()
if group is not None: if group is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists!") raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Group already exists!"
)
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@group_router.delete("/{group_id}") @group_router.delete("/{group_id}")
def delete_group(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)): def delete_group(
*,
db: Session = Depends(get_session),
group_id: int,
admin: bool = Depends(auth_is_admin),
):
db_group = db.get(GroupDB, group_id) db_group = db.get(GroupDB, group_id)
if db_group is None: if db_group is None:
raise HTTPException(status_code=404, detail="Group not found") raise HTTPException(status_code=404, detail="Group not found")
db.delete(db_group) db.delete(db_group)
db.commit() db.commit()
return {"message": "Group deleted successfully"} return {"message": "Group deleted successfully"}
+56 -18
View File
@@ -1,42 +1,75 @@
import logging import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, HTTPException, Depends, status
from sqlmodel import Session, select
from typing import List
from ..model.models import UserResponse, UserCreate, UserDB, UserUpdate from fastapi import APIRouter, Depends, HTTPException, status
from ..services.database import engine, get_session, add_and_refresh from sqlmodel import Session, select
from ..services.auth import get_password_hash, get_current_user as auth_user, auth_is_admin
from app.model.models import UserCreate, UserDB, UserResponse, UserUpdate
from app.services.auth import auth_is_admin, get_password_hash
from app.services.auth import get_current_user as auth_user
from app.services.database import add_and_refresh, get_session
logger = logging.getLogger(__name__)
user_router = APIRouter(tags=["Users"], prefix="/api/v1/users") user_router = APIRouter(tags=["Users"], prefix="/api/v1/users")
@user_router.post("/", response_model=UserResponse) @user_router.post("/", response_model=UserResponse)
def create_user(*, db: Session = Depends(get_session), user: UserCreate, admin: bool = Depends(auth_is_admin)): def create_user(
*,
db: Session = Depends(get_session),
user: UserCreate,
admin: bool = Depends(auth_is_admin),
):
hashed_password = {"passwordhash": get_password_hash(user.password)} hashed_password = {"passwordhash": get_password_hash(user.password)}
try: assert db.exec(select(UserDB).where(UserDB.name == user.name)).one_or_none() == None try:
except AssertionError: assert (
raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!") 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
db_user = UserDB.model_validate(user, update=hashed_password) db_user = UserDB.model_validate(user, update=hashed_password)
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)
@user_router.get("/", response_model=List[UserResponse])
def read_users(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): @user_router.get("/", response_model=list[UserResponse])
def read_users(
*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
):
users = db.exec(select(UserDB)).all() users = db.exec(select(UserDB)).all()
return users return users
@user_router.get("/current", response_model=UserResponse) @user_router.get("/current", response_model=UserResponse)
def get_current_user(db: Session = Depends(get_session), user: UserDB = Depends(auth_user)): def get_current_user(
db: Session = Depends(get_session), user: UserDB = Depends(auth_user)
):
return user return user
@user_router.get("/{user_id}", response_model=UserResponse) @user_router.get("/{user_id}", response_model=UserResponse)
def read_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)): def read_user(
*,
db: Session = Depends(get_session),
user_id: int,
admin: bool = Depends(auth_is_admin),
):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return db_user return db_user
@user_router.patch("/{user_id}", response_model=UserResponse) @user_router.patch("/{user_id}", response_model=UserResponse)
def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserUpdate, admin: bool = Depends(auth_is_admin)): def update_user(
*,
db: Session = Depends(get_session),
user_id: int,
user: UserUpdate,
admin: bool = Depends(auth_is_admin),
):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
@@ -48,12 +81,17 @@ def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserU
db_user.sqlmodel_update(user_data, update=hashed_password) db_user.sqlmodel_update(user_data, update=hashed_password)
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)
@user_router.delete("/{user_id}") @user_router.delete("/{user_id}")
def delete_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)): def delete_user(
*,
db: Session = Depends(get_session),
user_id: int,
admin: bool = Depends(auth_is_admin),
):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
db.delete(db_user) db.delete(db_user)
db.commit() db.commit()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}
+38 -28
View File
@@ -1,49 +1,59 @@
import logging import logging
logger = logging.getLogger(__name__) from contextlib import asynccontextmanager
import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from contextlib import asynccontextmanager
from dotenv import load_dotenv
from .controllers import userManager, cardManager, groupManager, aaManager, doorManager, debugManager from app.controllers import (
from .services.database import create_db_and_tables, get_db_session aaManager,
from .services.auth import token_router, create_first_user cardManager,
debugManager,
doorManager,
groupManager,
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 from app.services.scanner import BackgroundScanner
from app.services.settings import settings
logger = logging.getLogger(__name__)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
scanner = BackgroundScanner(db=get_db_session()) scanner = BackgroundScanner(db=get_db_session())
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
def checkDeps():
load_dotenv()
MIFARE_APP_MASTER_KEY = os.getenv('MIFARE_APP_MASTER_KEY')
if not MIFARE_APP_MASTER_KEY:
logger.critical(f"MIFARE APP MASTER KEY not found!")
logger.critical("Writing and reading cards is disabled!")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.critical("-"*63)
logger.critical("---- Documentation is at http://127.0.0.1:8000/api/v1/docs ----")
logger.critical("-"*63)
checkDeps()
create_db_and_tables() create_db_and_tables()
create_first_user(db=get_db_session()) create_first_user(db=get_db_session())
logger.info("Database created and tables initialized.") init_controller(
disableCards = os.getenv("DISABLE_CARDS") DoorController(
if not disableCards: mqtt_host=settings.mqtt_host,
mqtt_port=settings.mqtt_port,
mock_factory=settings.mock,
),
)
if not settings.disable_cards:
scanner.start() 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 yield
#scanner.stop() stop_caldav_handler()
# scanner.stop()
app = FastAPI( app = FastAPI(
lifespan=lifespan, lifespan=lifespan, docs_url="/api/v1/docs", openapi_url="/api/v1/openapi.json"
docs_url="/api/v1/docs", )
openapi_url="/api/v1/openapi.json"
)
origins = [ origins = [
"http://127.0.0.1", "http://127.0.0.1",
@@ -55,7 +65,7 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=origins, allow_origins=origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET" "PUT" "POST" "DELETE" "PATCH"], allow_methods=["GET", "PUT", "POST", "DELETE", "PATCH"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -65,4 +75,4 @@ app.include_router(groupManager.group_router)
app.include_router(cardManager.card_router) app.include_router(cardManager.card_router)
app.include_router(aaManager.aa_router) app.include_router(aaManager.aa_router)
app.include_router(doorManager.door_router) app.include_router(doorManager.door_router)
app.include_router(debugManager.debug_router) app.include_router(debugManager.debug_router)
+98 -29
View File
@@ -1,85 +1,165 @@
from sqlmodel import Field, Relationship, Session, SQLModel from datetime import UTC, datetime
from typing import List from typing import Literal
from datetime import time
from pydantic import model_validator
from sqlalchemy import DateTime, TypeDecorator
from sqlmodel import Field, Relationship, SQLModel
class Base(SQLModel): class Base(SQLModel):
pass 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 #### User
class UserBase(Base): class UserBase(Base):
name: str = Field(index=True, unique=True) name: str = Field(index=True, unique=True)
email: str | None = None email: str | None = None
is_admin: bool = False is_admin: bool = False
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
class UserDB(UserBase, table=True): class UserDB(UserBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
passwordhash: str passwordhash: str
class UserUpdate(Base): class UserUpdate(Base):
name: str | None = None name: str | None = None
email: str | None = None email: str | None = None
is_admin: bool | None = None is_admin: bool | None = None
password: str | None = None password: str | None = None
#### Special #### Special
class AaGroupLink(Base, table=True): class AaGroupLink(Base, table=True):
group_id: int | None = Field(default=None, foreign_key="groupdb.id", primary_key=True) group_id: int | None = Field(
accessauth_id: int | None = Field(default=None, foreign_key="accessauthorizationdb.id", primary_key=True) default=None, foreign_key="groupdb.id", primary_key=True
)
accessauth_id: int | None = Field(
default=None, foreign_key="accessauthorizationdb.id", primary_key=True
)
#### Token #### Token
class Token(Base): class Token(Base):
access_token: str access_token: str
token_type: str token_type: str
class TokenData(Base): class TokenData(Base):
username: str | None = None username: str | None = None
#### Group #### Group
class GroupBase(Base): class GroupBase(Base):
name: str = Field(index=True, unique=True) name: str = Field(index=True, unique=True)
class GroupCreate(GroupBase): class GroupCreate(GroupBase):
pass pass
class GroupDB(GroupBase, table=True): class GroupDB(GroupBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
cards: List["Card"] = Relationship(back_populates="group") cards: list["Card"] = Relationship(back_populates="group")
accessauths: List["AccessAuthorizationDB"] = Relationship(back_populates="groups", link_model=AaGroupLink) accessauths: list["AccessAuthorizationDB"] = Relationship(
back_populates="groups", link_model=AaGroupLink
)
class GroupResponse(GroupBase): class GroupResponse(GroupBase):
id: int id: int
cards: List["Card"] | None cards: list["Card"] | None
accessauths: List["AccessAuthorizationDB"] | None accessauths: list["AccessAuthorizationDB"] | None
#### AccessAuthorization #### AccessAuthorization
class AccessAuthorizationBase(Base): class AccessAuthorizationBase(Base):
name: str = Field(index=True) name: str = Field(index=True)
type: Literal["oneshot", "somefuturespec"]
is_active: bool is_active: bool
class AccessAuthorizationDB(AccessAuthorizationBase, table=True): class AccessAuthorizationDB(AccessAuthorizationBase, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
groups: List["GroupDB"] = Relationship(back_populates="accessauths", link_model=AaGroupLink) type: str
timetables: List["Timetable"] = Relationship(back_populates="accessauth", cascade_delete=True) groups: list["GroupDB"] = Relationship(
back_populates="accessauths", link_model=AaGroupLink
)
oneshot: "OneShotAccess" = Relationship(
back_populates="accessauth", cascade_delete=True
)
class OneShotAccessBase(Base):
uses: int = 1
ends_at: datetime = Field(sa_type=UTCDateTime)
class OneShotAccess(OneShotAccessBase, 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="oneshot")
class AccessAuthorizationCreate(AccessAuthorizationBase): class AccessAuthorizationCreate(AccessAuthorizationBase):
timetables: List["TimetableCreate"] oneshot: OneShotAccessBase | None = None
@model_validator(mode="after")
def check_type(self):
if self.type == "oneshot":
if not self.oneshot:
raise ValueError("oneshot auths require a oneshot object")
elif self.type == "somefuturespec":
raise ValueError("somefuturespec is not jet implemented. Please do not use")
return self
class AccessAuthorizationResponse(AccessAuthorizationBase): class AccessAuthorizationResponse(AccessAuthorizationBase):
id: int id: int
timetables: List["Timetable"] oneshot: OneShotAccessBase | None = None
groups: List["GroupDB"] groups: list["GroupDB"]
class AccessAuthorizationUpdate(Base): class AccessAuthorizationUpdate(Base):
name: str | None = None name: str | None = None
type: Literal["oneshot", "somefuturespec"] | None = None
is_active: bool | None = None is_active: bool | None = None
timetables: List["TimetableCreate"] | None = None oneshot: OneShotAccessBase | None = None
#### Card #### Card
class Card(Base, table=True): class Card(Base, table=True):
@@ -91,25 +171,14 @@ class Card(Base, table=True):
group_id: int | None = Field(default=None, foreign_key="groupdb.id") group_id: int | None = Field(default=None, foreign_key="groupdb.id")
group: GroupDB | None = Relationship(back_populates="cards") group: GroupDB | None = Relationship(back_populates="cards")
class CardCreate(Base): class CardCreate(Base):
name: str = Field(unique=True, max_length=32) name: str = Field(unique=True, max_length=32)
enabled: bool = True enabled: bool = True
group_id: int group_id: int
class CardUpdate(Base): class CardUpdate(Base):
name: str | None = None name: str | None = None
enabled: bool | None = None enabled: bool | None = None
group_id: int | 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
+37 -30
View File
@@ -1,21 +1,23 @@
import logging import logging
logger = logging.getLogger(__name__) import secrets
import string
from datetime import UTC, datetime, timedelta
from typing import Annotated from typing import Annotated
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlmodel import Session, select
from pwdlib import PasswordHash
import jwt import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError from jwt.exceptions import InvalidTokenError
from ..model.models import UserDB, Token, TokenData, UserCreate from pwdlib import PasswordHash
from ..services.database import * from sqlmodel import Session, select
import secrets, string, os
from dotenv import load_dotenv from app.model.models import Token, TokenData, UserDB
from app.services.database import add_and_refresh, get_session
from app.services.settings import settings
load_dotenv() logger = logging.getLogger(__name__)
SECRET_KEY = os.getenv("SECRET_KEY", default="ff"*16)
SECRET_KEY = settings.secret_key
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 120 ACCESS_TOKEN_EXPIRE_MINUTES = 120
@@ -25,16 +27,20 @@ token_router = APIRouter(tags=["Token"], prefix="/api/v1")
password_hash = PasswordHash.recommended() password_hash = PasswordHash.recommended()
def verify_password(plain_password, hashed_password): def verify_password(plain_password, hashed_password):
return password_hash.verify(plain_password, hashed_password) return password_hash.verify(plain_password, hashed_password)
def get_password_hash(password): def get_password_hash(password):
return password_hash.hash(password) return password_hash.hash(password)
def get_user(db, username: str): def get_user(db, username: str):
user = db.exec(select(UserDB).where(UserDB.name == username)).first() user = db.exec(select(UserDB).where(UserDB.name == username)).first()
return user return user
def authenticate_user(db, username: str, password: str): def authenticate_user(db, username: str, password: str):
user = get_user(db, username) user = get_user(db, username)
if not user: if not user:
@@ -43,24 +49,26 @@ def authenticate_user(db, username: str, password: str):
return False return False
return user return user
def create_access_token(data: dict, expires_delta: timedelta | None = None): def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(UTC) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15) expire = datetime.now(UTC) + timedelta(minutes=15)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt return encoded_jwt
def get_current_user( def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], token: Annotated[str, Depends(oauth2_scheme)],
db: Session = Depends(get_session), db: Session = Depends(get_session),
): ):
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
@@ -68,36 +76,36 @@ def get_current_user(
if username is None: if username is None:
raise credentials_exception raise credentials_exception
token_data = TokenData(username=username) token_data = TokenData(username=username)
except InvalidTokenError: except InvalidTokenError as err:
raise credentials_exception raise credentials_exception from err
user = get_user(db, username=token_data.username) user = get_user(db, username=token_data.username)
if user is None: if user is None:
raise credentials_exception raise credentials_exception
return user return user
def auth_is_admin( def auth_is_admin(
token: str = Depends(oauth2_scheme), token: str = Depends(oauth2_scheme),
db: Session = Depends(get_session), db: Session = Depends(get_session),
): ):
user = get_current_user(token=token, db=db) user = get_current_user(token=token, db=db)
if not user.is_admin: if not user.is_admin:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to perform this action", detail="Not authorized to perform this action",
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"},
) )
return True return True
def create_first_user(db: Session): def create_first_user(db: Session):
logger.info("Checking for admin user") logger.info("Checking for admin user")
admin_user = db.exec(select(UserDB)).first() admin_user = db.exec(select(UserDB)).first()
if admin_user is None: if admin_user is None:
password = ''.join(secrets.choice(string.digits) for i in range(8)) password = "".join(secrets.choice(string.digits) for i in range(8))
logger.info(f"Creating first admin user with password: {password}") logger.info(f"Creating first admin user with password: {password}")
user = UserDB( user = UserDB(
name="admin", name="admin", passwordhash=get_password_hash(password), is_admin=True
passwordhash=get_password_hash(password),
is_admin=True
) )
return add_and_refresh(db, user) return add_and_refresh(db, user)
logger.info(f"Admin user already exists: {admin_user.name}") logger.info(f"Admin user already exists: {admin_user.name}")
@@ -106,14 +114,14 @@ def create_first_user(db: Session):
@token_router.post("/token") @token_router.post("/token")
def login_for_access_token( def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Session = Depends(get_session) db: Session = Depends(get_session),
) -> Token: ) -> Token:
user = authenticate_user(db, form_data.username, form_data.password) user = authenticate_user(db, form_data.username, form_data.password)
if not user: if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or pw", detail="Incorrect username or pw",
headers={"WWW-Authenticate": "Bearer"} headers={"WWW-Authenticate": "Bearer"},
) )
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token( access_token = create_access_token(
@@ -121,8 +129,7 @@ def login_for_access_token(
) )
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")
@token_router.get("/test/login") @token_router.get("/test/login")
def test_login( def test_login(current_user: Annotated[UserDB, Depends(get_current_user)]) -> UserDB:
current_user: Annotated[UserDB, Depends(get_current_user)] return current_user
) -> UserDB:
return current_user
+186
View File
@@ -0,0 +1,186 @@
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
+75 -8
View File
@@ -1,23 +1,90 @@
from sqlmodel import create_engine, SQLModel, Session import logging
from ..model.models import Base from sqlalchemy import inspect
from sqlmodel import Session, SQLModel, create_engine
SQLALCHEMY_DATABASE_URL = "sqlite:///./gatekeeper.db" 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():
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
engine = create_engine(SQLALCHEMY_DATABASE_URL)
def create_db_and_tables(): def create_db_and_tables():
SQLModel.metadata.create_all(engine) engine = get_engine()
existing_tables = inspect(engine).get_table_names()
if not existing_tables:
SQLModel.metadata.create_all(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.attributes["sqlalchemy.url"] = settings.sqlalchemy_database_url
command.stamp(alembic_cfg, "head")
logger.info("Database created and tables initialized.")
else:
logger.info(
"Database already initialized (%d tables found).", len(existing_tables)
)
def get_session(): def get_session():
with Session(engine) as db: with Session(get_engine()) as db:
yield db yield db
def get_db_session(): def get_db_session():
return Session(engine) return Session(get_engine())
def add_and_refresh(db: Session, obj): def add_and_refresh(db: Session, obj):
db.add(obj) db.add(obj)
db.commit() db.commit()
db.refresh(obj) db.refresh(obj)
return obj return obj
+115 -34
View File
@@ -1,52 +1,133 @@
import logging import logging
logger = logging.getLogger(__name__) from datetime import datetime
from sqlalchemy import exc
from sqlmodel import select from sqlmodel import select
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import selectinload
import sqlalchemy.exc as exc
import datetime
from app.services.database import Session, get_session from app.model.models import Card, OneShotAccess
from app.model.models import * from app.services.caldav import get_caldav_handler
from app.services.database import Session, add_and_refresh
logger = logging.getLogger(__name__)
# See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional
# TODO: add sensor pin
class DoorController:
def __init__(
self,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mock_factory: bool = False,
):
self._is_open: bool = False
self._mqtt = None
self._mqtt_topic = "nukihub/lock/action"
self._mock = mock_factory
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.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,
mock_factory,
)
def open(self):
if self._mock:
self._is_open = True
logger.info("Door unlocked.[MOCK]")
return
self._mqtt.publish(self._mqtt_topic, "unlock")
self._is_open = True
logger.info("Door unlocked!")
def close(self):
if self._mock:
self._is_open = False
logger.info("Door locked.[MOCK]")
return
self._mqtt.publish(self._mqtt_topic, "lock")
self._is_open = False
logger.info("Door locked!")
def is_open(self):
return self._is_open
_contoller: DoorController | None = None
def init_controller(ctrl: DoorController):
global _contoller
_contoller = ctrl
def get_controller():
if _contoller is None:
raise RuntimeError("DoorController not initialized.")
return _contoller
doorIsOpen = True
# I think this could also be gpio controlled
#See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional
def openDoor(): def openDoor():
global doorIsOpen get_controller().open()
doorIsOpen = True
logger.info("Still needs gpio out")
pass
def closeDoor(): def closeDoor():
global doorIsOpen get_controller().close()
doorIsOpen = False
logger.info("Still needs gpio out")
pass
def isDoorOpen(): def isDoorOpen():
return doorIsOpen return get_controller().is_open()
def decrementOneshot(db: Session, oneshot: OneShotAccess):
data = oneshot.model_dump()
if data["uses"] > 0:
data["uses"] = data["uses"] - 1
oneshot.sqlmodel_update(oneshot, update=data)
add_and_refresh(db, oneshot)
def checkAccess(key: str, db: Session): def checkAccess(key: str, db: Session):
try: try:
current_weekday = datetime.datetime.weekday(datetime.date.today())
current_time = datetime.datetime.now()
card = db.exec(select(Card).where(Card.key == key)).one() 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: for auth in card.group.accessauths:
logger.info(f"checking auth: {auth.name}") logger.info(f"checking auth: {auth.name}")
for timetable in auth.timetables: if not auth.is_active:
logger.info(f" checking timetable {timetable.id}") logger.info("AA inactive!")
logger.info(f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}") continue
if current_weekday == timetable.weekday: if auth.type == "oneshot":
starttime = datetime.datetime.combine(datetime.date.today(), timetable.starttime) logger.info(f" oneshot auth found: {auth.oneshot}")
endtime = starttime + datetime.timedelta(minutes=timetable.duration) ends_at = auth.oneshot.ends_at
logger.info(f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}") if ends_at.tzinfo is None:
if starttime < current_time < endtime: ends_at = ends_at.astimezone()
logger.info("Access Valid!") if current_time < ends_at and auth.oneshot.uses > 0:
return True decrementOneshot(db, auth.oneshot)
return True
logger.info("No more auths found") logger.info("No more auths found")
return False return False
except exc.NoResultFound: except exc.NoResultFound as err:
raise Exception("No Access with that key found, this might be a db error") raise Exception(
"No Access with that key found, this might be a db error"
) from err
+104 -64
View File
@@ -1,33 +1,37 @@
import logging import logging
logger = logging.getLogger(__name__) import secrets
import threading import threading
import time import time
import os
import secrets
from typing import Optional from desfire import (
from sqlmodel import Session DESFire,
from dotenv import load_dotenv DESFireKey,
PCSCDevice,
diversify_key,
get_list,
to_hex_string,
)
from desfire.enums import (
DESFireCommunicationMode,
DESFireFileType,
DESFireKeySettings,
DESFireKeyType,
)
from desfire.schemas import FilePermissions, FileSettings, KeySettings
from fastapi import HTTPException, status from fastapi import HTTPException, status
from smartcard.CardRequest import CardRequest from smartcard.CardRequest import CardRequest
from smartcard.CardType import AnyCardType from smartcard.CardType import AnyCardType
from smartcard.Exceptions import CardRequestTimeoutException from smartcard.Exceptions import CardRequestTimeoutException
from desfire import DESFire, DESFireKey, PCSCDevice, diversify_key, get_list, to_hex_string from app.services.door import checkAccess, closeDoor, isDoorOpen, openDoor
from desfire.enums import DESFireCommunicationMode, DESFireFileType, DESFireKeySettings, DESFireKeyType from app.services.settings import settings
from desfire.schemas import FilePermissions, FileSettings, KeySettings
import desfire.exceptions as desExceptions
from app.services.door import openDoor, closeDoor, isDoorOpen, checkAccess logger = logging.getLogger(__name__)
# ENV vars
#ENV vars MIFARE_APP_MASTER_KEY = settings.mifare_app_master_key
load_dotenv() MIFARE_ACL_READ_BASE_KEY = settings.mifare_acl_read_base_key
MIFARE_APP_MASTER_KEY = os.getenv('MIFARE_APP_MASTER_KEY') MIFARE_ACL_WRITE_BASE_KEY = settings.mifare_acl_write_base_key
MIFARE_ACL_READ_BASE_KEY = os.getenv('MIFARE_ACL_READ_BASE_KEY')
MIFARE_ACL_WRITE_BASE_KEY = os.getenv('MIFARE_ACL_WRITE_BASE_KEY')
# Constants # Constants
MIFARE_APP_ID = "DEAFFE" # 7 bytes MIFARE_APP_ID = "DEAFFE" # 7 bytes
@@ -36,10 +40,15 @@ MIFARE_ACL_WRITE_BASE_KEY_ID = 0x2
MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything
MIFARE_ENCRYPTED_FILE_ID = 0x1 MIFARE_ENCRYPTED_FILE_ID = 0x1
def checkForKey(): def checkForKey():
if MIFARE_APP_MASTER_KEY == None: if MIFARE_APP_MASTER_KEY is None:
logger.critical("NO MASTER KEY LOADED") logger.critical("NO MASTER KEY LOADED")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No key loaded! Check application.") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No key loaded! Check application.",
)
def getCardService(timeout: int = 10): def getCardService(timeout: int = 10):
cardtype = AnyCardType() cardtype = AnyCardType()
@@ -48,51 +57,58 @@ def getCardService(timeout: int = 10):
cardservice.connection.connect() cardservice.connection.connect()
return cardservice return cardservice
def readFileOnCard(desfire: DESFire): def readFileOnCard(desfire: DESFire):
if not MIFARE_ACL_READ_BASE_KEY: if not MIFARE_ACL_READ_BASE_KEY:
logger.critical("MIFARE_ACL_READ_BASE_KEY not found! Reading skipped!") logger.critical("MIFARE_ACL_READ_BASE_KEY not found! Reading skipped!")
return return
#create keys # create keys
#desfire = DESFire(PCSCDevice(cardservice.connection.component)) # desfire = DESFire(PCSCDevice(cardservice.connection.component))
aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES) aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
keysettings = desfire.get_key_setting() keysettings = desfire.get_key_setting()
desKey = DESFireKey(keysettings, "00" * 8) desKey = DESFireKey(keysettings, "00" * 8)
# Get real UID # Get real UID
desfire.authenticate(0x0, desKey) desfire.authenticate(0x0, desKey)
#To get the uid you have to auth with an empty (default) key # To get the uid you have to auth with an empty (default) key
uid = desfire.get_real_uid() uid = desfire.get_real_uid()
applications = desfire.get_application_ids() applications = desfire.get_application_ids()
try: try:
assert len(applications) == 1 assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID) assert applications[0] == get_list(MIFARE_APP_ID)
except AssertionError: except AssertionError:
logger.error("No application found!") logger.error("No application found!")
time.sleep(4) time.sleep(4)
return return
#Then use the key derivation with that uid, the appid, the sysid # Then use the key derivation with that uid, the appid, the sysid
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID) diversification_data = (
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False) [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
)
#Log in with derived read key read_div_key_bytes = diversify_key(
get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False
)
# Log in with derived read key
logger.debug("Start auth") logger.debug("Start auth")
aes_app_read_key = DESFireKey(aes_keysettings, read_div_key_bytes) aes_app_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.select_application(MIFARE_APP_ID) desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(MIFARE_ACL_READ_BASE_KEY_ID, aes_app_read_key) desfire.authenticate(MIFARE_ACL_READ_BASE_KEY_ID, aes_app_read_key)
logger.debug(f"Read data from {MIFARE_ENCRYPTED_FILE_ID}") logger.debug(f"Read data from {MIFARE_ENCRYPTED_FILE_ID}")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID) file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data) rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
#convert list of int to str # convert list of int to str
rdata = to_hex_string(rdata).replace(" ", "").lower() rdata = to_hex_string(rdata).replace(" ", "").lower()
logger.debug(f"Data on card: {rdata}") logger.debug(f"Data on card: {rdata}")
return rdata return rdata
def DeleteCard(): def DeleteCard():
try: try:
checkForKey() checkForKey()
from app.main import scanner as scannerThread from app.main import scanner as scannerThread
scannerThread.stop() scannerThread.stop()
cardservice = getCardService(15) cardservice = getCardService(15)
@@ -107,24 +123,26 @@ def DeleteCard():
desKey = DESFireKey(des_keysettings, "00" * 8) desKey = DESFireKey(des_keysettings, "00" * 8)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY) aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
aes_null_key = DESFireKey(aes_keysettings, "00" * 16) aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
desfire.select_application(0x0) desfire.select_application(0x0)
try: try:
try: try:
logger.debug("Auth1")# logger.debug("Auth1")
desfire.authenticate(0x0, aes_master_key) desfire.authenticate(0x0, aes_master_key)
except: except (Exception, AssertionError):
logger.debug("Auth2") logger.debug("Auth2")
desfire.authenticate(0x0, aes_null_key) desfire.authenticate(0x0, aes_null_key)
except: except (Exception, AssertionError):
logger.debug("Auth3") logger.debug("Auth3")
desfire.authenticate(0x0, desKey) desfire.authenticate(0x0, desKey)
applications = desfire.get_application_ids() applications = desfire.get_application_ids()
logger.debug(f"Applications: {applications}") logger.debug(f"Applications: {applications}")
if len(applications) == 0: if len(applications) == 0:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="No applications on card") raise HTTPException(
status_code=status.HTTP_410_GONE, detail="No applications on card"
)
desfire.select_application(MIFARE_APP_ID) desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(0x0, aes_master_key) desfire.authenticate(0x0, aes_master_key)
@@ -136,14 +154,18 @@ def DeleteCard():
pass pass
scannerThread.start() scannerThread.start()
return rdata return rdata
except(Exception, AssertionError) as e: except (Exception, AssertionError) as err:
logger.error(f"Error in deletion function: {e}", exc_info=True) logger.error(f"Error in deletion function: {err}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}"
) from err
def WriteNewCard(): def WriteNewCard():
try: try:
checkForKey() checkForKey()
from app.main import scanner as scannerThread from app.main import scanner as scannerThread
scannerThread.stop() scannerThread.stop()
cardservice = getCardService(20) cardservice = getCardService(20)
@@ -159,8 +181,8 @@ def WriteNewCard():
# Authenticate with default DES key # Authenticate with default DES key
logger.debug("Authenticating with default DES key...") logger.debug("Authenticating with default DES key...")
desfire.authenticate(0x0, desKey) desfire.authenticate(0x0, desKey)
#get uid # get uid
uid = desfire.get_real_uid() uid = desfire.get_real_uid()
# Set default key # Set default key
@@ -189,7 +211,7 @@ def WriteNewCard():
# Select application # Select application
desfire.select_application(MIFARE_APP_ID) desfire.select_application(MIFARE_APP_ID)
#recreate key object # recreate key object
desfire.authenticate(0x0, aes_null_key) desfire.authenticate(0x0, aes_null_key)
desfire.change_key(0x0, aes_null_key, aes_master_key, 0x1) desfire.change_key(0x0, aes_null_key, aes_master_key, 0x1)
@@ -198,18 +220,28 @@ def WriteNewCard():
aes_null_key = DESFireKey(aes_keysettings, "00" * 16) aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
#generate div data # generate div data
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID) diversification_data = (
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False) [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
write_div_key_bytes = diversify_key(get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False) )
read_div_key_bytes = diversify_key(
get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False
)
write_div_key_bytes = diversify_key(
get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False
)
logger.debug("Changing file read key...") logger.debug("Changing file read key...")
aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes) aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.change_key(MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1) desfire.change_key(
MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1
)
logger.debug("Changing file write key...") logger.debug("Changing file write key...")
aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes) aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes)
desfire.change_key(MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1) desfire.change_key(
MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1
)
logger.debug("Create encrypted file containing key...") logger.debug("Create encrypted file containing key...")
file_settings = FileSettings( file_settings = FileSettings(
@@ -226,25 +258,29 @@ def WriteNewCard():
logger.debug("Writing UID to encrypted file...") logger.debug("Writing UID to encrypted file...")
key = secrets.token_hex(16) key = secrets.token_hex(16)
desfire.write_file_data(MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(key)) desfire.write_file_data(
MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(key)
)
logger.debug("Reading from encrypted file...") logger.debug("Reading from encrypted file...")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data) rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
assert rdata == get_list(key) assert rdata == get_list(key)
logger.debug(" - Data written successfully.") logger.debug(" - Data written successfully.")
scannerThread.start() scannerThread.start()
return key, to_hex_string(data=uid, separator=":") return key, to_hex_string(data=uid, separator=":")
except Exception as e: except Exception as err:
logger.error(f"Error in write function: {e}", exc_info=True) logger.error(f"Error in write function: {err}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}") raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}"
) from err
class BackgroundScanner: class BackgroundScanner:
def __init__(self, db): def __init__(self, db):
self.db = db self.db = db
self.is_running = False self.is_running = False
self.thread: Optional[threading.Thread] = None self.thread: threading.Thread | None = None
def start(self): def start(self):
if self.is_running: if self.is_running:
@@ -292,11 +328,15 @@ class BackgroundScanner:
except Exception as e: except Exception as e:
logger.error(f"something went wrong: {e}") logger.error(f"something went wrong: {e}")
time.sleep(5) time.sleep(5)
def _check_db(self, key): def _check_db(self, key):
check = checkAccess(key, self.db) if isDoorOpen():
if check == True: closeDoor()
openDoor() logger.info("Door closed by key %s", key)
logger.info("Access granted!")
else: else:
logger.error("Access denied!") check = checkAccess(key, self.db)
if check:
openDoor()
logger.info("Access granted!")
else:
logger.error("Access denied!")
+97
View File
@@ -0,0 +1,97 @@
import logging
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
secret_key: str
sqlalchemy_database_url: str = "sqlite:///./gatekeeper.db"
mqtt_host: str = "localhost"
mqtt_port: int = 1883
mock: bool = True
alembic_config: str = "./alembic.ini"
disable_cards: bool = False
mifare_app_master_key: str | None = None
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():
settings = Settings()
if not settings.disable_cards:
missing = [
name.upper()
for name in (
"mifare_app_master_key",
"mifare_acl_read_base_key",
"mifare_acl_write_base_key",
)
if not getattr(settings, name)
]
if missing:
logger.critical(
"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
class _SettingsProxy:
_instance: Settings | None = None
def _load(self) -> Settings:
if self._instance is None:
self._instance = _create_settings()
return self._instance
def __getattr__(self, name: str):
return getattr(self._load(), name)
def __setattr__(self, name: str, value):
if name == "_instance":
super().__setattr__(name, value)
else:
setattr(self._load(), name, value)
def reset(self) -> None:
self._instance = None
_create_settings.cache_clear()
settings = _SettingsProxy()
+10 -4
View File
@@ -65,6 +65,10 @@
buildInputs = (old.buildInputs or []) ++ [ pkgs.pcsclite.dev ]; buildInputs = (old.buildInputs or []) ++ [ pkgs.pcsclite.dev ];
NIX_CFLAGS_COMPILE = "-I${pkgs.pcsclite.dev}/include/PCSC"; NIX_CFLAGS_COMPILE = "-I${pkgs.pcsclite.dev}/include/PCSC";
}); });
lgpio = prev.lgpio.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.swig ];
buildInputs = (old.buildInputs or []) ++ [ pkgs.lgpio ];
});
}) })
] ]
) )
@@ -77,7 +81,7 @@
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
pythonSet = pythonSets.${system}.overrideScope editableOverlay; pythonSet = pythonSets.${system}.overrideScope editableOverlay;
virtualenv = pythonSet.mkVirtualEnv "gatekeeper" workspace.deps.all; virtualenv = (pythonSet.mkVirtualEnv "gatekeeper" workspace.deps.all).overrideAttrs(old: { venvIgnoreCollisions = ["*"];});
in in
{ {
default = pkgs.mkShell { default = pkgs.mkShell {
@@ -93,7 +97,7 @@
UV_NO_SYNC = "1"; UV_NO_SYNC = "1";
UV_PYTHON = pythonSet.python.interpreter; UV_PYTHON = pythonSet.python.interpreter;
UV_PYTHON_DOWNLOADS = "never"; UV_PYTHON_DOWNLOADS = "never";
LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib"; LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib:${lib.getLib pkgs.lgpio}/lib";
}; };
shellHook = '' shellHook = ''
unset PYTHONPATH unset PYTHONPATH
@@ -104,7 +108,7 @@
); );
packages = forAllSystems (system: { packages = forAllSystems (system: {
default = pythonSets.${system}.mkVirtualEnv "gatekeeper" workspace.deps.default; default = (pythonSets.${system}.mkVirtualEnv "gatekeeper" workspace.deps.default).overrideAttrs(old: { venvIgnoreCollisions = ["*"];});
}); });
apps = forAllSystems (system: { apps = forAllSystems (system: {
@@ -119,9 +123,11 @@
type = "app"; type = "app";
program = toString (nixpkgs.legacyPackages.${system}.writeShellScript "gatekeeper" '' program = toString (nixpkgs.legacyPackages.${system}.writeShellScript "gatekeeper" ''
export LD_LIBRARY_PATH="${lib.getLib nixpkgs.legacyPackages.${system}.pcsclite}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export LD_LIBRARY_PATH="${lib.getLib nixpkgs.legacyPackages.${system}.pcsclite}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec ${self.packages.${system}.default}/bin/fastapi run ${self}/app/main.py "$@"; exec ${self.packages.${system}.default}/bin/fastapi dev ${self}/app/main.py "$@";
''); '');
}; };
}); });
nixosModules.gatekeeper = { config, pkgs, lib, ... }@args:
import ./module.nix (args // { self = self; system = pkgs.system; });
}; };
} }
+74
View File
@@ -0,0 +1,74 @@
{config, pkgs, lib, self, system, ... }:
let
cfg = config.services.gatekeeper;
in
{
options = {
services.gatekeeper = {
enable = lib.mkEnableOption "Enable the gatekeeper api service.";
envFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
description = "The path to a .env file with all the other options";
};
db = lib.mkOption {
type = lib.types.path;
description = "Where to save the database.";
default = "/var/lib/gatekeeper";
};
mock = lib.mkOption {
type = lib.types.str;
default = "False";
description = "Mock the doorcontroller. Has to be a string!";
};
};
};
config = lib.mkIf cfg.enable {
users.groups.gatekeeper = {};
users.groups.gpio = {};
users.users.gatekeeper = {
description = "gatekeeper user";
group = "gatekeeper";
extraGroups = ["gpio"];
isSystemUser = true;
};
services.udev.extraRules = lib.mkBefore ''
KERNEL=="gpiomem", GROUP="gpio", MODE="0660"
SUBSYSTEM=="gpio", KERNEL=="gpiochip*", ACTION=="add", PROGRAM="${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/chgrp gpio /dev/%k && chmod 660 /dev/%k && ${pkgs.coreutils}/bin/chgrp -R gpio /sys/class/gpio && ${pkgs.coreutils}/bin/chmod -R g=u /sys/class/gpio'"
SUBSYSTEM=="gpio", ACTION=="add", PROGRAM="${pkgs.bash}/bin/bash -c '${pkgs.coreutils}/bin/chgrp -R gpio /sys%p && ${pkgs.coreutils}/bin/chmod -R g=u /sys%p'"
'';
boot.kernelParams = [
"iomem=relaxed" # for pigpiod
"strict-devmem=0"
];
services.pcscd = {
enable = true;
plugins = [ pkgs.acsccid ];
};
networking.firewall.allowedTCPPorts = [ 8000 ];
systemd.services.gatekeeper = {
description = "Runs the gatekeeper api";
script = ''
export LD_LIBRARY_PATH="${lib.getLib pkgs.pcsclite}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
exec ${self.packages.${system}.default}/bin/uvicorn --host 0.0.0.0 --port 8000 --app-dir ${self} app.main:app
'';
after = [ "network.target" ];
wantedBy = ["multi-user.target"];
serviceConfig = {
User = "gatekeeper";
Restart = "on-failure";
RestartSec = "20";
StateDirectory = "gatekeeper";
WorkingDirectory = "/var/lib/gatekeeper";
EnvironmentFile = cfg.envFile;
};
environment = {
SQLALCHEMY_DATABASE_URL = "sqlite:///${cfg.db}/gatekeeper.db";
ALEMBIC_CONFIG = "${self}/alembic.ini";
MOCK = cfg.mock;
};
};
};
}
+46 -2
View File
@@ -11,11 +11,20 @@ dependencies = [
"python-desfire", "python-desfire",
"pyjwt[crypto]>=2.12.1", "pyjwt[crypto]>=2.12.1",
"pwdlib[argon2]>=0.3.0", "pwdlib[argon2]>=0.3.0",
"pytest>=9.0.3",
"requests>=2.33.1", "requests>=2.33.1",
"pytest-cov>=7.1.0",
"setuptools>=82.0.1", "setuptools>=82.0.1",
"pyscard>=2.3.1", "pyscard>=2.3.1",
"alembic>=1.18.5",
"pydantic-settings>=2.13.1",
"paho-mqtt>=2.1.0",
"caldav>=3.7.0",
]
[dependency-groups]
dev = [
"ruff>=0.16.0",
"pytest>=9.0.3",
"pytest-cov>=7.1.0",
"jedi-language-server>=0.47.0",
] ]
[tool.uv.sources] [tool.uv.sources]
@@ -24,3 +33,38 @@ python-desfire = { git = "https://github.com/waza-ari/python-desfire" }
[tool.uv.extra-build-dependencies] [tool.uv.extra-build-dependencies]
python-desfire = ["poetry"] python-desfire = ["poetry"]
"pyscard" = ["setuptools"] "pyscard" = ["setuptools"]
"lgpio" = ["setuptools"]
[tool.setuptools]
py-modules = ["app"]
[tool.ruff]
exclude = ["alembic"]
[tool.ruff.lint]
select = [
# pycodestyle
"E",
# Pyflakes
"F",
# pyupgrade
"UP",
# flake8-bugbear
"B",
# flake8-simplify
"SIM",
# isort
"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
-164
View File
@@ -1,164 +0,0 @@
"""
This is a more involved example that performs initial configuration (often called personalization) of a DESFire card.
It performs the following steps:
1. Authenticate with the default DES key
3. Change the default key
2. Create an application
4. Change the application master key
6. Create a read and write key (diversified)
7. Create an encrypted file
8. Write the UID to the encrypted file
"""
import logging
import os
from smartcard.CardRequest import CardRequest
from smartcard.CardType import AnyCardType
from smartcard.Exceptions import CardRequestTimeoutException
from desfire import DESFire, DESFireKey, PCSCDevice, diversify_key, get_list, to_hex_string
from desfire.enums import DESFireCommunicationMode, DESFireFileType, DESFireKeySettings, DESFireKeyType
from desfire.schemas import FilePermissions, FileSettings, KeySettings
from dotenv import load_dotenv
# Please make sure to yet your own keys here before running this script
load_dotenv()
MIFARE_APP_MASTER_KEY = os.getenv('MIFARE_APP_MASTER_KEY')
MIFARE_ACL_READ_BASE_KEY = os.getenv('MIFARE_ACL_READ_BASE_KEY')
MIFARE_ACL_WRITE_BASE_KEY = os.getenv('MIFARE_ACL_WRITE_BASE_KEY')
# Constants
MIFARE_APP_ID = "DEAFFE" # 7 bytes
MIFARE_ACL_READ_BASE_KEY_ID = 0x1
MIFARE_ACL_WRITE_BASE_KEY_ID = 0x2
MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything
MIFARE_ENCRYPTED_FILE_ID = 0x1
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
cardtype = AnyCardType()
cardrequest = CardRequest(timeout=30, cardType=cardtype)
print("Please present DESfire tag...")
try:
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
print("No tag detected within the timeout.")
raise
cardservice.connection.connect()
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
# Create Key objects
AES_NULL_KEY_DATA = "00" * 16
aes_keysettings = KeySettings(
key_type=DESFireKeyType.DF_KEY_AES,
)
aes_null_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
# Authenticate with default DES key
print("Authenticating with default DES key...")
key_settings = desfire.get_key_setting()
mk = DESFireKey(key_settings, "00" * 8)
desfire.authenticate(0x0, mk)
# Get real UID
print("Getting real UID...")
uid = desfire.get_real_uid()
print(" - UID: ", to_hex_string(uid))
# Set default key
print("Setting default key...")
desfire.change_default_key(aes_null_key, 0x0)
# Create application
print("Creating application...")
app_settings = KeySettings(
settings=[
DESFireKeySettings.KS_ALLOW_CHANGE_MK,
DESFireKeySettings.KS_LISTING_WITHOUT_MK,
DESFireKeySettings.KS_CREATE_DELETE_WITHOUT_MK,
DESFireKeySettings.KS_CONFIGURATION_CHANGEABLE,
],
key_type=DESFireKeyType.DF_KEY_AES,
)
desfire.create_application(MIFARE_APP_ID, app_settings, 4)
# Verify application creation
applications = desfire.get_application_ids()
assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID)
print(" - Application created successfully.")
# Select application
print("Selecting application...")
desfire.select_application(MIFARE_APP_ID)
# Authenticate with AES key, as this has been set as the default key
print("Authenticating with AES key...")
# Create a new one as key data would be overriden by session data
aes_null_auth_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
desfire.authenticate(0x0, aes_null_auth_key)
# Change Application master key
print("Changing application master key (AMK)...")
aes_app_mk = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desfire.change_key(0x0, aes_null_key, aes_app_mk, 0x1)
# Re-Authenticate with new AES key
print("Re-authenticating with new AES key...")
desfire.authenticate(0x0, aes_app_mk)
# Change file read and write keys (diversified)
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
write_div_key_bytes = diversify_key(get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False)
print("Changing file read key...")
aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.change_key(MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1)
print("Changing file write key...")
aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes)
desfire.change_key(MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1)
print("Create encrypted file containing UID...")
file_settings = FileSettings(
file_size=8,
encryption=DESFireCommunicationMode.ENCRYPTED,
permissions=FilePermissions(
read_key=MIFARE_ACL_READ_BASE_KEY_ID,
write_key=MIFARE_ACL_WRITE_BASE_KEY_ID,
),
file_type=DESFireFileType.MDFT_STANDARD_DATA_FILE,
)
desfire.create_standard_file(MIFARE_ENCRYPTED_FILE_ID, file_settings)
print("Read and verify file settings again...")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
assert file_data.file_size == 8
assert file_data.encryption == DESFireCommunicationMode.ENCRYPTED
assert file_data.permissions is not None
assert file_data.permissions.read_access == MIFARE_ACL_READ_BASE_KEY_ID
assert file_data.permissions.write_access == MIFARE_ACL_WRITE_BASE_KEY_ID
assert file_data.file_type == DESFireFileType.MDFT_STANDARD_DATA_FILE
print(" - File created successfully.")
print("Writing UID to encrypted file...")
data = [0x0] + uid
assert len(data) == 8
desfire.write_file_data(MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(data))
print("Reading from encrypted file...")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
assert rdata == data
print(" - Data written successfully.")
print("Personalization finished.")
+153 -35
View File
@@ -1,22 +1,144 @@
import os
from datetime import datetime, timedelta
from time import tzset
import pytest import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlmodel import Session, create_engine, SQLModel from sqlalchemy import inspect
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select
from app.main import app # TEST_ENV must cover every Settings field
from app.model.models import UserDB, Card, GroupDB, AccessAuthorizationDB, Timetable, AaGroupLink # test_settings.py fails with the missing name if it does not.
from app.services.database import get_session 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",
}
# Use in-memory SQLite for testing
TEST_SQLALCHEMY_DATABASE_URL = "sqlite://"
engine = create_engine(TEST_SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool) 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
AccessAuthorizationDB,
Card,
GroupDB,
OneShotAccess,
UserDB,
)
from app.services.settings import settings # noqa: E402
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)
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def db_session(): def db_session():
"""Create a fresh database session for each test.""" """Create a fresh database session for each test."""
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
_seed_alembic_version()
_seed_default_admin()
with Session(engine) as session: with Session(engine) as session:
yield session yield session
SQLModel.metadata.drop_all(engine) SQLModel.metadata.drop_all(engine)
@@ -24,27 +146,19 @@ def db_session():
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def client(db_session): def client(db_session):
"""Create a test client with a database session override.""" """Create a test client bound to the test engine.
def override_get_session():
yield db_session
app.dependency_overrides[get_session] = override_get_session No FastAPI dependency override is needed: get_session() resolves its engine
through get_engine(), which already points at the test engine.
"""
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield test_client yield test_client
app.dependency_overrides.clear()
@pytest.fixture @pytest.fixture
def admin_user(db_session): def admin_user(db_session):
"""Create an admin user for testing.""" """Get the admin user the seeded database already contains."""
from app.services.auth import get_password_hash admin = db_session.exec(select(UserDB).where(UserDB.name == "admin")).one()
admin = UserDB(
name="admin",
passwordhash=get_password_hash("admin123"),
is_admin=True
)
db_session.add(admin)
db_session.commit()
db_session.refresh(admin) db_session.refresh(admin)
return admin return admin
@@ -53,10 +167,9 @@ def admin_user(db_session):
def regular_user(db_session): def regular_user(db_session):
"""Create a regular user for testing.""" """Create a regular user for testing."""
from app.services.auth import get_password_hash from app.services.auth import get_password_hash
user = UserDB( user = UserDB(
name="user", name="user", passwordhash=get_password_hash("user123"), is_admin=False
passwordhash=get_password_hash("user123"),
is_admin=False
) )
db_session.add(user) db_session.add(user)
db_session.commit() db_session.commit()
@@ -69,7 +182,7 @@ def auth_headers(client, admin_user):
"""Get authentication headers for admin user.""" """Get authentication headers for admin user."""
response = client.post( response = client.post(
"/api/v1/token", "/api/v1/token",
data={"username": admin_user.name, "password": "admin123"} data={"username": admin_user.name, "password": TEST_ADMIN_PASSWORD},
) )
token = response.json()["access_token"] token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
@@ -79,8 +192,7 @@ def auth_headers(client, admin_user):
def user_auth_headers(client, regular_user): def user_auth_headers(client, regular_user):
"""Get authentication headers for regular user.""" """Get authentication headers for regular user."""
response = client.post( response = client.post(
"/api/v1/token", "/api/v1/token", data={"username": regular_user.name, "password": "user123"}
data={"username": regular_user.name, "password": "user123"}
) )
token = response.json()["access_token"] token = response.json()["access_token"]
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
@@ -99,7 +211,13 @@ def test_group(db_session):
@pytest.fixture @pytest.fixture
def test_card(db_session, test_group): def test_card(db_session, test_group):
"""Create a test card.""" """Create a test card."""
card = Card(key="test-key-123", group_id=test_group.id, enabled=True, name="test_card", card_serial="00:00:00:00:00:00:00") card = Card(
key="test-key-123",
group_id=test_group.id,
enabled=True,
name="test_card",
card_serial="00:00:00:00:00:00:00",
)
db_session.add(card) db_session.add(card)
db_session.commit() db_session.commit()
db_session.refresh(card) db_session.refresh(card)
@@ -107,12 +225,12 @@ def test_card(db_session, test_group):
@pytest.fixture @pytest.fixture
def test_aa(db_session): def test_aa_os(db_session):
"""Create a test access authorization.""" oneshot = OneShotAccess(uses=1, ends_at=datetime.now() + timedelta(days=1))
aa = AccessAuthorizationDB( db_session.add(oneshot)
name="Test AA", aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=True, type="oneshot")
is_active=True aa.oneshot = oneshot
)
db_session.add(aa) db_session.add(aa)
db_session.commit() db_session.commit()
db_session.refresh(aa) db_session.refresh(aa)
+1 -6
View File
@@ -1,12 +1,7 @@
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(): def test_router_includes():
"""Test that all routers are included in the app.""" """Test that all routers are included in the app."""
from app.main import app from app.main import app
routes = [route.path for route in app.routes] routes = [route.path for route in app.routes]
# Check that router prefixes are present # Check that router prefixes are present
+22 -82
View File
@@ -1,95 +1,35 @@
import pytest import pytest
import datetime from pydantic import ValidationError
from app.model.models import (
UserBase, UserResponse, UserCreate, UserDB, UserUpdate, from app.model.models import AccessAuthorizationCreate, UserUpdate
GroupBase, GroupCreate, GroupDB, GroupResponse,
AccessAuthorizationBase, AccessAuthorizationCreate,
AccessAuthorizationDB, AccessAuthorizationResponse, AccessAuthorizationUpdate,
Card, Timetable, TimetableCreate, Token, TokenData, AaGroupLink
)
def test_user_models(): def test_user_update_fields_are_optional():
"""Test user model creation and validation.""" """Partial update models must leave unspecified fields as None."""
# 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") user_update = UserUpdate(name="Updated Name")
assert user_update.name == "Updated Name" assert user_update.name == "Updated Name"
assert user_update.email is None assert user_update.email is None
def test_group_models(): def test_access_authorization_type_is_validated():
"""Test group model creation and validation.""" """AA validation test"""
# Test GroupBase base = {"name": "AA", "is_active": True}
group_base = GroupBase(name="Test Group")
assert group_base.name == "Test Group"
# Test GroupCreate with pytest.raises(ValidationError) as exc_info:
group_create = GroupCreate(name="New Group") AccessAuthorizationCreate(type="timetable", **base)
assert group_create.name == "New Group" assert exc_info.value.errors()[0]["type"] == "literal_error"
# A oneshot type without its oneshot payload is incomplete.
with pytest.raises(ValueError, match="require a oneshot object"):
AccessAuthorizationCreate(type="oneshot", **base)
def test_access_authorization_models(): with pytest.raises(ValueError, match="not jet implemented"):
"""Test access authorization model creation and validation.""" AccessAuthorizationCreate(type="somefuturespec", **base)
# Test AccessAuthorizationBase
aa_base = AccessAuthorizationBase(name="Test AA", is_active=True)
assert aa_base.name == "Test AA"
assert aa_base.is_active is True
# Test AccessAuthorizationCreate with timetables valid = AccessAuthorizationCreate(
timetable_create = TimetableCreate(weekday=1, starttime="08:00", duration=60) type="oneshot",
aa_create = AccessAuthorizationCreate( name="AA",
name="New AA", is_active=True,
is_active=False, oneshot={"uses": 1, "ends_at": "2029-07-27"},
timetables=[timetable_create]
) )
assert aa_create.name == "New AA" assert valid.type == "oneshot"
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
+250 -71
View File
@@ -1,48 +1,104 @@
from datetime import UTC, datetime
import pytest import pytest
from fastapi import status from sqlalchemy import text
from sqlmodel import select
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 test_create_access_auth(client, auth_headers): def _post_oneshot_aa(client, auth_headers, name: str, ends_at: str):
"""Test creating a new access authorization.""" """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,
)
def test_create_access_auth_os(client, auth_headers):
"""Test creating a new access authorization with oneshot type."""
aa_data = { aa_data = {
"name": "New AA", "name": "New os_AA",
"type": "oneshot",
"is_active": True, "is_active": True,
"timetables": [ "oneshot": {"uses": 1, "ends_at": "2029-07-27"},
{"weekday": 1, "starttime": "08:00", "duration": 60},
{"weekday": 2, "starttime": "09:00", "duration": 90}
]
} }
response = client.post("/api/v1/aa/", json=aa_data, headers=auth_headers) response = client.post("/api/v1/aa/", json=aa_data, headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == "New AA" assert data["name"] == "New os_AA"
assert data["type"] == "oneshot"
assert data["is_active"] is True assert data["is_active"] is True
assert "id" in data assert "id" in data
assert len(data["timetables"]) == 2 assert data["oneshot"]["uses"] == 1
def test_get_all_access_auths(client, auth_headers, test_aa): def test_create_wrong_aa_type(client, auth_headers):
"""Test creating a new access authorization with oneshot type."""
aa_data = {
"name": "New AA",
"type": "wrong",
"is_active": True,
}
response = client.post("/api/v1/aa/", json=aa_data, headers=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):
"""Test retrieving all access authorizations.""" """Test retrieving all access authorizations."""
response = client.get("/api/v1/aa/", headers=auth_headers) response = client.get("/api/v1/aa/", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
aa_list = response.json() aa_list = response.json()
assert len(aa_list) >= 1 assert len(aa_list) >= 1
aa_names = [aa["name"] for aa in aa_list] aa_names = [aa["name"] for aa in aa_list]
assert test_aa.name in aa_names assert test_aa_os.name in aa_names
def test_get_access_auth_by_id(client, auth_headers, test_aa): def test_get_access_auth_by_id(client, auth_headers, test_aa_os):
"""Test retrieving a specific access authorization by ID.""" """Test retrieving a specific access authorization by ID."""
response = client.get(f"/api/v1/aa/{test_aa.id}", headers=auth_headers) response = client.get(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["id"] == test_aa.id assert data["id"] == test_aa_os.id
assert data["name"] == test_aa.name assert data["name"] == test_aa_os.name
def test_get_nonexistent_access_auth(client, auth_headers): def test_get_nonexistent_access_auth(client, auth_headers):
@@ -51,11 +107,10 @@ def test_get_nonexistent_access_auth(client, auth_headers):
assert response.status_code == 404 assert response.status_code == 404
def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa): def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa_os):
"""Test assigning an access authorization to a group.""" """Test assigning an access authorization to a group."""
response = client.put( response = client.put(
f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -65,66 +120,68 @@ def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa):
# Note: The response model might not include the full relationship # Note: The response model might not include the full relationship
def test_assign_already_assigned_access_auth(client, auth_headers, test_group, test_aa): def test_assign_already_assigned_access_auth(
client, auth_headers, test_group, test_aa_os
):
"""Test assigning an already assigned access authorization.""" """Test assigning an already assigned access authorization."""
# First assignment # First assignment
client.put(f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers) client.put(
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
)
# Second assignment should indicate it's already assigned # Second assignment should indicate it's already assigned
response = client.put( response = client.put(
f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
headers=auth_headers
) )
# According to the code, this returns 409 with "already assigned" message # According to the code, this returns 409 with "already assigned" message
assert response.status_code == 409 assert response.status_code == 409
assert "already assigned" in response.json()["detail"].lower() assert "already assigned" in response.json()["detail"].lower()
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa): def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa_os):
"""Test unassigning an access authorization from a group.""" """Test unassigning an access authorization from a group."""
# First assign # First assign
client.put(f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers) client.put(
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
)
# Then unassign # Then unassign
response = client.put( response = client.put(
f"/api/v1/aa/unassign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/unassign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa): def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa_os):
"""Test unassigning a non-existent assignment.""" """Test unassigning a non-existent assignment."""
response = client.put( response = client.put(
f"/api/v1/aa/unassign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/unassign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
headers=auth_headers
) )
assert response.status_code == 404 assert response.status_code == 404
def test_assign_to_nonexistent_group(client, auth_headers, test_aa): def test_assign_to_nonexistent_group(client, auth_headers, test_aa_os):
"""Test assigning an AA to a non-existent group.""" """Test assigning an AA to a non-existent group."""
response = client.put(f"/api/v1/aa/assign/99999/{test_aa.id}", headers=auth_headers) response = client.put(
f"/api/v1/aa/assign/99999/{test_aa_os.id}", headers=auth_headers
)
assert response.status_code == 404 assert response.status_code == 404
def test_assign_nonexistent_aa(client, auth_headers, test_group): def test_assign_nonexistent_aa(client, auth_headers, test_group):
"""Test assigning a non-existent AA to a group.""" """Test assigning a non-existent AA to a group."""
response = client.put(f"/api/v1/aa/assign/{test_group.id}/99999", headers=auth_headers) response = client.put(
f"/api/v1/aa/assign/{test_group.id}/99999", headers=auth_headers
)
assert response.status_code == 404 assert response.status_code == 404
def test_update_access_auth(client, auth_headers, test_aa): def test_update_access_auth(client, auth_headers, test_aa_os):
"""Test updating an access authorization.""" """Test updating an access authorization."""
update_data = { update_data = {"name": "Updated AA", "is_active": False}
"name": "Updated AA",
"is_active": False
}
response = client.patch( response = client.patch(
f"/api/v1/aa/{test_aa.id}", f"/api/v1/aa/{test_aa_os.id}", json=update_data, headers=auth_headers
json=update_data,
headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -133,27 +190,6 @@ def test_update_access_auth(client, auth_headers, test_aa):
assert data["is_active"] is False assert data["is_active"] is False
def test_update_access_auth_with_timetables(client, auth_headers, test_aa):
"""Test updating an access authorization with new timetables."""
update_data = {
"timetables": [
{"weekday": 5, "starttime": "10:00", "duration": 120}
]
}
response = client.patch(
f"/api/v1/aa/{test_aa.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): def test_update_nonexistent_access_auth(client, auth_headers):
"""Test updating a non-existent access authorization.""" """Test updating a non-existent access authorization."""
update_data = {"name": "Updated"} update_data = {"name": "Updated"}
@@ -161,14 +197,14 @@ def test_update_nonexistent_access_auth(client, auth_headers):
assert response.status_code == 404 assert response.status_code == 404
def test_delete_access_auth(client, auth_headers, test_aa): def test_delete_access_auth(client, auth_headers, test_aa_os):
"""Test deleting an access authorization.""" """Test deleting an access authorization."""
response = client.delete(f"/api/v1/aa/{test_aa.id}", headers=auth_headers) response = client.delete(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower() assert "deleted successfully" in response.json()["message"].lower()
# Verify AA is deleted # Verify AA is deleted
response = client.get(f"/api/v1/aa/{test_aa.id}", headers=auth_headers) response = client.get(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
@@ -178,13 +214,13 @@ def test_delete_nonexistent_access_auth(client, auth_headers):
assert response.status_code == 404 assert response.status_code == 404
def test_aa_operations_by_non_admin(client, test_aa, user_auth_headers): def test_aa_os_operations_by_non_admin(client, test_aa_os, user_auth_headers):
"""Test that non-admin users cannot perform AA operations.""" """Test that non-admin users cannot perform AA operations."""
# Try to create an AA # Try to create an AA
response = client.post( response = client.post(
"/api/v1/aa/", "/api/v1/aa/",
json={"name": "test", "is_active": True, "timetables": []}, json={"name": "test", "is_active": True},
headers=user_auth_headers headers=user_auth_headers,
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -193,5 +229,148 @@ def test_aa_operations_by_non_admin(client, test_aa, user_auth_headers):
assert response.status_code == 403 assert response.status_code == 403
# Try to assign AA # Try to assign AA
response = client.put(f"/api/v1/aa/assign/1/{test_aa.id}", headers=user_auth_headers) response = client.put(
f"/api/v1/aa/assign/1/{test_aa_os.id}", headers=user_auth_headers
)
assert response.status_code == 403 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
)
+22 -15
View File
@@ -1,12 +1,19 @@
from datetime import timedelta
import pytest import pytest
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.services.auth import (
verify_password, get_password_hash, get_user, authenticate_user,
create_access_token, get_current_user, auth_is_admin, create_first_user
)
from app.model.models import UserDB from app.model.models import UserDB
from jwt.exceptions import InvalidTokenError from app.services.auth import (
auth_is_admin,
authenticate_user,
create_access_token,
create_first_user,
get_current_user,
get_password_hash,
get_user,
verify_password,
)
def test_password_hashing(): def test_password_hashing():
@@ -84,7 +91,7 @@ def test_create_access_token():
def test_get_current_user(db_session, admin_user): def test_get_current_user(db_session, admin_user):
"""Test getting current user from token.""" """Test getting current user from token."""
from app.services.auth import create_access_token, get_current_user from app.services.auth import create_access_token
# Create token for admin user # Create token for admin user
token = create_access_token(data={"sub": admin_user.name}) token = create_access_token(data={"sub": admin_user.name})
@@ -102,7 +109,9 @@ def test_get_current_user(db_session, admin_user):
# Test expired token (create token with past expiration) # Test expired token (create token with past expiration)
past_expire = timedelta(minutes=-100) past_expire = timedelta(minutes=-100)
expired_token = create_access_token(data={"sub": admin_user.name}, expires_delta=past_expire) expired_token = create_access_token(
data={"sub": admin_user.name}, expires_delta=past_expire
)
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
get_current_user(token=expired_token) get_current_user(token=expired_token)
@@ -111,7 +120,7 @@ def test_get_current_user(db_session, admin_user):
def test_auth_is_admin(db_session, admin_user, regular_user): def test_auth_is_admin(db_session, admin_user, regular_user):
"""Test admin authorization check.""" """Test admin authorization check."""
from app.services.auth import create_access_token, auth_is_admin from app.services.auth import create_access_token
# Create token for admin user # Create token for admin user
admin_token = create_access_token(data={"sub": admin_user.name}) admin_token = create_access_token(data={"sub": admin_user.name})
@@ -133,6 +142,7 @@ def test_create_first_user(db_session):
"""Test automatic creation of first admin user.""" """Test automatic creation of first admin user."""
# Clear any existing users # Clear any existing users
from sqlmodel import select from sqlmodel import select
db_session.exec(select(UserDB)).all() db_session.exec(select(UserDB)).all()
for user in db_session.exec(select(UserDB)).all(): for user in db_session.exec(select(UserDB)).all():
db_session.delete(user) db_session.delete(user)
@@ -158,8 +168,7 @@ def test_token_endpoint(client, admin_user):
"""Test the token endpoint for login.""" """Test the token endpoint for login."""
# Test successful login # Test successful login
response = client.post( response = client.post(
"/api/v1/token", "/api/v1/token", data={"username": admin_user.name, "password": "admin123"}
data={"username": admin_user.name, "password": "admin123"}
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -168,15 +177,13 @@ def test_token_endpoint(client, admin_user):
# Test failed login with wrong password # Test failed login with wrong password
response = client.post( response = client.post(
"/api/v1/token", "/api/v1/token", data={"username": admin_user.name, "password": "wrongpassword"}
data={"username": admin_user.name, "password": "wrongpassword"}
) )
assert response.status_code == 401 assert response.status_code == 401
# Test failed login with non-existent user # Test failed login with non-existent user
response = client.post( response = client.post(
"/api/v1/token", "/api/v1/token", data={"username": "nonexistent", "password": "password"}
data={"username": "nonexistent", "password": "password"}
) )
assert response.status_code == 401 assert response.status_code == 401
+55
View File
@@ -0,0 +1,55 @@
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)
+11 -24
View File
@@ -1,6 +1,3 @@
import pytest
from fastapi import status
def test_get_cards_for_group(client, auth_headers, test_group, test_card): def test_get_cards_for_group(client, auth_headers, test_group, test_card):
"""Test getting all cards for a group.""" """Test getting all cards for a group."""
response = client.get(f"/api/v1/cards/{test_group.id}", headers=auth_headers) response = client.get(f"/api/v1/cards/{test_group.id}", headers=auth_headers)
@@ -23,53 +20,43 @@ def test_get_cards_for_nonexistent_group(client, auth_headers):
def test_card_operations_by_non_admin(client, test_group, user_auth_headers): def test_card_operations_by_non_admin(client, test_group, user_auth_headers):
"""Test that non-admin users cannot perform card operations.""" """Test that non-admin users cannot perform card operations."""
# Try to add a card # Try to add a card
response = client.post(f"/api/v1/cards/", headers=user_auth_headers) response = client.post("/api/v1/cards/", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403
# Try to get cards # Try to get cards
response = client.get(f"/api/v1/cards/{test_group.id}", headers=user_auth_headers) response = client.get(f"/api/v1/cards/{test_group.id}", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403
def test_update_card(client, auth_headers, test_group, test_card): def test_update_card(client, auth_headers, test_group, test_card):
"""Test Patching a card entity""" """Test Patching a card entity"""
update_data = { update_data = {"name": "changed_name", "enabled": "False"}
"name": "changed_name",
"enabled": "False"
}
response = client.patch( response = client.patch(
f"/api/v1/cards/{test_card.id}", f"/api/v1/cards/{test_card.id}", json=update_data, headers=auth_headers
json=update_data,
headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == "changed_name" assert data["name"] == "changed_name"
assert data["enabled"] == False assert data["enabled"] == False # noqa: E712
assert data["group_id"] == test_card.group_id assert data["group_id"] == test_card.group_id
def test_update_wrong_card(client, auth_headers, test_group, test_card): def test_update_wrong_card(client, auth_headers, test_group, test_card):
"""Test Patching a card entity""" """Test Patching a card entity"""
response = client.patch( response = client.patch("/api/v1/cards/9999", json={}, headers=auth_headers)
f"/api/v1/cards/9999",
json={},
headers=auth_headers
)
assert response.status_code == 404 assert response.status_code == 404
assert "Card not found" in response.json()["detail"] assert "Card not found" in response.json()["detail"]
def test_update_card_with_wrong_group(client, auth_headers, test_group, test_card): def test_update_card_with_wrong_group(client, auth_headers, test_group, test_card):
"""Test Patching a card entity with wrong group""" """Test Patching a card entity with wrong group"""
update_data = { update_data = {"group_id": "9999"}
"group_id": "9999"
}
response = client.patch( response = client.patch(
f"/api/v1/cards/{test_card.id}", f"/api/v1/cards/{test_card.id}", json=update_data, headers=auth_headers
json=update_data,
headers=auth_headers
) )
assert response.status_code == 404 assert response.status_code == 404
assert "GroupID not found" in response.json()["detail"] assert "GroupID not found" in response.json()["detail"]
+38 -33
View File
@@ -1,41 +1,46 @@
import pytest from alembic.config import Config
from sqlmodel import Session, select from alembic.script import ScriptDirectory
from app.services.database import create_db_and_tables, get_session, add_and_refresh from sqlalchemy import create_engine, text
from app.model.models import UserDB, GroupDB, Card 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
def test_create_db_and_tables(): def test_configure_engine_and_reset(tmp_path, db_session):
"""Test database and tables creation.""" """configure_engine() is the single injection point for the whole app.
# This is primarily an integration test reset_engine() drops the engine again and rebuilds from settings
from sqlalchemy import inspect conftest's autouse _installed_engine fixture puts the shared test engine back.
from app.services.database import engine """
from app.services import database as database_service
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
# 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() create_db_and_tables()
inspector = inspect(engine) 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]
# Check that tables exist reset_engine()
tables = inspector.get_table_names() rebuilt = get_engine()
assert "userdb" in tables assert rebuilt is not injected
assert "groupdb" in tables assert str(rebuilt.url) == str(database_service.settings.sqlalchemy_database_url)
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): def test_add_and_refresh(db_session):
+166 -31
View File
@@ -1,61 +1,196 @@
import pytest
import datetime import datetime
from app.services.door import checkAccess
from app.model.models import Card, GroupDB, AccessAuthorizationDB, Timetable
def test_check_access_with_valid_timetable(db_session): import pytest
from app.model.models import (
AccessAuthorizationDB,
Card,
GroupDB,
OneShotAccess,
)
from app.services.door import checkAccess
def test_check_access_with_valid_oneshot(db_session):
# Setup: create card with valid access # Setup: create card with valid access
group = GroupDB(name="Test Group") group = GroupDB(name="Test Group")
db_session.add(group) db_session.add(group)
db_session.commit() 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") 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) db_session.add(card)
timetable = Timetable( oneshot = OneShotAccess(
weekday=datetime.datetime.weekday(datetime.date.today()), uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
starttime=datetime.datetime.now().time(),
duration=120 # 2 hours
) )
db_session.add(timetable) db_session.add(oneshot)
aa = AccessAuthorizationDB(name="Test AA", is_active=True) aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="oneshot")
db_session.add(aa) db_session.add(aa)
aa.timetables = [timetable] aa.oneshot = oneshot
group.accessauths = [aa] group.accessauths = [aa]
db_session.commit() db_session.commit()
# Test: access should be granted within time window # Test: access should be granted within time window
result = checkAccess("test-key-123", db_session) assert checkAccess("test-key-123", db_session)
assert result == True assert aa.oneshot.uses == 0
def test_check_access_outside_hours(db_session):
# Test when current time is outside valid hours def test_check_access_invalid_card(db_session):
# Should raise exception for non-existent card
with pytest.raises(Exception, match="No Access"):
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") group = GroupDB(name="Test Group")
db_session.add(group) db_session.add(group)
db_session.commit() 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") 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) db_session.add(card)
timetable = Timetable( oneshot = OneShotAccess(
weekday=datetime.datetime.weekday(datetime.date.today()), uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
starttime=datetime.time(1, 0),
duration=1 # 2 hours
) )
db_session.add(timetable) db_session.add(oneshot)
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=False, type="oneshot")
aa = AccessAuthorizationDB(name="Test AA", is_active=True)
db_session.add(aa) db_session.add(aa)
aa.timetables = [timetable] aa.oneshot = oneshot
group.accessauths = [aa] group.accessauths = [aa]
db_session.commit() db_session.commit()
result = checkAccess("test-key-123", db_session)
assert result == False
def test_check_access_invalid_card(db_session): assert not checkAccess("test-key-123", db_session)
# Should raise exception for non-existent card
with pytest.raises(Exception):
checkAccess("non-existent-key", db_session) def test_check_access_with_inactive_card(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=False,
name="test_card",
card_serial="00:00:00:00:00:00:00",
)
db_session.add(card)
oneshot = OneShotAccess(
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
)
db_session.add(oneshot)
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=True, type="oneshot")
db_session.add(aa)
aa.oneshot = oneshot
group.accessauths = [aa]
db_session.commit()
assert not checkAccess("test-key-123", db_session)
def test_regression_check_access_issue_28(db_session):
# Having a active aa after a inactive/invalid one denies 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)
# Creating oneshot with inactive AA
oneshot1 = OneShotAccess(
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
)
db_session.add(oneshot1)
aa1 = AccessAuthorizationDB(name="First AA", is_active=False, type="oneshot")
db_session.add(aa1)
aa1.oneshot = oneshot1
# Creating oneshot with active AA
oneshot2 = OneShotAccess(
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
)
db_session.add(oneshot2)
aa2 = AccessAuthorizationDB(name="Second AA", is_active=True, type="oneshot")
db_session.add(aa2)
aa2.oneshot = oneshot2
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)
+1 -7
View File
@@ -1,7 +1,3 @@
import pytest
from fastapi import status
def test_create_group(client, auth_headers): def test_create_group(client, auth_headers):
"""Test creating a new group.""" """Test creating a new group."""
group_data = {"name": "New Test Group"} group_data = {"name": "New Test Group"}
@@ -57,9 +53,7 @@ def test_group_operations_by_non_admin(client, user_auth_headers):
"""Test that non-admin users cannot perform group operations.""" """Test that non-admin users cannot perform group operations."""
# Try to create a group # Try to create a group
response = client.post( response = client.post(
"/api/v1/groups/", "/api/v1/groups/", json={"name": "test"}, headers=user_auth_headers
json={"name": "test"},
headers=user_auth_headers
) )
assert response.status_code == 403 assert response.status_code == 403
+93
View File
@@ -0,0 +1,93 @@
"""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
+15 -24
View File
@@ -1,14 +1,10 @@
import pytest
from fastapi import status
def test_create_user(client, auth_headers): def test_create_user(client, auth_headers):
"""Test creating a new user.""" """Test creating a new user."""
user_data = { user_data = {
"name": "newuser", "name": "newuser",
"email": "newuser@example.com", "email": "newuser@example.com",
"is_admin": False, "is_admin": False,
"password": "newpassword123" "password": "newpassword123",
} }
response = client.post("/api/v1/users/", json=user_data, headers=auth_headers) response = client.post("/api/v1/users/", json=user_data, headers=auth_headers)
@@ -27,7 +23,7 @@ def test_create_user_unauthorized(client):
user_data = { user_data = {
"name": "unauthorized_user", "name": "unauthorized_user",
"email": "unauthorized@example.com", "email": "unauthorized@example.com",
"password": "password123" "password": "password123",
} }
response = client.post("/api/v1/users/", json=user_data) response = client.post("/api/v1/users/", json=user_data)
@@ -56,6 +52,7 @@ def test_get_user_by_id(client, auth_headers, regular_user):
assert data["id"] == regular_user.id assert data["id"] == regular_user.id
assert data["name"] == regular_user.name assert data["name"] == regular_user.name
def test_get_current_user(client, auth_headers, admin_user): def test_get_current_user(client, auth_headers, admin_user):
"""Test getting the special url current""" """Test getting the special url current"""
response = client.get("/api/v1/users/current", headers=auth_headers) response = client.get("/api/v1/users/current", headers=auth_headers)
@@ -66,6 +63,7 @@ def test_get_current_user(client, auth_headers, admin_user):
assert data["name"] == admin_user.name assert data["name"] == admin_user.name
assert data["is_admin"] == admin_user.is_admin assert data["is_admin"] == admin_user.is_admin
def test_get_nonexistent_user(client, auth_headers): def test_get_nonexistent_user(client, auth_headers):
"""Test retrieving a non-existent user.""" """Test retrieving a non-existent user."""
response = client.get("/api/v1/users/99999", headers=auth_headers) response = client.get("/api/v1/users/99999", headers=auth_headers)
@@ -75,15 +73,10 @@ def test_get_nonexistent_user(client, auth_headers):
def test_update_user(client, auth_headers, regular_user): def test_update_user(client, auth_headers, regular_user):
"""Test updating a user.""" """Test updating a user."""
update_data = { update_data = {"name": "updated_name", "email": "updated@example.com"}
"name": "updated_name",
"email": "updated@example.com"
}
response = client.patch( response = client.patch(
f"/api/v1/users/{regular_user.id}", f"/api/v1/users/{regular_user.id}", json=update_data, headers=auth_headers
json=update_data,
headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -96,21 +89,17 @@ def test_update_user(client, auth_headers, regular_user):
def test_update_user_password(client, auth_headers, regular_user): def test_update_user_password(client, auth_headers, regular_user):
"""Test updating a user's password.""" """Test updating a user's password."""
update_data = { update_data = {"password": "new_password_456"}
"password": "new_password_456"
}
response = client.patch( response = client.patch(
f"/api/v1/users/{regular_user.id}", f"/api/v1/users/{regular_user.id}", json=update_data, headers=auth_headers
json=update_data,
headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
# Verify password can be used for login # Verify password can be used for login
login_response = client.post( login_response = client.post(
"/api/v1/token", "/api/v1/token",
data={"username": regular_user.name, "password": "new_password_456"} data={"username": regular_user.name, "password": "new_password_456"},
) )
assert login_response.status_code == 200 assert login_response.status_code == 200
@@ -118,7 +107,9 @@ def test_update_user_password(client, auth_headers, regular_user):
def test_update_nonexistent_user(client, auth_headers): def test_update_nonexistent_user(client, auth_headers):
"""Test updating a non-existent user.""" """Test updating a non-existent user."""
update_data = {"name": "updated"} update_data = {"name": "updated"}
response = client.patch("/api/v1/users/99999", json=update_data, headers=auth_headers) response = client.patch(
"/api/v1/users/99999", json=update_data, headers=auth_headers
)
assert response.status_code == 404 assert response.status_code == 404
@@ -143,9 +134,9 @@ def test_user_operations_by_non_admin(client, user_auth_headers):
"""Test that non-admin users cannot perform admin operations.""" """Test that non-admin users cannot perform admin operations."""
# Try to create a user # Try to create a user
response = client.post( response = client.post(
"/api/v1/users/", "/api/v1/users/",
json={"name": "test", "password": "pass"}, json={"name": "test", "password": "pass"},
headers=user_auth_headers headers=user_auth_headers,
) )
assert response.status_code == 403 assert response.status_code == 403
Generated
+595 -4
View File
@@ -6,6 +6,20 @@ resolution-markers = [
"python_full_version < '3.14'", "python_full_version < '3.14'",
] ]
[[package]]
name = "alembic"
version = "1.18.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" },
]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.4" version = "0.0.4"
@@ -79,6 +93,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
] ]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]] [[package]]
name = "build" name = "build"
version = "1.4.3" version = "1.4.3"
@@ -111,6 +134,51 @@ filecache = [
{ name = "filelock" }, { 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
@@ -405,6 +473,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
] ]
[[package]]
name = "docstring-to-markdown"
version = "0.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/d8/8abe80d62c5dce1075578031bcfde07e735bcf0afe2886dd48b470162ab4/docstring_to_markdown-0.17.tar.gz", hash = "sha256:df72a112294c7492487c9da2451cae0faeee06e86008245c188c5761c9590ca3", size = 32260, upload-time = "2025-05-02T15:09:07.932Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/7b/af3d0da15bed3a8665419bb3a630585756920f4ad67abfdfef26240ebcc0/docstring_to_markdown-0.17-py3-none-any.whl", hash = "sha256:fd7d5094aa83943bf5f9e1a13701866b7c452eac19765380dead666e36d3711c", size = 23479, upload-time = "2025-05-02T15:09:06.676Z" },
]
[[package]] [[package]]
name = "dulwich" name = "dulwich"
version = "1.1.0" version = "1.1.0"
@@ -608,34 +689,54 @@ name = "gatekeeper"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" },
{ name = "caldav" },
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "paho-mqtt" },
{ name = "poetry" }, { name = "poetry" },
{ name = "pwdlib", extra = ["argon2"] }, { name = "pwdlib", extra = ["argon2"] },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "pyscard" }, { name = "pyscard" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "python-desfire" }, { name = "python-desfire" },
{ name = "requests" }, { name = "requests" },
{ name = "setuptools" }, { name = "setuptools" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
] ]
[package.dev-dependencies]
dev = [
{ name = "jedi-language-server" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.5" },
{ name = "caldav", specifier = ">=3.7.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "poetry", specifier = ">=2.3.4" }, { name = "poetry", specifier = ">=2.3.4" },
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
{ name = "pydantic-settings", specifier = ">=2.13.1" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
{ name = "pyscard", specifier = ">=2.3.1" }, { name = "pyscard", specifier = ">=2.3.1" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "python-desfire", git = "https://github.com/waza-ari/python-desfire" }, { name = "python-desfire", git = "https://github.com/waza-ari/python-desfire" },
{ name = "requests", specifier = ">=2.33.1" }, { name = "requests", specifier = ">=2.33.1" },
{ name = "setuptools", specifier = ">=82.0.1" }, { name = "setuptools", specifier = ">=82.0.1" },
{ name = "sqlmodel", specifier = ">=0.0.38" }, { name = "sqlmodel", specifier = ">=0.0.38" },
] ]
[package.metadata.requires-dev]
dev = [
{ name = "jedi-language-server", specifier = ">=0.47.0" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.16.0" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.4.0" version = "3.4.0"
@@ -726,6 +827,32 @@ 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -735,6 +862,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -786,6 +925,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
] ]
[[package]]
name = "jedi"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parso" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
]
[[package]]
name = "jedi-language-server"
version = "0.47.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cattrs" },
{ name = "docstring-to-markdown" },
{ name = "jedi" },
{ name = "lsprotocol" },
{ name = "pygls" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/00/0cc9fc9996f79f1935a39236f300e6b2399aed34b8170248c5108e38e652/jedi_language_server-0.47.0.tar.gz", hash = "sha256:43d9b89d07b504470544b80d76739449856a2be6565571cb362ee0681f6f0b86", size = 91714, upload-time = "2026-05-31T14:02:43.312Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/e1/ad4afa632b4ea58f422391d11e86b5100b5578879af6bf04bf5aaf4beede/jedi_language_server-0.47.0-py3-none-any.whl", hash = "sha256:0e00d5af41c1a966c9ec73221e81b1af3b8c0243d29477e5441c661d5b61ec3e", size = 33844, upload-time = "2026-05-31T14:02:42.161Z" },
]
[[package]] [[package]]
name = "jeepney" name = "jeepney"
version = "0.9.0" version = "0.9.0"
@@ -795,6 +962,75 @@ 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" }, { 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]] [[package]]
name = "jinja2" name = "jinja2"
version = "3.1.6" version = "3.1.6"
@@ -824,6 +1060,123 @@ 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" }, { 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 = "lsprotocol"
version = "2025.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cattrs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" }
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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.0.0"
@@ -941,6 +1294,20 @@ 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "26.1" version = "26.1"
@@ -950,6 +1317,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
] ]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]]
name = "parso"
version = "0.8.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
]
[[package]] [[package]]
name = "pbs-installer" name = "pbs-installer"
version = "2026.4.7" version = "2026.4.7"
@@ -1191,6 +1576,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
] ]
[[package]]
name = "pygls"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cattrs" },
{ name = "lsprotocol" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/2e/7bbe061d175c0baddde8fc9edb908a4c31ba5d9165b8c68e3439c3a9f138/pygls-2.1.1.tar.gz", hash = "sha256:1da03ba9053201bb337dcdd8d121df70feb2a91e1a0dcc74de5da79755b1a201", size = 55091, upload-time = "2026-03-25T11:19:10.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/1a/208293b6c350f5abea6941d5606080d4a492644052504f5312e5de30a902/pygls-2.1.1-py3-none-any.whl", hash = "sha256:510a6dea2476177230c7d851125e5948efdf3fdb9ebfd8543fc434972f8faed4", size = 68975, upload-time = "2026-03-25T11:19:11.374Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.20.0" version = "2.20.0"
@@ -1266,6 +1665,18 @@ 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" }, { 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]] [[package]]
name = "python-desfire" name = "python-desfire"
version = "0.1.6" version = "0.1.6"
@@ -1350,6 +1761,82 @@ 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" }, { 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]] [[package]]
name = "rapidfuzz" name = "rapidfuzz"
version = "3.14.5" version = "3.14.5"
@@ -1402,6 +1889,21 @@ 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" }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.33.1" version = "2.33.1"
@@ -1509,6 +2011,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
] ]
[[package]]
name = "ruff"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[package]] [[package]]
name = "secretstorage" name = "secretstorage"
version = "3.5.0" version = "3.5.0"
@@ -1553,6 +2080,15 @@ 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" }, { 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]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.49" version = "2.0.49"
@@ -1672,6 +2208,15 @@ 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" }, { 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]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"
@@ -1681,6 +2226,20 @@ 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" }, { 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.44.0" version = "0.44.0"
@@ -1746,6 +2305,15 @@ 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" }, { 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]] [[package]]
name = "watchfiles" name = "watchfiles"
version = "1.1.1" version = "1.1.1"
@@ -1839,6 +2407,20 @@ 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" }, { 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]] [[package]]
name = "xattr" name = "xattr"
version = "1.3.0" version = "1.3.0"
@@ -1859,6 +2441,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/19/4b4e3e2ea5fa213ff4220e84450628fecde042b0961e7b4e6d845e555ade/xattr-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1e6c216927b16fd4b72df655d5124b69b2a406cb3132b5231179021182f0f0d1", size = 19023, upload-time = "2025-10-13T22:16:34.395Z" }, { url = "https://files.pythonhosted.org/packages/fd/19/4b4e3e2ea5fa213ff4220e84450628fecde042b0961e7b4e6d845e555ade/xattr-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1e6c216927b16fd4b72df655d5124b69b2a406cb3132b5231179021182f0f0d1", size = 19023, upload-time = "2025-10-13T22:16:34.395Z" },
] ]
[[package]]
name = "zipp"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" },
]
[[package]] [[package]]
name = "zstandard" name = "zstandard"
version = "0.25.0" version = "0.25.0"