From 135a95d8f213a99a764f292d05429989c62ef242 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Mon, 12 Oct 2026 00:49:33 +0200 Subject: [PATCH] Fix various warnings and deprecations (#23641) * Migrate to fastapi lifespan for startup events The `on_event` style decorator is deprecated https://fastapi.tiangolo.com/advanced/events/ * Migrate to pydantic TypeAdapter https://pydantic.dev/docs/validation/2.12/get-started/migration/#introduction-of-typeadapter * Replace pytz with native tz-aware objects and zoneinfo library Supported from Python 3.9, see https://peps.python.org/pep-0615/. Also https://blog.ganssle.io/articles/2019/11/utcnow.html. Also provide tzdata as new dependency to supplement systems without local tzdata information. * Provide lookup for normalized timezone names This provides a helper to look up the correct timezone name independent of the casing of the given timezone name. * Test timezone naming normalization and error handling --- docker/main/requirements-wheels.txt | 2 +- docs/static/frigate-api.yaml | 14 +++---- .../api/defs/query/app_query_parameters.py | 2 +- .../api/defs/query/events_query_parameters.py | 6 +-- .../defs/query/recordings_query_parameters.py | 2 +- .../api/defs/query/review_query_parameters.py | 2 +- frigate/api/fastapi_app.py | 21 +++++----- frigate/api/media.py | 7 +++- frigate/api/preview.py | 7 +++- frigate/api/record.py | 2 +- frigate/record/export.py | 12 +++--- frigate/test/http_api/test_http_media.py | 39 +++++++++---------- frigate/test/http_api/test_http_review.py | 2 +- frigate/test/test_object_detector.py | 18 ++++++--- frigate/test/test_tz.py | 20 ++++++++++ frigate/util/time.py | 27 ++++++++++--- 16 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 frigate/test/test_tz.py diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index c603e794cb..23645e382b 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -25,7 +25,7 @@ peewee_migrate == 2.3.* psutil == 7.2.* pydantic == 2.13.* git+https://github.com/fbcotter/py3nvml#egg=py3nvml -pytz == 2026.* +tzdata == 2026.5 pyzmq == 27.2.* ruamel.yaml == 0.19.* tzlocal == 5.4.4 diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 014d0699d2..4d61778cf8 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2131,7 +2131,7 @@ paths: required: false schema: type: string - default: utc + default: UTC title: Timezone responses: '200': @@ -3266,7 +3266,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone responses: '200': @@ -4666,7 +4666,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone responses: '200': @@ -4928,7 +4928,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone - name: min_score in: query @@ -5008,7 +5008,7 @@ paths: anyOf: - type: string - type: 'null' - default: utc + default: UTC title: Timezone - name: has_clip in: query @@ -7367,7 +7367,7 @@ paths: required: false schema: type: string - default: utc + default: UTC title: Timezone - name: cameras in: query @@ -7417,7 +7417,7 @@ paths: required: false schema: type: string - default: utc + default: UTC title: Timezone responses: '200': diff --git a/frigate/api/defs/query/app_query_parameters.py b/frigate/api/defs/query/app_query_parameters.py index 626d39679b..26974d59b9 100644 --- a/frigate/api/defs/query/app_query_parameters.py +++ b/frigate/api/defs/query/app_query_parameters.py @@ -7,4 +7,4 @@ class AppTimelineHourlyQueryParameters(BaseModel): after: float | None = None before: float | None = None limit: int | None = 200 - timezone: str | None = "utc" + timezone: str | None = "UTC" diff --git a/frigate/api/defs/query/events_query_parameters.py b/frigate/api/defs/query/events_query_parameters.py index 80cc77c721..1cbc2d80af 100644 --- a/frigate/api/defs/query/events_query_parameters.py +++ b/frigate/api/defs/query/events_query_parameters.py @@ -40,7 +40,7 @@ class EventsQueryParams(BaseModel): max_length: float | None = None event_id: str | None = None sort: str | None = None - timezone: str | None = "utc" + timezone: str | None = "UTC" class EventsSearchQueryParams(BaseModel): @@ -68,7 +68,7 @@ class EventsSearchQueryParams(BaseModel): has_clip: bool | None = None has_snapshot: bool | None = None is_submitted: bool | None = None - timezone: str | None = "utc" + timezone: str | None = "UTC" min_score: float | None = None max_score: float | None = None min_speed: float | None = None @@ -78,6 +78,6 @@ class EventsSearchQueryParams(BaseModel): class EventsSummaryQueryParams(BaseModel): - timezone: str | None = "utc" + timezone: str | None = "UTC" has_clip: int | None = None has_snapshot: int | None = None diff --git a/frigate/api/defs/query/recordings_query_parameters.py b/frigate/api/defs/query/recordings_query_parameters.py index 770da96bb8..cd7b4221c2 100644 --- a/frigate/api/defs/query/recordings_query_parameters.py +++ b/frigate/api/defs/query/recordings_query_parameters.py @@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema class MediaRecordingsSummaryQueryParams(BaseModel): - timezone: str = "utc" + timezone: str = "UTC" cameras: str | None = "all" diff --git a/frigate/api/defs/query/review_query_parameters.py b/frigate/api/defs/query/review_query_parameters.py index b16146a9bc..16f759f42a 100644 --- a/frigate/api/defs/query/review_query_parameters.py +++ b/frigate/api/defs/query/review_query_parameters.py @@ -19,7 +19,7 @@ class ReviewSummaryQueryParams(BaseModel): cameras: str = "all" labels: str = "all" zones: str = "all" - timezone: str = "utc" + timezone: str = "UTC" class ReviewActivityMotionQueryParams(BaseModel): diff --git a/frigate/api/fastapi_app.py b/frigate/api/fastapi_app.py index 3b712dae6d..4e666c56f8 100644 --- a/frigate/api/fastapi_app.py +++ b/frigate/api/fastapi_app.py @@ -1,6 +1,7 @@ import asyncio import logging import re +from contextlib import asynccontextmanager from fastapi import Depends, FastAPI, Request from fastapi.responses import JSONResponse @@ -81,9 +82,20 @@ def create_fastapi_app( config_holder: ConfigHolder | None = None, notice_registry: NoticeRegistry | None = None, ): + @asynccontextmanager + async def lifespan(app: FastAPI): + logger.info("FastAPI started") + asyncio.create_task( + debug_replay_auto_stop_watchdog( + replay_manager, frigate_config, config_publisher + ) + ) + yield + logger.info("Starting FastAPI app") app = FastAPI( debug=False, + lifespan=lifespan, swagger_ui_parameters={"apisSorter": "alpha", "operationsSorter": "alpha"}, dependencies=[Depends(require_admin_by_default())] if enforce_default_admin @@ -119,15 +131,6 @@ def create_fastapi_app( database.close() return response - @app.on_event("startup") - async def startup(): - logger.info("FastAPI started") - asyncio.create_task( - debug_replay_auto_stop_watchdog( - replay_manager, frigate_config, config_publisher - ) - ) - # Rate limiter (used for login endpoint) if frigate_config.auth.failed_login_rate_limit is None: limiter.enabled = False diff --git a/frigate/api/media.py b/frigate/api/media.py index 2305f06eb6..2174d732b6 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -14,10 +14,10 @@ from enum import Enum from pathlib import Path as FilePath from typing import IO, Any from urllib.parse import unquote +from zoneinfo import ZoneInfo import cv2 import numpy as np -import pytz from fastapi import APIRouter, Depends, Path, Query, Request, Response from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pathvalidate import sanitize_filename @@ -64,6 +64,7 @@ from frigate.util.recording_coverage import ( plan_clip, resolve_coverage, ) +from frigate.util.time import get_normalized_tz_name logger = logging.getLogger(__name__) @@ -837,7 +838,9 @@ async def vod_hour( parts = year_month.split("-") start_date = ( datetime(int(parts[0]), int(parts[1]), day, hour, tzinfo=UTC) - - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() + - datetime.now( + ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/"))) + ).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) start_ts = start_date.timestamp() diff --git a/frigate/api/preview.py b/frigate/api/preview.py index 1047591434..81de23d003 100644 --- a/frigate/api/preview.py +++ b/frigate/api/preview.py @@ -5,8 +5,8 @@ import logging import os import threading from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo -import pytz from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse @@ -22,6 +22,7 @@ from frigate.api.defs.response.preview_response import ( from frigate.api.defs.tags import Tags from frigate.const import BASE_DIR, CACHE_DIR, PREVIEW_FRAME_TYPE from frigate.models import Previews +from frigate.util.time import get_normalized_tz_name logger = logging.getLogger(__name__) @@ -126,7 +127,9 @@ def preview_hour( parts = year_month.split("-") start_date = ( datetime(int(parts[0]), int(parts[1]), int(day), int(hour), tzinfo=UTC) - - datetime.now(pytz.timezone(tz_name.replace(",", "/"))).utcoffset() + - datetime.now( + ZoneInfo(get_normalized_tz_name(tz_name.replace(",", "/"))) + ).utcoffset() ) end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1) start_ts = start_date.timestamp() diff --git a/frigate/api/record.py b/frigate/api/record.py index 7501b408da..2f32e6a770 100644 --- a/frigate/api/record.py +++ b/frigate/api/record.py @@ -175,7 +175,7 @@ async def all_recordings_summary( @router.get( "/{camera_name}/recordings/summary", dependencies=[Depends(require_camera_access)] ) -async def recordings_summary(camera_name: str, timezone: str = "utc"): +async def recordings_summary(camera_name: str, timezone: str = "UTC"): """Returns hourly summary for recordings of given camera""" time_range_query = ( diff --git a/frigate/record/export.py b/frigate/record/export.py index 9983867976..2ec58d664a 100644 --- a/frigate/record/export.py +++ b/frigate/record/export.py @@ -15,8 +15,8 @@ from dataclasses import dataclass from enum import Enum from pathlib import Path from typing import Any, cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -import pytz from pathvalidate import sanitize_filename from peewee import DoesNotExist @@ -49,7 +49,7 @@ from frigate.util.recording_coverage import ( stream_media_summary, ) from frigate.util.services import get_video_properties -from frigate.util.time import is_current_hour +from frigate.util.time import get_normalized_tz_name, is_current_hour logger = logging.getLogger(__name__) @@ -874,8 +874,8 @@ class RecordingExporter(threading.Thread): tz_name = self.config.ui.timezone if tz_name: try: - tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: + tz = ZoneInfo(get_normalized_tz_name(tz_name)) + except (ValueError, ZoneInfoNotFoundError): tz = None if tz is not None: return datetime.datetime.fromtimestamp(timestamp, tz=tz).strftime( @@ -1038,8 +1038,8 @@ class RecordingExporter(threading.Thread): tz: datetime.tzinfo | None = None if tz_name: try: - tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: + tz = ZoneInfo(get_normalized_tz_name(tz_name)) + except (ValueError, ZoneInfoNotFoundError): tz = None if tz is None: tz = datetime.UTC diff --git a/frigate/test/http_api/test_http_media.py b/frigate/test/http_api/test_http_media.py index fd5b6b53a0..4c536a5671 100644 --- a/frigate/test/http_api/test_http_media.py +++ b/frigate/test/http_api/test_http_media.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from unittest.mock import patch +from zoneinfo import ZoneInfo -import pytz from fastapi import Request from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user @@ -128,16 +128,16 @@ class TestHttpMedia(BaseTestHttp): In 2024, DST in America/New_York transitions on March 10, 2024 at 2:00 AM Clocks spring forward from 2:00 AM to 3:00 AM (EST to EDT) """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # March 9, 2024 at 12:00 PM EST (before DST) - march_9_noon = tz.localize(datetime(2024, 3, 9, 12, 0, 0)).timestamp() + march_9_noon = datetime(2024, 3, 9, 12, 0, 0, tzinfo=tz).timestamp() # March 10, 2024 at 12:00 PM EDT (after DST transition) - march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() + march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() # March 11, 2024 at 12:00 PM EDT (after DST) - march_11_noon = tz.localize(datetime(2024, 3, 11, 12, 0, 0)).timestamp() + march_11_noon = datetime(2024, 3, 11, 12, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Insert recordings for each day @@ -201,19 +201,16 @@ class TestHttpMedia(BaseTestHttp): In 2024, DST in America/New_York transitions on November 3, 2024 at 2:00 AM Clocks fall back from 2:00 AM to 1:00 AM (EDT to EST) """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # November 2, 2024 at 12:00 PM EDT (before DST transition) - nov_2_noon = tz.localize(datetime(2024, 11, 2, 12, 0, 0)).timestamp() + nov_2_noon = datetime(2024, 11, 2, 12, 0, 0, tzinfo=tz).timestamp() # November 3, 2024 at 12:00 PM EST (after DST transition) - # Need to specify is_dst=False to get the time after fall back - nov_3_noon = tz.localize( - datetime(2024, 11, 3, 12, 0, 0), is_dst=False - ).timestamp() + nov_3_noon = datetime(2024, 11, 3, 12, 0, 0, tzinfo=tz).timestamp() # November 4, 2024 at 12:00 PM EST (after DST) - nov_4_noon = tz.localize(datetime(2024, 11, 4, 12, 0, 0)).timestamp() + nov_4_noon = datetime(2024, 11, 4, 12, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Insert recordings for each day @@ -274,13 +271,13 @@ class TestHttpMedia(BaseTestHttp): """ Test recordings summary with multiple cameras across DST boundary. """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # March 9, 2024 at 10:00 AM EST (before DST) - march_9_morning = tz.localize(datetime(2024, 3, 9, 10, 0, 0)).timestamp() + march_9_morning = datetime(2024, 3, 9, 10, 0, 0, tzinfo=tz).timestamp() # March 10, 2024 at 3:00 PM EDT (after DST transition) - march_10_afternoon = tz.localize(datetime(2024, 3, 10, 15, 0, 0)).timestamp() + march_10_afternoon = datetime(2024, 3, 10, 15, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Override allowed cameras for this test to include both @@ -343,15 +340,15 @@ class TestHttpMedia(BaseTestHttp): """ Test recordings that span the exact DST transition time. """ - tz = pytz.timezone("America/New_York") + tz = ZoneInfo("America/New_York") # March 10, 2024 at 1:00 AM EST (1 hour before DST transition) # At 2:00 AM, clocks jump to 3:00 AM - before_transition = tz.localize(datetime(2024, 3, 10, 1, 0, 0)).timestamp() + before_transition = datetime(2024, 3, 10, 1, 0, 0, tzinfo=tz).timestamp() # Recording that spans the transition (1:00 AM to 3:30 AM EDT) # This is 1.5 hours of actual time but spans the "missing" hour - after_transition = tz.localize(datetime(2024, 3, 10, 3, 30, 0)).timestamp() + after_transition = datetime(2024, 3, 10, 3, 30, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: Recordings.insert( @@ -411,7 +408,7 @@ class TestHttpMedia(BaseTestHttp): # Test with UTC timezone response = client.get( - "/recordings/summary", params={"timezone": "utc", "cameras": "all"} + "/recordings/summary", params={"timezone": "UTC", "cameras": "all"} ) assert response.status_code == 200 @@ -442,8 +439,8 @@ class TestHttpMedia(BaseTestHttp): """ Test recordings summary filtered to a single camera. """ - tz = pytz.timezone("America/New_York") - march_10_noon = tz.localize(datetime(2024, 3, 10, 12, 0, 0)).timestamp() + tz = ZoneInfo("America/New_York") + march_10_noon = datetime(2024, 3, 10, 12, 0, 0, tzinfo=tz).timestamp() with AuthTestClient(self.app) as client: # Insert recordings for both cameras diff --git a/frigate/test/http_api/test_http_review.py b/frigate/test/http_api/test_http_review.py index 1245ae9ad7..54bcee8172 100644 --- a/frigate/test/http_api/test_http_review.py +++ b/frigate/test/http_api/test_http_review.py @@ -342,7 +342,7 @@ class TestHttpReview(BaseTestHttp): "cameras": "front_door", "labels": "all", "zones": "all", - "timezone": "utc", + "timezone": "UTC", } response = client.get("/review/summary", params=params) assert response.status_code == 200 diff --git a/frigate/test/test_object_detector.py b/frigate/test/test_object_detector.py index e50723a0fa..1216c49d76 100644 --- a/frigate/test/test_object_detector.py +++ b/frigate/test/test_object_detector.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, Mock, patch import numpy as np import zmq -from pydantic import parse_obj_as +from pydantic import TypeAdapter import frigate.detectors as detectors import frigate.object_detection.base @@ -20,8 +20,8 @@ class TestLocalObjectDetector(unittest.TestCase): "frigate.detectors.api_types", {det_type: Mock() for det_type in DetectorTypeEnum}, ): - test_cfg = parse_obj_as( - DetectorConfig, ({"type": det_type, "model": {}}) + test_cfg = TypeAdapter(DetectorConfig).validate_python( + {"type": det_type, "model": {}} ) test_cfg.model.path = "/test/modelpath" test_obj = frigate.object_detection.base.LocalObjectDetector( @@ -45,7 +45,9 @@ class TestLocalObjectDetector(unittest.TestCase): TEST_DATA = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32]) test_obj_detect = frigate.object_detection.base.LocalObjectDetector( - detector_config=parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) + detector_config=TypeAdapter(DetectorConfig).validate_python( + {"type": "cpu", "model": {}} + ) ) mock_det_api = mock_cputfl.return_value @@ -68,7 +70,9 @@ class TestLocalObjectDetector(unittest.TestCase): TEST_DATA = np.zeros((1, 32, 32, 3), np.uint8) TEST_DETECT_RESULT = np.ndarray([1, 2, 4, 8, 16, 32]) - test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) + test_cfg = TypeAdapter(DetectorConfig).validate_python( + {"type": "cpu", "model": {}} + ) test_cfg.model.input_tensor = InputTensorEnum.nchw test_obj_detect = frigate.object_detection.base.LocalObjectDetector( @@ -117,7 +121,9 @@ class TestLocalObjectDetector(unittest.TestCase): 4: "label-5", } - test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}}) + test_cfg = TypeAdapter(DetectorConfig).validate_python( + {"type": "cpu", "model": {}} + ) test_cfg.model = ModelConfig() test_obj_detect = frigate.object_detection.base.LocalObjectDetector( detector_config=test_cfg, diff --git a/frigate/test/test_tz.py b/frigate/test/test_tz.py new file mode 100644 index 0000000000..abb5413037 --- /dev/null +++ b/frigate/test/test_tz.py @@ -0,0 +1,20 @@ +import unittest + +from frigate.util.time import get_normalized_tz_name + + +class TestGetNormalizedTzName(unittest.TestCase): + def test_valid(self): + cases = [ + ("utc", "UTC"), + ("america/new_york", "America/New_York"), + ("us/eastern", "US/Eastern"), + ] + + for tz_name, expected in cases: + with self.subTest(tz_name=tz_name): + self.assertEqual(get_normalized_tz_name(tz_name), expected) + + def test_invalid(self): + with self.assertRaisesRegex(ValueError, r"Unknown timezone: foo/bar"): + get_normalized_tz_name("foo/bar") diff --git a/frigate/util/time.py b/frigate/util/time.py index 8d2223ce7c..49524f8683 100644 --- a/frigate/util/time.py +++ b/frigate/util/time.py @@ -3,17 +3,32 @@ import datetime import logging import math -from zoneinfo import ZoneInfoNotFoundError +from typing import Final +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones -import pytz from tzlocal import get_localzone logger = logging.getLogger(__name__) +TIMEZONE_CASEFOLD_INDEX: Final = { + timezone.casefold(): timezone for timezone in available_timezones() +} + + +def get_normalized_tz_name(tz_name: str) -> str: + """Return the canonical name for a case-insensitive IANA timezone.""" + tz = TIMEZONE_CASEFOLD_INDEX.get(tz_name.casefold()) + if tz: + return tz + + raise ValueError(f"Unknown timezone: {tz_name}") + def get_tz_modifiers(tz_name: str) -> tuple[str, str, float]: seconds_offset = ( - datetime.datetime.now(pytz.timezone(tz_name)).utcoffset().total_seconds() + datetime.datetime.now(ZoneInfo(get_normalized_tz_name(tz_name))) + .utcoffset() + .total_seconds() ) hours_offset = int(seconds_offset / 60 / 60) minutes_offset = int(seconds_offset / 60 - hours_offset * 60) @@ -26,7 +41,7 @@ def get_tomorrow_at_time(hour: int) -> datetime.datetime: """Returns the datetime of the following day at 2am.""" try: tomorrow = datetime.datetime.now(get_localzone()) + datetime.timedelta(days=1) - except ZoneInfoNotFoundError: + except (ValueError, ZoneInfoNotFoundError): tomorrow = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) logger.warning( "Using utc for maintenance due to missing or incorrect timezone set" @@ -84,8 +99,8 @@ def get_dst_transitions( continuous periods with the same UTC offset """ try: - tz = pytz.timezone(tz_name) - except pytz.UnknownTimeZoneError: + tz = ZoneInfo(get_normalized_tz_name(tz_name)) + except (ValueError, ZoneInfoNotFoundError): # If timezone is invalid, return single period with no offset return [(start_time, end_time, 0)]