File: usage.md

package info (click to toggle)
python-typing-inspection 0.4.1-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 384 kB
  • sloc: python: 1,110; makefile: 33; sh: 6
file content (264 lines) | stat: -rw-r--r-- 10,546 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# Usage

The library is divided into two submodules:

- [`typing_inspection.typing_objects`][]: provides functions to check if a variable is a [`typing`][] object:
  ```python
  from typing_extensions import Literal, get_origin

  from typing_inspection.typing_objects import is_literal

  is_literal(get_origin(Literal[1, 2]))  # True
  ```

    !!! note
        You might be tempted to use a simple identity check:

        ```pycon
        >>> get_origin(Literal[1, 2]) is typing.Literal
        ```

        However, [`typing_extensions`][] might provide a different version of the [`typing`][] objects. Instead,
        the [`typing_objects`][typing_inspection.typing_objects] functions make sure to check against both variants,
        if they are different.

- [`typing_inspection.introspection`][]: provides high-level introspection functions, taking runtime edge cases
  into account.

## Inspecting annotations

If, as a library, you rely heavily on type hints, you may encounter subtle unexpected behaviors and performance
issues when inspecting annotations. As such, this section provides a recommended workflow to do so.

### Fetching type hints

The first step is to gather the type annotations from the object you want to inspect. The
[`typing.get_type_hints()`][typing.get_type_hints] function can be used to do so. If you want to make use of annotated
metadata, make sure to set the `include_extras` argument to `True`.

```pycon
>>> class A:
...    x: int
...    y: Annotated[int, ...]
...
>>> get_type_hints(A, include_extras=True)
{'x': int, 'y': Annotated[int, ...]}
```

