Skip to content

Commit 5259f4c

Browse files
authored
Preload type hints and remove global lookup cache (#168)
Fixes #159
1 parent 657a13d commit 5259f4c

File tree

11 files changed

+254
-217
lines changed

11 files changed

+254
-217
lines changed

temporalio/activity.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121
from typing import (
2222
Any,
2323
Callable,
24+
List,
2425
Mapping,
2526
MutableMapping,
2627
NoReturn,
2728
Optional,
2829
Sequence,
2930
Tuple,
31+
Type,
3032
Union,
3133
overload,
3234
)
@@ -351,6 +353,9 @@ class _Definition:
351353
name: str
352354
fn: Callable
353355
is_async: bool
356+
# Types loaded on post init if both are None
357+
arg_types: Optional[List[Type]] = None
358+
ret_type: Optional[Type] = None
354359

355360
@staticmethod
356361
def from_callable(fn: Callable) -> Optional[_Definition]:
@@ -396,3 +401,9 @@ def _apply_to_callable(fn: Callable, activity_name: str) -> None:
396401
is_async=inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(fn.__call__), # type: ignore
397402
),
398403
)
404+
405+
def __post_init__(self) -> None:
406+
if self.arg_types is None and self.ret_type is None:
407+
arg_types, ret_type = temporalio.common._type_hints_from_func(self.fn)
408+
object.__setattr__(self, "arg_types", arg_types)
409+
object.__setattr__(self, "ret_type", ret_type)

