Skip to content

Commit

Permalink
0.45.0 Release
Browse files Browse the repository at this point in the history
0.45.0 Release
  • Loading branch information
puddly committed Apr 26, 2022
2 parents fa3ef72 + 7807afe commit 67a8dfa
Show file tree
Hide file tree
Showing 23 changed files with 706 additions and 292 deletions.
18 changes: 9 additions & 9 deletions .github/workflows/ci.yml
Expand Up @@ -35,7 +35,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
restore-keys: |
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{ steps.python.outputs.python-version }}-
- name: Create Python virtual environment
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -108,7 +108,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -151,7 +151,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -197,7 +197,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -240,7 +240,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -286,7 +286,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -334,7 +334,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down Expand Up @@ -396,7 +396,7 @@ jobs:
key: >-
${{ env.CACHE_VERSION}}-${{ runner.os }}-base-venv-${{
steps.python.outputs.python-version }}-${{
hashFiles('requirements_test.txt') }}
hashFiles('setup.py', 'requirements_test.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Expand Up @@ -6,7 +6,7 @@

import zigpy

REQUIRES = ["aiohttp", "aiosqlite>=0.16.0", "crccheck", "pycryptodome", "voluptuous"]
REQUIRES = ["aiohttp", "aiosqlite>=0.16.0", "crccheck", "cryptography", "voluptuous"]

setup(
name="zigpy",
Expand Down
12 changes: 12 additions & 0 deletions tests/async_mock.py
Expand Up @@ -3,7 +3,19 @@

if sys.version_info[:2] < (3, 8):
from asynctest.mock import * # noqa
from asynctest.mock import MagicMock as _MagicMock

AsyncMock = CoroutineMock # noqa: F405

class MagicMock(_MagicMock):
async def __aenter__(self):
return self.aenter

async def __aexit__(self, *args):
pass

async def __aiter__(self):
return self.aiter

else:
from unittest.mock import * # noqa
14 changes: 14 additions & 0 deletions tests/test_appdb.py
@@ -1,8 +1,10 @@
import asyncio
from datetime import datetime
import os
import sqlite3
import sys
import threading
import time

import aiosqlite
import pytest
Expand Down Expand Up @@ -173,6 +175,12 @@ async def test_database(tmpdir):
assert dev.get_signature()[SIG_MANUFACTURER] == "Custom"
assert dev.get_signature()[SIG_MODEL] == "Model"

ts = time.time()
dev.last_seen = ts
dev_last_seen = dev.last_seen
assert isinstance(dev.last_seen, datetime)
assert abs(dev.last_seen.timestamp() - ts) < 0.1

# Test a CustomDevice
custom_ieee = make_ieee(1)
app.handle_join(199, custom_ieee, 0)
Expand All @@ -193,6 +201,9 @@ async def test_database(tmpdir):
dev.endpoints[99].level._update_attribute(0x0011, 17)
assert dev.endpoints[1].in_clusters[0x0008]._attr_cache[0x0011] == 17
assert dev.endpoints[99].in_clusters[0x0008]._attr_cache[0x0011] == 17
custom_dev_last_seen = dev.last_seen
assert isinstance(custom_dev_last_seen, datetime)

await app.pre_shutdown()

# Everything should've been saved - check that it re-loads
Expand All @@ -209,13 +220,16 @@ async def test_database(tmpdir):
assert dev.endpoints[2].out_clusters[1].cluster_id == 1
assert dev.endpoints[3].device_type == profiles.zll.DeviceType.COLOR_LIGHT
assert dev.relays == relays_1
# The timestamp won't be restored exactly but it is more than close enough
assert abs((dev.last_seen - dev_last_seen).total_seconds()) < 0.01

dev = app2.get_device(custom_ieee)
# This virtual attribute is added by the quirk, there is no corresponding cluster
# stored in the database, nor is there a corresponding endpoint 99
assert dev.endpoints[1].in_clusters[0x0008]._attr_cache[0x0011] == 17
assert dev.endpoints[99].in_clusters[0x0008]._attr_cache[0x0011] == 17
assert dev.relays == relays_2
assert abs((dev.last_seen - custom_dev_last_seen).total_seconds()) < 0.01
dev.relays = None

app.handle_leave(99, ieee)
Expand Down
40 changes: 39 additions & 1 deletion tests/test_appdb_migration.py
Expand Up @@ -9,7 +9,7 @@
import zigpy.types as t
from zigpy.zdo import types as zdo_t

from tests.async_mock import patch
from tests.async_mock import AsyncMock, MagicMock, patch
from tests.test_appdb import auto_kill_aiosqlite, make_app # noqa: F401


Expand Down Expand Up @@ -457,3 +457,41 @@ async def test_v5_to_v7_migration(test_db):

app = await make_app(test_db_v5)
await app.pre_shutdown()


async def test_migration_missing_tables():
app = MagicMock()
conn = MagicMock()
conn.close = AsyncMock()

appdb = zigpy.appdb.PersistingListener(conn, app)

appdb._get_table_versions = AsyncMock(
return_value={"table1_v1": "1", "table1": "", "table2_v1": "1"}
)

results = MagicMock()
results.__aiter__.return_value = results
results.__anext__.side_effect = StopIteration

appdb.execute = MagicMock()
appdb.execute.return_value.__aenter__.return_value = results

# Migrations must explicitly specify all old tables, even if they will be untouched
with pytest.raises(RuntimeError):
await appdb._migrate_tables(
{
"table1_v1": "table1_v2",
# "table2_v1": "table2_v2",
}
)

# The untouched table will never be queried
await appdb._migrate_tables({"table1_v1": "table1_v2", "table2_v1": None})

appdb.execute.assert_called_once_with("SELECT * FROM table1_v1")

with pytest.raises(AssertionError):
appdb.execute.assert_called_once_with("SELECT * FROM table2_v1")

await appdb.shutdown()
37 changes: 36 additions & 1 deletion tests/test_device.py
@@ -1,4 +1,5 @@
import asyncio
from datetime import datetime, timezone
import logging

import pytest
Expand All @@ -11,7 +12,7 @@
import zigpy.types as t
from zigpy.zdo import types as zdo_t

from .async_mock import AsyncMock, MagicMock, patch, sentinel
from .async_mock import ANY, AsyncMock, MagicMock, patch, sentinel


@pytest.fixture
Expand Down Expand Up @@ -111,6 +112,21 @@ async def mock_req(*args, **kwargs):
assert dev.last_seen is not None


async def test_request_without_reply(dev):
seq = sentinel.tsn

async def mock_req(*args, **kwargs):
dev._pending[seq].result.set_result(sentinel.result)
return 0, sentinel.radio_status

dev.application.request.side_effect = mock_req
assert dev.last_seen is None
r = await dev.request(1, 2, 3, 3, seq, b"", expect_reply=False)
assert r is None
assert dev._application.request.call_count == 1
assert dev.last_seen is not None


async def test_failed_request(dev):
assert dev.last_seen is None
dev._application.request = AsyncMock(return_value=(1, "error"))
Expand Down Expand Up @@ -414,3 +430,22 @@ def test_device_name(dev):

assert dev.nwk == 0xFFFF
assert dev.name == "0xFFFF"


def test_device_last_seen(dev, monkeypatch):
"""Test the device last_seen property handles updates and broadcasts events."""

monkeypatch.setattr(dev, "listener_event", MagicMock())
assert dev.last_seen is None

dev.last_seen = 0
epoch = datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
assert dev.last_seen == epoch

dev.listener_event.assert_called_once_with("device_last_seen_updated", epoch)
dev.listener_event.reset_mock()

dev.update_last_seen()
dev.listener_event.assert_called_once_with("device_last_seen_updated", ANY)
event_time = dev.listener_event.mock_calls[0][1][1]
assert (event_time - datetime.now(timezone.utc)).total_seconds() < 0.1
23 changes: 23 additions & 0 deletions tests/test_ota_provider.py
Expand Up @@ -696,6 +696,29 @@ async def test_ledvance_refresh_list(
"salesRegion": "us",
"length": 170800,
},
# Old version but shows up after the new version in the OTA list
{
"blob": None,
"identity": {
"company": 4489,
"product": 13,
"version": {
"major": 0,
"minor": 2,
"build": 428,
"revision": 40,
},
},
"releaseNotes": "",
"shA256": sha_2,
"name": "A19_TW_10_year_IMG000D_00102428-encrypted.ota",
"productName": "A19 TW 10 year",
"fullName": fn_2,
"extension": ".ota",
"released": "2015-02-28T16:42:50",
"salesRegion": "us",
"length": 170800,
},
]
}
]
Expand Down
22 changes: 9 additions & 13 deletions tests/test_struct.py
Expand Up @@ -121,19 +121,18 @@ class TestStruct(t.Struct):


