diff --git a/src/oca_pre_commit_hooks/checks_odoo_module.py b/src/oca_pre_commit_hooks/checks_odoo_module.py index 8de5346..b2286d8 100755 --- a/src/oca_pre_commit_hooks/checks_odoo_module.py +++ b/src/oca_pre_commit_hooks/checks_odoo_module.py @@ -136,12 +136,15 @@ def check_xml(self): if not manifest_datas: return checks_obj = checks_odoo_module_xml.ChecksOdooModuleXML( - manifest_datas, self.odoo_addon_name, self.enable, self.disable + manifest_datas, self.odoo_addon_name, self.enable, self.disable, self.autofix ) for check_meth in utils.getattr_checks(checks_obj): check_meth() self.checks_errors.extend(checks_obj.checks_errors) + if self.autofix: + checks_obj.perform_autofix() + @utils.only_required_for_installable() def check_csv(self): manifest_datas = self.manifest_referenced_files[".csv"] diff --git a/src/oca_pre_commit_hooks/checks_odoo_module_xml.py b/src/oca_pre_commit_hooks/checks_odoo_module_xml.py index b42adfe..90cb446 100644 --- a/src/oca_pre_commit_hooks/checks_odoo_module_xml.py +++ b/src/oca_pre_commit_hooks/checks_odoo_module_xml.py @@ -1,5 +1,7 @@ import os import re +import shutil +import tempfile from collections import defaultdict, namedtuple from typing import Dict, List @@ -11,6 +13,124 @@ DFTL_MIN_PRIORITY = 99 DFLT_DEPRECATED_TREE_ATTRS = ["colors", "fonts", "string"] +import difflib +import re + +# def generate_regex_from_diff(original, modified): +# # Paso 1: Encuentra diferencias +# diff = list(difflib.ndiff(original.split(), modified.split())) + +# # Paso 2: Identificar los tokens que cambiaron +# removed = [token[2:] for token in diff if token.startswith('- ')] +# added = [token[2:] for token in diff if token.startswith('+ ')] + +# # Paso 3: Construir patrón dinámico basado en las diferencias +# # Asumimos que el formato básico del XML es consistente +# if len(removed) == len(added): +# # Crear el patrón regex para identificar la línea original +# pattern_parts = [] +# for token in original.split(): +# if token in removed: +# # Usa grupos de captura para los valores que cambian +# pattern_parts.append(r'([^"]+)') +# else: +# pattern_parts.append(re.escape(token)) +# pattern = r'\s+'.join(pattern_parts) + +# # Crear la cadena de reemplazo dinámica +# replacement_parts = [] +# group_index = 1 +# for token in modified.split(): +# if token in added: +# # Reemplaza por el grupo capturado correspondiente +# replacement_parts.append(rf'\{group_index}') +# group_index += 1 +# else: +# replacement_parts.append(token) +# replacement = ' '.join(replacement_parts) + +# return pattern, replacement +# else: +# raise ValueError("El diff no tiene cambios coincidentes en longitud.") + +# def generate_regex_from_diff(original, modified): +# # Paso 1: Encuentra diferencias +# diff = list(difflib.ndiff(original.split(), modified.split())) + +# # Paso 2: Identificar los tokens que cambiaron +# removed = [token[2:] for token in diff if token.startswith('- ')] +# added = [token[2:] for token in diff if token.startswith('+ ')] + +# # Paso 3: Construir patrón dinámico basado en las diferencias +# if len(removed) == len(added): +# # Crear el patrón regex para identificar la línea original +# pattern_parts = [] +# for token in original.split(): +# if token in removed: +# # Usa grupos de captura para los valores que cambian +# pattern_parts.append(r'([^">]+)') +# else: +# pattern_parts.append(re.escape(token)) +# # Permitir cualquier espacio o salto de línea entre tokens +# pattern = r'\s*'.join(pattern_parts) + +# # Crear la cadena de reemplazo dinámica +# replacement_parts = [] +# group_index = 1 +# for token in modified.split(): +# if token in added: +# # Reemplaza por el grupo capturado correspondiente +# replacement_parts.append(rf'\{group_index}') +# group_index += 1 +# else: +# replacement_parts.append(token) +# replacement = ' '.join(replacement_parts) + +# return pattern, replacement +# else: +# raise ValueError("El diff no tiene cambios coincidentes en longitud.") + + +def generate_regex_from_diff(original, modified): + # Paso 1: Encuentra diferencias + diff = list(difflib.ndiff(original.split(), modified.split())) + + # Paso 2: Identificar los tokens que cambiaron + removed = [token[2:] for token in diff if token.startswith("- ")] + added = [token[2:] for token in diff if token.startswith("+ ")] + + # Paso 3: Construir patrón dinámico basado en las diferencias + if len(removed) == len(added): + # Crear el patrón regex para identificar la línea original + pattern_parts = [] + capture_index = 1 + capture_map = {} + + for token in original.split(): + if token in removed: + # Usa grupos de captura para los valores que cambian + pattern_parts.append(r'([^">]+)') + capture_map[token] = f"\\{capture_index}" + capture_index += 1 + else: + pattern_parts.append(re.escape(token)) + # Permitir cualquier espacio o salto de línea entre tokens + pattern = r"\s*".join(pattern_parts) + + # Crear la cadena de reemplazo dinámica basada en los grupos + replacement_parts = [] + for token in modified.split(): + if token in added: + # Reorganizar usando los grupos capturados + replacement_parts.append(capture_map.get(token, token)) + else: + replacement_parts.append(token) + replacement = " ".join(replacement_parts) + + return pattern, replacement + else: + raise ValueError("El diff no tiene cambios coincidentes en longitud.") + # Same as Odoo: https://github.com/odoo/odoo/commit/9cefa76988ff94c3d590c6631b604755114d0297 def _hasclass(context, *cls): @@ -25,6 +145,24 @@ def _hasclass(context, *cls): FileElementPair = namedtuple("FileElementPair", ["filename", "element"]) +xmlid_reorder_re = re.compile(r'(<(\w+)\s+)([^>]*?)\b(id="[^"]*")([^>]*)', flags=re.DOTALL) + + +def xmlid_reorder(old_content): + # TODO: Support multiline num_line_end: + break + return content + def getattr_checks(self, manifest_data, prefix): disable_node = manifest_data["disabled_checks"] yield from utils.getattr_checks(self, prefix, disable_node) @@ -117,46 +277,16 @@ def _is_replaced_field(cls, view): # Not set only_required_for_checks because of the calls to visit_xml_record... methods def check_xml_records(self): - """* Check xml-record-missing-id - Generated when a tag has no id. - - * Check xml-duplicate-record-id - - If a module has duplicated record_id AKA xml_ids - file1.xml - tag has no id. + + * Check xml-duplicate-record-id + + If a module has duplicated record_id AKA xml_ids + file1.xml + `', - info=f'Use `` instead', + code="xml-record-missing-id", + message="Record has no id, add a unique one to create a new record, use an existing one to update it", filepath=manifest_data["filename_short"], line=record.sourceline, ) + if self.is_message_enabled("xml-duplicate-record-id", manifest_data["disabled_checks"]): + # xmlids_duplicated + xmlid_key = ( + f"{manifest_data['data_section']}/{record_id}" f"_noupdate_{record.getparent().get('noupdate', '0')}" + ) + self.xmlids_section[xmlid_key].append(FileElementPair(manifest_data["filename_short"], record)) + if record_id: + first_attr = record.keys()[0] + if first_attr != "id" and self.is_message_enabled( + "xml-id-position-first", manifest_data["disabled_checks"] + ): + self.register_error( + code="xml-id-position-first", + message=f'The "id" attribute must be first', + info=f'Use `<{record.tag} id="{record_id}" {first_attr}=...` instead', + filepath=manifest_data["filename_short"], + line=record.sourceline, + ) + if self.autofix: + manifest_data["needs_autofix"] = True + + # LXML STYLE + record.attrib.pop("id") + attributes = dict(record.attrib) + record.attrib.clear() + record.attrib.update({"id": record_id, **attributes}) + + # STR REGEX STYLE + # old_content = self.read_content_start_end( + # manifest_data["filename"], record.getprevious().sourceline + 1, record.sourceline + # ) + # manifest_data["needs_autofix"] = True + # new_content = xmlid_reorder(old_content) + # manifest_data["changes"].append( + # { + # "sourceline": record.sourceline, + # "old_content": old_content, + # "new_content": new_content, + # } + # ) + + xmlid_module, xmlid_name = record_id.split(".") if "." in record_id else ["", record_id] + if xmlid_module == self.module_name and self.is_message_enabled( + "xml-redundant-module-name", manifest_data["disabled_checks"] + ): + self.register_error( + code="xml-redundant-module-name", + message=f'Redundant module name `<{record.tag} id="{record_id}"`', + info=f'Use `<{record.tag} id="{xmlid_name}"` instead', + filepath=manifest_data["filename_short"], + line=record.sourceline, + ) + if self.autofix: + manifest_data["needs_autofix"] = True + # LXML STYLE + record.attrib["id"] = record.attrib["id"].replace(f"{xmlid_module}.", "") + + # STR REGEX STYLE + # old_content = self.read_line(manifest_data["filename"], record.sourceline) + # new_content = old_content.replace(f"{xmlid_module}.", "") + # manifest_data["changes"].append( + # { + # "sourceline": record.sourceline, + # "old_content": old_content, + # "new_content": new_content, + # } + # ) + @utils.only_required_for_checks("xml-view-dangerous-replace-low-priority", "xml-deprecated-tree-attribute") def visit_xml_record_view(self, manifest_data, record): """* Check xml-view-dangerous-replace-low-priority in ir.ui.view diff --git a/tests/common.py b/tests/common.py index 5f6344d..d247160 100644 --- a/tests/common.py +++ b/tests/common.py @@ -257,3 +257,20 @@ def test_checks_as_string(self): all_check_errors = self.checks_run(self.file_paths, no_exit=True, no_verbose=False) for check_error in all_check_errors: self.assertTrue(str(check_error).count(check_error.code) == 1) + + def test_checks_autofix(self): + # TODO: Create a new tmp folder in oder to avoid generating changes in the original files + all_check_errors = self.checks_run( + self.file_paths, + no_exit=True, + no_verbose=False, + enable="xml-id-position-first,xml-redundant-module-name", + autofix=True, + ) + real_errors = self.get_count_code_errors(all_check_errors) + expected_errors = { + key: val + for key, val in self.expected_errors.items() + if key in ["xml-redundant-module-name", "xml-id-position-first"] + } + assertDictEqual(self, real_errors, expected_errors) diff --git a/tests/test_checks.py b/tests/test_checks.py index 545ad75..1cdff50 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -29,6 +29,7 @@ "xml-deprecated-tree-attribute": 3, "xml-duplicate-fields": 3, "xml-duplicate-record-id": 2, + "xml-id-position-first": 2, "xml-not-valid-char-link": 2, "xml-redundant-module-name": 1, "xml-syntax-error": 2,