typespy 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.
@@ -0,0 +1,40 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: typespy
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Detect python calling types
|
5
|
+
Author: Rasmus Nygren
|
6
|
+
Maintainer: Rasmus Nygren
|
7
|
+
Project-URL: Homepage, https://github.com/rasmusnygren/typespy
|
8
|
+
Project-URL: Repository, https://github.com/rasmusnygren/typespy
|
9
|
+
Keywords: type detection,type tracing,typing
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
13
|
+
Requires-Python: >=3.10
|
14
|
+
Description-Content-Type: text/markdown
|
15
|
+
License-File: LICENSE
|
16
|
+
Dynamic: license-file
|
17
|
+
|
18
|
+
Typespy
|
19
|
+
=======
|
20
|
+
Figure out which types your modules are being called with.
|
21
|
+
|
22
|
+
## Installation
|
23
|
+
```bash
|
24
|
+
pip install typespy
|
25
|
+
```
|
26
|
+
|
27
|
+
## Usage
|
28
|
+
```bash
|
29
|
+
usage: typespy [-h] [--verbose] module_names [module_names ...] dir
|
30
|
+
|
31
|
+
Analyze function call type information
|
32
|
+
|
33
|
+
positional arguments:
|
34
|
+
module_names Name of the modules to analyze
|
35
|
+
dir Path to the root directory of repositories
|
36
|
+
|
37
|
+
options:
|
38
|
+
-h, --help show this help message and exit
|
39
|
+
--verbose Print function calls with file locations
|
40
|
+
```
|
@@ -0,0 +1,7 @@
|
|
1
|
+
typespy.py,sha256=HsGk9vSahA1zpQAc73kC4bkXm2r3n8qzeRwdP1_wr1Q,10525
|
2
|
+
typespy-0.1.0.dist-info/licenses/LICENSE,sha256=pdhK9ZMpWV_vjPnMCHH5Qga0bfLbDqDfS36yM2WwUHA,1069
|
3
|
+
typespy-0.1.0.dist-info/METADATA,sha256=TK9_UoceXSBoaRZVOSo6x0JrDukicDQsmaBcINoT37c,1069
|
4
|
+
typespy-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
5
|
+
typespy-0.1.0.dist-info/entry_points.txt,sha256=7ltHj-X4VwvZFZ57Z7qOcdFhNzR8sPbeUvy-nMN98A4,41
|
6
|
+
typespy-0.1.0.dist-info/top_level.txt,sha256=1EhqvcbjTtVnohRE-zdooUCoEloaJMJo1dQxuEFY8Bo,8
|
7
|
+
typespy-0.1.0.dist-info/RECORD,,
|
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 RasmusNygren
|
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.
|
@@ -0,0 +1 @@
|
|
1
|
+
typespy
|
typespy.py
ADDED
@@ -0,0 +1,311 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import argparse
|
4
|
+
import ast
|
5
|
+
import os
|
6
|
+
from collections import defaultdict
|
7
|
+
from collections.abc import Collection
|
8
|
+
from typing import NamedTuple
|
9
|
+
from typing import TypeAlias
|
10
|
+
|
11
|
+
ModuleUsageTypes: TypeAlias = dict[str, dict[int, set[str]]]
|
12
|
+
|
13
|
+
|
14
|
+
class ListType(NamedTuple):
|
15
|
+
element_types: frozenset[InferredType] = frozenset()
|
16
|
+
|
17
|
+
def __str__(self) -> str:
|
18
|
+
if not self.element_types:
|
19
|
+
return "list[]"
|
20
|
+
return f"list[{' | '.join(sorted(str(x) for x in self.element_types))}]"
|
21
|
+
|
22
|
+
@classmethod
|
23
|
+
def from_set(cls, s: set[InferredType]) -> ListType:
|
24
|
+
return cls(element_types=frozenset(s))
|
25
|
+
|
26
|
+
|
27
|
+
class TupleType(NamedTuple):
|
28
|
+
element_types: tuple[InferredType, ...] = ()
|
29
|
+
# Use union types if it's not possible to determine
|
30
|
+
# the # of args and their exact type by position
|
31
|
+
use_union_types: bool = False
|
32
|
+
|
33
|
+
def __str__(self) -> str:
|
34
|
+
if self.use_union_types:
|
35
|
+
unique_types = sorted({str(x) for x in self.element_types})
|
36
|
+
return f"tuple[{' | '.join(unique_types)}]"
|
37
|
+
else:
|
38
|
+
return f"tuple[{', '.join(str(x) for x in self.element_types)}]"
|
39
|
+
|
40
|
+
|
41
|
+
class DictType(NamedTuple):
|
42
|
+
key_types: frozenset[InferredType] = frozenset()
|
43
|
+
value_types: frozenset[InferredType] = frozenset()
|
44
|
+
|
45
|
+
def __str__(self) -> str:
|
46
|
+
if not self.key_types and not self.value_types:
|
47
|
+
return "dict[]"
|
48
|
+
key_type = ' | '.join(sorted(str(x) for x in self.key_types))
|
49
|
+
value_type = ' | '.join(sorted(str(x) for x in self.value_types))
|
50
|
+
return f"dict[{key_type}, {value_type}]"
|
51
|
+
|
52
|
+
@classmethod
|
53
|
+
def from_sets(cls, keys: set[InferredType], values: set[InferredType]) -> DictType:
|
54
|
+
return cls(key_types=frozenset(keys), value_types=frozenset(values))
|
55
|
+
|
56
|
+
|
57
|
+
class VarType(NamedTuple):
|
58
|
+
name: str
|
59
|
+
|
60
|
+
def __str__(self) -> str:
|
61
|
+
return f"var:{self.name}"
|
62
|
+
|
63
|
+
|
64
|
+
InferredType = str | ListType | TupleType | DictType | VarType
|
65
|
+
|
66
|
+
|
67
|
+
def _extract_collection_types(inferred_type: InferredType) -> Collection[InferredType]:
|
68
|
+
if isinstance(inferred_type, (ListType, TupleType)):
|
69
|
+
return inferred_type.element_types
|
70
|
+
return [inferred_type]
|
71
|
+
|
72
|
+
|
73
|
+
def _infer_list_type(node: ast.List, var_types: dict[str, InferredType] | None = None) -> ListType:
|
74
|
+
if not node.elts:
|
75
|
+
return ListType()
|
76
|
+
|
77
|
+
element_types: set[InferredType] = set()
|
78
|
+
|
79
|
+
for elt in node.elts:
|
80
|
+
if isinstance(elt, ast.Starred):
|
81
|
+
expanded_type = infer_type(elt.value, var_types)
|
82
|
+
inner_types = _extract_collection_types(expanded_type)
|
83
|
+
element_types.update(inner_types)
|
84
|
+
else:
|
85
|
+
element_types.add(infer_type(elt, var_types))
|
86
|
+
|
87
|
+
return ListType.from_set(element_types)
|
88
|
+
|
89
|
+
|
90
|
+
def _infer_tuple_type(
|
91
|
+
node: ast.Tuple, var_types: dict[str, InferredType] | None = None,
|
92
|
+
) -> TupleType:
|
93
|
+
"""
|
94
|
+
If we can infer the exact number of arguments and types of the tuple
|
95
|
+
arguments then return an exact representation (tuple[int, int, int, str])
|
96
|
+
whereas if we can't, then return a unionized representation of the
|
97
|
+
tuple types (tuple[int | str]).
|
98
|
+
"""
|
99
|
+
if not node.elts:
|
100
|
+
return TupleType()
|
101
|
+
|
102
|
+
tuple_types = []
|
103
|
+
use_union_types = False
|
104
|
+
|
105
|
+
for elt in node.elts:
|
106
|
+
if isinstance(elt, ast.Starred):
|
107
|
+
if isinstance(elt.value, (ast.List, ast.Tuple)):
|
108
|
+
for sub_elt in elt.value.elts:
|
109
|
+
tuple_types.append(infer_type(sub_elt, var_types))
|
110
|
+
else:
|
111
|
+
expanded_type = infer_type(elt.value, var_types)
|
112
|
+
inner_types = _extract_collection_types(expanded_type)
|
113
|
+
tuple_types.extend(inner_types)
|
114
|
+
if isinstance(expanded_type, ListType):
|
115
|
+
use_union_types = True
|
116
|
+
else:
|
117
|
+
tuple_types.append(infer_type(elt, var_types))
|
118
|
+
|
119
|
+
return TupleType(
|
120
|
+
element_types=tuple(tuple_types),
|
121
|
+
use_union_types=use_union_types,
|
122
|
+
)
|
123
|
+
|
124
|
+
|
125
|
+
def _infer_dict_type(node: ast.Dict, var_types: dict[str, InferredType] | None = None) -> DictType:
|
126
|
+
if not node.keys and not node.values:
|
127
|
+
return DictType()
|
128
|
+
|
129
|
+
key_types: set[InferredType] = set()
|
130
|
+
value_types: set[InferredType] = set()
|
131
|
+
|
132
|
+
for key, value in zip(node.keys, node.values):
|
133
|
+
if key is None:
|
134
|
+
# If the key is None, we have a dict-unpacking expression (**dict)
|
135
|
+
# and need to recursively infer the inner types and determine
|
136
|
+
# if they are part of the key types or value types.
|
137
|
+
expanded_type = infer_type(value, var_types)
|
138
|
+
if isinstance(expanded_type, DictType):
|
139
|
+
key_types.update(expanded_type.key_types)
|
140
|
+
value_types.update(expanded_type.value_types)
|
141
|
+
|
142
|
+
else:
|
143
|
+
key_types.add(infer_type(key, var_types))
|
144
|
+
value_types.add(infer_type(value, var_types))
|
145
|
+
|
146
|
+
return DictType.from_sets(key_types, value_types)
|
147
|
+
|
148
|
+
|
149
|
+
def infer_type(node: ast.AST, var_types: dict[str, InferredType] | None = None) -> InferredType:
|
150
|
+
if isinstance(node, ast.Constant):
|
151
|
+
return type(node.value).__name__
|
152
|
+
elif isinstance(node, ast.List):
|
153
|
+
return _infer_list_type(node, var_types)
|
154
|
+
elif isinstance(node, ast.Dict):
|
155
|
+
return _infer_dict_type(node, var_types)
|
156
|
+
elif isinstance(node, ast.Tuple):
|
157
|
+
return _infer_tuple_type(node, var_types)
|
158
|
+
elif isinstance(node, ast.Name):
|
159
|
+
if var_types and node.id in var_types:
|
160
|
+
return var_types[node.id]
|
161
|
+
return VarType(node.id)
|
162
|
+
return "unknown"
|
163
|
+
|
164
|
+
|
165
|
+
def find_import_aliases(
|
166
|
+
tree: ast.AST,
|
167
|
+
module_name: str,
|
168
|
+
) -> tuple[set[str], dict[str, str]]:
|
169
|
+
module_aliases: set[str] = set()
|
170
|
+
direct_imports: dict[str, str] = {}
|
171
|
+
|
172
|
+
for node in ast.walk(tree):
|
173
|
+
if isinstance(node, ast.Import):
|
174
|
+
for alias in node.names:
|
175
|
+
if alias.name == module_name:
|
176
|
+
module_aliases.add(alias.asname or alias.name)
|
177
|
+
elif isinstance(node, ast.ImportFrom):
|
178
|
+
if node.module and node.module.startswith(module_name):
|
179
|
+
for alias in node.names:
|
180
|
+
local_name = alias.asname or alias.name
|
181
|
+
direct_imports[local_name] = alias.name
|
182
|
+
|
183
|
+
return module_aliases, direct_imports
|
184
|
+
|
185
|
+
|
186
|
+
class ModuleCallVisitor(ast.NodeVisitor):
|
187
|
+
def __init__(
|
188
|
+
self,
|
189
|
+
lib_aliases: set[str],
|
190
|
+
direct_imports: dict[str, str],
|
191
|
+
filepath: str | None = None,
|
192
|
+
verbose: bool = False,
|
193
|
+
) -> None:
|
194
|
+
self.lib_aliases = lib_aliases
|
195
|
+
self.direct_imports = direct_imports
|
196
|
+
self.filepath = filepath
|
197
|
+
self.verbose = verbose
|
198
|
+
self.calls: list[tuple[str, int, InferredType]] = []
|
199
|
+
self.var_types: dict[str, InferredType] = {}
|
200
|
+
|
201
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
202
|
+
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
203
|
+
var_name = node.targets[0].id
|
204
|
+
var_type = infer_type(node.value, self.var_types)
|
205
|
+
if not isinstance(var_type, VarType):
|
206
|
+
self.var_types[var_name] = var_type
|
207
|
+
|
208
|
+
self.generic_visit(node)
|
209
|
+
|
210
|
+
def visit_Call(self, node: ast.Call) -> None:
|
211
|
+
func_name: str | None = None
|
212
|
+
|
213
|
+
if (
|
214
|
+
isinstance(node.func, ast.Attribute)
|
215
|
+
and isinstance(node.func.value, ast.Name)
|
216
|
+
):
|
217
|
+
if node.func.value.id in self.lib_aliases:
|
218
|
+
func_name = node.func.attr
|
219
|
+
|
220
|
+
elif isinstance(node.func, ast.Name) and node.func.id in self.direct_imports:
|
221
|
+
func_name = self.direct_imports[node.func.id]
|
222
|
+
|
223
|
+
if func_name:
|
224
|
+
arg_types: list[InferredType] = []
|
225
|
+
for i, arg in enumerate(node.args):
|
226
|
+
arg_type = infer_type(arg, self.var_types)
|
227
|
+
arg_types.append(arg_type)
|
228
|
+
self.calls.append((func_name, i, arg_type))
|
229
|
+
|
230
|
+
if self.verbose:
|
231
|
+
args_str = ', '.join(str(t) for t in arg_types)
|
232
|
+
print(
|
233
|
+
f"[{self.filepath}:{node.lineno}] {func_name}({args_str})",
|
234
|
+
)
|
235
|
+
|
236
|
+
self.generic_visit(node)
|
237
|
+
|
238
|
+
|
239
|
+
def analyze_file(filepath: str, module_name: str, verbose: bool = False) -> ModuleUsageTypes:
|
240
|
+
with open(filepath, encoding="utf-8") as f:
|
241
|
+
source = f.read()
|
242
|
+
tree = ast.parse(source, filename=filepath)
|
243
|
+
module_aliases, direct_imports = find_import_aliases(tree, module_name)
|
244
|
+
|
245
|
+
usages: ModuleUsageTypes = defaultdict(lambda: defaultdict(set))
|
246
|
+
if module_aliases or direct_imports:
|
247
|
+
visitor = ModuleCallVisitor(
|
248
|
+
module_aliases, direct_imports, filepath, verbose,
|
249
|
+
)
|
250
|
+
visitor.visit(tree)
|
251
|
+
for func_name, arg_pos, arg_type in visitor.calls:
|
252
|
+
usages[func_name][arg_pos].add(str(arg_type))
|
253
|
+
|
254
|
+
return usages
|
255
|
+
|
256
|
+
|
257
|
+
def walk_repo(
|
258
|
+
root_dir: str,
|
259
|
+
module_name: str,
|
260
|
+
verbose: bool = False,
|
261
|
+
) -> ModuleUsageTypes:
|
262
|
+
usages: ModuleUsageTypes = defaultdict(lambda: defaultdict(set))
|
263
|
+
for dirpath, _, filenames in os.walk(root_dir):
|
264
|
+
filenames = [x for x in filenames if x.endswith(".py")]
|
265
|
+
for filename in filenames:
|
266
|
+
for func_name, args_data in analyze_file(
|
267
|
+
os.path.join(dirpath, filename),
|
268
|
+
module_name, verbose,
|
269
|
+
).items():
|
270
|
+
for arg_pos, types in args_data.items():
|
271
|
+
usages[func_name][arg_pos].update(types)
|
272
|
+
return usages
|
273
|
+
|
274
|
+
|
275
|
+
def main() -> int:
|
276
|
+
parser = argparse.ArgumentParser(
|
277
|
+
description="Analyze function call type information",
|
278
|
+
)
|
279
|
+
parser.add_argument(
|
280
|
+
"module_names", nargs="+",
|
281
|
+
help="Name of the modules to analyze",
|
282
|
+
)
|
283
|
+
parser.add_argument(
|
284
|
+
"dir", help="Path to the root directory of repositories",
|
285
|
+
)
|
286
|
+
parser.add_argument(
|
287
|
+
"--verbose", action="store_true",
|
288
|
+
help="Print function calls with file locations",
|
289
|
+
)
|
290
|
+
args = parser.parse_args()
|
291
|
+
|
292
|
+
module_usages: dict[str, ModuleUsageTypes] = {}
|
293
|
+
|
294
|
+
for module in args.module_names:
|
295
|
+
usages = walk_repo(
|
296
|
+
args.dir, module, verbose=args.verbose,
|
297
|
+
)
|
298
|
+
module_usages[module] = usages
|
299
|
+
|
300
|
+
for module, usages in module_usages.items():
|
301
|
+
print(f"=== Module <{module}> summary ===")
|
302
|
+
for func, args_types in usages.items():
|
303
|
+
print(f"Function: {func}")
|
304
|
+
for pos, types in args_types.items():
|
305
|
+
print(f" Arg {pos}: {', '.join(sorted(types))}")
|
306
|
+
print()
|
307
|
+
return 0
|
308
|
+
|
309
|
+
|
310
|
+
if __name__ == "__main__":
|
311
|
+
SystemExit(main())
|