Skip to content

Commit 665cc4b

Browse files
Technologicatclaude
andcommitted
typecheck: support NoReturn, Never, Literal, Type, ClassVar, Final, DefaultDict, OrderedDict, Counter, ChainMap (D4 set 1)
Add support for the easy-win typing features identified in D4. Also mark typing.Text and typing.ByteString as deprecated (remove at floor Python 3.12), remove stale Python 3.6 guard in tests, and add explanatory comment about why empty collections reject parametric type specs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0edba2d commit 665cc4b

File tree

3 files changed

+177
-18
lines changed

3 files changed

+177
-18
lines changed

TODO_DEFERRED.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# Deferred Issues
22

3-
- **D4**: `typecheck.py` — expand runtime type checker to support more `typing` features: NamedTuple, DefaultDict, Counter, ChainMap, OrderedDict, IO/TextIO/BinaryIO, Pattern/Match, Generic, Type, Awaitable, Coroutine, AsyncIterable, AsyncIterator, ContextManager, AsyncContextManager, Generator, AsyncGenerator, NoReturn, ClassVar, Final, Protocol, TypedDict, Literal, ForwardRef. Would improve `unpythonic.dispatch` (multiple dispatch). (Discovered during Phase 4 cleanup.)
3+
- **D4**: `typecheck.py` — expand runtime type checker to support more `typing` features. Split into three sets:
44

5+
**Set 1 — Easy wins** (do first):
6+
- `NoReturn` — always `False`
7+
- `Type[X]` — check `isinstance(value, type) and issubclass(value, X)`
8+
- `Literal[v1, v2, ...]` — check `value in args`
9+
- `ClassVar[T]`, `Final[T]` — strip wrapper, check inner type
10+
- `DefaultDict[K, V]`, `Counter[T]`, `OrderedDict[K, V]`, `ChainMap[K, V]` — slot into existing mapping/collection patterns
11+
- Also: deprecation markers on `typing.Text` and `typing.ByteString` (remove when floor bumps to Python 3.12); clean up stale `Python 3.6+` guard in `test_typecheck.py:182`
12+
13+
**Set 2 — Useful for dispatch** (follow-up):
14+
- `IO`, `TextIO`, `BinaryIO` — simple `isinstance` checks
15+
- `Pattern[T]`, `Match[T]``isinstance` against `re.Pattern`/`re.Match`
16+
- `ContextManager`, `AsyncContextManager``isinstance` checks
17+
- `Awaitable`, `Coroutine`, `AsyncIterable`, `AsyncIterator``isinstance` checks
18+
- `Generator`, `AsyncGenerator``isinstance` checks (no yield/send/return type checking)
19+
- `NamedTuple` — tricky but doable
20+
21+
**Set 3 — Hard / questionable value** (defer or discuss):
22+
- `Protocol` — full structural subtyping, heavy
23+
- `TypedDict` — required vs. optional keys, medium-hard
24+
- `Generic` — abstract, unclear semantics for value checking
25+
- `ForwardRef` — needs a namespace to resolve the string
526

unpythonic/tests/test_typecheck.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from ..test.fixtures import session, testset
55

66
import collections
7+
import sys
78
import typing
89

910
from ..collections import frozendict
@@ -32,6 +33,17 @@ def runtests():
3233
test[isoftype("something", typing.Any)]
3334
test[isoftype(lambda: ..., typing.Any)]
3435

36+
# NoReturn / Never — the bottom type; no value can match.
37+
with testset("typing.NoReturn"):
38+
test[not isoftype(None, typing.NoReturn)]
39+
test[not isoftype(42, typing.NoReturn)]
40+
test[not isoftype("anything", typing.NoReturn)]
41+
42+
if sys.version_info >= (3, 11):
43+
with testset("typing.Never"):
44+
test[not isoftype(None, typing.Never)]
45+
test[not isoftype(42, typing.Never)]
46+
3547
# TypeVar, bare; a named type, but behaves like Any.
3648
with testset("typing.TypeVar (bare; like a named Any)"):
3749
X = typing.TypeVar("X")
@@ -67,6 +79,45 @@ def runtests():
6779
test[isoftype(1337, typing.Optional[int])]
6880
test[not isoftype(3.14, typing.Optional[int])]
6981

