From 9641edcbc05cde828b58f137e47635665d2f79ae Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 8 Oct 2024 08:16:51 +0100 Subject: [PATCH] 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. --- kasa/cli/device.py | 2 +- kasa/device.py | 4 +- kasa/iot/iotdevice.py | 10 +- kasa/iot/iotstrip.py | 6 +- kasa/iot/iottimezone.py | 178 ++++++++++++++++++++++++++ kasa/iot/modules/emeter.py | 2 +- kasa/iot/modules/light.py | 2 +- kasa/iot/modules/lightpreset.py | 2 +- kasa/iot/modules/time.py | 20 ++- kasa/module.py | 2 +- kasa/smart/modules/devicemodule.py | 2 +- kasa/smart/modules/light.py | 2 +- kasa/smart/modules/lighteffect.py | 2 +- kasa/smart/modules/lightpreset.py | 2 +- kasa/smart/modules/lighttransition.py | 2 +- kasa/smart/modules/time.py | 23 +++- kasa/smart/smartchilddevice.py | 2 +- kasa/smart/smartdevice.py | 19 +-- kasa/smart/smartmodule.py | 2 +- kasa/tests/test_device.py | 32 +++++ pyproject.toml | 1 + uv.lock | 17 ++- 22 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 kasa/iot/iottimezone.py diff --git a/kasa/cli/device.py b/kasa/cli/device.py index f513a5e2..4a933b87 100644 --- a/kasa/cli/device.py +++ b/kasa/cli/device.py @@ -40,7 +40,7 @@ async def state(ctx, dev: Device): echo(f"Port: {dev.port}") 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"Software: {dev.hw_info['sw_ver']}") echo(f"MAC (rssi): {dev.mac} ({dev.rssi})") diff --git a/kasa/device.py b/kasa/device.py index 4397e2ff..d44ca2b8 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -109,7 +109,7 @@ import logging from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, tzinfo from typing import TYPE_CHECKING, Any from warnings import warn @@ -377,7 +377,7 @@ class Device(ABC): @property @abstractmethod - def timezone(self) -> dict: + def timezone(self) -> tzinfo: """Return the timezone and time_difference.""" @property diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index f0d14e10..94e72df6 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -18,7 +18,7 @@ import functools import inspect import logging 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 ..device import Device, WifiNetwork @@ -299,7 +299,7 @@ class IotDevice(Device): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) for module in self._modules.values(): - module._post_update_hook() + await module._post_update_hook() if not self._features: await self._initialize_features() @@ -464,7 +464,7 @@ class IotDevice(Device): @property @requires_update - def timezone(self) -> dict: + def timezone(self) -> tzinfo: """Return the current timezone.""" return self.modules[Module.IotTime].timezone @@ -606,9 +606,7 @@ class IotDevice(Device): on_time = self._sys_info["on_time"] - time = datetime.now(timezone.utc).astimezone().replace(microsecond=0) - - on_since = time - timedelta(seconds=on_time) + on_since = self.time - timedelta(seconds=on_time) if not self._on_since or timedelta( seconds=0 ) < on_since - self._on_since > timedelta(seconds=5): diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 0bdfc1cb..46699704 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from typing import Any from ..device_type import DeviceType @@ -373,7 +373,7 @@ class IotStripPlug(IotPlug): """ await self._modular_update({}) for module in self._modules.values(): - module._post_update_hook() + await module._post_update_hook() if not self._features: await self._initialize_features() @@ -445,7 +445,7 @@ class IotStripPlug(IotPlug): info = self._get_child_info() 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) if not self._on_since or timedelta( diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py new file mode 100644 index 00000000..53cb219e --- /dev/null +++ b/kasa/iot/iottimezone.py @@ -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", +} diff --git a/kasa/iot/modules/emeter.py b/kasa/iot/modules/emeter.py index 7ae89e5b..1764af90 100644 --- a/kasa/iot/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -12,7 +12,7 @@ from .usage import Usage class Emeter(Usage, EnergyInterface): """Emeter module.""" - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS if ( "voltage_mv" in self.data["get_realtime"] diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index c4d6cb09..d83031c8 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -239,7 +239,7 @@ class Light(IotModule, LightInterface): """Return the current 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: state = LightState(light_on=False) else: diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py index d5a603c0..bae401ef 100644 --- a/kasa/iot/modules/lightpreset.py +++ b/kasa/iot/modules/lightpreset.py @@ -41,7 +41,7 @@ class LightPreset(IotModule, LightPresetInterface): _presets: dict[str, IotLightPreset] _preset_list: list[str] - def _post_update_hook(self): + async def _post_update_hook(self): """Update the internal presets.""" self._presets = { f"Light preset {index+1}": IotLightPreset(**vals) diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py index c280e5d1..23cc5010 100644 --- a/kasa/iot/modules/time.py +++ b/kasa/iot/modules/time.py @@ -1,14 +1,19 @@ """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 ..iotmodule import IotModule, merge +from ..iottimezone import get_timezone class Time(IotModule): """Implements the timezone settings.""" + _timezone: tzinfo = timezone.utc + def query(self): """Request time and timezone.""" q = self.query_for_command("get_time") @@ -16,11 +21,16 @@ class Time(IotModule): merge(q, self.query_for_command("get_timezone")) 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 def time(self) -> datetime: """Return current device time.""" res = self.data["get_time"] - return datetime( + time = datetime( res["year"], res["month"], res["mday"], @@ -28,12 +38,12 @@ class Time(IotModule): res["min"], res["sec"], ) + return time.astimezone(self.timezone) @property - def timezone(self): + def timezone(self) -> tzinfo: """Return current timezone.""" - res = self.data["get_timezone"] - return res + return self._timezone async def get_time(self): """Return current device time.""" diff --git a/kasa/module.py b/kasa/module.py index faf17c4d..68f5170d 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -155,7 +155,7 @@ class Module(ABC): children's modules. """ - def _post_update_hook(self): # noqa: B027 + async def _post_update_hook(self): # noqa: B027 """Perform actions after a device update. This can be implemented if a module needs to perform actions each time diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py index 3203e82f..1d2b64f2 100644 --- a/kasa/smart/modules/devicemodule.py +++ b/kasa/smart/modules/devicemodule.py @@ -10,7 +10,7 @@ class DeviceModule(SmartModule): REQUIRED_COMPONENT = "device" - def _post_update_hook(self): + async def _post_update_hook(self): """Perform actions after a device update. Overrides the default behaviour to disable a module if the query returns diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py index 8e0a37d8..487c25f3 100644 --- a/kasa/smart/modules/light.py +++ b/kasa/smart/modules/light.py @@ -152,7 +152,7 @@ class Light(SmartModule, LightInterface): """Return the current 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: state = LightState(light_on=False) else: diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py index 7227c442..55dd3d49 100644 --- a/kasa/smart/modules/lighteffect.py +++ b/kasa/smart/modules/lighteffect.py @@ -28,7 +28,7 @@ class LightEffect(SmartModule, SmartLightEffect): _effect_list: list[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.""" # Copy the effects so scene name updates do not update the underlying dict. effects = copy.deepcopy( diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py index 16cd15ae..56ca42c2 100644 --- a/kasa/smart/modules/lightpreset.py +++ b/kasa/smart/modules/lightpreset.py @@ -34,7 +34,7 @@ class LightPreset(SmartModule, LightPresetInterface): self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info self._brightness_only: bool = False - def _post_update_hook(self): + async def _post_update_hook(self): """Update the internal presets.""" index = 0 self._presets = {} diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py index da05995d..947f8b0e 100644 --- a/kasa/smart/modules/lighttransition.py +++ b/kasa/smart/modules/lighttransition.py @@ -90,7 +90,7 @@ class LightTransition(SmartModule): ) ) - def _post_update_hook(self) -> None: + async def _post_update_hook(self) -> None: """Update the states.""" # Assumes any device with state in sysinfo supports on and off and # has maximum values for both. diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py index 254fd098..13831b2e 100644 --- a/kasa/smart/modules/time.py +++ b/kasa/smart/modules/time.py @@ -2,10 +2,12 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, timezone, tzinfo from time import mktime from typing import cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + from ...feature import Feature from ..smartmodule import SmartModule @@ -31,18 +33,27 @@ class Time(SmartModule): ) @property - def time(self) -> datetime: - """Return device's current datetime.""" + def timezone(self) -> tzinfo: + """Return current timezone.""" td = timedelta(minutes=cast(float, self.data.get("time_diff"))) - if self.data.get("region"): - tz = timezone(td, str(self.data.get("region"))) + if region := self.data.get("region"): + try: + # Zoneinfo will return a DST aware object + tz: tzinfo = ZoneInfo(region) + except ZoneInfoNotFoundError: + tz = timezone(td, region) else: # in case the device returns a blank region this will result in the # tzname being a UTC offset tz = timezone(td) + return tz + + @property + def time(self) -> datetime: + """Return device's current datetime.""" return datetime.fromtimestamp( cast(float, self.data.get("timestamp")), - tz=tz, + tz=self.timezone, ) async def set_time(self, dt: datetime): diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 8fe3b969..3b5f53ef 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -61,7 +61,7 @@ class SmartChildDevice(SmartDevice): self._last_update = await self.protocol.query(req) 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 ) self._last_update_time = now diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 8d373f58..095156e3 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -6,8 +6,8 @@ import base64 import logging import time from collections.abc import Mapping, Sequence -from datetime import datetime, timedelta, timezone -from typing import Any, cast +from datetime import datetime, timedelta, timezone, tzinfo +from typing import TYPE_CHECKING, Any, cast from ..aestransport import AesTransport from ..device import Device, WifiNetwork @@ -168,7 +168,7 @@ class SmartDevice(Device): await self._initialize_modules() # Run post update for the cloud module 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) @@ -195,7 +195,7 @@ class SmartDevice(Device): updated = self._last_update if first_update else resp _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 ): if module.disabled: @@ -203,7 +203,7 @@ class SmartDevice(Device): if had_query: module._last_update_time = update_time try: - module._post_update_hook() + await module._post_update_hook() module._set_error(None) except Exception as ex: # 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 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 ) @@ -516,10 +516,11 @@ class SmartDevice(Device): return self._on_since @property - def timezone(self) -> dict: + def timezone(self) -> tzinfo: """Return the timezone and time_difference.""" - ti = self.time - return {"timezone": ti.tzname()} + if TYPE_CHECKING: + assert self.time.tzinfo + return self.time.tzinfo @property def hw_info(self) -> dict: diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 381ce233..1f4c4f48 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -121,7 +121,7 @@ class SmartModule(Module): """Name of the module.""" 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. Any modules overriding this should ensure that self.data is diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index f67d37c2..4b851d26 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -10,10 +10,16 @@ from contextlib import AbstractContextManager from unittest.mock import AsyncMock, patch import pytest +import zoneinfo import kasa from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module 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.smart import SmartChildDevice, SmartDevice @@ -299,3 +305,29 @@ async def test_device_type_aliases(): ) assert isinstance(dev.config, DeviceConfig) 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") diff --git a/pyproject.toml b/pyproject.toml index dfe7de5b..7cb875c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "async-timeout>=3.0.0", "aiohttp>=3", "typing-extensions>=4.12.2,<5.0", + "tzdata>=2024.2 ; platform_system == 'Windows'", ] classifiers = [ diff --git a/uv.lock b/uv.lock index 509b6536..f47996bb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,8 +1,10 @@ version = 1 requires-python = ">=3.9, <4.0" resolution-markers = [ - "python_full_version < '3.13'", - "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'", + "python_full_version >= '3.13' and platform_system != 'Windows'", ] [[package]] @@ -1351,6 +1353,7 @@ dependencies = [ { name = "cryptography" }, { name = "pydantic" }, { name = "typing-extensions" }, + { name = "tzdata", marker = "platform_system == 'Windows'" }, ] [package.optional-dependencies] @@ -1407,6 +1410,7 @@ requires-dist = [ { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = "~=2.0" }, { name = "sphinxcontrib-programoutput", marker = "extra == 'docs'", specifier = "~=0.0" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0" }, + { name = "tzdata", marker = "platform_system == 'Windows'", specifier = ">=2024.2" }, ] [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 }, ] +[[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]] name = "urllib3" version = "2.2.3"