Browse Source

Merge pull request #375 from vauxoo-dev/8.0-oca-odoo-profiler-moy

[ADD] profiler: Add module profiler
pull/909/head
Pedro M. Baeza 7 years ago
committed by GitHub
parent
commit
050aa94168
  1. 95
      profiler/README.rst
  2. 6
      profiler/__init__.py
  3. 32
      profiler/__openerp__.py
  4. 5
      profiler/controllers/__init__.py
  5. 228
      profiler/controllers/main.py
  6. 16
      profiler/data/profiler_excluding.xml
  7. 103
      profiler/hooks.py
  8. 11
      profiler/security/group.xml
  9. BIN
      profiler/static/description/clear_stats.png
  10. BIN
      profiler/static/description/dump_stats.png
  11. BIN
      profiler/static/description/player.png
  12. BIN
      profiler/static/description/start_profiling.png
  13. BIN
      profiler/static/description/stop_profiling.png
  14. 21
      profiler/static/src/css/player.css
  15. 52
      profiler/static/src/js/player.js
  16. 52
      profiler/static/src/js/test_profiler.js
  17. 24
      profiler/static/src/less/player.less
  18. 26
      profiler/static/src/xml/player.xml
  19. 5
      profiler/tests/__init__.py
  20. 17
      profiler/tests/test_profiler.py
  21. 12
      profiler/views/assets.xml
  22. 11
      profiler/views/profiler.xml
  23. 1
      requirements.txt

95
profiler/README.rst

@ -0,0 +1,95 @@
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
========
Profiler
========
Integration of cProfile and PgBadger.
Installation
============
To install this module, you need the following requirements:
* Install `pgbadger <http://dalibo.github.io/pgbadger/>`_ binary package.
* Install `pstats_print2list <https://pypi.python.org/pypi/pstats_print2list>`_ python package.
* Set `PG_LOG_PATH` environment variable to know location of the `postgresql.log` file by default is `/var/lib/postgresql/9.X/main/pg_log/postgresql.log`
* Enable postgresql logs from postgresql's configuration file (Default location for Linux Debian is `/etc/postgresql/*/main/postgresql.conf`)
- Add the following lines at final (A postgresql restart is required `/etc/init.d/postgresql restart`)
.. code-block:: text
logging_collector=on
log_destination='stderr'
log_directory='pg_log'
log_filename='postgresql.log'
log_rotation_age=0
log_checkpoints=on
log_hostname=on
log_line_prefix='%t [%p]: [%l-1] db=%d,user=%u '
Configuration
=============
By default profiler module adds two system parameters
- exclude_fnames > '/.repo_requirements,~/odoo-8.0,/usr/,>'
- exclude_query > 'ir_translation'.
These parameters can be configurated in order to exclude some outputs from
profiling stats or pgbadger output.
Usage
=====
After installation, a player is add on the header bar, with following buttons:
- .. figure:: static/description/player.png
:alt: Player to manage profiler
* Start profiling
- .. figure:: static/description/start_profiling.png
:alt: Start profiling
:height: 35px
* Stop profiling
- .. figure:: static/description/stop_profiling.png
:alt: Stop profiling
:height: 35px
* Download stats: download stats file
- .. figure:: static/description/dump_stats.png
:alt: Download cprofile stats file
:height: 35px
* Clear stats
- .. figure:: static/description/clear_stats.png
:alt: Clear and remove stats file
:height: 35px
Credits
=======
Contributors
------------
* Georges Racinet <gracinet@anybox.fr>
- Remotely inspired from ZopeProfiler, although there is no online visualisation and there may never be one.
* Moisés López <moylop260@vauxoo.com>
* Hugo Adan <hugo@vauxoo.com>
Maintainer
----------
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
This module is maintained by the OCA.
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
To contribute to this module, please visit https://odoo-community.org.

6
profiler/__init__.py

@ -0,0 +1,6 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2014 Anybox <http://anybox.fr>
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
from . import controllers
from .hooks import post_load

32
profiler/__openerp__.py