temporalio/client.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ def __init__(
141141
142142
See :py:meth:`connect` for details on the parameters.
143143
"""
144-
self._type_lookup = temporalio.converter._FunctionTypeLookup()
145144
# Iterate over interceptors in reverse building the impl
146145
self._impl: OutboundInterceptor = _ClientImpl(self)
147146
for interceptor in reversed(list(interceptors)):
@@ -359,7 +358,7 @@ async def start_workflow(
359358
elif callable(workflow):
360359
defn = temporalio.workflow._Definition.must_from_run_fn(workflow)
361360
name = defn.name
362-
_, ret_type = self._type_lookup.get_type_hints(defn.run_fn)
361+
ret_type = defn.ret_type
363362
else:
364363
raise TypeError("Workflow must be a string or callable")
365364

@@ -589,12 +588,11 @@ def get_workflow_handle_for(
589588
The workflow handle.
590589
"""
591590
defn = temporalio.workflow._Definition.must_from_run_fn(workflow)
592-
_, ret_type = self._type_lookup.get_type_hints(defn.run_fn)
593591
return self.get_workflow_handle(
594592
workflow_id,
595593
run_id=run_id,
596594
first_execution_run_id=first_execution_run_id,
597-
result_type=ret_type,
595+
result_type=defn.ret_type,
598596
)
599597

600598
@overload
@@ -1053,7 +1051,7 @@ async def query(
10531051
raise RuntimeError("Cannot invoke dynamic query definition")
10541052
# TODO(cretz): Check count/type of args at runtime?
10551053
query_name = defn.name
1056-
_, ret_type = self._client._type_lookup.get_type_hints(defn.fn)
1054+
ret_type = defn.ret_type
10571055
else:
10581056
query_name = str(query)
10591057

temporalio/common.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,24 @@
22

33
from __future__ import annotations
44

5+
import inspect
6+
import types
57
from dataclasses import dataclass
68
from datetime import datetime, timedelta
79
from enum import IntEnum
8-
from typing import Any, List, Mapping, Optional, Sequence, Text, Union
10+
from typing import (
11+
Any,
12+
Callable,
13+
List,
14+
Mapping,
15+
Optional,
16+
Sequence,
17+
Text,
18+
Tuple,
19+
Type,
20+
Union,
21+
get_type_hints,
22+
)
923

1024
import google.protobuf.internal.containers
1125
from typing_extensions import TypeAlias
@@ -160,3 +174,77 @@ def _apply_headers(
160174
for k, v in source.items():
161175
# This does not copy bytes, just messages
162176
dest[k].CopyFrom(v)
177+
178+
179+
# Same as inspect._NonUserDefinedCallables
180+
_non_user_defined_callables = (
181+
type(type.__call__),
182+
type(all.__call__), # type: ignore
183+
type(int.__dict__["from_bytes"]),
184+
types.BuiltinFunctionType,
185+
)
186+
187+
188+
def _type_hints_from_func(
189+
func: Callable,
190+
) -> Tuple[Optional[List[Type]], Optional[Type]]:
191+
"""Extracts the type hints from the function.
192+
193+
Args:
194+
func: Function to extract hints from.
195+
196+
Returns:
197+
Tuple containing parameter types and return type. The parameter types
198+
will be None if there are any non-positional parameters or if any of the
199+
parameters to not have an annotation that represents a class. If the
200+
first parameter is "self" with no attribute, it is not included.
201+
"""
202+
# If this is a class instance with user-defined __call__, then use that as
203+
# the func. This mimics inspect logic inside Python.
204+
if (
205+
not inspect.isfunction(func)
206+
and not isinstance(func, _non_user_defined_callables)
207+
and not isinstance(func, types.MethodType)
208+
):
209+
# Class type or Callable instance
210+
tmp_func = func if isinstance(func, type) else type(func)
211+
call_func = getattr(tmp_func, "__call__", None)
212+
if call_func is not None and not isinstance(
213+
tmp_func, _non_user_defined_callables
214+
):
215+
func = call_func
216+
217+
# We use inspect.signature for the parameter names and kinds, but we cannot
218+
# use it for annotations because those that are using deferred hinting (i.e.
219+
# from __future__ import annotations) only work with the eval_str parameter
220+
# which is only supported in >= 3.10. But typing.get_type_hints is supported
221+
# in >= 3.7.
222+
sig = inspect.signature(func)
223+
hints = get_type_hints(func)
224+
ret_hint = hints.get("return")
225+
ret = (
226+
ret_hint
227+
if inspect.isclass(ret_hint) and ret_hint is not inspect.Signature.empty
228+
else None
229+
)
230+
args: List[Type] = []
231+
for index, value in enumerate(sig.parameters.values()):
232+
# Ignore self on methods
233+
if (
234+
index == 0
235+
and value.name == "self"
236+
and value.annotation is inspect.Parameter.empty
237+
):
238+
continue
239+
# Stop if non-positional or not a class
240+
if (
241+
value.kind is not inspect.Parameter.POSITIONAL_ONLY
242+
and value.kind is not inspect.Parameter.POSITIONAL_OR_KEYWORD
243+
):
244+
return (None, ret)
245+
# All params must have annotations or we consider none to have them
246+
arg_hint = hints.get(value.name)
247+
if not inspect.isclass(arg_hint) or arg_hint is inspect.Parameter.empty:
248+
return (None, ret)
249+
args.append(arg_hint)
250+
return args, ret

temporalio/converter.py

Lines changed: 0 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,12 @@
77
import dataclasses
88
import inspect
99
import json
10-
import types
11-
import typing
1210
from abc import ABC, abstractmethod
1311
from dataclasses import dataclass
1412
from datetime import datetime
1513
from enum import IntEnum
1614
from typing import (
1715
Any,
18-
Callable,
1916
Dict,
2017
List,
2118
Mapping,
@@ -705,105 +702,6 @@ def decode_search_attributes(
705702
return ret
706703

707704

708-
class _FunctionTypeLookup:
709-
def __init__(self) -> None:
710-
# Keyed by callable __qualname__, value is optional arg types and
711-
# optional ret type
712-
self._cache: Dict[str, Tuple[Optional[List[Type]], Optional[Type]]] = {}
713-
714-
def get_type_hints(self, fn: Any) -> Tuple[Optional[List[Type]], Optional[Type]]:
715-
# Due to MyPy issues, we cannot type "fn" as callable
716-
if not callable(fn):
717-
return (None, None)
718-
# We base the cache key on the qualified name of the function. However,
719-
# since some callables are not functions, we assume we can never cache
720-
# these just in case the type hints are dynamic for some strange reason.
721-
cache_key = getattr(fn, "__qualname__", None)
722-
if cache_key:
723-
ret = self._cache.get(cache_key)
724-
if ret:
725-
return ret
726-
# TODO(cretz): Do we even need to cache?
727-
ret = _type_hints_from_func(fn)
728-
if cache_key:
729-
self._cache[cache_key] = ret
730-
return ret
731-
732-
733-
# Same as inspect._NonUserDefinedCallables
734-
_non_user_defined_callables = (
735-
type(type.__call__),
736-
type(all.__call__), # type: ignore
737-
type(int.__dict__["from_bytes"]),
738-
types.BuiltinFunctionType,
739-
)
740-
741-
742-
def _type_hints_from_func(
743-
func: Callable,
744-
) -> Tuple[Optional[List[Type]], Optional[Type]]:
745-
"""Extracts the type hints from the function.
746-
747-
Args:
748-
func: Function to extract hints from.
749-
750-
Returns:
751-
Tuple containing parameter types and return type. The parameter types
752-
will be None if there are any non-positional parameters or if any of the
753-
parameters to not have an annotation that represents a class. If the
754-
first parameter is "self" with no attribute, it is not included.
755-
"""
756-
# If this is a class instance with user-defined __call__, then use that as
757-
# the func. This mimics inspect logic inside Python.
758-
if (
759-
not inspect.isfunction(func)
760-
and not isinstance(func, _non_user_defined_callables)
761-
and not isinstance(func, types.MethodType)
762-
):
763-
# Class type or Callable instance
764-
tmp_func = func if isinstance(func, type) else type(func)
765-
call_func = getattr(tmp_func, "__call__", None)
766-
if call_func is not None and not isinstance(
767-
tmp_func, _non_user_defined_callables
768-
):
769-
func = call_func
770-
771-
# We use inspect.signature for the parameter names and kinds, but we cannot
772-
# use it for annotations because those that are using deferred hinting (i.e.
773-
# from __future__ import annotations) only work with the eval_str parameter
774-
# which is only supported in >= 3.10. But typing.get_type_hints is supported
775-
# in >= 3.7.
776-
sig = inspect.signature(func)
777-
hints = typing.get_type_hints(func)
778-
ret_hint = hints.get("return")
779-
ret = (
780-
ret_hint
781-
if inspect.isclass(ret_hint) and ret_hint is not inspect.Signature.empty
782-
else None
783-
)
784-
args: List[Type] = []
785-
for index, value in enumerate(sig.parameters.values()):
786-
# Ignore self on methods
787-
if (
788-
index == 0
789-
and value.name == "self"
790-
and value.annotation is inspect.Parameter.empty
791-
):
792-
continue
793-
# Stop if non-positional or not a class
794-
if (
795-
value.kind is not inspect.Parameter.POSITIONAL_ONLY
796-
and value.kind is not inspect.Parameter.POSITIONAL_OR_KEYWORD
797-
):
798-
return (None, ret)
799-
# All params must have annotations or we consider none to have them
800-
arg_hint = hints.get(value.name)
801-
if not inspect.isclass(arg_hint) or arg_hint is inspect.Parameter.empty:
802-
return (None, ret)
803-
args.append(arg_hint)
804-
return args, ret
805-
806-
807705
def value_to_type(hint: Type, value: Any) -> Any:
808706
"""Convert a given value to the given type hint.
809707

temporalio/worker/activity.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ def __init__(
5454
shared_state_manager: Optional[SharedStateManager],
5555
data_converter: temporalio.converter.DataConverter,
5656
interceptors: Sequence[Interceptor],
57-
type_lookup: temporalio.converter._FunctionTypeLookup,
5857
) -> None:
5958
self._bridge_worker = bridge_worker
6059
self._task_queue = task_queue
@@ -63,7 +62,6 @@ def __init__(
6362
self._running_activities: Dict[bytes, _RunningActivity] = {}
6463
self._data_converter = data_converter
6564
self._interceptors = interceptors
66-
self._type_lookup = type_lookup
6765
# Lazily created on first activity
6866
self._worker_shutdown_event: Optional[
6967
temporalio.activity._CompositeEvent
@@ -303,7 +301,7 @@ async def _run_activity(
303301

304302
# Convert arguments. We only use arg type hints if they match the
305303
# input count.
306-
arg_types, _ = self._type_lookup.get_type_hints(activity_def.fn)
304+
arg_types = activity_def.arg_types
307305
if arg_types is not None and len(arg_types) != len(start.input):
308306
arg_types = None
309307
try:

temporalio/worker/worker.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,6 @@ def __init__(
175175
)
176176
interceptors = interceptors_from_client + list(interceptors)
177177

178-
# Instead of using the _type_lookup on the client, we create a separate
179-
# one here so we can continue to only use the public API of the client
180-
type_lookup = temporalio.converter._FunctionTypeLookup()
181-
182178
# Extract the bridge service client. We try the service on the client
183179
# first, then we support a worker_service_client on the client's service
184180
# to return underlying service client we can use.
@@ -240,7 +236,6 @@ def __init__(
240236
shared_state_manager=shared_state_manager,
241237
data_converter=client_config["data_converter"],
242238
interceptors=interceptors,
243-
type_lookup=type_lookup,
244239
)
245240
self._workflow_worker: Optional[_WorkflowWorker] = None
246241
if workflows:

0 commit comments

Comments
 (0)