Repository: XKNX/xknx
Pull request #1087 (2022-11-24 20:25:47)

Load keyfile async friendly
<!--
  You are awesome! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template!.
-->
## Description
<!--
Please include a summary of the change and which issue is fixed.
Please also include relevant motivation and context.
List any dependencies that are required for this change.
-->


Fixes # (issue)

## Type of change
<!--
Please tick the applicable options.
NOTE: Ticking multiple options most likely indicates
that your change is to big and it is suggested to split it into several smaller PRs.
-->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

## Checklist:

- [ ] The documentation has been adjusted accordingly
- [ ] Tests have been added that prove the fix is effective or that the feature works
- [x] The changes are documented in the changelog (docs/changelog.md)

codecov[bot]: # [Codecov](https://codecov.io/gh/XKNX/xknx/pull/1087?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) Report
> Merging [#1087](https://codecov.io/gh/XKNX/xknx/pull/1087?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (bae52f1) into [main](https://codecov.io/gh/XKNX/xknx/commit/74ef6ea9e1c45cd75614ab4e854f1b2e62639c75?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) (74ef6ea) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/XKNX/xknx/pull/1087/graphs/tree.svg?width=650&height=150&src=pr&token=irWbIygS84&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)](https://codecov.io/gh/XKNX/xknx/pull/1087?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX)

```diff
@@           Coverage Diff           @@
##             main    #1087   +/-   ##
=======================================
  Coverage   96.29%   96.29%           
=======================================
  Files         138      138           
  Lines        8633     8635    +2     
=======================================
+ Hits         8313     8315    +2     
  Misses        320      320           
```


| [Impacted Files](https://codecov.io/gh/XKNX/xknx/pull/1087?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX) | Coverage Δ | |
|---|---|---|
| [xknx/io/knxip\_interface.py](https://codecov.io/gh/XKNX/xknx/pull/1087/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9pby9rbnhpcF9pbnRlcmZhY2UucHk=) | `84.13% <100.00%> (ø)` | |
| [xknx/secure/\_\_init\_\_.py](https://codecov.io/gh/XKNX/xknx/pull/1087/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9zZWN1cmUvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [xknx/secure/keyring.py](https://codecov.io/gh/XKNX/xknx/pull/1087/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=XKNX#diff-eGtueC9zZWN1cmUva2V5cmluZy5weQ==) | `99.11% <100.00%> (+<0.01%)` | :arrow_up: |


Patch:
diff --git a/docs/changelog.md b/docs/changelog.md
index a46847b5..e3db6e17 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -29,6 +29,7 @@ nav_order: 2
 ### Internal
 
 - Remove `InterfaceWithUserIdNotFound` and `InvalidSignature` errors in favor of `InvalidSecureConfiguration`.
+- Keyring: rename `load_key_ring` to `load_keyring` and make it a coroutine.
 
 ### Management
 
diff --git a/xknx/io/gateway_scanner.py b/xknx/io/gateway_scanner.py
index 834585a8..78f05f10 100644
--- a/xknx/io/gateway_scanner.py
+++ b/xknx/io/gateway_scanner.py
@@ -192,6 +192,10 @@ class GatewayScanFilter:
             return True
         return False
 
+    def __eq__(self, other: object) -> bool:
+        """Equality for GatewayScanFilter class."""
+        return self.__dict__ == other.__dict__
+
 
 class GatewayScanner:
     """Class for searching KNX/IP devices."""
diff --git a/xknx/io/knxip_interface.py b/xknx/io/knxip_interface.py
index 0301f223..793101bc 100644
--- a/xknx/io/knxip_interface.py
+++ b/xknx/io/knxip_interface.py
@@ -20,7 +20,7 @@ from xknx.exceptions import (
     XKNXException,
 )
 from xknx.io import util
-from xknx.secure.keyring import XMLInterface, load_key_ring
+from xknx.secure.keyring import XMLInterface, load_keyring
 from xknx.telegram import IndividualAddress, Telegram
 
 from .connection import ConnectionConfig, ConnectionType
@@ -197,7 +197,7 @@ class KNXIPInterface:
             _gateway = gateway_descriptor or await request_description(
                 gateway_ip=gateway_ip, gateway_port=gateway_port
             )
-            xml_interface = self._get_tunnel_interface_from_keyfile(
+            xml_interface = await self._get_tunnel_interface_from_keyfile(
                 keyfile_path=secure_config.knxkeys_file_path,
                 keyfile_password=secure_config.knxkeys_password,
                 gateway_descriptor=_gateway,
@@ -234,7 +234,7 @@ class KNXIPInterface:
         )
         await self._interface.connect()
 
-    def _get_tunnel_interface_from_keyfile(
+    async def _get_tunnel_interface_from_keyfile(
         self,
         keyfile_path: str,
         keyfile_password: str,
@@ -246,7 +246,7 @@ class KNXIPInterface:
 
         Precedence: configured individual address > configured user id > first free tunnel interface
         """
-        keyring = load_key_ring(
+        keyring = await load_keyring(
             keyfile_path,
             keyfile_password,
         )
@@ -379,7 +379,7 @@ class KNXIPInterface:
             self.connection_config.secure_config.knxkeys_file_path is not None
             and self.connection_config.secure_config.knxkeys_password is not None
         ):
-            keyring = load_key_ring(
+            keyring = await load_keyring(
                 self.connection_config.secure_config.knxkeys_file_path,
                 self.connection_config.secure_config.knxkeys_password,
             )
diff --git a/xknx/secure/__init__.py b/xknx/secure/__init__.py
index 81aae992..e9f7bd59 100644
--- a/xknx/secure/__init__.py
+++ b/xknx/secure/__init__.py
@@ -1,10 +1,10 @@
 """Classes for handling KNX IP Secure."""
-from .keyring import Keyring, load_key_ring
+from .keyring import Keyring, load_keyring
 from .util import bytes_xor, sha256_hash
 
 __all__ = [
     "Keyring",
-    "load_key_ring",
+    "load_keyring",
     "bytes_xor",
     "sha256_hash",
 ]
diff --git a/xknx/secure/keyring.py b/xknx/secure/keyring.py
index 3225a3c0..6a2960d2 100644
--- a/xknx/secure/keyring.py
+++ b/xknx/secure/keyring.py
@@ -1,8 +1,8 @@
 """Keyring class for loading and decrypting knxkeys files."""
 from __future__ import annotations
 
-import abc
-from abc import ABC
+from abc import ABC, abstractmethod
+import asyncio
 import base64
 import enum
 from itertools import chain
@@ -37,7 +37,7 @@ class InterfaceType(enum.Enum):
 class AttributeReader(ABC):
     """Abstract base class for modelling attribute reader capabilities."""
 
-    @abc.abstractmethod
+    @abstractmethod
     def parse_xml(self, node: Document) -> None:
         """Parse all needed attributes from the given node map."""
 
@@ -348,7 +348,19 @@ class Keyring(AttributeReader):
             self.backbone.decrypt_attributes(hashed_password, initialization_vector)
 
 
-def load_key_ring(path: str, password: str, validate_signature: bool = True) -> Keyring:
+async def load_keyring(
+    path: str, password: str, validate_signature: bool = True
+) -> Keyring:
+    """Load a .knxkeys file from the given path in an executor."""
+    return await asyncio.to_thread(
+        _load_keyring,
+        path,
+        password,
+        validate_signature=validate_signature,
+    )
+
+
+def _load_keyring(path: str, password: str, validate_signature: bool = True) -> Keyring:
     """Load a .knxkeys file from the given path."""
 
     if validate_signature:


Test patch:
diff --git a/test/io_tests/connection_test.py b/test/io_tests/connection_test.py
new file mode 100644
index 00000000..fe29092f
--- /dev/null
+++ b/test/io_tests/connection_test.py
@@ -0,0 +1,15 @@
+"""Unit test for xknx.io.connection module."""
+
+from xknx.io.connection import ConnectionConfig, SecureConfig
+
+
+def test_connection_config_compare():
+    """Test ConnectionConfig comparison."""
+    assert ConnectionConfig() == ConnectionConfig()
+    assert ConnectionConfig() != ConnectionConfig(individual_address="1.1.1")
+
+
+def test_secure_config_compare():
+    """Test SecureConfig comparison."""
+    assert SecureConfig() == SecureConfig()
+    assert SecureConfig() != SecureConfig(user_id=5)
diff --git a/test/io_tests/gateway_scanner_test.py b/test/io_tests/gateway_scanner_test.py
index 40a939c3..27cb150d 100644
--- a/test/io_tests/gateway_scanner_test.py
+++ b/test/io_tests/gateway_scanner_test.py
@@ -466,6 +466,11 @@ class TestGatewayScanner:
         assert isinstance(frame_2.body, SearchRequest)
         assert frame_1.body.discovery_endpoint == HPAI(ip_addr="10.1.1.2", port=56789)
 
+    def test_gateway_scan_filter_compare(self):
+        """Test GatewayScanFilter comparison."""
+        assert GatewayScanFilter() == GatewayScanFilter()
+        assert GatewayScanFilter() != GatewayScanFilter(tunnelling=False)
+
 
 def fake_router_search_response() -> KNXIPFrame:
     """Return the KNXIPFrame of a KNX/IP Router with a SearchResponse body."""
diff --git a/test/secure_tests/keyring_test.py b/test/secure_tests/keyring_test.py
index 302aea1f..e70c9c9a 100644
--- a/test/secure_tests/keyring_test.py
+++ b/test/secure_tests/keyring_test.py
@@ -4,8 +4,13 @@ import os
 import pytest
 
 from xknx.exceptions.exception import InvalidSecureConfiguration
-from xknx.secure import Keyring, load_key_ring
-from xknx.secure.keyring import XMLDevice, XMLInterface, verify_keyring_signature
+from xknx.secure.keyring import (
+    Keyring,
+    XMLDevice,
+    XMLInterface,
+    _load_keyring,
+    verify_keyring_signature,
+)
 from xknx.telegram import IndividualAddress
 
 
@@ -34,7 +39,7 @@ class TestKeyRing:
 
     def test_load_keyring(self):
         """Test load keyring from knxkeys file."""
-        keyring: Keyring = load_key_ring(self.keyring_test_file, "pwd")
+        keyring: Keyring = _load_keyring(self.keyring_test_file, "pwd")
         TestKeyRing.assert_interface(keyring, "user4", IndividualAddress("1.1.4"))
         TestKeyRing.assert_interface(keyring, "@zvI1G&_", IndividualAddress("1.1.6"))
         TestKeyRing.assert_interface(keyring, "ZvDY-:g#", IndividualAddress("1.1.7"))
@@ -47,7 +52,7 @@ class TestKeyRing:
 
     def test_load_keyring_real(self):
         """Test load keyring from knxkeys file."""
-        keyring: Keyring = load_key_ring(self.testcase_file, "password")
+        keyring: Keyring = _load_keyring(self.testcase_file, "password")
         TestKeyRing.assert_interface(keyring, "user1", IndividualAddress("1.0.1"))
         TestKeyRing.assert_interface(keyring, "user2", IndividualAddress("1.0.11"))
         TestKeyRing.assert_interface(keyring, "user3", IndividualAddress("1.0.12"))
@@ -72,11 +77,11 @@ class TestKeyRing:
     def test_invalid_signature(self):
         """Test invalid signature throws error."""
         with pytest.raises(InvalidSecureConfiguration):
-            load_key_ring(self.testcase_file, "wrong_password")
+            _load_keyring(self.testcase_file, "wrong_password")
 
     def test_raises_error(self):
         """Test raises error if password is wrong."""
         with pytest.raises(InvalidSecureConfiguration):
-            load_key_ring(
+            _load_keyring(
                 self.testcase_file, "wrong_password", validate_signature=False
             )