From 1cfe331133b1c65043681f24312cc6cda24b0987 Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Nov 2026 19:52:35 +0100 Subject: [PATCH 01/15] caldav: init --- app/services/caldav.py | 119 ++++++++++++++++++++++++++++++ test/test_services/test_caldav.py | 41 ++++++++++ 2 files changed, 160 insertions(+) create mode 100644 app/services/caldav.py create mode 100644 test/test_services/test_caldav.py diff --git a/app/services/caldav.py b/app/services/caldav.py new file mode 100644 index 0000000..a3d3ba1 --- /dev/null +++ b/app/services/caldav.py @@ -0,0 +1,119 @@ +from datetime import date, datetime, timedelta, timezone +from caldav import get_calendar, get_calendars + +from app.services.settings import settings + +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 + + def start(self): + if self.is_running: + logger.error("CaldavEventCache already running") + return + self.is_running = True + self.thread = threading.Thread(target=self._refresh_loop, daemon=True) + self.thread.start() + logger.info("CaldavEventCache started") + + def stop(self): + self.is_running = False + 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) + time.sleep(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(timezone.utc) + today = date.today() + 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_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) diff --git a/test/test_services/test_caldav.py b/test/test_services/test_caldav.py new file mode 100644 index 0000000..b4d1b12 --- /dev/null +++ b/test/test_services/test_caldav.py @@ -0,0 +1,41 @@ +from datetime import date, datetime, timezone + +# 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=timezone.utc), + "end": datetime(2026, 11, 2, 11, 0, tzinfo=timezone.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, + }, +] + +# Active during the timed event +during_event = datetime(2026, 11, 2, 10, 30, tzinfo=timezone.utc) +assert cache.is_group_active("team-a", during_event) +assert cache.is_group_active("shared", during_event) +assert not cache.is_group_active("team-b", during_event) + +# Timed event end is exclusive; the all-day event is still active +at_timed_event_end = datetime(2026, 11, 2, 11, 0, tzinfo=timezone.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) + +# Unknown group +assert not cache.is_group_active("unknown", during_event) + +# All-day event end date is exclusive +next_day = datetime(2026, 11, 3, 9, 0, tzinfo=timezone.utc) +assert not cache.is_group_active("team-b", next_day) -- 2.54.0 From e07013b7a49a1949b01ea9a7d8281ddd6165e91a Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Nov 2026 19:53:13 +0100 Subject: [PATCH 02/15] add caldav settings --- app/services/settings.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/services/settings.py b/app/services/settings.py index 4a0d7d3..b766162 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -21,6 +21,12 @@ class Settings(BaseSettings): mifare_acl_read_base_key: str | None = None mifare_acl_write_base_key: str | None = None + disable_caldav: bool = True + caldav_url: str | None = None + caldav_user: str | None = None + caldav_password: str | None = None + caldav_calendars: list[str] | None = None + caldav_refresh_interval: int | None = None @lru_cache(1) def _create_settings(): @@ -42,6 +48,26 @@ def _create_settings(): ", ".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 -- 2.54.0 From 7e39fa2c1e8c3d2ea68e8f45eda1b662de1276a0 Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Nov 2026 19:53:22 +0100 Subject: [PATCH 03/15] run caldav iv enabled --- app/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/main.py b/app/main.py index 81fe0a4..27497ac 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from app.controllers import ( from app.services.auth import create_first_user, token_router from app.services.database import create_db_and_tables, get_db_session from app.services.door import DoorController, init_controller +from app.services.caldav import CaldavEventCache from app.services.scanner import BackgroundScanner from app.services.settings import settings @@ -23,6 +24,12 @@ logger = logging.getLogger(__name__) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") scanner = BackgroundScanner(db=get_db_session()) +caldav_handler = CaldavEventCache( + url=settings.caldav_url, + username=settings.caldav_user, + password=settings.caldav_password, + calendarNames=settings.caldav_calendars + ) logging.basicConfig(level=logging.INFO) @@ -40,6 +47,9 @@ async def lifespan(app: FastAPI): if not settings.disable_cards: scanner.start() + if not settings.disable_caldav: + caldav_handler.start() + logger.info("-" * 63) logger.info("---- Documentation is at http://127.0.0.1:8000/api/v1/docs ----") logger.info("-" * 63) -- 2.54.0 From 6666f47f1fd59dc6a34ec28f76d4eaa062eb2706 Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Nov 2026 19:53:34 +0100 Subject: [PATCH 04/15] enable caldav access check --- app/services/door.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/services/door.py b/app/services/door.py index 5b36ea7..1e51f8a 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -8,6 +8,7 @@ from sqlmodel import select from app.model.models import Card, OneShotAccess from app.services.database import Session, add_and_refresh + logger = logging.getLogger(__name__) @@ -98,12 +99,17 @@ def decrementOneshot(db: Session, oneshot: OneShotAccess): def checkAccess(key: str, db: Session): try: - current_weekday = datetime.weekday(date.today()) - current_time = datetime.now() card = db.exec(select(Card).where(Card.key == key)).one() if not card.enabled: logger.info("Card Inactive!") return False + + if caldav_handler.is_group_active(card.group.name): + logger.info("Access Valid!") + return True + + current_weekday = datetime.weekday(date.today()) + current_time = datetime.now() for auth in card.group.accessauths: logger.info(f"checking auth: {auth.name}") if not auth.is_active: -- 2.54.0 From f7a157593b8299cad33a3d3562ad37dc0a6274b8 Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Nov 2026 19:53:43 +0100 Subject: [PATCH 05/15] add caldav dependencies --- pyproject.toml | 1 + uv.lock | 393 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 321e0b7..57fd44b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "alembic>=1.18.5", "pydantic-settings>=2.13.1", "paho-mqtt>=2.1.0", + "caldav>=3.7.0", ] [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index 19ba9f1..6ccb7b5 100644 --- a/uv.lock +++ b/uv.lock @@ -134,6 +134,38 @@ 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" @@ -658,6 +690,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "caldav" }, { name = "fastapi", extra = ["standard"] }, { name = "paho-mqtt" }, { name = "poetry" }, @@ -682,6 +715,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.5" }, + { name = "caldav", specifier = ">=3.7.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "poetry", specifier = ">=2.3.4" }, @@ -793,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" @@ -902,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" @@ -944,6 +1073,98 @@ 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" @@ -1073,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" @@ -1430,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" @@ -1514,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" @@ -1566,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" @@ -1742,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" @@ -1861,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" @@ -1870,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" @@ -1935,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" @@ -2028,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" -- 2.54.0 From ba3e471bb953d35f8604a47381a0aad95fb68c44 Mon Sep 17 00:00:00 2001 From: kalipso Date: Mon, 2 Nov 2026 20:14:20 +0100 Subject: [PATCH 06/15] fix CaldavEventCache not found error --- test/test_services/test_caldav.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_services/test_caldav.py b/test/test_services/test_caldav.py index b4d1b12..3e4c15c 100644 --- a/test/test_services/test_caldav.py +++ b/test/test_services/test_caldav.py @@ -1,4 +1,5 @@ from datetime import date, datetime, timezone +from app.services.caldav import CaldavEventCache # Create the instance without calling __init__, so tests need no CalDAV server. cache = CaldavEventCache.__new__(CaldavEventCache) -- 2.54.0 From a8edc862bc0349d3e18044f6a21e276d0c993fd5 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Tue, 3 Nov 2026 15:54:13 +0100 Subject: [PATCH 07/15] Ignore venv collisions --- flake.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index f9ba513..11a5bd3 100644 --- a/flake.nix +++ b/flake.nix @@ -81,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 { @@ -108,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: { -- 2.54.0 From b31413931b3bf6f883848c5b5cbc41fb3da04670 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Tue, 3 Nov 2026 21:57:41 +0100 Subject: [PATCH 08/15] Fix caldav tests --- app/main.py | 13 ++--- app/services/door.py | 19 +++++-- test/test_services/test_caldav.py | 82 ++++++++++++++++++------------- 3 files changed, 69 insertions(+), 45 deletions(-) diff --git a/app/main.py b/app/main.py index 27497ac..9ebc165 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from app.controllers import ( ) from app.services.auth import create_first_user, token_router from app.services.database import create_db_and_tables, get_db_session -from app.services.door import DoorController, init_controller +from app.services.door import DoorController, init_controller, init_caldav_handler from app.services.caldav import CaldavEventCache from app.services.scanner import BackgroundScanner from app.services.settings import settings @@ -25,11 +25,11 @@ logger = logging.getLogger(__name__) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") scanner = BackgroundScanner(db=get_db_session()) caldav_handler = CaldavEventCache( - url=settings.caldav_url, - username=settings.caldav_user, - password=settings.caldav_password, - calendarNames=settings.caldav_calendars - ) + url=settings.caldav_url, + username=settings.caldav_user, + password=settings.caldav_password, + calendarNames=settings.caldav_calendars, +) logging.basicConfig(level=logging.INFO) @@ -49,6 +49,7 @@ async def lifespan(app: FastAPI): if not settings.disable_caldav: caldav_handler.start() + init_caldav_handler(caldav_handler) logger.info("-" * 63) logger.info("---- Documentation is at http://127.0.0.1:8000/api/v1/docs ----") diff --git a/app/services/door.py b/app/services/door.py index 1e51f8a..82a4c60 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -20,7 +20,6 @@ class DoorController: mqtt_host: str = "localhost", mqtt_port: int = 1883, mock_factory: bool = False, - ): self._is_open: bool = False self._mqtt = None @@ -29,13 +28,17 @@ class DoorController: if not mock_factory: import paho.mqtt.client as mqtt + self._mqtt = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) - self._mqtt.connect(mqtt_host, mqtt_port) #TODO: add login with username+pw, tls + self._mqtt.connect( + mqtt_host, mqtt_port + ) # TODO: add login with username+pw, tls self._mqtt.loop_start() logger.info("Mqtt client connect to %s:%s", mqtt_host, mqtt_port) logger.info( - "DoorController started. topic=%s mock=%s", self._mqtt_topic, + "DoorController started. topic=%s mock=%s", + self._mqtt_topic, mock_factory, ) @@ -64,6 +67,7 @@ class DoorController: _contoller: DoorController | None = None +_caldav_handler = None def init_controller(ctrl: DoorController): @@ -71,6 +75,11 @@ def init_controller(ctrl: DoorController): _contoller = ctrl +def init_caldav_handler(handler): + global _caldav_handler + _caldav_handler = handler + + def get_controller(): if _contoller is None: raise RuntimeError("DoorController not initialized.") @@ -104,7 +113,9 @@ def checkAccess(key: str, db: Session): logger.info("Card Inactive!") return False - if caldav_handler.is_group_active(card.group.name): + if _caldav_handler is not None and _caldav_handler.is_group_active( + card.group.name + ): logger.info("Access Valid!") return True diff --git a/test/test_services/test_caldav.py b/test/test_services/test_caldav.py index 3e4c15c..5835e75 100644 --- a/test/test_services/test_caldav.py +++ b/test/test_services/test_caldav.py @@ -1,42 +1,54 @@ from datetime import date, datetime, timezone from app.services.caldav import CaldavEventCache -# 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=timezone.utc), - "end": datetime(2026, 11, 2, 11, 0, tzinfo=timezone.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, - }, -] -# Active during the timed event -during_event = datetime(2026, 11, 2, 10, 30, tzinfo=timezone.utc) -assert cache.is_group_active("team-a", during_event) -assert cache.is_group_active("shared", during_event) -assert not cache.is_group_active("team-b", during_event) +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=timezone.utc), + "end": datetime(2026, 11, 2, 11, 0, tzinfo=timezone.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 -# Timed event end is exclusive; the all-day event is still active -at_timed_event_end = datetime(2026, 11, 2, 11, 0, tzinfo=timezone.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) -# Unknown group -assert not cache.is_group_active("unknown", during_event) +def test_active_during_timed_event(): + cache = create_cache() + during_event = datetime(2026, 11, 2, 10, 30, tzinfo=timezone.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'? -# All-day event end date is exclusive -next_day = datetime(2026, 11, 3, 9, 0, tzinfo=timezone.utc) -assert not cache.is_group_active("team-b", next_day) + +def test_timed_event_end_exclusive(): + cache = create_cache() + at_timed_event_end = datetime(2026, 11, 2, 11, 0, tzinfo=timezone.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=timezone.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=timezone.utc) + assert not cache.is_group_active("team-b", next_day) -- 2.54.0 From af2f254937a71550351ed650e7c4643052d9323d Mon Sep 17 00:00:00 2001 From: ahtlon Date: Tue, 3 Nov 2026 22:05:03 +0100 Subject: [PATCH 09/15] Git squash from Ruff format Ruff autofix Fix the rest of the issues --- app/controllers/cardManager.py | 24 ++++++++++------ app/controllers/debugManager.py | 6 ++-- app/controllers/userManager.py | 6 ++-- app/main.py | 4 +-- app/services/auth.py | 4 +-- app/services/caldav.py | 47 ++++++++++++++++++------------- app/services/door.py | 24 ++++++++-------- app/services/scanner.py | 20 ++++++------- app/services/settings.py | 3 +- test/test_services/test_caldav.py | 15 +++++----- test/test_services/test_door.py | 15 ++++++---- 11 files changed, 95 insertions(+), 73 deletions(-) diff --git a/app/controllers/cardManager.py b/app/controllers/cardManager.py index 128c739..6637f91 100644 --- a/app/controllers/cardManager.py +++ b/app/controllers/cardManager.py @@ -42,8 +42,10 @@ def add_card( db.exec(select(Card).where(Card.name == cardInput.name)).one_or_none() is None ) - except AssertionError: - raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!") + except AssertionError as err: + raise HTTPException( + status.HTTP_409_CONFLICT, detail="Name already used!" + ) from err try: assert ( db.exec( @@ -51,8 +53,10 @@ def add_card( ).one_or_none() is not None ) - except AssertionError: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!") + 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) @@ -65,12 +69,12 @@ def del_card( 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}", # noqa: E501 - ) + detail="Key on card not found in DB. KEY={key}", + ) from err db.delete(card) db.commit() return {"message": "Card deleted successfully"} @@ -106,7 +110,9 @@ def update_card( ).one_or_none() is not None ) - except AssertionError: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="GroupID not found!") + 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) diff --git a/app/controllers/debugManager.py b/app/controllers/debugManager.py index 0cbeafa..e1037bb 100644 --- a/app/controllers/debugManager.py +++ b/app/controllers/debugManager.py @@ -44,8 +44,10 @@ def add_card_manually( def remove_card_manually(card_id: str, db: Session = Depends(get_session)): try: card = db.exec(select(Card).where(Card.id == card_id)).one() - except exc.NoResultFound: - 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() diff --git a/app/controllers/userManager.py b/app/controllers/userManager.py index 4185a2b..c029a64 100644 --- a/app/controllers/userManager.py +++ b/app/controllers/userManager.py @@ -26,8 +26,10 @@ def create_user( db.exec(select(UserDB).where(UserDB.name == user.name)).one_or_none() is None ) - except AssertionError: - raise HTTPException(status.HTTP_409_CONFLICT, detail="Name already used!") + 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) diff --git a/app/main.py b/app/main.py index 9ebc165..6c7d156 100644 --- a/app/main.py +++ b/app/main.py @@ -14,9 +14,9 @@ from app.controllers import ( userManager, ) from app.services.auth import create_first_user, token_router -from app.services.database import create_db_and_tables, get_db_session -from app.services.door import DoorController, init_controller, init_caldav_handler from app.services.caldav import CaldavEventCache +from app.services.database import create_db_and_tables, get_db_session +from app.services.door import DoorController, init_caldav_handler, init_controller from app.services.scanner import BackgroundScanner from app.services.settings import settings diff --git a/app/services/auth.py b/app/services/auth.py index d4392db..18a631d 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -76,8 +76,8 @@ 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 diff --git a/app/services/caldav.py b/app/services/caldav.py index a3d3ba1..c15f8dc 100644 --- a/app/services/caldav.py +++ b/app/services/caldav.py @@ -1,8 +1,15 @@ -from datetime import date, datetime, timedelta, timezone -from caldav import get_calendar, get_calendars +import logging +import threading +import time +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 @@ -41,18 +48,18 @@ class CaldavEventCache: url=self.url, username=self.username, password=self.password, - calendar_name=self.calendarNames + calendar_name=self.calendarNames, ) cached = [] - now = datetime.now(timezone.utc) - today = date.today() + 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) + expand=True, + ) for event in my_events: component = event.component start, end = component.start, component.end @@ -65,14 +72,16 @@ class CaldavEventCache: 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, - }) + 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 @@ -86,22 +95,22 @@ class CaldavEventCache: 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_active_groups(self, active_time=None): diff --git a/app/services/door.py b/app/services/door.py index 82a4c60..316ad2e 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -1,6 +1,5 @@ import logging from datetime import date, datetime, timedelta -from time import sleep from sqlalchemy import exc from sqlmodel import select @@ -8,7 +7,6 @@ from sqlmodel import select from app.model.models import Card, OneShotAccess from app.services.database import Session, add_and_refresh - logger = logging.getLogger(__name__) @@ -113,9 +111,7 @@ def checkAccess(key: str, db: Session): logger.info("Card Inactive!") return False - if _caldav_handler is not None and _caldav_handler.is_group_active( - card.group.name - ): + if _caldav_handler and _caldav_handler.is_group_active(card.group.name): logger.info("Access Valid!") return True @@ -130,24 +126,26 @@ def checkAccess(key: str, db: Session): for timetable in auth.timetables: logger.info(f" checking timetable {timetable.id}") logger.info( - f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}" + f" weekday: CUR:{current_weekday} TT:{timetable.weekday}" ) if current_weekday == timetable.weekday: starttime = datetime.combine(date.today(), timetable.starttime) endtime = starttime + timedelta(minutes=timetable.duration) logger.info( - f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}" + f" comparing time: Start:{starttime} " + f"Current:{current_time} End:{endtime}" ) if starttime < current_time < endtime: logger.info("Access Valid!") return True if auth.type == "oneshot": logger.info(f" oneshot auth found: {auth.oneshot}") - if current_time < auth.oneshot.ends_at: - if auth.oneshot.uses > 0: - decrementOneshot(db, auth.oneshot) - return True + if current_time < auth.oneshot.ends_at and auth.oneshot.uses > 0: + decrementOneshot(db, auth.oneshot) + 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 diff --git a/app/services/scanner.py b/app/services/scanner.py index 13ac085..e86906f 100644 --- a/app/services/scanner.py +++ b/app/services/scanner.py @@ -130,10 +130,10 @@ def DeleteCard(): try: 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) @@ -154,11 +154,11 @@ def DeleteCard(): pass scannerThread.start() return rdata - except (Exception, AssertionError) as e: - logger.error(f"Error in deletion function: {e}", exc_info=True) + 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: {e}" - ) + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}" + ) from err def WriteNewCard(): @@ -269,11 +269,11 @@ def WriteNewCard(): scannerThread.start() return key, to_hex_string(data=uid, separator=":") - except Exception as e: - logger.error(f"Error in write function: {e}", exc_info=True) + 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: {e}" - ) + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {err}" + ) from err class BackgroundScanner: diff --git a/app/services/settings.py b/app/services/settings.py index b766162..6d95295 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -28,6 +28,7 @@ class Settings(BaseSettings): caldav_calendars: list[str] | None = None caldav_refresh_interval: int | None = None + @lru_cache(1) def _create_settings(): settings = Settings() @@ -48,7 +49,7 @@ def _create_settings(): ", ".join(missing), ) settings.disable_cards = True - + if not settings.disable_caldav: missing = [ name.upper() diff --git a/test/test_services/test_caldav.py b/test/test_services/test_caldav.py index 5835e75..c39dda6 100644 --- a/test/test_services/test_caldav.py +++ b/test/test_services/test_caldav.py @@ -1,4 +1,5 @@ -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime + from app.services.caldav import CaldavEventCache @@ -10,8 +11,8 @@ def create_cache(): "uid": "1", "summary": "Timed event", "groups": ["team-a", "shared"], - "start": datetime(2026, 11, 2, 10, 0, tzinfo=timezone.utc), - "end": datetime(2026, 11, 2, 11, 0, tzinfo=timezone.utc), + "start": datetime(2026, 11, 2, 10, 0, tzinfo=UTC), + "end": datetime(2026, 11, 2, 11, 0, tzinfo=UTC), "all_day": False, }, { @@ -28,7 +29,7 @@ def create_cache(): def test_active_during_timed_event(): cache = create_cache() - during_event = datetime(2026, 11, 2, 10, 30, tzinfo=timezone.utc) + 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'? @@ -36,7 +37,7 @@ def test_active_during_timed_event(): def test_timed_event_end_exclusive(): cache = create_cache() - at_timed_event_end = datetime(2026, 11, 2, 11, 0, tzinfo=timezone.utc) + 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) @@ -44,11 +45,11 @@ def test_timed_event_end_exclusive(): def test_unknown_group(): cache = create_cache() - during_event = datetime(2026, 11, 2, 10, 30, tzinfo=timezone.utc) + 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=timezone.utc) + next_day = datetime(2026, 11, 3, 9, 0, tzinfo=UTC) assert not cache.is_group_active("team-b", next_day) diff --git a/test/test_services/test_door.py b/test/test_services/test_door.py index 3a5d33d..0cabcf5 100644 --- a/test/test_services/test_door.py +++ b/test/test_services/test_door.py @@ -110,9 +110,10 @@ def test_check_access_with_valid_oneshot(db_session): def test_check_access_invalid_card(db_session): # Should raise exception for non-existent card - with pytest.raises(Exception): + 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") @@ -142,7 +143,8 @@ def test_check_access_with_inactive_aa(db_session): db_session.commit() - assert checkAccess("test-key-123", db_session) == False + assert not checkAccess("test-key-123", db_session) + def test_check_access_with_inactive_card(db_session): # Setup: create card with valid access @@ -173,7 +175,8 @@ def test_check_access_with_inactive_card(db_session): db_session.commit() - assert checkAccess("test-key-123", db_session) == False + 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 @@ -190,7 +193,7 @@ def test_regression_check_access_issue_28(db_session): ) db_session.add(card) - #Creating timetable with inactive AA + # Creating timetable with inactive AA timetable1 = Timetable( weekday=datetime.datetime.weekday(datetime.date.today()), starttime=datetime.datetime.now().time(), @@ -201,7 +204,7 @@ def test_regression_check_access_issue_28(db_session): db_session.add(aa1) aa1.timetables = [timetable1] - #Creating timetable with active AA + # Creating timetable with active AA timetable2 = Timetable( weekday=datetime.datetime.weekday(datetime.date.today()), starttime=datetime.datetime.now().time(), @@ -216,4 +219,4 @@ def test_regression_check_access_issue_28(db_session): db_session.commit() - assert checkAccess("test-key-123", db_session) == True \ No newline at end of file + assert checkAccess("test-key-123", db_session) -- 2.54.0 From ff170af93514db29b635bb1a68a488f381fbe6dd Mon Sep 17 00:00:00 2001 From: ahtlon Date: Thu, 5 Nov 2026 12:13:46 +0100 Subject: [PATCH 10/15] Add timezone marker to requests Tests helpfully written by local-Qwen3.8 --- app/model/models.py | 31 ++++- app/services/door.py | 13 +- test/conftest.py | 22 ++++ test/test_services/test_aa_manager.py | 168 ++++++++++++++++++++++++++ test/test_services/test_door.py | 84 +++++++++++++ 5 files changed, 313 insertions(+), 5 deletions(-) diff --git a/app/model/models.py b/app/model/models.py index 6935e13..e951e7d 100644 --- a/app/model/models.py +++ b/app/model/models.py @@ -1,7 +1,8 @@ -from datetime import datetime, time +from datetime import UTC, datetime, time from typing import Literal from pydantic import model_validator +from sqlalchemy import DateTime, TypeDecorator from sqlmodel import Field, Relationship, SQLModel @@ -9,6 +10,32 @@ 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) @@ -102,7 +129,7 @@ class AccessAuthorizationDB(AccessAuthorizationBase, table=True): class OneShotAccessBase(Base): uses: int = 1 - ends_at: datetime + ends_at: datetime = Field(sa_type=UTCDateTime) class OneShotAccess(OneShotAccessBase, table=True): diff --git a/app/services/door.py b/app/services/door.py index 316ad2e..1d2a00b 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -116,7 +116,7 @@ def checkAccess(key: str, db: Session): return True current_weekday = datetime.weekday(date.today()) - current_time = datetime.now() + current_time = datetime.now().astimezone() for auth in card.group.accessauths: logger.info(f"checking auth: {auth.name}") if not auth.is_active: @@ -129,7 +129,11 @@ def checkAccess(key: str, db: Session): f" weekday: CUR:{current_weekday} TT:{timetable.weekday}" ) if current_weekday == timetable.weekday: - starttime = datetime.combine(date.today(), timetable.starttime) + starttime = datetime.combine( + date.today(), + timetable.starttime, + tzinfo=current_time.tzinfo, + ) endtime = starttime + timedelta(minutes=timetable.duration) logger.info( f" comparing time: Start:{starttime} " @@ -140,7 +144,10 @@ def checkAccess(key: str, db: Session): return True if auth.type == "oneshot": logger.info(f" oneshot auth found: {auth.oneshot}") - if current_time < auth.oneshot.ends_at and auth.oneshot.uses > 0: + ends_at = auth.oneshot.ends_at + if ends_at.tzinfo is None: + ends_at = ends_at.astimezone() + if current_time < ends_at and auth.oneshot.uses > 0: decrementOneshot(db, auth.oneshot) return True logger.info("No more auths found") diff --git a/test/conftest.py b/test/conftest.py index 8f12ee3..448eae0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,6 @@ import os from datetime import time +from time import tzset import pytest from fastapi.testclient import TestClient @@ -28,6 +29,27 @@ engine = create_engine( ) +@pytest.fixture +def system_tz(request): + """Run a test with the host-local timezone forced to a POSIX TZ string. + + POSIX strings (e.g. "CET-1CEST,M3.5.0,M10.5.0/3") are used instead of + zoneinfo names because glibc in this container does not resolve names such + as "Europe/Berlin". + """ + 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(scope="function") def db_session(): """Create a fresh database session for each test.""" diff --git a/test/test_services/test_aa_manager.py b/test/test_services/test_aa_manager.py index fdfce11..6c30130 100644 --- a/test/test_services/test_aa_manager.py +++ b/test/test_services/test_aa_manager.py @@ -1,3 +1,29 @@ +from datetime import UTC, datetime + +import pytest +from sqlalchemy import text +from sqlmodel import select + +from app.model.models import AccessAuthorizationDB, OneShotAccess + +# Berlin's DST rules as a POSIX TZ string: +01:00 in winter, +02:00 in summer. +BERLIN_TZ = "CET-1CEST,M3.5.0,M10.5.0/3" + + +def _post_oneshot_aa(client, auth_headers, name: str, ends_at: str): + """Create a oneshot access authorization and return the response.""" + return client.post( + "/api/v1/aa/", + json={ + "name": name, + "type": "oneshot", + "is_active": True, + "oneshot": {"uses": 1, "ends_at": ends_at}, + }, + headers=auth_headers, + ) + + def test_create_access_auth_tt(client, auth_headers): """Test creating a new access authorization.""" aa_data = { @@ -224,3 +250,145 @@ def test_aa_tt_operations_by_non_admin(client, test_aa_tt, user_auth_headers): f"/api/v1/aa/assign/1/{test_aa_tt.id}", headers=user_auth_headers ) assert response.status_code == 403 + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ, "UTC-2"], indirect=True) +def test_create_access_auth_os_returns_timezone(client, auth_headers, system_tz): + """The oneshot end time must come back with a timezone, not as a naive value.""" + response = _post_oneshot_aa( + client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00" + ) + assert response.status_code == 200 + + ends_at = response.json()["oneshot"]["ends_at"] + parsed = datetime.fromisoformat(ends_at) + assert parsed.utcoffset() is not None, f"response has no timezone: {ends_at}" + assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC), ( + f"the +02:00 offset was not normalised to UTC: {ends_at}" + ) + + +def test_get_access_auth_os_returns_timezone(client, auth_headers): + """Reading the value back from the database keeps it timezone aware.""" + created = _post_oneshot_aa( + client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00" + ) + assert created.status_code == 200 + aa_id = created.json()["id"] + + for endpoint in (f"/api/v1/aa/{aa_id}", "/api/v1/aa/"): + response = client.get(endpoint, headers=auth_headers) + assert response.status_code == 200 + body = response.json() + items = body if isinstance(body, list) else [body] + auths = [aa for aa in items if aa.get("oneshot")] + assert auths, f"no oneshot auth in {endpoint}: {body}" + for auth in auths: + ends_at = auth["oneshot"]["ends_at"] + parsed = datetime.fromisoformat(ends_at) + assert parsed.utcoffset() is not None, ( + f"{endpoint} has no timezone: {ends_at}" + ) + assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC) + + +def test_oneshot_timezone_is_stored_in_db(client, auth_headers, db_session): + """The submitted offset is normalized before the value reaches the database.""" + response = _post_oneshot_aa( + client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00" + ) + assert response.status_code == 200 + + db_session.expire_all() + stored = db_session.exec(select(OneShotAccess)).one() + assert stored.ends_at == datetime(2029, 7, 27, 10, 0, tzinfo=UTC) + + # The column itself must hold the UTC wall clock, not the submitted local time. + raw = db_session.exec(text("SELECT ends_at FROM oneshotaccess")).one()[0] + assert datetime.fromisoformat(raw) == datetime(2029, 7, 27, 10, 0), ( + f"unexpected value in the database: {raw}" + ) + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) +def test_oneshot_without_offset_is_treated_as_local_time( + client, auth_headers, db_session, system_tz +): + """A datetime without an offset is wall-clock time on the host, not UTC.""" + response = _post_oneshot_aa( + client, auth_headers, "naive os_AA", "2029-07-27T12:00:00" + ) + assert response.status_code == 200 + + # 2029-07-27 is summer in Berlin, so 12:00 local is 10:00 UTC. + parsed = datetime.fromisoformat(response.json()["oneshot"]["ends_at"]) + assert parsed == datetime(2029, 7, 27, 10, 0, tzinfo=UTC) + + db_session.expire_all() + stored = db_session.exec(select(OneShotAccess)).one() + assert stored.ends_at == datetime(2029, 7, 27, 10, 0, tzinfo=UTC) + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) +def test_oneshot_date_only_is_local_midnight(client, auth_headers, system_tz): + """A bare date is local midnight, which is not the same instant as 00:00 UTC.""" + response = _post_oneshot_aa(client, auth_headers, "date os_AA", "2029-07-27") + assert response.status_code == 200 + + ends_at = response.json()["oneshot"]["ends_at"] + parsed = datetime.fromisoformat(ends_at) + assert parsed == datetime(2029, 7, 26, 22, 0, tzinfo=UTC), ( + f"local midnight was not converted to UTC: {ends_at}" + ) + + +def test_update_access_auth_os_returns_timezone(client, auth_headers, db_session): + """PATCHing the oneshot keeps the timezone in the response and in the DB.""" + created = _post_oneshot_aa( + client, auth_headers, "tz os_AA", "2029-07-27T12:00:00+02:00" + ) + assert created.status_code == 200 + aa_id = created.json()["id"] + + response = client.patch( + f"/api/v1/aa/{aa_id}", + json={"oneshot": {"uses": 2, "ends_at": "2030-01-01T05:00:00+05:00"}}, + headers=auth_headers, + ) + assert response.status_code == 200 + + ends_at = response.json()["oneshot"]["ends_at"] + parsed = datetime.fromisoformat(ends_at) + assert parsed.utcoffset() is not None, f"response has no timezone: {ends_at}" + assert parsed == datetime(2030, 1, 1, 0, 0, tzinfo=UTC) + + db_session.expire_all() + stored = db_session.exec(select(OneShotAccess)).one() + assert stored.ends_at == datetime(2030, 1, 1, 0, 0, tzinfo=UTC) + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) +def test_oneshot_local_time_uses_the_dst_rule_of_its_own_date( + client, auth_headers, system_tz +): + """Naive local times must pick the offset in force on that date. + + Without this the host's *current* offset gets applied to every value, so a + summer date entered in winter is stored an hour off. + """ + summer = _post_oneshot_aa( + client, auth_headers, "summer os_AA", "2029-07-27T12:00:00" + ) + winter = _post_oneshot_aa( + client, auth_headers, "winter os_AA", "2029-12-27T12:00:00" + ) + assert summer.status_code == 200 and winter.status_code == 200 + + # CEST is UTC+2, CET is UTC+1. + assert datetime.fromisoformat(summer.json()["oneshot"]["ends_at"]) == datetime( + 2029, 7, 27, 10, 0, tzinfo=UTC + ) + assert datetime.fromisoformat(winter.json()["oneshot"]["ends_at"]) == datetime( + 2029, 12, 27, 11, 0, tzinfo=UTC + ) + diff --git a/test/test_services/test_door.py b/test/test_services/test_door.py index 0cabcf5..46dcce3 100644 --- a/test/test_services/test_door.py +++ b/test/test_services/test_door.py @@ -220,3 +220,87 @@ def test_regression_check_access_issue_28(db_session): db_session.commit() assert checkAccess("test-key-123", db_session) + + +# Berlin's DST rules as a POSIX TZ string: +01:00 in winter, +02:00 in summer. +BERLIN_TZ = "CET-1CEST,M3.5.0,M10.5.0/3" + + +def _create_card_with_oneshot(db_session, ends_at): + """Create a card whose only authorization is a oneshot ending at ends_at.""" + group = GroupDB(name="Test Group") + db_session.add(group) + db_session.commit() + + card = Card( + key="test-key-123", + group_id=group.id, + enabled=True, + name="test_card", + card_serial="00:00:00:00:00:00:00", + ) + db_session.add(card) + + oneshot = OneShotAccess(uses=1, ends_at=ends_at) + db_session.add(oneshot) + + aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="oneshot") + db_session.add(aa) + aa.oneshot = oneshot + group.accessauths = [aa] + + db_session.commit() + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) +def test_check_access_oneshot_end_time_is_compared_in_utc(db_session, system_tz): + """A oneshot ending in 30 minutes is valid even though the local clock is + ahead of UTC: the stored end time is UTC and must not be read as local time.""" + ends_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=30) + _create_card_with_oneshot(db_session, ends_at) + + assert checkAccess("test-key-123", db_session) + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) +def test_check_access_oneshot_expired(db_session, system_tz): + """A oneshot that already ended must be rejected whatever the local clock is.""" + ends_at = datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=30) + _create_card_with_oneshot(db_session, ends_at) + + assert not checkAccess("test-key-123", db_session) + + +@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) +def test_check_access_timetable_window_is_local_wall_clock(db_session, system_tz): + """Timetables are local wall-clock times and still work with an aware door clock.""" + now_local = datetime.datetime.now().astimezone() + + group = GroupDB(name="Test Group") + db_session.add(group) + db_session.commit() + + card = Card( + key="test-key-123", + group_id=group.id, + enabled=True, + name="test_card", + card_serial="00:00:00:00:00:00:00", + ) + db_session.add(card) + + timetable = Timetable( + weekday=now_local.weekday(), + starttime=now_local.time(), + duration=120, # 2 hours + ) + db_session.add(timetable) + + aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable") + db_session.add(aa) + aa.timetables = [timetable] + group.accessauths = [aa] + + db_session.commit() + + assert checkAccess("test-key-123", db_session) -- 2.54.0 From db87625384b1af635d4023aac70287ff38badaf4 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Thu, 5 Nov 2026 17:19:29 +0000 Subject: [PATCH 11/15] Prevent accidental opening of the db on disk while running tests This was anoying to fix (and even debug) so I used the local llm for some code changes Assisted-by: local-qwen3.8 --- alembic.ini | 2 +- alembic/env.py | 10 +- app/services/database.py | 52 ++++++++-- test/conftest.py | 141 +++++++++++++++++++--------- test/test_services/test_database.py | 64 +++++++------ 5 files changed, 187 insertions(+), 82 deletions(-) diff --git a/alembic.ini b/alembic.ini index db56117..3c3eed9 100644 --- a/alembic.ini +++ b/alembic.ini @@ -86,7 +86,7 @@ path_separator = os # database URL. This is consumed by the user-maintained env.py script only. # other means of configuring database URLs may be customized within the env.py # file. -sqlalchemy.url = sqlite:///./gatekeeper.db +# sqlalchemy.url = sqlite:///./gatekeeper.db [post_write_hooks] diff --git a/alembic/env.py b/alembic/env.py index baa2726..add939a 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -5,6 +5,8 @@ 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 @@ -22,6 +24,10 @@ 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") @@ -40,7 +46,7 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = _database_url() context.configure( url=url, target_metadata=target_metadata, @@ -60,7 +66,7 @@ def run_migrations_online() -> None: """ connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), + {"sqlalchemy.url": _database_url()}, prefix="sqlalchemy.", poolclass=pool.NullPool, ) diff --git a/app/services/database.py b/app/services/database.py index 7b19034..99205f5 100644 --- a/app/services/database.py +++ b/app/services/database.py @@ -1,5 +1,4 @@ import logging -from functools import lru_cache from sqlalchemy import inspect from sqlmodel import Session, SQLModel, create_engine @@ -8,25 +7,64 @@ 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 -@lru_cache -def get_engine(): + +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 + + def create_db_and_tables(): - inspector = inspect(get_engine()) - existing_tables = inspector.get_table_names() + engine = get_engine() + existing_tables = inspect(engine).get_table_names() if not existing_tables: - SQLModel.metadata.create_all(get_engine()) + 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(get_engine().url)) + 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.") diff --git a/test/conftest.py b/test/conftest.py index 448eae0..0a4a797 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,42 +1,96 @@ import os -from datetime import time +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 sqlalchemy import inspect from sqlalchemy.pool import StaticPool -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session, SQLModel, create_engine, select -os.environ["SECRET_KEY"] = "ff" * 16 +os.environ.setdefault("SECRET_KEY", "ff" * 16) +os.environ.setdefault("MOCK", "True") +os.environ.setdefault("SQLALCHEMY_DATABASE_URL", "sqlite://") +os.environ.setdefault("DISABLE_CARDS", "True") -from app.main import app -from app.model.models import ( - AccessAuthorizationDB, - Card, - GroupDB, - Timetable, - UserDB, -) -from app.services.database import get_session +import app.services.database as database_service -# Use in-memory SQLite for testing -TEST_SQLALCHEMY_DATABASE_URL = "sqlite://" +TEST_ADMIN_PASSWORD = "admin123" engine = create_engine( - TEST_SQLALCHEMY_DATABASE_URL, + os.environ["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. - - POSIX strings (e.g. "CET-1CEST,M3.5.0,M10.5.0/3") are used instead of - zoneinfo names because glibc in this container does not resolve names such - as "Europe/Berlin". - """ + """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() @@ -50,10 +104,20 @@ def system_tz(request): 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) @@ -61,27 +125,19 @@ def db_session(): @pytest.fixture(scope="function") def client(db_session): - """Create a test client with a database session override.""" + """Create a test client bound to the test engine. - def override_get_session(): - yield db_session - - app.dependency_overrides[get_session] = override_get_session + No FastAPI dependency override is needed: get_session() resolves its engine + through get_engine(), which already points at the test engine. + """ with TestClient(app) as test_client: 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 @@ -104,7 +160,8 @@ def regular_user(db_session): def auth_headers(client, admin_user): """Get authentication headers for admin user.""" response = client.post( - "/api/v1/token", data={"username": admin_user.name, "password": "admin123"} + "/api/v1/token", + data={"username": admin_user.name, "password": TEST_ADMIN_PASSWORD}, ) token = response.json()["access_token"] return {"Authorization": f"Bearer {token}"} @@ -147,12 +204,12 @@ def test_card(db_session, test_group): @pytest.fixture -def test_aa_tt(db_session): - """Create a test access authorization with timetable.""" - tt = Timetable(weekday=1, starttime=time(1, 0, 0, 0), duration=50) - aa = AccessAuthorizationDB( - name="Test AA", is_active=True, type="timetable", timetables=[tt] - ) +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) diff --git a/test/test_services/test_database.py b/test/test_services/test_database.py index 2b55570..905e844 100644 --- a/test/test_services/test_database.py +++ b/test/test_services/test_database.py @@ -1,42 +1,46 @@ +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, create_db_and_tables +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 +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 - from app.services.database import get_engine + 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(get_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): -- 2.54.0 From 63ea10379345f8cd0e2a223ddac6237554d443ba Mon Sep 17 00:00:00 2001 From: ahtlon Date: Thu, 5 Nov 2026 19:01:36 +0100 Subject: [PATCH 12/15] Remove timetable in favour of the new calendar stuff --- ..._remove_timetable_access_authorizations.py | 57 +++++++ app/controllers/aaManager.py | 18 +- app/model/models.py | 39 +---- app/services/door.py | 22 +-- test/test_main.py | 7 - test/test_models.py | 111 +++---------- test/test_services/test_aa_manager.py | 120 ++++++-------- test/test_services/test_door.py | 154 +++--------------- 8 files changed, 158 insertions(+), 370 deletions(-) create mode 100644 alembic/versions/93e8dda267e5_remove_timetable_access_authorizations.py diff --git a/alembic/versions/93e8dda267e5_remove_timetable_access_authorizations.py b/alembic/versions/93e8dda267e5_remove_timetable_access_authorizations.py new file mode 100644 index 0000000..dd211f3 --- /dev/null +++ b/alembic/versions/93e8dda267e5_remove_timetable_access_authorizations.py @@ -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'], + ), + ) diff --git a/app/controllers/aaManager.py b/app/controllers/aaManager.py index 48a133b..9eb758a 100644 --- a/app/controllers/aaManager.py +++ b/app/controllers/aaManager.py @@ -1,7 +1,6 @@ import logging from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import selectinload from sqlmodel import Session, select from app.model.models import ( @@ -12,7 +11,6 @@ from app.model.models import ( GroupDB, GroupResponse, OneShotAccess, - Timetable, ) from app.services.auth import auth_is_admin from app.services.database import add_and_refresh, get_session @@ -30,10 +28,6 @@ def add_accessauth( admin: bool = Depends(auth_is_admin), ): logger.info(f"Creating accessauth with data: {aa}") - if aa.timetables != []: - timetables = [Timetable.model_validate(t) for t in aa.timetables] - else: - timetables = [] if aa.oneshot is not None: oneshot = OneShotAccess.model_validate(aa.oneshot) else: @@ -42,7 +36,6 @@ def add_accessauth( name=aa.name, type=aa.type, is_active=aa.is_active, - timetables=timetables, oneshot=oneshot, ) return add_and_refresh(db, db_aa) @@ -52,11 +45,7 @@ def add_accessauth( 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() + return db.exec(select(AccessAuthorizationDB)).all() @aa_router.get("/{aa_id}", response_model=AccessAuthorizationResponse) @@ -126,11 +115,6 @@ def change_accessauth( if db_aa is None: raise HTTPException(status_code=404, detail="AccessAuthorization not found") aa_data = aa.model_dump(exclude_unset=True, exclude_none=True) - if "timetables" in aa_data and aa_data["timetables"] is not None: - db_aa.timetables.clear() - timetables = [Timetable.model_validate(t) for t in aa_data["timetables"]] - db_aa.timetables = timetables - aa_data.pop("timetables") if "oneshot" in aa_data and aa_data["oneshot"] is not None: oneshot = OneShotAccess.model_validate(aa_data["oneshot"]) db_aa.oneshot = oneshot diff --git a/app/model/models.py b/app/model/models.py index e951e7d..ea5a3ee 100644 --- a/app/model/models.py +++ b/app/model/models.py @@ -1,4 +1,4 @@ -from datetime import UTC, datetime, time +from datetime import UTC, datetime from typing import Literal from pydantic import model_validator @@ -109,7 +109,7 @@ class GroupResponse(GroupBase): #### AccessAuthorization class AccessAuthorizationBase(Base): name: str = Field(index=True) - type: Literal["timetable", "oneshot", "somefuturespec"] + type: Literal["oneshot", "somefuturespec"] is_active: bool @@ -119,9 +119,6 @@ class AccessAuthorizationDB(AccessAuthorizationBase, table=True): groups: list["GroupDB"] = Relationship( back_populates="accessauths", link_model=AaGroupLink ) - timetables: list["Timetable"] = Relationship( - back_populates="accessauth", cascade_delete=True - ) oneshot: "OneShotAccess" = Relationship( back_populates="accessauth", cascade_delete=True ) @@ -139,23 +136,13 @@ class OneShotAccess(OneShotAccessBase, table=True): class AccessAuthorizationCreate(AccessAuthorizationBase): - timetables: list["TimetableCreate"] = [] oneshot: OneShotAccessBase | None = None @model_validator(mode="after") def check_type(self): - if self.type == "timetable": - if not self.timetables: - raise ValueError( - "timetable auths require at least one timetable object" - ) - if self.oneshot is not None: - raise ValueError("timetable auths are not allowed oneshot objects") - elif self.type == "oneshot": + if self.type == "oneshot": if not self.oneshot: raise ValueError("oneshot auths require a oneshot object") - if self.timetables: - raise ValueError("oneshot auths are not allowed timetable objects") elif self.type == "somefuturespec": raise ValueError("somefuturespec is not jet implemented. Please do not use") return self @@ -163,16 +150,14 @@ class AccessAuthorizationCreate(AccessAuthorizationBase): class AccessAuthorizationResponse(AccessAuthorizationBase): id: int - timetables: list["Timetable"] = [] oneshot: OneShotAccessBase | None = None groups: list["GroupDB"] class AccessAuthorizationUpdate(Base): name: str | None = None - type: Literal["timetable", "oneshot", "somefuturespec"] | None = None + type: Literal["oneshot", "somefuturespec"] | None = None is_active: bool | None = None - timetables: list["TimetableCreate"] | None = None oneshot: OneShotAccessBase | None = None @@ -197,19 +182,3 @@ 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 diff --git a/app/services/door.py b/app/services/door.py index 1d2a00b..06dede4 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -1,5 +1,5 @@ import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime from sqlalchemy import exc from sqlmodel import select @@ -122,26 +122,6 @@ def checkAccess(key: str, db: Session): if not auth.is_active: logger.info("AA inactive!") continue - if auth.type == "timetable": - for timetable in auth.timetables: - logger.info(f" checking timetable {timetable.id}") - logger.info( - f" weekday: CUR:{current_weekday} TT:{timetable.weekday}" - ) - if current_weekday == timetable.weekday: - starttime = datetime.combine( - date.today(), - timetable.starttime, - tzinfo=current_time.tzinfo, - ) - endtime = starttime + timedelta(minutes=timetable.duration) - logger.info( - f" comparing time: Start:{starttime} " - f"Current:{current_time} End:{endtime}" - ) - if starttime < current_time < endtime: - logger.info("Access Valid!") - return True if auth.type == "oneshot": logger.info(f" oneshot auth found: {auth.oneshot}") ends_at = auth.oneshot.ends_at diff --git a/test/test_main.py b/test/test_main.py index 204f294..13ddbb1 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1,10 +1,3 @@ -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 diff --git a/test/test_models.py b/test/test_models.py index 59b3deb..25cf166 100644 --- a/test/test_models.py +++ b/test/test_models.py @@ -1,102 +1,35 @@ -import datetime +import pytest +from pydantic import ValidationError -from app.model.models import ( - AaGroupLink, - AccessAuthorizationBase, - AccessAuthorizationCreate, - Card, - GroupBase, - GroupCreate, - TimetableCreate, - Token, - TokenData, - UserBase, - UserCreate, - UserUpdate, -) +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, type="timetable") - 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, type="timetable", 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" diff --git a/test/test_services/test_aa_manager.py b/test/test_services/test_aa_manager.py index 6c30130..a4a2606 100644 --- a/test/test_services/test_aa_manager.py +++ b/test/test_services/test_aa_manager.py @@ -4,7 +4,7 @@ import pytest from sqlalchemy import text from sqlmodel import select -from app.model.models import AccessAuthorizationDB, OneShotAccess +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" @@ -24,29 +24,6 @@ def _post_oneshot_aa(client, auth_headers, name: str, ends_at: str): ) -def test_create_access_auth_tt(client, auth_headers): - """Test creating a new access authorization.""" - aa_data = { - "name": "New tt_AA", - "type": "timetable", - "is_active": True, - "timetables": [ - {"weekday": 1, "starttime": "08:00", "duration": 60}, - {"weekday": 2, "starttime": "09:00", "duration": 90}, - ], - } - - response = client.post("/api/v1/aa/", json=aa_data, headers=auth_headers) - assert response.status_code == 200 - - data = response.json() - assert data["name"] == "New tt_AA" - assert data["is_active"] is True - assert data["type"] == "timetable" - assert "id" in data - assert len(data["timetables"]) == 2 - - def test_create_access_auth_os(client, auth_headers): """Test creating a new access authorization with oneshot type.""" aa_data = { @@ -79,26 +56,49 @@ def test_create_wrong_aa_type(client, auth_headers): assert response.status_code == 422 -def test_get_all_access_auths(client, auth_headers, test_aa_tt): +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_tt.name in aa_names + assert test_aa_os.name in aa_names -def test_get_access_auth_by_id(client, auth_headers, test_aa_tt): +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_tt.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_tt.id - assert data["name"] == test_aa_tt.name + assert data["id"] == test_aa_os.id + assert data["name"] == test_aa_os.name def test_get_nonexistent_access_auth(client, auth_headers): @@ -107,10 +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_tt): +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_tt.id}", headers=auth_headers + f"/api/v1/aa/assign/{test_group.id}/{test_aa_os.id}", headers=auth_headers ) assert response.status_code == 200 @@ -121,49 +121,49 @@ def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa_t def test_assign_already_assigned_access_auth( - client, auth_headers, test_group, test_aa_tt + 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_tt.id}", headers=auth_headers + 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_tt.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_tt): +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_tt.id}", headers=auth_headers + 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_tt.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_tt): +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_tt.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_tt): +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_tt.id}", headers=auth_headers + f"/api/v1/aa/assign/99999/{test_aa_os.id}", headers=auth_headers ) assert response.status_code == 404 @@ -176,12 +176,12 @@ def test_assign_nonexistent_aa(client, auth_headers, test_group): assert response.status_code == 404 -def test_update_access_auth(client, auth_headers, test_aa_tt): +def test_update_access_auth(client, auth_headers, test_aa_os): """Test updating an access authorization.""" update_data = {"name": "Updated AA", "is_active": False} response = client.patch( - f"/api/v1/aa/{test_aa_tt.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 @@ -190,23 +190,6 @@ def test_update_access_auth(client, auth_headers, test_aa_tt): assert data["is_active"] is False -def test_update_access_auth_with_timetables(client, auth_headers, test_aa_tt): - """Test updating an access authorization with new timetables.""" - update_data = { - "timetables": [{"weekday": 5, "starttime": "10:00", "duration": 120}] - } - - response = client.patch( - f"/api/v1/aa/{test_aa_tt.id}", json=update_data, headers=auth_headers - ) - assert response.status_code == 200 - jresponse = response.json() - assert len(jresponse["timetables"]) == 1 - assert jresponse["timetables"][0]["weekday"] == 5 - assert jresponse["timetables"][0]["starttime"] == "10:00:00" - assert jresponse["timetables"][0]["duration"] == 120 - - def test_update_nonexistent_access_auth(client, auth_headers): """Test updating a non-existent access authorization.""" update_data = {"name": "Updated"} @@ -214,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_tt): +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_tt.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_tt.id}", headers=auth_headers) + response = client.get(f"/api/v1/aa/{test_aa_os.id}", headers=auth_headers) assert response.status_code == 404 @@ -231,12 +214,12 @@ def test_delete_nonexistent_access_auth(client, auth_headers): assert response.status_code == 404 -def test_aa_tt_operations_by_non_admin(client, test_aa_tt, 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": []}, + json={"name": "test", "is_active": True}, headers=user_auth_headers, ) assert response.status_code == 403 @@ -247,7 +230,7 @@ def test_aa_tt_operations_by_non_admin(client, test_aa_tt, user_auth_headers): # Try to assign AA response = client.put( - f"/api/v1/aa/assign/1/{test_aa_tt.id}", headers=user_auth_headers + f"/api/v1/aa/assign/1/{test_aa_os.id}", headers=user_auth_headers ) assert response.status_code == 403 @@ -391,4 +374,3 @@ def test_oneshot_local_time_uses_the_dst_rule_of_its_own_date( assert datetime.fromisoformat(winter.json()["oneshot"]["ends_at"]) == datetime( 2029, 12, 27, 11, 0, tzinfo=UTC ) - diff --git a/test/test_services/test_door.py b/test/test_services/test_door.py index 46dcce3..9ed8345 100644 --- a/test/test_services/test_door.py +++ b/test/test_services/test_door.py @@ -7,75 +7,10 @@ from app.model.models import ( Card, GroupDB, OneShotAccess, - Timetable, ) from app.services.door import checkAccess -def test_check_access_with_valid_timetable(db_session): - # Setup: create card with valid access - group = GroupDB(name="Test Group") - db_session.add(group) - db_session.commit() - - card = Card( - key="test-key-123", - group_id=group.id, - enabled=True, - name="test_card", - card_serial="00:00:00:00:00:00:00", - ) - db_session.add(card) - - timetable = Timetable( - weekday=datetime.datetime.weekday(datetime.date.today()), - starttime=datetime.datetime.now().time(), - duration=120, # 2 hours - ) - db_session.add(timetable) - - aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable") - db_session.add(aa) - aa.timetables = [timetable] - group.accessauths = [aa] - - db_session.commit() - - # Test: access should be granted within time window - assert checkAccess("test-key-123", db_session) - - -def test_check_access_outside_hours(db_session): - # Test when current time is outside valid hours - group = GroupDB(name="Test Group") - db_session.add(group) - db_session.commit() - - card = Card( - key="test-key-123", - group_id=group.id, - enabled=True, - name="test_card", - card_serial="00:00:00:00:00:00:00", - ) - db_session.add(card) - - timetable = Timetable( - weekday=datetime.datetime.weekday(datetime.date.today()), - starttime=datetime.time(1, 0), - duration=1, # 2 hours - ) - db_session.add(timetable) - - aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable") - db_session.add(aa) - aa.timetables = [timetable] - group.accessauths = [aa] - - db_session.commit() - assert not checkAccess("test-key-123", db_session) - - def test_check_access_with_valid_oneshot(db_session): # Setup: create card with valid access group = GroupDB(name="Test Group") @@ -129,16 +64,13 @@ def test_check_access_with_inactive_aa(db_session): ) 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) - - aa = AccessAuthorizationDB(name="Test AA", is_active=False, type="timetable") + 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() @@ -161,16 +93,13 @@ def test_check_access_with_inactive_card(db_session): ) 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) - - aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable") + db_session.add(oneshot) + aa = AccessAuthorizationDB(name="Test oneshot AA", is_active=True, type="oneshot") db_session.add(aa) - aa.timetables = [timetable] + aa.oneshot = oneshot group.accessauths = [aa] db_session.commit() @@ -193,27 +122,23 @@ def test_regression_check_access_issue_28(db_session): ) db_session.add(card) - # Creating timetable with inactive AA - timetable1 = Timetable( - weekday=datetime.datetime.weekday(datetime.date.today()), - starttime=datetime.datetime.now().time(), - duration=120, # 2 hours + # Creating oneshot with inactive AA + oneshot1 = OneShotAccess( + uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1) ) - db_session.add(timetable1) - aa1 = AccessAuthorizationDB(name="First AA", is_active=False, type="timetable") + db_session.add(oneshot1) + aa1 = AccessAuthorizationDB(name="First AA", is_active=False, type="oneshot") db_session.add(aa1) - aa1.timetables = [timetable1] + aa1.oneshot = oneshot1 - # Creating timetable with active AA - timetable2 = Timetable( - weekday=datetime.datetime.weekday(datetime.date.today()), - starttime=datetime.datetime.now().time(), - duration=120, # 2 hours + # Creating oneshot with active AA + oneshot2 = OneShotAccess( + uses=1, ends_at=datetime.datetime.now() + datetime.timedelta(days=1) ) - db_session.add(timetable2) - aa2 = AccessAuthorizationDB(name="Second AA", is_active=True, type="timetable") + db_session.add(oneshot2) + aa2 = AccessAuthorizationDB(name="Second AA", is_active=True, type="oneshot") db_session.add(aa2) - aa2.timetables = [timetable2] + aa2.oneshot = oneshot2 group.accessauths = [aa1, aa2] @@ -269,38 +194,3 @@ def test_check_access_oneshot_expired(db_session, system_tz): _create_card_with_oneshot(db_session, ends_at) assert not checkAccess("test-key-123", db_session) - - -@pytest.mark.parametrize("system_tz", [BERLIN_TZ], indirect=True) -def test_check_access_timetable_window_is_local_wall_clock(db_session, system_tz): - """Timetables are local wall-clock times and still work with an aware door clock.""" - now_local = datetime.datetime.now().astimezone() - - group = GroupDB(name="Test Group") - db_session.add(group) - db_session.commit() - - card = Card( - key="test-key-123", - group_id=group.id, - enabled=True, - name="test_card", - card_serial="00:00:00:00:00:00:00", - ) - db_session.add(card) - - timetable = Timetable( - weekday=now_local.weekday(), - starttime=now_local.time(), - duration=120, # 2 hours - ) - db_session.add(timetable) - - aa = AccessAuthorizationDB(name="Test AA", is_active=True, type="timetable") - db_session.add(aa) - aa.timetables = [timetable] - group.accessauths = [aa] - - db_session.commit() - - assert checkAccess("test-key-123", db_session) -- 2.54.0 From db627c34dc0f163831661c6d406507bbd2c66fa8 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Thu, 5 Nov 2026 19:37:26 +0100 Subject: [PATCH 13/15] Dang it bender... also config --- app/services/door.py | 3 +-- pyproject.toml | 12 ++++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/services/door.py b/app/services/door.py index 06dede4..fda34ba 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -1,5 +1,5 @@ import logging -from datetime import date, datetime +from datetime import datetime from sqlalchemy import exc from sqlmodel import select @@ -115,7 +115,6 @@ def checkAccess(key: str, db: Session): logger.info("Access Valid!") return True - current_weekday = datetime.weekday(date.today()) current_time = datetime.now().astimezone() for auth in card.group.accessauths: logger.info(f"checking auth: {auth.name}") diff --git a/pyproject.toml b/pyproject.toml index 57fd44b..a8936a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,3 +56,15 @@ select = [ "I", ] ignore = ["B008"] + +[tool.pytest.ini_options] +testpaths = ["test"] + +[tool.coverage.run] +source = ["app"] +relative_files = true + +[tool.coverage.report] +fail_under = 65 +show_missing = false +skip_covered = true \ No newline at end of file -- 2.54.0 From 9810a4f810d1741af8195cc63d765407c2af5fee Mon Sep 17 00:00:00 2001 From: ahtlon Date: Thu, 5 Nov 2026 22:45:23 +0100 Subject: [PATCH 14/15] Just had a bunch of problems with tests being polluted by local .env This should fix that. test_settings.py written by qwen3.8 Assisted-by: local-qwen3.8 --- app/services/settings.py | 4 +- test/conftest.py | 33 ++++++++-- test/test_services/test_settings.py | 93 +++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 test/test_services/test_settings.py diff --git a/app/services/settings.py b/app/services/settings.py index 6d95295..6d8c329 100644 --- a/app/services/settings.py +++ b/app/services/settings.py @@ -44,7 +44,7 @@ def _create_settings(): ] if missing: logger.critical( - "Missing environment variable for scanner start: %s" + "Missing environment variable for scanner start: %s " "Card scanner and related funcionality is disabled!", ", ".join(missing), ) @@ -64,7 +64,7 @@ def _create_settings(): ] if missing: logger.critical( - "Missing environment variable for caldav handling: %s" + "Missing environment variable for caldav handling: %s " "Caldav handling and related funcionality is disabled!", ", ".join(missing), ) diff --git a/test/conftest.py b/test/conftest.py index 0a4a797..74ecd70 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -10,17 +10,38 @@ from sqlalchemy import inspect from sqlalchemy.pool import StaticPool from sqlmodel import Session, SQLModel, create_engine, select -os.environ.setdefault("SECRET_KEY", "ff" * 16) -os.environ.setdefault("MOCK", "True") -os.environ.setdefault("SQLALCHEMY_DATABASE_URL", "sqlite://") -os.environ.setdefault("DISABLE_CARDS", "True") +# 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", +} -import app.services.database as database_service + +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( - os.environ["SQLALCHEMY_DATABASE_URL"], + TEST_ENV["SQLALCHEMY_DATABASE_URL"], connect_args={"check_same_thread": False}, poolclass=StaticPool, ) diff --git a/test/test_services/test_settings.py b/test/test_services/test_settings.py new file mode 100644 index 0000000..dbb5976 --- /dev/null +++ b/test/test_services/test_settings.py @@ -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 -- 2.54.0 From 1d37be53172f918e2273b8617ee5cab28e5ccd04 Mon Sep 17 00:00:00 2001 From: ahtlon Date: Thu, 5 Nov 2026 22:48:26 +0100 Subject: [PATCH 15/15] Move caldav_handler to caldav.py Make the sleep interruptable Add debug endpoints for caldav - gonna make a new endpoint for them i think... --- app/controllers/debugManager.py | 25 +++++++++++++ app/main.py | 15 +++----- app/services/caldav.py | 62 +++++++++++++++++++++++++++++++-- app/services/door.py | 10 ++---- 4 files changed, 92 insertions(+), 20 deletions(-) diff --git a/app/controllers/debugManager.py b/app/controllers/debugManager.py index e1037bb..2d6cc6e 100644 --- a/app/controllers/debugManager.py +++ b/app/controllers/debugManager.py @@ -6,6 +6,7 @@ from sqlmodel import Session, select from app.model.models import Card from app.services.auth import auth_is_admin +from app.services.caldav import get_caldav_handler from app.services.database import add_and_refresh, get_session logger = logging.getLogger(__name__) @@ -63,3 +64,27 @@ def list_all_cards(db: Session = Depends(get_session)): for i in cards: out.append({i.key: i.group.name}) return out + + +@debug_router.get("/calGetActiveEvents") +def cal_get_active_events(db: Session = Depends(get_session)): + handler = get_caldav_handler() + if handler is None: + raise HTTPException(503, "Caldav handler not initialized") + return handler.get_active_events() + + +@debug_router.get("/calGetFutureEvents") +def cal_get_future_events(db: Session = Depends(get_session)): + handler = get_caldav_handler() + if handler is None: + raise HTTPException(503, "Caldav handler not initialized") + return handler.get_future_events() + + +@debug_router.get("/calGetActiveGroups") +def cal_get_active_groups(db: Session = Depends(get_session)): + handler = get_caldav_handler() + if handler is None: + raise HTTPException(503, "Caldav handler not initialized") + return handler.get_active_groups() diff --git a/app/main.py b/app/main.py index 6c7d156..db0f527 100644 --- a/app/main.py +++ b/app/main.py @@ -14,9 +14,9 @@ from app.controllers import ( userManager, ) from app.services.auth import create_first_user, token_router -from app.services.caldav import CaldavEventCache +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_caldav_handler, init_controller +from app.services.door import DoorController, init_controller from app.services.scanner import BackgroundScanner from app.services.settings import settings @@ -24,12 +24,6 @@ logger = logging.getLogger(__name__) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") scanner = BackgroundScanner(db=get_db_session()) -caldav_handler = CaldavEventCache( - url=settings.caldav_url, - username=settings.caldav_user, - password=settings.caldav_password, - calendarNames=settings.caldav_calendars, -) logging.basicConfig(level=logging.INFO) @@ -47,14 +41,13 @@ async def lifespan(app: FastAPI): if not settings.disable_cards: scanner.start() - if not settings.disable_caldav: - caldav_handler.start() - init_caldav_handler(caldav_handler) + init_caldav_handler() logger.info("-" * 63) logger.info("---- Documentation is at http://127.0.0.1:8000/api/v1/docs ----") logger.info("-" * 63) yield + stop_caldav_handler() # scanner.stop() diff --git a/app/services/caldav.py b/app/services/caldav.py index c15f8dc..5112e47 100644 --- a/app/services/caldav.py +++ b/app/services/caldav.py @@ -1,6 +1,5 @@ import logging import threading -import time from datetime import UTC, date, datetime, timedelta from caldav import get_calendars @@ -19,18 +18,21 @@ class CaldavEventCache: 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") @@ -41,7 +43,7 @@ class CaldavEventCache: self.refresh() except Exception as e: logger.error(f"Error updating CaldavEventCache: {e}", exc_info=True) - time.sleep(settings.caldav_refresh_interval) + self._stopped.wait(settings.caldav_refresh_interval) def refresh(self, daysBack=1, daysAhead=7): calendars = get_calendars( @@ -113,6 +115,28 @@ class CaldavEventCache: 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) @@ -126,3 +150,37 @@ class CaldavEventCache: 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 diff --git a/app/services/door.py b/app/services/door.py index fda34ba..3b512b1 100644 --- a/app/services/door.py +++ b/app/services/door.py @@ -5,6 +5,7 @@ from sqlalchemy import exc from sqlmodel import select from app.model.models import Card, OneShotAccess +from app.services.caldav import get_caldav_handler from app.services.database import Session, add_and_refresh logger = logging.getLogger(__name__) @@ -65,7 +66,6 @@ class DoorController: _contoller: DoorController | None = None -_caldav_handler = None def init_controller(ctrl: DoorController): @@ -73,11 +73,6 @@ def init_controller(ctrl: DoorController): _contoller = ctrl -def init_caldav_handler(handler): - global _caldav_handler - _caldav_handler = handler - - def get_controller(): if _contoller is None: raise RuntimeError("DoorController not initialized.") @@ -111,7 +106,8 @@ def checkAccess(key: str, db: Session): logger.info("Card Inactive!") return False - if _caldav_handler and _caldav_handler.is_group_active(card.group.name): + caldav_handler = get_caldav_handler() + if caldav_handler and caldav_handler.is_group_active(card.group.name): logger.info("Access Valid!") return True -- 2.54.0