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.
@@ -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)