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