!!! note
    Currently, `typing-inspection` does not provide any utility to fetch (and evaluate) type annotations. The current
    [`typing`][] utilities might contain subtle bugs across the different Python versions, so there is value in
    having similar functionality. It might be best to wait for [PEP 649](https://peps.python.org/pep-0649/) to be fully
    implemented first. In the meanwhile, the [`typing_extensions.get_type_hints()`][typing_extensions.get_type_hints]
    backport can be used.

### Unpacking metadata and qualifiers

The annotations fetched in the previous step are called [annotation expressions][annotation expression].
An annotation expression is a [type expression][], optionally surrounded by one or more [type qualifiers][type qualifier]
or by the [`Annotated`][typing.Annotated] form.

For instance, in the following example:

```python
from typing import Annotated, ClassVar

class A:
    x: ClassVar[Annotated[int, "meta"]]
```


The type hint of `x` is an annotation expression. The underlying type expression is `int`. It is wrapped
by the [`ClassVar`][typing.ClassVar] type qualifier, and the [`Annotated`][typing.Annotated] [special form][].

The goal of this step is to:

- Unwrap the underlying [type expression][].
- Keep track of the type qualifiers and annotated metadata.

To unwrap the type hint, use the [`inspect_annotation()`][typing_inspection.introspection.inspect_annotation] function:

```pycon
>>> from typing_inspection.introspection import AnnotationSource, inspect_annotation
>>> inspect_annotation(
...    ClassVar[Annotated[int, "meta"]],
...    annotation_source=AnnotationSource.CLASS,
... )
...
InspectedAnnotation(type=int, qualifiers={"class_var"}, metadata=["meta"])
```

Note that depending on the annotation source, different type qualifiers can be (dis)allowed.
For instance, [`TypedDict`][typing.TypedDict] classes allow [`Required`][typing.Required] and [`NotRequired`][typing.NotRequired],
which are not allowed elsewhere (the allowed typed qualifiers are documented in the
[`AnnotationSource`][typing_inspection.introspection.AnnotationSource] enum class).

A [ForbiddenQualifier][typing_inspection.introspection.ForbiddenQualifier] exception is raised if an invalid qualifier is used.
If you want to allow all of them, use the [`AnnotationSource.ANY`][typing_inspection.introspection.AnnotationSource.ANY] annotation
source.

The result of the [`inspect_annotation()`][typing_inspection.introspection.inspect_annotation] function contains the underlying
[type expression][], the qualifiers and the annotated metadata.

#### Handling bare type qualifiers

Note that some qualifiers are allowed to be used without any
type expression. In this case, the [`InspectedAnnotation.type`][typing_inspection.introspection.InspectedAnnotation.type] attribute
will take the value of the [`UNKNOWN`][typing_inspection.introspection.UNKNOWN] sentinel.

Depending on the type qualifier that was used, you can infer the actual type in different ways:

```python
from typing import get_type_hints

from typing_inspection.introspection import UNKNOWN, AnnotationSource, inspect_annotation


class A:
    # For `Final` annotations, the type should be inferred from the assignment
    # (and you may error if no assignment is available).
    # In this case, you can infer to either `int` or `Literal[1]`:
    x: Annotated[Final, 'meta'] = 1

    # For `ClassVar` annotations, the type can be inferred as `Any`,
    # or from the assignment if available (both options are valid in all cases):
    y: ClassVar


inspected_annotation = inspect_annotation(
    get_type_hints(A)['x'],
    annotation_source=AnnotationSource.CLASS,
)

if inspected_annotation.type is UNKNOWN:
    ann_type = type(A.x)
else:
    ann_type = inspected_annotation.type
```

!!! note "Parsing [PEP 695](https://peps.python.org/pep-0695/) type aliases"
    In Python 3.12, the new [type][] statement can be used to define [type aliases][type-aliases].
    When a type alias is wrapped by the [`Annotated`][typing.Annotated] form, the type alias' value will *not* be unpacked by Python
    at runtime. This means that while the following is technically valid:

    ```python
    type MyInt = Annotated[int, "int_meta"]

    class A:
        x: Annotated[MyInt, "other_meta"]
    ```

    it might be necessary to parse the type alias during annotation inspection. This behavior can be controlled using the
    `unpack_type_aliases` parameter:

    ```pycon
    >>> inspect_annotation(
    ...     Annotated[MyInt, "other_meta"],
    ...     annotation_source=AnnotationSource.CLASS,
    ...     unpack_type_aliases="eager",
    ... )
    ...
    InspectedAnnotation(type=int, qualifiers={}, metadata=["int_meta", "other_meta"])
    ```

    Whether you should unpack type aliases depends on your use case. If the annotated metadata present in the type alias
    is *only* meant to be applied on the annotated type (and not the attribute that will be type hinted), you probably
    need to keep type aliases as is, and possibly error later if invalid metadata is found when inspecting the type alias.

    Note that type aliases are lazily evaluated. During type alias inspection, any undefined symbol
    will raise a [`NameError`][]. To prevent this from happening, you can use `'skip'` to avoid expanding
    type aliases (the default), or `'lenient'` to fallback to `'skip'` if the type alias contains an undefined
    symbol:

    ```pycon
    >>> type BrokenType = Annotated[Undefined, ...]
    >>> type MyAlias = Annotated[BrokenType, "meta"]
    >>> inspect_annotation(
    ...     MyAlias,
    ...     annotation_source=AnnotationSource.CLASS,
    ...     unpack_type_aliases="lenient",
    ... )
    ...
    InspectedAnnotation(type=BrokenType, qualifiers={}, metadata=["meta"])
    ```

### Inspecting the type expression

With the qualifiers and [`Annotated`][typing.Annotated] forms removed, we can now proceed to inspect
the type expression.

First of all, some simple typing [special forms][special form] can be checked:

```python
from typing_inspection.typing_objects import is_any, is_self

# This would come from `InspectedAnnotation.type`, after checking for `INFERRED`:
type_expr = ...

if is_any(type_expr):
    ...  # Handle `typing.Any`

if is_self(type_expr):
    ...  # Handle `typing.Self`
```

We will then use the [`typing.get_origin()`][typing.get_origin] function to fetch the origin of the type. Depending
on the type, the origin has different meanings:

```python
from typing_inspection.introspection import get_literal_values, is_union_origin
from typing_inspection.typing_objects import is_annotated, is_literal

origin = get_origin(type_expr)

if is_union_origin(origin):
    # Handle `typing.Union` (or the new `|` syntax)
    union_args = type_expr.__args__
    ...

# You may also want to check for Annotated forms. While we unwrapped them
# in step 2, `Annotated` can be used in parts of the annotation, e.g.
# `list[Annotated[int, ...]]`:
if is_annotated(origin):
    annotated_type = type_expr.__origin__  # not to be confused with the origin above
    metadata = type_expr.__metadata__

if is_literal(origin):
    # Handle `typing.Literal`
    literal_values = get_literal_values(type_expr)
```

While [`Literal`][typing.Literal] values can be retrieved using `type_expr.__args__`, the
[`get_literal_values()`][typing_inspection.introspection.get_literal_values] function ensures
[PEP 695](https://peps.python.org/pep-0695/) type aliases are properly expanded.

Next, we will take care of the typing aliases deprecated by [PEP 585](https://peps.python.org/pep-0585/).
For instance, [`typing.List`][] is deprecated and replaced by the built-in [`list`][] type. In this case,
the origin of an *unparameterized* deprecated type alias is the replacement type, so we will use this one:

```python
from typing_inspection.typing_objects import DEPRECATED_ALIASES

# If `type_expr` is `typing.List`, `origin` is the built-in `list`.
# We thus replace `type_expr` with `list`, and set `origin` to `None`
# to emulate the same behavior if `type_expr` was `list` in the beginning:
if origin is not None and type_expr in DEPRECATED_ALIASES:
    type_expr = origin
    origin = None
```

At this point, if `origin` is not `None`, you can safely assume that `type_expr` is a parameterized generic type.
You can then define your own logic to handle the type expression, and have different code paths if you are
dealing with a parameterized type (e.g. `list[int]`) or a "bare" type:

```python
if origin is not None:
    handle_generic_type(type=origin, arguments=type_expr.__args__)
else:
    handle_type(type=type_expr)
```

!!! note
    If a deprecated type alias is *parameterized* (e.g. `typing.List[int]`), the origin will be the
    replacement type (e.g. `list`), and not the deprecated alias (e.g. `typing.List`). This means
    that handling `typing.List[int]` or `list` should be equivalent.