def test_nested_structs(expose_global):
@expose_global
class InnerStruct(t.Struct):
b: t.uint8_t
c: t.uint8_t

class OuterStruct(t.Struct):
class InnerStruct(t.Struct):
b: t.uint8_t
c: t.uint8_t

a: t.uint8_t
inner: InnerStruct
inner: None = t.StructField(type=InnerStruct)
d: t.uint8_t

assert len(OuterStruct.fields) == 3
assert OuterStruct.fields.a.type is t.uint8_t
assert OuterStruct.fields.inner.type is InnerStruct
assert OuterStruct.fields.inner.type is OuterStruct.InnerStruct
assert len(OuterStruct.fields.inner.type.fields) == 2
assert OuterStruct.fields.d.type is t.uint8_t

Expand All @@ -146,13 +145,10 @@ class OuterStruct(t.Struct):


def test_nested_structs2(expose_global):
@expose_global
class InnerStruct(t.Struct):
b: t.uint8_t
c: t.uint8_t

class OuterStruct(t.Struct):
InnerStruct = InnerStruct
class InnerStruct(t.Struct):
b: t.uint8_t
c: t.uint8_t

a: t.uint8_t
inner: None = t.StructField(type=InnerStruct)
Expand Down
14 changes: 14 additions & 0 deletions tests/test_zcl.py
Expand Up @@ -468,13 +468,17 @@ async def test_write_attributes_cache_default_response(cluster, status):
),
)
async def test_write_attributes_cache_success(cluster, attributes, result):
listener = MagicMock()
cluster.add_listener(listener)

