From 0fb8c69c5a01a0986e04d456930dae1d7dfea869 Mon Sep 17 00:00:00 2001 From: Stephen Rosen Date: Tue, 8 Aug 2023 14:03:44 -0500 Subject: [PATCH] Add runtime and typing tests for _guards This also leads to a minor change from __call__ to "apply()" in order to ensure type-checkers read the types correctly. --- src/globus_sdk/_guards.py | 4 +- .../auth_requirements_error/_validators.py | 2 +- .../mypy-ignore-tests/test_guards.py | 51 +++++++++++ tests/unit/test_guards.py | 86 +++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/non-pytest/mypy-ignore-tests/test_guards.py create mode 100644 tests/unit/test_guards.py diff --git a/src/globus_sdk/_guards.py b/src/globus_sdk/_guards.py index 6eaebcdbe..d951e7e1c 100644 --- a/src/globus_sdk/_guards.py +++ b/src/globus_sdk/_guards.py @@ -47,7 +47,9 @@ class _Reducer(t.Generic[T]): def __init__(self, typ: type[T]): self.typ = typ - def __call__(self, data: t.Any) -> TypeGuard[T]: + # although it might be nice to use __call__ here, type checkers (read: mypy) do not + # always correctly treat an arbitrary callable TypeGuard as a a guard + def apply(self, data: t.Any) -> TypeGuard[T]: return isinstance(data, self.typ) def list_of(self, data: t.Any) -> TypeGuard[list[T]]: diff --git a/src/globus_sdk/experimental/auth_requirements_error/_validators.py b/src/globus_sdk/experimental/auth_requirements_error/_validators.py index 3d8f71973..3d24660b6 100644 --- a/src/globus_sdk/experimental/auth_requirements_error/_validators.py +++ b/src/globus_sdk/experimental/auth_requirements_error/_validators.py @@ -31,7 +31,7 @@ def validator(name: str, value: t.Any) -> T: return validator -str_ = _from_guard(_guards.reduce(str), "a string") +str_ = _from_guard(_guards.reduce(str).apply, "a string") opt_str = _from_guard(_guards.reduce(str).optional, "a string or null") opt_bool = _from_guard(_guards.reduce(bool).optional, "a bool or null") str_list = _from_guard(_guards.reduce(str).list_of, "a list of strings") diff --git a/tests/non-pytest/mypy-ignore-tests/test_guards.py b/tests/non-pytest/mypy-ignore-tests/test_guards.py new file mode 100644 index 000000000..ce6a4070b --- /dev/null +++ b/tests/non-pytest/mypy-ignore-tests/test_guards.py @@ -0,0 +1,51 @@ +# test that the internal _guards module provides valid and well-formed type-guards +import typing as t + +from globus_sdk import _guards + + +def get_any() -> t.Any: + return 1 + + +x = get_any() +t.assert_type(x, t.Any) + +# test reduce().apply +if _guards.reduce(str).apply(x): + y1 = x + t.assert_type(y1, str) + +# test is_list_of / reduce().list_of +if _guards.is_list_of(x, str): + t.assert_type(x, list[str]) +elif _guards.is_list_of(x, int): + t.assert_type(x, list[int]) + +if _guards.reduce(str).list_of(x): + t.assert_type(x, list[str]) +elif _guards.reduce(int).list_of(x): + t.assert_type(x, list[int]) + +# test is_optional / reduce().optional +if _guards.is_optional(x, float): + t.assert_type(x, float | None) +elif _guards.is_optional(x, bytes): + t.assert_type(x, bytes | None) + +if _guards.reduce(float).optional(x): + t.assert_type(x, float | None) +elif _guards.reduce(bytes).optional(x): + t.assert_type(x, bytes | None) + + +# test is_optional_list_of / reduce().optional_list +if _guards.is_optional_list_of(x, type(None)): + t.assert_type(x, list[None] | None) +elif _guards.is_optional_list_of(x, dict): + t.assert_type(x, list[dict[t.Any, t.Any]] | None) + +if _guards.reduce(type(None)).optional_list(x): + t.assert_type(x, list[None] | None) +elif _guards.reduce(dict).optional_list(x): + t.assert_type(x, list[dict[t.Any, t.Any]] | None) diff --git a/tests/unit/test_guards.py b/tests/unit/test_guards.py new file mode 100644 index 000000000..6dd16ccda --- /dev/null +++ b/tests/unit/test_guards.py @@ -0,0 +1,86 @@ +import pytest + +from globus_sdk import _guards + + +@pytest.mark.parametrize( + "value, typ, ok", + [ + # passing + ([], str, True), + ([1, 2], int, True), + (["1", ""], str, True), + ([], list, True), + ([[], [1, 2], ["foo"]], list, True), + # failing + ([1], str, False), + (["foo"], int, False), + ((1, 2), int, False), + (list, list, False), + (list, str, False), + (["foo", 1], str, False), + ([1, 2], list, False), + ], +) +def test_list_of_guard(value, typ, ok): + assert _guards.is_list_of(value, typ) == ok + + +@pytest.mark.parametrize( + "value, typ, ok", + [ + # passing + (None, str, True), + ("foo", str, True), + # failing + (b"foo", str, False), + ("", int, False), + (type(None), str, False), + ], +) +def test_opt_guard(value, typ, ok): + assert _guards.is_optional(value, typ) == ok + + +@pytest.mark.parametrize( + "value, typ, ok", + [ + # passing + ([], str, True), + ([], int, True), + ([1, 2], int, True), + (["1", ""], str, True), + (None, str, True), + # failing + # NB: the guard checks `list[str] | None`, not `list[str | None]` + ([None], str, False), + (b"foo", str, False), + ("", str, False), + (type(None), str, False), + ], +) +def test_opt_list_guard(value, typ, ok): + assert _guards.is_optional_list_of(value, typ) == ok + + +def test_reduced_guards(): + reduced_str = _guards.reduce(str) + + # apply => isinstance + assert reduced_str.apply("foo") + assert not reduced_str.apply(None) + + # list_of => is_list_of + assert reduced_str.list_of(["foo", "bar"]) + assert not reduced_str.list_of(["foo", "bar", 1]) + + # optional => is_optional + assert reduced_str.optional("foo") + assert reduced_str.optional(None) + assert not reduced_str.optional(1) + + # optional_list => is_optional_list_of + assert reduced_str.optional_list(["foo", "bar"]) + assert not reduced_str.optional_list(["foo", "bar", 1]) + assert reduced_str.optional_list(None) + assert not reduced_str.optional_list("")