82+
with testset("typing.Literal"):
83+
test[isoftype(1, typing.Literal[1, 2, 3])]
84+
test[isoftype(3, typing.Literal[1, 2, 3])]
85+
test[not isoftype(4, typing.Literal[1, 2, 3])]
86+
test[isoftype("red", typing.Literal["red", "green", "blue"])]
87+
test[not isoftype("yellow", typing.Literal["red", "green", "blue"])]
88+
# Literal values are compared by equality, not identity
89+
test[isoftype(True, typing.Literal[True, False])]
90+
test[not isoftype(None, typing.Literal[True, False])]
91+
92+
with testset("typing.Type"):
93+
test[isoftype(int, typing.Type[int])]
94+
test[isoftype(bool, typing.Type[int])] # bool is a subclass of int
95+
test[not isoftype(str, typing.Type[int])]
96+
test[not isoftype(42, typing.Type[int])] # an instance, not a class
97+
# bare Type: any class matches
98+
test[isoftype(int, typing.Type)]
99+
test[isoftype(str, typing.Type)]
100+
test[not isoftype(42, typing.Type)]
101+
102+
with testset("typing.ClassVar"):
103+
test[isoftype(42, typing.ClassVar[int])]
104+
test[not isoftype("hello", typing.ClassVar[int])]
105+
# Compound: ClassVar wrapping a Union
106+
test[isoftype(42, typing.ClassVar[typing.Union[int, str]])]
107+
test[isoftype("hello", typing.ClassVar[typing.Union[int, str]])]
108+
test[not isoftype(3.14, typing.ClassVar[typing.Union[int, str]])]
109+
110+
with testset("typing.Final"):
111+
test[isoftype(42, typing.Final[int])]
112+
test[not isoftype("hello", typing.Final[int])]
113+
test[isoftype("hello", typing.Final[str])]
114+
115+
# Empty collections reject parametric type specs (e.g. `Tuple[int, ...]`,
116+
# `List[int]`, `Dict[str, int]`). An empty collection has no elements to
117+
# infer the type from, so matching it against a specific element type would
118+
# be guesswork — which would make multiple dispatch unpredictable.
119+
# Bare (unparametrized) specs like `Tuple` or `Dict` still accept empties.
120+
70121
with testset("typing.Tuple"):
71122
test[isoftype((1, 2, 3), typing.Tuple)]
72123
test[isoftype((1, 2, 3), typing.Tuple[int, ...])]
@@ -101,6 +152,34 @@ def runtests():
101152
# no type arguments: any key/value types ok (consistent with Python 3.7+)
102153
test[isoftype({"cat": "animal", "pi": 3.14159, 2.71828: "e"}, typing.Dict)]
103154

155+
with testset("typing.DefaultDict"):
156+
dd = collections.defaultdict(int, {"a": 1, "b": 2})
157+
test[isoftype(dd, typing.DefaultDict[str, int])]
158+
test[not isoftype(dd, typing.DefaultDict[int, int])]
159+
test[not isoftype({}, typing.DefaultDict[str, int])] # regular dict is not defaultdict
160+
test[not isoftype(collections.defaultdict(int), typing.DefaultDict[str, int])] # empty
161+
162+
with testset("typing.OrderedDict"):
163+
od = collections.OrderedDict({"x": 1, "y": 2})
164+
test[isoftype(od, typing.OrderedDict[str, int])]
165+
test[not isoftype(od, typing.OrderedDict[int, int])]
166+
test[not isoftype({}, typing.OrderedDict[str, int])] # regular dict is not OrderedDict
167+
test[not isoftype(collections.OrderedDict(), typing.OrderedDict[str, int])] # empty
168+
169+
with testset("typing.Counter"):
170+
c = collections.Counter("abracadabra")
171+
test[isoftype(c, typing.Counter[str])]
172+
test[not isoftype(c, typing.Counter[int])]
173+
test[not isoftype({}, typing.Counter[str])] # regular dict is not Counter
174+
test[not isoftype(collections.Counter(), typing.Counter[str])] # empty
175+
176+
with testset("typing.ChainMap"):
177+
cm = collections.ChainMap({"a": 1}, {"b": 2})
178+
test[isoftype(cm, typing.ChainMap[str, int])]
179+
test[not isoftype(cm, typing.ChainMap[int, int])]
180+
test[not isoftype({}, typing.ChainMap[str, int])] # regular dict is not ChainMap
181+
test[not isoftype(collections.ChainMap(), typing.ChainMap[str, int])] # empty
182+
104183
# type alias (at run time, this is just an assignment)
105184
with testset("type alias"):
106185
U = typing.Union[int, str]
@@ -179,8 +258,7 @@ def runtests():
179258
test[isoftype([1, 2, 3], typing.Iterable)]
180259
test[isoftype([1, 2, 3], typing.Reversible)]
181260
test[isoftype([1, 2, 3], typing.Container)]
182-
if hasattr(typing, "Collection"): # Python 3.6+
183-
test[isoftype([1, 2, 3], typing.Collection)] # Sized Iterable Container
261+
test[isoftype([1, 2, 3], typing.Collection)] # Sized Iterable Container
184262

