From afa5b924cde117529bc6279eb5b45e3ea16e6664 Mon Sep 17 00:00:00 2001 From: "Commentator2.0" Date: Tue, 3 Jun 2025 11:28:51 +0200 Subject: [PATCH] tests/functional2: improve type checking util currently, there is a small helper funciton in lang_util to check if something is of a list type generic to improve re-usability, this function is moved to utils and improved to be also check for nested iterables and such Change-Id: I92984daa4c4decf13d340a2ea5e52f724cee800e --- tests/functional2/lang/lang_util.py | 26 +------- tests/functional2/testlib/test_utils.py | 87 +++++++++++++++++++++++++ tests/functional2/testlib/utils.py | 41 +++++++++++- 3 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 tests/functional2/testlib/test_utils.py diff --git a/tests/functional2/lang/lang_util.py b/tests/functional2/lang/lang_util.py index f68a859ea..29e43c93d 100644 --- a/tests/functional2/lang/lang_util.py +++ b/tests/functional2/lang/lang_util.py @@ -9,8 +9,7 @@ import toml from toml import TomlDecodeError from functional2.testlib.fixtures.file_helper import FileDeclaration, CopyFile, AssetSymlink -from functional2.testlib.utils import test_base_folder - +from functional2.testlib.utils import test_base_folder, is_value_of_type LANG_TEST_ID_PATTERN = "{folder_name}:{test_name}" @@ -126,25 +125,6 @@ def _group_lang_tests(tests: list[LangTest]) -> dict[LangTestRunner, list[LangTe return grouped_tests -def _is_list_of_type(value: Any, expected_type: type[Any]) -> bool: - """ - checks if the given value conforms to the type `list[expected_type]` - :param value: value to check - :param expected_type: what type each item should be - :return: True, if it conforms, False otherwise - """ - return isinstance(value, list) and all(isinstance(v, expected_type) for v in value) - - -def _is_list_of_strings(value: Any) -> bool: - """ - same as `_is_list_of_type` but with type `str` pre-applied - :param value: value to check - :return: True, if the value conforms to `list[str]` otherwise False - """ - return _is_list_of_type(value, str) - - def _collect_toml_test_group(folder: Path) -> tuple[list[LangTest], list[InvalidLangTest]]: """ Collects all tests, declared by a `test.toml` file within the given folder @@ -185,7 +165,7 @@ def _collect_toml_test_group(folder: Path) -> tuple[list[LangTest], list[Invalid continue flags = definition.pop("flags", []) - if not _is_list_of_strings(flags): + if not is_value_of_type(flags, list[str]): test_errors.append( f"invalid value type for 'flags': {flags}, expected a list of strings" ) @@ -198,7 +178,7 @@ def _collect_toml_test_group(folder: Path) -> tuple[list[LangTest], list[Invalid runner = None extra_files = definition.pop("extra-files", []) - if not _is_list_of_strings(extra_files): + if not is_value_of_type(extra_files, list[str]): test_errors.append( f"invalid value type for 'extra_files': {extra_files}, expected a list of strings" ) diff --git a/tests/functional2/testlib/test_utils.py b/tests/functional2/testlib/test_utils.py new file mode 100644 index 000000000..5d6ba3793 --- /dev/null +++ b/tests/functional2/testlib/test_utils.py @@ -0,0 +1,87 @@ +from collections.abc import Callable, Generator +from typing import Literal, Any + +import pytest +from functional2.testlib.utils import is_value_of_type + + +def test_list_type_valid(): + assert is_value_of_type([1, 2, 3], list[int]) + + +def test_list_type_valid_multi_type(): + assert is_value_of_type([1, "a", 2], list[int | str]) + + +def test_list_type_invalid_single_fail(): + assert not is_value_of_type([1, 2, "a"], list[int]) + + +def test_list_type_invalid_all_wrong(): + assert not is_value_of_type([1, 2, 3], list[str]) + + +def test_list_type_nested(): + assert is_value_of_type([[1], [2, 3], [4]], list[list[int]]) + + +def test_list_type_nested_single_invalid(): + assert not is_value_of_type([[1], [2, 3], ["a"]], list[list[int]]) + + +def test_weird_type_valid(): + assert is_value_of_type(42, Literal[42]) + + +def test_type_type(): + class Foo: ... + + assert is_value_of_type(Foo, type[Foo]) + + +def test_type_doesnt_allow_generator(): + def foo(): + yield 1 + + with pytest.raises(ValueError, match="Unsupported expected_type.+"): + is_value_of_type(foo, Generator[int, None, None]) + + +def test_type_doesnt_allow_callable(): + def foo(a): # noqa: ANN001, ANN202: dummy stuff within testing, nothing external + return "a" + str(a) + + with pytest.raises(ValueError, match="Unsupported expected_type.+"): + is_value_of_type(foo, Callable[[int], str]) + + +def test_type_allows_none(): + assert is_value_of_type(None, type[None]) + + +def test_doesnt_allow_generic(): + class X[T]: ... + + with pytest.raises(ValueError, match="Unsupported expected_type.+"): + is_value_of_type(0, X[int]) + + +def test_type_dict_checks_valid(): + d = {"a": 1, "b": 2} + assert is_value_of_type(d, dict[str, int]) + + +def test_type_dict_key_invalid(): + d = {"a": 1, 1: 2} + assert not is_value_of_type(d, dict[str, int]) + + +def test_type_dict_value_invalid(): + d = {"a": 1, "b": "2"} + assert not is_value_of_type(d, dict[str, int]) + + +def test_type_allows_any(): + assert is_value_of_type(1, Any) + assert is_value_of_type("a", Any) + assert is_value_of_type(lambda x: x, Any) diff --git a/tests/functional2/testlib/utils.py b/tests/functional2/testlib/utils.py index 576eb8bdb..737e8d9ad 100644 --- a/tests/functional2/testlib/utils.py +++ b/tests/functional2/testlib/utils.py @@ -1,4 +1,9 @@ +import builtins +import types +import typing from pathlib import Path +from types import UnionType +from typing import Any, Literal, get_args, get_origin from functional2.testlib.fixtures.file_helper import ( CopyFile, @@ -7,7 +12,6 @@ from functional2.testlib.fixtures.file_helper import ( merge_file_declaration, ) - # Things have to be resolved from top to bottom, because otherwise the tests behave flakey # due to the internal file structure @@ -86,3 +90,38 @@ def get_functional2_lang_files(additional_files: FileDeclaration | None = None) additional_files, ) ) + + +def is_value_of_type(value: Any, expected_type: type[Any] | UnionType) -> bool: + """ + checks if the given value conforms to the type. + This function is useful, to check if all values conform to the inner iterable type + :param value: value to check + :param expected_type: what type each item should be; must be a non-generic type or one of {dict, list, set, UnionType, Literal} + :return: True if it conforms, otherwise False + """ + supported_origin_types = {dict, list, set, type, UnionType, Literal} + origin = get_origin(expected_type) + if expected_type is Any: + return True + match origin: + case None | types.UnionType: + return isinstance(value, expected_type) + case typing.Literal: + return value in get_args(expected_type) + case builtins.type: + return value is get_args(expected_type)[0] + case builtins.list | builtins.set | builtins.dict: + matches_origin = isinstance(value, origin) + if not matches_origin: + return False + matches = all(is_value_of_type(v, get_args(expected_type)[0]) for v in value) + if origin is dict: + # also check the value side of dicts + matches = matches and all( + is_value_of_type(v, get_args(expected_type)[1]) for v in value.values() + ) + return matches + case _: + msg = f"Unsupported expected_type. Must be a non-generic or one of {supported_origin_types!r}" + raise ValueError(msg)