xsdata-odoo 0.8.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 @@
1
+ __version__ = "0.8.0"
@@ -0,0 +1,23 @@
1
+ from typing import List
2
+
3
+ from xsdata.codegen.models import Class
4
+ from xsdata.codegen.resolver import DependenciesResolver
5
+
6
+
7
+ class OdooDependenciesResolver(DependenciesResolver):
8
+ def sorted_classes(self) -> List[Class]:
9
+ """Sorted: Enumerations 1st"""
10
+
11
+ result = []
12
+ for name in sorted(
13
+ self.class_list,
14
+ key=lambda x: (
15
+ self.class_map.get(x) is not None
16
+ and not self.class_map.get(x).is_enumeration
17
+ ),
18
+ ):
19
+ obj = self.class_map.get(name)
20
+ if obj is not None:
21
+ self.apply_aliases(obj)
22
+ result.append(obj)
23
+ return result
xsdata_odoo/filters.py ADDED
@@ -0,0 +1,536 @@
1
+ import os
2
+ import re
3
+ from collections import OrderedDict
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from jinja2 import Environment
7
+ from xsdata.codegen.models import Attr, Class
8
+ from xsdata.formats.dataclass.filters import Filters
9
+ from xsdata.logger import logger
10
+ from xsdata.models.config import GeneratorConfig, ObjectType
11
+ from xsdata.utils import namespaces
12
+
13
+ from .text_utils import extract_string_and_help, wrap_text
14
+
15
+ INTEGER_TYPES = ("integer", "positiveInteger")
16
+ FLOAT_TYPES = ("float", "decimal")
17
+ # Odoo has no Decimal field and all in all it's better to guess float by default
18
+ # and override if required.
19
+ # DECIMAL_TYPES = ("decimal",)
20
+ MONETARY_DIGITS = 2
21
+ CHAR_TYPES = (
22
+ "string",
23
+ "NMTOKEN",
24
+ "ID",
25
+ "IDREF",
26
+ "IDREFS",
27
+ "anyURI",
28
+ "base64Binary",
29
+ "normalizedString",
30
+ "language",
31
+ )
32
+ DATE_TYPES = ("date", "TData")
33
+ DATETIME_TYPES = ("dateTime", "TDateTimeUTC")
34
+ BOOLEAN_TYPES = "boolean"
35
+
36
+ # generally it's not interresting to generate mixins for signature
37
+ SIGNATURE_CLASS_SKIP = [
38
+ "^Signature$",
39
+ "^SignatureType$",
40
+ "^SignatureValueType$",
41
+ "^SignedInfoType$",
42
+ "^ReferenceType$",
43
+ "^DigestMethodType$",
44
+ "^TransformsType$",
45
+ "^TransformType$",
46
+ "^KeyInfoType$",
47
+ "^X509DataType$",
48
+ "^CanonicalizationMethodType$",
49
+ "^SignatureMethodType$",
50
+ ]
51
+
52
+
53
+ class OdooFilters(Filters):
54
+ __slots__ = (
55
+ "files_to_etree",
56
+ "all_simple_types",
57
+ "all_complex_types",
58
+ "registry_names",
59
+ "implicit_many2ones",
60
+ "schema",
61
+ "version",
62
+ "python_inherit_model",
63
+ "inherit_model",
64
+ "xsd_extra_info",
65
+ )
66
+
67
+ def __init__(
68
+ self,
69
+ config: GeneratorConfig,
70
+ all_simple_types: List[Class],
71
+ all_complex_types: List[Class],
72
+ registry_names: Dict,
73
+ implicit_many2ones: Dict,
74
+ schema: str = "spec",
75
+ version: str = "10",
76
+ python_inherit_model: str = "models.AbstractModel",
77
+ inherit_model=None,
78
+ ):
79
+ super().__init__(config)
80
+ self.all_simple_types = all_simple_types
81
+ self.all_complex_types = all_complex_types
82
+ self.registry_names = registry_names
83
+ self.implicit_many2ones = implicit_many2ones
84
+ self.files_to_etree: Dict[str, Any] = {}
85
+ self.schema = schema
86
+ self.version = version
87
+ self.python_inherit_model = python_inherit_model
88
+ if inherit_model is None:
89
+ inherit_model = f"spec.mixin.{schema}"
90
+ self.inherit_model = inherit_model
91
+ self.xsd_extra_info: Dict[str, Any] = {}
92
+
93
+ def register(self, env: Environment):
94
+ super().register(env)
95
+ env.filters.update(
96
+ {
97
+ "enum_skip": self.enum_skip,
98
+ "pattern_skip": self.pattern_skip,
99
+ "registry_name": self.registry_name,
100
+ "odoo_python_inherit_model": self.odoo_python_inherit_model,
101
+ "odoo_inherit_model": self.odoo_inherit_model,
102
+ "clean_docstring": self.clean_docstring,
103
+ "binding_type": self.binding_type,
104
+ "odoo_class_name": self.odoo_class_name,
105
+ "class_properties": self.class_properties,
106
+ "odoo_class_description": self.odoo_class_description,
107
+ "odoo_field_definition": self.odoo_field_definition,
108
+ "odoo_implicit_many2ones": self.odoo_implicit_many2ones,
109
+ "import_class": self.import_class,
110
+ "enum_docstring": self.enum_docstring,
111
+ }
112
+ )
113
+
114
+ def enum_skip(self, obj: Class, name: str) -> bool:
115
+ """
116
+ Avoids Postgres errors with fields.Selection
117
+ by disabling case insensitive duplicates.
118
+ For instance:
119
+ TENDEREMI_XPAIS = [
120
+ ("Brasil", "Brasil"),
121
+ # ("BRASIL", "BRASIL"),
122
+ ]
123
+ """
124
+ lower_vals = set()
125
+ allowed_vals = set()
126
+ for attr in obj.attrs:
127
+ if attr.default.lower() not in lower_vals:
128
+ lower_vals.add(attr.default.lower())
129
+ allowed_vals.add(attr.default)
130
+ if name in allowed_vals:
131
+ return False
132
+ else:
133
+ return True
134
+
135
+ def pattern_skip(self, name: str, parents: List[Class]) -> bool:
136
+ """Should class or field be skipped?"""
137
+ if parents is None:
138
+ parents = []
139
+ class_skip = SIGNATURE_CLASS_SKIP.copy()
140
+ if os.environ.get("XSDATA_SKIP"):
141
+ class_skip += os.environ["XSDATA_SKIP"].split("|")
142
+ for pattern in class_skip:
143
+ # do we have a simple match? (no scoping can be risky)
144
+ if re.search(pattern, name):
145
+ return True
146
+ part_count = pattern.count(".") + 1
147
+ if part_count > 1:
148
+ # eventually we search for the class with its parents scope
149
+ parent_pattern = ".".join(
150
+ [namespaces.local_name(c.qname) for c in parents[-part_count:]]
151
+ )
152
+ if re.search(pattern, parent_pattern):
153
+ return True
154
+ # we now search for the field_name with its parents scope
155
+ field_parent_pattern = ".".join(
156
+ [namespaces.local_name(c.qname) for c in parents[-part_count + 1 :]]
157
+ + [name]
158
+ )
159
+ if re.search(pattern, field_parent_pattern):
160
+ return True
161
+
162
+ return False
163
+
164
+ def odoo_class_description(self, obj: Class) -> str:
165
+ if obj.help:
166
+ return """textwrap.dedent(" %s" % (__doc__,))"""
167
+ else:
168
+ # TODO some inner classes have no obj.help
169
+ # but we can read the XML annotations. Ex: "TretEnviNfe.InfRec", "Tnfe.InfNfe"...
170
+ return f'"{obj.name}"'
171
+
172
+ def enum_docstring(self, obj: Class) -> str:
173
+ """Works well for Brazilian fiscal xsd, may need adaptations for your
174
+ xsd."""
175
+ # see https://github.com/akretion/generateds-odoo/blob/465539b46e4216a5b94f1b0dabf39b34e7f4624c/gends_extract_simple_types.py#L385
176
+ # for possible improvement
177
+
178
+ if "_" in obj.name:
179
+ type_qname = obj.qname.split("_")[0]
180
+ field_name = obj.name.split("_")[1]
181
+
182
+ for klass in self.all_complex_types:
183
+ if klass.qname == type_qname:
184
+ for idx, item in enumerate(obj.attrs):
185
+ for field in klass.attrs:
186
+ if field.name == field_name and field.help:
187
+ item_help = False
188
+ separators = (" - ", "-", " – ", "–")
189
+ for separator in separators:
190
+ split = field.help.split(
191
+ f"{item.default}{separator}"
192
+ )
193
+ if len(split) > 1:
194
+ # TODO sometimes the line may continue
195
+ # until the next value or may end at next value...
196
+ item_help = (
197
+ split[1].splitlines()[0].split(";")[0]
198
+ )
199
+ break
200
+ if item_help:
201
+ item.help = item_help
202
+ if idx == 0 and len(split) > 1:
203
+ obj.help, help_trash = extract_string_and_help(
204
+ obj.name,
205
+ field.name,
206
+ split[0],
207
+ set(),
208
+ 1024,
209
+ )
210
+ else:
211
+ item.help = item.default
212
+ if (
213
+ idx == 0
214
+ and not obj.help
215
+ and not field.help.startswith(item.default)
216
+ ):
217
+ obj.help = (
218
+ field.help
219
+ ) # no split but better than no docstring
220
+
221
+ return self.clean_docstring(obj.help)
222
+
223
+ def odoo_class_name(self, obj: Class, parents: List[Class] = []):
224
+ """
225
+ Same as class_name with the --unnest-classes option but without the
226
+ option side effects.
227
+ """
228
+ full_name = ".".join([self.class_name(c.name) for c in parents])
229
+ return self.registry_names[full_name].replace(".", "")
230
+
231
+ def registry_name(
232
+ self,
233
+ name: str = "",
234
+ parents: List[Class] = [],
235
+ type_names: List[str] = [],
236
+ ) -> str:
237
+ if parents:
238
+ full_name = ".".join([self.class_name(c.name) for c in parents])
239
+ else:
240
+ if type_names:
241
+ full_name = ".".join(type_names)
242
+ else:
243
+ full_name = self.class_name(name)
244
+ # NOTE we cannot use the class ref as a key because only type names
245
+ # are provided by xsdata for fields
246
+ unique_name = self.registry_names[full_name].replace(".", "_")
247
+ return f"{self.schema}.{self.version}.{unique_name.lower()}"
248
+
249
+ def odoo_inherit_model(self, obj: Class) -> str:
250
+ return self.inherit_model
251
+
252
+ def odoo_python_inherit_model(self, obj: Class) -> str:
253
+ return self.python_inherit_model
254
+
255
+ def registry_comodel(self, type_name: str):
256
+ # NOTE: we take only the last part of inner Types with .split(".")[-1]
257
+ # but if that were to create Type duplicates we could change that.
258
+ clean_type_names = type_name.replace('"', "").split(".")
259
+ return self.registry_name(clean_type_names[-1], type_names=clean_type_names)
260
+
261
+ def clean_docstring(self, string: Optional[str], escape: bool = True) -> str:
262
+ """Prepare string for docstring generation."""
263
+ if not string:
264
+ return "" # TODO read from parent field if any
265
+ return "\n {}".format(wrap_text(string, 4, 79))
266
+
267
+ def class_properties(
268
+ self,
269
+ obj: Class,
270
+ parents: List[Class],
271
+ ) -> str:
272
+ """Return the name of the xsdata class for a given Odoo model."""
273
+ if os.environ.get("XSDATA_GENDS"):
274
+ return (
275
+ f'_binding_type = "{self.binding_type(obj, parents)}"\n'
276
+ f' _generateds_type = "{self.generateds_type(obj, parents)}"'
277
+ )
278
+ else:
279
+ return f'_binding_type = "{self.binding_type(obj, parents)}"\n'
280
+
281
+ def binding_type(
282
+ self,
283
+ obj: Class,
284
+ parents: List[Class],
285
+ ) -> str:
286
+ """Return the name of the xsdata class for a given Odoo model."""
287
+ return ".".join([self.class_name(p.name) for p in parents])
288
+
289
+ def generateds_type(
290
+ self,
291
+ obj: Class,
292
+ parents: List[Class],
293
+ ) -> str:
294
+ """
295
+ DEPRECATED!
296
+ Return the name of the GenerateDS class for a given Odoo model.
297
+ This is for backward compatibility: it allows to use Odoo mixins
298
+ generated with xsdata along with legacy GenerateDS Python bindings.
299
+ """
300
+ if len(parents) > 1:
301
+ return obj.qname.split("}")[1] + "Type"
302
+ else:
303
+ return obj.qname.split("}")[1]
304
+
305
+ def odoo_implicit_many2ones(self, obj: Class, parents: List[Class]) -> str:
306
+ """The m2o fields for the o2m keys."""
307
+ fields = []
308
+ implicit_many2ones = self.implicit_many2ones.get(
309
+ self.binding_type(obj, parents).lower(),
310
+ [],
311
+ # NOTE: strangely lower is required (Brazilian CTe)
312
+ )
313
+ for implicit_many2one_data in implicit_many2ones:
314
+ kwargs = OrderedDict()
315
+ kwargs["comodel_name"] = implicit_many2one_data[0]
316
+ kwargs["xsd_implicit"] = True
317
+ # kwargs["required"] = True # FIXME seems it creates ORM issues
318
+ kwargs["ondelete"] = "cascade"
319
+ target_field = implicit_many2one_data[1]
320
+ fields.append(
321
+ f"{target_field} = fields.Many2one({self.format_arguments(kwargs, 4)})"
322
+ )
323
+ return ("\n").join(fields)
324
+
325
+ def field_name(self, name: str, class_name: str) -> str:
326
+ """Like xsdata but you can enforce the prefix or safe_name
327
+ conversion."""
328
+ prefix = self.field_safe_prefix
329
+ name = self.apply_substitutions(name, ObjectType.FIELD)
330
+
331
+ if self.field_safe_prefix in ("", "value"): # do like standard xsdata
332
+ name = self.safe_name(name, prefix, self.field_case, class_name=class_name)
333
+ if self.field_safe_prefix == "NO_PREFIX_NO_SAFE_NAME": # keep my field name!
334
+ pass
335
+ elif self.field_safe_prefix.endswith("SAFE_NAME"): # prefix + python field name
336
+ prefix = prefix.split("SAFE_NAME")[0]
337
+ name = f"{prefix}{name}"
338
+ name = self.safe_name(name, prefix, self.field_case, class_name=class_name)
339
+ else: # prefix + keep my field name
340
+ name = f"{prefix}{name}"
341
+
342
+ return self.apply_substitutions(name, ObjectType.FIELD)
343
+
344
+ def odoo_field_definition(
345
+ self,
346
+ attr: Attr,
347
+ parents: List[Class],
348
+ ) -> str:
349
+ """Return the Odoo field definition."""
350
+
351
+ # 1st some checks inspired from xsdata Filters:
352
+ obj = parents[-1]
353
+ type_names = self._field_type_names(obj, attr)
354
+ # collections.unique_sequence(
355
+ # self._field_type_name(x, [p.name for p in parents]) for x in attr.types
356
+ # )
357
+ if len(type_names) > 1:
358
+ logger.warning(
359
+ f"len(type_names) > 1 (Union) not implemented yet! class: {obj.name} attr: {attr}"
360
+ )
361
+
362
+ if attr.is_tokens:
363
+ logger.warning(
364
+ f"attr.is_tokens not implemented yet! class: {obj.name} attr: {attr}"
365
+ )
366
+
367
+ if attr.is_dict:
368
+ logger.warning(
369
+ f"attr.is_dict not implemented yet! class: {obj.name} attr: {attr}"
370
+ )
371
+
372
+ # default_value = self.field_default_value(attr, {})
373
+
374
+ kwargs = self._extract_field_attributes(parents, attr)
375
+
376
+ if attr.types[0].datatype: # simple field
377
+ return self._simple_field_definition(obj, attr, type_names, kwargs)
378
+
379
+ else: # relational field
380
+ field = self._try_one2many_field_definition(obj, attr, type_names, kwargs)
381
+ if field is None:
382
+ field = self._try_selection_field_definition(
383
+ obj, attr, type_names, kwargs
384
+ )
385
+ if field is None:
386
+ field = self._try_many2one_field_definition(
387
+ obj, attr, type_names, kwargs
388
+ )
389
+
390
+ if field is not None:
391
+ return field
392
+
393
+ message = f"Missing class {attr.types[0]}! class: {obj.name} attr: {attr}"
394
+ logger.warning(message)
395
+ return message
396
+
397
+ def _extract_field_attributes(
398
+ self, parents: List[Class], attr: Attr
399
+ ) -> OrderedDict[str, Any]:
400
+ obj = parents[-1]
401
+ kwargs: OrderedDict[str, Any] = OrderedDict()
402
+ if not hasattr(obj, "unique_labels"):
403
+ obj.unique_labels = set() # will avoid repeating field labels
404
+ string, help_attr = extract_string_and_help(
405
+ obj.name, attr.name, attr.help, obj.unique_labels
406
+ )
407
+ kwargs["string"] = string
408
+
409
+ metadata = self.field_metadata(obj, attr, None)
410
+ if metadata.get("required"):
411
+ # we choose not to put required=True (required in database) to avoid
412
+ # messing with existing Odoo modules.
413
+ kwargs["xsd_required"] = True
414
+
415
+ kwargs.update(self.xsd_extra_info.get(f"{obj.name}#{attr.name}", {}))
416
+ if help_attr and not kwargs.get("help"):
417
+ kwargs["help"] = help_attr # (help as the last attribute)
418
+
419
+ return kwargs
420
+
421
+ def _extract_number_attrs(
422
+ self, obj: Class, attr: Attr, kwargs: OrderedDict[str, Any]
423
+ ):
424
+ """
425
+ Monetary vs Float field detection.
426
+
427
+ Detection tends to be brittle but in general it doesn't impact
428
+ XML serialization/desrialization as both types are floats and we
429
+ usually take the xsd_type into account.
430
+
431
+ You can somewhat customize the default behavior with ENV VARs:
432
+ XSDATA_MONETARY_TYPE: xsd type to force fields.Monetary
433
+ XSDATA_NUM_TYPE: xsd type for fields.Float or fields.Monetary eventually
434
+ you can use a prefix followed by brackets to indicate where to take the
435
+ floor part and the decimal part like Prefix[in_start:int_stop.dec_start:dec_stop]
436
+ """
437
+ python_type = attr.types[0].datatype.code
438
+ if python_type in FLOAT_TYPES or python_type in CHAR_TYPES:
439
+ xsd_type = kwargs.get("xsd_type", "")
440
+
441
+ monetary_type = os.environ.get("XSDATA_MONETARY_TYPE")
442
+
443
+ num_type_complete = os.environ.get("XSDATA_NUM_TYPE", "TDec_[5:7.7:9]")
444
+ if "[" in num_type_complete:
445
+ int_part = num_type_complete.split("[")[1].split(".")[0]
446
+ int_start = int(int_part.split(":")[0])
447
+ int_stop = int(int_part.split(":")[1])
448
+ dec_part = num_type_complete.split(".")[1].split("]")[0]
449
+ dec_start = int(dec_part.split(":")[0])
450
+ dec_stop = int(dec_part.split(":")[1])
451
+ num_type = num_type_complete.split("[")[0]
452
+ conditional_monetary = True
453
+ else:
454
+ num_type = num_type_complete
455
+ conditional_monetary = False
456
+
457
+ if monetary_type and xsd_type.startswith(monetary_type):
458
+ kwargs["currency_field"] = "brl_currency_id" # TODO use spec_curreny_id
459
+ elif xsd_type.startswith(num_type):
460
+ if conditional_monetary:
461
+ if int(
462
+ xsd_type.replace("03v", "03")[dec_start:dec_stop]
463
+ ) != MONETARY_DIGITS or (
464
+ # for Brazilian edocs, pSomething means percentualSomething ->Float
465
+ attr.name[0] == "p" and attr.name[1].isupper()
466
+ ):
467
+ kwargs["digits"] = (
468
+ int(xsd_type.replace("03v", "03")[int_start:int_stop]),
469
+ int(xsd_type.replace("03v", "03")[dec_start:dec_stop]),
470
+ )
471
+ else:
472
+ kwargs["currency_field"] = (
473
+ "brl_currency_id" # TODO use spec_curreny_id
474
+ )
475
+ else:
476
+ kwargs["digits"] = (16, 4)
477
+
478
+ def _simple_field_definition(
479
+ self, obj: Class, attr: Attr, type_names: str, kwargs: OrderedDict
480
+ ):
481
+ self._extract_number_attrs(obj, attr, kwargs)
482
+ if kwargs.get("help"):
483
+ kwargs.move_to_end("help", last=True)
484
+ python_type = attr.types[0].datatype.code
485
+ if python_type in DATE_TYPES or kwargs.get("xsd_type") in DATE_TYPES:
486
+ return f"fields.Date({self.format_arguments(kwargs, 4)})"
487
+ elif python_type in DATETIME_TYPES or kwargs.get("xsd_type") in DATETIME_TYPES:
488
+ return f"fields.Datetime({self.format_arguments(kwargs, 4)})"
489
+ elif python_type in INTEGER_TYPES:
490
+ return f"fields.Integer({self.format_arguments(kwargs, 4)})"
491
+ if kwargs.get("currency_field"):
492
+ return f"fields.Monetary({self.format_arguments(kwargs, 4)})"
493
+ elif python_type in FLOAT_TYPES or kwargs.get("digits"):
494
+ return f"fields.Float({self.format_arguments(kwargs, 4)})"
495
+ elif python_type in CHAR_TYPES:
496
+ return f"fields.Char({self.format_arguments(kwargs, 4)})"
497
+ elif python_type in BOOLEAN_TYPES:
498
+ return f"fields.Boolean({self.format_arguments(kwargs, 4)})"
499
+ else:
500
+ logger.warning(
501
+ f"{python_type} {attr.types[0].datatype} not implemented yet! class: {obj.name} attr: {attr}"
502
+ )
503
+ return ""
504
+
505
+ def _try_one2many_field_definition(
506
+ self, obj: Class, attr: Attr, type_names: str, kwargs: OrderedDict
507
+ ):
508
+ if attr.is_list:
509
+ comodel_key = self.field_name(f"{attr.name}_{obj.name}_id", obj.name)
510
+ return f"""fields.One2many("{self.registry_comodel(type_names)}", "{comodel_key}",{self.format_arguments(kwargs, 4)})"""
511
+
512
+ def _try_selection_field_definition(
513
+ self, obj: Class, attr: Attr, type_names: str, kwargs: OrderedDict
514
+ ):
515
+ for klass in self.all_simple_types:
516
+ if attr.types[0].qname == klass.qname:
517
+ return f"fields.Selection({klass.name.upper()},{self.format_arguments(kwargs, 4)})"
518
+
519
+ def _try_many2one_field_definition(
520
+ self, obj: Class, attr: Attr, type_names: str, kwargs: OrderedDict
521
+ ):
522
+ for klass in self.all_complex_types:
523
+ if attr.types[0].qname == klass.qname:
524
+ # Many2one
525
+ kwargs["comodel_name"] = self.registry_comodel(type_names)
526
+ kwargs.move_to_end("comodel_name", last=False)
527
+ return f"fields.Many2one({self.format_arguments(kwargs, 4)})"
528
+
529
+ def import_class(self, name: str, alias: Optional[str]) -> str:
530
+ """Convert import class name with alias support."""
531
+ if alias:
532
+ return f"{self.class_name(name)} as {self.class_name(alias)}"
533
+ if name in [klass.name for klass in self.all_simple_types]:
534
+ return name.upper() # const are upcase in Odoo
535
+ else:
536
+ return self.class_name(name)