Make iot time timezone aware (#1147)

Also makes on_since for iot devices use device time.
Changes the return value for device.timezone to be tzinfo instead of a dict.
This commit is contained in:
Steven B. 2024-10-08 08:16:51 +01:00 committed by GitHub
parent 8bb2cca7cf
commit 9641edcbc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 289 additions and 45 deletions

View File

@ -40,7 +40,7 @@ async def state(ctx, dev: Device):
echo(f"Port: {dev.port}") echo(f"Port: {dev.port}")
echo(f"Device state: {dev.is_on}") echo(f"Device state: {dev.is_on}")
echo(f"Time: {dev.time} (tz: {dev.timezone}") echo(f"Time: {dev.time} (tz: {dev.timezone})")
echo(f"Hardware: {dev.hw_info['hw_ver']}") echo(f"Hardware: {dev.hw_info['hw_ver']}")
echo(f"Software: {dev.hw_info['sw_ver']}") echo(f"Software: {dev.hw_info['sw_ver']}")
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")

View File

@ -109,7 +109,7 @@ import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, tzinfo
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from warnings import warn from warnings import warn
@ -377,7 +377,7 @@ class Device(ABC):
@property @property
@abstractmethod @abstractmethod
def timezone(self) -> dict: def timezone(self) -> tzinfo:
"""Return the timezone and time_difference.""" """Return the timezone and time_difference."""
@property @property

View File

@ -18,7 +18,7 @@ import functools
import inspect import inspect
import logging import logging
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, tzinfo
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from ..device import Device, WifiNetwork from ..device import Device, WifiNetwork
@ -299,7 +299,7 @@ class IotDevice(Device):
self._set_sys_info(self._last_update["system"]["get_sysinfo"]) self._set_sys_info(self._last_update["system"]["get_sysinfo"])
for module in self._modules.values(): for module in self._modules.values():
module._post_update_hook() await module._post_update_hook()
if not self._features: if not self._features:
await self._initialize_features() await self._initialize_features()
@ -464,7 +464,7 @@ class IotDevice(Device):
@property @property
@requires_update @requires_update
def timezone(self) -> dict: def timezone(self) -> tzinfo:
"""Return the current timezone.""" """Return the current timezone."""
return self.modules[Module.IotTime].timezone return self.modules[Module.IotTime].timezone
@ -606,9 +606,7 @@ class IotDevice(Device):
on_time = self._sys_info["on_time"] on_time = self._sys_info["on_time"]
time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) on_since = self.time - timedelta(seconds=on_time)
on_since = time - timedelta(seconds=on_time)
if not self._on_since or timedelta( if not self._on_since or timedelta(
seconds=0 seconds=0
) < on_since - self._on_since > timedelta(seconds=5): ) < on_since - self._on_since > timedelta(seconds=5):

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from typing import Any from typing import Any
from ..device_type import DeviceType from ..device_type import DeviceType
@ -373,7 +373,7 @@ class IotStripPlug(IotPlug):
""" """
await self._modular_update({}) await self._modular_update({})
for module in self._modules.values(): for module in self._modules.values():
module._post_update_hook() await module._post_update_hook()
if not self._features: if not self._features:
await self._initialize_features() await self._initialize_features()
@ -445,7 +445,7 @@ class IotStripPlug(IotPlug):
info = self._get_child_info() info = self._get_child_info()
on_time = info["on_time"] on_time = info["on_time"]
time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) time = self._parent.time
on_since = time - timedelta(seconds=on_time) on_since = time - timedelta(seconds=on_time)
if not self._on_since or timedelta( if not self._on_since or timedelta(

178
kasa/iot/iottimezone.py Normal file
View File

@ -0,0 +1,178 @@
"""Module for io device timezone lookups."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, tzinfo
from zoneinfo import ZoneInfo
_LOGGER = logging.getLogger(__name__)
async def get_timezone(index: int) -> tzinfo:
"""Get the timezone from the index."""
if index > 109:
_LOGGER.error(
"Unexpected index %s not configured as a timezone, defaulting to UTC", index
)
return await _CachedZoneInfo.get_cached_zone_info("Etc/UTC")
name = TIMEZONE_INDEX[index]
return await _CachedZoneInfo.get_cached_zone_info(name)
async def get_timezone_index(name: str) -> int:
"""Return the iot firmware index for a valid IANA timezone key."""
rev = {val: key for key, val in TIMEZONE_INDEX.items()}
if name in rev:
return rev[name]
# Try to find a supported timezone matching dst true/false
zone = await _CachedZoneInfo.get_cached_zone_info(name)
now = datetime.now()
winter = datetime(now.year, 1, 1, 12)
summer = datetime(now.year, 7, 1, 12)
for i in range(110):
configured_zone = await get_timezone(i)
if zone.utcoffset(winter) == configured_zone.utcoffset(
winter
) and zone.utcoffset(summer) == configured_zone.utcoffset(summer):
return i
raise ValueError("Device does not support timezone %s", name)
class _CachedZoneInfo(ZoneInfo):
"""Cache zone info objects."""
_cache: dict[str, ZoneInfo] = {}
@classmethod
async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo:
"""Get a cached zone info object."""
if cached := cls._cache.get(time_zone_str):
return cached
loop = asyncio.get_running_loop()
zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str)
cls._cache[time_zone_str] = zinfo
return zinfo
def _get_zone_info(time_zone_str: str) -> ZoneInfo:
"""Get a time zone object for the given time zone string."""
return ZoneInfo(time_zone_str)
TIMEZONE_INDEX = {
0: "Etc/GMT+12",
1: "Pacific/Samoa",
2: "US/Hawaii",
3: "US/Alaska",
4: "Mexico/BajaNorte",
5: "Etc/GMT+8",
6: "PST8PDT",
7: "US/Arizona",
8: "America/Mazatlan",
9: "MST",
10: "MST7MDT",
11: "Mexico/General",
12: "Etc/GMT+6",
13: "CST6CDT",
14: "America/Monterrey",
15: "Canada/Saskatchewan",
16: "America/Bogota",
17: "Etc/GMT+5",
18: "EST",
19: "America/Indiana/Indianapolis",
20: "America/Caracas",
21: "America/Asuncion",
22: "Etc/GMT+4",
23: "Canada/Atlantic",
24: "America/Cuiaba",
25: "Brazil/West",
26: "America/Santiago",
27: "Canada/Newfoundland",
28: "America/Sao_Paulo",
29: "America/Argentina/Buenos_Aires",
30: "America/Cayenne",
31: "America/Miquelon",
32: "America/Montevideo",
33: "Chile/Continental",
34: "Etc/GMT+2",
35: "Atlantic/Azores",
36: "Atlantic/Cape_Verde",
37: "Africa/Casablanca",
38: "UCT",
39: "GB",
40: "Africa/Monrovia",
41: "Europe/Amsterdam",
42: "Europe/Belgrade",
43: "Europe/Brussels",
44: "Europe/Sarajevo",
45: "Africa/Lagos",
46: "Africa/Windhoek",
47: "Asia/Amman",
48: "Europe/Athens",
49: "Asia/Beirut",
50: "Africa/Cairo",
51: "Asia/Damascus",
52: "EET",
53: "Africa/Harare",
54: "Europe/Helsinki",
55: "Asia/Istanbul",
56: "Asia/Jerusalem",
57: "Europe/Kaliningrad",
58: "Africa/Tripoli",
59: "Asia/Baghdad",
60: "Asia/Kuwait",
61: "Europe/Minsk",
62: "Europe/Moscow",
63: "Africa/Nairobi",
64: "Asia/Tehran",
65: "Asia/Muscat",
66: "Asia/Baku",
67: "Europe/Samara",
68: "Indian/Mauritius",
69: "Asia/Tbilisi",
70: "Asia/Yerevan",
71: "Asia/Kabul",
72: "Asia/Ashgabat",
73: "Asia/Yekaterinburg",
74: "Asia/Karachi",
75: "Asia/Kolkata",
76: "Asia/Colombo",
77: "Asia/Kathmandu",
78: "Asia/Almaty",
79: "Asia/Dhaka",
80: "Asia/Novosibirsk",
81: "Asia/Rangoon",
82: "Asia/Bangkok",
83: "Asia/Krasnoyarsk",
84: "Asia/Chongqing",
85: "Asia/Irkutsk",
86: "Asia/Singapore",
87: "Australia/Perth",
88: "Asia/Taipei",
89: "Asia/Ulaanbaatar",
90: "Asia/Tokyo",
91: "Asia/Seoul",
92: "Asia/Yakutsk",
93: "Australia/Adelaide",
94: "Australia/Darwin",
95: "Australia/Brisbane",
96: "Australia/Canberra",
97: "Pacific/Guam",
98: "Australia/Hobart",
99: "Antarctica/DumontDUrville",
100: "Asia/Magadan",
101: "Asia/Srednekolymsk",
102: "Etc/GMT-11",
103: "Asia/Anadyr",
104: "Pacific/Auckland",
105: "Etc/GMT-12",
106: "Pacific/Fiji",
107: "Etc/GMT-13",
108: "Pacific/Apia",
109: "Etc/GMT-14",
}

View File

@ -12,7 +12,7 @@ from .usage import Usage
class Emeter(Usage, EnergyInterface): class Emeter(Usage, EnergyInterface):
"""Emeter module.""" """Emeter module."""
def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS
if ( if (
"voltage_mv" in self.data["get_realtime"] "voltage_mv" in self.data["get_realtime"]

View File

@ -239,7 +239,7 @@ class Light(IotModule, LightInterface):
"""Return the current light state.""" """Return the current light state."""
return self._light_state return self._light_state
def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
if self._device.is_on is False: if self._device.is_on is False:
state = LightState(light_on=False) state = LightState(light_on=False)
else: else:

View File

@ -41,7 +41,7 @@ class LightPreset(IotModule, LightPresetInterface):
_presets: dict[str, IotLightPreset] _presets: dict[str, IotLightPreset]
_preset_list: list[str] _preset_list: list[str]
def _post_update_hook(self): async def _post_update_hook(self):
"""Update the internal presets.""" """Update the internal presets."""
self._presets = { self._presets = {
f"Light preset {index+1}": IotLightPreset(**vals) f"Light preset {index+1}": IotLightPreset(**vals)

View File

@ -1,14 +1,19 @@
"""Provides the current time and timezone information.""" """Provides the current time and timezone information."""
from datetime import datetime from __future__ import annotations
from datetime import datetime, timezone, tzinfo
from ...exceptions import KasaException from ...exceptions import KasaException
from ..iotmodule import IotModule, merge from ..iotmodule import IotModule, merge
from ..iottimezone import get_timezone
class Time(IotModule): class Time(IotModule):
"""Implements the timezone settings.""" """Implements the timezone settings."""
_timezone: tzinfo = timezone.utc
def query(self): def query(self):
"""Request time and timezone.""" """Request time and timezone."""
q = self.query_for_command("get_time") q = self.query_for_command("get_time")
@ -16,11 +21,16 @@ class Time(IotModule):
merge(q, self.query_for_command("get_timezone")) merge(q, self.query_for_command("get_timezone"))
return q return q
async def _post_update_hook(self):
"""Perform actions after a device update."""
if res := self.data.get("get_timezone"):
self._timezone = await get_timezone(res.get("index"))
@property @property
def time(self) -> datetime: def time(self) -> datetime:
"""Return current device time.""" """Return current device time."""
res = self.data["get_time"] res = self.data["get_time"]
return datetime( time = datetime(
res["year"], res["year"],
res["month"], res["month"],
res["mday"], res["mday"],
@ -28,12 +38,12 @@ class Time(IotModule):
res["min"], res["min"],
res["sec"], res["sec"],
) )
return time.astimezone(self.timezone)
@property @property
def timezone(self): def timezone(self) -> tzinfo:
"""Return current timezone.""" """Return current timezone."""
res = self.data["get_timezone"] return self._timezone
return res
async def get_time(self): async def get_time(self):
"""Return current device time.""" """Return current device time."""

View File

@ -155,7 +155,7 @@ class Module(ABC):
children's modules. children's modules.
""" """
def _post_update_hook(self): # noqa: B027 async def _post_update_hook(self): # noqa: B027
"""Perform actions after a device update. """Perform actions after a device update.
This can be implemented if a module needs to perform actions each time This can be implemented if a module needs to perform actions each time

View File

@ -10,7 +10,7 @@ class DeviceModule(SmartModule):
REQUIRED_COMPONENT = "device" REQUIRED_COMPONENT = "device"
def _post_update_hook(self): async def _post_update_hook(self):
"""Perform actions after a device update. """Perform actions after a device update.
Overrides the default behaviour to disable a module if the query returns Overrides the default behaviour to disable a module if the query returns

View File

@ -152,7 +152,7 @@ class Light(SmartModule, LightInterface):
"""Return the current light state.""" """Return the current light state."""
return self._light_state return self._light_state
def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
if self._device.is_on is False: if self._device.is_on is False:
state = LightState(light_on=False) state = LightState(light_on=False)
else: else:

View File

@ -28,7 +28,7 @@ class LightEffect(SmartModule, SmartLightEffect):
_effect_list: list[str] _effect_list: list[str]
_scenes_names_to_id: dict[str, str] _scenes_names_to_id: dict[str, str]
def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
"""Update internal effect state.""" """Update internal effect state."""
# Copy the effects so scene name updates do not update the underlying dict. # Copy the effects so scene name updates do not update the underlying dict.
effects = copy.deepcopy( effects = copy.deepcopy(

View File

@ -34,7 +34,7 @@ class LightPreset(SmartModule, LightPresetInterface):
self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info
self._brightness_only: bool = False self._brightness_only: bool = False
def _post_update_hook(self): async def _post_update_hook(self):
"""Update the internal presets.""" """Update the internal presets."""
index = 0 index = 0
self._presets = {} self._presets = {}

View File

@ -90,7 +90,7 @@ class LightTransition(SmartModule):
) )
) )
def _post_update_hook(self) -> None: async def _post_update_hook(self) -> None:
"""Update the states.""" """Update the states."""
# Assumes any device with state in sysinfo supports on and off and # Assumes any device with state in sysinfo supports on and off and
# has maximum values for both. # has maximum values for both.

View File

@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone, tzinfo
from time import mktime from time import mktime
from typing import cast from typing import cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from ...feature import Feature from ...feature import Feature
from ..smartmodule import SmartModule from ..smartmodule import SmartModule
@ -31,18 +33,27 @@ class Time(SmartModule):
) )
@property @property
def time(self) -> datetime: def timezone(self) -> tzinfo:
"""Return device's current datetime.""" """Return current timezone."""
td = timedelta(minutes=cast(float, self.data.get("time_diff"))) td = timedelta(minutes=cast(float, self.data.get("time_diff")))
if self.data.get("region"): if region := self.data.get("region"):
tz = timezone(td, str(self.data.get("region"))) try:
# Zoneinfo will return a DST aware object
tz: tzinfo = ZoneInfo(region)
except ZoneInfoNotFoundError:
tz = timezone(td, region)
else: else:
# in case the device returns a blank region this will result in the # in case the device returns a blank region this will result in the
# tzname being a UTC offset # tzname being a UTC offset
tz = timezone(td) tz = timezone(td)
return tz
@property
def time(self) -> datetime:
"""Return device's current datetime."""
return datetime.fromtimestamp( return datetime.fromtimestamp(
cast(float, self.data.get("timestamp")), cast(float, self.data.get("timestamp")),
tz=tz, tz=self.timezone,
) )
async def set_time(self, dt: datetime): async def set_time(self, dt: datetime):

View File

@ -61,7 +61,7 @@ class SmartChildDevice(SmartDevice):
self._last_update = await self.protocol.query(req) self._last_update = await self.protocol.query(req)
for module in self.modules.values(): for module in self.modules.values():
self._handle_module_post_update( await self._handle_module_post_update(
module, now, had_query=module in module_queries module, now, had_query=module in module_queries
) )
self._last_update_time = now self._last_update_time = now

View File

@ -6,8 +6,8 @@ import base64
import logging import logging
import time import time
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone, tzinfo
from typing import Any, cast from typing import TYPE_CHECKING, Any, cast
from ..aestransport import AesTransport from ..aestransport import AesTransport
from ..device import Device, WifiNetwork from ..device import Device, WifiNetwork
@ -168,7 +168,7 @@ class SmartDevice(Device):
await self._initialize_modules() await self._initialize_modules()
# Run post update for the cloud module # Run post update for the cloud module
if cloud_mod := self.modules.get(Module.Cloud): if cloud_mod := self.modules.get(Module.Cloud):
self._handle_module_post_update(cloud_mod, now, had_query=True) await self._handle_module_post_update(cloud_mod, now, had_query=True)
resp = await self._modular_update(first_update, now) resp = await self._modular_update(first_update, now)
@ -195,7 +195,7 @@ class SmartDevice(Device):
updated = self._last_update if first_update else resp updated = self._last_update if first_update else resp
_LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys()))
def _handle_module_post_update( async def _handle_module_post_update(
self, module: SmartModule, update_time: float, had_query: bool self, module: SmartModule, update_time: float, had_query: bool
): ):
if module.disabled: if module.disabled:
@ -203,7 +203,7 @@ class SmartDevice(Device):
if had_query: if had_query:
module._last_update_time = update_time module._last_update_time = update_time
try: try:
module._post_update_hook() await module._post_update_hook()
module._set_error(None) module._set_error(None)
except Exception as ex: except Exception as ex:
# Only set the error if a query happened. # Only set the error if a query happened.
@ -260,7 +260,7 @@ class SmartDevice(Device):
# Call handle update for modules that want to update internal data # Call handle update for modules that want to update internal data
for module in self._modules.values(): for module in self._modules.values():
self._handle_module_post_update( await self._handle_module_post_update(
module, update_time, had_query=module in module_queries module, update_time, had_query=module in module_queries
) )
@ -516,10 +516,11 @@ class SmartDevice(Device):
return self._on_since return self._on_since
@property @property
def timezone(self) -> dict: def timezone(self) -> tzinfo:
"""Return the timezone and time_difference.""" """Return the timezone and time_difference."""
ti = self.time if TYPE_CHECKING:
return {"timezone": ti.tzname()} assert self.time.tzinfo
return self.time.tzinfo
@property @property
def hw_info(self) -> dict: def hw_info(self) -> dict:

