8 changed files with 1473 additions and 34 deletions
			
			
		@ -0,0 +1,711 @@ | 
				
			|||||
 | 
					# -*- coding: utf-8 -*- | 
				
			||||
 | 
					###################################################################################### | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    Cybrosys Technologies Pvt. Ltd. | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) | 
				
			||||
 | 
					#    Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>) | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) | 
				
			||||
 | 
					#    It is forbidden to publish, distribute, sublicense, or sell copies of the Software | 
				
			||||
 | 
					#    or modified copies of the Software. | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | 
				
			||||
 | 
					#    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | 
				
			||||
 | 
					#    FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. | 
				
			||||
 | 
					#    IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | 
				
			||||
 | 
					#    DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, | 
				
			||||
 | 
					#    ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER | 
				
			||||
 | 
					#    DEALINGS IN THE SOFTWARE. | 
				
			||||
 | 
					# | 
				
			||||
 | 
					###################################################################################### | 
				
			||||
 | 
					import ast | 
				
			||||
 | 
					import os | 
				
			||||
 | 
					import re | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					results = [] | 
				
			||||
 | 
					module_technical_name = ['no_module'] | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def is_camel_case(name): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check if a given name follows CamelCase format. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        name (str): The variable or class name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Returns: | 
				
			||||
 | 
					        bool: True if name is CamelCase. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    return re.match(r'^[A-Z][a-zA-Z0-9]+$', name) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def is_snake_case(name): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check if a given name follows snake_case format. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        name (str): The variable or function name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Returns: | 
				
			||||
 | 
					        bool: True if name is snake_case. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    return re.match(r'^[a-z_][a-z0-9_]*$', name) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_class_name(node, file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Validate that the class name follows CamelCase. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.ClassDef): The class node from AST. | 
				
			||||
 | 
					        file_path (str): The source file path. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    if not is_camel_case(node.name): | 
				
			||||
 | 
					        file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Class name '{node.name}' is not CamelCase", | 
				
			||||
 | 
					            "suggestion": "Use CamelCase (e.g., MyClassName)" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_model_class(node, file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check Odoo model class rules, including naming consistency. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.ClassDef): The class node from AST. | 
				
			||||
 | 
					        file_path (str): The source file path. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    model_type = None | 
				
			||||
 | 
					    model_name = None | 
				
			||||
 | 
					    inherit_found = False | 
				
			||||
 | 
					    name_found = False | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for base in node.bases: | 
				
			||||
 | 
					        if isinstance(base, ast.Attribute) and base.attr in ["Model", "TransientModel"]: | 
				
			||||
 | 
					            model_type = base.attr | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for item in node.body: | 
				
			||||
 | 
					        if isinstance(item, ast.Assign): | 
				
			||||
 | 
					            for target in item.targets: | 
				
			||||
 | 
					                if isinstance(target, ast.Name): | 
				
			||||
 | 
					                    if target.id == "_name": | 
				
			||||
 | 
					                        name_found = True | 
				
			||||
 | 
					                        if isinstance(item.value, ast.Constant) and isinstance(item.value.value, str): | 
				
			||||
 | 
					                            model_name = item.value.value | 
				
			||||
 | 
					                    elif target.id == "_inherit": | 
				
			||||
 | 
					                        inherit_found = True | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    # Check if _name or _inherit is present | 
				
			||||
 | 
					    if model_type and not (name_found or inherit_found): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Class '{node.name}' is missing both '_name' and '_inherit'", | 
				
			||||
 | 
					            "suggestion": "Add either _name (for new model) or _inherit (for extending existing model)" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    # Consistency Check | 
				
			||||
 | 
					    filename = os.path.basename(file_path).replace(".py", "") | 
				
			||||
 | 
					    expected_class_name = "".join(part.capitalize() for part in filename.split("_")) | 
				
			||||
 | 
					    expected_model_name = filename.replace("_", ".") | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if node.name != expected_class_name: | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Class name '{node.name}' does not match file name '{filename}.py'", | 
				
			||||
 | 
					            "suggestion": f"Rename class to '{expected_class_name}' to match file name" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if model_name and model_type == "Model": | 
				
			||||
 | 
					        if model_name != expected_model_name: | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": node.lineno, | 
				
			||||
 | 
					                "issue": f"_name '{model_name}' does not match file-based model name '{expected_model_name}'", | 
				
			||||
 | 
					                "suggestion": f"Use _name = '{expected_model_name}'" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if model_name: | 
				
			||||
 | 
					        if "." not in model_name: | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": node.lineno, | 
				
			||||
 | 
					                "issue": f"Model name '{model_name}' should use dot notation", | 
				
			||||
 | 
					                "suggestion": "Use format: <module>.<model> (e.g., res.partner)" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        if model_type == "TransientModel" and "wizard" in model_name: | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": node.lineno, | 
				
			||||
 | 
					                "issue": f"Transient model name '{model_name}' contains 'wizard'", | 
				
			||||
 | 
					                "suggestion": "Avoid using 'wizard'; use format: <related_model>.<action>" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        if ".report." in model_name and len(model_name.split(".")) < 3: | 
				
			||||
 | 
					            parts = model_name.split(".") | 
				
			||||
 | 
					            if len(parts) < 3: | 
				
			||||
 | 
					                results.append({ | 
				
			||||
 | 
					                    "file": file_path_shorten, | 
				
			||||
 | 
					                    "line": node.lineno, | 
				
			||||
 | 
					                    "issue": f"Report model name '{model_name}' should follow '<model>.report.<action>'", | 
				
			||||
 | 
					                    "suggestion": "Ensure full dot-notation like 'sale.report.invoice'" | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        if model_type == "Model": | 
				
			||||
 | 
					            last_part = model_name.split(".")[-1] | 
				
			||||
 | 
					            if last_part.endswith("s"): | 
				
			||||
 | 
					                results.append({ | 
				
			||||
 | 
					                    "file": file_path_shorten, | 
				
			||||
 | 
					                    "line": node.lineno, | 
				
			||||
 | 
					                    "issue": f"Model name '{model_name}' seems plural", | 
				
			||||
 | 
					                    "suggestion": "Use singular form (e.g., 'res.partner', not 'res.partners')" | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_missing_docstring(node, file_path, type="function"): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check whether a class or function has a docstring. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.AST): The node (ClassDef or FunctionDef). | 
				
			||||
 | 
					        file_path (str): File path. | 
				
			||||
 | 
					        type (str): Either 'function' or 'class'. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    if not ast.get_docstring(node): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"{type.capitalize()} '{node.name}' is missing a docstring", | 
				
			||||
 | 
					            "suggestion": f"Add a docstring to the {type}" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_function_name(node, file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check function or method names for snake_case and method type prefixes. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.FunctionDef): The function node. | 
				
			||||
 | 
					        file_path (str): Path to the source file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    if not is_snake_case(node.name): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Function name '{node.name}' is not snake_case", | 
				
			||||
 | 
					            "suggestion": "Use snake_case (e.g., my_function_name)" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for decorator in node.decorator_list: | 
				
			||||
 | 
					        if isinstance(decorator, ast.Attribute): | 
				
			||||
 | 
					            if decorator.attr in ["multi", "one"]: | 
				
			||||
 | 
					                results.append({ | 
				
			||||
 | 
					                    "file": file_path_shorten, | 
				
			||||
 | 
					                    "line": node.lineno, | 
				
			||||
 | 
					                    "issue": f"Decorator '@api.{decorator.attr}' is deprecated", | 
				
			||||
 | 
					                    "suggestion": "Remove it; use '@api.model' or '@api.depends' based on logic" | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					    check_method_prefix(node, file_path) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_method_prefix(node, file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check method prefix based on decorators like @api.depends, @api.onchange, etc. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.FunctionDef): The method definition. | 
				
			||||
 | 
					        file_path (str): Path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    method_prefix_map = { | 
				
			||||
 | 
					        'depends': '_compute_', | 
				
			||||
 | 
					        'onchange': '_onchange_', | 
				
			||||
 | 
					        'constrains': '_check_', | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for decorator in node.decorator_list: | 
				
			||||
 | 
					        if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Attribute): | 
				
			||||
 | 
					            deco_name = decorator.func.attr | 
				
			||||
 | 
					            expected_prefix = method_prefix_map.get(deco_name) | 
				
			||||
 | 
					            if expected_prefix and not node.name.startswith(expected_prefix): | 
				
			||||
 | 
					                results.append({ | 
				
			||||
 | 
					                    "file": file_path_shorten, | 
				
			||||
 | 
					                    "line": node.lineno, | 
				
			||||
 | 
					                    "issue": f"Method '{node.name}' uses @{deco_name} but doesn't start with '{expected_prefix}'", | 
				
			||||
 | 
					                    "suggestion": f"Prefix method name with '{expected_prefix}'" | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if 'default_' in node.name and not node.name.startswith('_default_'): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Default method '{node.name}' should start with '_default_'", | 
				
			||||
 | 
					            "suggestion": "Rename method to start with '_default_'" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if 'selection' in node.name and not node.name.startswith('_selection_'): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Selection method '{node.name}' should start with '_selection_'", | 
				
			||||
 | 
					            "suggestion": "Rename method to start with '_selection_'" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if node.name.startswith('do_'): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Action method '{node.name}' should start with 'action_'", | 
				
			||||
 | 
					            "suggestion": "Rename method to start with 'action_'" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_field_suffix(node, file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Ensure field names have correct suffixes based on field type. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.Assign): The field definition node. | 
				
			||||
 | 
					        file_path (str): Path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    if isinstance(node.value, ast.Call) and isinstance(node.value.func, ast.Attribute): | 
				
			||||
 | 
					        field_type = node.value.func.attr | 
				
			||||
 | 
					        if field_type in ['Many2one', 'One2many', 'Many2many']: | 
				
			||||
 | 
					            target = node.targets[0] | 
				
			||||
 | 
					            name = target.id if isinstance(target, ast.Name) else getattr(target, 'attr', None) | 
				
			||||
 | 
					            if name: | 
				
			||||
 | 
					                correct_suffix = '_id' if field_type == 'Many2one' else '_ids' | 
				
			||||
 | 
					                if not name.endswith(correct_suffix): | 
				
			||||
 | 
					                    results.append({ | 
				
			||||
 | 
					                        "file": file_path_shorten, | 
				
			||||
 | 
					                        "line": node.lineno, | 
				
			||||
 | 
					                        "issue": f"{field_type} field '{name}' should end with '{correct_suffix}'", | 
				
			||||
 | 
					                        "suggestion": f"Rename variable to end with '{correct_suffix}'" | 
				
			||||
 | 
					                    }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_variable_naming(node, file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check local variable naming conventions and suffixes for record/recordsets. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        node (ast.Assign): The assignment statement node. | 
				
			||||
 | 
					        file_path (str): Source file path. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call): | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    targets = node.targets | 
				
			||||
 | 
					    if not targets: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    var_name = targets[0].id if isinstance(targets[0], ast.Name) else None | 
				
			||||
 | 
					    if not var_name: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    call_func = node.value.func | 
				
			||||
 | 
					    call_attr = call_func.attr if isinstance(call_func, ast.Attribute) else '' | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if isinstance(call_func, ast.Subscript): | 
				
			||||
 | 
					        if isinstance(call_func.value, ast.Attribute) and call_func.value.attr == 'env': | 
				
			||||
 | 
					            if not is_camel_case(var_name): | 
				
			||||
 | 
					                results.append({ | 
				
			||||
 | 
					                    "file": file_path_shorten, | 
				
			||||
 | 
					                    "line": node.lineno, | 
				
			||||
 | 
					                    "issue": f"Model variable '{var_name}' is not CamelCase", | 
				
			||||
 | 
					                    "suggestion": "Use CamelCase (e.g., MyModelVariable)" | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if call_attr in ['browse', 'search', 'search_read']: | 
				
			||||
 | 
					        if call_attr == 'browse' and not var_name.endswith('_id'): | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": node.lineno, | 
				
			||||
 | 
					                "issue": f"Variable '{var_name}' may represent a single record, use _id suffix", | 
				
			||||
 | 
					                "suggestion": "Rename variable to end with '_id'" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					        elif call_attr in ['search', 'search_read'] and not var_name.endswith('_ids'): | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": node.lineno, | 
				
			||||
 | 
					                "issue": f"Variable '{var_name}' may represent multiple records, use _ids suffix", | 
				
			||||
 | 
					                "suggestion": "Rename variable to end with '_ids'" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if not is_snake_case(var_name): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": node.lineno, | 
				
			||||
 | 
					            "issue": f"Variable name '{var_name}' is not snake_case", | 
				
			||||
 | 
					            "suggestion": "Use snake_case (e.g., my_variable)" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_python_file_name(file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check if the Python file name follows snake_case convention. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        file_path (str): Full path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    filename = os.path.basename(file_path) | 
				
			||||
 | 
					    if not filename.endswith(".py") or filename in {"__init__.py", "__manifest__.py"}: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    name_without_ext = filename[:-3]  # Remove .py | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if not re.fullmatch(r"[a-z0-9_]+", name_without_ext): | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": 0, | 
				
			||||
 | 
					            "issue": f"File name '{filename}' does not follow snake_case", | 
				
			||||
 | 
					            "suggestion": "Rename file using lowercase and underscores (e.g., my_model.py)" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if name_without_ext in {"temp", "test", "test1", "new", "misc"}: | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": 0, | 
				
			||||
 | 
					            "issue": f"File name '{filename}' is too generic or non-descriptive", | 
				
			||||
 | 
					            "suggestion": "Use meaningful file names related to model or logic" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_manifest_file(file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the manifest file for any standard issues. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        file_path (str): Full path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    with open(file_path, "r", encoding="utf-8") as f: | 
				
			||||
 | 
					        source = f.read() | 
				
			||||
 | 
					    try: | 
				
			||||
 | 
					        tree = ast.parse(source) | 
				
			||||
 | 
					    except SyntaxError: | 
				
			||||
 | 
					        results.append({ | 
				
			||||
 | 
					            "file": file_path_shorten, | 
				
			||||
 | 
					            "line": 0, | 
				
			||||
 | 
					            "issue": "Invalid Python syntax in __manifest__.py", | 
				
			||||
 | 
					            "suggestion": "Fix the syntax error" | 
				
			||||
 | 
					        }) | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    manifest_data = {} | 
				
			||||
 | 
					    seen_keys = set() | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for node in ast.walk(tree): | 
				
			||||
 | 
					        if isinstance(node, ast.Dict): | 
				
			||||
 | 
					            for k_node, v_node in zip(node.keys, node.values): | 
				
			||||
 | 
					                if isinstance(k_node, ast.Str): | 
				
			||||
 | 
					                    key = k_node.s | 
				
			||||
 | 
					                    if key in seen_keys: | 
				
			||||
 | 
					                        results.append({ | 
				
			||||
 | 
					                            "file": file_path_shorten, | 
				
			||||
 | 
					                            "line": k_node.lineno if hasattr(k_node, "lineno") else 1, | 
				
			||||
 | 
					                            "issue": f"Duplicate field '{key}' in manifest", | 
				
			||||
 | 
					                            "suggestion": "Remove the duplicate entry" | 
				
			||||
 | 
					                        }) | 
				
			||||
 | 
					                    seen_keys.add(key) | 
				
			||||
 | 
					                    manifest_data[key] = v_node | 
				
			||||
 | 
					            break  # Assume top-level dict is the manifest | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    required_fields = {"name", "version", "depends", "author", "category", "summary", "data", "installable", "license"} | 
				
			||||
 | 
					    for field in required_fields: | 
				
			||||
 | 
					        if field not in manifest_data: | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": 1, | 
				
			||||
 | 
					                "issue": f"Missing required field '{field}' in manifest", | 
				
			||||
 | 
					                "suggestion": f"Add '{field}' to the manifest dictionary" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    # Version check | 
				
			||||
 | 
					    version = manifest_data.get("version") | 
				
			||||
 | 
					    if isinstance(version, ast.Constant): | 
				
			||||
 | 
					        version_val = version.value | 
				
			||||
 | 
					        if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+\.\d+", version_val): | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": version.lineno if hasattr(version, "lineno") else 1, | 
				
			||||
 | 
					                "issue": f"Version '{version_val}' does not follow '16.0.1.0.0' format", | 
				
			||||
 | 
					                "suggestion": "Use 5-level semantic versioning like '16.0.1.0.0'" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    # Depends check | 
				
			||||
 | 
					    depends = manifest_data.get("depends") | 
				
			||||
 | 
					    if isinstance(depends, ast.List): | 
				
			||||
 | 
					        for d in depends.elts: | 
				
			||||
 | 
					            if isinstance(d, ast.Constant) and not d.value.strip(): | 
				
			||||
 | 
					                results.append({ | 
				
			||||
 | 
					                    "file": file_path_shorten, | 
				
			||||
 | 
					                    "line": d.lineno if hasattr(d, "lineno") else 1, | 
				
			||||
 | 
					                    "issue": "Empty string in 'depends'", | 
				
			||||
 | 
					                    "suggestion": "Remove empty string or specify actual dependency" | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    # Maintainer email format check | 
				
			||||
 | 
					    maintainer = manifest_data.get("maintainer") | 
				
			||||
 | 
					    if maintainer and isinstance(maintainer, ast.Constant): | 
				
			||||
 | 
					        email = maintainer.value | 
				
			||||
 | 
					        # Use this if not worked properly: r".*<[^@]+@[^@]+\.[^@]+>" | 
				
			||||
 | 
					        if "@" not in email or not re.match(r"[^@]+@[^@]+\.[^@]+", email): | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": maintainer.lineno if hasattr(maintainer, "lineno") else 1, | 
				
			||||
 | 
					                "issue": f"Invalid email format for 'maintainer': '{email}'", | 
				
			||||
 | 
					                "suggestion": "Use a valid email address (e.g., john@example.com)" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    # Check license | 
				
			||||
 | 
					    valid_licenses = {"LGPL-3", "AGPL-3", "OEEL-1", "OPL-1", "GPL-3", "MIT"} | 
				
			||||
 | 
					    license_node = manifest_data.get("license") | 
				
			||||
 | 
					    if isinstance(license_node, ast.Constant): | 
				
			||||
 | 
					        license_val = license_node.value | 
				
			||||
 | 
					        if license_val not in valid_licenses: | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": license_node.lineno if hasattr(license_node, "lineno") else 1, | 
				
			||||
 | 
					                "issue": f"License '{license_val}' is not a recognized Odoo-compatible license", | 
				
			||||
 | 
					                "suggestion": f"Use one of the valid licenses: {', '.join(valid_licenses)}" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					    # Data file order check: security/ files must come first | 
				
			||||
 | 
					    data_node = manifest_data.get("data") | 
				
			||||
 | 
					    if isinstance(data_node, ast.List): | 
				
			||||
 | 
					        found_non_security = False | 
				
			||||
 | 
					        for file_node in data_node.elts: | 
				
			||||
 | 
					            if isinstance(file_node, ast.Constant): | 
				
			||||
 | 
					                file_value = file_node.value | 
				
			||||
 | 
					                if not file_value.startswith("security/"): | 
				
			||||
 | 
					                    found_non_security = True | 
				
			||||
 | 
					                elif found_non_security: | 
				
			||||
 | 
					                    results.append({ | 
				
			||||
 | 
					                        "file": file_path_shorten, | 
				
			||||
 | 
					                        "line": file_node.lineno if hasattr(file_node, "lineno") else 1, | 
				
			||||
 | 
					                        "issue": f"'security' file '{file_value}' appears after non-security files", | 
				
			||||
 | 
					                        "suggestion": "Move all 'security/' files to the top of the 'data' list" | 
				
			||||
 | 
					                    }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_transient_model_location(py_file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the transient model is in the wizard folder. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        file_path (str): Full path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{py_file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    with open(py_file_path, "r", encoding="utf-8") as f: | 
				
			||||
 | 
					        source = f.read() | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    try: | 
				
			||||
 | 
					        tree = ast.parse(source) | 
				
			||||
 | 
					    except SyntaxError: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for node in ast.walk(tree): | 
				
			||||
 | 
					        if isinstance(node, ast.ClassDef): | 
				
			||||
 | 
					            for base in node.bases: | 
				
			||||
 | 
					                if isinstance(base, ast.Attribute) and base.attr == "TransientModel": | 
				
			||||
 | 
					                    if "wizard" not in py_file_path.replace("\\", "/").split("/"): | 
				
			||||
 | 
					                        results.append({ | 
				
			||||
 | 
					                            "file": file_path_shorten, | 
				
			||||
 | 
					                            "line": node.lineno, | 
				
			||||
 | 
					                            "issue": f"TransientModel class '{node.name}' is not in a 'wizard/' directory", | 
				
			||||
 | 
					                            "suggestion": "Move the file to a 'wizard/' folder to follow Odoo best practices" | 
				
			||||
 | 
					                        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_report_model_location(py_file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the report model is in the report folder. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        file_path (str): Full path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{py_file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    with open(py_file_path, "r", encoding="utf-8") as f: | 
				
			||||
 | 
					        source = f.read() | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    try: | 
				
			||||
 | 
					        tree = ast.parse(source) | 
				
			||||
 | 
					    except SyntaxError: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for node in ast.walk(tree): | 
				
			||||
 | 
					        if isinstance(node, ast.ClassDef): | 
				
			||||
 | 
					            is_abstract_model = any( | 
				
			||||
 | 
					                isinstance(base, ast.Attribute) and base.attr == "AbstractModel" | 
				
			||||
 | 
					                for base in node.bases | 
				
			||||
 | 
					            ) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					            if is_abstract_model: | 
				
			||||
 | 
					                for body_item in node.body: | 
				
			||||
 | 
					                    if isinstance(body_item, ast.Assign): | 
				
			||||
 | 
					                        for target in body_item.targets: | 
				
			||||
 | 
					                            if isinstance(target, ast.Name) and target.id == "_name": | 
				
			||||
 | 
					                                if isinstance(body_item.value, ast.Constant): | 
				
			||||
 | 
					                                    model_name = body_item.value.value | 
				
			||||
 | 
					                                    if (model_name.startswith("report.") and | 
				
			||||
 | 
					                                            "report" not in py_file_path.replace("\\", "/").split("/")): | 
				
			||||
 | 
					                                        results.append({ | 
				
			||||
 | 
					                                            "file": file_path_shorten, | 
				
			||||
 | 
					                                            "line": node.lineno, | 
				
			||||
 | 
					                                            "issue": f"Report model '{model_name}' is not in a 'report/' directory", | 
				
			||||
 | 
					                                            "suggestion": "Move the report model to a 'report/' folder" | 
				
			||||
 | 
					                                        }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_controller_naming(module_path, module_name): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the controller file naming. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        module_path (str): Full path to the Python file. | 
				
			||||
 | 
					        module_name (str): Module technical name. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    controller_dir = os.path.join(module_path, "controllers") | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{module_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    if not os.path.isdir(controller_dir): | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for filename in os.listdir(controller_dir): | 
				
			||||
 | 
					        filepath = os.path.join(controller_dir, filename) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        if not filename.endswith(".py"): | 
				
			||||
 | 
					            continue | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        if filename == "main.py": | 
				
			||||
 | 
					            results.append({ | 
				
			||||
 | 
					                "file": file_path_shorten, | 
				
			||||
 | 
					                "line": 1, | 
				
			||||
 | 
					                "issue": "'main.py' is deprecated for controller files", | 
				
			||||
 | 
					                "suggestion": f"Rename to '{module_name}.py' or the name of the inherited module" | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        if filename != f"{module_name}.py" and not filename.startswith("portal") and not filename.startswith("website"): | 
				
			||||
 | 
					            with open(filepath, "r", encoding="utf-8") as f: | 
				
			||||
 | 
					                content = f.read() | 
				
			||||
 | 
					                if "http.Controller" in content and f"{module_name}.py" not in os.listdir(controller_dir): | 
				
			||||
 | 
					                    results.append({ | 
				
			||||
 | 
					                        "file": file_path_shorten, | 
				
			||||
 | 
					                        "line": 1, | 
				
			||||
 | 
					                        "issue": f"'{filename}' may not follow the controller naming convention", | 
				
			||||
 | 
					                        "suggestion": f"Use '{module_name}.py' for your base controller, " | 
				
			||||
 | 
					                                      f"or name after the module you're inheriting" | 
				
			||||
 | 
					                    }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_compute_method_exact_naming(py_file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the compute function naming. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        file_path (str): Full path to the Python file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    file_path_shorten = f"{module_technical_name[0]}/{py_file_path.split(f'/{module_technical_name[0]}/')[1]}" | 
				
			||||
 | 
					    with open(py_file_path, "r", encoding="utf-8") as f: | 
				
			||||
 | 
					        source = f.read() | 
				
			||||
 | 
					    try: | 
				
			||||
 | 
					        tree = ast.parse(source) | 
				
			||||
 | 
					    except SyntaxError: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for node in ast.walk(tree): | 
				
			||||
 | 
					        if isinstance(node, ast.Assign): | 
				
			||||
 | 
					            for target in node.targets: | 
				
			||||
 | 
					                if isinstance(target, ast.Name): | 
				
			||||
 | 
					                    field_name = target.id | 
				
			||||
 | 
					                    for value in ast.walk(node.value): | 
				
			||||
 | 
					                        if isinstance(value, ast.Call) and isinstance(value.func, ast.Attribute): | 
				
			||||
 | 
					                            if value.func.attr in { | 
				
			||||
 | 
					                                "Char", "Text", "Integer", "Float", "Boolean", "Date", "Datetime", | 
				
			||||
 | 
					                                "Many2one", "One2many", "Many2many", "Html", "Binary", "Selection" | 
				
			||||
 | 
					                            }: | 
				
			||||
 | 
					                                for keyword in value.keywords: | 
				
			||||
 | 
					                                    if keyword.arg == "compute" and isinstance(keyword.value, ast.Constant): | 
				
			||||
 | 
					                                        compute_func = keyword.value.value | 
				
			||||
 | 
					                                        expected_name = f"_compute_{field_name}" | 
				
			||||
 | 
					                                        if compute_func != expected_name: | 
				
			||||
 | 
					                                            results.append({ | 
				
			||||
 | 
					                                                "file": file_path_shorten, | 
				
			||||
 | 
					                                                "line": keyword.lineno, | 
				
			||||
 | 
					                                                "issue": f"Compute method should be named '{expected_name}' for " | 
				
			||||
 | 
					                                                         f"field '{field_name}', found '{compute_func}'", | 
				
			||||
 | 
					                                                "suggestion": f"Rename compute method to '{expected_name}'" | 
				
			||||
 | 
					                                            }) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def analyze_file(file_path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Analyze a Python file using AST and apply all naming rules. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        file_path (str): Full path to the .py file. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    module_name = os.path.basename(file_path.rstrip("/\\"))  # Get folder name | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    check_python_file_name(file_path) | 
				
			||||
 | 
					    check_transient_model_location(file_path) | 
				
			||||
 | 
					    check_report_model_location(file_path) | 
				
			||||
 | 
					    check_controller_naming(file_path, module_name) | 
				
			||||
 | 
					    check_compute_method_exact_naming(file_path) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    try: | 
				
			||||
 | 
					        with open(file_path, "r", encoding="utf-8") as f: | 
				
			||||
 | 
					            tree = ast.parse(f.read(), filename=file_path) | 
				
			||||
 | 
					    except SyntaxError: | 
				
			||||
 | 
					        return | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    for node in ast.walk(tree): | 
				
			||||
 | 
					        if isinstance(node, ast.ClassDef): | 
				
			||||
 | 
					            check_class_name(node, file_path) | 
				
			||||
 | 
					            check_model_class(node, file_path) | 
				
			||||
 | 
					            check_missing_docstring(node, file_path, type="class") | 
				
			||||
 | 
					        elif isinstance(node, ast.FunctionDef): | 
				
			||||
 | 
					            check_function_name(node, file_path) | 
				
			||||
 | 
					            check_missing_docstring(node, file_path, type="function") | 
				
			||||
 | 
					        elif isinstance(node, ast.Assign): | 
				
			||||
 | 
					            check_field_suffix(node, file_path) | 
				
			||||
 | 
					            check_variable_naming(node, file_path) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def scan_directory(directory): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Recursively scan a directory and analyze all .py files, skipping __init__.py and __manifest__.py. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        directory (str): Path to the root directory. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    for root, _, files in os.walk(directory): | 
				
			||||
 | 
					        for file in files: | 
				
			||||
 | 
					            if file == "__manifest__.py": | 
				
			||||
 | 
					                check_manifest_file(os.path.join(root, file)) | 
				
			||||
 | 
					            if file.endswith(".py") and file not in {"__init__.py", "__manifest__.py"}: | 
				
			||||
 | 
					                analyze_file(os.path.join(root, file)) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_odoo_python_standards(name, path): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					       Perform a scan of Python files in the given directory to check for Odoo-specific coding standard violations. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					       This function clears previous results, sets the current module's technical name, and recursively scans the | 
				
			||||
 | 
					       given directory for Python files. Each file is analyzed for adherence to Odoo's Python coding standards, | 
				
			||||
 | 
					       and the results are accumulated. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					       Args: | 
				
			||||
 | 
					           name (str): The technical name of the Odoo module being checked. | 
				
			||||
 | 
					           path (str): The absolute or relative file path to the directory containing the module's Python code. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					       Returns: | 
				
			||||
 | 
					           list: A list of dictionaries containing information about detected coding standard violations. | 
				
			||||
 | 
					       """ | 
				
			||||
 | 
					    results.clear() | 
				
			||||
 | 
					    module_technical_name[0] = name | 
				
			||||
 | 
					    scan_directory(path) | 
				
			||||
 | 
					    return results | 
				
			||||
								
									Binary file not shown.
								
							
						
					@ -0,0 +1,316 @@ | 
				
			|||||
 | 
					# -*- coding: utf-8 -*- | 
				
			||||
 | 
					###################################################################################### | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    Cybrosys Technologies Pvt. Ltd. | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>) | 
				
			||||
 | 
					#    Author: Cybrosys Techno Solutions(<https://www.cybrosys.com>) | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1) | 
				
			||||
 | 
					#    It is forbidden to publish, distribute, sublicense, or sell copies of the Software | 
				
			||||
 | 
					#    or modified copies of the Software. | 
				
			||||
 | 
					# | 
				
			||||
 | 
					#    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | 
				
			||||
 | 
					#    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | 
				
			||||
 | 
					#    FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. | 
				
			||||
 | 
					#    IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, | 
				
			||||
 | 
					#    DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, | 
				
			||||
 | 
					#    ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER | 
				
			||||
 | 
					#    DEALINGS IN THE SOFTWARE. | 
				
			||||
 | 
					# | 
				
			||||
 | 
					###################################################################################### | 
				
			||||
 | 
					import json | 
				
			||||
 | 
					import re | 
				
			||||
 | 
					import subprocess | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					import sys | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					from odoo.modules import get_module_path | 
				
			||||
 | 
					from . import check_odoo_python_guidelines | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def violations_report(self, module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the violations for selected modules for PDF report. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        self (obj): The odoo ClassDef. | 
				
			||||
 | 
					        module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Returns: | 
				
			||||
 | 
					        dict: Module name and its violations | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    violations = get_violations(module) | 
				
			||||
 | 
					    report_dict = { | 
				
			||||
 | 
					        'module': self.env.ref(f'base.module_{module}').display_name, | 
				
			||||
 | 
					        'violations': { | 
				
			||||
 | 
					            "odoo_standards": violations.get('odoo_standards_check'), | 
				
			||||
 | 
					            "style_lint": violations.get('style_lint_check'), | 
				
			||||
 | 
					            "code_quality": violations.get('code_quality_check'), | 
				
			||||
 | 
					            "maintainability_index": violations.get('mi_check'), | 
				
			||||
 | 
					            "import_sort": violations.get('import_sort_check'), | 
				
			||||
 | 
					            "code_format": violations.get('code_format_check'), | 
				
			||||
 | 
					            "code_complexity": violations.get('cc_check'), | 
				
			||||
 | 
					            "security_scan": violations.get('security_scan') | 
				
			||||
 | 
					        } | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					    return report_dict | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def get_violations(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the violations for selected module. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Returns: | 
				
			||||
 | 
					        dict: Selected module violations | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    return { | 
				
			||||
 | 
					        "style_lint_check": check_style_lint(module), | 
				
			||||
 | 
					        "code_quality_check": check_code_quality(module), | 
				
			||||
 | 
					        "mi_check": check_maintainability_index(module), | 
				
			||||
 | 
					        "import_sort_check": check_import_sort(module), | 
				
			||||
 | 
					        "code_format_check": check_code_format(module), | 
				
			||||
 | 
					        "cc_check": check_code_complexity(module), | 
				
			||||
 | 
					        "security_scan": scan_code_security(module), | 
				
			||||
 | 
					        "odoo_standards_check": check_odoo_python_guidelines.check_odoo_python_standards( | 
				
			||||
 | 
					            name=module, path=get_module_path(module)) | 
				
			||||
 | 
					    } | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_style_lint(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the style and lint for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Returns: | 
				
			||||
 | 
					        list of dictionaries for the violations with the file name and line number. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    excluded_files = ['__init__.py', '__manifest__.py', '__pycache__', '*.pyc', '.git'] | 
				
			||||
 | 
					    ignored_violations = ['E501', 'E301', 'E302'] | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    venv_python = sys.executable | 
				
			||||
 | 
					    exclude_param = f"--exclude={','.join(excluded_files)}" | 
				
			||||
 | 
					    ignore_param = f"--ignore={','.join(ignored_violations)}" | 
				
			||||
 | 
					    module_path = get_module_path(module) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    result = subprocess.run( | 
				
			||||
 | 
					        [venv_python, '-m', 'flake8', '--select=D', exclude_param, ignore_param, module_path], | 
				
			||||
 | 
					        stdout=subprocess.PIPE, | 
				
			||||
 | 
					        stderr=subprocess.PIPE, | 
				
			||||
 | 
					        text=True | 
				
			||||
 | 
					    ) | 
				
			||||
 | 
					    violations = result.stdout.strip().splitlines() | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    violations_list = [] | 
				
			||||
 | 
					    for violation in violations: | 
				
			||||
 | 
					        parts = violation.split(":", 3) | 
				
			||||
 | 
					        if len(parts) >= 4: | 
				
			||||
 | 
					            file_path = f"{module}/{parts[0].split(f'/{module}/')[1]}" | 
				
			||||
 | 
					            violations_list.append({ | 
				
			||||
 | 
					                'file_name': file_path, | 
				
			||||
 | 
					                'line_number': parts[1], | 
				
			||||
 | 
					                'violation_message': parts[3] | 
				
			||||
 | 
					            }) | 
				
			||||
 | 
					    return violations_list | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_code_quality(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    Check the code quality for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Args: | 
				
			||||
 | 
					        module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    Returns: | 
				
			||||
 | 
					        list of dictionaries for the violations with the file name, code, line and column number. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    pylint_result = subprocess.run([ | 
				
			||||
 | 
					        sys.executable, '-m', 'pylint', | 
				
			||||
 | 
					        '--load-plugins=pylint_odoo', | 
				
			||||
 | 
					        '-d all', | 
				
			||||
 | 
					        '-e odoolint', | 
				
			||||
 | 
					        '--ignore-patterns=__init__.py,__manifest__.py', | 
				
			||||
 | 
					        get_module_path(module) | 
				
			||||
 | 
					    ], | 
				
			||||
 | 
					        stdout=subprocess.PIPE, | 
				
			||||
 | 
					        stderr=subprocess.PIPE, | 
				
			||||
 | 
					        text=True | 
				
			||||
 | 
					    ) | 
				
			||||
 | 
					    output = pylint_result.stdout.strip().splitlines() | 
				
			||||
 | 
					    result = [] | 
				
			||||
 | 
					    for rec in output: | 
				
			||||
 | 
					        if rec.startswith(('*', '-')) or rec == '': | 
				
			||||
 | 
					            continue | 
				
			||||
 | 
					        else: | 
				
			||||
 | 
					            rec = rec.split(':') | 
				
			||||
 | 
					            if len(rec) >= 5: | 
				
			||||
 | 
					                file_path = f"{module}/{rec[0].split(f'/{module}/')[1]}" | 
				
			||||
 | 
					                result.append({ | 
				
			||||
 | 
					                    'file': file_path, | 
				
			||||
 | 
					                    'line': rec[1], | 
				
			||||
 | 
					                    'column': rec[2], | 
				
			||||
 | 
					                    'code': rec[3].strip(), | 
				
			||||
 | 
					                    'message': rec[4].strip(), | 
				
			||||
 | 
					                }) | 
				
			||||
 | 
					    return result | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_maintainability_index(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					        Check the maintainability index for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Args: | 
				
			||||
 | 
					            module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Returns: | 
				
			||||
 | 
					            list of dictionaries for the file name and grade. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    result = subprocess.run([ | 
				
			||||
 | 
					        sys.executable, '-m', 'radon', | 
				
			||||
 | 
					        'mi', | 
				
			||||
 | 
					        get_module_path(module), | 
				
			||||
 | 
					    ], | 
				
			||||
 | 
					        stdout=subprocess.PIPE, | 
				
			||||
 | 
					        stderr=subprocess.PIPE, | 
				
			||||
 | 
					        text=True | 
				
			||||
 | 
					    ) | 
				
			||||
 | 
					    excluded_files = ("__init__.py", "__manifest__.py") | 
				
			||||
 | 
					    mi_list = [] | 
				
			||||
 | 
					    for rec in result.stdout.splitlines(): | 
				
			||||
 | 
					        if not any(exclude in rec for exclude in excluded_files): | 
				
			||||
 | 
					            rec = rec.split(' - ') | 
				
			||||
 | 
					            if len(rec) >= 2: | 
				
			||||
 | 
					                file_path = f"{module}/{rec[0].split(f'/{module}/')[1]}" | 
				
			||||
 | 
					                mi_list.append({'file': file_path, 'grade': rec[1]}) | 
				
			||||
 | 
					    return mi_list | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_import_sort(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					        Check the import sorting for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Args: | 
				
			||||
 | 
					            module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Returns: | 
				
			||||
 | 
					            list of dictionaries for the file name and message. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    result = subprocess.run([ | 
				
			||||
 | 
					        sys.executable, '-m', 'isort', | 
				
			||||
 | 
					        '--check-only', | 
				
			||||
 | 
					        '--skip', '__init__.py', | 
				
			||||
 | 
					        '--skip', '__manifest__.py', | 
				
			||||
 | 
					        get_module_path(module), | 
				
			||||
 | 
					    ], | 
				
			||||
 | 
					        stdout=subprocess.PIPE, | 
				
			||||
 | 
					        stderr=subprocess.PIPE, | 
				
			||||
 | 
					        text=True, | 
				
			||||
 | 
					    ) | 
				
			||||
 | 
					    import_sort_list = [] | 
				
			||||
 | 
					    for rec in result.stderr.splitlines(): | 
				
			||||
 | 
					        rec = rec.replace("ERROR: ", "") | 
				
			||||
 | 
					        parts = rec.split(" ", 1) | 
				
			||||
 | 
					        if len(parts) >= 2: | 
				
			||||
 | 
					            file_path = f"{module}/{parts[0].split(f'/{module}/')[1]}" | 
				
			||||
 | 
					            import_sort_list.append({'file': file_path, 'message': parts[1]}) | 
				
			||||
 | 
					    return import_sort_list | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def parse_black_output_simple(line): | 
				
			||||
 | 
					    """Simple parsing for all Black output types""" | 
				
			||||
 | 
					    actions = ['would reformat ', 'reformatted ', 'formatted ', 'would format '] | 
				
			||||
 | 
					    for action in actions: | 
				
			||||
 | 
					        if line.startswith(action): | 
				
			||||
 | 
					            filename = line[len(action):] | 
				
			||||
 | 
					            return action.strip(), filename | 
				
			||||
 | 
					    return None, None | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def is_file_line(line): | 
				
			||||
 | 
					    """Check if line is a file entry (not summary)""" | 
				
			||||
 | 
					    skip_patterns = ['💥', '💔', '!', 'files would be', 'file would be', 'All done'] | 
				
			||||
 | 
					    actions = ['would reformat ', 'reformatted ', 'formatted ', 'would format '] | 
				
			||||
 | 
					    if any(pattern in line for pattern in skip_patterns): | 
				
			||||
 | 
					        return False | 
				
			||||
 | 
					    return any(line.startswith(action) for action in actions) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_code_format(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					        Check the code format for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Args: | 
				
			||||
 | 
					            module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Returns: | 
				
			||||
 | 
					            list of dictionaries for the file name and action. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    result = subprocess.run([ | 
				
			||||
 | 
					        sys.executable, '-m', 'black', | 
				
			||||
 | 
					        '--check', | 
				
			||||
 | 
					        '--exclude', '__init__.py|__manifest__.py', | 
				
			||||
 | 
					        get_module_path(module), | 
				
			||||
 | 
					    ], | 
				
			||||
 | 
					        stdout=subprocess.PIPE, | 
				
			||||
 | 
					        stderr=subprocess.PIPE, | 
				
			||||
 | 
					        text=True, | 
				
			||||
 | 
					    ) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    code_format_list = [] | 
				
			||||
 | 
					    for line in result.stderr.strip().split('\n'): | 
				
			||||
 | 
					        line = line.strip() | 
				
			||||
 | 
					        if not line or not is_file_line(line): | 
				
			||||
 | 
					            continue | 
				
			||||
 | 
					        parsed = parse_black_output_simple(line) | 
				
			||||
 | 
					        if parsed[0] is not None: | 
				
			||||
 | 
					            code_format_list.append(parsed) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    return code_format_list | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def check_code_complexity(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					        Check the code complexity for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Args: | 
				
			||||
 | 
					            module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Returns: | 
				
			||||
 | 
					            list of dictionaries for the file name and grade. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    result = subprocess.run([ | 
				
			||||
 | 
					        sys.executable, '-m', 'radon', | 
				
			||||
 | 
					        'cc', | 
				
			||||
 | 
					        '--json', | 
				
			||||
 | 
					        get_module_path(module), | 
				
			||||
 | 
					    ], | 
				
			||||
 | 
					        capture_output=True, | 
				
			||||
 | 
					        text=True | 
				
			||||
 | 
					    ) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if result.returncode == 0 and result.stdout: | 
				
			||||
 | 
					        clean_stdout = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout) | 
				
			||||
 | 
					        cc_dict = json.loads(clean_stdout.strip()) | 
				
			||||
 | 
					        return cc_dict | 
				
			||||
 | 
					    return {} | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					def scan_code_security(module): | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					        Scan the code security for selected module . | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Args: | 
				
			||||
 | 
					            module (str): The module technical name. | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					        Returns: | 
				
			||||
 | 
					            list of dictionaries of the security scan. | 
				
			||||
 | 
					    """ | 
				
			||||
 | 
					    result = subprocess.run([ | 
				
			||||
 | 
					        sys.executable, '-m', 'bandit', | 
				
			||||
 | 
					        '-r', get_module_path(module), | 
				
			||||
 | 
					        '-x', '__init__.py,__manifest__.py', | 
				
			||||
 | 
					        '-f', 'json' | 
				
			||||
 | 
					    ], | 
				
			||||
 | 
					        stdout=subprocess.PIPE, | 
				
			||||
 | 
					        stderr=subprocess.PIPE, | 
				
			||||
 | 
					        text=True) | 
				
			||||
 | 
					
 | 
				
			||||
 | 
					    if result.stdout: | 
				
			||||
 | 
					        security_list = json.loads(result.stdout).get('results', []) | 
				
			||||
 | 
					        return security_list | 
				
			||||
 | 
					    return [] | 
				
			||||
					Loading…
					
					
				
		Reference in new issue