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