diff --git a/.github/workflows/codecov.yaml b/.github/workflows/codecov.yaml index f5b51185..66d5224d 100644 --- a/.github/workflows/codecov.yaml +++ b/.github/workflows/codecov.yaml @@ -15,12 +15,11 @@ jobs: - name: Setup Python uses: actions/setup-python@master with: - python-version: 3.7 + python-version: 3.9 - name: Generate coverage report run: | - pip install pytest - pip install pytest-cov + pip install -r requirements-test.txt pytest --cov=./ --cov-report=xml # documentation: https://github.com/codecov/codecov-action @@ -30,7 +29,8 @@ jobs: directory: ./coverage/reports/ env_vars: OS,PYTHON fail_ci_if_error: true - files: ./coverage1.xml,./coverage2.xml + files: ./coverage.xml flags: unittests name: codecov-volkswagencarnet - path_to_write_report: ./coverage/codecov_report.txt \ No newline at end of file + # path_to_write_report: ./coverage/codecov_report.txt + verbose: true \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9ffd8d12..0e112509 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: language: ["python"] - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - name: Checkout repository diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 70113b80..47a761a5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -50,7 +50,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10"] steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index dcc55544..5267e606 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,11 @@ build dist *.egg-info *vw.conf +.coverage +.eggs .vscode /tests/credentials.py -.eggs /venv/ /volkswagencarnet/version.py +/volkswagencarnet.iml +htmlcov diff --git a/pytest.ini b/pytest.ini index 52b4658b..d89aa100 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] asyncio_mode=strict -addopts = --doctest-modules +addopts = -ra +minversion = 5.4.3 diff --git a/requirements-test.txt b/requirements-test.txt index d57d9609..ffbd76dc 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,7 +2,9 @@ lxml beautifulsoup4 aiohttp pyjwt -pytest +pytest>=7.0.0 setuptools pytest-asyncio -flake8 \ No newline at end of file +flake8 +pytest-cov +black \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index ae6aa2c8..7272624a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -75,3 +75,11 @@ minversion = 5.4.3 addopts = -ra -q testpaths = tests python_files = *_test.py + +[coverage:run] +branch = True +omit = tests/*,volkswagencarnet/version.py # define paths to omit + +[coverage:report] +#show_missing = True +skip_covered = False \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..ce9c654c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys + +pytest_plugins = ["pytest_cov"] + +if sys.version_info >= (3, 8): + pytest_plugins.append("tests.fixtures.connection") diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/connection.py b/tests/fixtures/connection.py new file mode 100644 index 00000000..3582f6c2 --- /dev/null +++ b/tests/fixtures/connection.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +import pytest +import pytest_asyncio +from aiohttp import CookieJar, ClientSession + +from volkswagencarnet.vw_connection import Connection + +current_path = Path(os.path.dirname(os.path.realpath(__file__))) +resource_path = os.path.join(current_path, "resources") + + +@pytest_asyncio.fixture +async def session(): + """Client session that can be used in tests""" + jar = CookieJar() + jar.load(os.path.join(resource_path, "dummy_cookies.pickle")) + sess = ClientSession(headers={"Connection": "keep-alive"}, cookie_jar=jar) + yield sess + await sess.close() + + +@pytest.fixture +def connection(session): + """Real connection for integration tests""" + return Connection(session=session, username="", password="", country="DE", interval=999, fulldebug=True) diff --git a/tests/fixtures/resources/dummy_cookies.pickle b/tests/fixtures/resources/dummy_cookies.pickle new file mode 100644 index 00000000..6074b72f Binary files /dev/null and b/tests/fixtures/resources/dummy_cookies.pickle differ diff --git a/tests/vw_connection_test.py b/tests/vw_connection_test.py new file mode 100644 index 00000000..252cfb0e --- /dev/null +++ b/tests/vw_connection_test.py @@ -0,0 +1,110 @@ +import logging.config +import sys + +# This won't work on python versions less than 3.8 +if sys.version_info >= (3, 8): + from unittest import IsolatedAsyncioTestCase +else: + + class IsolatedAsyncioTestCase: + pass + + +import unittest +from io import StringIO +from sys import argv +from unittest.mock import patch + +import pytest + +import volkswagencarnet.vw_connection +from volkswagencarnet.vw_connection import Connection +from volkswagencarnet.vw_vehicle import Vehicle + + +@pytest.mark.skipif(condition=sys.version_info < (3, 8), reason="Test incompatible with Python < 3.8") +def test_clear_cookies(connection): + assert len(connection._session._cookie_jar._cookies) > 0 + connection._clear_cookies() + assert len(connection._session._cookie_jar._cookies) == 0 + + +class CmdLineTest(IsolatedAsyncioTestCase, unittest.TestCase): + class FailingLoginConnection: + def __init__(self, sess, **kwargs): + self._session = sess + + async def doLogin(self): + return False + + class TwoVehiclesConnection: + def __init__(self, sess, **kwargs): + self._session = sess + + async def doLogin(self): + return True + + async def update(self): + return True + + @property + def vehicles(self): + vehicle1 = Vehicle(None, "vin1") + vehicle2 = Vehicle(None, "vin2") + return [vehicle1, vehicle2] + + @pytest.mark.asyncio + @patch.object(volkswagencarnet.vw_connection.logging, "basicConfig") + @patch("volkswagencarnet.vw_connection.Connection", spec_set=Connection, new=FailingLoginConnection) + @pytest.mark.skipif(condition=sys.version_info < (3, 8), reason="Test incompatible with Python < 3.8") + async def test_main_argv(self, logger_config): + # TODO: use patch to only change argv during the test? + if "-v" in argv: + argv.remove("-v") + if "-vv" in argv: + argv.remove("-vv") + # Assert default logger level is ERROR + await volkswagencarnet.vw_connection.main() + logger_config.assert_called_with(level=logging.ERROR) + + # -v should be INFO + argv.append("-v") + await volkswagencarnet.vw_connection.main() + logger_config.assert_called_with(level=logging.INFO) + argv.remove("-v") + + # -vv should be DEBUG + argv.append("-vv") + await volkswagencarnet.vw_connection.main() + logger_config.assert_called_with(level=logging.DEBUG) + + @pytest.mark.asyncio + @patch("sys.stdout", new_callable=StringIO) + @patch("volkswagencarnet.vw_connection.Connection", spec_set=Connection, new=FailingLoginConnection) + @pytest.mark.skipif(condition=sys.version_info < (3, 8), reason="Test incompatible with Python < 3.8") + async def test_main_output_failed(self, stdout: StringIO): + await volkswagencarnet.vw_connection.main() + assert stdout.getvalue() == "" + + @pytest.mark.asyncio + @patch("sys.stdout", new_callable=StringIO) + @patch("volkswagencarnet.vw_connection.Connection", spec_set=Connection, new=TwoVehiclesConnection) + @pytest.mark.skipif(condition=sys.version_info < (3, 8), reason="Test incompatible with Python < 3.8") + async def test_main_output_two_vehicles(self, stdout: StringIO): + await volkswagencarnet.vw_connection.main() + assert ( + stdout.getvalue() + == """Vehicle id: vin1 +Supported sensors: + - Force data refresh (domain:switch) - Off + - Request results (domain:sensor) - Unknown + - Requests remaining (domain:sensor) - -1 + - Request in progress (domain:binary_sensor) - Off +Vehicle id: vin2 +Supported sensors: + - Force data refresh (domain:switch) - Off + - Request results (domain:sensor) - Unknown + - Requests remaining (domain:sensor) - -1 + - Request in progress (domain:binary_sensor) - Off +""" + ) diff --git a/tests/vw_utilities_test.py b/tests/vw_utilities_test.py new file mode 100644 index 00000000..2a377c0e --- /dev/null +++ b/tests/vw_utilities_test.py @@ -0,0 +1,104 @@ +import unittest +from datetime import datetime, timezone, timedelta +from json import JSONDecodeError +from unittest import mock +from unittest.mock import DEFAULT + +from volkswagencarnet.vw_utilities import camel2slug, is_valid_path, obj_parser, json_loads, read_config + + +class UtilitiesTest(unittest.TestCase): + def test_camel_to_slug(self): + data = {"foo": "foo", "fooBar": "foo_bar", "XYZ": "x_y_z", "B4R": "b4_r"} # Should this actually be "b_4_r"? =) + for v in data: + res = camel2slug(v) + self.assertEqual(data[v], res) + + def test_is_valid_path(self): + data = { + "None": [None, None, True], + "a in a": [{"a": 1}, "a", True], + "b in a": [{"a": 1}, "b", False], + "a.b in a.b": [{"a": {"b": 7}}, "a.b", True], + "list": [[1, "a", None], "a", TypeError], + } + + for v in data: + with self.subTest(): + try: + if isinstance(data[v][2], bool): + self.assertEqual( + data[v][2], + is_valid_path(data[v][0], data[v][1]), + msg=f"Path validation error for {data[v][1]} in {data[v][0]}", + ) + else: + with self.assertRaises(data[v][2]): + is_valid_path(data[v][0], data[v][1]) + except Exception as e: + if isinstance(e, AssertionError): + raise + self.fail(f"Wrong exception? Got {type(e)} but expected {data[v][2]}") + + def test_obj_parser(self): + data = { + "int": [0, AttributeError], + "dict": [{"foo": "bar"}, {"foo": "bar"}], + "dict with time": [ + {"foo": "2001-01-01T23:59:59Z"}, + {"foo": datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone.utc)}, + ], + "dict with timezone": [ + {"foo": "2001-01-01T23:59:59+0200"}, + {"foo": datetime(2001, 1, 1, 23, 59, 59, tzinfo=timezone(timedelta(hours=2)))}, + ], + } + for v in data: + if isinstance(data[v][1], dict): + res = obj_parser(data[v][0]) + self.assertEqual(data[v][1], res) + else: + with self.assertRaises(data[v][1]): + obj_parser(data[v][0]) + + def test_json_loads(self): + expected = {"foo": {"bar": "baz"}} + actual = json_loads('{"foo": {\n"bar":\t"baz"}}') + self.assertEqual(expected, actual) + + self.assertEqual(42, json_loads("42")) + + with self.assertRaises(JSONDecodeError): + json_loads("{[}") + with self.assertRaises(TypeError): + json_loads(42) + + def test_read_config_success(self): + """successfully read configuration from a file""" + read_data = """ +# Comment +foo: bar +""" + mock_open = mock.mock_open(read_data=read_data) + with mock.patch("builtins.open", mock_open): + res = read_config() + self.assertEqual({"foo": "bar"}, res) + + def test_read_config_error(self): + """success on second file, but parse error""" + read_data = """ +foo: bar +baz +""" + mock_open = mock.mock_open(read_data=read_data) + mock_open.side_effect = [IOError, DEFAULT] + with mock.patch("builtins.open", mock_open): + with self.assertRaises(ValueError): + read_config() + + def test_read_config_not_found(self): + """empty config on no file found""" + mock_open = mock.mock_open() + mock_open.side_effect = IOError + with mock.patch("builtins.open", mock_open): + self.assertEqual({}, read_config()) diff --git a/volkswagencarnet/vw_connection.py b/volkswagencarnet/vw_connection.py index e004ff67..5dfc13e9 100644 --- a/volkswagencarnet/vw_connection.py +++ b/volkswagencarnet/vw_connection.py @@ -94,7 +94,7 @@ async def doLogin(self, tries: int = 1): if self._session_logged_in: break _LOGGER.info("Something failed") - await asyncio.sleep(random * 5) + await asyncio.sleep(random() * 5) if not self._session_logged_in: return False @@ -1026,7 +1026,6 @@ async def setClimater(self, vin, data, spin): except: self._session_headers.pop("X-securityToken", None) raise - return False async def setPreHeater(self, vin, data, spin): contType = None diff --git a/volkswagencarnet/vw_utilities.py b/volkswagencarnet/vw_utilities.py index e6da6610..03cef730 100644 --- a/volkswagencarnet/vw_utilities.py +++ b/volkswagencarnet/vw_utilities.py @@ -6,11 +6,12 @@ from os import environ as env from os.path import join, dirname, expanduser from sys import argv +from typing import Any _LOGGER = logging.getLogger(__name__) -def read_config(): +def read_config() -> dict: """Read config from file.""" for directory, filename in product( [ @@ -30,11 +31,11 @@ def read_config(): return {} -def json_loads(s): +def json_loads(s) -> Any: return json.loads(s, object_hook=obj_parser) -def obj_parser(obj): +def obj_parser(obj: dict) -> dict: """Parse datetime.""" for key, val in obj.items(): try: @@ -44,7 +45,7 @@ def obj_parser(obj): return obj -def find_path(src, path): +def find_path(src, path) -> Any: """Simple navigation of a hierarchical dict structure using XPATH-like syntax. >>> find_path(dict(a=1), 'a') @@ -101,7 +102,7 @@ def is_valid_path(src, path): return False -def camel2slug(s): +def camel2slug(s: str) -> str: """Convert camelCase to camel_case. >>> camel2slug('fooBar')