View File

@ -121,7 +121,7 @@ class SmartModule(Module):
"""Name of the module.""" """Name of the module."""
return getattr(self, "NAME", self.__class__.__name__) return getattr(self, "NAME", self.__class__.__name__)
def _post_update_hook(self): # noqa: B027 async def _post_update_hook(self): # noqa: B027
"""Perform actions after a device update. """Perform actions after a device update.
Any modules overriding this should ensure that self.data is Any modules overriding this should ensure that self.data is

View File

@ -10,10 +10,16 @@ from contextlib import AbstractContextManager
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest
import zoneinfo
import kasa import kasa
from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module
from kasa.iot import IotDevice from kasa.iot import IotDevice
from kasa.iot.iottimezone import (
TIMEZONE_INDEX,
get_timezone,
get_timezone_index,
)
from kasa.iot.modules import IotLightPreset from kasa.iot.modules import IotLightPreset
from kasa.smart import SmartChildDevice, SmartDevice from kasa.smart import SmartChildDevice, SmartDevice
@ -299,3 +305,29 @@ async def test_device_type_aliases():
) )
assert isinstance(dev.config, DeviceConfig) assert isinstance(dev.config, DeviceConfig)
assert DeviceType.Dimmer == Device.Type.Dimmer assert DeviceType.Dimmer == Device.Type.Dimmer
async def test_device_timezones():
"""Test the timezone data is good."""
# Check all indexes return a zoneinfo
for i in range(110):
tz = await get_timezone(i)
assert tz
assert tz != zoneinfo.ZoneInfo("Etc/UTC"), f"{i} is default Etc/UTC"
# Check an unexpected index returns a UTC default.
tz = await get_timezone(110)
assert tz == zoneinfo.ZoneInfo("Etc/UTC")
# Get an index from a timezone
for index, zone in TIMEZONE_INDEX.items():
found_index = await get_timezone_index(zone)
assert found_index == index
# Try a timezone not hardcoded finds another match
index = await get_timezone_index("Asia/Katmandu")
assert index == 77
# Try a timezone not hardcoded no match
with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
await get_timezone_index("Foo/bar")

