1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3import ast 4import pathlib 5import os 6import re 7import shutil 8 9import odoo 10from odoo.tools.config import config 11 12VERSION = 1 13DEFAULT_EXCLUDE = [ 14 "__manifest__.py", 15 "__openerp__.py", 16 "tests/**/*", 17 "static/lib/**/*", 18 "static/tests/**/*", 19 "migrations/**/*", 20] 21 22STANDARD_MODULES = ['web', 'web_enterprise', 'website_animate', 'base'] 23MAX_FILE_SIZE = 25 * 2**20 # 25 MB 24 25class Cloc(object): 26 def __init__(self): 27 self.modules = {} 28 self.code = {} 29 self.total = {} 30 self.errors = {} 31 self.max_width = 70 32 33 #------------------------------------------------------ 34 # Parse 35 #------------------------------------------------------ 36 def parse_xml(self, s): 37 s = s.strip() + "\n" 38 # Unbalanced xml comments inside a CDATA are not supported, and xml 39 # comments inside a CDATA will (wrongly) be considered as comment 40 total = s.count("\n") 41 s = re.sub("(<!--.*?-->)", "", s, flags=re.DOTALL) 42 s = re.sub(r"\s*\n\s*", r"\n", s).lstrip() 43 return s.count("\n"), total 44 45 def parse_py(self, s): 46 try: 47 s = s.strip() + "\n" 48 total = s.count("\n") 49 lines = set() 50 for i in ast.walk(ast.parse(s)): 51 # we only count 1 for a long string or a docstring 52 if hasattr(i, 'lineno'): 53 lines.add(i.lineno) 54 return len(lines), total 55 except Exception: 56 return (-1, "Syntax Error") 57 58 def parse_js(self, s): 59 # Based on https://stackoverflow.com/questions/241327 60 s = s.strip() + "\n" 61 total = s.count("\n") 62 def replacer(match): 63 s = match.group(0) 64 return " " if s.startswith('/') else s 65 comments_re = re.compile(r'//.*?$|(?<!\\)/\*.*?\*/|\'(\\.|[^\\\'])*\'|"(\\.|[^\\"])*"', re.DOTALL|re.MULTILINE) 66 s = re.sub(comments_re, replacer, s) 67 s = re.sub(r"\s*\n\s*", r"\n", s).lstrip() 68 return s.count("\n"), total 69 70 #------------------------------------------------------ 71 # Enumeration 72 #------------------------------------------------------ 73 def book(self, module, item='', count=(0, 0)): 74 if count[0] == -1: 75 self.errors.setdefault(module, {}) 76 self.errors[module][item] = count[1] 77 else: 78 self.modules.setdefault(module, {}) 79 if item: 80 self.modules[module][item] = count 81 self.code[module] = self.code.get(module, 0) + count[0] 82 self.total[module] = self.total.get(module, 0) + count[1] 83 self.max_width = max(self.max_width, len(module), len(item) + 4) 84 85 def count_path(self, path, exclude=None): 86 path = path.rstrip('/') 87 exclude_list = [] 88 for i in odoo.modules.module.MANIFEST_NAMES: 89 manifest_path = os.path.join(path, i) 90 try: 91 with open(manifest_path, 'rb') as manifest: 92 exclude_list.extend(DEFAULT_EXCLUDE) 93 d = ast.literal_eval(manifest.read().decode('latin1')) 94 for j in ['cloc_exclude', 'demo', 'demo_xml']: 95 exclude_list.extend(d.get(j, [])) 96 break 97 except Exception: 98 pass 99 if not exclude: 100 exclude = set() 101 for i in exclude_list: 102 exclude.update(str(p) for p in pathlib.Path(path).glob(i)) 103 104 module_name = os.path.basename(path) 105 self.book(module_name) 106 for root, dirs, files in os.walk(path): 107 for file_name in files: 108 file_path = os.path.join(root, file_name) 109 110 if file_path in exclude: 111 continue 112 113 ext = os.path.splitext(file_path)[1].lower() 114 if ext in ['.py', '.js', '.xml']: 115 if os.path.getsize(file_path) > MAX_FILE_SIZE: 116 self.book(module_name, file_path, (-1, "Max file size exceeded")) 117 continue 118 119 with open(file_path, 'rb') as f: 120 content = f.read().decode('latin1') 121 if ext == '.py': 122 self.book(module_name, file_path, self.parse_py(content)) 123 elif ext == '.js': 124 self.book(module_name, file_path, self.parse_js(content)) 125 elif ext == '.xml': 126 self.book(module_name, file_path, self.parse_xml(content)) 127 128 def count_modules(self, env): 129 # Exclude standard addons paths 130 exclude_heuristic = [odoo.modules.get_module_path(m, display_warning=False) for m in STANDARD_MODULES] 131 exclude_path = set([os.path.dirname(os.path.realpath(m)) for m in exclude_heuristic if m]) 132 133 domain = [('state', '=', 'installed')] 134 # if base_import_module is present 135 if env['ir.module.module']._fields.get('imported'): 136 domain.append(('imported', '=', False)) 137 module_list = env['ir.module.module'].search(domain).mapped('name') 138 139 for module_name in module_list: 140 module_path = os.path.realpath(odoo.modules.get_module_path(module_name)) 141 if module_path: 142 if any(module_path.startswith(i) for i in exclude_path): 143 continue 144 self.count_path(module_path) 145 146 def count_customization(self, env): 147 imported_module = "" 148 if env['ir.module.module']._fields.get('imported'): 149 imported_module = "OR (m.imported = TRUE AND m.state = 'installed')" 150 query = """ 151 SELECT s.id, m.name FROM ir_act_server AS s 152 LEFT JOIN ir_model_data AS d ON (d.res_id = s.id AND d.model = 'ir.actions.server') 153 LEFT JOIN ir_module_module AS m ON m.name = d.module 154 WHERE s.state = 'code' AND (m.name IS null {}) 155 """.format(imported_module) 156 env.cr.execute(query) 157 data = {r[0]: r[1] for r in env.cr.fetchall()} 158 for a in env['ir.actions.server'].browse(data.keys()): 159 self.book(data[a.id] or "odoo/studio", "ir.actions.server/%s: %s" % (a.id, a.name), self.parse_py(a.code)) 160 161 query = """ 162 SELECT f.id, m.name FROM ir_model_fields AS f 163 LEFT JOIN ir_model_data AS d ON (d.res_id = f.id AND d.model = 'ir.model.fields') 164 LEFT JOIN ir_module_module AS m ON m.name = d.module 165 WHERE f.compute IS NOT null AND (m.name IS null {}) 166 """.format(imported_module) 167 env.cr.execute(query) 168 data = {r[0]: r[1] for r in env.cr.fetchall()} 169 for f in env['ir.model.fields'].browse(data.keys()): 170 self.book(data[f.id] or "odoo/studio", "ir.model.fields/%s: %s" % (f.id, f.name), self.parse_py(f.compute)) 171 172 def count_env(self, env): 173 self.count_modules(env) 174 self.count_customization(env) 175 176 def count_database(self, database): 177 with odoo.api.Environment.manage(): 178 registry = odoo.registry(config['db_name']) 179 with registry.cursor() as cr: 180 uid = odoo.SUPERUSER_ID 181 env = odoo.api.Environment(cr, uid, {}) 182 self.count_env(env) 183 184 #------------------------------------------------------ 185 # Report 186 #------------------------------------------------------ 187 def report(self, verbose=False, width=None): 188 # Prepare format 189 if not width: 190 width = min(self.max_width, shutil.get_terminal_size()[0] - 24) 191 hr = "-" * (width + 24) + "\n" 192 fmt = '{k:%d}{lines:>8}{other:>8}{code:>8}\n' % (width,) 193 194 # Render 195 s = fmt.format(k="Odoo cloc", lines="Line", other="Other", code="Code") 196 s += hr 197 for m in sorted(self.modules): 198 s += fmt.format(k=m, lines=self.total[m], other=self.total[m]-self.code[m], code=self.code[m]) 199 if verbose: 200 for i in sorted(self.modules[m], key=lambda i: self.modules[m][i][0], reverse=True): 201 code, total = self.modules[m][i] 202 s += fmt.format(k=' ' + i, lines=total, other=total - code, code=code) 203 s += hr 204 total = sum(self.total.values()) 205 code = sum(self.code.values()) 206 s += fmt.format(k='', lines=total, other=total - code, code=code) 207 print(s) 208 209 if self.errors: 210 e = "\nErrors\n\n" 211 for m in sorted(self.errors): 212 e += "{}\n".format(m) 213 for i in sorted(self.errors[m]): 214 e += fmt.format(k=' ' + i, lines=self.errors[m][i], other='', code='') 215 print(e) 216