odoo-typegen 0.1.2__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.
- odoo_typegen/__init__.py +1 -0
- odoo_typegen/cli.py +87 -0
- odoo_typegen/compiler/__init__.py +0 -0
- odoo_typegen/compiler/compiler.py +288 -0
- odoo_typegen/compiler/consolidated_model.py +34 -0
- odoo_typegen/compiler/core_stub_compiler.py +216 -0
- odoo_typegen/compiler/environment_compiler.py +74 -0
- odoo_typegen/compiler/model_compiler.py +79 -0
- odoo_typegen/compiler/model_fragment.py +12 -0
- odoo_typegen/compiler/model_index.py +18 -0
- odoo_typegen/emitter/__init__.py +1 -0
- odoo_typegen/emitter/type_emitter.py +134 -0
- odoo_typegen/registry/__init__.py +0 -0
- odoo_typegen/registry/module.py +9 -0
- odoo_typegen/registry/registry.py +29 -0
- odoo_typegen/registry/registry_service.py +87 -0
- odoo_typegen-0.1.2.dist-info/LICENSE +21 -0
- odoo_typegen-0.1.2.dist-info/METADATA +258 -0
- odoo_typegen-0.1.2.dist-info/RECORD +21 -0
- odoo_typegen-0.1.2.dist-info/WHEEL +4 -0
- odoo_typegen-0.1.2.dist-info/entry_points.txt +3 -0
odoo_typegen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
odoo_typegen/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from cyclopts import App
|
|
4
|
+
|
|
5
|
+
from odoo_typegen.compiler.compiler import Compiler
|
|
6
|
+
from odoo_typegen.compiler.environment_compiler import EnvironmentCompiler
|
|
7
|
+
from odoo_typegen.compiler.model_compiler import ModelCompiler
|
|
8
|
+
from odoo_typegen.emitter.type_emitter import TypeEmitter
|
|
9
|
+
from odoo_typegen.registry.registry_service import RegistryService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
app = App()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _generate(
|
|
16
|
+
addon_path: Path,
|
|
17
|
+
output_path: Path,
|
|
18
|
+
odoo_path: Path | None = None,
|
|
19
|
+
) -> tuple[Path, ...]:
|
|
20
|
+
registry = RegistryService(addon_path=addon_path).build()
|
|
21
|
+
models = Compiler().compile(registry)
|
|
22
|
+
environment = EnvironmentCompiler(odoo_path=odoo_path).compile(models)
|
|
23
|
+
core_models = ModelCompiler(odoo_path=odoo_path).compile()
|
|
24
|
+
emitter = TypeEmitter(output_path=output_path)
|
|
25
|
+
|
|
26
|
+
emitted_files = (
|
|
27
|
+
*emitter.emit(models),
|
|
28
|
+
*emitter.emit_environment(environment),
|
|
29
|
+
*emitter.emit_core_models(core_models),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
for emitted_file in emitted_files:
|
|
33
|
+
print(emitted_file)
|
|
34
|
+
|
|
35
|
+
return emitted_files
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.default
|
|
39
|
+
def default(
|
|
40
|
+
addon_path: Path,
|
|
41
|
+
output_path: Path = Path("typings"),
|
|
42
|
+
*,
|
|
43
|
+
odoo_path: Path | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Generate Python stub files for Odoo addons.
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
addon_path: Path
|
|
50
|
+
Directory containing Odoo addon directories.
|
|
51
|
+
output_path: Path
|
|
52
|
+
Directory where generated .pyi files should be written. Defaults to
|
|
53
|
+
./typings, which Pyright discovers automatically.
|
|
54
|
+
odoo_path: Path | None
|
|
55
|
+
Optional path to a local Odoo checkout used to overlay core stubs.
|
|
56
|
+
"""
|
|
57
|
+
_generate(addon_path, output_path, odoo_path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command
|
|
61
|
+
def generate(
|
|
62
|
+
addon_path: Path,
|
|
63
|
+
output_path: Path = Path("typings"),
|
|
64
|
+
*,
|
|
65
|
+
odoo_path: Path | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Generate Python stub files for Odoo addons.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
addon_path: Path
|
|
72
|
+
Directory containing Odoo addon directories.
|
|
73
|
+
output_path: Path
|
|
74
|
+
Directory where generated .pyi files should be written. Defaults to
|
|
75
|
+
./typings, which Pyright discovers automatically.
|
|
76
|
+
odoo_path: Path | None
|
|
77
|
+
Optional path to a local Odoo checkout used to overlay core stubs.
|
|
78
|
+
"""
|
|
79
|
+
_generate(addon_path, output_path, odoo_path)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
app()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import astroid
|
|
4
|
+
from astroid import nodes
|
|
5
|
+
|
|
6
|
+
from odoo_typegen.compiler.consolidated_model import (
|
|
7
|
+
ConsolidatedModel,
|
|
8
|
+
StubAttribute,
|
|
9
|
+
StubClass,
|
|
10
|
+
StubMethod,
|
|
11
|
+
)
|
|
12
|
+
from odoo_typegen.compiler.model_fragment import ModelFragment
|
|
13
|
+
from odoo_typegen.compiler.model_index import ModelIndex
|
|
14
|
+
from odoo_typegen.registry.module import Module
|
|
15
|
+
from odoo_typegen.registry.registry import Registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Compiler:
|
|
19
|
+
_FIELD_TYPES = {
|
|
20
|
+
"Char": "str",
|
|
21
|
+
"Text": "str",
|
|
22
|
+
"Selection": "str",
|
|
23
|
+
"Boolean": "bool",
|
|
24
|
+
"Integer": "int",
|
|
25
|
+
"Float": "float",
|
|
26
|
+
"Monetary": "float",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def compile(self, registry: Registry) -> tuple[ConsolidatedModel, ...]:
|
|
30
|
+
return self.consolidate(self.index_fragments(registry))
|
|
31
|
+
|
|
32
|
+
def index_fragments(self, registry: Registry) -> ModelIndex:
|
|
33
|
+
index = ModelIndex()
|
|
34
|
+
for module in registry.values():
|
|
35
|
+
self._compile_module(module, index)
|
|
36
|
+
return index
|
|
37
|
+
|
|
38
|
+
def consolidate(self, model_index: ModelIndex) -> tuple[ConsolidatedModel, ...]:
|
|
39
|
+
models: list[ConsolidatedModel] = []
|
|
40
|
+
for model_name, fragments in model_index.items():
|
|
41
|
+
attributes: list[StubAttribute] = []
|
|
42
|
+
methods: list[StubMethod] = []
|
|
43
|
+
|
|
44
|
+
for fragment in fragments:
|
|
45
|
+
class_node = self._find_fragment_class(fragment)
|
|
46
|
+
if class_node is None:
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
attributes.extend(self._extract_fields(fragment, class_node))
|
|
50
|
+
methods.extend(self._extract_methods(fragment, class_node))
|
|
51
|
+
|
|
52
|
+
models.append(
|
|
53
|
+
ConsolidatedModel(
|
|
54
|
+
name=model_name,
|
|
55
|
+
stub_class=StubClass(
|
|
56
|
+
import_path=model_name,
|
|
57
|
+
class_name=self._class_name_for_model(model_name),
|
|
58
|
+
bases=("odoo.models.Model",),
|
|
59
|
+
attributes=tuple(attributes),
|
|
60
|
+
methods=tuple(methods),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return tuple(models)
|
|
66
|
+
|
|
67
|
+
def _compile_module(self, module: Module, index: ModelIndex) -> None:
|
|
68
|
+
self._compile_python_file(
|
|
69
|
+
module=module,
|
|
70
|
+
file=module.path / "__init__.py",
|
|
71
|
+
index=index,
|
|
72
|
+
seen=set(),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _compile_python_file(
|
|
76
|
+
self,
|
|
77
|
+
module: Module,
|
|
78
|
+
file: Path,
|
|
79
|
+
index: ModelIndex,
|
|
80
|
+
seen: set[Path],
|
|
81
|
+
) -> None:
|
|
82
|
+
if file in seen or not file.exists():
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
seen.add(file)
|
|
86
|
+
tree = astroid.parse(file.read_text())
|
|
87
|
+
|
|
88
|
+
for fragment in self._extract_model_fragments(module, file, tree):
|
|
89
|
+
model_name = self._effective_model_name(fragment)
|
|
90
|
+
if model_name is not None:
|
|
91
|
+
index.add(model_name, fragment)
|
|
92
|
+
|
|
93
|
+
for imported_file in self._resolve_local_imports(file, tree):
|
|
94
|
+
self._compile_python_file(module, imported_file, index, seen)
|
|
95
|
+
|
|
96
|
+
def _resolve_local_imports(
|
|
97
|
+
self,
|
|
98
|
+
file: Path,
|
|
99
|
+
tree: nodes.Module,
|
|
100
|
+
) -> tuple[Path, ...]:
|
|
101
|
+
imported_files: list[Path] = []
|
|
102
|
+
package_path = file.parent
|
|
103
|
+
|
|
104
|
+
for node in tree.body:
|
|
105
|
+
if not isinstance(node, nodes.ImportFrom) or node.level != 1:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
for imported_name, _alias in node.names:
|
|
109
|
+
if imported_name == "*":
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
package_import = package_path / imported_name / "__init__.py"
|
|
113
|
+
module_import = package_path / f"{imported_name}.py"
|
|
114
|
+
if package_import.exists():
|
|
115
|
+
imported_files.append(package_import)
|
|
116
|
+
elif module_import.exists():
|
|
117
|
+
imported_files.append(module_import)
|
|
118
|
+
|
|
119
|
+
return tuple(imported_files)
|
|
120
|
+
|
|
121
|
+
def _extract_model_fragments(
|
|
122
|
+
self,
|
|
123
|
+
module: Module,
|
|
124
|
+
file: Path,
|
|
125
|
+
tree: nodes.Module,
|
|
126
|
+
) -> tuple[ModelFragment, ...]:
|
|
127
|
+
fragments: list[ModelFragment] = []
|
|
128
|
+
|
|
129
|
+
for node in tree.body:
|
|
130
|
+
if not isinstance(node, nodes.ClassDef):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
name = None
|
|
134
|
+
inherits: tuple[str, ...] = ()
|
|
135
|
+
for statement in node.body:
|
|
136
|
+
if not isinstance(statement, nodes.Assign):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
for target in statement.targets:
|
|
140
|
+
if not isinstance(target, nodes.AssignName):
|
|
141
|
+
continue
|
|
142
|
+
if target.name == "_name":
|
|
143
|
+
name = self._parse_string_literal(statement.value)
|
|
144
|
+
elif target.name == "_inherit":
|
|
145
|
+
inherits = self._parse_string_collection(statement.value)
|
|
146
|
+
|
|
147
|
+
if not inherits:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
fragments.append(
|
|
151
|
+
ModelFragment(
|
|
152
|
+
module=module.name,
|
|
153
|
+
file=file,
|
|
154
|
+
class_name=node.name,
|
|
155
|
+
name=name,
|
|
156
|
+
inherits=inherits,
|
|
157
|
+
line=node.lineno,
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return tuple(fragments)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _parse_string_literal(node: nodes.NodeNG) -> str | None:
|
|
165
|
+
if isinstance(node, nodes.Const) and isinstance(node.value, str):
|
|
166
|
+
return node.value
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _parse_string_collection(self, node: nodes.NodeNG) -> tuple[str, ...]:
|
|
170
|
+
literal = self._parse_string_literal(node)
|
|
171
|
+
if literal is not None:
|
|
172
|
+
return (literal,)
|
|
173
|
+
|
|
174
|
+
if not isinstance(node, nodes.List | nodes.Tuple):
|
|
175
|
+
return ()
|
|
176
|
+
|
|
177
|
+
values: list[str] = []
|
|
178
|
+
for item in node.elts:
|
|
179
|
+
literal = self._parse_string_literal(item)
|
|
180
|
+
if literal is None:
|
|
181
|
+
return ()
|
|
182
|
+
values.append(literal)
|
|
183
|
+
|
|
184
|
+
return tuple(values)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _effective_model_name(fragment: ModelFragment) -> str | None:
|
|
188
|
+
if fragment.name is not None:
|
|
189
|
+
return fragment.name
|
|
190
|
+
|
|
191
|
+
if len(fragment.inherits) == 1:
|
|
192
|
+
return fragment.inherits[0]
|
|
193
|
+
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _find_fragment_class(self, fragment: ModelFragment) -> nodes.ClassDef | None:
|
|
197
|
+
tree = astroid.parse(fragment.file.read_text())
|
|
198
|
+
for node in tree.body:
|
|
199
|
+
if (
|
|
200
|
+
isinstance(node, nodes.ClassDef)
|
|
201
|
+
and node.name == fragment.class_name
|
|
202
|
+
and node.lineno == fragment.line
|
|
203
|
+
):
|
|
204
|
+
return node
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
def _extract_fields(
|
|
208
|
+
self,
|
|
209
|
+
fragment: ModelFragment,
|
|
210
|
+
class_node: nodes.ClassDef,
|
|
211
|
+
) -> tuple[StubAttribute, ...]:
|
|
212
|
+
fields: list[StubAttribute] = []
|
|
213
|
+
|
|
214
|
+
for statement in class_node.body:
|
|
215
|
+
if not isinstance(statement, nodes.Assign):
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
field_type = self._parse_field_type(statement.value)
|
|
219
|
+
if field_type is None:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
for target in statement.targets:
|
|
223
|
+
if not isinstance(target, nodes.AssignName):
|
|
224
|
+
continue
|
|
225
|
+
fields.append(
|
|
226
|
+
StubAttribute(
|
|
227
|
+
name=target.name,
|
|
228
|
+
type=field_type,
|
|
229
|
+
module=fragment.module,
|
|
230
|
+
file=fragment.file,
|
|
231
|
+
line=statement.lineno,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return tuple(fields)
|
|
236
|
+
|
|
237
|
+
def _extract_methods(
|
|
238
|
+
self,
|
|
239
|
+
fragment: ModelFragment,
|
|
240
|
+
class_node: nodes.ClassDef,
|
|
241
|
+
) -> tuple[StubMethod, ...]:
|
|
242
|
+
methods: list[StubMethod] = []
|
|
243
|
+
|
|
244
|
+
for statement in class_node.body:
|
|
245
|
+
if not isinstance(statement, nodes.FunctionDef):
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
methods.append(
|
|
249
|
+
StubMethod(
|
|
250
|
+
name=statement.name,
|
|
251
|
+
signature=self._method_signature(statement),
|
|
252
|
+
module=fragment.module,
|
|
253
|
+
file=fragment.file,
|
|
254
|
+
line=statement.lineno,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return tuple(methods)
|
|
259
|
+
|
|
260
|
+
def _parse_field_type(self, node: nodes.NodeNG) -> str | None:
|
|
261
|
+
if not isinstance(node, nodes.Call):
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
func = node.func
|
|
265
|
+
if not (
|
|
266
|
+
isinstance(func, nodes.Attribute)
|
|
267
|
+
and isinstance(func.expr, nodes.Name)
|
|
268
|
+
and func.expr.name == "fields"
|
|
269
|
+
):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
return self._FIELD_TYPES.get(func.attrname, "Any")
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _method_signature(node: nodes.FunctionDef) -> str:
|
|
276
|
+
return_type = "Any"
|
|
277
|
+
if node.returns is not None:
|
|
278
|
+
return_type = node.returns.as_string()
|
|
279
|
+
|
|
280
|
+
return f"def {node.name}({node.args.format_args()}) -> {return_type}"
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _class_name_for_model(model_name: str) -> str:
|
|
284
|
+
return "".join(
|
|
285
|
+
part.capitalize()
|
|
286
|
+
for part in model_name.replace("_", ".").split(".")
|
|
287
|
+
if part
|
|
288
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pydantic
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class StubAttribute(pydantic.BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
type: str
|
|
9
|
+
module: str | None = None
|
|
10
|
+
file: Path | None = None
|
|
11
|
+
line: int | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StubMethod(pydantic.BaseModel):
|
|
15
|
+
name: str
|
|
16
|
+
signature: str
|
|
17
|
+
decorators: tuple[str, ...] = ()
|
|
18
|
+
module: str | None = None
|
|
19
|
+
file: Path | None = None
|
|
20
|
+
line: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StubClass(pydantic.BaseModel):
|
|
24
|
+
import_path: str
|
|
25
|
+
class_name: str
|
|
26
|
+
imports: tuple[str, ...] = ()
|
|
27
|
+
bases: tuple[str, ...] = ()
|
|
28
|
+
attributes: tuple[StubAttribute, ...] = ()
|
|
29
|
+
methods: tuple[StubMethod, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConsolidatedModel(pydantic.BaseModel):
|
|
33
|
+
name: str
|
|
34
|
+
stub_class: StubClass
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import astroid
|
|
4
|
+
from astroid import nodes
|
|
5
|
+
|
|
6
|
+
from odoo_typegen.compiler.consolidated_model import StubAttribute, StubClass, StubMethod
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CoreStubCompiler:
|
|
10
|
+
_ALLOWED_PRIVATE_MEMBERS: set[str] = set()
|
|
11
|
+
_ALLOWED_DUNDERS = {
|
|
12
|
+
"__call__",
|
|
13
|
+
"__contains__",
|
|
14
|
+
"__getitem__",
|
|
15
|
+
"__iter__",
|
|
16
|
+
"__len__",
|
|
17
|
+
}
|
|
18
|
+
_SAFE_TYPES = {
|
|
19
|
+
"Any",
|
|
20
|
+
"None",
|
|
21
|
+
"bool",
|
|
22
|
+
"dict",
|
|
23
|
+
"float",
|
|
24
|
+
"int",
|
|
25
|
+
"list",
|
|
26
|
+
"object",
|
|
27
|
+
"str",
|
|
28
|
+
"tuple",
|
|
29
|
+
"typing.Any",
|
|
30
|
+
"typing.Iterator",
|
|
31
|
+
"typing.Literal",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def _extract_stub_class(
|
|
35
|
+
self,
|
|
36
|
+
source_file: Path,
|
|
37
|
+
class_name: str,
|
|
38
|
+
import_path: str,
|
|
39
|
+
imports: tuple[str, ...] = (),
|
|
40
|
+
bases: tuple[str, ...] = (),
|
|
41
|
+
) -> StubClass:
|
|
42
|
+
class_node = self._find_class(source_file, class_name)
|
|
43
|
+
if class_node is None:
|
|
44
|
+
return StubClass(
|
|
45
|
+
import_path=import_path,
|
|
46
|
+
class_name=class_name,
|
|
47
|
+
imports=imports,
|
|
48
|
+
bases=bases,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return StubClass(
|
|
52
|
+
import_path=import_path,
|
|
53
|
+
class_name=class_name,
|
|
54
|
+
imports=imports,
|
|
55
|
+
bases=bases,
|
|
56
|
+
attributes=self._extract_attributes(class_node),
|
|
57
|
+
methods=self._extract_methods(class_node),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _find_class(source_file: Path, class_name: str) -> nodes.ClassDef | None:
|
|
62
|
+
if not source_file.is_file():
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
tree = astroid.parse(source_file.read_text())
|
|
66
|
+
for node in tree.body:
|
|
67
|
+
if isinstance(node, nodes.ClassDef) and node.name == class_name:
|
|
68
|
+
return node
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def _extract_attributes(
|
|
72
|
+
self,
|
|
73
|
+
class_node: nodes.ClassDef,
|
|
74
|
+
) -> tuple[StubAttribute, ...]:
|
|
75
|
+
attributes: list[StubAttribute] = []
|
|
76
|
+
|
|
77
|
+
for statement in class_node.body:
|
|
78
|
+
if isinstance(statement, nodes.AnnAssign):
|
|
79
|
+
name = self._assignment_name(statement.target)
|
|
80
|
+
if name is None or not self._is_public_member(name):
|
|
81
|
+
continue
|
|
82
|
+
attributes.append(
|
|
83
|
+
StubAttribute(
|
|
84
|
+
name=name,
|
|
85
|
+
type=self._safe_annotation(statement.annotation),
|
|
86
|
+
line=statement.lineno,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
elif isinstance(statement, nodes.FunctionDef) and self._is_property(statement):
|
|
90
|
+
if not self._is_public_member(statement.name):
|
|
91
|
+
continue
|
|
92
|
+
attributes.append(
|
|
93
|
+
StubAttribute(
|
|
94
|
+
name=statement.name,
|
|
95
|
+
type=self._safe_annotation(statement.returns),
|
|
96
|
+
line=statement.lineno,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return tuple(attributes)
|
|
101
|
+
|
|
102
|
+
def _extract_methods(self, class_node: nodes.ClassDef) -> tuple[StubMethod, ...]:
|
|
103
|
+
methods: list[StubMethod] = []
|
|
104
|
+
|
|
105
|
+
for statement in class_node.body:
|
|
106
|
+
if not isinstance(statement, nodes.FunctionDef):
|
|
107
|
+
continue
|
|
108
|
+
if self._is_property(statement) or not self._is_public_member(statement.name):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
methods.append(
|
|
112
|
+
StubMethod(
|
|
113
|
+
name=statement.name,
|
|
114
|
+
signature=self._method_signature(statement),
|
|
115
|
+
decorators=self._method_decorators(statement),
|
|
116
|
+
line=statement.lineno,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return tuple(methods)
|
|
121
|
+
|
|
122
|
+
def _method_signature(self, node: nodes.FunctionDef) -> str:
|
|
123
|
+
return_type = self._safe_annotation(node.returns)
|
|
124
|
+
return f"def {node.name}({self._format_args(node.args)}) -> {return_type}"
|
|
125
|
+
|
|
126
|
+
def _format_args(self, args: nodes.Arguments) -> str:
|
|
127
|
+
formatted = args.format_args()
|
|
128
|
+
for annotation in self._annotations_in_args(args):
|
|
129
|
+
if annotation is None:
|
|
130
|
+
continue
|
|
131
|
+
raw_annotation = annotation.as_string()
|
|
132
|
+
safe_annotation = self._safe_annotation(annotation)
|
|
133
|
+
if raw_annotation != safe_annotation:
|
|
134
|
+
formatted = formatted.replace(f": {raw_annotation}", f": {safe_annotation}")
|
|
135
|
+
return formatted
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _annotations_in_args(args: nodes.Arguments) -> tuple[nodes.NodeNG | None, ...]:
|
|
139
|
+
annotations: list[nodes.NodeNG | None] = []
|
|
140
|
+
annotations.extend(args.annotations)
|
|
141
|
+
annotations.extend(args.posonlyargs_annotations)
|
|
142
|
+
annotations.extend(args.kwonlyargs_annotations)
|
|
143
|
+
if args.varargannotation is not None:
|
|
144
|
+
annotations.append(args.varargannotation)
|
|
145
|
+
if args.kwargannotation is not None:
|
|
146
|
+
annotations.append(args.kwargannotation)
|
|
147
|
+
return tuple(annotations)
|
|
148
|
+
|
|
149
|
+
def _safe_annotation(self, annotation: nodes.NodeNG | None) -> str:
|
|
150
|
+
if annotation is None:
|
|
151
|
+
return "typing.Any"
|
|
152
|
+
|
|
153
|
+
rendered = annotation.as_string()
|
|
154
|
+
if self._is_safe_annotation(rendered):
|
|
155
|
+
return rendered
|
|
156
|
+
return "typing.Any"
|
|
157
|
+
|
|
158
|
+
def _is_safe_annotation(self, rendered: str) -> bool:
|
|
159
|
+
cleaned = (
|
|
160
|
+
rendered.replace(" | ", "|")
|
|
161
|
+
.replace("[", "|")
|
|
162
|
+
.replace("]", "|")
|
|
163
|
+
.replace(",", "|")
|
|
164
|
+
.replace("'", "")
|
|
165
|
+
.replace('"', "")
|
|
166
|
+
)
|
|
167
|
+
parts = {
|
|
168
|
+
part.strip()
|
|
169
|
+
for part in cleaned.split("|")
|
|
170
|
+
if part.strip()
|
|
171
|
+
}
|
|
172
|
+
return all(
|
|
173
|
+
part in self._SAFE_TYPES
|
|
174
|
+
or part.startswith("typing.")
|
|
175
|
+
or part in {"True", "False"}
|
|
176
|
+
for part in parts
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _is_public_member(self, name: str) -> bool:
|
|
180
|
+
return (
|
|
181
|
+
not name.startswith("_")
|
|
182
|
+
or name in self._ALLOWED_DUNDERS
|
|
183
|
+
or name in self._ALLOWED_PRIVATE_MEMBERS
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _assignment_name(node: nodes.NodeNG) -> str | None:
|
|
188
|
+
if isinstance(node, nodes.AssignName):
|
|
189
|
+
return node.name
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _is_property(node: nodes.FunctionDef) -> bool:
|
|
194
|
+
for decorator in node.decorators.nodes if node.decorators is not None else ():
|
|
195
|
+
if isinstance(decorator, nodes.Name) and decorator.name == "property":
|
|
196
|
+
return True
|
|
197
|
+
if (
|
|
198
|
+
isinstance(decorator, nodes.Attribute)
|
|
199
|
+
and decorator.attrname == "cached_property"
|
|
200
|
+
):
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _method_decorators(node: nodes.FunctionDef) -> tuple[str, ...]:
|
|
206
|
+
decorators: list[str] = []
|
|
207
|
+
for decorator in node.decorators.nodes if node.decorators is not None else ():
|
|
208
|
+
if isinstance(decorator, nodes.Attribute):
|
|
209
|
+
name = decorator.as_string()
|
|
210
|
+
elif isinstance(decorator, nodes.Name):
|
|
211
|
+
name = decorator.name
|
|
212
|
+
else:
|
|
213
|
+
continue
|
|
214
|
+
if name in {"typing.overload", "overload"}:
|
|
215
|
+
decorators.append("@typing.overload")
|
|
216
|
+
return tuple(decorators)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from odoo_typegen.compiler.consolidated_model import ConsolidatedModel, StubClass, StubMethod
|
|
4
|
+
from odoo_typegen.compiler.core_stub_compiler import CoreStubCompiler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EnvironmentCompiler(CoreStubCompiler):
|
|
8
|
+
def __init__(self, odoo_path: Path | None = None):
|
|
9
|
+
self._odoo_path = odoo_path
|
|
10
|
+
|
|
11
|
+
def compile(self, models: tuple[ConsolidatedModel, ...]) -> StubClass:
|
|
12
|
+
stub_class = self._extract_stub_class(
|
|
13
|
+
source_file=self._source_file(),
|
|
14
|
+
class_name="Environment",
|
|
15
|
+
import_path="odoo.orm.environments",
|
|
16
|
+
imports=self._imports_for(models),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
methods = tuple(
|
|
20
|
+
method
|
|
21
|
+
for method in stub_class.methods
|
|
22
|
+
if method.name != "__getitem__"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return stub_class.model_copy(
|
|
26
|
+
update={
|
|
27
|
+
"methods": (
|
|
28
|
+
*methods,
|
|
29
|
+
*self._getitem_overloads(models),
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _source_file(self) -> Path:
|
|
35
|
+
if self._odoo_path is None:
|
|
36
|
+
return Path()
|
|
37
|
+
return self._odoo_path / "odoo/orm/environments.py"
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _imports_for(models: tuple[ConsolidatedModel, ...]) -> tuple[str, ...]:
|
|
41
|
+
imports = ["import typing"]
|
|
42
|
+
imports.extend(
|
|
43
|
+
f"from {model.stub_class.import_path} import {model.stub_class.class_name}"
|
|
44
|
+
for model in models
|
|
45
|
+
)
|
|
46
|
+
return tuple(imports)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _getitem_overloads(
|
|
50
|
+
models: tuple[ConsolidatedModel, ...],
|
|
51
|
+
) -> tuple[StubMethod, ...]:
|
|
52
|
+
overloads: list[StubMethod] = []
|
|
53
|
+
for model in models:
|
|
54
|
+
overloads.append(
|
|
55
|
+
StubMethod(
|
|
56
|
+
name="__getitem__",
|
|
57
|
+
decorators=("@typing.overload",),
|
|
58
|
+
signature=(
|
|
59
|
+
"def __getitem__("
|
|
60
|
+
"self, "
|
|
61
|
+
f"model_name: typing.Literal[\"{model.name}\"]"
|
|
62
|
+
f") -> {model.stub_class.class_name}"
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
overloads.append(
|
|
68
|
+
StubMethod(
|
|
69
|
+
name="__getitem__",
|
|
70
|
+
decorators=("@typing.overload",),
|
|
71
|
+
signature="def __getitem__(self, model_name: str) -> typing.Any",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return tuple(overloads)
|