185263
with testset("typing.KeysView, typing.ValuesView, typing.ItemsView"):
186264
d = {17: "cat", 23: "fox", 42: "python"}

unpythonic/typecheck.py

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
# -*- coding: utf-8; -*-
2-
"""Simplistic run-time type checker.
2+
"""Lightweight run-time type checker.
33
4-
This implements just a minimal feature set needed for checking function
5-
arguments in typical uses of multiple dispatch (see `unpythonic.dispatch`).
6-
That said, this DOES support many (but not all) features of the `typing` stdlib
7-
module.
4+
Originally built for the minimal feature set needed by multiple dispatch
5+
(see `unpythonic.dispatch`), but designed as a general-purpose utility.
6+
Supports many (but not all) features of the `typing` stdlib module.
87
98
We currently provide `isoftype` (cf. `isinstance`), but no `issubtype` (cf. `issubclass`).
109
@@ -15,6 +14,7 @@
1514
"""
1615

1716
import collections
17+
import sys
1818
import types
1919
import typing
2020

@@ -40,14 +40,21 @@ def isoftype(value, T):
4040
- `TypeVar`
4141
- `NewType` (any instance of the underlying actual type will match)
4242
- `Union[T1, T2, ..., TN]`
43+
- `NoReturn`, `Never` (no value matches; `Never` requires Python 3.11+)
44+
- `Literal[v1, v2, ...]`
45+
- `Type[X]` (value must be a class that is `X` or a subclass of `X`)
46+
- `ClassVar[T]`, `Final[T]` (wrapper stripped, inner type checked)
4347
- `Tuple`, `Tuple[T, ...]`, `Tuple[T1, T2, ..., TN]`, `Sequence[T]`
4448
- `List[T]`, `MutableSequence[T]`
4549
- `FrozenSet[T]`, `AbstractSet[T]`
4650
- `Set[T]`, `MutableSet[T]`
47-
- `Dict[K, V]`, `MutableMapping[K, V]`, `Mapping[K, V]`
51+
- `Dict[K, V]`, `DefaultDict[K, V]`, `OrderedDict[K, V]`
52+
- `Counter[T]` (element type checked; value type is always `int`)
53+
- `ChainMap[K, V]`
54+
- `MutableMapping[K, V]`, `Mapping[K, V]`
4855
- `ItemsView[K, V]`, `KeysView[K]`, `ValuesView[V]`
4956
- `Callable` (argument and return value types currently NOT checked)
50-
- `Text`
57+
- `Text` (deprecated since Python 3.11; will be removed at floor Python 3.12)
5158
5259
Any checks on the type arguments of the meta-utilities are performed
5360
recursively using `isoftype`, in order to allow compound specifications.
@@ -66,15 +73,22 @@ def isoftype(value, T):
6673
# Python provides no official public API for run-time type introspection.
6774
#
6875
# Unsupported typing features:
69-
# NamedTuple, DefaultDict, Counter, ChainMap, OrderedDict,
70-
# IO, TextIO, BinaryIO, Pattern, Match, Generic, Type,
76+
# NamedTuple,
77+
# IO, TextIO, BinaryIO, Pattern, Match,
7178
# Awaitable, Coroutine, AsyncIterable, AsyncIterator,
7279
# ContextManager, AsyncContextManager, Generator, AsyncGenerator,
73-
# NoReturn, ClassVar, Final, Protocol, TypedDict, Literal, ForwardRef
80+
# Generic, Protocol, TypedDict, ForwardRef
7481

7582
if T is typing.Any:
7683
return True
7784

85+
# NoReturn means a function never returns — no value has this type.
86+
# Never (3.11+) is the bottom type; semantically the same for our purposes.
87+
if T is typing.NoReturn:
88+
return False
89+
if sys.version_info >= (3, 11) and T is typing.Never:
90+
return False
91+
7892
# AnyStr normalizes to TypeVar("AnyStr", str, bytes)
7993
if isinstance(T, typing.TypeVar):
8094
if not T.__constraints__: # just an abstract type name
@@ -102,6 +116,28 @@ def isNewType(T):
102116
# print(type(i)) # int
103117
return isinstance(value, T.__supertype__)
104118

119+
# Literal[v1, v2, ...] — value must be one of the listed constants.
120+
if typing.get_origin(T) is typing.Literal:
121+
return value in T.__args__
122+
123+
# Type[X] — value must be a class that is X or a subclass of X.
124+
if typing.get_origin(T) is type:
125+
if not isinstance(value, type):
126+
return False
127+
args = getattr(T, "__args__", None)
128+
if args is None:
129+
return True # bare Type, any class matches
130+
return issubclass(value, args[0])
131+
132+
# ClassVar[T] and Final[T] — these are declaration wrappers. At runtime,
133+
# we just strip the wrapper and check the inner type.
134+
for wrapper_origin in (typing.ClassVar, typing.Final):
135+
if typing.get_origin(T) is wrapper_origin:
136+
args = getattr(T, "__args__", None)
137+
if args is None:
138+
return True # bare ClassVar or Final, no inner type constraint
139+
return isoftype(value, args[0])
140+
105141
# Some one-trick ponies.
106142
for U in (typing.Iterator, # can't non-destructively check element type
107143
typing.Iterable, # can't non-destructively check element type
@@ -128,8 +164,10 @@ def isNewType(T):
128164

129165
# We don't have a match yet, so T might still be one of those meta-utilities
130166
# that hate `issubclass` with a passion.
131-
if safeissubclass(T, typing.Text): # https://docs.python.org/3/library/typing.html#typing.Text
132-
return isinstance(value, str) # alias for str
167+
# DEPRECATED: typing.Text is deprecated since Python 3.11 (it's just an alias for str).
168+
# TODO: Remove this branch when the floor bumps to Python 3.12.
169+
if safeissubclass(T, typing.Text):
170+
return isinstance(value, str)
133171

134172
if typing.get_origin(T) is tuple:
135173
if not isinstance(value, tuple):
@@ -162,7 +200,25 @@ def ismapping(runtimetype):
162200
return False
163201
K, V = args
164202
return all(isoftype(k, K) and isoftype(v, V) for k, v in value.items())
165-
for runtimetype in (dict, collections.abc.MutableMapping, collections.abc.Mapping):
203+
# Counter[T] is a mapping (keys: T, values: int), but has only one type arg.
204+
if typing.get_origin(T) is collections.Counter:
205+
if not isinstance(value, collections.Counter):
206+
return False
207+
args = getattr(T, "__args__", None)
208+
if args is None:
209+
args = (typing.TypeVar("T"),)
210+
assert len(args) == 1
211+
if not value:
212+
return False
213+
U = args[0]
214+
return all(isoftype(k, U) and isinstance(v, int) for k, v in value.items())
215+
216+
for runtimetype in (dict,
217+
collections.defaultdict,
218+
collections.OrderedDict,
219+
collections.ChainMap,
220+
collections.abc.MutableMapping,
221+
collections.abc.Mapping):
166222
if typing.get_origin(T) is runtimetype:
167223
return ismapping(runtimetype)
168224

@@ -190,8 +246,12 @@ def iscollection(statictype, runtimetype):
190246
if not isinstance(value, runtimetype):
191247
return False
192248
if typing.get_origin(statictype) is collections.abc.ByteString:
249+
# DEPRECATED: typing.ByteString is deprecated since Python 3.12.
250+
# TODO: Remove this branch and the ByteString entry in the loop below
251+
# when the floor bumps to Python 3.12.
252+
#
193253
# WTF? A ByteString is a Sequence[int], but only statically.
194-
# At run time, the `__args__` are actually empty - it looks
254+
# At run time, the `__args__` are actually empty it looks
195255
# like a bare Sequence, which is invalid. HACK the special case.
196256
typeargs = (int,)
197257
else:
@@ -209,7 +269,7 @@ def iscollection(statictype, runtimetype):
209269
(typing.FrozenSet, frozenset),
210270
(typing.Set, set),
211271
(typing.Deque, collections.deque),
212-
(typing.ByteString, collections.abc.ByteString), # must check before Sequence
272+
(typing.ByteString, collections.abc.ByteString), # DEPRECATED; must check before Sequence
213273
(typing.MutableSet, collections.abc.MutableSet), # must check mutable first
214274
# because a mutable value has *also* the interface of the immutable variant
215275
# (e.g. MutableSet is a subtype of AbstractSet)

0 commit comments

Comments
 (0)