def on_xml_record(self, xml_record): model = xml_record.record_node.get("model") if model != "ir.ui.view": return arch = get_view_arch(xml_record.record_node) if arch is None: return for attr, version in (("string", 8), ("colors", 9), ("fonts", 9)): if xml_record.addon.version < version: continue for search in arch.xpath(f".//tree[@{attr}]"): yield Issue( "deprecated_tree_attribute", f"`<tree>` `{attr}` attribute is deprecated since version " f"{version}.0", xml_record.addon.manifest_path, [Location(xml_record.path, [search.sourceline])], categories=["deprecated"], ) for xpath in arch.xpath('.//xpath[@position="attributes"]'): nodename = get_xpath_expr_target_element(xpath.get("expr")) if nodename != "tree": continue for attr_el in xpath.xpath(f'.//attribute[@name="{attr}"]'): yield Issue( "deprecated_tree_attribute", f"`<tree>` `{attr}` attribute is deprecated since version " f"{version}.0", xml_record.addon.manifest_path, [Location(xml_record.path, [attr_el.sourceline])], categories=["deprecated"], )
def on_python_module(self, python_module): for node in walk(python_module.module): if node.type != "decorator": continue name_parts = extract_func_name(node) if (len(name_parts) == 1 and name_parts[0] != "route") or ( len(name_parts) == 2 and (name_parts[0] != "http" or name_parts[1] != "route")): continue kwargs = { c.children[0].value: ( c.children[0].line, column_index_1(c.children[0].start_pos), ) for c in walk(node) if c.type == "argument" } for kw in kwargs.keys() - ROUTE_KWARGS[ python_module.addon.version]: yield Issue( "unknown_route_kwarg", f'Unknown `http.route()` keyword argument "{kw}"', python_module.addon.manifest_path, [Location(python_module.path, [kwargs[kw][0]])], categories=["correctness"], )
def on_xml_record(self, xml_record): record = xml_record.record_node record_id = record.get("id") if ( not record_id or xml_record.noupdate or xml_record.path not in xml_record.addon.data_files ): return addon_name, _ = split_external_id(record_id) if addon_name and addon_name != xml_record.addon.name: return model = record.attrib["model"] if model in MODELS: yield Issue( "expected_noupdate_flag", f'`{model}` model records should be declared in a `noupdate="1"` ' f"XML data element to allow user modifications", xml_record.addon.manifest_path, [ Location( xml_record.path, [record.sourceline, record.getparent().sourceline], ) ], categories=["correctness"], )
def _extract_xml_record(self, addon, filename, tree): # <record> operation. for record in tree.xpath("//record"): record_model, record_id = record.attrib["model"], record.get("id") if record_model: yield _model_ref(addon, filename, record.sourceline, record_model) if record_id: yield _ref_or_def(addon, filename, record.sourceline, record_id, record_model) # <field> operation. for field in record.iterchildren(tag="field"): field_name = field.attrib["name"] field_external_id = _field_record_id(record_model, field_name) yield ExternalIDReference( addon, UNKNOWN, field_external_id, "ir.model.fields", Location(filename, field.sourceline), ) ref = field.get("ref") if ref: ref_model = KNOWN_FIELD_MODELS.get(record_model, {}).get( field_name, UNKNOWN) yield _ref(addon, filename, field.sourceline, ref, ref_model) for attr_name in ("eval", "search"): yield from self._get_ref_from_eval(addon, filename, field.sourceline, field.get(attr_name)) # View-specific tags. if record_model != "ir.ui.view": continue arch = get_view_arch(record) if arch is None: continue for button in arch.xpath(".//button[@type='action' and @name]"): button_name = button.get("name") if button_name: yield _ref( addon, filename, button.sourceline, remove_old_style_format(button_name), "ir.ui.view", ) for el in arch.xpath(".//*[@groups]"): groups = el.get("groups") for group in split_groups(groups): yield _ref(addon, filename, el.sourceline, group, "res.groups")
def on_addon(self, addon): extensions = { f".{ext}" for ext in lookup_version_list(EXT_VERSION_MAP, addon.version) } combined_data_files = { *addon.data_files, *addon.demo_files, *addon.qweb_files } exclude_dirs = { addon.path / dir for dir in ("tests", "static/src/xml") } for file_path in list_files(addon.path, list_dirs=False, exclude_dirs=exclude_dirs): if file_path.suffix not in extensions: continue if file_path not in combined_data_files: yield Issue( "data_file_missing_in_manifest", "Data file is not included in `demo` or `data` " "sections in the manifest file", addon.manifest_path, [Location(file_path)], categories=["correctness"], )
def _ref_or_def(addon, filename, position, external_id, model) -> typing.Union[ExternalID, ExternalIDReference]: addon_name, record_id = split_external_id(external_id) cls_ = ExternalIDReference if not addon_name or addon_name == addon.name: cls_ = ExternalID return cls_(addon, addon_name, record_id, model, Location(filename, [position]))
def on_xml_record(self, xml_record): record = xml_record.record_node if record.attrib["model"] != "ir.ui.view": return view_xml_id = record.get("id") # Skip inherited views. inherit_id = record.xpath("./field[@name='inherit_id']") if inherit_id and inherit_id[0].attrib.get("ref"): return # Skip `arch` override in an extending addon. if view_xml_id: addon_name, _ = split_external_id(view_xml_id) if addon_name and addon_name != xml_record.addon.name: return arch = get_view_arch(record) if arch is None: _LOG.warning( "`ir.ui.view` record has no `arch` field " "in file: %s at line %d", xml_record.path, record.sourceline, ) return children = [ c for c in arch.getchildren() if c.tag is not etree.Comment ] if len(children) != 1: _LOG.warning( "Unexpected number of children in `ir.ui.view` " "`arch` in file: %s at line %d", xml_record.path, arch.sourceline, ) return fields = collections.defaultdict(list) for path, line_no in find_fields(arch, ()): fields[path].append(line_no) for path, line_nos in fields.items(): if len(line_nos) > 1: field_name = path.split("/")[-1] yield Issue( "duplicate_view_field", f'"{view_xml_id}" `ir.ui.view` has duplicate field ' f'"{field_name}"' if view_xml_id else (f'`ir.ui.view` has duplicate field "{field_name}"'), xml_record.addon.manifest_path, [ Location(xml_record.path, [line_no]) for line_no in line_nos ], categories=["correctness"], )
def on_field_definition(self, field): addon, path = field.model.addon, field.model.path known_fields = FIELD_TYPE_VERSION_MAP.get(addon.version, set()) get_odoo_string = get_odoo_string_compute_func(addon.version) sources = ([ODOO_SOURCE_URL_VERSION_MAP[addon.version]] if addon.version in ODOO_SOURCE_URL_VERSION_MAP else []) if field.class_name not in known_fields: _LOG.warning("Unknown field type: %s", field.class_name) return string_kwarg = None for kwarg in field.kwargs: if kwarg.name == "string": string_kwarg = kwarg break if string_kwarg and string_kwarg.value == get_odoo_string(field.name): yield Issue( "redundant_field_attribute", f'Redundant field attribute `string="{string_kwarg.value}"` ' f'for field "{field.name}". The same value will be computed ' f"by Odoo automatically.", addon.manifest_path, [Location(path, [column_index_1(string_kwarg.start_pos)])], categories=["redundancy"], sources=sources, ) return if field.args: arg_index = FIELD_TYPE_STRING_INDEX_MAP.get(field.class_name, 0) if len(field.args) < arg_index + 1: return string_arg = field.args[arg_index] if string_arg.value == get_odoo_string(field.name): yield Issue( "redundant_field_attribute", f"Redundant implied field attribute `string` " f'"{string_arg.value}"` for field "{field.name}". ' f"The same value will be computed by Odoo automatically.", addon.manifest_path, [Location(path, [column_index_1(string_arg.start_pos)])], categories=["redundancy"], sources=sources, )
def _get_issue_from_element(self, addon, filename, element, attr_names): yield Issue( "search_view_element_takes_no_attributes", f"`<search>` view element takes no attributes, " f"has: {', '.join(attr_names)}", addon.manifest_path, [Location(filename, [element.sourceline])], categories=["maintainability"], )
def on_xml_tree(self, xml_tree): for el in xml_tree.tree_node.xpath(XPATH_EXPR): yield Issue( "xml_operation_without_id", f"XML operation `<{el.tag}>` has no `id` attribute", xml_tree.addon.manifest_path, [Location(xml_tree.path, [el.sourceline])], categories=["maintainability"], )
def on_xml_record(self, xml_record): if xml_record.record_node.get("model") != "ir.ui.view": return # Only works on non-inherited views. if xml_record.record_node.xpath('./field[@name="inherit_id"]'): return # Does not work on QWeb templates. if xml_record.record_node.xpath( './field[@name="type" and text() = "qweb"]'): return arch = get_view_arch(xml_record.record_node) if arch is None: return view_el = next(c for c in arch.iterchildren() if c.tag is not ET.Comment) # RelaxNG based validation in <= v10 is only possible for < 7.0 version views. if xml_record.addon.version < 11: if float(view_el.get("version", "7.0")) >= 7.0: return # `<form>` and `<kanban>` seem to not be supported in v11+. if view_el.tag in ("form", "kanban") and xml_record.addon.version >= 11: return # `<gantt>` was moved somewhere in v13 (https://git.io/fjxGB). if view_el.tag == "gantt" and xml_record.addon.version >= 13: return # In case it is e.g. `xpath` element in view `arch` override. if view_el.tag not in VIEW_ELEMENT_VERSION_MAP[ xml_record.addon.version]: return relaxng = _load_validator(view_el.tag, xml_record.addon.version) try: relaxng.assert_(view_el) except AssertionError: last_error = relaxng.error_log.last_error yield Issue( "view_relaxng_error", f'"{view_el.tag}" view does not match the RelaxNG schema: ' f"{last_error.message}", xml_record.addon.manifest_path, [ Location(xml_record.path, [(last_error.line, last_error.column + 1)]) ], categories=["correctness"], )
def on_after(self, addon): for model_name, (filename, start_pos) in self._models.items(): model_external_id = f"model_{model_name.replace('.', '_')}" if model_external_id not in self._access_rules: yield Issue( "no_ir_model_access_record", f'Model "{model_name}" has no `ir.model.access` records', addon.manifest_path, [Location(filename, [column_index_1(start_pos)])], categories=["correctness", "security"], )
def on_addon(self, addon): if addon.version >= 10 and addon.manifest_path.path.name != "__manifest__.py": yield Issue( "deprecated_manifest_filename", "Starting with Odoo 10, addon manifest files should be named " '"__manifest__.py"', addon.manifest_path, [Location(addon.manifest_path.path)], categories=["deprecated"], sources=[odoo_commit_url("4339196e5231aa734a0154e2f4e88b2e54f27d48")], )
def on_xml_record(self, xml_record): if (xml_record.addon.version < 11 or xml_record.record_node.get("model") != "ir.cron"): return record = xml_record.record_node data = collect_fields(record) code, code_line_no = data.get("code", (None, None)) state, state_line_no = data.get("state", (None, None)) if code and not state: yield Issue( "incorrect_cron_record", f"`ir.cron` record \"{record.attrib['id']}\" has `code` set, " f"but no `state` set - code will not be executed when the " f"cron job runs", xml_record.addon.manifest_path, [Location(xml_record.path, [code_line_no])], categories=["correctness"], ) elif code and state != "code": yield Issue( "incorrect_cron_record", f"`ir.cron` record \"{record.attrib['id']}\" has `code` set, " f'but `state` is not "code" - code will not be executed ' f"when the cron job runs", xml_record.addon.manifest_path, [ Location(xml_record.path, [code_line_no]), Location(xml_record.path, [state_line_no]), ], categories=["correctness"], ) elif not code and state == "code": yield Issue( "incorrect_cron_record", f"`ir.cron` record \"{record.attrib['id']}\" has `state` set " f'to "code", but `code` is not set', xml_record.addon.manifest_path, [Location(xml_record.path, [state_line_no])], categories=["correctness"], )
def on_csv_row(self, csv_row): model = csv_row.path.stem addon, path, row = csv_row.addon, csv_row.path, csv_row.row yield ExternalIDReference(addon, UNKNOWN, _model_record_id(model), "ir.model", Location(path)) if path not in self._csv_external_id_fields: for field_name in row: if (field_name.split(":")[-1] == "id" or field_name.split("/")[-1] == "id"): self._csv_external_id_fields[path].add(field_name) # Add references to fields from the CSV file header. field_external_id = _field_record_id( model, (field_name[:-3] if field_name.endswith( (":id", "/id")) else field_name), ) yield ExternalIDReference( addon, UNKNOWN, field_external_id, "ir.model.fields", Location(path, [1]), ) for field_name in self._csv_external_id_fields[path]: # TODO: Add support for KNOWN_FIELD_MODELS. external_id = csv_row.row[field_name] if not external_id: continue addon_name, record_id = split_external_id(external_id) cls_ = ExternalID if field_name == "id" else ExternalIDReference yield cls_( csv_row.addon, addon_name, record_id, model if field_name == "id" else UNKNOWN, Location(csv_row.path, [csv_row.line_no]), )
def issue(module_import, import_name): yield Issue( "legacy_import", f"Legacy import `{import_name}`", python_module.addon.manifest_path, [ Location( python_module.path, [column_index_1(module_import.start_pos)] ) ], categories=["deprecated"], )
def on_addon_path(self, addon_path): path = addon_path.path if path.is_dir(): current_permissions = path.stat().st_mode & 0o777 if current_permissions != RECOMMENDED_DIRECTORY_PERMISSIONS: yield Issue( "directory_permissions", f"Directories should have {RECOMMENDED_DIRECTORY_PERMISSIONS:o} " f"permissions (current: {current_permissions:o})", addon_path.addon.manifest_path, [Location(path)], categories=["correctness"], )
def on_xml_tree(self, xml_tree): root_tags = ROOT_TAG_VERSION_MAP[xml_tree.addon.version] xpath_expr = "//data[%s]" % " and ".join(f"not(parent::{root_tag})" for root_tag in root_tags) for data in xml_tree.tree_node.xpath(xpath_expr): yield Issue( "invalid_data_element_parent", f"Expected `<data>` element to be a direct child of one of " f"these elements: {', '.join(root_tags)}", xml_tree.addon.manifest_path, [Location(xml_tree.path, [data.sourceline])], categories=["correctness"], )
def _ref(addon, filename, position, external_id, model, unknown_addon=False) -> ExternalIDReference: addon_name, record_id = split_external_id(external_id) return ExternalIDReference( addon, UNKNOWN if unknown_addon else addon_name, record_id, model, Location(filename, [position]), )
def on_xml_tree(self, xml_tree): relaxng = VERSION_RNG_MAP[xml_tree.addon.version] try: relaxng.assert_(xml_tree.tree_node) except AssertionError: last_error = relaxng.error_log.last_error yield Issue( "relaxng_error", f"XML file does not match Odoo RelaxNG schema: {last_error.message}", xml_tree.addon.manifest_path, [Location(xml_tree.path, [(last_error.line, last_error.column + 1)])], categories=["correctness"], )
def run_check_test( data_dir: pathlib.Path, check_name, manifest_path_parts, version, issues, extra_checks=( "addon_path_emitter", "addon_file_emitter", "xml_tree_emitter", "python_emitter", "external_id_emitter", "csv_row_emitter", "model_definition_emitter", "field_definition_emitter", ), ): manifest_path = ManifestPath( data_dir.joinpath(check_name, *manifest_path_parts)) expected_issues = [] for issue in issues: locations = [] for path_parts, line_nos in issue.pop("locations", []): locations.append( Location(manifest_path.addon_path.joinpath(*path_parts), line_nos)) sources = [] for source in issue.pop("sources", []): sources.append(yarl.URL(source)) expected_issues.append( Issue( **{ "manifest_path": manifest_path, "locations": locations, "sources": sources, **issue, })) checks_to_load = {check_name} if extra_checks: checks_to_load.update(extra_checks) actual_issues = list( check_addon(manifest_path, get_checks(checks_to_load), version=version)) assert expected_issues == actual_issues
def on_xml_tree(self, xml_tree): if xml_tree.addon.version < 9: return attributes = ", ".join(f'"{attr}"' for attr in ["class"]) xpath = "|".join(f"/{main_tag}//attribute[@name=({attributes})]" for main_tag in ("openerp", "odoo")) for el in xml_tree.tree_node.xpath(xpath): is_override = not any(a in el.attrib for a in ("add", "remove")) if is_override: yield Issue( "attribute_override", f"`<attribute>` overrides the `{el.get('name')}` attribute value, " f'consider using `add="..."` or `remove="..."` instead of ' f"overriding", xml_tree.addon.manifest_path, [Location(xml_tree.path, [el.sourceline])], categories=["correctness", "maintainability"], )
def on_python_module(self, python_module): if python_module.path.name not in MANIFEST_FILENAMES: return key_locations = None for check in ("active", "deprecated_xml", "unknown_keys"): for key, issue in getattr(self, f"_check_{check}")( python_module.addon.manifest): if key_locations is None: key_locations = self._get_key_locations( python_module.module) yield Issue( **{ "locations": [Location(python_module.path, key_locations[key])], "manifest_path": python_module.addon.manifest_path, **issue, })
def on_python_module(self, python_module): if python_module.addon.version < 12 or not python_module.path.name.startswith( "test_"): return imports = set() for imp in get_imports(python_module.module): from_part = ".".join(imp.from_names) if from_part: imports.update((from_part, n) for n in imp.names) else: imports.update(imp.names) for classdef in python_module.module.iter_classdefs(): bases = get_bases(classdef.get_super_arglist()) is_test_case = (("unittest", "TestCase") in bases and "unittest" in imports) or ( ("TestCase", ) in bases and ("unittest", "TestCase") in imports) if not is_test_case: continue tagged = False for decorator in classdef.get_decorators(): if extract_func_name(decorator)[-1] == "tagged": tagged = True break if not tagged: yield Issue( "unittest_testcase_not_tagged", f"`unittest.TestCase` subclass `{classdef.name.value}` is not " f"decorated with `@tagged()`, it will not be picked up by Odoo " f"test runner", python_module.addon.manifest_path, [ Location(python_module.path, [column_index_1(classdef.start_pos)]) ], categories=["correctness"], sources=[ odoo_commit_url( "b356b190338e3ee032b9e3a7f670f76468965006") ], )
def on_xml_tree(self, xml_tree): for template in xml_tree.tree_node.xpath("//template"): for el in template.xpath( ".//*/@*[starts-with(name(), 't-attf-')]/.."): for name, value in el.attrib.items(): if not name.startswith("t-attf-"): continue is_format_string = ("{{" in value and "}}" in value) or ( "#{" in value and "}" in value) if not is_format_string: yield Issue( "redundant_t_attf", f"Element `<{el.tag}>` has a redundant `t-attf-$name` " f"attribute `{name}`: {value}", xml_tree.addon.manifest_path, [Location(xml_tree.path, [el.sourceline])], categories=["correctness", "performance"], )
def on_xml_record(self, xml_record): record = xml_record.record_node record_fields = get_fields(record) for field_name, line_nos in record_fields.items(): model, record_id = record.attrib["model"], record.get("id") if len(line_nos) > 1: yield Issue( "duplicate_record_field", f'`{model}` record "{record_id}" has duplicated values ' f'for field "{field_name}"' if record_id else (f"`{model}` record has duplicated values " f'for field "{field_name}"'), xml_record.addon.manifest_path, [ Location(xml_record.path, [line_no]) for line_no in line_nos ], categories=["correctness", "maintainability"], )
def on_csv_row(self, csv_row): if csv_row.path.name.lower() != "ir.model.access.csv" or csv_row.row.get( "group_id:id" ): return permissions = [ perm for perm in ("create", "read", "write", "unlink") if csv_row.row.get(f"perm_{perm}") == "1" ] yield Issue( "ir_model_access_without_group", f"`ir.model.access` record ({csv_row.row['id']}) allows the " f"following operations to users without group: " f"{', '.join(permissions)}", csv_row.addon.manifest_path, [Location(csv_row.path, [csv_row.line_no])], categories=["security", "correctness"], )
def _get_issues(xml_record, element, classes): classes = set((classes or "").split()) for old_class, new_class in CLASS_MAP.items(): if old_class in classes: yield Issue( "deprecated_button_class", f"`{old_class}` button class is deprecated since v12.0 " f"in favor of `{new_class}`", xml_record.addon.manifest_path, [Location(xml_record.path, [element.sourceline])], categories=["maintainability", "deprecated"], sources=[ odoo_source_url( "1e5fbb8e5bf0e0458d83a399b2b59d03a601e86a", "addons/web/static/src/js/core/dom.js", start=340, end=345, ) ], )
def on_field_definition(self, field: FieldDefinition): addon = field.model.addon if addon.version < 12: return for kwarg in field.kwargs: if kwarg.name == "track_visibility" and kwarg.value == "always": yield Issue( "track_visibility_always_deprecated", 'Field `track_visibility` attribute value "always" is ' "deprecated since version 12.0", addon.manifest_path, [ Location(field.model.path, [column_index_1(kwarg.start_pos)]) ], categories=["deprecated"], sources=[ odoo_commit_url( "c99de4551583e801ecc6669ac456c4f7e2eef1da") ], )
def on_model_definition(self, model): if get_model_type(model.node) == UNKNOWN: return model_name = model.params.get("_name") if model.params.get("_inherit") or not model_name: return if not model.params.get("_description"): yield Issue( "no_model_description", f'Model "{model_name}" has no `_description` set', model.addon.manifest_path, [Location(model.path, [column_index_1(model.node.start_pos)])], categories=(["future-warning"] if model.addon.version < 12 else ["correctness"]), sources=[ odoo_source_url( "ff9ddfdacc9581361b555fd5f69e2da61800acad", "odoo/models.py", start=529, ) ], )