Coverage for adhoc-cicd-odoo-odoo / odoo / tools / view_validation.py: 79%
189 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:22 +0000
1""" View validation code (using assertions, not the RNG schema). """
3import ast
4import collections
5import logging
6import os
7import re
9import odoo.orm.domains as domains
10from lxml import etree
11from odoo import tools
13_logger = logging.getLogger(__name__)
16_validators = collections.defaultdict(list)
17_relaxng_cache = {}
19READONLY = re.compile(r"\breadonly\b")
21# predefined symbols for evaluating attributes (invisible, readonly...)
22IGNORED_IN_EXPRESSION = {
23 'True', 'False', 'None', # those are identifiers in Python 2.7
24 'self',
25 'uid',
26 'context',
27 'context_today',
28 'allowed_company_ids',
29 'current_company_id',
30 'time',
31 'datetime',
32 'relativedelta',
33 'current_date',
34 'today',
35 'now',
36 'abs',
37 'len',
38 'bool',
39 'float',
40 'str',
41 'unicode',
42 'set',
43}
44DOMAIN_OPERATORS = {
45 domains.DomainNot.OPERATOR,
46 domains.DomainAnd.OPERATOR,
47 domains.DomainOr.OPERATOR,
48}
51def get_domain_value_names(domain):
52 """ Return all field name used by this domain
53 eg: [
54 ('id', 'in', [1, 2, 3]),
55 ('field_a', 'in', parent.truc),
56 ('field_b', 'in', context.get('b')),
57 (1, '=', 1),
58 bool(context.get('c')),
59 ]
60 returns {'id', 'field_a', 'field_b'}, {'parent', 'parent.truc', 'context'}
62 :param domain: list(tuple) or str
63 :return: set(str), set(str)
64 """
65 contextual_values = set()
66 field_names = set()
68 try:
69 if isinstance(domain, list): 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 for leaf in domain:
71 if leaf in DOMAIN_OPERATORS or leaf in (True, False):
72 # "&", "|", "!", True, False
73 continue
74 left, _operator, _right = leaf
75 if isinstance(left, str):
76 field_names.add(left)
77 elif left not in (1, 0):
78 # deprecate: True leaf and False leaf
79 raise ValueError()
81 elif isinstance(domain, str): 81 ↛ 142line 81 didn't jump to line 142 because the condition on line 81 was always true
82 def extract_from_domain(ast_domain):
83 if isinstance(ast_domain, ast.IfExp):
84 # [] if condition else []
85 extract_from_domain(ast_domain.body)
86 extract_from_domain(ast_domain.orelse)
87 return
88 if isinstance(ast_domain, ast.BoolOp):
89 # condition and []
90 # this formating don't check returned domain syntax
91 for value in ast_domain.values:
92 if isinstance(value, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)):
93 extract_from_domain(value)
94 else:
95 contextual_values.update(_get_expression_contextual_values(value))
96 return
97 if isinstance(ast_domain, ast.BinOp):
98 # [] + []
99 # this formating don't check returned domain syntax
100 if isinstance(ast_domain.left, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)): 100 ↛ 103line 100 didn't jump to line 103 because the condition on line 100 was always true
101 extract_from_domain(ast_domain.left)
102 else:
103 contextual_values.update(_get_expression_contextual_values(ast_domain.left))
105 if isinstance(ast_domain.right, (ast.List, ast.IfExp, ast.BoolOp, ast.BinOp)): 105 ↛ 108line 105 didn't jump to line 108 because the condition on line 105 was always true
106 extract_from_domain(ast_domain.right)
107 else:
108 contextual_values.update(_get_expression_contextual_values(ast_domain.right))
109 return
110 for ast_item in ast_domain.elts:
111 if isinstance(ast_item, ast.Constant):
112 # "&", "|", "!", True, False
113 if ast_item.value not in DOMAIN_OPERATORS and ast_item.value not in (True, False): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 raise ValueError()
115 elif isinstance(ast_item, (ast.List, ast.Tuple)): 115 ↛ 129line 115 didn't jump to line 129 because the condition on line 115 was always true
116 left, _operator, right = ast_item.elts
117 contextual_values.update(_get_expression_contextual_values(right))
118 if isinstance(left, ast.Constant) and isinstance(left.value, str):
119 field_names.add(left.value)
120 elif isinstance(left, ast.Constant) and left.value in (1, 0): 120 ↛ 123line 120 didn't jump to line 123 because the condition on line 120 was always true
121 # deprecate: True leaf (1, '=', 1) and False leaf (0, '=', 1)
122 pass
123 elif isinstance(right, ast.Constant) and right.value == 1:
124 # deprecate: True/False leaf (py expression, '=', 1)
125 contextual_values.update(_get_expression_contextual_values(left))
126 else:
127 raise ValueError()
128 else:
129 raise ValueError()
131 expr = domain.strip()
132 item_ast = ast.parse(f"({expr})", mode='eval').body
133 if isinstance(item_ast, ast.Name):
134 # domain="other_field_domain"
135 contextual_values.update(_get_expression_contextual_values(item_ast))
136 else:
137 extract_from_domain(item_ast)
139 except ValueError:
140 raise ValueError("Wrong domain formatting.") from None
142 value_names = set()
143 for name in contextual_values:
144 if name == 'parent': 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 continue
146 root = name.split('.')[0]
147 if root not in IGNORED_IN_EXPRESSION:
148 value_names.add(name if root == 'parent' else root)
149 return field_names, value_names
152def _get_expression_contextual_values(item_ast):
153 """ Return all contextual value this ast
155 eg: ast from '''(
156 id in [1, 2, 3]
157 and field_a in parent.truc
158 and field_b in context.get('b')
159 or (
160 True
161 and bool(context.get('c'))
162 )
163 )
164 returns {'parent', 'parent.truc', 'context', 'bool'}
166 :param item_ast: ast
167 :return: set(str)
168 """
170 if isinstance(item_ast, ast.Constant):
171 return set()
172 if isinstance(item_ast, (ast.List, ast.Tuple)):
173 values = set()
174 for item in item_ast.elts:
175 values |= _get_expression_contextual_values(item)
176 return values
177 if isinstance(item_ast, ast.Name):
178 return {item_ast.id}
179 if isinstance(item_ast, ast.Attribute):
180 values = _get_expression_contextual_values(item_ast.value)
181 if len(values) == 1:
182 path = sorted(list(values)).pop()
183 values = {f"{path}.{item_ast.attr}"}
184 return values
185 return values
186 if isinstance(item_ast, ast.Index): # deprecated python ast class for Subscript key 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 return _get_expression_contextual_values(item_ast.value)
188 if isinstance(item_ast, ast.Subscript):
189 values = _get_expression_contextual_values(item_ast.value)
190 values |= _get_expression_contextual_values(item_ast.slice)
191 return values
192 if isinstance(item_ast, ast.Compare):
193 values = _get_expression_contextual_values(item_ast.left)
194 for sub_ast in item_ast.comparators:
195 values |= _get_expression_contextual_values(sub_ast)
196 return values
197 if isinstance(item_ast, ast.BinOp):
198 values = _get_expression_contextual_values(item_ast.left)
199 values |= _get_expression_contextual_values(item_ast.right)
200 return values
201 if isinstance(item_ast, ast.BoolOp):
202 values = set()
203 for ast_value in item_ast.values:
204 values |= _get_expression_contextual_values(ast_value)
205 return values
206 if isinstance(item_ast, ast.UnaryOp):
207 return _get_expression_contextual_values(item_ast.operand)
208 if isinstance(item_ast, ast.Call):
209 values = _get_expression_contextual_values(item_ast.func)
210 for ast_arg in item_ast.args:
211 values |= _get_expression_contextual_values(ast_arg)
212 return values
213 if isinstance(item_ast, ast.IfExp):
214 values = _get_expression_contextual_values(item_ast.test)
215 values |= _get_expression_contextual_values(item_ast.body)
216 values |= _get_expression_contextual_values(item_ast.orelse)
217 return values
218 if isinstance(item_ast, ast.Dict): 218 ↛ 226line 218 didn't jump to line 226 because the condition on line 218 was always true
219 values = set()
220 for item in item_ast.keys:
221 values |= _get_expression_contextual_values(item)
222 for item in item_ast.values:
223 values |= _get_expression_contextual_values(item)
224 return values
226 raise ValueError(f"Undefined item {item_ast!r}.")
229def get_expression_field_names(expression):
230 """ Return all field name used by this expression
232 eg: expression = '''(
233 id in [1, 2, 3]
234 and field_a in parent.truc.id
235 and field_b in context.get('b')
236 or (True and bool(context.get('c')))
237 )
238 returns {'parent', 'parent.truc', 'parent.truc.id', 'context', 'context.get'}
240 :param expression: str
241 :param ignored: set contains the value name to ignore.
242 Add '.' to ignore attributes (eg: {'parent.'} will
243 ignore 'parent.truc' and 'parent.truc.id')
244 :return: set(str)
245 """
246 if not expression: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 return set()
248 item_ast = ast.parse(expression.strip(), mode='eval').body
249 contextual_values = _get_expression_contextual_values(item_ast)
251 value_names = set()
252 for name in contextual_values:
253 if name == 'parent': 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 continue
255 root = name.split('.')[0]
256 if root not in IGNORED_IN_EXPRESSION:
257 value_names.add(name if root == 'parent' else root)
259 return value_names
262def get_dict_asts(expr):
263 """ Check that the given string or AST node represents a dict expression
264 where all keys are string literals, and return it as a dict mapping string
265 keys to the AST of values.
266 """
267 if isinstance(expr, str): 267 ↛ 270line 267 didn't jump to line 270 because the condition on line 267 was always true
268 expr = ast.parse(expr.strip(), mode='eval').body
270 if not isinstance(expr, ast.Dict): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 raise ValueError("Non-dict expression")
272 if not all((isinstance(key, ast.Constant) and isinstance(key.value, str)) for key in expr.keys): 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 raise ValueError("Non-string literal dict key")
274 return {key.value: val for key, val in zip(expr.keys, expr.values)}
277def _check(condition, explanation):
278 if not condition:
279 raise ValueError("Expression is not a valid domain: %s" % explanation)
282def valid_view(arch, **kwargs):
283 for pred in _validators[arch.tag]:
284 check = pred(arch, **kwargs)
285 if not check: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true
286 _logger.warning("Invalid XML: %s", pred.__doc__)
287 return False
288 return True
291def validate(*view_types):
292 """ Registers a view-validation function for the specific view types
293 """
294 def decorator(fn):
295 for arch in view_types:
296 _validators[arch].append(fn)
297 return fn
298 return decorator
301def relaxng(view_type):
302 """ Return a validator for the given view type, or None. """
303 if view_type not in _relaxng_cache:
304 with tools.file_open(os.path.join('base', 'rng', '%s_view.rng' % view_type)) as frng:
305 try:
306 relaxng_doc = etree.parse(frng)
307 _relaxng_cache[view_type] = etree.RelaxNG(relaxng_doc)
308 except Exception:
309 _logger.exception('Failed to load RelaxNG XML schema for views validation')
310 _relaxng_cache[view_type] = None
311 return _relaxng_cache[view_type]
314@validate('calendar', 'graph', 'pivot', 'search', 'list', 'activity')
315def schema_valid(arch, **kwargs):
316 """ Get RNG validator and validate RNG file."""
317 validator = relaxng(arch.tag)
318 if validator and not validator.validate(arch): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 for error in validator.error_log:
320 _logger.warning("%s", error)
321 return False
322 return True