Compare commits
56
Commits
package
...
wunschliste_1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee91b03198 | ||
|
|
2a44e3a1a6 | ||
|
|
f0bd1e68e8 | ||
|
|
8ca4da2e1a
|
||
|
|
d93491f466 | ||
|
|
1d37be5317
|
||
|
|
9810a4f810
|
||
|
|
db627c34dc
|
||
|
|
63ea103793
|
||
|
|
db87625384
|
||
|
|
ff170af935 | ||
|
|
af2f254937 | ||
|
|
b31413931b
|
||
|
|
a8edc862bc | ||
|
|
ba3e471bb9 | ||
|
|
f7a157593b | ||
|
|
6666f47f1f | ||
|
|
7e39fa2c1e | ||
|
|
e07013b7a4 | ||
|
|
1cfe331133 | ||
|
|
25ecbbf375 | ||
|
|
c5934d05c6
|
||
|
|
0bcc83871d
|
||
|
|
4b797997d4
|
||
|
|
f635149d1f
|
||
|
|
bbb41e41ab | ||
|
|
0497d1bbcf | ||
|
|
c5c428c621 | ||
|
|
023a704e36
|
||
|
|
73ff546c70
|
||
|
|
55072bcc1d
|
||
|
|
c9f740b7a3
|
||
|
|
936b786659
|
||
|
|
158b430235
|
||
|
|
bad4e8dd1b
|
||
|
|
b7b07b82da
|
||
|
|
717ce951a6
|
||
|
|
d37526e39a
|
||
|
|
40312020c8 | ||
|
|
04a76f91fe
|
||
|
|
a8e80ed19d
|
||
|
|
7a70b6b1d4
|
||
|
|
3f20cdeed9 | ||
|
|
683cd9a545
|
||
|
|
1dfd01fc71
|
||
|
|
54c7fe1ed2
|
||
|
|
48d272d4d6
|
||
|
|
7e27026da9
|
||
|
|
29c013a121 | ||
|
|
5f1030c831 | ||
|
|
ea2a1f916d | ||
|
|
1efbad1db3 | ||
|
|
4b87603e06 | ||
|
|
5f19409da4 | ||
|
|
e45abb5c55 | ||
|
|
47b84a097c |
@@ -0,0 +1,14 @@
|
||||
name: "Check flake syntax"
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
flake-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies for Nix setup action
|
||||
run: |
|
||||
apt update -y
|
||||
apt install sudo -y
|
||||
- uses: cachix/install-nix-action@v30
|
||||
- run: nix flake check --no-update-lock-file --accept-flake-config .
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+4
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
## 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 dev server `nix run .#dev` or `nix run .#dev -- {args}`<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>
|
||||
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:
|
||||
- cards can only unlock, not lock
|
||||
- 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
|
||||
- cors for frontend: https://fastapi.tiangolo.com/tutorial/cors
|
||||
- Load cors from env var or something
|
||||
- BackgroundScanner shouldn't get a single session for the whole lifecycle
|
||||
- input validation maybe
|
||||
- too many imports
|
||||
- inconsistent logging (request logging?)
|
||||
- rate limiting maybe
|
||||
- pretty sure the controllers are doing too much stuff
|
||||
|
||||
+149
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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
@@ -6,4 +6,6 @@ MIFARE_ACL_WRITE_BASE_KEY="f1aa99f81cca268de98d422ee0ccb65c"
|
||||
# 16 bytes AES key
|
||||
SECRET_KEY="8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85"
|
||||
#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"
|
||||
|
||||
@@ -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)
|
||||
@@ -1,44 +1,74 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List
|
||||
|
||||
from ..model.models import *
|
||||
from ..services.database import engine, get_session, add_and_refresh
|
||||
from ..services.auth import auth_is_admin
|
||||
import uuid as gen_uuid
|
||||
from app.model.models import (
|
||||
AccessAuthorizationCreate,
|
||||
AccessAuthorizationDB,
|
||||
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.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}")
|
||||
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(
|
||||
name=aa.name,
|
||||
type=aa.type,
|
||||
is_active=aa.is_active,
|
||||
timetables=timetables
|
||||
oneshot=oneshot,
|
||||
)
|
||||
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)):
|
||||
return db.exec(
|
||||
select(AccessAuthorizationDB)
|
||||
.options(selectinload(AccessAuthorizationDB.timetables))
|
||||
).all()
|
||||
|
||||
@aa_router.get("/", response_model=list[AccessAuthorizationResponse])
|
||||
def get_all_accessauths(
|
||||
db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)
|
||||
):
|
||||
return db.exec(select(AccessAuthorizationDB)).all()
|
||||
|
||||
|
||||
@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)
|
||||
if db_aa is None:
|
||||
raise HTTPException(status_code=404, detail="AA not found")
|
||||
return db_aa
|
||||
|
||||
|
||||
@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)
|
||||
if db_group is None:
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="AA not found")
|
||||
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)
|
||||
return add_and_refresh(db, db_group)
|
||||
|
||||
|
||||
@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)
|
||||
if db_group is None:
|
||||
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)
|
||||
return add_and_refresh(db, db_group)
|
||||
|
||||
|
||||
@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)
|
||||
if db_aa is None:
|
||||
raise HTTPException(status_code=404, detail="AccessAuthorization not found")
|
||||
aa_data = aa.model_dump(exclude_unset=True)
|
||||
if "timetables" in aa_data and aa_data["timetables"] is not None:
|
||||
db_aa.timetables.clear()
|
||||
timetables = [Timetable.model_validate(t) for t in aa_data["timetables"]]
|
||||
db_aa.timetables = timetables
|
||||
aa_data.pop("timetables")
|
||||
aa_data = aa.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if "oneshot" in aa_data and aa_data["oneshot"] is not None:
|
||||
oneshot = OneShotAccess.model_validate(aa_data["oneshot"])
|
||||
db_aa.oneshot = oneshot
|
||||
aa_data.pop("oneshot")
|
||||
db_aa.sqlmodel_update(aa_data)
|
||||
return add_and_refresh(db, db_aa)
|
||||
|
||||
|
||||
@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)
|
||||
if db_aa is None:
|
||||
raise HTTPException(status_code=404, detail="AccessAuthorization not found")
|
||||
db.delete(db_aa)
|
||||
db.commit()
|
||||
return {"message": "AccessAuthorization deleted successfully"}
|
||||
return {"message": "AccessAuthorization deleted successfully"}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.services.auth import auth_is_admin
|
||||
from app.services.caldav import get_caldav_handler
|
||||
from app.model.models import Card
|
||||
from sqlmodel import Session, select
|
||||
|
||||
logger= logging.getLogger(__name__)
|
||||
|
||||
calendar_router = APIRouter(
|
||||
prefix="/api/v1/calendar",
|
||||
tags=["Calendar diagnostics"],
|
||||
dependencies=[Depends(auth_is_admin)]
|
||||
)
|
||||
|
||||
@calendar_router.get("/isActive")
|
||||
def cal_is_active():
|
||||
if get_caldav_handler() is None:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@calendar_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()
|
||||
|
||||
|
||||
@calendar_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()
|
||||
|
||||
|
||||
@calendar_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()
|
||||
@@ -1,70 +1,128 @@
|
||||
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 ..services.database import engine, get_session, add_and_refresh
|
||||
from ..services.auth import auth_is_admin
|
||||
import uuid as gen_uuid
|
||||
from app.services.scanner import WriteNewCard, DeleteCard
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlmodel import Session, select
|
||||
from datetime import datetime, UTC
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
def register_card(cardInput: CardCreate):
|
||||
key, uid = WriteNewCard()
|
||||
if key == None:
|
||||
if key is None:
|
||||
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(
|
||||
group_id=cardInput.group_id,
|
||||
key=key,
|
||||
name=cardInput.name,
|
||||
card_serial=uid,
|
||||
enabled=cardInput.enabled
|
||||
)
|
||||
enabled=cardInput.enabled,
|
||||
last_used=datetime.now(tz=UTC)
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
@card_router.post("/", response_model=Card)
|
||||
def add_card(cardInput: CardCreate, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
|
||||
try: assert db.exec(select(Card).where(Card.name == cardInput.name)).one_or_none() == None
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!")
|
||||
try: assert db.exec(select(GroupDB).where(GroupDB.id == cardInput.group_id)).one_or_none() is not None
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!")
|
||||
def add_card(
|
||||
cardInput: CardCreate,
|
||||
db: Session = Depends(get_session),
|
||||
admin: bool = Depends(auth_is_admin),
|
||||
):
|
||||
try:
|
||||
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)
|
||||
return add_and_refresh(db, card)
|
||||
|
||||
|
||||
@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()
|
||||
logger.info(key)
|
||||
try:
|
||||
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!")
|
||||
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.commit()
|
||||
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()
|
||||
return cards
|
||||
|
||||
|
||||
@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)
|
||||
if db_card is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Card not found!")
|
||||
card_data = cardInput.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if "group_id" in card_data:
|
||||
try: assert db.exec(select(GroupDB).where(GroupDB.id == cardInput.group_id)).one_or_none() is not None
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!")
|
||||
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
|
||||
db_card.sqlmodel_update(card_data)
|
||||
return add_and_refresh(db, db_card)
|
||||
return add_and_refresh(db, db_card)
|
||||
|
||||
@card_router.get("/inactive")
|
||||
def inactive_card_search(enabled: bool = False, uses: bool = False, ended: bool = False, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
|
||||
results = []
|
||||
if not enabled and not uses and not ended:
|
||||
return {"message": "No results. Please activate at least one filter rule"}
|
||||
if enabled:
|
||||
return {"message": "Results with disabled cards heres"}
|
||||
@@ -1,11 +1,15 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from app.services.auth import auth_is_admin
|
||||
from sqlalchemy import exc
|
||||
from sqlmodel import Session, select
|
||||
import sqlalchemy.exc as exc
|
||||
from app.model.models import *
|
||||
from app.services.database import get_session, add_and_refresh
|
||||
|
||||
from app.model.models import Card
|
||||
from app.services.auth import auth_is_admin
|
||||
from app.services.caldav import get_caldav_handler
|
||||
from app.services.database import add_and_refresh, get_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
debug_router = APIRouter(
|
||||
prefix="/api/v1/debug",
|
||||
@@ -13,35 +17,47 @@ debug_router = APIRouter(
|
||||
dependencies=[Depends(auth_is_admin)],
|
||||
)
|
||||
|
||||
|
||||
@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)"""
|
||||
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(
|
||||
group_id=groupid,
|
||||
key=card_key,
|
||||
name=name,
|
||||
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)
|
||||
return card
|
||||
|
||||
|
||||
@debug_router.get("/rmcard/{card_id}")
|
||||
def remove_card_manually(card_id: str, db: Session = Depends(get_session)):
|
||||
try:
|
||||
card = db.exec(select(Card).where(Card.id == card_id)).one()
|
||||
except exc.NoResultFound:
|
||||
raise HTTPException(status_code=500, detail="No card with that id found.")
|
||||
except exc.NoResultFound as err:
|
||||
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}")
|
||||
db.delete(card)
|
||||
db.commit()
|
||||
return {"message": "Card deleted successfully"}
|
||||
|
||||
|
||||
|
||||
@debug_router.put("/getcards")
|
||||
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()
|
||||
print(cards)
|
||||
out = []
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
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
|
||||
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")
|
||||
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
|
||||
doorService.opendoor()
|
||||
doorService.openDoor()
|
||||
|
||||
|
||||
@door_router.put("/close")
|
||||
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
|
||||
doorService.closedoor()
|
||||
def close_door(
|
||||
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")
|
||||
def test_access(input: str, db: Session = Depends(get_session)):
|
||||
return doorService.checkAccess(input, db=db)
|
||||
return doorService.checkAccess(input, db=db)
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
from typing import List
|
||||
|
||||
from ..model.models import GroupDB, GroupResponse, GroupCreate
|
||||
from ..services.database import engine, get_session, add_and_refresh
|
||||
from ..model.models import GroupCreate, GroupDB, GroupResponse
|
||||
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.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()
|
||||
return groups
|
||||
|
||||
|
||||
@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)
|
||||
group = db.exec(select(GroupDB).where(GroupDB.name == db_group.name)).first()
|
||||
if group is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Group already exists!")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="Group already exists!"
|
||||
)
|
||||
return add_and_refresh(db, db_group)
|
||||
|
||||
|
||||
@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)
|
||||
if db_group is None:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
db.delete(db_group)
|
||||
db.commit()
|
||||
return {"message": "Group deleted successfully"}
|
||||
return {"message": "Group deleted successfully"}
|
||||
|
||||
@@ -1,42 +1,75 @@
|
||||
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 ..services.database import engine, get_session, add_and_refresh
|
||||
from ..services.auth import get_password_hash, get_current_user as auth_user, auth_is_admin
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
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.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)}
|
||||
try: assert db.exec(select(UserDB).where(UserDB.name == user.name)).one_or_none() == None
|
||||
except AssertionError:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!")
|
||||
try:
|
||||
assert (
|
||||
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)
|
||||
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()
|
||||
return users
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@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)
|
||||
if db_user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return db_user
|
||||
|
||||
|
||||
@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)
|
||||
if db_user is None:
|
||||
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)
|
||||
return add_and_refresh(db, db_user)
|
||||
|
||||
|
||||
@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)
|
||||
if db_user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
|
||||
+38
-28
@@ -1,49 +1,59 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .controllers import userManager, cardManager, groupManager, aaManager, doorManager, debugManager
|
||||
from .services.database import create_db_and_tables, get_db_session
|
||||
from .services.auth import token_router, create_first_user
|
||||
from app.controllers import (
|
||||
aaManager,
|
||||
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.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
scanner = BackgroundScanner(db=get_db_session())
|
||||
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
|
||||
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_first_user(db=get_db_session())
|
||||
logger.info("Database created and tables initialized.")
|
||||
disableCards = os.getenv("DISABLE_CARDS")
|
||||
if not disableCards:
|
||||
init_controller(
|
||||
DoorController(
|
||||
mqtt_host=settings.mqtt_host,
|
||||
mqtt_port=settings.mqtt_port,
|
||||
mock_factory=settings.mock,
|
||||
),
|
||||
)
|
||||
if not settings.disable_cards:
|
||||
scanner.start()
|
||||
|
||||
init_caldav_handler()
|
||||
|
||||
logger.info("-" * 63)
|
||||
logger.info("---- Documentation is at http://127.0.0.1:8000/api/v1/docs ----")
|
||||
logger.info("-" * 63)
|
||||
yield
|
||||
#scanner.stop()
|
||||
stop_caldav_handler()
|
||||
# scanner.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
docs_url="/api/v1/docs",
|
||||
openapi_url="/api/v1/openapi.json"
|
||||
)
|
||||
lifespan=lifespan, docs_url="/api/v1/docs", openapi_url="/api/v1/openapi.json"
|
||||
)
|
||||
|
||||
origins = [
|
||||
"http://127.0.0.1",
|
||||
@@ -55,7 +65,7 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET" "PUT" "POST" "DELETE" "PATCH"],
|
||||
allow_methods=["GET", "PUT", "POST", "DELETE", "PATCH"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@@ -65,4 +75,4 @@ app.include_router(groupManager.group_router)
|
||||
app.include_router(cardManager.card_router)
|
||||
app.include_router(aaManager.aa_router)
|
||||
app.include_router(doorManager.door_router)
|
||||
app.include_router(debugManager.debug_router)
|
||||
app.include_router(debugManager.debug_router)
|
||||
|
||||
+99
-29
@@ -1,85 +1,165 @@
|
||||
from sqlmodel import Field, Relationship, Session, SQLModel
|
||||
from typing import List
|
||||
from datetime import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from sqlalchemy import DateTime, TypeDecorator
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
|
||||
class Base(SQLModel):
|
||||
pass
|
||||
|
||||
|
||||
#### Extra Column Types
|
||||
class UTCDateTime(TypeDecorator):
|
||||
"""Modified datetime for timezones
|
||||
Input time gets read as given tz or local tz, then converted to UTC
|
||||
Output everything as UTC
|
||||
"""
|
||||
|
||||
impl = DateTime(timezone=True)
|
||||
LOCAL_TIMEZONE = datetime.now(UTC).astimezone().tzinfo
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: datetime | None, dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.astimezone(self.LOCAL_TIMEZONE)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
def process_result_value(self, value: datetime | None, dialect) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
#### User
|
||||
class UserBase(Base):
|
||||
name: str = Field(index=True, unique=True)
|
||||
email: str | None = None
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
|
||||
class UserDB(UserBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
passwordhash: str
|
||||
|
||||
|
||||
class UserUpdate(Base):
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
is_admin: bool | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
#### Special
|
||||
class AaGroupLink(Base, table=True):
|
||||
group_id: int | None = Field(default=None, foreign_key="groupdb.id", primary_key=True)
|
||||
accessauth_id: int | None = Field(default=None, foreign_key="accessauthorizationdb.id", primary_key=True)
|
||||
group_id: int | None = Field(
|
||||
default=None, foreign_key="groupdb.id", primary_key=True
|
||||
)
|
||||
accessauth_id: int | None = Field(
|
||||
default=None, foreign_key="accessauthorizationdb.id", primary_key=True
|
||||
)
|
||||
|
||||
|
||||
#### Token
|
||||
class Token(Base):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(Base):
|
||||
username: str | None = None
|
||||
|
||||
|
||||
#### Group
|
||||
class GroupBase(Base):
|
||||
name: str = Field(index=True, unique=True)
|
||||
|
||||
|
||||
class GroupCreate(GroupBase):
|
||||
pass
|
||||
|
||||
|
||||
class GroupDB(GroupBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
cards: List["Card"] = Relationship(back_populates="group")
|
||||
accessauths: List["AccessAuthorizationDB"] = Relationship(back_populates="groups", link_model=AaGroupLink)
|
||||
cards: list["Card"] = Relationship(back_populates="group")
|
||||
accessauths: list["AccessAuthorizationDB"] = Relationship(
|
||||
back_populates="groups", link_model=AaGroupLink
|
||||
)
|
||||
|
||||
|
||||
class GroupResponse(GroupBase):
|
||||
id: int
|
||||
cards: List["Card"] | None
|
||||
accessauths: List["AccessAuthorizationDB"] | None
|
||||
cards: list["Card"] | None
|
||||
accessauths: list["AccessAuthorizationDB"] | None
|
||||
|
||||
|
||||
#### AccessAuthorization
|
||||
class AccessAuthorizationBase(Base):
|
||||
name: str = Field(index=True)
|
||||
type: Literal["oneshot", "somefuturespec"]
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AccessAuthorizationDB(AccessAuthorizationBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
groups: List["GroupDB"] = Relationship(back_populates="accessauths", link_model=AaGroupLink)
|
||||
timetables: List["Timetable"] = Relationship(back_populates="accessauth", cascade_delete=True)
|
||||
type: str
|
||||
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):
|
||||
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):
|
||||
id: int
|
||||
timetables: List["Timetable"]
|
||||
groups: List["GroupDB"]
|
||||
oneshot: OneShotAccessBase | None = None
|
||||
groups: list["GroupDB"]
|
||||
|
||||
|
||||
class AccessAuthorizationUpdate(Base):
|
||||
name: str | None = None
|
||||
type: Literal["oneshot", "somefuturespec"] | None = None
|
||||
is_active: bool | None = None
|
||||
timetables: List["TimetableCreate"] | None = None
|
||||
|
||||
oneshot: OneShotAccessBase | None = None
|
||||
|
||||
|
||||
#### Card
|
||||
class Card(Base, table=True):
|
||||
@@ -90,26 +170,16 @@ class Card(Base, table=True):
|
||||
name: str = Field(unique=True, max_length=32)
|
||||
group_id: int | None = Field(default=None, foreign_key="groupdb.id")
|
||||
group: GroupDB | None = Relationship(back_populates="cards")
|
||||
last_used: datetime = Field(sa_type=UTCDateTime)
|
||||
|
||||
|
||||
class CardCreate(Base):
|
||||
name: str = Field(unique=True, max_length=32)
|
||||
enabled: bool = True
|
||||
group_id: int
|
||||
|
||||
|
||||
class CardUpdate(Base):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
group_id: int | None = None
|
||||
|
||||
class TimetableBase(Base):
|
||||
weekday: int = Field(le=6, ge=0)
|
||||
starttime: time
|
||||
duration: int = Field(gt=0, lt=1440)
|
||||
|
||||
class Timetable(TimetableBase, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
accessauth_id: int = Field(default=None, foreign_key="accessauthorizationdb.id")
|
||||
accessauth: AccessAuthorizationDB = Relationship(back_populates="timetables")
|
||||
|
||||
class TimetableCreate(TimetableBase):
|
||||
pass
|
||||
|
||||
+37
-30
@@ -1,21 +1,23 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
import secrets
|
||||
import string
|
||||
from datetime import UTC, datetime, timedelta
|
||||
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
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from ..model.models import UserDB, Token, TokenData, UserCreate
|
||||
from ..services.database import *
|
||||
import secrets, string, os
|
||||
from pwdlib import PasswordHash
|
||||
from sqlmodel import Session, select
|
||||
|
||||
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()
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", default="ff"*16)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SECRET_KEY = settings.secret_key
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 120
|
||||
|
||||
@@ -25,16 +27,20 @@ token_router = APIRouter(tags=["Token"], prefix="/api/v1")
|
||||
|
||||
password_hash = PasswordHash.recommended()
|
||||
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
return password_hash.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password):
|
||||
return password_hash.hash(password)
|
||||
|
||||
|
||||
def get_user(db, username: str):
|
||||
user = db.exec(select(UserDB).where(UserDB.name == username)).first()
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(db, username: str, password: str):
|
||||
user = get_user(db, username)
|
||||
if not user:
|
||||
@@ -43,24 +49,26 @@ def authenticate_user(db, username: str, password: str):
|
||||
return False
|
||||
return user
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
expire = datetime.now(UTC) + timedelta(minutes=15)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
@@ -68,36 +76,36 @@ def get_current_user(
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except InvalidTokenError:
|
||||
raise credentials_exception
|
||||
except InvalidTokenError as err:
|
||||
raise credentials_exception from err
|
||||
user = get_user(db, username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
def auth_is_admin(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_session),
|
||||
):
|
||||
):
|
||||
user = get_current_user(token=token, db=db)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to perform this action",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def create_first_user(db: Session):
|
||||
logger.info("Checking for admin user")
|
||||
admin_user = db.exec(select(UserDB)).first()
|
||||
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}")
|
||||
user = UserDB(
|
||||
name="admin",
|
||||
passwordhash=get_password_hash(password),
|
||||
is_admin=True
|
||||
name="admin", passwordhash=get_password_hash(password), is_admin=True
|
||||
)
|
||||
return add_and_refresh(db, user)
|
||||
logger.info(f"Admin user already exists: {admin_user.name}")
|
||||
@@ -106,14 +114,14 @@ def create_first_user(db: Session):
|
||||
@token_router.post("/token")
|
||||
def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Session = Depends(get_session)
|
||||
db: Session = Depends(get_session),
|
||||
) -> Token:
|
||||
user = authenticate_user(db, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or pw",
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
@@ -121,8 +129,7 @@ def login_for_access_token(
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
|
||||
@token_router.get("/test/login")
|
||||
def test_login(
|
||||
current_user: Annotated[UserDB, Depends(get_current_user)]
|
||||
) -> UserDB:
|
||||
return current_user
|
||||
def test_login(current_user: Annotated[UserDB, Depends(get_current_user)]) -> UserDB:
|
||||
return current_user
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
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():
|
||||
with Session(engine) as db:
|
||||
with Session(get_engine()) as db:
|
||||
yield db
|
||||
|
||||
|
||||
def get_db_session():
|
||||
return Session(engine)
|
||||
return Session(get_engine())
|
||||
|
||||
|
||||
def add_and_refresh(db: Session, obj):
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
return obj
|
||||
|
||||
+124
-33
@@ -1,52 +1,143 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from sqlalchemy import exc
|
||||
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 *
|
||||
from app.model.models import Card, OneShotAccess
|
||||
from app.services.caldav import get_caldav_handler
|
||||
from app.services.database import Session, add_and_refresh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 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():
|
||||
global doorIsOpen
|
||||
doorIsOpen = True
|
||||
logger.info("Still needs gpio out")
|
||||
pass
|
||||
get_controller().open()
|
||||
|
||||
|
||||
def closeDoor():
|
||||
global doorIsOpen
|
||||
doorIsOpen = False
|
||||
logger.info("Still needs gpio out")
|
||||
pass
|
||||
get_controller().close()
|
||||
|
||||
|
||||
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 updateLastUsed(db: Session, card: Card):
|
||||
data = card.model_dump()
|
||||
data["lastUsed"] = datetime.now(UTC)
|
||||
card.sqlmodel_update(card, update=data)
|
||||
add_and_refresh(db, card)
|
||||
|
||||
def checkAccess(key: str, db: Session):
|
||||
try:
|
||||
current_weekday = datetime.datetime.weekday(datetime.date.today())
|
||||
current_time = datetime.datetime.now()
|
||||
card = db.exec(select(Card).where(Card.key == key)).one()
|
||||
updateLastUsed(db, card)
|
||||
if not card.enabled:
|
||||
logger.info("Card Inactive!")
|
||||
return False
|
||||
|
||||
caldav_handler = get_caldav_handler()
|
||||
if caldav_handler and caldav_handler.is_group_active(card.group.name):
|
||||
logger.info("Access Valid!")
|
||||
return True
|
||||
|
||||
current_time = datetime.now().astimezone()
|
||||
for auth in card.group.accessauths:
|
||||
logger.info(f"checking auth: {auth.name}")
|
||||
for timetable in auth.timetables:
|
||||
logger.info(f" checking timetable {timetable.id}")
|
||||
logger.info(f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}")
|
||||
if current_weekday == timetable.weekday:
|
||||
starttime = datetime.datetime.combine(datetime.date.today(), timetable.starttime)
|
||||
endtime = starttime + datetime.timedelta(minutes=timetable.duration)
|
||||
logger.info(f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}")
|
||||
if starttime < current_time < endtime:
|
||||
logger.info("Access Valid!")
|
||||
if not auth.is_active:
|
||||
logger.info("AA inactive!")
|
||||
continue
|
||||
if auth.type == "oneshot":
|
||||
logger.info(f" oneshot auth found: {auth.oneshot}")
|
||||
ends_at = auth.oneshot.ends_at
|
||||
if ends_at.tzinfo is None:
|
||||
ends_at = ends_at.astimezone()
|
||||
if current_time < ends_at:
|
||||
if auth.oneshot.uses > 0:
|
||||
decrementOneshot(db, auth.oneshot)
|
||||
return True
|
||||
elif auth.oneshot.uses == -1:
|
||||
logger.debug("Unlimited use card found")
|
||||
return True
|
||||
logger.info("No more auths found")
|
||||
return False
|
||||
except exc.NoResultFound:
|
||||
raise Exception("No Access with that key found, this might be a db error")
|
||||
|
||||
except exc.NoResultFound as err:
|
||||
raise Exception(
|
||||
"No Access with that key found, this might be a db error"
|
||||
) from err
|
||||
|
||||
+104
-64
@@ -1,33 +1,37 @@
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from typing import Optional
|
||||
from sqlmodel import Session
|
||||
from dotenv import load_dotenv
|
||||
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 fastapi import HTTPException, status
|
||||
|
||||
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
|
||||
import desfire.exceptions as desExceptions
|
||||
from app.services.door import checkAccess, closeDoor, isDoorOpen, openDoor
|
||||
from app.services.settings import settings
|
||||
|
||||
from app.services.door import openDoor, closeDoor, isDoorOpen, checkAccess
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#ENV vars
|
||||
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')
|
||||
# ENV vars
|
||||
MIFARE_APP_MASTER_KEY = settings.mifare_app_master_key
|
||||
MIFARE_ACL_READ_BASE_KEY = settings.mifare_acl_read_base_key
|
||||
MIFARE_ACL_WRITE_BASE_KEY = settings.mifare_acl_write_base_key
|
||||
|
||||
# Constants
|
||||
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_ENCRYPTED_FILE_ID = 0x1
|
||||
|
||||
|
||||
def checkForKey():
|
||||
if MIFARE_APP_MASTER_KEY == None:
|
||||
logger.critical("NO MASTER KEY LOADED")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No key loaded! Check application.")
|
||||
if MIFARE_APP_MASTER_KEY is None:
|
||||
logger.critical("NO MASTER KEY LOADED")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="No key loaded! Check application.",
|
||||
)
|
||||
|
||||
|
||||
def getCardService(timeout: int = 10):
|
||||
cardtype = AnyCardType()
|
||||
@@ -48,51 +57,58 @@ def getCardService(timeout: int = 10):
|
||||
cardservice.connection.connect()
|
||||
return cardservice
|
||||
|
||||
|
||||
def readFileOnCard(desfire: DESFire):
|
||||
if not MIFARE_ACL_READ_BASE_KEY:
|
||||
logger.critical("MIFARE_ACL_READ_BASE_KEY not found! Reading skipped!")
|
||||
return
|
||||
#create keys
|
||||
#desfire = DESFire(PCSCDevice(cardservice.connection.component))
|
||||
# create keys
|
||||
# desfire = DESFire(PCSCDevice(cardservice.connection.component))
|
||||
aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
|
||||
keysettings = desfire.get_key_setting()
|
||||
desKey = DESFireKey(keysettings, "00" * 8)
|
||||
|
||||
# Get real UID
|
||||
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()
|
||||
applications = desfire.get_application_ids()
|
||||
try:
|
||||
assert len(applications) == 1
|
||||
assert applications[0] == get_list(MIFARE_APP_ID)
|
||||
assert applications[0] == get_list(MIFARE_APP_ID)
|
||||
except AssertionError:
|
||||
logger.error("No application found!")
|
||||
time.sleep(4)
|
||||
return
|
||||
#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)
|
||||
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
|
||||
# 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)
|
||||
)
|
||||
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")
|
||||
aes_app_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
|
||||
desfire.select_application(MIFARE_APP_ID)
|
||||
|
||||
desfire.authenticate(MIFARE_ACL_READ_BASE_KEY_ID, aes_app_read_key)
|
||||
|
||||
|
||||
logger.debug(f"Read data from {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)
|
||||
#convert list of int to str
|
||||
# convert list of int to str
|
||||
rdata = to_hex_string(rdata).replace(" ", "").lower()
|
||||
logger.debug(f"Data on card: {rdata}")
|
||||
return rdata
|
||||
|
||||
|
||||
def DeleteCard():
|
||||
try:
|
||||
checkForKey()
|
||||
from app.main import scanner as scannerThread
|
||||
|
||||
scannerThread.stop()
|
||||
cardservice = getCardService(15)
|
||||
|
||||
@@ -107,24 +123,26 @@ def DeleteCard():
|
||||
desKey = DESFireKey(des_keysettings, "00" * 8)
|
||||
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
|
||||
aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
|
||||
|
||||
|
||||
desfire.select_application(0x0)
|
||||
|
||||
try:
|
||||
try:
|
||||
logger.debug("Auth1")#
|
||||
logger.debug("Auth1")
|
||||
desfire.authenticate(0x0, aes_master_key)
|
||||
except:
|
||||
except (Exception, AssertionError):
|
||||
logger.debug("Auth2")
|
||||
desfire.authenticate(0x0, aes_null_key)
|
||||
except:
|
||||
except (Exception, AssertionError):
|
||||
logger.debug("Auth3")
|
||||
desfire.authenticate(0x0, desKey)
|
||||
|
||||
|
||||
applications = desfire.get_application_ids()
|
||||
logger.debug(f"Applications: {applications}")
|
||||
if len(applications) == 0:
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="No applications on card")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE, detail="No applications on card"
|
||||
)
|
||||
|
||||
desfire.select_application(MIFARE_APP_ID)
|
||||
desfire.authenticate(0x0, aes_master_key)
|
||||
@@ -136,14 +154,18 @@ def DeleteCard():
|
||||
pass
|
||||
scannerThread.start()
|
||||
return rdata
|
||||
except(Exception, AssertionError) as e:
|
||||
logger.error(f"Error in deletion function: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}")
|
||||
except (Exception, AssertionError) as err:
|
||||
logger.error(f"Error in deletion function: {err}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}"
|
||||
) from err
|
||||
|
||||
|
||||
def WriteNewCard():
|
||||
try:
|
||||
checkForKey()
|
||||
from app.main import scanner as scannerThread
|
||||
|
||||
scannerThread.stop()
|
||||
|
||||
cardservice = getCardService(20)
|
||||
@@ -159,8 +181,8 @@ def WriteNewCard():
|
||||
# Authenticate with default DES key
|
||||
logger.debug("Authenticating with default DES key...")
|
||||
desfire.authenticate(0x0, desKey)
|
||||
|
||||
#get uid
|
||||
|
||||
# get uid
|
||||
uid = desfire.get_real_uid()
|
||||
|
||||
# Set default key
|
||||
@@ -189,7 +211,7 @@ def WriteNewCard():
|
||||
# Select application
|
||||
desfire.select_application(MIFARE_APP_ID)
|
||||
|
||||
#recreate key object
|
||||
# recreate key object
|
||||
desfire.authenticate(0x0, aes_null_key)
|
||||
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)
|
||||
|
||||
#generate div data
|
||||
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)
|
||||
|
||||
# generate div data
|
||||
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
|
||||
)
|
||||
|
||||
logger.debug("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)
|
||||
desfire.change_key(
|
||||
MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1
|
||||
)
|
||||
|
||||
logger.debug("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)
|
||||
desfire.change_key(
|
||||
MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1
|
||||
)
|
||||
|
||||
logger.debug("Create encrypted file containing key...")
|
||||
file_settings = FileSettings(
|
||||
@@ -226,25 +258,29 @@ def WriteNewCard():
|
||||
|
||||
logger.debug("Writing UID to encrypted file...")
|
||||
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...")
|
||||
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
|
||||
assert rdata == get_list(key)
|
||||
logger.debug(" - Data written successfully.")
|
||||
scannerThread.start()
|
||||
return key, to_hex_string(data=uid, separator=":")
|
||||
return key, to_hex_string(data=uid, separator=":")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in write function: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}")
|
||||
except Exception as err:
|
||||
logger.error(f"Error in write function: {err}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}"
|
||||
) from err
|
||||
|
||||
|
||||
class BackgroundScanner:
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
self.is_running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
if self.is_running:
|
||||
@@ -292,11 +328,15 @@ class BackgroundScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"something went wrong: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
def _check_db(self, key):
|
||||
check = checkAccess(key, self.db)
|
||||
if check == True:
|
||||
openDoor()
|
||||
logger.info("Access granted!")
|
||||
if isDoorOpen():
|
||||
closeDoor()
|
||||
logger.info("Door closed by key %s", key)
|
||||
else:
|
||||
logger.error("Access denied!")
|
||||
check = checkAccess(key, self.db)
|
||||
if check:
|
||||
openDoor()
|
||||
logger.info("Access granted!")
|
||||
else:
|
||||
logger.error("Access denied!")
|
||||
|
||||
@@ -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()
|
||||
@@ -65,6 +65,10 @@
|
||||
buildInputs = (old.buildInputs or []) ++ [ pkgs.pcsclite.dev ];
|
||||
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
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
pythonSet = pythonSets.${system}.overrideScope editableOverlay;
|
||||
virtualenv = pythonSet.mkVirtualEnv "gatekeeper" workspace.deps.all;
|
||||
virtualenv = (pythonSet.mkVirtualEnv "gatekeeper" workspace.deps.all).overrideAttrs(old: { venvIgnoreCollisions = ["*"];});
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
@@ -93,7 +97,7 @@
|
||||
UV_NO_SYNC = "1";
|
||||
UV_PYTHON = pythonSet.python.interpreter;
|
||||
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 = ''
|
||||
unset PYTHONPATH
|
||||
@@ -104,7 +108,7 @@
|
||||
);
|
||||
|
||||
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: {
|
||||
@@ -119,9 +123,11 @@
|
||||
type = "app";
|
||||
program = toString (nixpkgs.legacyPackages.${system}.writeShellScript "gatekeeper" ''
|
||||
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
@@ -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
@@ -11,11 +11,20 @@ dependencies = [
|
||||
"python-desfire",
|
||||
"pyjwt[crypto]>=2.12.1",
|
||||
"pwdlib[argon2]>=0.3.0",
|
||||
"pytest>=9.0.3",
|
||||
"requests>=2.33.1",
|
||||
"pytest-cov>=7.1.0",
|
||||
"setuptools>=82.0.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]
|
||||
@@ -24,3 +33,38 @@ python-desfire = { git = "https://github.com/waza-ari/python-desfire" }
|
||||
[tool.uv.extra-build-dependencies]
|
||||
python-desfire = ["poetry"]
|
||||
"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
|
||||
@@ -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.")
|
||||
+154
-35
@@ -1,22 +1,144 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from time import tzset
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, create_engine, SQLModel
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app.main import app
|
||||
from app.model.models import UserDB, Card, GroupDB, AccessAuthorizationDB, Timetable, AaGroupLink
|
||||
from app.services.database import get_session
|
||||
# TEST_ENV must cover every Settings field
|
||||
# test_settings.py fails with the missing name if it does not.
|
||||
TEST_ENV = {
|
||||
"SECRET_KEY": "ff" * 16,
|
||||
"SQLALCHEMY_DATABASE_URL": "sqlite://",
|
||||
"MQTT_HOST": "localhost",
|
||||
"MQTT_PORT": "1883",
|
||||
"MOCK": "True",
|
||||
"ALEMBIC_CONFIG": "./alembic.ini",
|
||||
"DISABLE_CARDS": "True",
|
||||
"MIFARE_APP_MASTER_KEY": "0" * 16,
|
||||
"MIFARE_ACL_READ_BASE_KEY": "1" * 16,
|
||||
"MIFARE_ACL_WRITE_BASE_KEY": "2" * 16,
|
||||
"DISABLE_CALDAV": "True",
|
||||
"CALDAV_URL": "http://caldav.invalid/dav.php",
|
||||
"CALDAV_USER": "test-user",
|
||||
"CALDAV_PASSWORD": "test-password",
|
||||
"CALDAV_CALENDARS": '["test-calendar"]',
|
||||
"CALDAV_REFRESH_INTERVAL": "3600",
|
||||
}
|
||||
|
||||
# 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")
|
||||
def db_session():
|
||||
"""Create a fresh database session for each test."""
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_seed_alembic_version()
|
||||
_seed_default_admin()
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
SQLModel.metadata.drop_all(engine)
|
||||
@@ -24,27 +146,19 @@ def db_session():
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client(db_session):
|
||||
"""Create a test client with a database session override."""
|
||||
def override_get_session():
|
||||
yield db_session
|
||||
"""Create a test client bound to the test engine.
|
||||
|
||||
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:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db_session):
|
||||
"""Create an admin user for testing."""
|
||||
from app.services.auth import get_password_hash
|
||||
admin = UserDB(
|
||||
name="admin",
|
||||
passwordhash=get_password_hash("admin123"),
|
||||
is_admin=True
|
||||
)
|
||||
db_session.add(admin)
|
||||
db_session.commit()
|
||||
"""Get the admin user the seeded database already contains."""
|
||||
admin = db_session.exec(select(UserDB).where(UserDB.name == "admin")).one()
|
||||
db_session.refresh(admin)
|
||||
return admin
|
||||
|
||||
@@ -53,10 +167,9 @@ def admin_user(db_session):
|
||||
def regular_user(db_session):
|
||||
"""Create a regular user for testing."""
|
||||
from app.services.auth import get_password_hash
|
||||
|
||||
user = UserDB(
|
||||
name="user",
|
||||
passwordhash=get_password_hash("user123"),
|
||||
is_admin=False
|
||||
name="user", passwordhash=get_password_hash("user123"), is_admin=False
|
||||
)
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
@@ -69,7 +182,7 @@ def auth_headers(client, admin_user):
|
||||
"""Get authentication headers for admin user."""
|
||||
response = client.post(
|
||||
"/api/v1/token",
|
||||
data={"username": admin_user.name, "password": "admin123"}
|
||||
data={"username": admin_user.name, "password": TEST_ADMIN_PASSWORD},
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -79,8 +192,7 @@ def auth_headers(client, admin_user):
|
||||
def user_auth_headers(client, regular_user):
|
||||
"""Get authentication headers for regular user."""
|
||||
response = client.post(
|
||||
"/api/v1/token",
|
||||
data={"username": regular_user.name, "password": "user123"}
|
||||
"/api/v1/token", data={"username": regular_user.name, "password": "user123"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -99,7 +211,14 @@ def test_group(db_session):
|
||||
@pytest.fixture
|
||||
def test_card(db_session, test_group):
|
||||
"""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",
|
||||
last_used=datetime.now()
|
||||
)
|
||||
db_session.add(card)
|
||||
db_session.commit()
|
||||
db_session.refresh(card)
|
||||
@@ -107,12 +226,12 @@ def test_card(db_session, test_group):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_aa(db_session):
|
||||
"""Create a test access authorization."""
|
||||
aa = AccessAuthorizationDB(
|
||||
name="Test AA",
|
||||
is_active=True
|
||||
)
|
||||
def test_aa_os(db_session):
|
||||
oneshot = OneShotAccess(uses=1, ends_at=datetime.now() + timedelta(days=1))
|
||||
db_session.add(oneshot)
|
||||
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=True, type="oneshot")
|
||||
aa.oneshot = oneshot
|
||||
|
||||
db_session.add(aa)
|
||||
db_session.commit()
|
||||
db_session.refresh(aa)
|
||||
|
||||
+1
-6
@@ -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():
|
||||
"""Test that all routers are included in the app."""
|
||||
from app.main import app
|
||||
|
||||
routes = [route.path for route in app.routes]
|
||||
|
||||
# Check that router prefixes are present
|
||||
|
||||
+22
-82
@@ -1,95 +1,35 @@
|
||||
import pytest
|
||||
import datetime
|
||||
from app.model.models import (
|
||||
UserBase, UserResponse, UserCreate, UserDB, UserUpdate,
|
||||
GroupBase, GroupCreate, GroupDB, GroupResponse,
|
||||
AccessAuthorizationBase, AccessAuthorizationCreate,
|
||||
AccessAuthorizationDB, AccessAuthorizationResponse, AccessAuthorizationUpdate,
|
||||
Card, Timetable, TimetableCreate, Token, TokenData, AaGroupLink
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.model.models import AccessAuthorizationCreate, UserUpdate
|
||||
|
||||
|
||||
def test_user_models():
|
||||
"""Test user model creation and validation."""
|
||||
# Test UserBase
|
||||
user_base = UserBase(name="Test User", email="test@example.com", is_admin=False)
|
||||
assert user_base.name == "Test User"
|
||||
assert user_base.email == "test@example.com"
|
||||
assert user_base.is_admin is False
|
||||
|
||||
# Test UserCreate
|
||||
user_create = UserCreate(name="New User", email="new@example.com", password="secret123")
|
||||
assert user_create.password == "secret123"
|
||||
|
||||
# Test UserUpdate
|
||||
def test_user_update_fields_are_optional():
|
||||
"""Partial update models must leave unspecified fields as None."""
|
||||
user_update = UserUpdate(name="Updated Name")
|
||||
assert user_update.name == "Updated Name"
|
||||
assert user_update.email is None
|
||||
|
||||
|
||||
def test_group_models():
|
||||
"""Test group model creation and validation."""
|
||||
# Test GroupBase
|
||||
group_base = GroupBase(name="Test Group")
|
||||
assert group_base.name == "Test Group"
|
||||
def test_access_authorization_type_is_validated():
|
||||
"""AA validation test"""
|
||||
base = {"name": "AA", "is_active": True}
|
||||
|
||||
# Test GroupCreate
|
||||
group_create = GroupCreate(name="New Group")
|
||||
assert group_create.name == "New Group"
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
AccessAuthorizationCreate(type="timetable", **base)
|
||||
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():
|
||||
"""Test access authorization model creation and validation."""
|
||||
# Test AccessAuthorizationBase
|
||||
aa_base = AccessAuthorizationBase(name="Test AA", is_active=True)
|
||||
assert aa_base.name == "Test AA"
|
||||
assert aa_base.is_active is True
|
||||
with pytest.raises(ValueError, match="not jet implemented"):
|
||||
AccessAuthorizationCreate(type="somefuturespec", **base)
|
||||
|
||||
# Test AccessAuthorizationCreate with timetables
|
||||
timetable_create = TimetableCreate(weekday=1, starttime="08:00", duration=60)
|
||||
aa_create = AccessAuthorizationCreate(
|
||||
name="New AA",
|
||||
is_active=False,
|
||||
timetables=[timetable_create]
|
||||
valid = AccessAuthorizationCreate(
|
||||
type="oneshot",
|
||||
name="AA",
|
||||
is_active=True,
|
||||
oneshot={"uses": 1, "ends_at": "2029-07-27"},
|
||||
)
|
||||
assert aa_create.name == "New AA"
|
||||
assert aa_create.is_active is False
|
||||
assert len(aa_create.timetables) == 1
|
||||
|
||||
|
||||
def test_card_model():
|
||||
"""Test card model creation and validation."""
|
||||
card = Card(key="test-key", group_id=1)
|
||||
assert card.key == "test-key"
|
||||
assert card.group_id == 1
|
||||
|
||||
|
||||
def test_timetable_models():
|
||||
"""Test timetable model creation and validation."""
|
||||
# Test TimetableBase with valid values
|
||||
timetable = TimetableCreate(weekday=1, starttime="09:00", duration=120)
|
||||
assert timetable.weekday == 1
|
||||
assert timetable.starttime == datetime.time(9, 0)
|
||||
assert timetable.duration == 120
|
||||
|
||||
# Test boundary values
|
||||
max_duration = TimetableCreate(weekday=6, starttime="23:59", duration=1439)
|
||||
assert max_duration.duration == 1439
|
||||
assert max_duration.weekday == 6
|
||||
|
||||
|
||||
def test_token_models():
|
||||
"""Test token model creation and validation."""
|
||||
token = Token(access_token="test-token", token_type="bearer")
|
||||
assert token.access_token == "test-token"
|
||||
assert token.token_type == "bearer"
|
||||
|
||||
token_data = TokenData(username="testuser")
|
||||
assert token_data.username == "testuser"
|
||||
|
||||
|
||||
def test_aa_group_link_model():
|
||||
"""Test many-to-many relationship link model."""
|
||||
link = AaGroupLink(group_id=1, accessauth_id=2)
|
||||
assert link.group_id == 1
|
||||
assert link.accessauth_id == 2
|
||||
assert valid.type == "oneshot"
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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):
|
||||
"""Test creating a new access authorization."""
|
||||
def _post_oneshot_aa(client, auth_headers, name: str, ends_at: str):
|
||||
"""Create a oneshot access authorization and return the response."""
|
||||
return client.post(
|
||||
"/api/v1/aa/",
|
||||
json={
|
||||
"name": name,
|
||||
"type": "oneshot",
|
||||
"is_active": True,
|
||||
"oneshot": {"uses": 1, "ends_at": ends_at},
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
|
||||
def test_create_access_auth_os(client, auth_headers):
|
||||
"""Test creating a new access authorization with oneshot type."""
|
||||
aa_data = {
|
||||
"name": "New AA",
|
||||
"name": "New os_AA",
|
||||
"type": "oneshot",
|
||||
"is_active": True,
|
||||
"timetables": [
|
||||
{"weekday": 1, "starttime": "08:00", "duration": 60},
|
||||
{"weekday": 2, "starttime": "09:00", "duration": 90}
|
||||
]
|
||||
"oneshot": {"uses": 1, "ends_at": "2029-07-27"},
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/aa/", json=aa_data, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
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 "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."""
|
||||
response = client.get("/api/v1/aa/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
aa_list = response.json()
|
||||
assert len(aa_list) >= 1
|
||||
|
||||
aa_names = [aa["name"] for aa in aa_list]
|
||||
assert test_aa.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."""
|
||||
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
|
||||
|
||||
data = response.json()
|
||||
assert data["id"] == test_aa.id
|
||||
assert data["name"] == test_aa.name
|
||||
assert data["id"] == test_aa_os.id
|
||||
assert data["name"] == test_aa_os.name
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
response = client.put(
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}",
|
||||
headers=auth_headers
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
# 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
|
||||
response = client.put(
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}",
|
||||
headers=auth_headers
|
||||
f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
)
|
||||
# According to the code, this returns 409 with "already assigned" message
|
||||
assert response.status_code == 409
|
||||
assert "already assigned" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa):
|
||||
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa_os):
|
||||
"""Test unassigning an access authorization from a group."""
|
||||
# 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
|
||||
response = client.put(
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa.id}",
|
||||
headers=auth_headers
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
)
|
||||
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."""
|
||||
response = client.put(
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa.id}",
|
||||
headers=auth_headers
|
||||
f"/api/v1/aa/unassign/{test_group.id}/{test_aa_os.id}", headers=auth_headers
|
||||
)
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
def test_assign_nonexistent_aa(client, auth_headers, test_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
|
||||
|
||||
|
||||
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."""
|
||||
update_data = {
|
||||
"name": "Updated AA",
|
||||
"is_active": False
|
||||
}
|
||||
update_data = {"name": "Updated AA", "is_active": False}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/aa/{test_aa.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
f"/api/v1/aa/{test_aa_os.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Test updating a non-existent access authorization."""
|
||||
update_data = {"name": "Updated"}
|
||||
@@ -161,14 +197,14 @@ def test_update_nonexistent_access_auth(client, auth_headers):
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_access_auth(client, auth_headers, test_aa):
|
||||
def test_delete_access_auth(client, auth_headers, test_aa_os):
|
||||
"""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 "deleted successfully" in response.json()["message"].lower()
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -178,13 +214,13 @@ def test_delete_nonexistent_access_auth(client, auth_headers):
|
||||
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."""
|
||||
# Try to create an AA
|
||||
response = client.post(
|
||||
"/api/v1/aa/",
|
||||
json={"name": "test", "is_active": True, "timetables": []},
|
||||
headers=user_auth_headers
|
||||
"/api/v1/aa/",
|
||||
json={"name": "test", "is_active": True},
|
||||
headers=user_auth_headers,
|
||||
)
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ, "UTC-2"], indirect=True)
|
||||
def test_create_access_auth_os_returns_timezone(client, auth_headers, system_tz):
|
||||
"""The oneshot end time must come back with a timezone, not as a naive value."""
|
||||
response = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
ends_at = response.json()["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed.utcoffset() is not None, f"response has no timezone: {ends_at}"
|
||||
assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC), (
|
||||
f"the +02:00 offset was not normalised to UTC: {ends_at}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_access_auth_os_returns_timezone(client, auth_headers):
|
||||
"""Reading the value back from the database keeps it timezone aware."""
|
||||
created = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert created.status_code == 200
|
||||
aa_id = created.json()["id"]
|
||||
|
||||
for endpoint in (f"/api/v1/aa/{aa_id}", "/api/v1/aa/"):
|
||||
response = client.get(endpoint, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
items = body if isinstance(body, list) else [body]
|
||||
auths = [aa for aa in items if aa.get("oneshot")]
|
||||
assert auths, f"no oneshot auth in {endpoint}: {body}"
|
||||
for auth in auths:
|
||||
ends_at = auth["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed.utcoffset() is not None, (
|
||||
f"{endpoint} has no timezone: {ends_at}"
|
||||
)
|
||||
assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_oneshot_timezone_is_stored_in_db(client, auth_headers, db_session):
|
||||
"""The submitted offset is normalized before the value reaches the database."""
|
||||
response = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
db_session.expire_all()
|
||||
stored = db_session.exec(select(OneShotAccess)).one()
|
||||
assert stored.ends_at == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
# The column itself must hold the UTC wall clock, not the submitted local time.
|
||||
raw = db_session.exec(text("SELECT ends_at FROM oneshotaccess")).one()[0]
|
||||
assert datetime.fromisoformat(raw) == datetime(2029, 7, 27, 10, 0), (
|
||||
f"unexpected value in the database: {raw}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_oneshot_without_offset_is_treated_as_local_time(
|
||||
client, auth_headers, db_session, system_tz
|
||||
):
|
||||
"""A datetime without an offset is wall-clock time on the host, not UTC."""
|
||||
response = _post_oneshot_aa(
|
||||
client, auth_headers, "naive os_AA", "2029-07-27T12:00:00"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 2029-07-27 is summer in Berlin, so 12:00 local is 10:00 UTC.
|
||||
parsed = datetime.fromisoformat(response.json()["oneshot"]["ends_at"])
|
||||
assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
db_session.expire_all()
|
||||
stored = db_session.exec(select(OneShotAccess)).one()
|
||||
assert stored.ends_at == datetime(2029, 7, 27, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_oneshot_date_only_is_local_midnight(client, auth_headers, system_tz):
|
||||
"""A bare date is local midnight, which is not the same instant as 00:00 UTC."""
|
||||
response = _post_oneshot_aa(client, auth_headers, "date os_AA", "2029-07-27")
|
||||
assert response.status_code == 200
|
||||
|
||||
ends_at = response.json()["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed == datetime(2029, 7, 26, 22, 0, tzinfo=UTC), (
|
||||
f"local midnight was not converted to UTC: {ends_at}"
|
||||
)
|
||||
|
||||
|
||||
def test_update_access_auth_os_returns_timezone(client, auth_headers, db_session):
|
||||
"""PATCHing the oneshot keeps the timezone in the response and in the DB."""
|
||||
created = _post_oneshot_aa(
|
||||
client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00"
|
||||
)
|
||||
assert created.status_code == 200
|
||||
aa_id = created.json()["id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/aa/{aa_id}",
|
||||
json={"oneshot": {"uses": 2, "ends_at": "2030-01-01T05:00:00+05:00"}},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
ends_at = response.json()["oneshot"]["ends_at"]
|
||||
parsed = datetime.fromisoformat(ends_at)
|
||||
assert parsed.utcoffset() is not None, f"response has no timezone: {ends_at}"
|
||||
assert parsed == datetime(2030, 1, 1, 0, 0, tzinfo=UTC)
|
||||
|
||||
db_session.expire_all()
|
||||
stored = db_session.exec(select(OneShotAccess)).one()
|
||||
assert stored.ends_at == datetime(2030, 1, 1, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True)
|
||||
def test_oneshot_local_time_uses_the_dst_rule_of_its_own_date(
|
||||
client, auth_headers, system_tz
|
||||
):
|
||||
"""Naive local times must pick the offset in force on that date.
|
||||
|
||||
Without this the host's *current* offset gets applied to every value, so a
|
||||
summer date entered in winter is stored an hour off.
|
||||
"""
|
||||
summer = _post_oneshot_aa(
|
||||
client, auth_headers, "summer os_AA", "2029-07-27T12:00:00"
|
||||
)
|
||||
winter = _post_oneshot_aa(
|
||||
client, auth_headers, "winter os_AA", "2029-12-27T12:00:00"
|
||||
)
|
||||
assert summer.status_code == 200 and winter.status_code == 200
|
||||
|
||||
# CEST is UTC+2, CET is UTC+1.
|
||||
assert datetime.fromisoformat(summer.json()["oneshot"]["ends_at"]) == datetime(
|
||||
2029, 7, 27, 10, 0, tzinfo=UTC
|
||||
)
|
||||
assert datetime.fromisoformat(winter.json()["oneshot"]["ends_at"]) == datetime(
|
||||
2029, 12, 27, 11, 0, tzinfo=UTC
|
||||
)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import HTTPException, status
|
||||
from app.services.auth import (
|
||||
verify_password, get_password_hash, get_user, authenticate_user,
|
||||
create_access_token, get_current_user, auth_is_admin, create_first_user
|
||||
)
|
||||
|
||||
from app.model.models import UserDB
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
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():
|
||||
@@ -84,7 +91,7 @@ def test_create_access_token():
|
||||
|
||||
def test_get_current_user(db_session, admin_user):
|
||||
"""Test getting current user from token."""
|
||||
from app.services.auth import create_access_token, get_current_user
|
||||
from app.services.auth import create_access_token
|
||||
|
||||
# Create token for admin user
|
||||
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)
|
||||
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:
|
||||
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):
|
||||
"""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
|
||||
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."""
|
||||
# Clear any existing users
|
||||
from sqlmodel import select
|
||||
|
||||
db_session.exec(select(UserDB)).all()
|
||||
for user in db_session.exec(select(UserDB)).all():
|
||||
db_session.delete(user)
|
||||
@@ -158,8 +168,7 @@ def test_token_endpoint(client, admin_user):
|
||||
"""Test the token endpoint for login."""
|
||||
# Test successful login
|
||||
response = client.post(
|
||||
"/api/v1/token",
|
||||
data={"username": admin_user.name, "password": "admin123"}
|
||||
"/api/v1/token", data={"username": admin_user.name, "password": "admin123"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -168,15 +177,13 @@ def test_token_endpoint(client, admin_user):
|
||||
|
||||
# Test failed login with wrong password
|
||||
response = client.post(
|
||||
"/api/v1/token",
|
||||
data={"username": admin_user.name, "password": "wrongpassword"}
|
||||
"/api/v1/token", data={"username": admin_user.name, "password": "wrongpassword"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
# Test failed login with non-existent user
|
||||
response = client.post(
|
||||
"/api/v1/token",
|
||||
data={"username": "nonexistent", "password": "password"}
|
||||
"/api/v1/token", data={"username": "nonexistent", "password": "password"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,3 @@
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
def test_get_cards_for_group(client, auth_headers, test_group, test_card):
|
||||
"""Test getting all cards for a group."""
|
||||
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):
|
||||
"""Test that non-admin users cannot perform card operations."""
|
||||
# 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
|
||||
|
||||
# Try to get cards
|
||||
response = client.get(f"/api/v1/cards/{test_group.id}", headers=user_auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_update_card(client, auth_headers, test_group, test_card):
|
||||
"""Test Patching a card entity"""
|
||||
update_data = {
|
||||
"name": "changed_name",
|
||||
"enabled": "False"
|
||||
}
|
||||
update_data = {"name": "changed_name", "enabled": "False"}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/cards/{test_card.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
f"/api/v1/cards/{test_card.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["name"] == "changed_name"
|
||||
assert data["enabled"] == False
|
||||
assert data["enabled"] == False # noqa: E712
|
||||
assert data["group_id"] == test_card.group_id
|
||||
|
||||
|
||||
def test_update_wrong_card(client, auth_headers, test_group, test_card):
|
||||
"""Test Patching a card entity"""
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/cards/9999",
|
||||
json={},
|
||||
headers=auth_headers
|
||||
)
|
||||
response = client.patch("/api/v1/cards/9999", json={}, headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
assert "Card not found" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_update_card_with_wrong_group(client, auth_headers, test_group, test_card):
|
||||
"""Test Patching a card entity with wrong group"""
|
||||
update_data = {
|
||||
"group_id": "9999"
|
||||
}
|
||||
update_data = {"group_id": "9999"}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/cards/{test_card.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
f"/api/v1/cards/{test_card.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "GroupID not found" in response.json()["detail"]
|
||||
assert "GroupID not found" in response.json()["detail"]
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
import pytest
|
||||
from sqlmodel import Session, select
|
||||
from app.services.database import create_db_and_tables, get_session, add_and_refresh
|
||||
from app.model.models import UserDB, GroupDB, Card
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.model.models import UserDB
|
||||
from app.services.database import (
|
||||
add_and_refresh,
|
||||
configure_engine,
|
||||
create_db_and_tables,
|
||||
get_engine,
|
||||
reset_engine,
|
||||
)
|
||||
from app.services.settings import settings
|
||||
|
||||
|
||||
def test_create_db_and_tables():
|
||||
"""Test database and tables creation."""
|
||||
# This is primarily an integration test
|
||||
from sqlalchemy import inspect
|
||||
from app.services.database import engine
|
||||
def test_configure_engine_and_reset(tmp_path, db_session):
|
||||
"""configure_engine() is the single injection point for the whole app.
|
||||
reset_engine() drops the engine again and rebuilds from settings
|
||||
conftest's autouse _installed_engine fixture puts the shared test engine back.
|
||||
"""
|
||||
from app.services import database as database_service
|
||||
|
||||
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()
|
||||
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
|
||||
tables = inspector.get_table_names()
|
||||
assert "userdb" in tables
|
||||
assert "groupdb" in tables
|
||||
assert "card" in tables
|
||||
assert "accessauthorizationdb" in tables
|
||||
assert "timetable" in tables
|
||||
assert "aagrouplink" in tables
|
||||
|
||||
|
||||
def test_get_session(db_session):
|
||||
"""Test database session generator."""
|
||||
# Test that we can get a session
|
||||
assert isinstance(db_session, Session)
|
||||
|
||||
# Test that session works
|
||||
user = UserDB(name="Test_User", passwordhash="hash")
|
||||
db_session.add(user)
|
||||
db_session.commit()
|
||||
|
||||
retrieved_user = db_session.get(UserDB, user.id)
|
||||
assert retrieved_user is not None
|
||||
assert retrieved_user.name == "Test_User"
|
||||
reset_engine()
|
||||
rebuilt = get_engine()
|
||||
assert rebuilt is not injected
|
||||
assert str(rebuilt.url) == str(database_service.settings.sqlalchemy_database_url)
|
||||
|
||||
|
||||
def test_add_and_refresh(db_session):
|
||||
|
||||
+171
-31
@@ -1,61 +1,201 @@
|
||||
import pytest
|
||||
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
|
||||
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")
|
||||
card = Card(
|
||||
key="test-key-123",
|
||||
group_id=group.id,
|
||||
enabled=True,
|
||||
name="test_card",
|
||||
card_serial="00:00:00:00:00:00:00",
|
||||
last_used=datetime.datetime.now()
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
timetable = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.datetime.now().time(),
|
||||
duration=120 # 2 hours
|
||||
oneshot = OneShotAccess(
|
||||
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
)
|
||||
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)
|
||||
aa.timetables = [timetable]
|
||||
aa.oneshot = oneshot
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
|
||||
# Test: access should be granted within time window
|
||||
result = checkAccess("test-key-123", db_session)
|
||||
assert result == True
|
||||
assert checkAccess("test-key-123", db_session)
|
||||
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")
|
||||
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")
|
||||
card = Card(
|
||||
key="test-key-123",
|
||||
group_id=group.id,
|
||||
enabled=True,
|
||||
name="test_card",
|
||||
card_serial="00:00:00:00:00:00:00",
|
||||
last_used=datetime.datetime.now()
|
||||
)
|
||||
db_session.add(card)
|
||||
|
||||
timetable = Timetable(
|
||||
weekday=datetime.datetime.weekday(datetime.date.today()),
|
||||
starttime=datetime.time(1, 0),
|
||||
duration=1 # 2 hours
|
||||
oneshot = OneShotAccess(
|
||||
uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1)
|
||||
)
|
||||
db_session.add(timetable)
|
||||
|
||||
aa = AccessAuthorizationDB(name="Test AA", is_active=True)
|
||||
db_session.add(oneshot)
|
||||
aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=False, type="oneshot")
|
||||
db_session.add(aa)
|
||||
aa.timetables = [timetable]
|
||||
aa.oneshot = oneshot
|
||||
group.accessauths = [aa]
|
||||
|
||||
db_session.commit()
|
||||
result = checkAccess("test-key-123", db_session)
|
||||
assert result == False
|
||||
|
||||
def test_check_access_invalid_card(db_session):
|
||||
# Should raise exception for non-existent card
|
||||
with pytest.raises(Exception):
|
||||
checkAccess("non-existent-key", db_session)
|
||||
assert not checkAccess("test-key-123", 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",
|
||||
last_used=datetime.datetime.now()
|
||||
)
|
||||
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",
|
||||
last_used=datetime.datetime.now()
|
||||
)
|
||||
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",
|
||||
last_used=datetime.datetime.now()
|
||||
)
|
||||
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 +1,3 @@
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
|
||||
def test_create_group(client, auth_headers):
|
||||
"""Test creating a new 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."""
|
||||
# Try to create a group
|
||||
response = client.post(
|
||||
"/api/v1/groups/",
|
||||
json={"name": "test"},
|
||||
headers=user_auth_headers
|
||||
"/api/v1/groups/", json={"name": "test"}, headers=user_auth_headers
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,14 +1,10 @@
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
|
||||
def test_create_user(client, auth_headers):
|
||||
"""Test creating a new user."""
|
||||
user_data = {
|
||||
"name": "newuser",
|
||||
"email": "newuser@example.com",
|
||||
"is_admin": False,
|
||||
"password": "newpassword123"
|
||||
"password": "newpassword123",
|
||||
}
|
||||
|
||||
response = client.post("/api/v1/users/", json=user_data, headers=auth_headers)
|
||||
@@ -27,7 +23,7 @@ def test_create_user_unauthorized(client):
|
||||
user_data = {
|
||||
"name": "unauthorized_user",
|
||||
"email": "unauthorized@example.com",
|
||||
"password": "password123"
|
||||
"password": "password123",
|
||||
}
|
||||
|
||||
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["name"] == regular_user.name
|
||||
|
||||
|
||||
def test_get_current_user(client, auth_headers, admin_user):
|
||||
"""Test getting the special url current"""
|
||||
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["is_admin"] == admin_user.is_admin
|
||||
|
||||
|
||||
def test_get_nonexistent_user(client, auth_headers):
|
||||
"""Test retrieving a non-existent user."""
|
||||
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):
|
||||
"""Test updating a user."""
|
||||
update_data = {
|
||||
"name": "updated_name",
|
||||
"email": "updated@example.com"
|
||||
}
|
||||
update_data = {"name": "updated_name", "email": "updated@example.com"}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/users/{regular_user.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
f"/api/v1/users/{regular_user.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
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):
|
||||
"""Test updating a user's password."""
|
||||
update_data = {
|
||||
"password": "new_password_456"
|
||||
}
|
||||
update_data = {"password": "new_password_456"}
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/users/{regular_user.id}",
|
||||
json=update_data,
|
||||
headers=auth_headers
|
||||
f"/api/v1/users/{regular_user.id}", json=update_data, headers=auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify password can be used for login
|
||||
login_response = client.post(
|
||||
"/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
|
||||
|
||||
@@ -118,7 +107,9 @@ def test_update_user_password(client, auth_headers, regular_user):
|
||||
def test_update_nonexistent_user(client, auth_headers):
|
||||
"""Test updating a non-existent user."""
|
||||
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
|
||||
|
||||
|
||||
@@ -143,9 +134,9 @@ def test_user_operations_by_non_admin(client, user_auth_headers):
|
||||
"""Test that non-admin users cannot perform admin operations."""
|
||||
# Try to create a user
|
||||
response = client.post(
|
||||
"/api/v1/users/",
|
||||
json={"name": "test", "password": "pass"},
|
||||
headers=user_auth_headers
|
||||
"/api/v1/users/",
|
||||
json={"name": "test", "password": "pass"},
|
||||
headers=user_auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@@ -6,6 +6,20 @@ resolution-markers = [
|
||||
"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]]
|
||||
name = "annotated-doc"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "build"
|
||||
version = "1.4.3"
|
||||
@@ -111,6 +134,51 @@ filecache = [
|
||||
{ name = "filelock" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caldav"
|
||||
version = "3.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "calendaring-config" },
|
||||
{ name = "dnspython" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "icalendar-searcher" },
|
||||
{ name = "lxml" },
|
||||
{ name = "niquests" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "recurring-ical-events" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/04/d083c70cbdf6a201fd4a9c6184b3470bda55f14b13979ade80a5909fa623/caldav-3.7.0.tar.gz", hash = "sha256:e121600e858e62c3631b9460167a099685f78f4ffda98b1a96e460c7b252fb45", size = 804207, upload-time = "2026-10-29T21:32:39.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/55/152109585ebb2779e864db486a7d750bcd9af5c23fec82b0af0f778a9b46/caldav-3.7.0-py3-none-any.whl", hash = "sha256:41d052f535e3f3e733b84cc455b2f62145aa70097eb7e345697fbe451b5e3db7", size = 292739, upload-time = "2026-10-29T21:32:37.237Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calendaring-config"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/ce/bd49e6a64ade7fa09d872eea80601281b1b4167b737ceb0de2b16d29b329/calendaring_config-0.5.0.tar.gz", hash = "sha256:ed68ae96a5d9790cdf0ea6a933ddafd617523759a8a3fd273c63a4f3c3dcc41c", size = 37968, upload-time = "2026-10-22T14:44:50.337Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/17/3e4e04e3a344b2260139e64f53d34e95163e02b128b6066753540d5df524/calendaring_config-0.5.0-py3-none-any.whl", hash = "sha256:c5e1f6ffaf428a59b0e1373d951eb8e7fd9f49924912e65548cc2803e73a8646", size = 28571, upload-time = "2026-10-22T14:44:49.195Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cattrs"
|
||||
version = "26.1.0"
|
||||
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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "dulwich"
|
||||
version = "1.1.0"
|
||||
@@ -608,34 +689,54 @@ name = "gatekeeper"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "caldav" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "poetry" },
|
||||
{ name = "pwdlib", extra = ["argon2"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pyscard" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "python-desfire" },
|
||||
{ name = "requests" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "sqlmodel" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "jedi-language-server" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.18.5" },
|
||||
{ name = "caldav", specifier = ">=3.7.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||
{ name = "poetry", specifier = ">=2.3.4" },
|
||||
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.13.1" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.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 = "requests", specifier = ">=2.33.1" },
|
||||
{ name = "setuptools", specifier = ">=82.0.1" },
|
||||
{ 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]]
|
||||
name = "greenlet"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "7.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2b/1bbf82d316df18c3331d9a06228819c8a5814ceda545a3e9980e52ffce1b/icalendar-7.3.0.tar.gz", hash = "sha256:7bd001c8e648205e1bde5c6a5b77096598e8d0893dcf57755c6c597635620132", size = 525811, upload-time = "2026-08-19T15:10:21.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/82/50bff78b0bb0c7d7c0cb39e0ee189b92f611fff6bd7cf57f25e92d5a7551/icalendar-7.3.0-py3-none-any.whl", hash = "sha256:8355acfe17be81b368f0b1e3740817cea9b56ea889931f8f1a87c62f2d28db0b", size = 542540, upload-time = "2026-08-19T15:10:19.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar-searcher"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
{ name = "recurring-ical-events" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6f/42fd52fe3d216d8681ac3b05ddf76ff735ed518a289189558400ca676ee1/icalendar_searcher-1.3.0.tar.gz", hash = "sha256:9cf9b881c54b596bf5867e226fd7044603a124d6bb3cf879171854d39027475e", size = 117981, upload-time = "2026-10-21T10:48:10.537Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/f2/11841ab790fb9cea568159b793275aad3ec2e87712fb15cb3a70e9b772e3/icalendar_searcher-1.3.0-py3-none-any.whl", hash = "sha256:3492a5d3c30705bd5386b05760bb8ec63b855aa6766ff230431b13b4c39f34b6", size = 51179, upload-time = "2026-10-21T10:48:09.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "iniconfig"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "jeepney"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jh2"
|
||||
version = "5.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/fa/a3ba1417800b142d6777c48657f6b01fc46bd1a4777aba979463e36d78c5/jh2-5.0.15.tar.gz", hash = "sha256:53fb377caca50441fd1892f60745c44d3ba8c43844c3792e73c34bf9f841adaf", size = 7336589, upload-time = "2026-09-21T09:26:30.487Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/cd/accbdb5274bc4bee11fc7d8a1b94fd8ffcfd8efbee02765afb6023373f84/jh2-5.0.15-cp311-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:855dea668a30eab826ecdbc07b69b19e8daedeb1f0519e02759a6b37426354c5", size = 631133, upload-time = "2026-09-21T09:23:08.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a7/2b63b9ad01c4d9e7742a528501ab9d57503dbc968b1e40cc0084f9c8dba3/jh2-5.0.15-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd129c07f30387463e634d7aec629da31b041219e17c6d42fc7a7641e1e7ef8b", size = 401835, upload-time = "2026-09-21T09:23:10.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/2a/31f3046c4ca641aa462deb35477e3eb88ad74c52fa0a8003b4c2514d5369/jh2-5.0.15-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f702abcd2e3666d54f721629ec921a1e698357e28f6348af7b4d9d95b89fe37", size = 404159, upload-time = "2026-09-21T09:23:12.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/e3/b13b1fcb66c8668cd642849aa7858b9a33a1df99d96a5dd68115a7b96d58/jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9698673b2cccac7a27dd4ae48d6f4936c998a7962043049783dab4fd03e65084", size = 529332, upload-time = "2026-09-21T09:23:14.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ad/5a903ea3cef3d75751878ce46f2e49f1b32de8fba7a26bebfcaed1d6f8fc/jh2-5.0.15-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9695b0f3c696cb87b851b3f28d5dc81d8932c81a1547713913266b88ee99e37a", size = 521994, upload-time = "2026-09-21T09:23:16.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a3/44195ec39fa7e38b182c3db025b8dd99799cad96bdb50f999f509b16cc10/jh2-5.0.15-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d71f6aa2c97f8042669102200a1a2dc1241e0fb5044cc14032d0321936f85cd0", size = 421130, upload-time = "2026-09-21T09:23:18.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/fa/d80e721e77714ccda44437da86b8424dd03ce07977c8f7f0dc3d6efd805c/jh2-5.0.15-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4ac921ddd2d519646df9b94f96e2cd55529f92f6d7ff4c65508ca2f65399ef", size = 407364, upload-time = "2026-09-21T09:23:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/06/f3ec546c42ce68476b33113d799bce960cc1cd05cfc05e7e352d10a16ec3/jh2-5.0.15-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f0257484827212950bb4624dbfdf70d713dbaed5b1c489151b41f525fea3c1", size = 424773, upload-time = "2026-09-21T09:23:22.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/f8/0939b4cfc3ea9b5c7e372b8c9c8302f2d30ec20d8f92525874135f5395ff/jh2-5.0.15-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:7f1f45e3a7f4b13ec10e9a55d1188fb64f090a24fd847ad124fde4da3682e8ab", size = 578499, upload-time = "2026-09-21T09:23:24.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/82/ac555673b970e536621d27b6864c097f34a186fef24056dae4c4df9ba1c6/jh2-5.0.15-cp311-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:1a7f6ee93c57672f7bd7c3e2c2f841a35c1a0c3571c39abc674e125f344242fa", size = 680124, upload-time = "2026-09-21T09:23:26.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/21/2761e9fad9a436ffdde17ff29c7816c511caca22f1749f342b21ab270f0d/jh2-5.0.15-cp311-abi3-musllinux_1_1_i686.whl", hash = "sha256:7bddf19067d411e900cc19fdfee566cc3bc0aa1b76d07666202a8fcbf2c980e5", size = 641709, upload-time = "2026-09-21T09:23:27.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/fc/2a601d517907fc65ca6958874fed2a6ea0b6300d628ec6afdfed7f636bde/jh2-5.0.15-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:bfd24d34ed13b5943d5d548a86fd0efaa5029aaa6615932e677ea14d1a47324b", size = 610414, upload-time = "2026-09-21T09:23:29.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/85fb291a6c971417c6793c57f7cd76e2f70bef4d9930b5fcf9efbc618002/jh2-5.0.15-cp311-abi3-win32.whl", hash = "sha256:03ef4cea5c924a89d3747fa5eca768763e727326ddf8d2ee20b66f4cd2942161", size = 254216, upload-time = "2026-09-21T09:23:31.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e2/55d6701b78285b8b6d595700b5c7179752941e4212ef873960e5e700a566/jh2-5.0.15-cp311-abi3-win_amd64.whl", hash = "sha256:5a32cd4bb2fc7f03315b3415b0d1f37b204f4334b352e68c25e3e31f0750b7cc", size = 261552, upload-time = "2026-09-21T09:23:32.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bd/f2fc94e23a2eabd613c83b8fddcfd7e1be20c91545cc90762a4deb088fa9/jh2-5.0.15-cp311-abi3-win_arm64.whl", hash = "sha256:4d0b98c5c2f27d7dfcec835960f9e4c76c7936f4bad1c94ab323f8f0831295c6", size = 258453, upload-time = "2026-09-21T09:23:34.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/b7/08fc98d041e3054aa412e8a596994450e2df21c7bf8630d58e44773caf30/jh2-5.0.15-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3d4a44a6e28b1fc653795b5e34bd910ae90022f8bd25320a0e9e70851864a154", size = 634869, upload-time = "2026-09-21T09:23:35.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d8/01da7a605dff616785e941f8b1486efc6134e81b93dedafdeb1bd6a650ff/jh2-5.0.15-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43ae82fbeada5b1a8c9460d83ed2a527e3284b14538f0f9336ebdee137d59ef1", size = 401920, upload-time = "2026-09-21T09:23:37.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/69/7554341fd992b3e1ae64a20a8f3cc07ad6c1371c25656b6650a2c3862f65/jh2-5.0.15-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8c934fc618a663796f104f6f10519e27a45020f4163e4ff9ff3ae2bb9254cf2", size = 406607, upload-time = "2026-09-21T09:23:38.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/98/66bc2b44fe444180fc847633882d9f1d7f69fe48929c2e8f53dbb295830d/jh2-5.0.15-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:249b38e59fa0e5e4c1f9dfb935e30fa2f3a20bbeb547f9725494fc2c62f59d58", size = 528423, upload-time = "2026-09-21T09:23:40.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ef/dcfb83b72a15731e92f1b06ecea85e71052f362433adabecfde158931022/jh2-5.0.15-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c69436326bb70f759695b58f9630628c5e54d3c2c3efda819ae402df30644f28", size = 519933, upload-time = "2026-09-21T09:23:42.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/be/bf04f4bf6f5314760aaf3536d0e7137bad96822a8428e42ec19a9432f90e/jh2-5.0.15-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56746b5542a0181e2717af4ae151f4c51761de9999e227d7022d98593b0d3e8d", size = 423079, upload-time = "2026-09-21T09:23:43.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/8b/0404495d2e201a0a1461be9ed9e33d586a53975e6e383b3144f70bf95de7/jh2-5.0.15-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d9e7387e7d123f42afa3ce6867c71d071a73342c359263bf29b3328e48b7c", size = 407070, upload-time = "2026-09-21T09:23:45.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/41/6d8a16b983aad43d471022ab7ccb98e953e8a8f64c4f20bf54da1fe01fd7/jh2-5.0.15-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:727859bf2579af904b70294a5f8e096776396be8410459c635e87742ba3ac23a", size = 425687, upload-time = "2026-09-21T09:23:46.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/ae/c643cd0cc0260557dc6b1b3c59389b5edb1166e30ed170b757424029a424/jh2-5.0.15-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:778e72cc6a7ea3273ce290c0a71ad907cb3482d70df33e625c76f180754ff2d3", size = 578424, upload-time = "2026-09-21T09:23:48.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/5b/cf84e2563f25faf777beeec1bc4b7b44e353b252a87354951d91d98ceeca/jh2-5.0.15-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:835c5aab444a7bd3694c1941b27e0da85ff7476629812b7d6b91efafcf1cdd28", size = 683048, upload-time = "2026-09-21T09:23:49.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/7a/5edc713b7b86587f33c209cf911f8b9139967310a55f32ab92840fcf3c1e/jh2-5.0.15-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:e33d7d2213b74c860f0ec5731484059e00e729debb3a385f8120f0b92af7a277", size = 642329, upload-time = "2026-09-21T09:23:51.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/72/f1e910a74ddb000f20353f5ba59e3dd5ee3d133f4d45ad4378a9ae55eb7e/jh2-5.0.15-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ba3ad560f3f0b9efbc4f3d8ee0b28fe010ae39918ae46203ff1f9446b33491c0", size = 610518, upload-time = "2026-09-21T09:23:53.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/c4/92a57264dc432556c5fb90b75d7d3202bc2128ad6d5f14aebf18fdb72c96/jh2-5.0.15-cp313-cp313t-win32.whl", hash = "sha256:faa0733a4c2dd0b38200d665bc2f33f9dc33d0fcb353e00744da555570816527", size = 255378, upload-time = "2026-09-21T09:23:54.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/79/1cd633016cbdeb12c4743e8d1d54bef2f669f5c5514ab6b483e3351901aa/jh2-5.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:4fbc6e3784e778a834a6201735c3b71505393a471c336e9f518c07ecbf65f07d", size = 263494, upload-time = "2026-09-21T09:23:56.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/fa/fd28891b1a2eaf3291738381683aa1971479e366a0ecbcadd41a21c3bce4/jh2-5.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:5364883ba707e689167e37e795f2e5b04cb69379cd716cdef00c6137437679b8", size = 260108, upload-time = "2026-09-21T09:23:57.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ac/594165f15b860cca56264eda0a174e01d1ae857b954ea748cd378f0a4db4/jh2-5.0.15-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:136f1f06d644e105ace8e830a55c66bb6be15355af427c013cefabbb4360889c", size = 634614, upload-time = "2026-09-21T09:23:59.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ea/519be71bc4188ee72becdf4c0dbd1c30db5d0d4d9121d3bdcba9ebd9f948/jh2-5.0.15-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7aae38ee0dea5f3af2a918597df28f7659d1e82d1795605ee46c5d291d7e8ff", size = 401257, upload-time = "2026-09-21T09:24:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f4/c27efd5a931f172fbdf69af1a75abd0433798cef646eddd5f47673f04774/jh2-5.0.15-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34f864522d4fafd8a61bf40dc01e2a93840e6c1a17dba37db008529be22ef40a", size = 405697, upload-time = "2026-09-21T09:24:02.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1a/ae7cf874b48babe926fc1b3b861892e291bee7b59387a921dfcacc88f146/jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:783463574dcac3a6d8ae948e2110bb1b46099c79b919912c88360d21b7c91b5c", size = 527107, upload-time = "2026-09-21T09:24:04.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/50/e2dafa5435a02265eabe747b58c4addcc917b0f8b76fbabf2a0a1627f697/jh2-5.0.15-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d6069850eb0febdcdd649a4f5dbe74560ea6219ddce467808b4b4bf532c14e3", size = 519280, upload-time = "2026-09-21T09:24:06.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/95/ef4cc29497ddd194c681af013dc47875c807bb20dbf66d8235c4261b1adb/jh2-5.0.15-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6af18cc6b061b5830b121fff1cc876e864e881909c446fd606903e64a9796567", size = 422291, upload-time = "2026-09-21T09:24:07.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c5/67eb11e17093e2ac5c5efe118a48ce0ab94eac8a66213721406c67a1517c/jh2-5.0.15-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aaf2dd05d85289cf83641f3c4f54947a0168fa81c46fc9038ea9d241caad50e", size = 406956, upload-time = "2026-09-21T09:24:09.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/6456d56f796304fb3ead01125b9eff8301e03c8277f505b5e34cc8a50b08/jh2-5.0.15-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bad01872ec803e67d91228fe5f040175c86bbdb0b345be4ce0c5ccd620a220a0", size = 425105, upload-time = "2026-09-21T09:24:11.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/e2/9cb3bc8a54a646d837fef8427728417d252aff3c470725acdc41a89394a9/jh2-5.0.15-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:c201b89f9d15f563fe4d6dad0738ce02641636c13b141096ad7497aa7bd95b3c", size = 577255, upload-time = "2026-09-21T09:24:12.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/c7/8a71b2f0bd38b94208c34396108310a42d71b214a8c6a960771a79804cc0/jh2-5.0.15-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:b43d69d715f77d840d5600842a179d8175625bcb5f5d8dbe9bc7ed71dcb43696", size = 681961, upload-time = "2026-09-21T09:24:14.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/87/727968778eebb3e328c37c24435c7cca91443ace85a83da964ad35787017/jh2-5.0.15-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:4a511057fb3d8bc6cb76eed0645a8a446e870640d9250144f0d403007383e2c3", size = 641875, upload-time = "2026-09-21T09:24:16.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/d2/422129114ce5a243a33d27b93a9c67893400ef648c1e5b0b7472ca1de516/jh2-5.0.15-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c4a2c16fae658a2f7e67d1af3f1cfaf03003cb2f6d9a8c1d3f237b337614d568", size = 609990, upload-time = "2026-09-21T09:24:17.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/13/ad4e1ea235fa5f79ffafb9ff7992758c4400cadf8931df63069ddb7162d5/jh2-5.0.15-cp314-cp314t-win32.whl", hash = "sha256:36aeaa24c9af2a2ac0830523721d0fadad5602842d662aa0c5391b89ea3e7d1e", size = 255313, upload-time = "2026-09-21T09:24:19.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f9/d5642a254e2f6526c4ed09354d495451f4db2840e69a16afbecc2654620c/jh2-5.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:a88320003a21783b5ab5c605ba4b687f836b46dadedc55cfc3e64ca88c41b81d", size = 262881, upload-time = "2026-09-21T09:24:20.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/a3/663a4a10b42e2603b14fe7655ceaab716afec6cbf45584dbac4b409c0188/jh2-5.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:7eb050ada76d078cf27b7660451c0ee7b38e3f770222d852cc34d2f4cf0bc782", size = 259576, upload-time = "2026-09-21T09:24:22.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9e/a9a50a5176a720d9ea6752bfa36b4ee9655b26ff9f4870bd71cabb1ec63d/jh2-5.0.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:06cd5acc4ff28ac27cca5c2f0985d259e81bc29b845c521aae04440a369a00ae", size = 647460, upload-time = "2026-09-21T09:24:24.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/3e/df15495c118ae1749c74df00c4355dab7c44cd5859f3bd648573e1df2581/jh2-5.0.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8912f22c97cdcb00edce434b61fa1d05af41ad07deb6bb1ed9e06348df11eea", size = 409994, upload-time = "2026-09-21T09:24:25.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/4f/3e1ce6fb6728de964ff87ced5574988bea1ae7306f087cad5cef17466bda/jh2-5.0.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d28771bccaa6944c4e606a44b1035ea11b2d684bd2c7c195ee3c3a9afd84443f", size = 414812, upload-time = "2026-09-21T09:24:27.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/21/988a70b062dfc1a78f210ebc17a98fce8592b4245b6c3d81cd9b2d865f02/jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1e0fda6e8a7b7364077a03749321f3bb9f01aa369ab659f63d241860f4b66bbb", size = 536476, upload-time = "2026-09-21T09:24:29.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/83/81cd4dcd7b24d010e525f25c6be71ffa019138585da62a8edc0753d2d0c8/jh2-5.0.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6596a9d05f049f783326e927b5363ebe0b1da6716835237ae32b642529b405ac", size = 527785, upload-time = "2026-09-21T09:24:31.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/f2/e6bbd8667dd3516764026fef86367c9c6f79a8b4611b53324d7088dd99e1/jh2-5.0.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29a0014ee53ffc8bf0581dd77270e25329397c55f4724bb18fddef9a105177e5", size = 429758, upload-time = "2026-09-21T09:24:33.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0b/3118b778d2cbc2eccac1ee6007d47fb0030398f4a6df3425656b1a624347/jh2-5.0.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cd7e4d259eab50e076e4b30ecbd937e55b319a58ad75642bdffb401455520b7", size = 416188, upload-time = "2026-09-21T09:24:35.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/cf/67c69906bae910700fc02acd0865a4ac3d247a50adc5e6ebc070cbfcc223/jh2-5.0.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97299e27743ebf52d9544e596165700407b8b46394870dc374d6fd49c3251084", size = 434214, upload-time = "2026-09-21T09:24:36.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/15/ba3848b2ef78203f6d1fc570e41067fbd2494950dc1a94c580d10c3e6380/jh2-5.0.15-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:95443c602d6e7fa8aa5e49d19381cb5a39d53b79a1678e90a4c39f4f0f0542e5", size = 587932, upload-time = "2026-09-21T09:24:38.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b7/dd90d99d86381f4df44bf69ddc9a8b5e68c27ad795587b563fdcb9306d9f/jh2-5.0.15-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:13fa1a5e98a8c8f55405e2e7c6f6a1e005d01a48f7510826ff34939f624b1ad6", size = 691155, upload-time = "2026-09-21T09:24:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/cf/8d771ac927d49cc279841a592f4ae4bb2d962dcf9ca6952973e0dfc4bef2/jh2-5.0.15-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:aa99840f9222928b0c9c0dd2b0c0308f5d1be8f6815bbf6f820746c51de54dd7", size = 651868, upload-time = "2026-09-21T09:24:41.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ed/0c7328a574efe79203af639e37072f25cdd70386a3d1ddd31dc44d92259e/jh2-5.0.15-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:723a0a4c5ac0e61c94cb275118c7a6a6aaf5fd56084b02632f4b080cc83c6691", size = 618661, upload-time = "2026-09-21T09:24:42.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/2d/10348c52fc0660729ad4fb5285c2546e2c879b326a6ff332764bd837b468/jh2-5.0.15-cp37-abi3-win32.whl", hash = "sha256:fa1c3885ed090c65054e4bbd983a407df0436121b1a841ba7fe1f678a7982c65", size = 261264, upload-time = "2026-09-21T09:24:44.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/94/d702bcbf81730301bbac04c93e82e533bc59dcdd608b9ffd0e99717518be/jh2-5.0.15-cp37-abi3-win_amd64.whl", hash = "sha256:8c5850335fe11078b42fab606e5ac4a68c6ee8c6799864badc464f7839f06ec2", size = 269034, upload-time = "2026-09-21T09:24:46.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/1d/c7cb221314fbe3c119a8c8e463a38bd70c3d9bfb2fa09f107399843dd353/jh2-5.0.15-cp37-abi3-win_arm64.whl", hash = "sha256:9493d5a20f6ba04dc285e3580bcffe64c0d61660c85aee7fc19e9adbaea55ef7", size = 266111, upload-time = "2026-09-21T09:24:48.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/9f/f94e7a18e880824bef49a1f80b4ce081570a3c19e8a3937ad2fea0544651/jh2-5.0.15-py3-none-any.whl", hash = "sha256:0ed7246846e68109fef261fdb00534ca5af3363d48dbf881d06a2d0e3a814e99", size = 99140, upload-time = "2026-09-21T09:26:28.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markdown-it-py"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "niquests"
|
||||
version = "3.21.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "urllib3-future" },
|
||||
{ name = "wassima", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/2a/e368ec88a02c6bfc56c031ba6da9dd8099ad998fb818c28b687bee74c91a/niquests-3.21.2.tar.gz", hash = "sha256:586d6b9475c9018a42f19f1e4688077b8158101cbf40673c63d2c1a096cc7665", size = 1094494, upload-time = "2026-09-23T06:23:46.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/51/eb07449eb9e7ecae4350b5a40bfcb002d6d26d58cf2cfd2f928ae7c2f559/niquests-3.21.2-py3-none-any.whl", hash = "sha256:73e52614a712ee2f4d410bdd29789ca6a4ed031f8961410156e395d42b091208", size = 229060, upload-time = "2026-09-23T06:23:44.056Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.1"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pbs-installer"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygments"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-desfire"
|
||||
version = "0.1.6"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qh3"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/c3/777d963d852a19c1777eb74ab543d00d8b95592a849174d9a4f3b1c3f51a/qh3-2.1.0.tar.gz", hash = "sha256:622e3c63f2417039785f61d6af2cb26d0f0e5935ac2b84ef85a2bf76862fe6f3", size = 416198, upload-time = "2026-10-25T18:34:36.611Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/15/e463e025469339d2959357430a4bc38edd6d564919077762b6bf0549c7b1/qh3-2.1.0-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:1393780b16de50f4f43221bcd5eb850ecaacbcd026494f896048651f72d29303", size = 4689485, upload-time = "2026-10-25T18:30:53.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/9b/1d69545cb12858fb4b2d16a43aba0e30c2ec75335269bdeadcf5f05d310d/qh3-2.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0b5b980799ea1a859b9ec5de1070afc1076937f953139b38f64bae3dd449aed", size = 2292143, upload-time = "2026-10-25T18:30:56.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/dc/553b308b42ab7afa4e0c0f5443eb574cb546ed4b25bd4cd2225beb0a5edf/qh3-2.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9cd8b52cbbff5c3e671956eadd8251bed35a54d8d6404a532008636b2518cdee", size = 2023144, upload-time = "2026-10-25T18:30:58.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/39/1fb49e744cda75081edb821b5fa2c849ba6880555b930b88da755cd811fd/qh3-2.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cdf8de2c41a0d2f7164434c77f0559da055a709c51825ce2cc47d3b552982f", size = 2207365, upload-time = "2026-10-25T18:31:00.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f4/eaf89fddaf5d22d2bae583704055d5cdea69f5d2ebca79c718cae578ca38/qh3-2.1.0-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a52c076b41ac91cc7d55aefcc8136bea39bcaa5c9d77adb7b23c9274410e8c68", size = 2202629, upload-time = "2026-10-25T18:31:01.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/87/8f5b5db604ecea59d5a490c93dc11aec831a5d809fcc873baedccfb428b1/qh3-2.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b84f5cc0da6e526b2c4ea57b7c5353d3084046c2eeb34c78014347b5dedce4de", size = 2221082, upload-time = "2026-10-25T18:31:03.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/38/9b2ec85ba6778eaba4128dc1a11805e3a70de582fa8c32b5c82f8674592c/qh3-2.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34cdba8a19c7b1e38483bb24442a40608a84b91723be15b25edc6107a180c23d", size = 2255718, upload-time = "2026-10-25T18:31:05.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/5d/4b7059625ae90d4deab530f8a31ed343a7749023eb8d0ede201e07214b97/qh3-2.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb0ae31f0ae168b2d9d18fed0b42b8ef0a7fa6ea3ff5b41addafefebd77e7f1f", size = 2520917, upload-time = "2026-10-25T18:31:07.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8a/8658f6d5fc5ff1285ceb856fa0b72bb6ef17d8c6b68ac7785fa988a89f34/qh3-2.1.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:700391db7767c52833b60500f9d03b8e929a654534a6721b998250e4ebb7f638", size = 2106012, upload-time = "2026-10-25T18:31:08.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/3d/b712bbbf9c29aa4f688c51825e6f2d751f9b81934404f5e72f3a9616a7d9/qh3-2.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:72ed04a190f1b99cc10ebf360d6d6d0ea967f343566f49341b56899f39c494c0", size = 2488815, upload-time = "2026-10-25T18:31:10.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/fe/2d75fde70de88f7a58fbbcfd67075dc8d3dad273834d270affb19049443a/qh3-2.1.0-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:b9c9f4602d138c89696ccbf570be119c294727864d66b91554bce5e28e0a054a", size = 2284543, upload-time = "2026-10-25T18:31:12.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/71/b82e2626adc445b1a9df122fa05126bd7480a3881b2df0fc44fb9b9a7f1d/qh3-2.1.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:d81103c015c27b1fbf7ba768e922ff8176eaa6461a00c6c99f166295db030987", size = 2397166, upload-time = "2026-10-25T18:31:13.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/04/82dd427c2f4648a0c855dae60271352653b35dd627ad6db60c6bd31bd73a/qh3-2.1.0-cp313-cp313t-musllinux_1_1_riscv64.whl", hash = "sha256:8cbadc13df9e529f18ed507c0a0d32a424c3247e5b4895377872aade8d1d880b", size = 2210993, upload-time = "2026-10-25T18:31:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/8d/1bd332607c3f46bac06b1294b887c8fe40cc9ad9c5bab4c68187775f0822/qh3-2.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d88639721d5b0bfd90bb440242f570be778e2841799a68bfc29ed725b324a28e", size = 2744521, upload-time = "2026-10-25T18:31:17.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/14/d2226d640ab86d9b20798cd9409fd43e07a279faed84dff63d4a45c43318/qh3-2.1.0-cp313-cp313t-win32.whl", hash = "sha256:f116c53985220f1a06f84b317cbd2e66024d16bfd41dcf168df6890e4198b1f3", size = 2025197, upload-time = "2026-10-25T18:31:18.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/65b436400a3dda2e33817d24703792e93e348ddd8971714f1e99614d0c9d/qh3-2.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:df3d5c5dcc590b642f1c43e5987cd9ae0886bd2cd214e7908dc62e79c4deafd8", size = 2291651, upload-time = "2026-10-25T18:31:20.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/97/f4c9bb99bcad8b14cc22ab40f3994f9ab60e547f2663735a0fa114b58711/qh3-2.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:135e7f722b7523ea22d36b7eab9873d19f25c3d3f585d24ded6673bd31ebe593", size = 2104967, upload-time = "2026-10-25T18:31:22.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/92/d264ccf9fe2a54d0dea3cb5e621e3bbab8c78cacf413d0bfd0eaf964468e/qh3-2.1.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ba62fe5a0a8394e3cff6b1e205d4cdb21f18d9191193d963fc887a1f7339d3cf", size = 4690342, upload-time = "2026-10-25T18:31:23.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/59/28d92e577656b5abe63a99a058c8ecc4a238f09ab4d7e3588c2b6d315d78/qh3-2.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:962cdfab72c26b5fa9549c5bd1fe645ca437a449ee2c1528aff226e3264452ff", size = 2292764, upload-time = "2026-10-25T18:31:25.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/c64a50433891f34b28cd5bf8d171ee50f88a436861c3309904df3eb384da/qh3-2.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ec9b4cc8796a7a158f05667200fbc9906ccf8ab6804bf007c61b17901035ebe", size = 2022524, upload-time = "2026-10-25T18:31:27.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/13/eb6cec78c284cb87337d4cbac4b995c1defad2217efe8608f9de2b0a5ee0/qh3-2.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65b2cb6aba980a5821223969d15452e102e01f1b656485b401c40dd0d4d93c8c", size = 2206837, upload-time = "2026-10-25T18:31:29.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/63/83c16002bef8b09fa4162e388b8ff6199ffb84ad7f09fdc8a1a2164a2edc/qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82505dbb6e5a5aa814fac47789b185b21020692c37ccfd96d428d32c9822ee5d", size = 2202427, upload-time = "2026-10-25T18:31:30.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/9f/27291cb15e4d664ec7359db5988c1af6358d0f428ed189a9a80289b5f45e/qh3-2.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5d68107e1c2bb6538bfeddd0238cfd309396acb51e0d3bd0fd7ae87390219b6", size = 2221164, upload-time = "2026-10-25T18:31:32.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/a6/d52444f7e286cb33da29688d6356dcff638cd238f18555f65d9918ee4c0b/qh3-2.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e856c5c9393b1fb4c9e7458165ad2560ec8b0c14e6092c4e8f456dcba8ce3f1f", size = 2255435, upload-time = "2026-10-25T18:31:34.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/45/ead5d22957caab56e12e12a2f45ca04fa4710d890e1557ca847e9c96d933/qh3-2.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f647ef9f76bd289a28baab858dcfd34efd2255a7da274a9e8f45a020be36b795", size = 2520651, upload-time = "2026-10-25T18:31:35.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/f3/684f31bc55b46dbc79ef0ee2461cce34d2967e62f2b3b108fab4db6c71ba/qh3-2.1.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:86ebca1a4d4874c619ef7d3eee4d699968b236e4292bb4cbd498c7054cdc5319", size = 2106061, upload-time = "2026-10-25T18:31:37.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6a/656bf639c92dcc9ff158434a798e845ce479cdf2372461e66ee6a67c40f8/qh3-2.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:4c21a3133460449abd93a61cc675dbde893f1094f18bc6f286d44d3d3ae5d462", size = 2488422, upload-time = "2026-10-25T18:31:39.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/95/727d54837a2cde950d4480d50943d1c8935133f0d72699e5d77a975e74c5/qh3-2.1.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0b3d1ddf862bb9ffde56d4ef4477a0bb86fe60983d6b03b7e8869aa026c4c3d4", size = 2284266, upload-time = "2026-10-25T18:31:41.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/87/bdc71bdca9b9ad7b8fd034c7e7ae8683c87ceed76f7646664bc5d0a7d4fb/qh3-2.1.0-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:b812694d3884bf8f92ba05b02889f41ea519821fc98c2634010e5d595ec2c330", size = 2396753, upload-time = "2026-10-25T18:31:42.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/69/799473c877c003cbb3455198d43168eaebabfc5c14d492530fe748c93acb/qh3-2.1.0-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:a80118168ebf68413b089930fcef0d6c7ba2907d61273a4fcd72414322281d75", size = 2210620, upload-time = "2026-10-25T18:31:44.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/66/9145d587aca2da40cb57118c810d878238169758b3b5f0b9a2321dda7fba/qh3-2.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a6013c83b0988544ca084feea6c91b4dceb9b9a835a4c2eec0407faf820ba4d2", size = 2744036, upload-time = "2026-10-25T18:31:45.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/c6/465bbceca908f614039b0ecf662b62d95b72ce366108bd4d838a9ae2938f/qh3-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:358f66b86c6ff4ac515aaf8c6848bf0e61939b075f8fa455b977d0aa3e7e6860", size = 2025220, upload-time = "2026-10-25T18:31:47.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/55/02c0f01f9048c79ba0782b658d91f92cbd0fbfe25dccd73f4b09e695c1a3/qh3-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1bd9d323d3a059d79c3586c00f973d64ba803234218d47ca2e8a364ec885b9dd", size = 2291442, upload-time = "2026-10-25T18:31:49.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/5a/4189ea71debe60246ac6b212ddc316c73ba4e44f56002538915f3e3cd4f2/qh3-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7644fc608c6d28012f567c5ce060b281c5a014ff60ddf4167ba9b6507ef3e576", size = 2104825, upload-time = "2026-10-25T18:31:50.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/01/b6f10b2a365b68526b68e44a864981c1d50df16eb92d9b304340aeb92998/qh3-2.1.0-cp315-cp315t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a07c074dcb9d58b41d1d73207357bd70e2617ed9816f36bbc5824eeef4465c1b", size = 4680306, upload-time = "2026-10-25T18:31:52.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/32/81b9a3e61c6ad8badf4a5ebc0fb7e7b19db95cf964a1a58ee29d1b6700fc/qh3-2.1.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6dd04f2718a3412bdebd580e35ff465128ba3d6dfa967c0ce4186f60a2df060b", size = 2286357, upload-time = "2026-10-25T18:31:54.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/91/74120b53de2ef4f5b13462c0e255b2e85834a4e59d4997b6550cf283af87/qh3-2.1.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b2304539fee9c70d385bd834402ae3fbfff58fcb2793adf7fd44bd53cc983be0", size = 2018498, upload-time = "2026-10-25T18:31:55.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/41/fc04957e2080c056c56687a0799162376e25b7be4a5f615f95e9f9cdf805/qh3-2.1.0-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:451ae203b2670e256333170afc2ccc558fa15de172412ec02c2ea3e03854645f", size = 2203739, upload-time = "2026-10-25T18:31:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/9c/aaa5d1a1484a66ec7a0117c8af575da7059106d4da7a63ceeb9ed4525d06/qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:129ff9821720341c847dffe99f8fe599914f583cfe0c725061ecfd5d578ee2b8", size = 2195664, upload-time = "2026-10-25T18:31:58.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/92/fd0860779e5bddd01898a6acb5ac290bcf09c9cd48d82c49cdf9408132f8/qh3-2.1.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a648d7ef66894113a23b0b197b9c164e8d6cd6a1175e10c1ba5bfc1d5f02eab1", size = 2218418, upload-time = "2026-10-25T18:32:00.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/6d/2e7d6136a59cf1f0dec5e32b89e23da480e538159027e1708f251c25b41e/qh3-2.1.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:833a93ff543f4e98b71b497841c5c3ac4c7f255d56f544f5223ffd413dca2efb", size = 2251483, upload-time = "2026-10-25T18:32:02.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/05/4f131be92d11407586fd916eba3dfcf6a7e48bd408129cec0db89692829a/qh3-2.1.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e649419464428547e314c8048e5257d9a8cd09e15643405cc9fbe177e10cad37", size = 2517617, upload-time = "2026-10-25T18:32:03.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/dc/18d5ac783c9dbb330b393139975381726cbc5c4e2f6f6012e81786bb5022/qh3-2.1.0-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1d6e9364a537785755b5b2724c330ed59fbae8bc29df28a1a74cb83ef1defd4c", size = 2100897, upload-time = "2026-10-25T18:32:05.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a9/bef0c2408c3c11933aba0f53f3c1ed6244ad9a9b890fb7484201bd2c8253/qh3-2.1.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:4c3fcb8ec0da9ad2a349a2a0b96902e0432830e6740163863911d1ea88439fad", size = 2482641, upload-time = "2026-10-25T18:32:06.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f4/22b90ebf44bb427822498c21f76679ac97f835f946b60c610cf0fdd8bb0a/qh3-2.1.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d15f365e295d1926bb283324c72dfb2568cdb145a2d521495b562c765a9e3507", size = 2278962, upload-time = "2026-10-25T18:32:08.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/74/2e691ff9a2e4a987bf9980ccc4196ae54e9f4603a0f5b70b6ad904bccf02/qh3-2.1.0-cp315-cp315t-musllinux_1_1_i686.whl", hash = "sha256:ba8598d3ee4c72325d099714920f23c35844a0e2f0bf20dd56b51d61545bc097", size = 2392700, upload-time = "2026-10-25T18:32:10.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/f9/b626778b70d08fe5b9b8569a98ae3485a75854896a3a7ee1ce505e934c4b/qh3-2.1.0-cp315-cp315t-musllinux_1_1_riscv64.whl", hash = "sha256:67e6377dad202507d50e4af854a6a464975ed310f4ecc272f83cda73811e9b71", size = 2204995, upload-time = "2026-10-25T18:32:11.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f0/18d65ae7ae4655a11d8ff59d023259f2b8dc20f10194c6d5fce900b11b65/qh3-2.1.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:9915fd34cf3010f186c783a492c9f3ec31c0e5cad8c5e16a6fe08d2dda1defee", size = 2740263, upload-time = "2026-10-25T18:32:14.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/76/182804ba95ef16f1e17e788eb60547c8a2fc50c24734ef02e6fc2dac9797/qh3-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:71ffe60e5a8dc67b7ecd3b2ba6a105fe4cda8e770d6fae2ff6445b557d0b4d38", size = 2023027, upload-time = "2026-10-25T18:32:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/c7/d778b06705e274f0dbf152b0cffd371a459217aa254f7f7450bec58d9ee8/qh3-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8a1a95c687bd2d26e7cb55dc14095b95ab55f1afcd8adfe1c885fa7818582ad7", size = 2287300, upload-time = "2026-10-25T18:32:17.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/af/d0518e0c21afaacf104f8849c048d192b4da3bf4c3b728f3a7539a70a4b0/qh3-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2eefd993b952bb2bff34396b7abf4cffa46f25667cba85de0dff6e083d1e83ac", size = 2098935, upload-time = "2026-10-25T18:32:20.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/db/28b5f12044af0a284b91938ae0f567b3625f13c6b316be9e360de023b735/qh3-2.1.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:94412f03ec7b2d27e9f238404de0ab0a293ef4eb172ce1e6f6f6fd11ab857e58", size = 4702601, upload-time = "2026-10-25T18:32:21.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/ab/1b9bd5f83fe3b1de0600853b90b7eec8cb338f57a91c742c02cc18618635/qh3-2.1.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b66c2ff3d8c5bad514f7f9362e0c9e3e4664209db5c0167278f1d90918bbd12b", size = 2299659, upload-time = "2026-10-25T18:32:23.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b8/7b1bbed622b40c55fca01929d948e83b6a446ff491fee65fad24a9ef5b8a/qh3-2.1.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8982eae1cd88e5d7700802b82f29a7cb834d453bb557827010199a037d4062f9", size = 2027280, upload-time = "2026-10-25T18:32:24.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/bf8963ec468dc5ce793966e8d6f48b47fa85e3e8be29959a34ebb5259acf/qh3-2.1.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fbdfefe22dcc6222b853be72f062fc7e84d6c333294310502bf0090e20ac0cf3", size = 2212422, upload-time = "2026-10-25T18:32:26.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/b2/1cfdf7f7f3d3368c6a0c194b6e4eaca0e56d121beb4cd14ad51946282dab/qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:341793106cb0f803985031690bc2e4e184bbeecbaed63d902921c7dfd07f1808", size = 2210619, upload-time = "2026-10-25T18:32:27.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4d/fbe50cff7df4b081133f3947351005444dec1027189591c60b5e5aa1ce66/qh3-2.1.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b205fd9b5fffcf467331b82ae1f4e360581bd60886d37adf626197e75e0e0bc", size = 2229445, upload-time = "2026-10-25T18:32:29.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a1/308e91843b74e67bee948f65da454b4391c26e6c7bdc34827cd5690a91d6/qh3-2.1.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27bd2e3a03985be5e6e6a04faa1f53106c75ad607f662d54b57585a0e52121c1", size = 2264523, upload-time = "2026-10-25T18:32:30.998Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/46/2c509a0237bc1f2469a53ae5c4dfa68b924baccaed37d99cbbb33542517e/qh3-2.1.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1808e123760e78482948b8e6bf5547a8a91637573b89caf165ffe948850d2ba5", size = 2524346, upload-time = "2026-10-25T18:32:32.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fc/afee6c2b6d76f5c21df9a9f2ed4a3abfb3bd6449820dc6823e3aac32cca2/qh3-2.1.0-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:343780d1f7e8000a6e06458e6940d0a80e29cc2be7e47b60f24f714dd17da690", size = 2109918, upload-time = "2026-10-25T18:32:34.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/21/aa9725997d3d321b4fa31b21ba211eb25781131a59f48a4960446ba4fb90/qh3-2.1.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3cb6649dee2f4d3dba008dc9d0e15a75fdb0c16b864535725f2266adc55a54e9", size = 2496917, upload-time = "2026-10-25T18:32:35.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ee/33f719e6f37b4b7d45b11bbbb7a91be8be9d3dd5a632bbf22743e2c956e3/qh3-2.1.0-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:23ff27b10d88fae56fd3fb530bd93a19dcce7b3ee921b9cebc4d75767b2b71a1", size = 2286839, upload-time = "2026-10-25T18:32:37.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/7c/fc0ee6da0ff2f3a96ba5afab913166c39860d1f04279f0639fe4a8ee912d/qh3-2.1.0-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:a51ef02b036b2a06293ce87d48105a516a31694a77746062d16016a5aca6a013", size = 2400938, upload-time = "2026-10-25T18:32:38.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/44/bb8511574c859f4f831728ae5251bc8d155bfc8f061aa4c736b5e60dbf59/qh3-2.1.0-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:81a03727a5ddf001fdb57251a86b950ffea47c7a3473641e2707d647d941b398", size = 2219264, upload-time = "2026-10-25T18:32:40.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9e/0e78319e7be595e32ab815cfcf4cac73c453f69c5a901c457e991d9fa481/qh3-2.1.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b7e7300dc9352f7d31b4204e6bad6c0a310a12c4b40e36a0ff347f64c8d793f", size = 2748521, upload-time = "2026-10-25T18:32:42.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/66/5df8eb3e6593dbc3dacd0ce54eccf64f258298c8671e922ab25a5b07a6a2/qh3-2.1.0-cp37-abi3-win32.whl", hash = "sha256:19d1b93d669177215675bd21a303763d2222bc9ba1c9c3850e1e9a5bdfe868aa", size = 2033904, upload-time = "2026-10-25T18:32:43.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/19/8e4e9ac60b5e3f83a4f702b0094533de8c86d8d6baa77cb3ba6f7ef4d8a9/qh3-2.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:85be2ef6a536a687d12c018f4605a5517b151d02ea7d42a3d9f4386985c735a5", size = 2299731, upload-time = "2026-10-25T18:32:45.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/d2/922f63131bf1f72848198d0ded9dde9dbd7245a59c14c18dda07ba4d153f/qh3-2.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:0c257cef34cf990da943ffbf12c4ee2ab44d131f4b4024383edae84d19428056", size = 2117482, upload-time = "2026-10-25T18:32:47.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.14.5"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recurring-ical-events"
|
||||
version = "3.8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
{ name = "x-wr-timezone" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/f5/898abd8fbb25766ec6cb17b244730ebf192f47963c71eeacd61aadd903a1/recurring_ical_events-3.8.2.tar.gz", hash = "sha256:e731af31d0b7dec5cd47a1defacd8549e2f36fab1c1995e8b9f042822a0acf8e", size = 604992, upload-time = "2026-04-30T19:21:46.2Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/f7/d3eb4f545baf5d6daaa5881694517626fb7ad04036d94168197a4f021384/recurring_ical_events-3.8.2-py3-none-any.whl", hash = "sha256:9ad605e27b4fbeb70ee1c66205ade32550be15a57cedc579606522f1c67aef6d", size = 241358, upload-time = "2026-04-30T19:21:44.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "secretstorage"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.49"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3-future"
|
||||
version = "2.25.905"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "jh2" },
|
||||
{ name = "qh3", marker = "(platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/28/765b30ba80b434803d287f873453463d9eef9d5cb4e28004196460166976/urllib3_future-2.25.905.tar.gz", hash = "sha256:d5bd25dad01d7358b33f4c7b3f225506231158086ff790028c1de5b38c26e89e", size = 1704064, upload-time = "2026-10-28T13:20:47.275Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/78/e4f6952a009df5f65b3af31b6221d2d660e0a705245676b5fc73508d88ea/urllib3_future-2.25.905-py3-none-any.whl", hash = "sha256:c8e3629be86cf3c074769a23beb2b99993cc3454d2049bd3c0643952f6bfbafb", size = 890513, upload-time = "2026-10-28T13:20:45.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.44.0"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wassima"
|
||||
version = "2.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/8e/9167e3e39e921833d35119849422cfbbba68c90ad0723c2a9c7965303195/wassima-2.1.5.tar.gz", hash = "sha256:b02c1985e527c9539396cc85b909857d810dafaaa318a30266e585a0e7135f54", size = 141994, upload-time = "2026-10-22T07:24:05.966Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0e/9bc13dc706f86752a6afe36228f452be797c50183bddba513efad60a0272/wassima-2.1.5-py3-none-any.whl", hash = "sha256:53d9a56cf55fc969f8cdd1993f8dc69f857d1c32229fb9084dc2b267044b6dde", size = 132848, upload-time = "2026-10-22T07:24:04.368Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x-wr-timezone"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212, upload-time = "2025-02-06T17:10:40.913Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102, upload-time = "2025-02-06T17:10:39.192Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.3.0"
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "zstandard"
|
||||
version = "0.25.0"
|
||||
|
||||
Reference in New Issue
Block a user