View File

@ -13,6 +13,7 @@ dependencies = [
"async-timeout>=3.0.0", "async-timeout>=3.0.0",
"aiohttp>=3", "aiohttp>=3",
"typing-extensions>=4.12.2,<5.0", "typing-extensions>=4.12.2,<5.0",
"tzdata>=2024.2 ; platform_system == 'Windows'",
] ]
classifiers = [ classifiers = [

17
uv.lock
View File

@ -1,8 +1,10 @@
version = 1 version = 1
requires-python = ">=3.9, <4.0" requires-python = ">=3.9, <4.0"
resolution-markers = [ resolution-markers = [
"python_full_version < '3.13'", "python_full_version < '3.13' and platform_system == 'Windows'",
"python_full_version >= '3.13'", "python_full_version < '3.13' and platform_system != 'Windows'",
"python_full_version >= '3.13' and platform_system == 'Windows'",
"python_full_version >= '3.13' and platform_system != 'Windows'",
] ]
[[package]] [[package]]
@ -1351,6 +1353,7 @@ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "tzdata", marker = "platform_system == 'Windows'" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@ -1407,6 +1410,7 @@ requires-dist = [
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" },
{ name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0" },
{ name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -1693,6 +1697,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
] ]
[[package]]
name = "tzdata"
version = "2024.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.2.3" version = "2.2.3"