Skip to content

Instantly share code, notes, and snippets.

@dimaqq
Last active August 30, 2024 06:31
Show Gist options
  • Save dimaqq/28e9b4fde1921e7c4474f9c89c613c0b to your computer and use it in GitHub Desktop.
Save dimaqq/28e9b4fde1921e7c4474f9c89c613c0b to your computer and use it in GitHub Desktop.
Type tests via custom type guard
import ... # exinsting code
@pytest_fixture
def charm_env(monkeypatch, ...): # existing code
...
from typing import Type, TypeVar, Any, Optional, Callable, Mapping
from typing_extensions import TypeGuard
import inspect
T = TypeVar('T', bound=Callable[..., Any])
def type_guard(func: T) -> TypeGuard[T]: # ⬅️ NEW ❗️
"""Validate that passed callable has the type signature we expect ops.main() to have.
- argument names in correct order
- both positional and keyword invocation is possible
- allows extending the signature with more arguments
"""
assert callable(func)
params: Mapping[str, inspect.Parameter] = inspect.signature(func).parameters
p1, p2 = tuple(params.values())[:2]
assert p1.name == "charm_class"
assert p1.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
assert p1.annotation == Type[ops.charm.CharmBase]
assert p1.default is inspect.Parameter.empty
assert p2.name == 'use_juju_for_storage'
assert p2.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD
assert p2.annotation == Optional[bool]
assert p2.default is None
return True
def test_top_level_import(charm_env: None):
import ops
assert type_guard(ops.main) # ⬅️ NEW ❗️
ops.main(ops.CharmBase)
with pytest.raises(TypeError):
ops.main() # type: ignore
def test_top_level_import_legacy_call(charm_env: None):
import ops
assert type_guard(ops.main.main) # ⬅️ NEW ❗️
with pytest.deprecated_call():
ops.main.main(ops.CharmBase)
with pytest.raises(TypeError):
ops.main.main() # type: ignore
def test_submodule_import(charm_env: None):
import ops.main
assert type_guard(ops.main) # type: ignore # ⬅️ NEW ❗️
ops.main(ops.CharmBase) # type: ignore # https://github.com/microsoft/pyright/issues/8830
with pytest.raises(TypeError):
ops.main() # type: ignore
def test_submodule_import_legacy_call(charm_env: None):
import ops.main
assert type_guard(ops.main.main) # ⬅️ NEW ❗️
with pytest.deprecated_call():
ops.main.main(ops.CharmBase)
with pytest.raises(TypeError):
ops.main.main() # type: ignore
def test_import_from_top_level_module(charm_env: None):
from ops import main
assert type_guard(main) # ⬅️ NEW ❗️
main(ops.CharmBase)
with pytest.raises(TypeError):
main() # type: ignore
def test_import_from_top_level_module_legacy_call(charm_env: None):
from ops import main
assert type_guard(main.main) # ⬅️ NEW ❗️
with pytest.deprecated_call():
main.main(ops.CharmBase)
with pytest.raises(TypeError):
main.main() # type: ignore
def test_legacy_import_from_submodule(charm_env: None):
from ops.main import main
assert type_guard(main) # ⬅️ NEW ❗️
with pytest.deprecated_call():
main(ops.CharmBase)
with pytest.raises(TypeError):
main() # type: ignore
@dimaqq
Copy link
Author

dimaqq commented Aug 30, 2024

Caveats:

Supports main decorated by stdlib decorators, does not support proxies like def foo(*args, **kw).

Works when type is correct.

Shows reasonable error when type is wrong:

>       assert p1.name == "charm_class"
E       AssertionError: assert 'args' == 'charm_class'
E         - charm_class
E         + args

test/test_main_invocation.py:54: AssertionError

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment