typewire 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typewire/__init__.py +3 -0
- typewire/caster.py +181 -0
- typewire/py.typed +0 -0
- typewire-0.1.0.dist-info/METADATA +186 -0
- typewire-0.1.0.dist-info/RECORD +7 -0
- typewire-0.1.0.dist-info/WHEEL +4 -0
- typewire-0.1.0.dist-info/licenses/LICENSE +21 -0
typewire/__init__.py
ADDED
typewire/caster.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from collections.abc import Iterable, Mapping
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
import inspect
|
|
4
|
+
import types
|
|
5
|
+
from typing import Annotated, Any, get_args, get_origin, Literal, TypeAlias, TypeVar, Union
|
|
6
|
+
|
|
7
|
+
TypeHint: TypeAlias = Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_union(type_hint: TypeHint) -> bool:
|
|
11
|
+
"""Determine whether the given type represents a union type."""
|
|
12
|
+
return type_hint is Union or (hasattr(types, "UnionType") and isinstance(type_hint, types.UnionType))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_mapping(type_hint: TypeHint) -> bool:
|
|
16
|
+
"""Determine whether the given type represents a mapping type."""
|
|
17
|
+
origin = get_origin(type_hint)
|
|
18
|
+
real_type = origin if origin is not None else type_hint
|
|
19
|
+
return isinstance(real_type, type) and issubclass(real_type, Mapping)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_iterable(type_hint: TypeHint) -> bool:
|
|
23
|
+
"""Determine whether the given type represents an iterable type."""
|
|
24
|
+
origin = get_origin(type_hint)
|
|
25
|
+
real_type = origin if origin is not None else type_hint
|
|
26
|
+
return isinstance(real_type, type) and issubclass(real_type, Iterable) and real_type not in (str, bytes)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def as_type(value: Any, to: TypeHint, *, transparent_int: bool = False, semantic_bool: bool = False) -> Any:
|
|
30
|
+
"""Cast a value to the given type hint.
|
|
31
|
+
|
|
32
|
+
:param value:
|
|
33
|
+
The raw input value to cast.
|
|
34
|
+
:param to:
|
|
35
|
+
The type hint to cast to.
|
|
36
|
+
:param transparent_int:
|
|
37
|
+
Whether to allow more transparent casting to int.
|
|
38
|
+
For example, int("1.0") raises a ValueError, so as_type("1.0", int) raises a ValueError as well.
|
|
39
|
+
However, as_type("1.0", int, transparent_int=True) will return 1.
|
|
40
|
+
This passes the conversion to float, then int, so as_type("1.3", int, transparent_int=True) returns 1.
|
|
41
|
+
:param semantic_bool:
|
|
42
|
+
Whether to allow for more semantic casting to bool.
|
|
43
|
+
For example, bool("false") returns True, so as_type("false", bool) returns True.
|
|
44
|
+
However, as_type("false", bool, semantic_bool=True) returns False.
|
|
45
|
+
|
|
46
|
+
:return: The casted value.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# We can't cast to Any or an unbound TypeVar, so just return the value as-is
|
|
50
|
+
if to is Any or isinstance(to, TypeVar):
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
origin: Any = get_origin(to)
|
|
54
|
+
args: Any = get_args(to)
|
|
55
|
+
|
|
56
|
+
# reach into Annotated
|
|
57
|
+
if origin is Annotated:
|
|
58
|
+
to = get_args(to)[0]
|
|
59
|
+
origin = get_origin(to)
|
|
60
|
+
args = get_args(to)
|
|
61
|
+
|
|
62
|
+
# handle unions
|
|
63
|
+
if is_union(to):
|
|
64
|
+
for type_hint in get_args(to):
|
|
65
|
+
if type_hint is type(None) and value is None:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
with suppress(ValueError, TypeError):
|
|
69
|
+
return as_type(value, type_hint, transparent_int=transparent_int, semantic_bool=semantic_bool)
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"Value {value!r} does not match any type in {to}")
|
|
72
|
+
|
|
73
|
+
# handle literals
|
|
74
|
+
if origin is Literal:
|
|
75
|
+
if value in args:
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
raise ValueError(f"Value {value!r} does not match any literal in {to}")
|
|
79
|
+
|
|
80
|
+
# If `to` is a plain type (e.g., int), then origin is None. But we want something we can actually call.
|
|
81
|
+
real_type = origin if origin is not None else to
|
|
82
|
+
|
|
83
|
+
# handle unions (e.g., int | float | None)
|
|
84
|
+
if is_union(real_type):
|
|
85
|
+
for type_hint in args:
|
|
86
|
+
# if value is None, then we can allow None in the union
|
|
87
|
+
if type_hint is type(None):
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
# otherwise, try to find the first matching type
|
|
93
|
+
with suppress(ValueError, TypeError):
|
|
94
|
+
return as_type(value, type_hint, transparent_int=transparent_int, semantic_bool=semantic_bool)
|
|
95
|
+
else:
|
|
96
|
+
# none of the types match
|
|
97
|
+
raise ValueError(f"Value {value!r} does not match any type in {to}")
|
|
98
|
+
|
|
99
|
+
# handle mappings
|
|
100
|
+
if is_mapping(real_type):
|
|
101
|
+
if not isinstance(value, Mapping):
|
|
102
|
+
# input is a list of pairs like [("a", 1), ("b", 2)]
|
|
103
|
+
try:
|
|
104
|
+
value = dict(value)
|
|
105
|
+
except ValueError:
|
|
106
|
+
raise ValueError(f"Value {value!r} is not a mapping")
|
|
107
|
+
|
|
108
|
+
key_type = args[0] if args else Any
|
|
109
|
+
val_type = args[1] if len(args) > 1 else Any
|
|
110
|
+
|
|
111
|
+
dct = {
|
|
112
|
+
as_type(key, key_type, transparent_int=transparent_int, semantic_bool=semantic_bool): as_type(
|
|
113
|
+
val, val_type, transparent_int=transparent_int, semantic_bool=semantic_bool
|
|
114
|
+
)
|
|
115
|
+
for key, val in value.items()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if inspect.isabstract(real_type) and isinstance(value, real_type):
|
|
119
|
+
# We can't cast to an abstract container, so just return the dict that we have
|
|
120
|
+
return dct
|
|
121
|
+
|
|
122
|
+
return real_type(dct)
|
|
123
|
+
|
|
124
|
+
# handle containers
|
|
125
|
+
if is_iterable(real_type):
|
|
126
|
+
if isinstance(value, (str, bytes)) and isinstance(value, real_type):
|
|
127
|
+
# specifically handle Iterable[str] and Iterable[bytes] as simply str and bytes
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
# default to str if the inner type is not set, e.g. x: list
|
|
131
|
+
inner_type = args[0] if args else Any
|
|
132
|
+
|
|
133
|
+
# if tuple[T, T] fixed length
|
|
134
|
+
if origin is tuple and args and Ellipsis not in args:
|
|
135
|
+
if len(args) != len(value):
|
|
136
|
+
raise ValueError(f"Expected tuple of length {len(args)}, got {len(value)}")
|
|
137
|
+
|
|
138
|
+
return tuple(
|
|
139
|
+
as_type(v, t, transparent_int=transparent_int, semantic_bool=semantic_bool) for v, t in zip(value, args)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# otherwise, it's a variadic container
|
|
143
|
+
vals = (
|
|
144
|
+
as_type(
|
|
145
|
+
v,
|
|
146
|
+
inner_type,
|
|
147
|
+
transparent_int=transparent_int,
|
|
148
|
+
semantic_bool=semantic_bool,
|
|
149
|
+
)
|
|
150
|
+
for v in value
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if inspect.isabstract(real_type):
|
|
154
|
+
# We can't cast to an abstract container, so just return the value as a list
|
|
155
|
+
return list(vals)
|
|
156
|
+
|
|
157
|
+
return real_type(vals)
|
|
158
|
+
|
|
159
|
+
# handle possible semantic conversions
|
|
160
|
+
if to is int and transparent_int:
|
|
161
|
+
with suppress(ValueError, TypeError):
|
|
162
|
+
return int(float(value))
|
|
163
|
+
|
|
164
|
+
if to is bool and semantic_bool and isinstance(value, str):
|
|
165
|
+
normalized = value.lower()
|
|
166
|
+
|
|
167
|
+
if normalized in ("true", "yes", "1", "on"):
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
if normalized in ("false", "no", "0", "off"):
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
if isinstance(real_type, type) and callable(real_type):
|
|
174
|
+
if inspect.isabstract(real_type):
|
|
175
|
+
# We can't instantiate an abstract class, so just return the value
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
return real_type(value)
|
|
179
|
+
|
|
180
|
+
# fallback
|
|
181
|
+
return to(value)
|
typewire/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typewire
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A single-file utility to allow better runtime handling of types by providing a predictable way of transforming data into the shape of a given type hint.
|
|
5
|
+
Project-URL: repository, https://github.com/lilellia/typewire
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/lilellia/typewire/issues
|
|
7
|
+
Author-email: Lily Ellington <lilell_@outlook.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: annotated,casting,runtime-types,type-casting,type-hints,typing
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# typewire
|
|
27
|
+
|
|
28
|
+
A single-file utility to allow better runtime handling of types by providing a predictable way of transforming data into the shape of a given type hint.
|
|
29
|
+
|
|
30
|
+
## Why?
|
|
31
|
+
|
|
32
|
+
Python's standard library provides tools for describing types via type hints, but it doesn't provide a unified way of actually enforcing those type hints at runtime, even buried deep in the `typing` module.
|
|
33
|
+
|
|
34
|
+
Our goal is to allow for `x: T` to behave transparently as usual whilst also allowing the user to convert to that type, regardless of whether that is `x: int` or `x: float | None` or `x: dict[str, list[int | dict[float, User]]]`. Just like `int(x)` will (to the best of its ability) turn `x` into an `int`, `typewire.as_type(x, int)` will do the same, but with the added benefit of working on type hints that aren't callable (like `list[float]`).
|
|
35
|
+
|
|
36
|
+
```py
|
|
37
|
+
>>> from typewire import as_type
|
|
38
|
+
|
|
39
|
+
>>> as_type("3.2", float)
|
|
40
|
+
3.2
|
|
41
|
+
|
|
42
|
+
>>> as_type(78.1, int)
|
|
43
|
+
78
|
|
44
|
+
|
|
45
|
+
>>> as_type("3.2", int, transparent_int=True)
|
|
46
|
+
3
|
|
47
|
+
|
|
48
|
+
>>> as_type(["13.2", "18", "-1.2"], list[int | float])
|
|
49
|
+
[13.2, 18, -1.2]
|
|
50
|
+
|
|
51
|
+
>>> as_type([("a", "1"), ("b", "2"), ("c", "3"), ("z", "26")], dict[str, float])
|
|
52
|
+
{'a': 1.0, 'b': 2.0, 'c': 3.0, 'z': 26.0}
|
|
53
|
+
|
|
54
|
+
>>> from pathlib import Path
|
|
55
|
+
>>> data = {"logs": ["/tmp/app.log", "123"]}
|
|
56
|
+
>>> hint = dict[str, list[Path | int]]
|
|
57
|
+
>>> as_type(data, hint)
|
|
58
|
+
{'logs': [Path('/tmp/app.log'), 123]}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
`typewire` is supported on Python 3.10 and onward and can be easily installed with a package manager such as:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# using pip
|
|
67
|
+
$ pip install typewire
|
|
68
|
+
|
|
69
|
+
# using uv
|
|
70
|
+
$ uv add typewire
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`typewire` does not have any additional dependencies.
|
|
74
|
+
|
|
75
|
+
## Documentation
|
|
76
|
+
|
|
77
|
+
### `TypeHint`
|
|
78
|
+
|
|
79
|
+
`TypeHint` is provided as a top-level alias for `typing.Any`.
|
|
80
|
+
|
|
81
|
+
### `is_union`, `is_mapping`, `is_iterable`
|
|
82
|
+
|
|
83
|
+
These three functions check whether a given type hint is a union type (e.g., `int | str | bytes`), a mapping type (e.g., `dict[str, Any]`), or an iterable type (`e.g., list[str]`).
|
|
84
|
+
|
|
85
|
+
Note that `is_iterable` specifically excludes `str` and `bytes`: `is_iterable(str) == False` as, while `str` does support iteration, for the purposes of type casting, it's not really an iterable/container type.
|
|
86
|
+
|
|
87
|
+
### `as_type`
|
|
88
|
+
|
|
89
|
+
The signature is
|
|
90
|
+
|
|
91
|
+
```py
|
|
92
|
+
def as_type(value: Any, to: TypeHint, *, transparent_int: bool = False, semantic_bool: False = False) -> Any:
|
|
93
|
+
...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In particular, it casts the given `value` to the given `to` type, regardless of whether `to` is:
|
|
97
|
+
|
|
98
|
+
```py
|
|
99
|
+
|
|
100
|
+
# a plain type
|
|
101
|
+
>>> as_type(3.2, int)
|
|
102
|
+
3
|
|
103
|
+
|
|
104
|
+
# typing.Literal, returning the value as-is if it's a valid entry
|
|
105
|
+
>>> as_type("abc", Literal["abc", "def"])
|
|
106
|
+
'abc'
|
|
107
|
+
|
|
108
|
+
>>> as_type("80", Literal[80, 443])
|
|
109
|
+
ValueError(...)
|
|
110
|
+
|
|
111
|
+
# a union type, casting to the first valid type
|
|
112
|
+
>>> as_type("3", float | int)
|
|
113
|
+
3.0
|
|
114
|
+
|
|
115
|
+
>>> as_type("3", int | float)
|
|
116
|
+
3
|
|
117
|
+
|
|
118
|
+
# an optional type
|
|
119
|
+
>>> as_type(43, int | None)
|
|
120
|
+
43
|
|
121
|
+
|
|
122
|
+
>>> as_type(None, int | None)
|
|
123
|
+
None
|
|
124
|
+
|
|
125
|
+
# a mapping type
|
|
126
|
+
>>> as_type({"a": "1", "b": "2.0"}, dict[str, float])
|
|
127
|
+
{'a': 1.0, 'b': 2.0}
|
|
128
|
+
|
|
129
|
+
# a container/iterable type
|
|
130
|
+
>>> as_type([1.2, -3, 449], list[str])
|
|
131
|
+
['1.2', '-3', '449']
|
|
132
|
+
|
|
133
|
+
>>> as_type([1.2, -3, 449], tuple[str, ...])
|
|
134
|
+
('1.2', '-3', '449')
|
|
135
|
+
|
|
136
|
+
# typing.Annotated, treating it as the bare type
|
|
137
|
+
>>> as_type("3", Annotated[int, "some metadata"])
|
|
138
|
+
3
|
|
139
|
+
|
|
140
|
+
# an abstract collections.abc.Iterable/Mapping, cast as concrete list/dict
|
|
141
|
+
>>> as_type([1.2, -3, 449], Iterable[str])
|
|
142
|
+
['1.2', '-3', '449']
|
|
143
|
+
|
|
144
|
+
>>> as_type({"a": "1", "b": "2.0"}, Mapping[str, float])
|
|
145
|
+
{'a': 1.0, 'b': 2.0}
|
|
146
|
+
|
|
147
|
+
# ...unless it's a string being cast as Iterable[str]
|
|
148
|
+
>>> as_type("hello world", Iterable[str])
|
|
149
|
+
'hello world'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
On a failure, `ValueError` is raised.
|
|
153
|
+
|
|
154
|
+
#### `transparent_int`
|
|
155
|
+
|
|
156
|
+
This flag (default = False) allows for a nonstrict cast to `int`.
|
|
157
|
+
|
|
158
|
+
```py
|
|
159
|
+
>>> int("3.2", int)
|
|
160
|
+
ValueError # invalid literal for int() with base 10: '3.2'
|
|
161
|
+
|
|
162
|
+
>>> as_type("3.2", int)
|
|
163
|
+
ValueError # invalid literal for int() with base 10: '3.2'
|
|
164
|
+
|
|
165
|
+
>>> as_type("3.2", int, transparent_int = True)
|
|
166
|
+
3
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
In practice, this flag results in a call of `int(float(value))` instead of just `int(value)`.
|
|
170
|
+
|
|
171
|
+
#### `semantic_bool`
|
|
172
|
+
|
|
173
|
+
This flag (default = False) allows for a nonstrict cast to `bool`.
|
|
174
|
+
|
|
175
|
+
```py
|
|
176
|
+
>>> bool("false") # non-empty string
|
|
177
|
+
True
|
|
178
|
+
|
|
179
|
+
>>> as_type("false", bool)
|
|
180
|
+
True
|
|
181
|
+
|
|
182
|
+
>>> as_type("false", bool, semantic_bool = True)
|
|
183
|
+
False
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
In practice, if `value` is a string and is one of `["false", "no", "0", "off"]` (case-insensitive), then it will be cast as `False` with this flag enabled.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
typewire/__init__.py,sha256=j6T9wa5TbKAfZmB_7PqbYGQ3Miu63JRzvHaEoKIgMiE,149
|
|
2
|
+
typewire/caster.py,sha256=RaiIZg5ClhnJDBFwh1GXsnGKN7ZglWx1EwANF2KsXic,6613
|
|
3
|
+
typewire/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
typewire-0.1.0.dist-info/METADATA,sha256=kXGOByc2kqf21ZpN_cHNYNgR0zTle0af8x6B2HVZZxo,5541
|
|
5
|
+
typewire-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
6
|
+
typewire-0.1.0.dist-info/licenses/LICENSE,sha256=jYMMQTZU3uN4XkybqyCDbjVfv9wWQRGPDkBebAxS-SQ,1065
|
|
7
|
+
typewire-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 lilellia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|