rsp_type = t.List[foundation.WriteAttributesStatusRecord]
write_mock = AsyncMock(return_value=[rsp_type.deserialize(result)[0]])
with patch.object(cluster, "_write_attributes", write_mock):
await cluster.write_attributes(attributes)
assert cluster._write_attributes.call_count == 1
for attr_id in attributes:
assert cluster._attr_cache[attr_id] == attributes[attr_id]
listener.attribute_updated.assert_any_call(attr_id, attributes[attr_id])


@pytest.mark.parametrize(
Expand All @@ -501,6 +505,9 @@ async def test_write_attributes_cache_success(cluster, attributes, result):
),
)
async def test_write_attributes_cache_failure(cluster, attributes, result, failed):
listener = MagicMock()
cluster.add_listener(listener)

rsp_type = foundation.WriteAttributesResponse
write_mock = AsyncMock(return_value=[rsp_type.deserialize(result)[0]])

Expand All @@ -510,8 +517,15 @@ async def test_write_attributes_cache_failure(cluster, attributes, result, faile
for attr_id in attributes:
if attr_id in failed:
assert attr_id not in cluster._attr_cache

# Failed writes do not propagate
with pytest.raises(AssertionError):
listener.attribute_updated.assert_any_call(
attr_id, attributes[attr_id]
)
else:
assert cluster._attr_cache[attr_id] == attributes[attr_id]
listener.attribute_updated.assert_any_call(attr_id, attributes[attr_id])


def test_read_attributes_response(cluster):
Expand Down

0 comments on commit 67a8dfa

Please sign in to comment.