[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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user