Skip to content

Commit 5cef7e8

Browse files
committed
typing.ParamSpec
1 parent cf5806b commit 5cef7e8

File tree

3 files changed

+103
-9
lines changed

3 files changed

+103
-9
lines changed

netlify.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/bin/bash
2-
# This script is used by netlify
2+
# This script is used by cloud flare.
3+
# The name is a leftover from when we used Netlify to build the website.
34
python3 -m pip install .
45
python3 -m sdk html

posts/param-spec.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
author: orsinium
3+
topics:
4+
- typing
5+
traces:
6+
- [module: typing, type: ParamSpec]
7+
pep: 612
8+
python: "3.10"
9+
---
10+
11+
# ParamSpec
12+
13+
Let's say, you have a typical decorator that returns a new function. Something like this:
14+
15+
```python {no-print}
16+
def debug(f):
17+
name = f.__name__
18+
def inner(*args, **kwargs):
19+
print(f'called {name} with {args=} and {kwargs=}')
20+
return f(*args, **kwargs)
21+
return inner
22+
23+
@debug
24+
def concat(a: str, b: str) -> str:
25+
return a + b
26+
27+
concat('hello ', 'world')
28+
# called concat with args=('hello ', 'world') and kwargs={}
29+
```
30+
31+
If you check the type of `concat` using [reveal_type](https://t.me/pythonetc/712), you'll see that its type is unknown because of the decorator:
32+
33+
```python {continue}
34+
reveal_type(concat)
35+
# Revealed type is "Any"
36+
```
37+
38+
So, we need to properly annotate the decorator. But how?
39+
40+
This is not precise enough (type errors like `x: int = concat(1, 2)` won't be detected):
41+
42+
```python
43+
from typing import Callable
44+
def debug(f: Callable) -> Callable: ...
45+
```
46+
47+
This is slightly better but function arguments are still untyped:
48+
49+
```python {continue}
50+
from typing import TypeVar
51+
52+
T = TypeVar('T')
53+
def debug(f: Callable[..., T]) -> Callable[..., T]: ...
54+
```
55+
56+
This is type safe but it requres the decorated function to accept exactly 2 arguments:
57+
58+
```python {continue}
59+
A = TypeVar('A')
60+
B = TypeVar('B')
61+
R = TypeVar('R')
62+
def debug(f: Callable[[A, B], R]) -> Callable[[A, B], R]: ...
63+
```
64+
65+
This is type safe and works on any function but it will report type error because `inner` is not guaranteed to have the same type as the passed callable (for example, someone might pass a class that is callable but we return a function):
66+
67+
```python {continue}
68+
F = TypeVar('F', bound=Callable)
69+
def debug(f: F) -> F: ...
70+
```
71+
72+
[PEP 612](https://peps.python.org/pep-0612/) (landed in Python 3.10) introduced [typing.ParamSpec](https://docs.python.org/3/library/typing.html#typing.ParamSpec) which solves exactly this problem. You can use it to tell type checkers that the decorator returns a new function that accepts exactly the same arguments as the wrapped one:
73+
74+
```python
75+
from typing import Callable, TypeVar, ParamSpec
76+
77+
P = ParamSpec('P')
78+
R = TypeVar('R')
79+
80+
def debug(f: Callable[P, R]) -> Callable[P, R]:
81+
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
82+
...
83+
return f(*args, **kwargs)
84+
return inner
85+
86+
@debug
87+
def concat(a: str, b: str) -> str:
88+
...
89+
90+
reveal_type(concat)
91+
# Revealed type is "def (a: str, b: str) -> str"
92+
```

sdk/post_markdown.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import dataclasses
44
import enum
55
from functools import cached_property
6-
from typing import Any, Iterator
6+
from types import MappingProxyType
7+
from typing import Any, Iterator, Mapping
78

89
import markdown_it.token
910
from markdown_it import MarkdownIt
@@ -22,6 +23,9 @@
2223
'ipython-native': 'ipython_native',
2324
'shield': 'shield',
2425
}
26+
_DEFAULT_GLOBALS: Mapping[str, object] = MappingProxyType(dict(
27+
reveal_type=lambda x: x,
28+
))
2529

2630

2731
class Language(str, enum.Enum):
@@ -220,7 +224,7 @@ def to_telegram(self) -> None:
220224
self._remove_code_info()
221225

222226
def run_code(self) -> None:
223-
shared_globals: dict = {}
227+
shared_globals: dict[str, object] = dict(_DEFAULT_GLOBALS)
224228
for paragraph in self._paragraphs():
225229

226230
if (
@@ -235,12 +239,9 @@ def run_code(self) -> None:
235239
continue
236240

237241
if not paragraph.code.continue_code:
238-
shared_globals = {}
239-
shared_globals['print'] = (
240-
(lambda *args, **kwargs: None)
241-
if paragraph.code.no_print
242-
else print
243-
)
242+
shared_globals = dict(_DEFAULT_GLOBALS)
243+
if paragraph.code.no_print:
244+
shared_globals['print'] = lambda *args, **kwargs: None
244245

245246
code = paragraph.tokens[-1].content
246247
if paragraph.code.is_python:

0 commit comments

Comments
 (0)