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.
This commit is contained in:
Martin Weinelt 2026-07-06 13:17:16 +02:00
parent 6e344b8b88
commit 497a50e6b6
No known key found for this signature in database
GPG Key ID: 87C1E9888F856759
13 changed files with 48 additions and 52 deletions

View File

@ -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

View File

@ -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':

View File

@ -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"

View File

@ -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

View File

@ -3,7 +3,7 @@ from pydantic.json_schema import SkipJsonSchema
class MediaRecordingsSummaryQueryParams(BaseModel):
timezone: str = "utc"
timezone: str = "UTC"
cameras: str | None = "all"

View File

@ -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):

View File

@ -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
@ -837,7 +837,7 @@ 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(tz_name.replace(",", "/"))).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()

View File

@ -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
@ -126,7 +126,7 @@ 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(tz_name.replace(",", "/"))).utcoffset()
)
end_date = start_date + timedelta(hours=1) - timedelta(milliseconds=1)
start_ts = start_date.timestamp()

View File

@ -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 = (

View File

@ -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
@ -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(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(tz_name)
except (ValueError, ZoneInfoNotFoundError):
tz = None
if tz is None:
tz = datetime.UTC

View File

@ -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

View File

@ -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

View File

@ -3,9 +3,8 @@
import datetime
import logging
import math
from zoneinfo import ZoneInfoNotFoundError
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytz
from tzlocal import get_localzone
logger = logging.getLogger(__name__)
@ -13,7 +12,7 @@ logger = logging.getLogger(__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(tz_name)).utcoffset().total_seconds()
)
hours_offset = int(seconds_offset / 60 / 60)
minutes_offset = int(seconds_offset / 60 - hours_offset * 60)
@ -26,7 +25,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 +83,8 @@ def get_dst_transitions(
continuous periods with the same UTC offset
"""
try:
tz = pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
tz = ZoneInfo(tz_name)
except (ValueError, ZoneInfoNotFoundError):
# If timezone is invalid, return single period with no offset
return [(start_time, end_time, 0)]