Skip to content

Commit d221f06

Browse files
committed
Refactor run-time type checker match_value_to_typespec
1 parent e40ede9 commit d221f06

File tree

3 files changed

+125
-84
lines changed

3 files changed

+125
-84
lines changed

unpythonic/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .slicing import *
4040
from .symbol import *
4141
from .tco import *
42+
from .typecheck import *
4243

4344
# HACK: break dependency loop
4445
from .lazyutil import _init_module

unpythonic/dispatch.py

Lines changed: 2 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import typing
2626

2727
from .arity import resolve_bindings, _getfunc
28+
from .typecheck import match_value_to_typespec
2829

2930
def generic(f):
3031
"""Decorator. Make `f` a generic function (in the sense of CLOS or Julia).
@@ -80,97 +81,14 @@ def generic(f):
8081
# Dispatcher - this will replace the original f.
8182
@wraps(f)
8283
def multidispatch(*args, **kwargs):
83-
# It's silly that while Python supports type annotations, the stdlib doesn't have a function to
84-
# perform a type annotation based runtime type check. Fuck it, we'll just have to implement one.
85-
#
86-
# Many `typing` meta-utilities explicitly reject using isinstance and issubclass on them,
87-
# so we hack those by inspecting the repr.
88-
#
89-
# value: value whose type to check
90-
#
91-
# spec: match value against this.
92-
# Either a concrete type, or one of the meta-utilities from the `typing` module.
93-
#
94-
# Only the most fundamental meta-utilities (Any, TypeVar, Union, Tuple, Callable)
95-
# are currently supported.
96-
#
97-
# TODO: This is a solved problem, we should use https://github.com/agronholm/typeguard
98-
def match_type(value, spec):
99-
# TODO: Python 3.8 adds `typing.get_origin` and `typing.get_args`, which may be useful
100-
# TODO: once we bump our minimum to that.
101-
# TODO: Right now we're accessing internal fields to get what we need.
102-
# https://docs.python.org/3/library/typing.html#typing.get_origin
103-
if spec is typing.Any:
104-
return True
105-
if repr(spec.__class__) == "TypeVar": # AnyStr gets normalized to TypeVar("AnyStr", str, bytes)
106-
if not spec.__constraints__: # just an abstract type name
107-
return True
108-
return any(match_type(value, typ) for typ in spec.__constraints__)
109-
# TODO: typing.Generic
110-
# if repr(spec).startswith("typing.Generic["):
111-
# pass
112-
# TODO: Protocol, Type, Iterable, Iterator, Reversible, ...
113-
# TODO: List, Set, FrozenSet, Dict, NamedTuple
114-
# TODO: many, many others; see https://docs.python.org/3/library/typing.html
115-
if repr(spec.__class__) == "typing.Union": # Optional gets normalized to Union[argtype, NoneType].
116-
if spec.__args__ is None: # bare `typing.Union`; has no types in it, so no value can match.
117-
return False
118-
if not any(match_type(value, typ) for typ in spec.__args__):
119-
return False
120-
return True
121-
try:
122-
if issubclass(spec, typing.Text):
123-
return isinstance(value, str)
124-
if issubclass(spec, typing.Tuple):
125-
if not isinstance(value, tuple):
126-
return False
127-
if spec.__args__ is None: # bare `typing.Tuple`, any tuple matches.
128-
return True
129-
# homogeneous type, arbitrary length
130-
if len(spec.__args__ == 2 and spec.__args__[1] is Ellipsis):
131-
typ = spec.__args__[0]
132-
return all(match_type(elt, typ) for elt in value)
133-
# heterogeneous types, exact length
134-
if len(value) == len(spec.__args__):
135-
return False
136-
return all(match_type(elt, typ) for typ, elt in zip(spec.__args__, value))
137-
if issubclass(spec, typing.Callable):
138-
if not callable(value):
139-
return False
140-
return True
141-
# # TODO: Callable[[a0, a1, ...], ret], Callable[..., ret].
142-
# if spec.__args__ is None: # bare `typing.Callable`, no restrictions on arg/return types.
143-
# return True
144-
# sig = typing.get_type_hints(value)
145-
# *argtypes, rettype = spec.__args__
146-
# if len(argtypes) == 1 and argtypes[0] is Ellipsis:
147-
# pass # argument types not specified
148-
# else:
149-
# # TODO: we need the names of the positional arguments of the `value` callable here.
150-
# for a in argtypes:
151-
# # TODO: Can't use match_type here; we're comparing two specs against each other,
152-
# # TODO: not a value against a spec. Need to implement a compatible_specs function.
153-
# if not compatible_specs(???, a):
154-
# return False
155-
# if not compatible_specs(sig["return"], rettype):
156-
# return False
157-
# return True
158-
except TypeError: # probably one of those meta-utilities that hates issubclass for no reason
159-
pass
160-
# TODO: typing.Literal (Python 3.8)
161-
# catch any meta-utilities we don't currently support
162-
if hasattr(spec, "__module__") and spec.__module__ == "typing":
163-
fullname = "{}.{}".format(spec.__module__, spec.__qualname__)
164-
raise NotImplementedError("This simple runtime typechecker doesn't support {}".format(fullname))
165-
return isinstance(value, spec) # a concrete type parameter
16684
# signature comes from typing.get_type_hints.
16785
def match_argument_types(signature):
16886
# TODO: handle *args (bindings["vararg"], bindings["vararg_name"])
16987
# TODO: handle **kwargs (bindings["kwarg"], bindings["kwarg_name"])
17088
for parameter, value in bindings["args"].items():
17189
assert parameter in signature # resolve_bindings should already TypeError when not.
17290
expected_type = signature[parameter]
173-
if not match_type(value, expected_type):
91+
if not match_value_to_typespec(value, expected_type):
17492
return False
17593
return True
17694

unpythonic/typecheck.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# -*- coding: utf-8; -*-
2+
"""Simplistic run-time type checker.
3+
4+
This implements just a minimal feature set needed for checking function arguments in
5+
typical uses of multiple dispatch (see `unpythonic.dispatch`).
6+
7+
If you need a run-time type checker for serious general use, consider `typeguard`:
8+
9+
https://github.com/agronholm/typeguard
10+
"""
11+
12+
import typing
13+
14+
__all__ = ["match_value_to_typespec"]
15+
16+
# Many `typing` meta-utilities explicitly reject using isinstance and issubclass on them,
17+
# so we hack those by inspecting the repr.
18+
def match_value_to_typespec(value, T):
19+
"""A simple run-time type check.
20+
21+
Check that `value` matches the type specification `T`.
22+
23+
value: a regular run-time value whose type to check.
24+
25+
T: a type specification to check against.
26+
27+
Either a concrete type (e.g. `int`, `somemodule.MyClass`), or a specification
28+
using one of the meta-utilities defined by the `typing` module.
29+
30+
Only the most fundamental meta-utilities are currently supported:
31+
- Any, TypeVar
32+
- Union, Tuple
33+
- Callable
34+
- Text
35+
36+
Additionally, the `typing` module itself automatically normalizes the
37+
following specifications:
38+
- Optional[T] -> Union[T, NoneType]
39+
- AnyStr -> TypeVar("AnyStr", str, bytes)
40+
"""
41+
# TODO: Python 3.8 adds `typing.get_origin` and `typing.get_args`, which may be useful
42+
# TODO: to analyze generics once we bump our minimum Python to that.
43+
#
44+
# TODO: Right now we're accessing internal fields to get what we need.
45+
# https://docs.python.org/3/library/typing.html#typing.get_origin
46+
47+
if T is typing.Any:
48+
return True
49+
50+
if repr(T.__class__) == "TypeVar": # AnyStr normalizes to TypeVar("AnyStr", str, bytes)
51+
if not T.__constraints__: # just an abstract type name
52+
return True
53+
return any(match_value_to_typespec(value, U) for U in T.__constraints__)
54+
55+
# TODO: List, Set, FrozenSet, Dict, NamedTuple
56+
# TODO: Protocol, Type, Iterable, Iterator, Reversible, ...
57+
# TODO: typing.Generic
58+
# if repr(T).startswith("typing.Generic["):
59+
# pass
60+
# TODO: many, many others; see https://docs.python.org/3/library/typing.html
61+
62+
if repr(T.__class__) == "typing.Union": # Optional normalizes to Union[argtype, NoneType].
63+
if T.__args__ is None: # bare `typing.Union`; empty, has no types in it, so no value can match.
64+
return False
65+
if not any(match_value_to_typespec(value, U) for U in T.__args__):
66+
return False
67+
return True
68+
69+
# many of the meta-utilities hate issubclass with a passion, so we must catch TypeError.
70+
try:
71+
if issubclass(T, typing.Text): # https://docs.python.org/3/library/typing.html#typing.Text
72+
return isinstance(value, str) # alias for str
73+
74+
if issubclass(T, typing.Tuple):
75+
if not isinstance(value, tuple):
76+
return False
77+
# bare `typing.Tuple`, no restrictions on length or element type.
78+
if T.__args__ is None:
79+
return True
80+
# homogeneous type, arbitrary length
81+
if len(T.__args__) == 2 and T.__args__[1] is Ellipsis:
82+
U = T.__args__[0]
83+
return all(match_value_to_typespec(elt, U) for elt in value)
84+
# heterogeneous types, exact length
85+
if len(value) != len(T.__args__):
86+
return False
87+
return all(match_value_to_typespec(elt, U) for elt, U in zip(value, T.__args__))
88+
89+
if issubclass(T, typing.Callable):
90+
if not callable(value):
91+
return False
92+
return True
93+
# # TODO: analyze Callable[[a0, a1, ...], ret], Callable[..., ret].
94+
# if T.__args__ is None: # bare `typing.Callable`, no restrictions on arg/return types.
95+
# return True
96+
# sig = typing.get_type_hints(value)
97+
# *argtypes, rettype = T.__args__
98+
# if len(argtypes) == 1 and argtypes[0] is Ellipsis:
99+
# pass # argument types not specified
100+
# else:
101+
# # TODO: we need the names of the positional arguments of the `value` callable here.
102+
# for a in argtypes:
103+
# # TODO: Can't use match_value_to_typespec here; we're comparing two specs against
104+
# # TODO: each other, not a value against T. Need to implement an `issubtype` function.
105+
# # https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)
106+
# if not issubtype(???, a): # arg types behave contravariantly.
107+
# return False
108+
# if not issubtype(rettype, sig["return"]): # return type behaves covariantly.
109+
# return False
110+
# return True
111+
except TypeError as err: # probably one of those meta-utilities that hates issubclass.
112+
print(err)
113+
pass
114+
115+
# TODO: typing.Literal (Python 3.8)
116+
117+
# catch any meta-utilities we don't currently support
118+
if hasattr(T, "__module__") and T.__module__ == "typing":
119+
fullname = "{}.{}".format(T.__module__, T.__qualname__)
120+
raise NotImplementedError("This simple run-time type checker doesn't support '{}'".format(fullname))
121+
122+
return isinstance(value, T) # T is a concrete class

0 commit comments

Comments
 (0)