[ADD] cloc: count loc in style file and from imported module

Context
-------

Web design specific require a lot of work and need maintenance.
So far the style files were not taken into account despite the
amount of maintenance they require.

With this commit they are going to be counted,
knowing they still can be excluded in the manifest.

Imported module allow to deploy frontend assets: stylesheet,
javascript, xml template and Qweb view that require
as well some maintenance. They are going to be counted

Implementation
--------------

- Add method to parse css and scss file
- Include .scss and .css file in the count

- Add external_id to attachment that store
  frontend asset of imported module
- Find all attachment with .js, .css, .scss, .xml
  from imported module
- Find qweb view from imported module
- cound the content of the attachment and the qweb view

closes odoo/odoo#86816

X-original-commit: d2e0b48271dbd8cd7bbdbc5c2c1649c55c51417b
Signed-off-by: Christophe Simonis <chs@odoo.com>
Signed-off-by: Thibault Francois <tfr@odoo.com>
This commit is contained in:
Thibault Francois
2022-03-21 17:57:05 +01:00
parent e9e1864153
commit dc8f06624f
5 changed files with 227 additions and 25 deletions
@@ -104,7 +104,13 @@ class IrModule(models.Model):
if attachment:
attachment.write(values)
else:
IrAttachment.create(values)
attachment = IrAttachment.create(values)
self.env['ir.model.data'].create({
'name': f"attachment_{url_path}".replace('.', '_'),
'model': 'ir.attachment',
'module': module,
'res_id': attachment.id,
})
IrAsset = self.env['ir.asset']
assets_vals = []
@@ -139,7 +145,7 @@ class IrModule(models.Model):
# Create new assets and attach 'ir.model.data' records to them
created_assets = IrAsset.create(assets_to_create)
self.env['ir.model.data'].create([{
'name': f"{asset['bundle']}.{asset['path']}",
'name': f"{asset['bundle']}_{asset['path']}".replace(".", "_"),
'model': 'ir.asset',
'module': module,
'res_id': asset.id,
+80 -2
View File
@@ -1,9 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from io import BytesIO
from zipfile import ZipFile
from odoo.tools import cloc
from odoo.addons.base.tests import test_cloc
VALID_XML = """
<templates id="template" xml:space="preserve">
<t t-name="stock_barcode.LineComponent">
<div t-if="line.picking_id and line.picking_id.origin" name="origin">
<i class="fa fa-fw fa-file" />
<span t-esc="line.picking_id.origin" />
</div>
</t>
</templates>
"""
class TestClocFields(test_cloc.TestClocCustomization):
def test_fields_from_import_module(self):
@@ -17,12 +31,76 @@ class TestClocFields(test_cloc.TestClocCustomization):
'imported': True,
})
f1 = self.create_field('x_imported_field')
self.create_xml_id('import_field', f1.id, 'imported_module')
self.create_xml_id('import_field', 'ir.model.fields', f1.id, 'imported_module')
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('imported_module', 0), 1, 'Count fields with xml_id of imported module')
f2 = self.create_field('x_base_field')
self.create_xml_id('base_field', f2.id, 'base')
self.create_xml_id('base_field', 'ir.model.fields', f2.id, 'base')
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('base', 0), 0, "Don't count fields from standard module")
def test_count_qweb_imported_module(self):
self.env['ir.module.module'].create({
'author': 'Odoo',
'imported': True,
'latest_version': '15.0.1.0.0',
'name': 'test_imported_module',
'state': 'installed',
'summary': 'Test imported module for cloc',
})
qweb_view = self.env['ir.ui.view'].create({
"name": "Qweb Test",
"type": "qweb",
"mode": "primary",
"arch_base": "<html>\n <body>\n <t t-out=\"Hello World\"/>\n </body>\n</html>",
})
self.create_xml_id("qweb_view_test", 'ir.ui.view', qweb_view.id, 'test_imported_module')
# Add qweb view from non imported module
qweb_view = self.env['ir.ui.view'].create({
"name": "Qweb Test",
"type": "qweb",
"arch_base": "<html>\n <body>\n <t t-out=\"Hello World\"/>\n </body>\n</html>",
})
self.create_xml_id("qweb_view_test", 'ir.ui.view', qweb_view.id)
# Add form view from module
form_view = self.env['ir.ui.view'].create({
"name": "Test partner",
"type": "form",
"model": "res.partner",
"arch_base": "<form>\n <field name=\"name\" \n invisible=\"1\" />\n</form>",
})
self.create_xml_id("form_view_test", 'ir.ui.view', form_view.id, 'test_imported_module')
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('test_imported_module', 0), 5, "Count only qweb view from imported module")
def test_count_attachment_imported_module(self):
manifest_content = json.dumps({
'name': 'test_imported_module',
'description': 'Test',
'assets': {
'web.assets_backend': [
'test_imported_module/static/src/js/test.js',
'test_imported_module/static/src/css/test.scss',
]
},
'license': 'LGPL-3',
})
stream = BytesIO()
with ZipFile(stream, 'w') as archive:
archive.writestr('test_imported_module/__manifest__.py', manifest_content)
archive.writestr('test_imported_module/static/src/js/test.js', test_cloc.JS_TEST)
archive.writestr('test_imported_module/static/src/js/test.scss', test_cloc.SCSS_TEST)
archive.writestr('test_imported_module/static/src/js/test.xml', VALID_XML)
# Import test module
self.env['ir.module.module'].import_zipfile(stream)
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('test_imported_module', 0), 35)
@@ -44,13 +44,13 @@ class TestImportModule(odoo.tests.TransactionCase):
asset_data = self.env['ir.model.data'].search([('model', '=', 'ir.asset'), ('res_id', '=', asset.id)])
self.assertEqual(asset_data.module, 'test_module')
self.assertEqual(asset_data.name, f'{bundle}.{path}')
self.assertEqual(asset_data.name, f'{bundle}_{path}'.replace(".", "_"))
# Uninstall test module
self.env['ir.module.module'].search([('name', '=', 'test_module')]).module_uninstall()
attachment = self.env['ir.attachment'].search([('url', '=', path)])
self.assertEqual(len(attachment), 1)
self.assertEqual(len(attachment), 0)
asset = self.env['ir.asset'].search([('name', '=', f'test_module.{bundle}.{path}')])
self.assertEqual(len(asset), 0)
+58 -5
View File
@@ -79,11 +79,60 @@ function() {
}
'''
CSS_TEST = '''
/*
Comment
*/
p {
text-align: center;
color: red;
text-overflow: ' /* ';
}
#content, #footer, #supplement {
position: absolute;
left: 510px;
width: 200px;
text-overflow: ' */ ';
}
'''
SCSS_TEST = '''
/*
Comment
*/
// Standalone list views
.o_content > .o_list_view > .table-responsive > .table {
// List views always have the table-sm class, maybe we should remove
// it (and consider it does not exist) and change the default table paddings
@include o-list-view-full-width-padding($base: $table-cell-padding-sm, $ratio: 2);
&:not(.o_list_table_grouped) {
@include media-breakpoint-up(xl) {
@include o-list-view-full-width-padding($base: $table-cell-padding-sm, $ratio: 2.5);
}
}
.o_optional_columns_dropdown_toggle {
padding: 8px 10px;
}
}
#content, #footer, #supplement {
text-overflow: '/*';
left: 510px;
width: 200px;
text-overflow: '*/';
}
'''
class TestClocCustomization(TransactionCase):
def create_xml_id(self, name, res_id, module='studio_customization'):
def create_xml_id(self, name, model, res_id, module='studio_customization'):
self.env['ir.model.data'].create({
'name': name,
'model': 'ir.model.fields',
'model': model,
'res_id': res_id,
'module': module,
})
@@ -105,12 +154,12 @@ class TestClocCustomization(TransactionCase):
Having an xml_id but no existing module is consider as not belonging to a module
"""
f1 = self.create_field('x_invoice_count')
self.create_xml_id('invoice_count', f1.id)
self.create_xml_id('invoice_count', 'ir.model.fields', f1.id)
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('odoo/studio', 0), 0, 'Studio auto generated count field should not be counted in cloc')
f2 = self.create_field('x_studio_custom_field')
self.create_xml_id('studio_custom', f2.id)
self.create_xml_id('studio_custom', 'ir.model.fields', f2.id)
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('odoo/studio', 0), 1, 'Count other studio computed field')
@@ -119,7 +168,7 @@ class TestClocCustomization(TransactionCase):
cl.count_customization(self.env)
self.assertEqual(cl.code.get('odoo/studio', 0), 2, 'Count fields without xml_id')
f4 = self.create_field('x_custom_field_export')
self.create_xml_id('studio_custom', f4.id, '__export__')
self.create_xml_id('studio_custom', 'ir.model.fields', f4.id, '__export__')
cl = cloc.Cloc()
cl.count_customization(self.env)
self.assertEqual(cl.code.get('odoo/studio', 0), 3, 'Count fields with xml_id but without module')
@@ -143,6 +192,10 @@ class TestClocParser(TransactionCase):
self.assertEqual(py_count, (8, 16))
js_count = cl.parse_js(JS_TEST)
self.assertEqual(js_count, (10, 17))
css_count = cl.parse_css(CSS_TEST)
self.assertEqual(css_count, (11, 17))
scss_count = cl.parse_scss(SCSS_TEST)
self.assertEqual(scss_count, (17, 26))
@tagged('post_install', '-at_install')
+79 -14
View File
@@ -22,6 +22,7 @@ DEFAULT_EXCLUDE = [
STANDARD_MODULES = ['web', 'web_enterprise', 'theme_common', 'base']
MAX_FILE_SIZE = 25 * 2**20 # 25 MB
VALID_EXTENSION = ['.py', '.js', '.xml', '.css', '.scss']
class Cloc(object):
def __init__(self):
@@ -56,18 +57,41 @@ class Cloc(object):
except Exception:
return (-1, "Syntax Error")
def parse_js(self, s):
def parse_c_like(self, s, regex):
# Based on https://stackoverflow.com/questions/241327
s = s.strip() + "\n"
total = s.count("\n")
def replacer(match):
s = match.group(0)
return " " if s.startswith('/') else s
comments_re = re.compile(r'//.*?$|(?<!\\)/\*.*?\*/|\'(\\.|[^\\\'])*\'|"(\\.|[^\\"])*"', re.DOTALL|re.MULTILINE)
comments_re = re.compile(regex, re.DOTALL | re.MULTILINE)
s = re.sub(comments_re, replacer, s)
s = re.sub(r"\s*\n\s*", r"\n", s).lstrip()
return s.count("\n"), total
def parse_js(self, s):
return self.parse_c_like(s, r'//.*?$|(?<!\\)/\*.*?\*/|\'(\\.|[^\\\'])*\'|"(\\.|[^\\"])*"')
def parse_scss(self, s):
return self.parse_c_like(s, r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"')
def parse_css(self, s):
return self.parse_c_like(s, r'/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"')
def parse(self, s, ext):
if ext == '.py':
return self.parse_py(s)
elif ext == '.js':
return self.parse_js(s)
elif ext == '.xml':
return self.parse_xml(s)
elif ext == '.css':
return self.parse_css(s)
elif ext == '.scss':
return self.parse_scss(s)
#------------------------------------------------------
# Enumeration
#------------------------------------------------------
@@ -112,19 +136,18 @@ class Cloc(object):
continue
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.py', '.js', '.xml']:
if os.path.getsize(file_path) > MAX_FILE_SIZE:
self.book(module_name, file_path, (-1, "Max file size exceeded"))
continue
if ext not in VALID_EXTENSION:
continue
with open(file_path, 'rb') as f:
content = f.read().decode('latin1')
if ext == '.py':
self.book(module_name, file_path, self.parse_py(content))
elif ext == '.js':
self.book(module_name, file_path, self.parse_js(content))
elif ext == '.xml':
self.book(module_name, file_path, self.parse_xml(content))
if os.path.getsize(file_path) > MAX_FILE_SIZE:
self.book(module_name, file_path, (-1, "Max file size exceeded"))
continue
with open(file_path, 'rb') as f:
# Decode using latin1 to avoid error that may raise by decoding with utf8
# The chars not correctly decoded in latin1 have no impact on how many lines will be counted
content = f.read().decode('latin1')
self.book(module_name, file_path, self.parse(content, ext))
def count_modules(self, env):
# Exclude standard addons paths
@@ -175,6 +198,48 @@ class Cloc(object):
for f in env['ir.model.fields'].browse(data.keys()):
self.book(data[f.id] or "odoo/studio", "ir.model.fields/%s: %s" % (f.id, f.name), self.parse_py(f.compute))
if not imported_module:
return
# Count qweb view only from imported module
query = """
SELECT view.id, data.module
FROM ir_ui_view view
INNER JOIN ir_model_data data ON view.id = data.res_id AND data.model = 'ir.ui.view'
INNER JOIN ir_module_module mod ON mod.name = data.module AND mod.imported = True
WHERE view.type = 'qweb'
"""
env.cr.execute(query)
custom_views = dict(env.cr.fetchall())
for view in env['ir.ui.view'].browse(custom_views.keys()):
modue_name = custom_views[view.id]
self.book(modue_name, "/%s/views/%s.xml" % (modue_name, view.name), self.parse_xml(view.arch_base))
# Count js, xml, css/scss file from imported module
query = r"""
SELECT attach.id, data.module
FROM ir_attachment attach
INNER JOIN ir_model_data data ON attach.id = data.res_id AND data.model = 'ir.attachment'
INNER JOIN ir_module_module mod ON mod.name = data.module AND mod.imported = True
WHERE attach.name ~ '.*\.(js|xml|css|scss)$'
"""
env.cr.execute(query)
uploaded_file = dict(env.cr.fetchall())
for attach in env['ir.attachment'].browse(uploaded_file.keys()):
module_name = uploaded_file[attach.id]
ext = os.path.splitext(attach.url)[1].lower()
if ext not in VALID_EXTENSION:
continue
if len(attach.datas) > MAX_FILE_SIZE:
self.book(module_name, attach.url, (-1, "Max file size exceeded"))
continue
# Decode using latin1 to avoid error that may raise by decoding with utf8
# The chars not correctly decoded in latin1 have no impact on how many lines will be counted
content = attach.raw.decode('latin1')
self.book(module_name, attach.url, self.parse(content, ext))
def count_env(self, env):
self.count_modules(env)
self.count_customization(env)