@ -0,0 +1,32 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2014 Anybox <http://anybox.fr>
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
{
'name': 'profiler',
'version': '8.0.1.0.0',
'category': 'Tools',
'license': 'AGPL-3',
'author': 'Anybox, Vauxoo, Odoo Community Association (OCA)',
'website': 'https://odoo-community.org',
'depends': ['website'],
'data': [
'data/profiler_excluding.xml',
'security/group.xml',
'views/profiler.xml',
'views/assets.xml',
],
'external_dependencies': {
'python': [
'pstats_print2list',
],
},
'qweb': [
'static/src/xml/player.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'post_load': 'post_load',
}

5
profiler/controllers/__init__.py

@ -0,0 +1,5 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2014 Anybox <http://anybox.fr>
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
from . import main

228
profiler/controllers/main.py

@ -0,0 +1,228 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2014 Anybox <http://anybox.fr>
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
import errno
import logging
import os
import sys
import tempfile
from cStringIO import StringIO
from datetime import datetime
from openerp import http, sql_db, tools
from openerp.addons.web.controllers.main import content_disposition
from openerp.http import request
from openerp.service.db import dump_db_manifest
from openerp.tools.misc import find_in_path
from ..hooks import CoreProfile as core
_logger = logging.getLogger(__name__)
try:
from pstats_print2list import get_pstats_print2list, print_pstats_list
except ImportError as err: # pragma: no cover
_logger.debug(err)
DFTL_LOG_PATH = '/var/lib/postgresql/%s/main/pg_log/postgresql.log'
PGOPTIONS = (
'-c client_min_messages=notice -c log_min_messages=warning '
'-c log_min_error_statement=error '
'-c log_min_duration_statement=0 -c log_connections=on '
'-c log_disconnections=on -c log_duration=off '
'-c log_error_verbosity=verbose -c log_lock_waits=on '
'-c log_statement=none -c log_temp_files=0 '
)
class Capturing(list):
def __enter__(self):
self._stdout = sys.stdout
sys.stdout = self._stringio = StringIO()
return self
def __exit__(self, *args):
self.extend(self._stringio.getvalue().splitlines())
del self._stringio # free up some memory
sys.stdout = self._stdout
class ProfilerController(http.Controller):
_cp_path = '/web/profiler'
player_state = 'profiler_player_clear'
begin_date = ''
end_date = ''
"""Indicate the state(css class) of the player:
* profiler_player_clear
* profiler_player_enabled
* profiler_player_disabled
"""
@http.route(['/web/profiler/enable'], type='json', auth="user")
def enable(self):
_logger.info("Enabling")
core.enabled = True
ProfilerController.begin_date = datetime.now().strftime(
"%Y-%m-%d %H:%M:%S")
ProfilerController.player_state = 'profiler_player_enabled'
os.environ['PGOPTIONS'] = PGOPTIONS
self.empty_cursor_pool()
@http.route(['/web/profiler/disable'], type='json', auth="user")
def disable(self, **post):
_logger.info("Disabling")
core.enabled = False
ProfilerController.end_date = datetime.now().strftime(
"%Y-%m-%d %H:%M:%S")
ProfilerController.player_state = 'profiler_player_disabled'
os.environ.pop("PGOPTIONS", None)
self.empty_cursor_pool()
@http.route(['/web/profiler/clear'], type='json', auth="user")
def clear(self, **post):
core.profile.clear()
_logger.info("Cleared stats")
ProfilerController.player_state = 'profiler_player_clear'
ProfilerController.end_date = ''
ProfilerController.begin_date = ''
@http.route(['/web/profiler/dump'], type='http', auth="user")
def dump(self, token, **post):
"""Provide the stats as a file download.
Uses a temporary file, because apparently there's no API to
dump stats in a stream directly.
"""
exclude_fname = self.get_exclude_fname()
with tools.osutil.tempdir() as dump_dir:
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = 'openerp_%s' % ts
stats_path = os.path.join(dump_dir, '%s.stats' % filename)
core.profile.dump_stats(stats_path)
_logger.info("Pstats Command:")
params = {'fnames': stats_path, 'sort': 'cumulative', 'limit': 45,
'exclude_fnames': exclude_fname}
_logger.info(
"fnames=%(fnames)s, sort=%(sort)s,"
" limit=%(limit)s, exclude_fnames=%(exclude_fnames)s", params)
pstats_list = get_pstats_print2list(**params)
with Capturing() as output:
print_pstats_list(pstats_list)
result_path = os.path.join(dump_dir, '%s.txt' % filename)
with open(result_path, "a") as res_file:
for line in output:
res_file.write('%s\n' % line)
# PG_BADGER
self.dump_pgbadger(dump_dir, 'pgbadger_output.txt', request.cr)
t_zip = tempfile.TemporaryFile()
tools.osutil.zip_dir(dump_dir, t_zip, include_dir=False)
t_zip.seek(0)
headers = [
('Content-Type', 'application/octet-stream; charset=binary'),
('Content-Disposition', content_disposition(
'%s.zip' % filename))]
_logger.info('Download Profiler zip: %s', t_zip.name)
return request.make_response(
t_zip, headers=headers, cookies={'fileToken': token})
@http.route(['/web/profiler/initial_state'], type='json', auth="user")
def initial_state(self, **post):
user = request.env['res.users'].browse(request.uid)
return {
'has_player_group': user.has_group(
'profiler.group_profiler_player'),
'player_state': ProfilerController.player_state,
}
def dump_pgbadger(self, dir_dump, output, cursor):
pgbadger = find_in_path("pgbadger")
if not pgbadger:
_logger.error("Pgbadger not found")
return
filename = os.path.join(dir_dump, output)
pg_version = dump_db_manifest(cursor)['pg_version']
log_path = os.environ.get('PG_LOG_PATH', DFTL_LOG_PATH % pg_version)
if not os.path.exists(os.path.dirname(filename)):
try:
os.makedirs(os.path.dirname(filename))
except OSError as exc:
# error is different than File exists
if exc.errno != errno.EEXIST:
_logger.error("Folder %s can not be created",
os.path.dirname(filename))
return
_logger.info("Generating PG Badger report.")
exclude_query = self.get_exclude_query()
dbname = cursor.dbname
command = [
pgbadger, '-f', 'stderr', '-T', 'Odoo-Profiler',
'-o', '-', '-d', dbname, '-b', ProfilerController.begin_date,
'-e', ProfilerController.end_date, '--sample', '2',
'--disable-type', '--disable-error', '--disable-hourly',
'--disable-session', '--disable-connection',
'--disable-temporary', '--quiet']
command.extend(exclude_query)
command.append(log_path)
_logger.info("Pgbadger Command:")
_logger.info(command)
result = tools.exec_command_pipe(*command)
with open(filename, 'w') as fw:
fw.write(result[1].read())
_logger.info("Done")
def get_exclude_fname(self):
efnameid = request.env.ref(
'profiler.default_exclude_fnames_pstas', raise_if_not_found=False)
if not efnameid:
return []
return [os.path.expanduser(path)
for path in efnameid and efnameid.value.strip(',').split(',')
if path]
def get_exclude_query(self):
"""Example '^(COPY|COMMIT)'
"""
equeryid = request.env.ref(
'profiler.default_exclude_query_pgbadger',
raise_if_not_found=False)
if not equeryid:
return []
exclude_queries = []
for path in equeryid and equeryid.value.strip(',').split(','):
exclude_queries.extend(
['--exclude-query', '"^(%s)" ' % path.encode('UTF-8')])
return exclude_queries
def empty_cursor_pool(self):
"""This method cleans (rollback) all current transactions over actual
cursor in order to avoid errors with waiting transactions.
- request.cr.rollback()
Also connections on current database's only are closed by the next
statement
- dsn = openerp.sql_db.dsn(request.cr.dbname)
- openerp.sql_db._Pool.close_all(dsn[1])
Otherwise next error will be trigger
'InterfaceError: connection already closed'
Finally new cursor is assigned to the request object, this cursor will
take the os.environ setted. In this case the os.environ is setted with
all 'PGOPTIONS' required to log all sql transactions in postgres.log
file.
If this method is called one more time, it will create a new cursor and
take the os.environ again, this is usefully if we want to reset
'PGOPTIONS'
"""
request.cr._cnx.reset()
dsn = sql_db.dsn(request.cr.dbname)
sql_db._Pool.close_all(dsn[1])
db = sql_db.db_connect(request.cr.dbname)
request._cr = db.cursor()

16
profiler/data/profiler_excluding.xml

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<!-- exclude_fnames de pstats_print2list -->
<record id="default_exclude_fnames_pstas" model="ir.config_parameter">
<field name="key">exclude_fnames</field>
<field name="value">/.repo_requirements,~/odoo-8.0,/usr/,></field>
</record>
<!-- exclude-query de pgbader -->
<record id="default_exclude_query_pgbadger" model="ir.config_parameter">
<field name="key">exclude_query</field>
<field name="value">ir_translation</field>
</record>
</data>
</openerp>

103
profiler/hooks.py

@ -0,0 +1,103 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2014 Anybox <http://anybox.fr>
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
import logging
import os
from contextlib import contextmanager
from cProfile import Profile
import openerp
from openerp import sql_db
from openerp.http import WebRequest
from openerp.service.server import ThreadedServer
_logger = logging.getLogger(__name__)
class CoreProfile(object):
"""The thread-shared profile object"""
profile = None
# Indicates if the whole profiling functionality is globally active or not.
enabled = False
@contextmanager
def profiling():
"""Thread local profile management, according to the shared :data:`enabled`
"""
if CoreProfile.enabled:
CoreProfile.profile.enable()
yield
if CoreProfile.enabled:
CoreProfile.profile.disable()
def patch_odoo():
"""Modify Odoo entry points so that profile can record.
Odoo is a multi-threaded program. Therefore, the :data:`profile` object
needs to be enabled/disabled each in each thread to capture all the
execution.
For instance, Odoo spawns a new thread for each request.
Modify database connect method to add options to enable postgresql logging
based on PGOPTIONS environment variable
"""
_logger.info('Patching openerp.http.WebRequest._call_function')
webreq_f_origin = WebRequest._call_function
def webreq_f(*args, **kwargs):
with profiling():
return webreq_f_origin(*args, **kwargs)
WebRequest._call_function = webreq_f
_logger.info('Patching openerp.sql_db.db_connect')
db_connect_origin = sql_db.db_connect
def dbconnect_f(to, *args, **kwargs):
try:
to += " options='%s' " % (os.environ['PGOPTIONS'] or '')
except KeyError:
pass
return db_connect_origin(to, *args, **kwargs)
sql_db.db_connect = dbconnect_f
def dump_stats():
"""Dump stats to standard file"""
_logger.info('Dump stats')
CoreProfile.profile.dump_stats(
os.path.expanduser('~/.openerp_server.stats'))
def create_profile():
"""Create the global, shared profile object."""
_logger.info('Create profile')
CoreProfile.profile = Profile()
def patch_stop():
"""When the server is stopped then save the result of cProfile stats"""
origin_stop = ThreadedServer.stop
_logger.info('Patching openerp.service.server.ThreadedServer.stop')
def stop(*args, **kwargs):
if openerp.tools.config['test_enable']:
dump_stats()
return origin_stop(*args, **kwargs)
ThreadedServer.stop = stop
def post_load():
_logger.info('Post load')
create_profile()
patch_odoo()
if openerp.tools.config['test_enable']:
# Enable profile in test mode for orm methods.
_logger.info('Enabling profiler and apply patch')
CoreProfile.enabled = True
patch_stop()

11
profiler/security/group.xml

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data noupdate="1">
<record id="group_profiler_player" model="res.groups">
<field name="name">Profiling capability</field>
</record>
<record model="res.users" id="base.user_root">
<field name="groups_id" eval="[(4, ref('profiler.group_profiler_player'))]"/>
</record>
</data>
</openerp>

BIN
profiler/static/description/clear_stats.png

After

Width: 277  |  Height: 58  |  Size: 8.2 KiB

BIN
profiler/static/description/dump_stats.png

After

Width: 279  |  Height: 52  |  Size: 8.1 KiB

BIN
profiler/static/description/player.png

After

Width: 76  |  Height: 30  |  Size: 1.5 KiB

BIN
profiler/static/description/start_profiling.png

After

Width: 280  |  Height: 49  |  Size: 7.1 KiB

BIN
profiler/static/description/stop_profiling.png

After

Width: 275  |  Height: 57  |  Size: 7.2 KiB

21
profiler/static/src/css/player.css

@ -0,0 +1,21 @@
.oe_topbar_item.profiler_player i {
cursor: pointer;
}
.oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_enable {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_dump {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_disabled a.profiler_disable {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_disable {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_clear {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_dump {
display: None;
}

52
profiler/static/src/js/player.js

@ -0,0 +1,52 @@
openerp.profiler = function(instance) {
instance.profiler.Player = instance.web.Widget.extend({
template: 'profiler.player',
events: {
"click .profiler_enable": "enable",
"click .profiler_disable": "disable",
"click .profiler_clear": "clear",
"click .profiler_dump": "dump"
},
apply_class: function(css_class) {
this.$el.removeClass('profiler_player_enabled');
this.$el.removeClass('profiler_player_disabled');
this.$el.removeClass('profiler_player_clear');
this.$el.addClass(css_class);
},
enable: function() {
this.rpc('/web/profiler/enable', {});
this.apply_class('profiler_player_enabled');
},
disable: function() {
this.rpc('/web/profiler/disable', {});
this.apply_class('profiler_player_disabled');
},
clear: function() {
this.rpc('/web/profiler/clear', {});
this.apply_class('profiler_player_clear');
},
dump: function() {
$.blockUI();
this.session.get_file({
url: '/web/profiler/dump',
complete: $.unblockUI
});
}
});
instance.web.UserMenu.include({
do_update: function () {
var self = this;
this.update_promise.done(function () {
self.rpc('/web/profiler/initial_state', {}).done(function(state) {
if (state.has_player_group) {
this.profiler_player = new instance.profiler.Player(this);
this.profiler_player.prependTo(instance.webclient.$('.oe_systray'));
this.profiler_player.apply_class(state.player_state);
}
});
});
return this._super();
}
});
};

52
profiler/static/src/js/test_profiler.js

@ -0,0 +1,52 @@
(function(){
'use_strict';
openerp.Tour.register({
id: 'profile_run',
name: 'Profile run',
path: '/web',
mode: 'test',
steps: [
{
title: 'Check if is cleared',
waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_clear'
},
{
title: 'Start profiling',
onload: function () {
$('a.profiler_enable').trigger('click');
}
},
{
title: 'Check if is enabled',
waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_enabled'
},
{
title: 'Stop profiling',
onload: function () {
$('a.profiler_disable').trigger('click');
}
},
{
title: 'Check if is disabled',
waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_disabled'
},
{
title: 'Dump profiling',
onload: function () {
$('a.profiler_dump').trigger('click');
}
},
{
title: 'Clear profiling',
onload: function () {
$('a.profiler_clear').trigger('click');
}
},
{
title: 'Check if is cleared again',
waitFor: 'li.oe_topbar_item.profiler_player.profiler_player_clear'
},
]
});
}());

24
profiler/static/src/less/player.less

@ -0,0 +1,24 @@
#oe_main_menu_navbar .o_menu_systray {
.oe_topbar_item.profiler_player i {
cursor: pointer;
}
.oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_enable {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_enabled a.profiler_dump {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_disabled a.profiler_disable {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_disable {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_clear {
display: None;
}
.oe_topbar_item.profiler_player.profiler_player_clear a.profiler_dump {
display: None;
}
}

26
profiler/static/src/xml/player.xml

@ -0,0 +1,26 @@
<templates>
<t t-name="profiler.player">
<li class="oe_topbar_item profiler_player">
<a href="#" class="profiler_enable">
<i class="fa fa-play" title="Start profiling"/>
</a>
</li>
<li class="oe_topbar_item profiler_player">
<a href="#" class="profiler_disable">
<!-- <i class="fa fa-pause" title="Stop profiling"/> -->
<i class="fa fa-stop" title="Stop profiling"/>
</a>
</li>
<li class="oe_topbar_item profiler_player">
<a href="#" class="profiler_clear">
<!-- <i class="fa fa-stop" title="Clear profiling"/> -->
<i class="fa fa-refresh" title="Clear profiling"/>
</a>
</li>
<li class="oe_topbar_item profiler_player">
<a href="#" class="profiler_dump">
<i class="fa fa-floppy-o" title="Download stats"/>
</a>
</li>
</t>
</templates>

5
profiler/tests/__init__.py

@ -0,0 +1,5 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2014 Anybox <http://anybox.fr>
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
from . import test_profiler

17
profiler/tests/test_profiler.py

@ -0,0 +1,17 @@
# coding: utf-8
# License AGPL-3 or later (http://www.gnu.org/licenses/lgpl).
# Copyright 2016 Vauxoo (https://www.vauxoo.com) <info@vauxoo.com>
import unittest
from openerp import tests
@tests.at_install(False)
@tests.post_install(True)
class TestProfiler(tests.HttpCase):
@unittest.skip("phantomjs tests async for 8.0 are so flaky")
def test_profiler_tour(self):
self.phantom_js('/web', "openerp.Tour.run('profile_run', 'test')",
'openerp.Tour.tours.profile_run', login='admin')

12
profiler/views/assets.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<template id="assets_backend_test_profiler" name="Profiler Tests"
inherit_id="website.assets_backend">
<xpath expr="//script[last()]" position="after">
<script src="/profiler/static/src/js/test_profiler.js"
type="text/javascript"/>
</xpath>
</template>
</data>
</openerp>

11
profiler/views/profiler.xml

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<template id="profiler_assets_backend" name="profiler assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/profiler/static/src/css/player.css" />
<script type="text/javascript" src="/profiler/static/src/js/player.js" />
</xpath>
</template>
</data>
</openerp>

1
requirements.txt

@ -1,3 +1,4 @@
pstats_print2list
python-ldap
unidecode
validate_email

Loading…
Cancel
Save