mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-02-28 13:49:57 +00:00
Some checks failed
CI / Perform Lint Checks (3.13) (push) Has been cancelled
CodeQL Checks / Analyze (python) (push) Has been cancelled
CI / Python 3.11 on macos-latest (push) Has been cancelled
CI / Python 3.12 on macos-latest (push) Has been cancelled
CI / Python 3.13 on macos-latest (push) Has been cancelled
CI / Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Python 3.11 on windows-latest (push) Has been cancelled
CI / Python 3.12 on windows-latest (push) Has been cancelled
CI / Python 3.13 on windows-latest (push) Has been cancelled
Stale / stale (push) Has been cancelled
Adds a failover in the IOT Time Module to handle issues where the system default time zone files don't have the correct time zone info.
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
from datetime import UTC, datetime, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import pytest
|
|
from pytest_mock import MockerFixture
|
|
|
|
|
|
def test_expected_dst_behavior_for_index_cases():
|
|
"""Exercise _expected_dst_behavior_for_index for several representative indices."""
|
|
from kasa.iot.iottimezone import _expected_dst_behavior_for_index
|
|
|
|
# Posix-style DST zones
|
|
assert _expected_dst_behavior_for_index(10) is True # MST7MDT
|
|
assert _expected_dst_behavior_for_index(13) is True # CST6CDT
|
|
# Fixed-offset or fixed-abbreviation zones
|
|
assert _expected_dst_behavior_for_index(34) is False # Etc/GMT+2
|
|
assert _expected_dst_behavior_for_index(18) is False # EST
|
|
# Invalid index should raise KeyError
|
|
with pytest.raises(KeyError):
|
|
_expected_dst_behavior_for_index(999)
|
|
|
|
|
|
async def test_guess_timezone_by_offset_fixed_fallback_unit():
|
|
"""When no ZoneInfo matches, return a fixed-offset tzinfo."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
year = datetime.now(UTC).year
|
|
when = datetime(year, 1, 15, 12, tzinfo=UTC)
|
|
offset = timedelta(minutes=2) # unlikely to match any real zone
|
|
tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when)
|
|
assert tz.utcoffset(when) == offset
|
|
|
|
|
|
async def test_guess_timezone_by_offset_candidates_unit():
|
|
"""Cover naive when_utc branch and candidate selection path (non-empty candidates)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
# naive datetime hits the 'naive -> UTC' branch
|
|
when = datetime(2025, 1, 15, 12)
|
|
offset = timedelta(0)
|
|
tz = await tzmod._guess_timezone_by_offset(offset, when_utc=when)
|
|
|
|
# Should choose a ZoneInfo candidate (not the fixed-offset fallback), with matching offset
|
|
assert isinstance(tz, ZoneInfo)
|
|
assert tz.utcoffset(when.replace(tzinfo=UTC)) == offset
|
|
|
|
|
|
async def test_guess_timezone_by_offset_dst_expected_true_filters(
|
|
mocker: MockerFixture,
|
|
):
|
|
"""dst_expected=True should prefer a DST-observing zone when possible."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC)
|
|
tz = await tzmod._guess_timezone_by_offset(
|
|
timedelta(0), when_utc=when, dst_expected=True
|
|
)
|
|
assert tz.utcoffset(when) == timedelta(0)
|
|
if isinstance(tz, ZoneInfo):
|
|
jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
assert jan != jul # observes DST
|
|
|
|
|
|
async def test_guess_timezone_by_offset_dst_expected_false_prefers_non_dst():
|
|
"""dst_expected=False should prefer a non-DST zone and skip DST candidates (covers False branch)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC)
|
|
tz = await tzmod._guess_timezone_by_offset(
|
|
timedelta(0), when_utc=when, dst_expected=False
|
|
)
|
|
assert tz.utcoffset(when) == timedelta(0)
|
|
if isinstance(tz, ZoneInfo):
|
|
jan = datetime(when.year, 1, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
jul = datetime(when.year, 7, 15, 12, tzinfo=UTC).astimezone(tz).utcoffset()
|
|
assert jan == jul # non-DST zone chosen
|
|
|
|
|
|
async def test_guess_timezone_by_offset_handles_missing_zoneinfo_unit(
|
|
mocker: MockerFixture,
|
|
):
|
|
"""Cover the ZoneInfoNotFoundError continue path within guess_timezone_by_offset."""
|
|
from zoneinfo import ZoneInfoNotFoundError as ZNF
|
|
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
original = tzmod.CachedZoneInfo.get_cached_zone_info
|
|
|
|
async def flaky_get(name: str):
|
|
# Force the first entry to raise to exercise the except path (143-144)
|
|
first_name = next(iter(tzmod.TIMEZONE_INDEX.values()))
|
|
if name == first_name:
|
|
raise ZNF("unavailable on host")
|
|
return await original(name)
|
|
|
|
mocker.patch.object(tzmod.CachedZoneInfo, "get_cached_zone_info", new=flaky_get)
|
|
|
|
when = datetime(datetime.now(UTC).year, 1, 15, 12, tzinfo=UTC)
|
|
tz = await tzmod._guess_timezone_by_offset(timedelta(0), when_utc=when)
|
|
assert tz.utcoffset(when) == timedelta(0)
|
|
|
|
|
|
async def test_get_timezone_index_direct_match():
|
|
"""If ZoneInfo key is in TIMEZONE_INDEX, return index directly."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
idx = await tzmod.get_timezone_index(ZoneInfo("GB"))
|
|
assert idx == 39 # "GB" is mapped to index 39
|
|
|
|
|
|
async def test_get_timezone_index_non_zoneinfo_unit():
|
|
"""Exercise get_timezone_index path when input tzinfo is not a ZoneInfo instance."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
# Fixed offset +0 should match a valid index (e.g., UCT/Africa/Monrovia)
|
|
idx = await tzmod.get_timezone_index(timezone(timedelta(0)))
|
|
assert isinstance(idx, int)
|
|
assert 0 <= idx <= 109
|
|
|
|
|
|
async def test_get_timezone_index_skips_missing_unit(mocker: MockerFixture):
|
|
"""Cover ZoneInfoNotFoundError path in get_timezone_index loop and successful match."""
|
|
from zoneinfo import ZoneInfoNotFoundError as ZNF
|
|
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
original_get_tz = tzmod.get_timezone
|
|
|
|
async def side_effect(i: int):
|
|
if i < 5:
|
|
raise ZNF("unavailable on host")
|
|
return await original_get_tz(i)
|
|
|
|
mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect)
|
|
|
|
# Use a ZoneInfo not directly present in TIMEZONE_INDEX values to avoid early return
|
|
idx = await tzmod.get_timezone_index(ZoneInfo("Europe/London"))
|
|
assert isinstance(idx, int)
|
|
assert 0 <= idx <= 109
|
|
assert idx >= 5
|
|
|
|
|
|
async def test_get_timezone_index_raises_for_unmatched_unit():
|
|
"""Ensure get_timezone_index completes loop and raises when no match exists (covers raise branch)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
# Uncommon 2-minute offset won't match any real zone in TIMEZONE_INDEX
|
|
with pytest.raises(ValueError, match="Device does not support timezone"):
|
|
await tzmod.get_timezone_index(timezone(timedelta(minutes=2)))
|
|
|
|
|
|
async def test_get_matching_timezones_branches_unit(mocker: MockerFixture):
|
|
"""Cover initial append, except path, and duplicate suppression in get_matching_timezones."""
|
|
from zoneinfo import ZoneInfoNotFoundError as ZNF
|
|
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
original_get_tz = tzmod.get_timezone
|
|
|
|
async def side_effect(i: int):
|
|
# Force one miss to hit the except path
|
|
if i == 0:
|
|
raise ZNF("unavailable on host")
|
|
return await original_get_tz(i)
|
|
|
|
mocker.patch("kasa.iot.iottimezone.get_timezone", new=side_effect)
|
|
|
|
# 'GB' is in TIMEZONE_INDEX; passing ZoneInfo('GB') will trigger initial append
|
|
matches = await tzmod.get_matching_timezones(ZoneInfo("GB"))
|
|
assert "GB" in matches # initial append done
|
|
# Loop should find GB again but not duplicate it
|
|
|
|
|
|
async def test_get_matching_timezones_non_zoneinfo_unit():
|
|
"""Exercise get_matching_timezones when input tzinfo is not a ZoneInfo (skips initial append)."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
matches = await tzmod.get_matching_timezones(timezone(timedelta(0)))
|
|
assert isinstance(matches, list)
|
|
assert len(matches) > 0
|
|
|
|
|
|
async def test_get_timezone_out_of_range_defaults_to_utc():
|
|
"""Out-of-range index should log and default to UTC."""
|
|
import kasa.iot.iottimezone as tzmod
|
|
|
|
tz = await tzmod.get_timezone(-1)
|
|
assert isinstance(tz, ZoneInfo)
|
|
assert tz.key in ("Etc/UTC", "UTC") # platform alias acceptable
|
|
|
|
tz2 = await tzmod.get_timezone(999)
|
|
assert isinstance(tz2, ZoneInfo)
|
|
assert tz2.key in ("Etc/UTC", "UTC")
|