Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add helpers for typing proxies #199

Merged
merged 1 commit into from
Jun 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/api/typing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
==========
Type hints
==========

.. automodule:: pykka.typing
:members: proxy_field, proxy_method

.. versionadded:: 4.0

.. autoclass:: pykka.typing.ActorMemberMixin
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Project resources
api/messages
api/logging
api/debug
api/typing


License
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ include = ["docs", "examples", "tests"]

[tool.poetry.dependencies]
python = "^3.8.0"
typing-extensions = { version = "^4.0.0", python = "<3.10" }

[tool.poetry.group.black.dependencies]
black = "^23.3.0"
Expand Down Expand Up @@ -55,7 +56,7 @@ branch = true
source = ["pykka"]

[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING"]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING", '\.\.\.']

[tool.mypy]
disallow_untyped_defs = true
Expand Down
114 changes: 114 additions & 0 deletions src/pykka/typing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""The :mod:`pykka.typing` module contains helpers to improve type hints.

Since Pykka 4.0, Pykka has complete type hints for the public API, tested using
both `Mypy <https://www.mypy-lang.org/>`_ and `Pyright
<https://github.com/microsoft/pyright>`_.

Due to the dynamic nature of :class:`~pykka.ActorProxy` objects, it is not
possible to automatically type them correctly. This module contains helpers to
manually create additional classes that correctly describe the type hints for
the proxy objects. In cases where a proxy objects is used a lot, this might be
worth the extra effort to increase development speed and catch bugs earlier.

Example usage::

from typing import cast

from pykka import ActorProxy, ThreadingActor
from pykka.typing import ActorMemberMixin, proxy_field, proxy_method


class CircleActor(ThreadingActor):
pi = 3.14

def area(self, radius: float) -> float:
return self.pi * radius**2


class CircleProxy(ActorMemberMixin, ActorProxy[CircleActor]):
pi = proxy_field(CircleActor.pi)
area = proxy_method(CircleActor.area)


proxy = cast(CircleProxy, CircleActor.start().proxy())

reveal_type(proxy.stop)
# Revealed type is 'Callable[[], pykka.Future[None]]'

reveal_type(proxy.pi)
# Revealed type is 'pykka.Future[float]'

reveal_type(proxy.area))
# Revealed type is 'Callable[[float], pykka.Future[float]]'
"""

from __future__ import annotations

import sys
from typing import TYPE_CHECKING, Any, Callable, Generic, Protocol, TypeVar

from pykka import Actor

if TYPE_CHECKING:
from pykka import Future

if sys.version_info >= (3, 10):
from typing import (
Concatenate,
ParamSpec,
)
else:
from typing_extensions import (

Check warning on line 61 in src/pykka/typing.py

View check run for this annotation

Codecov / codecov/patch

src/pykka/typing.py#L61

Added line #L61 was not covered by tests
Concatenate,
ParamSpec,
)

__all__ = [
"ActorMemberMixin",
"proxy_field",
"proxy_method",
]


T = TypeVar("T")
P = ParamSpec("P")
R = TypeVar("R", covariant=True)


class Method(Protocol, Generic[P, R]):
def __get__(self, instance: Any, owner: type | None = None) -> Callable[P, R]:
...

def __call__(self, obj: Any, *args: P.args, **kwargs: P.kwargs) -> R:
...


def proxy_field(field: T) -> Future[T]:
"""Type a field on an actor proxy.

.. versionadded:: 4.0
"""
return field # type: ignore[return-value]


def proxy_method(
field: Callable[Concatenate[Any, P], T],
) -> Method[P, Future[T]]:
"""Type a method on an actor proxy.

.. versionadded:: 4.0
"""
return field # type: ignore[return-value]


class ActorMemberMixin:
"""Mixin class for typing actor members accessible through a proxy.

.. versionadded:: 4.0
"""

stop = proxy_method(Actor.stop)
on_start = proxy_method(Actor.on_start)
on_stop = proxy_method(Actor.on_stop)
on_failure = proxy_method(Actor.on_failure)
on_receive = proxy_method(Actor.on_receive)
69 changes: 69 additions & 0 deletions tests/proxy/test_typed_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Iterator, cast

import pytest

import pykka
from pykka import Actor, ActorProxy
from pykka.typing import ActorMemberMixin, proxy_field, proxy_method

if TYPE_CHECKING:
from tests.types import Runtime


@dataclass
class Constants:
pi: float


class CircleActor(Actor):
constants = pykka.traversable(Constants(pi=3.14))
text: str = "The fox crossed the road."

def area(self, radius: float) -> float:
return self.constants.pi * radius**2


class ConstantsProxy:
pi = proxy_field(CircleActor.constants.pi)


class FooProxy(ActorMemberMixin, ActorProxy[CircleActor]):
numbers: ConstantsProxy
text = proxy_field(CircleActor.text)
area = proxy_method(CircleActor.area)


@pytest.fixture(scope="module")
def actor_class(runtime: Runtime) -> type[CircleActor]:
class FooActorImpl(CircleActor, runtime.actor_class): # type: ignore[name-defined] # noqa: E501
pass

return FooActorImpl


@pytest.fixture()
def proxy(
actor_class: type[CircleActor],
) -> Iterator[FooProxy]:
proxy = cast(FooProxy, actor_class.start().proxy())
yield proxy
proxy.stop()


def test_proxy_field(proxy: FooProxy) -> None:
assert proxy.text.get() == "The fox crossed the road."


def test_proxy_traversable_object_field(proxy: FooProxy) -> None:
assert proxy.constants.pi.get() == 3.14


def test_proxy_method(proxy: FooProxy) -> None:
assert proxy.area(2.0).get() == 12.56


def test_proxy_to_actor_methods(proxy: FooProxy) -> None:
assert proxy.stop().get() is None