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