19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

View file

@ -1,25 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# ruff: noqa: F401
""" Modules (also called addons) management.
"""
import odoo.init # import first for core setup
from . import db, graph, loading, migration, module, registry, neutralize
from . import db # used directly during some migration scripts
from odoo.modules.loading import load_modules, reset_modules_state
from odoo.modules.module import (
from . import module
from .module import (
Manifest,
adapt_version,
check_manifest_dependencies,
get_module_path,
get_module_resource,
get_modules,
get_modules_with_version,
get_resource_from_path,
get_resource_path,
check_resource_path,
initialize_sys_path,
get_manifest,
load_openerp_module,
load_script
)
from . import registry

View file

@ -1,15 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
""" Initialize the database for module management and Odoo installation. """
from __future__ import annotations
from psycopg2.extras import Json
import logging
import typing
from enum import IntEnum
from psycopg2.extras import Json
import odoo.modules
import odoo.tools
if typing.TYPE_CHECKING:
from odoo.sql_db import BaseCursor, Cursor
_logger = logging.getLogger(__name__)
def is_initialized(cr):
def is_initialized(cr: Cursor) -> bool:
""" Check if a database has been initialized for the ORM.
The database can be initialized with the 'initialize' function below.
@ -17,7 +25,8 @@ def is_initialized(cr):
"""
return odoo.tools.sql.table_exists(cr, 'ir_module_module')
def initialize(cr):
def initialize(cr: Cursor) -> None:
""" Initialize a database with for the ORM.
This executes base/data/base_data.sql, creates the ir_module_categories
@ -30,21 +39,13 @@ def initialize(cr):
except FileNotFoundError:
m = "File not found: 'base.sql' (provided by module 'base')."
_logger.critical(m)
raise IOError(m)
raise OSError(m)
with odoo.tools.misc.file_open(f) as base_sql_file:
cr.execute(base_sql_file.read()) # pylint: disable=sql-injection
for i in odoo.modules.get_modules():
mod_path = odoo.modules.get_module_path(i)
if not mod_path:
continue
# This will raise an exception if no/unreadable descriptor file.
info = odoo.modules.get_manifest(i)
if not info:
continue
for info in odoo.modules.Manifest.all_addon_manifests():
module_name = info.name
categories = info['category'].split('/')
category_id = create_categories(cr, categories)
@ -58,23 +59,27 @@ def initialize(cr):
category_id, auto_install, state, web, license, application, icon, sequence, summary) \
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id', (
info['author'],
info['website'], i, Json({'en_US': info['name']}),
info['website'], module_name, Json({'en_US': info['name']}),
Json({'en_US': info['description']}), category_id,
info['auto_install'] is not False, state,
info['web'],
info['license'],
info['application'], info['icon'],
info['sequence'], Json({'en_US': info['summary']})))
id = cr.fetchone()[0]
cr.execute('INSERT INTO ir_model_data \
(name,model,module, res_id, noupdate) VALUES (%s,%s,%s,%s,%s)', (
'module_'+i, 'ir.module.module', 'base', id, True))
row = cr.fetchone()
assert row is not None # for typing
module_id = row[0]
cr.execute(
'INSERT INTO ir_model_data'
'(name,model,module, res_id, noupdate) VALUES (%s,%s,%s,%s,%s)',
('module_' + module_name, 'ir.module.module', 'base', module_id, True),
)
dependencies = info['depends']
for d in dependencies:
cr.execute(
'INSERT INTO ir_module_module_dependency (module_id, name, auto_install_required)'
' VALUES (%s, %s, %s)',
(id, d, d in (info['auto_install'] or ()))
(module_id, d, d in (info['auto_install'] or ()))
)
# Install recursively all auto-installing modules
@ -106,10 +111,12 @@ def initialize(cr):
""", [to_auto_install, to_auto_install])
to_auto_install.extend(x[0] for x in cr.fetchall())
if not to_auto_install: break
if not to_auto_install:
break
cr.execute("""UPDATE ir_module_module SET state='to install' WHERE name in %s""", (tuple(to_auto_install),))
def create_categories(cr, categories):
def create_categories(cr: Cursor, categories: list[str]) -> int | None:
""" Create the ir_module_category entries for some categories.
categories is a list of strings forming a single category with its
@ -127,26 +134,30 @@ def create_categories(cr, categories):
cr.execute("SELECT res_id FROM ir_model_data WHERE name=%s AND module=%s AND model=%s",
(xml_id, "base", "ir.module.category"))
c_id = cr.fetchone()
if not c_id:
row = cr.fetchone()
if not row:
cr.execute('INSERT INTO ir_module_category \
(name, parent_id) \
VALUES (%s, %s) RETURNING id', (Json({'en_US': categories[0]}), p_id))
c_id = cr.fetchone()[0]
row = cr.fetchone()
assert row is not None # for typing
p_id = row[0]
cr.execute('INSERT INTO ir_model_data (module, name, res_id, model, noupdate) \
VALUES (%s, %s, %s, %s, %s)', ('base', xml_id, c_id, 'ir.module.category', True))
VALUES (%s, %s, %s, %s, %s)', ('base', xml_id, p_id, 'ir.module.category', True))
else:
c_id = c_id[0]
p_id = c_id
p_id = row[0]
assert isinstance(p_id, int)
categories = categories[1:]
return p_id
class FunctionStatus(IntEnum):
MISSING = 0 # function is not present (falsy)
PRESENT = 1 # function is present but not indexable (not immutable)
INDEXABLE = 2 # function is present and indexable (immutable)
def has_unaccent(cr):
def has_unaccent(cr: BaseCursor) -> FunctionStatus:
""" Test whether the database has function 'unaccent' and return its status.
The unaccent is supposed to be provided by the PostgreSQL unaccent contrib
@ -170,7 +181,8 @@ def has_unaccent(cr):
# https://www.postgresql.org/docs/current/catalog-pg-proc.html.
return FunctionStatus.INDEXABLE if result[0] == 'i' else FunctionStatus.PRESENT
def has_trigram(cr):
def has_trigram(cr: BaseCursor) -> bool:
""" Test if the database has the a word_similarity function.
The word_similarity is supposed to be provided by the PostgreSQL built-in

View file

@ -1,199 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
""" Modules dependency graph. """
import functools
import itertools
import logging
import odoo
import odoo.tools as tools
_logger = logging.getLogger(__name__)
@functools.lru_cache(maxsize=1)
def _ignored_modules(cr):
result = ['studio_customization']
if tools.sql.column_exists(cr, 'ir_module_module', 'imported'):
cr.execute('SELECT name FROM ir_module_module WHERE imported')
result += [m[0] for m in cr.fetchall()]
return result
class Graph(dict):
""" Modules dependency graph.
The graph is a mapping from module name to Nodes.
"""
def add_node(self, name, info):
max_depth, father = 0, None
for d in info['depends']:
n = self.get(d) or Node(d, self, None) # lazy creation, do not use default value for get()
if n.depth >= max_depth:
father = n
max_depth = n.depth
if father:
return father.add_child(name, info)
else:
return Node(name, self, info)
def update_from_db(self, cr):
if not len(self):
return
# update the graph with values from the database (if exist)
## First, we set the default values for each package in graph
additional_data = {key: {'id': 0, 'state': 'uninstalled', 'dbdemo': False, 'installed_version': None} for key in self.keys()}
## Then we get the values from the database
cr.execute('SELECT name, id, state, demo AS dbdemo, latest_version AS installed_version'
' FROM ir_module_module'
' WHERE name IN %s',(tuple(additional_data),)
)
## and we update the default values with values from the database
additional_data.update((x['name'], x) for x in cr.dictfetchall())
for package in self.values():
for k, v in additional_data[package.name].items():
setattr(package, k, v)
def add_module(self, cr, module, force=None):
self.add_modules(cr, [module], force)
def add_modules(self, cr, module_list, force=None):
if force is None:
force = []
packages = []
len_graph = len(self)
for module in module_list:
info = odoo.modules.module.get_manifest(module)
if info and info['installable']:
packages.append((module, info)) # TODO directly a dict, like in get_modules_with_version
elif module not in _ignored_modules(cr):
_logger.warning('module %s: not installable, skipped', module)
dependencies = dict([(p, info['depends']) for p, info in packages])
current, later = set([p for p, info in packages]), set()
while packages and current > later:
package, info = packages[0]
deps = info['depends']
# if all dependencies of 'package' are already in the graph, add 'package' in the graph
if all(dep in self for dep in deps):
if not package in current:
packages.pop(0)
continue
later.clear()
current.remove(package)
node = self.add_node(package, info)
for kind in ('init', 'demo', 'update'):
if package in tools.config[kind] or 'all' in tools.config[kind] or kind in force:
setattr(node, kind, True)
else:
later.add(package)
packages.append((package, info))
packages.pop(0)
self.update_from_db(cr)
for package in later:
unmet_deps = [p for p in dependencies[package] if p not in self]
_logger.info('module %s: Unmet dependencies: %s', package, ', '.join(unmet_deps))
return len(self) - len_graph
def __iter__(self):
level = 0
done = set(self.keys())
while done:
level_modules = sorted((name, module) for name, module in self.items() if module.depth==level)
for name, module in level_modules:
done.remove(name)
yield module
level += 1
def __str__(self):
return '\n'.join(str(n) for n in self if n.depth == 0)
class Node(object):
""" One module in the modules dependency graph.
Node acts as a per-module singleton. A node is constructed via
Graph.add_module() or Graph.add_modules(). Some of its fields are from
ir_module_module (set by Graph.update_from_db()).
"""
def __new__(cls, name, graph, info):
if name in graph:
inst = graph[name]
else:
inst = object.__new__(cls)
graph[name] = inst
return inst
def __init__(self, name, graph, info):
self.name = name
self.graph = graph
self.info = info or getattr(self, 'info', {})
if not hasattr(self, 'children'):
self.children = []
if not hasattr(self, 'depth'):
self.depth = 0
@property
def data(self):
return self.info
def add_child(self, name, info):
node = Node(name, self.graph, info)
node.depth = self.depth + 1
if node not in self.children:
self.children.append(node)
for attr in ('init', 'update', 'demo'):
if hasattr(self, attr):
setattr(node, attr, True)
self.children.sort(key=lambda x: x.name)
return node
def __setattr__(self, name, value):
super(Node, self).__setattr__(name, value)
if name in ('init', 'update', 'demo'):
tools.config[name][self.name] = 1
for child in self.children:
setattr(child, name, value)
if name == 'depth':
for child in self.children:
setattr(child, name, value + 1)
def __iter__(self):
return itertools.chain(
self.children,
itertools.chain.from_iterable(self.children)
)
def __str__(self):
return self._pprint()
def _pprint(self, depth=0):
s = '%s\n' % self.name
for c in self.children:
s += '%s`-> %s' % (' ' * depth, c._pprint(depth+1))
return s
def should_have_demo(self):
return (hasattr(self, 'demo') or (self.dbdemo and self.state != 'installed')) and all(p.dbdemo for p in self.parents)
@property
def parents(self):
if self.depth == 0:
return []
return (
node for node in self.graph.values()
if node.depth < self.depth
if self in node.children
)

View file

@ -1,90 +1,72 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
""" Modules (also called addons) management.
"""
from __future__ import annotations
import datetime
import itertools
import logging
import sys
import threading
import time
import typing
import traceback
import odoo
import odoo.modules.db
import odoo.modules.graph
import odoo.modules.migration
import odoo.modules.registry
from .. import SUPERUSER_ID, api, tools
import odoo.sql_db
import odoo.tools.sql
import odoo.tools.translate
from odoo import api, tools
from odoo.tools.convert import convert_file, IdRef, ConvertMode as LoadMode
from . import db as modules_db
from .migration import MigrationManager
from .module import adapt_version, initialize_sys_path, load_openerp_module
from .module_graph import ModuleGraph
from .registry import Registry
if typing.TYPE_CHECKING:
from collections.abc import Collection, Iterable
from odoo.api import Environment
from odoo.sql_db import BaseCursor
from odoo.tests.result import OdooTestResult
from .module_graph import ModuleNode
LoadKind = typing.Literal['data', 'demo']
_logger = logging.getLogger(__name__)
def load_data(env, idref, mode, kind, package):
def load_data(env: Environment, idref: IdRef, mode: LoadMode, kind: LoadKind, package: ModuleNode) -> bool:
"""
kind: data, demo, test, init_xml, update_xml, demo_xml.
noupdate is False, unless it is demo data or it is csv data in
init mode.
noupdate is False, unless it is demo data
:returns: Whether a file was loaded
:rtype: bool
"""
keys = ('init_xml', 'data') if kind == 'data' else ('demo',)
def _get_files_of_kind(kind):
if kind == 'demo':
keys = ['demo_xml', 'demo']
elif kind == 'data':
keys = ['init_xml', 'update_xml', 'data']
if isinstance(kind, str):
keys = [kind]
files = []
for k in keys:
for f in package.data[k]:
if f in files:
_logger.warning("File %s is imported twice in module %s %s", f, package.name, kind)
files.append(f)
if k.endswith('_xml') and not (k == 'init_xml' and not f.endswith('.xml')):
# init_xml, update_xml and demo_xml are deprecated except
# for the case of init_xml with csv and sql files as
# we can't specify noupdate for those file.
correct_key = 'demo' if k.count('demo') else 'data'
_logger.warning(
"module %s: key '%s' is deprecated in favor of '%s' for file '%s'.",
package.name, k, correct_key, f
)
return files
files: set[str] = set()
for k in keys:
if k == 'init_xml' and package.manifest[k]:
_logger.warning("module %s: key 'init_xml' is deprecated in Odoo 19.", package.name)
for filename in package.manifest[k]:
if filename in files:
_logger.warning("File %s is imported twice in module %s %s", filename, package.name, kind)
files.add(filename)
filename = None
try:
if kind in ('demo', 'test'):
threading.current_thread().testing = True
for filename in _get_files_of_kind(kind):
_logger.info("loading %s/%s", package.name, filename)
noupdate = False
if kind in ('demo', 'demo_xml') or (filename.endswith('.csv') and kind in ('init', 'init_xml')):
noupdate = True
tools.convert_file(env, package.name, filename, idref, mode, noupdate, kind)
finally:
if kind in ('demo', 'test'):
threading.current_thread().testing = False
convert_file(env, package.name, filename, idref, mode, noupdate=kind == 'demo')
return bool(filename)
return bool(files)
def load_demo(env, package, idref, mode):
def load_demo(env: Environment, package: ModuleNode, idref: IdRef, mode: LoadMode) -> bool:
"""
Loads demo data for the specified package.
"""
if not package.should_have_demo():
return False
try:
if package.data.get('demo') or package.data.get('demo_xml'):
if package.manifest.get('demo') or package.manifest.get('demo_xml'):
_logger.info("Module %s: loading demo", package.name)
with env.cr.savepoint(flush=False):
load_data(env(su=True), idref, mode, kind='demo', package=package)
@ -103,46 +85,47 @@ def load_demo(env, package, idref, mode):
return False
def force_demo(env):
def force_demo(env: Environment) -> None:
"""
Forces the `demo` flag on all modules, and installs demo data for all installed modules.
"""
graph = odoo.modules.graph.Graph()
env.cr.execute('UPDATE ir_module_module SET demo=True')
env.cr.execute(
"SELECT name FROM ir_module_module WHERE state IN ('installed', 'to upgrade', 'to remove')"
)
module_list = [name for (name,) in env.cr.fetchall()]
graph.add_modules(env.cr, module_list, ['demo'])
graph = ModuleGraph(env.cr, mode='load')
graph.extend(module_list)
for package in graph:
load_demo(env, package, {}, 'init')
env['ir.module.module'].invalidate_model(['demo'])
env['res.groups']._update_user_groups_view()
def load_module_graph(env, graph, status=None, perform_checks=True,
skip_modules=None, report=None, models_to_check=None):
"""Migrates+Updates or Installs all module nodes from ``graph``
def load_module_graph(
env: Environment,
graph: ModuleGraph,
update_module: bool = False,
report: OdooTestResult | None = None,
models_to_check: set[str] | None = None,
install_demo: bool = True,
) -> None:
""" Load, upgrade and install not loaded module nodes in the ``graph`` for ``env.registry``
:param env:
:param graph: graph of module nodes to load
:param status: deprecated parameter, unused, left to avoid changing signature in 8.0
:param perform_checks: whether module descriptors should be checked for validity (prints warnings
for same cases)
:param skip_modules: optional list of module names (packages) which have previously been loaded and can be skipped
:param update_module: whether to update modules or not
:param report:
:param set models_to_check:
:return: list of modules that were installed or updated
:param install_demo: whether to attempt installing demo data for newly installed modules
"""
if models_to_check is None:
models_to_check = set()
processed_modules = []
loaded_modules = []
registry = env.registry
migrations = odoo.modules.migration.MigrationManager(env.cr, graph)
assert isinstance(env.cr, odoo.sql_db.Cursor), "Need for a real Cursor to load modules"
migrations = MigrationManager(env.cr, graph)
module_count = len(graph)
_logger.info('loading %d modules...', module_count)
@ -157,102 +140,98 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
module_name = package.name
module_id = package.id
if skip_modules and module_name in skip_modules:
if module_name in registry._init_modules:
continue
module_t0 = time.time()
module_cursor_query_count = env.cr.sql_log_count
module_extra_query_count = odoo.sql_db.sql_counter
needs_update = (
hasattr(package, "init")
or hasattr(package, "update")
or package.state in ("to install", "to upgrade")
)
update_operation = (
'install' if package.state == 'to install' else
'upgrade' if package.state == 'to upgrade' else
'reinit' if module_name in registry._reinit_modules else
None
) if update_module else None
module_log_level = logging.DEBUG
if needs_update:
if update_operation:
module_log_level = logging.INFO
_logger.log(module_log_level, 'Loading module %s (%d/%d)', module_name, index, module_count)
new_install = package.state == 'to install'
if needs_update:
if not new_install:
if update_operation:
if update_operation == 'upgrade' or module_name in registry._force_upgrade_scripts:
if package.name != 'base':
registry.setup_models(env.cr)
registry._setup_models__(env.cr, []) # incremental setup
migrations.migrate_module(package, 'pre')
if package.name != 'base':
env.flush_all()
load_openerp_module(package.name)
if new_install:
if update_operation == 'install':
py_module = sys.modules['odoo.addons.%s' % (module_name,)]
pre_init = package.info.get('pre_init_hook')
pre_init = package.manifest.get('pre_init_hook')
if pre_init:
registry.setup_models(env.cr)
registry._setup_models__(env.cr, []) # incremental setup
getattr(py_module, pre_init)(env)
model_names = registry.load(env.cr, package)
model_names = registry.load(package)
mode = 'update'
if hasattr(package, 'init') or package.state == 'to install':
mode = 'init'
loaded_modules.append(package.name)
if needs_update:
if update_operation:
model_names = registry.descendants(model_names, '_inherit', '_inherits')
models_updated |= set(model_names)
models_to_check -= set(model_names)
registry.setup_models(env.cr)
registry.init_models(env.cr, model_names, {'module': package.name}, new_install)
elif package.state != 'to remove':
registry._setup_models__(env.cr, []) # incremental setup
registry.init_models(env.cr, model_names, {'module': package.name}, update_operation == 'install')
elif update_module and package.state != 'to remove':
# The current module has simply been loaded. The models extended by this module
# and for which we updated the schema, must have their schema checked again.
# This is because the extension may have changed the model,
# e.g. adding required=True to an existing field, but the schema has not been
# updated by this module because it's not marked as 'to upgrade/to install'.
model_names = registry.descendants(model_names, '_inherit', '_inherits')
models_to_check |= set(model_names) & models_updated
idref = {}
if needs_update:
if update_operation:
# Can't put this line out of the loop: ir.module.module will be
# registered by init_models() above.
module = env['ir.module.module'].browse(module_id)
module._check()
if perform_checks:
module._check()
idref: dict = {}
if package.state == 'to upgrade':
if update_operation == 'install':
load_data(env, idref, 'init', kind='data', package=package)
if install_demo and package.demo_installable:
package.demo = load_demo(env, package, idref, 'init')
else: # 'upgrade' or 'reinit'
# upgrading the module information
module.write(module.get_values_from_terp(package.data))
load_data(env, idref, mode, kind='data', package=package)
demo_loaded = package.dbdemo = load_demo(env, package, idref, mode)
env.cr.execute('update ir_module_module set demo=%s where id=%s', (demo_loaded, module_id))
module.write(module.get_values_from_terp(package.manifest))
mode = 'update' if update_operation == 'upgrade' else 'init'
load_data(env, idref, mode, kind='data', package=package)
if package.demo:
package.demo = load_demo(env, package, idref, mode)
env.cr.execute('UPDATE ir_module_module SET demo = %s WHERE id = %s', (package.demo, module_id))
module.invalidate_model(['demo'])
migrations.migrate_module(package, 'post')
# Update translations for all installed languages
overwrite = odoo.tools.config["overwrite_existing_translations"]
overwrite = tools.config["overwrite_existing_translations"]
module._update_translations(overwrite=overwrite)
if package.name is not None:
registry._init_modules.add(package.name)
if needs_update:
if new_install:
post_init = package.info.get('post_init_hook')
if update_operation:
if update_operation == 'install':
post_init = package.manifest.get('post_init_hook')
if post_init:
getattr(py_module, post_init)(env)
if mode == 'update':
elif update_operation == 'upgrade':
# validate the views that have not been checked yet
env['ir.ui.view']._validate_module_views(module_name)
# need to commit any modification the module's installation or
# update made to the schema or data so the tests can run
# (separately in their own transaction)
env.cr.commit()
concrete_models = [model for model in model_names if not registry[model]._abstract]
if concrete_models:
env.cr.execute("""
@ -270,18 +249,32 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
lines.append(f"{module_name}.access_{xmlid},access_{xmlid},{module_name}.model_{xmlid},base.group_user,1,0,0,0")
_logger.warning('\n'.join(lines))
updating = tools.config.options['init'] or tools.config.options['update']
test_time = test_queries = 0
registry.updated_modules.append(package.name)
ver = adapt_version(package.manifest['version'])
# Set new modules and dependencies
module.write({'state': 'installed', 'latest_version': ver})
package.state = 'installed'
module.env.flush_all()
module.env.cr.commit()
test_time = 0.0
test_queries = 0
test_results = None
if tools.config.options['test_enable'] and (needs_update or not updating):
update_from_config = tools.config['update'] or tools.config['init'] or tools.config['reinit']
if tools.config['test_enable'] and (update_operation or not update_from_config):
from odoo.tests import loader # noqa: PLC0415
suite = loader.make_suite([module_name], 'at_install')
if suite.countTestCases():
if not needs_update:
registry.setup_models(env.cr)
if not update_operation:
registry._setup_models__(env.cr, []) # incremental setup
registry.check_null_constraints(env.cr)
# Python tests
tests_t0, tests_q0 = time.time(), odoo.sql_db.sql_counter
test_results = loader.run_suite(suite, global_report=report)
assert report is not None, "Missing report during tests"
report.update(test_results)
test_time = time.time() - tests_t0
test_queries = odoo.sql_db.sql_counter - tests_q0
@ -289,20 +282,6 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
# tests may have reset the environment
module = env['ir.module.module'].browse(module_id)
if needs_update:
processed_modules.append(package.name)
ver = adapt_version(package.data['version'])
# Set new modules and dependencies
module.write({'state': 'installed', 'latest_version': ver})
package.load_state = package.state
package.load_version = package.installed_version
package.state = 'installed'
for kind in ('init', 'demo', 'update'):
if hasattr(package, kind):
delattr(package, kind)
module.env.flush_all()
extra_queries = odoo.sql_db.sql_counter - module_extra_query_count - test_queries
extras = []
@ -330,59 +309,43 @@ def load_module_graph(env, graph, status=None, perform_checks=True,
env.cr.sql_log_count - loading_cursor_query_count,
odoo.sql_db.sql_counter - loading_extra_query_count) # extra queries: testes, notify, any other closed cursor
return loaded_modules, processed_modules
def _check_module_names(cr, module_names):
def _check_module_names(cr: BaseCursor, module_names: Iterable[str]) -> None:
mod_names = set(module_names)
if 'base' in mod_names:
# ignore dummy 'all' module
if 'all' in mod_names:
mod_names.remove('all')
mod_names.discard('all')
if mod_names:
cr.execute("SELECT count(id) AS count FROM ir_module_module WHERE name in %s", (tuple(mod_names),))
if cr.dictfetchone()['count'] != len(mod_names):
row = cr.fetchone()
assert row is not None # for typing
if row[0] != len(mod_names):
# find out what module name(s) are incorrect:
cr.execute("SELECT name FROM ir_module_module")
incorrect_names = mod_names.difference([x['name'] for x in cr.dictfetchall()])
_logger.warning('invalid module names, ignored: %s', ", ".join(incorrect_names))
def load_marked_modules(env, graph, states, force, progressdict, report,
loaded_modules, perform_checks, models_to_check=None):
"""Loads modules marked with ``states``, adding them to ``graph`` and
``loaded_modules`` and returns a list of installed/upgraded modules."""
if models_to_check is None:
models_to_check = set()
processed_modules = []
while True:
env.cr.execute("SELECT name from ir_module_module WHERE state IN %s", (tuple(states),))
module_list = [name for (name,) in env.cr.fetchall() if name not in graph]
if not module_list:
break
graph.add_modules(env.cr, module_list, force)
_logger.debug('Updating graph with %d more modules', len(module_list))
loaded, processed = load_module_graph(
env, graph, progressdict, report=report, skip_modules=loaded_modules,
perform_checks=perform_checks, models_to_check=models_to_check
)
processed_modules.extend(processed)
loaded_modules.extend(loaded)
if not processed:
break
return processed_modules
def load_modules(registry, force_demo=False, status=None, update_module=False):
def load_modules(
registry: Registry,
*,
update_module: bool = False,
upgrade_modules: Collection[str] = (),
install_modules: Collection[str] = (),
reinit_modules: Collection[str] = (),
new_db_demo: bool = False,
) -> None:
""" Load the modules for a registry object that has just been created. This
function is part of Registry.new() and should not be used anywhere else.
:param registry: The new inited registry object used to load modules.
:param update_module: Whether to update (install, upgrade, or uninstall) modules. Defaults to ``False``
:param upgrade_modules: A collection of module names to upgrade.
:param install_modules: A collection of module names to install.
:param reinit_modules: A collection of module names to reinitialize.
:param new_db_demo: Whether to install demo data for new database. Defaults to ``False``
"""
initialize_sys_path()
force = []
if force_demo:
force.append('demo')
models_to_check = set()
models_to_check: set[str] = set()
with registry.cursor() as cr:
# prevent endless wait for locks on schema changes (during online
@ -390,52 +353,53 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
# connection settings are automatically reset when the connection is
# borrowed from the pool
cr.execute("SET SESSION lock_timeout = '15s'")
if not odoo.modules.db.is_initialized(cr):
if not modules_db.is_initialized(cr):
if not update_module:
_logger.error("Database %s not initialized, you can force it with `-i base`", cr.dbname)
return
_logger.info("init db")
odoo.modules.db.initialize(cr)
update_module = True # process auto-installed modules
tools.config["init"]["all"] = 1
if not tools.config['without_demo']:
tools.config["demo"]['all'] = 1
_logger.info("Initializing database %s", cr.dbname)
modules_db.initialize(cr)
elif 'base' in reinit_modules:
registry._reinit_modules.add('base')
if 'base' in tools.config['update'] or 'all' in tools.config['update']:
if 'base' in upgrade_modules:
cr.execute("update ir_module_module set state=%s where name=%s and state=%s", ('to upgrade', 'base', 'installed'))
# STEP 1: LOAD BASE (must be done before module dependencies can be computed for later steps)
graph = odoo.modules.graph.Graph()
graph.add_module(cr, 'base', force)
graph = ModuleGraph(cr, mode='update' if update_module else 'load')
graph.extend(['base'])
if not graph:
_logger.critical('module base cannot be loaded! (hint: verify addons-path)')
raise ImportError('Module `base` cannot be loaded! (hint: verify addons-path)')
if update_module and tools.config['update']:
for pyfile in tools.config['pre_upgrade_scripts'].split(','):
if update_module and upgrade_modules:
for pyfile in tools.config['pre_upgrade_scripts']:
odoo.modules.migration.exec_script(cr, graph['base'].installed_version, pyfile, 'base', 'pre')
if update_module and odoo.tools.sql.table_exists(cr, 'ir_model_fields'):
if update_module and tools.sql.table_exists(cr, 'ir_model_fields'):
# determine the fields which are currently translated in the database
cr.execute("SELECT model || '.' || name FROM ir_model_fields WHERE translate IS TRUE")
registry._database_translated_fields = {row[0] for row in cr.fetchall()}
cr.execute("SELECT model || '.' || name, translate FROM ir_model_fields WHERE translate IS NOT NULL")
registry._database_translated_fields = dict(cr.fetchall())
# determine the fields which are currently company dependent in the database
if odoo.tools.sql.column_exists(cr, 'ir_model_fields', 'company_dependent'):
cr.execute("SELECT model || '.' || name FROM ir_model_fields WHERE company_dependent IS TRUE")
registry._database_company_dependent_fields = {row[0] for row in cr.fetchall()}
# processed_modules: for cleanup step after install
# loaded_modules: to avoid double loading
report = registry._assertion_report
env = api.Environment(cr, SUPERUSER_ID, {})
loaded_modules, processed_modules = load_module_graph(
env, graph, status, perform_checks=update_module,
report=report, models_to_check=models_to_check)
env = api.Environment(cr, api.SUPERUSER_ID, {})
load_module_graph(
env,
graph,
update_module=update_module,
report=report,
models_to_check=models_to_check,
install_demo=new_db_demo,
)
load_lang = tools.config.pop('load_language')
load_lang = tools.config._cli_options.pop('load_language', None)
if load_lang or update_module:
# some base models are used below, so make sure they are set up
registry.setup_models(cr)
registry._setup_models__(cr, []) # incremental setup
if load_lang:
for lang in load_lang.split(','):
@ -447,54 +411,52 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
_logger.info('updating modules list')
Module.update_list()
_check_module_names(cr, itertools.chain(tools.config['init'], tools.config['update']))
_check_module_names(cr, itertools.chain(install_modules, upgrade_modules))
module_names = [k for k, v in tools.config['init'].items() if v]
if module_names:
modules = Module.search([('state', '=', 'uninstalled'), ('name', 'in', module_names)])
if install_modules:
modules = Module.search([('state', '=', 'uninstalled'), ('name', 'in', tuple(install_modules))])
if modules:
modules.button_install()
module_names = [k for k, v in tools.config['update'].items() if v]
if module_names:
modules = Module.search([('state', 'in', ('installed', 'to upgrade')), ('name', 'in', module_names)])
if upgrade_modules:
modules = Module.search([('state', 'in', ('installed', 'to upgrade')), ('name', 'in', tuple(upgrade_modules))])
if modules:
modules.button_upgrade()
if reinit_modules:
modules = Module.search([('state', 'in', ('installed', 'to upgrade')), ('name', 'in', tuple(reinit_modules))])
reinit_modules = modules.downstream_dependencies(exclude_states=('uninstalled', 'uninstallable', 'to remove', 'to install')) + modules
registry._reinit_modules.update(m for m in reinit_modules.mapped('name') if m not in graph._imported_modules)
env.flush_all()
cr.execute("update ir_module_module set state=%s where name=%s", ('installed', 'base'))
Module.invalidate_model(['state'])
# STEP 3: Load marked modules (skipping base which was done in STEP 1)
# IMPORTANT: this is done in two parts, first loading all installed or
# partially installed modules (i.e. installed/to upgrade), to
# offer a consistent system to the second part: installing
# newly selected modules.
# We include the modules 'to remove' in the first step, because
# they are part of the "currently installed" modules. They will
# be dropped in STEP 6 later, before restarting the loading
# process.
# IMPORTANT 2: We have to loop here until all relevant modules have been
# processed, because in some rare cases the dependencies have
# changed, and modules that depend on an uninstalled module
# will not be processed on the first pass.
# It's especially useful for migrations.
previously_processed = -1
while previously_processed < len(processed_modules):
previously_processed = len(processed_modules)
processed_modules += load_marked_modules(env, graph,
['installed', 'to upgrade', 'to remove'],
force, status, report, loaded_modules, update_module, models_to_check)
# loop this step in case extra modules' states are changed to 'to install'/'to update' during loading
while True:
if update_module:
processed_modules += load_marked_modules(env, graph,
['to install'], force, status, report,
loaded_modules, update_module, models_to_check)
states = ('installed', 'to upgrade', 'to remove', 'to install')
else:
states = ('installed', 'to upgrade', 'to remove')
env.cr.execute("SELECT name from ir_module_module WHERE state IN %s", [states])
module_list = [name for (name,) in env.cr.fetchall() if name not in graph]
if not module_list:
break
graph.extend(module_list)
_logger.debug('Updating graph with %d more modules', len(module_list))
updated_modules_count = len(registry.updated_modules)
load_module_graph(
env, graph, update_module=update_module,
report=report, models_to_check=models_to_check)
if len(registry.updated_modules) == updated_modules_count:
break
if update_module:
# set up the registry without the patch for translated fields
database_translated_fields = registry._database_translated_fields
registry._database_translated_fields = ()
registry.setup_models(cr)
registry._database_translated_fields = {}
registry._setup_models__(cr, []) # incremental setup
# determine which translated fields should no longer be translated,
# and make their model fix the database schema
models_to_untranslate = set()
@ -508,7 +470,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
registry.init_models(cr, list(models_to_untranslate), {'models_to_check': True})
registry.loaded = True
registry.setup_models(cr)
registry._setup_models__(cr)
# check that all installed modules have been loaded by the registry
Module = env['ir.module.module']
@ -518,9 +480,10 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
_logger.error("Some modules are not loaded, some dependencies or manifest may be missing: %s", missing)
# STEP 3.5: execute migration end-scripts
migrations = odoo.modules.migration.MigrationManager(cr, graph)
for package in graph:
migrations.migrate_module(package, 'end')
if update_module:
migrations = MigrationManager(cr, graph)
for package in graph:
migrations.migrate_module(package, 'end')
# check that new module dependencies have been properly installed after a migration/upgrade
cr.execute("SELECT name from ir_module_module WHERE state IN ('to install', 'to upgrade')")
@ -529,10 +492,10 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
_logger.error("Some modules have inconsistent states, some dependencies may be missing: %s", sorted(module_list))
# STEP 3.6: apply remaining constraints in case of an upgrade
registry.finalize_constraints()
registry.finalize_constraints(cr)
# STEP 4: Finish and cleanup installations
if processed_modules:
if registry.updated_modules:
cr.execute("SELECT model from ir_model")
for (model,) in cr.fetchall():
@ -542,7 +505,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
_logger.runbot("Model %s is declared but cannot be loaded! (Perhaps a module was partially removed or renamed)", model)
# Cleanup orphan records
env['ir.model.data']._process_end(processed_modules)
env['ir.model.data']._process_end(registry.updated_modules)
# Cleanup cron
vacuum_cron = env.ref('base.autovacuum_job', raise_if_not_found=False)
if vacuum_cron:
@ -551,9 +514,6 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
env.flush_all()
for kind in ('init', 'demo', 'update'):
tools.config[kind] = {}
# STEP 5: Uninstall modules to remove
if update_module:
# Remove records referenced from ir_model_data for modules to be
@ -563,7 +523,7 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
if modules_to_remove:
pkgs = reversed([p for p in graph if p.name in modules_to_remove])
for pkg in pkgs:
uninstall_hook = pkg.info.get('uninstall_hook')
uninstall_hook = pkg.manifest.get('uninstall_hook')
if uninstall_hook:
py_module = sys.modules['odoo.addons.%s' % (pkg.name,)]
getattr(py_module, uninstall_hook)(env)
@ -575,13 +535,13 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
# modules to remove next time
cr.commit()
_logger.info('Reloading registry once more after uninstalling modules')
registry = odoo.modules.registry.Registry.new(
cr.dbname, force_demo, status, update_module
registry = Registry.new(
cr.dbname, update_module=update_module
)
cr.reset()
registry.check_tables_exist(cr)
cr.commit()
return registry
return
# STEP 5.5: Verify extended fields on every model
# This will fix the schema of all models in a situation such as:
@ -590,12 +550,15 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
# - module C is loaded and extends model M;
# - module B and C depend on A but not on each other;
# The changes introduced by module C are not taken into account by the upgrade of B.
if update_module:
# We need to fix custom fields for which we have dropped the not-null constraint.
cr.execute("""SELECT DISTINCT model FROM ir_model_fields WHERE state = 'manual'""")
models_to_check.update(model_name for model_name, in cr.fetchall() if model_name in registry)
if models_to_check:
registry.init_models(cr, list(models_to_check), {'models_to_check': True})
registry.init_models(cr, list(models_to_check), {'models_to_check': True, 'update_custom_fields': True})
# STEP 6: verify custom views on every model
if update_module:
env['res.groups']._update_user_groups_view()
View = env['ir.ui.view']
for model in registry:
try:
@ -608,21 +571,20 @@ def load_modules(registry, force_demo=False, status=None, update_module=False):
else:
_logger.error('At least one test failed when loading the modules.')
# STEP 8: save installed/updated modules for post-install tests and _register_hook
registry.updated_modules += processed_modules
# STEP 9: call _register_hook on every model
# This is done *exactly once* when the registry is being loaded. See the
# management of those hooks in `Registry.setup_models`: all the calls to
# setup_models() done here do not mess up with hooks, as registry.ready
# management of those hooks in `Registry._setup_models__`: all the calls to
# _setup_models__() done here do not mess up with hooks, as registry.ready
# is False.
for model in env.values():
model._register_hook()
env.flush_all()
# STEP 10: check that we can trust nullable columns
registry.check_null_constraints(cr)
def reset_modules_state(db_name):
def reset_modules_state(db_name: str) -> None:
"""
Resets modules flagged as "to x" to their original state
"""

View file

@ -1,21 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
""" Modules migration handling. """
from __future__ import annotations
import glob
import importlib.util
import inspect
import itertools
import logging
import os
import re
import typing
from collections import defaultdict
from os.path import join as opj
import odoo.release as release
import odoo.upgrade
from odoo.tools.parse_version import parse_version
from odoo.modules.module import load_script
from odoo.orm.registry import Registry
from odoo.tools.misc import file_path
from odoo.tools.parse_version import parse_version
if typing.TYPE_CHECKING:
from collections.abc import Iterator
from odoo.sql_db import Cursor
from . import module_graph
_logger = logging.getLogger(__name__)
@ -47,15 +55,7 @@ VERSION_RE = re.compile(
)
def load_script(path, module_name):
full_path = file_path(path) if not os.path.isabs(path) else path
spec = importlib.util.spec_from_file_location(module_name, full_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class MigrationManager(object):
class MigrationManager:
""" Manages the migration of modules.
Migrations files must be python files containing a ``migrate(cr, installed_version)``
@ -86,21 +86,22 @@ class MigrationManager(object):
| `-- end-invariants.py # processed on all version update
`-- foo.py # not processed
"""
migrations: defaultdict[str, dict]
def __init__(self, cr, graph):
def __init__(self, cr: Cursor, graph: module_graph.ModuleGraph):
self.cr = cr
self.graph = graph
self.migrations = defaultdict(dict)
self._get_files()
def _get_files(self):
def _get_upgrade_path(pkg):
def _get_files(self) -> None:
def _get_upgrade_path(pkg: str) -> Iterator[str]:
for path in odoo.upgrade.__path__:
upgrade_path = opj(path, pkg)
if os.path.exists(upgrade_path):
yield upgrade_path
def _verify_upgrade_version(path, version):
def _verify_upgrade_version(path: str, version: str) -> bool:
full_path = opj(path, version)
if not os.path.isdir(full_path):
return False
@ -114,7 +115,7 @@ class MigrationManager(object):
return True
def get_scripts(path):
def get_scripts(path: str) -> dict[str, list[str]]:
if not path:
return {}
return {
@ -123,15 +124,14 @@ class MigrationManager(object):
if _verify_upgrade_version(path, version)
}
def check_path(path):
def check_path(path: str) -> str:
try:
return file_path(path)
except FileNotFoundError:
return False
return ''
for pkg in self.graph:
if not (hasattr(pkg, 'update') or pkg.state == 'to upgrade' or
getattr(pkg, 'load_state', None) == 'to upgrade'):
if pkg.load_state != 'to upgrade' and pkg.name not in Registry(self.cr.dbname)._force_upgrade_scripts:
continue
@ -146,26 +146,24 @@ class MigrationManager(object):
scripts[v].extend(s)
self.migrations[pkg.name]["upgrade"] = scripts
def migrate_module(self, pkg, stage):
def migrate_module(self, pkg: module_graph.ModuleNode, stage: typing.Literal['pre', 'post', 'end']) -> None:
assert stage in ('pre', 'post', 'end')
stageformat = {
'pre': '[>%s]',
'post': '[%s>]',
'end': '[$%s]',
}
state = pkg.state if stage in ('pre', 'post') else getattr(pkg, 'load_state', None)
if not (hasattr(pkg, 'update') or state == 'to upgrade') or state == 'to install':
if pkg.load_state != 'to upgrade' and pkg.name not in Registry(self.cr.dbname)._force_upgrade_scripts:
return
def convert_version(version):
def convert_version(version: str) -> str:
if version == "0.0.0":
return version
if version.count(".") > 2:
return version # the version number already contains the server version, see VERSION_RE for details
return "%s.%s" % (release.major_version, version)
def _get_migration_versions(pkg, stage):
def _get_migration_versions(pkg, stage: str) -> list[str]:
versions = sorted({
ver
for lv in self.migrations[pkg.name].values()
@ -196,11 +194,11 @@ class MigrationManager(object):
key=os.path.basename,
)
installed_version = getattr(pkg, 'load_version', pkg.installed_version) or ''
installed_version = pkg.load_version or ''
parsed_installed_version = parse_version(installed_version)
current_version = parse_version(convert_version(pkg.data['version']))
current_version = parse_version(convert_version(pkg.manifest['version']))
def compare(version):
def compare(version: str) -> bool:
if version == "0.0.0" and parsed_installed_version < current_version:
return True

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
"""Utility functions to manage module manifest files and discovery."""
from __future__ import annotations
import ast
import collections.abc
import copy
import functools
import importlib
@ -12,21 +12,23 @@ import os
import re
import sys
import traceback
import typing
import warnings
from os.path import join as opj, normpath
from collections.abc import Collection, Iterable, Mapping
from os.path import join as opj
import odoo
import odoo.tools as tools
import odoo.addons
import odoo.release as release
from odoo.tools.misc import file_path
import odoo.tools as tools
import odoo.upgrade
try:
from packaging.requirements import InvalidRequirement, Requirement
except ImportError:
class InvalidRequirement(Exception):
class InvalidRequirement(Exception): # type: ignore[no-redef]
...
class Requirement:
class Requirement: # type: ignore[no-redef]
def __init__(self, pydep):
if not re.fullmatch(r'[\w\-]+', pydep): # check that we have no versions or marker in pydep
msg = f"Package `packaging` is required to parse `{pydep}` external dependency and is not installed"
@ -35,42 +37,56 @@ except ImportError:
self.specifier = None
self.name = pydep
__all__ = [
"Manifest",
"adapt_version",
"get_manifest",
"get_module_path",
"get_modules",
"get_modules_with_version",
"get_resource_from_path",
"initialize_sys_path",
"load_openerp_module",
]
MANIFEST_NAMES = ('__manifest__.py', '__openerp__.py')
README = ['README.rst', 'README.md', 'README.txt']
MODULE_NAME_RE = re.compile(r'^\w{1,256}$')
MANIFEST_NAMES = ['__manifest__.py']
README = ['README.rst', 'README.md', 'README.txt', 'README']
_DEFAULT_MANIFEST = {
#addons_path: f'/path/to/the/addons/path/of/{module}', # automatic
# Mandatory fields (with no defaults):
# - author
# - license
# - name
# Derived fields are computed in the Manifest class.
'application': False,
'bootstrap': False, # web
'assets': {},
'author': 'Odoo S.A.',
'auto_install': False,
'category': 'Uncategorized',
'cloc_exclude': [],
'configurator_snippets': {}, # website themes
'configurator_snippets_addons': {}, # website themes
'countries': [],
'data': [],
'demo': [],
'demo_xml': [],
'depends': [],
'description': '',
'description': '', # defaults to README file
'external_dependencies': {},
#icon: f'/{module}/static/description/icon.png', # automatic
'init_xml': [],
'installable': True,
'images': [], # website
'images_preview_theme': {}, # website themes
#license, mandatory
'live_test_url': '', # website themes
'new_page_templates': {}, # website themes
#name, mandatory
'post_init_hook': '',
'post_load': '',
'pre_init_hook': '',
'sequence': 100,
'summary': '',
'test': [],
'theme_customizations': {}, # themes
'update_xml': [],
'uninstall_hook': '',
'version': '1.0',
@ -91,8 +107,11 @@ TYPED_FIELD_DEFINITION_RE = re.compile(r'''
_logger = logging.getLogger(__name__)
current_test: bool = False
"""Indicates whteher we are in a test mode"""
class UpgradeHook(object):
class UpgradeHook:
"""Makes the legacy `migrations` package being `odoo.upgrade`"""
def find_spec(self, fullname, path=None, target=None):
@ -118,49 +137,197 @@ class UpgradeHook(object):
return sys.modules[name]
def initialize_sys_path():
def initialize_sys_path() -> None:
"""
Setup the addons path ``odoo.addons.__path__`` with various defaults
and explicit directories.
"""
# hook odoo.addons on data dir
dd = os.path.normcase(tools.config.addons_data_dir)
if os.access(dd, os.R_OK) and dd not in odoo.addons.__path__:
odoo.addons.__path__.append(dd)
# hook odoo.addons on addons paths
for ad in tools.config['addons_path'].split(','):
ad = os.path.normcase(os.path.abspath(ad.strip()))
if ad not in odoo.addons.__path__:
odoo.addons.__path__.append(ad)
# hook odoo.addons on base module path
base_path = os.path.normcase(os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'addons')))
if base_path not in odoo.addons.__path__ and os.path.isdir(base_path):
odoo.addons.__path__.append(base_path)
for path in (
# tools.config.addons_base_dir, # already present
tools.config.addons_data_dir,
*tools.config['addons_path'],
tools.config.addons_community_dir,
):
if os.access(path, os.R_OK) and path not in odoo.addons.__path__:
odoo.addons.__path__.append(path)
# hook odoo.upgrade on upgrade-path
from odoo import upgrade
legacy_upgrade_path = os.path.join(base_path, 'base', 'maintenance', 'migrations')
for up in (tools.config['upgrade_path'] or legacy_upgrade_path).split(','):
up = os.path.normcase(os.path.abspath(up.strip()))
if os.path.isdir(up) and up not in upgrade.__path__:
upgrade.__path__.append(up)
legacy_upgrade_path = os.path.join(tools.config.addons_base_dir, 'base/maintenance/migrations')
for up in tools.config['upgrade_path'] or [legacy_upgrade_path]:
if up not in odoo.upgrade.__path__:
odoo.upgrade.__path__.append(up)
# create decrecated module alias from odoo.addons.base.maintenance.migrations to odoo.upgrade
spec = importlib.machinery.ModuleSpec("odoo.addons.base.maintenance", None, is_package=True)
maintenance_pkg = importlib.util.module_from_spec(spec)
maintenance_pkg.migrations = upgrade
maintenance_pkg.migrations = odoo.upgrade # type: ignore
sys.modules["odoo.addons.base.maintenance"] = maintenance_pkg
sys.modules["odoo.addons.base.maintenance.migrations"] = upgrade
sys.modules["odoo.addons.base.maintenance.migrations"] = odoo.upgrade
# hook deprecated module alias from openerp to odoo and "crm"-like to odoo.addons
if not getattr(initialize_sys_path, 'called', False): # only initialize once
# hook for upgrades and namespace freeze
if not getattr(initialize_sys_path, 'called', False): # only initialize once
odoo.addons.__path__._path_finder = lambda *a: None # prevent path invalidation
odoo.upgrade.__path__._path_finder = lambda *a: None # prevent path invalidation
sys.meta_path.insert(0, UpgradeHook())
initialize_sys_path.called = True
initialize_sys_path.called = True # type: ignore
def get_module_path(module, downloaded=False, display_warning=True):
@typing.final
class Manifest(Mapping[str, typing.Any]):
"""The manifest data of a module."""
def __init__(self, *, path: str, manifest_content: dict):
assert os.path.isabs(path), "path of module must be absolute"
self.path = path
_, self.name = os.path.split(path)
if not MODULE_NAME_RE.match(self.name):
raise FileNotFoundError(f"Invalid module name: {self.name}")
self._manifest_content = manifest_content
@property
def addons_path(self) -> str:
parent_path, name = os.path.split(self.path)
assert name == self.name
return parent_path
@functools.cached_property
def manifest_cached(self) -> dict:
"""Parsed and validated manifest data from the file."""
return _load_manifest(self.name, self._manifest_content)
@functools.cached_property
def description(self):
"""The description of the module defaulting to the README file."""
if (desc := self.manifest_cached.get('description')):
return desc
for file_name in README:
try:
with tools.file_open(opj(self.path, file_name)) as f:
return f.read()
except OSError:
pass
return ''
@functools.cached_property
def version(self):
try:
return self.manifest_cached['version']
except Exception: # noqa: BLE001
return adapt_version('1.0')
@functools.cached_property
def icon(self) -> str:
return get_module_icon(self.name)
@functools.cached_property
def static_path(self) -> str | None:
static_path = opj(self.path, 'static')
manifest = self.manifest_cached
if (manifest['installable'] or manifest['assets']) and os.path.isdir(static_path):
return static_path
return None
def __getitem__(self, key: str):
if key in ('description', 'icon', 'addons_path', 'version', 'static_path'):
return getattr(self, key)
return copy.deepcopy(self.manifest_cached[key])
def __iter__(self):
manifest = self.manifest_cached
yield from manifest
for key in ('description', 'icon', 'addons_path', 'version', 'static_path'):
if key not in manifest:
yield key
def check_manifest_dependencies(self) -> None:
"""Check that the dependecies of the manifest are available.
- Checking for external python dependencies
- Checking binaries are available in PATH
On missing dependencies, raise an error.
"""
depends = self.get('external_dependencies')
if not depends:
return
for pydep in depends.get('python', []):
check_python_external_dependency(pydep)
for binary in depends.get('bin', []):
try:
tools.find_in_path(binary)
except OSError:
msg = "Unable to find {dependency!r} in path"
raise MissingDependency(msg, binary)
def __bool__(self):
return True
def __len__(self):
return sum(1 for _ in self)
def __repr__(self):
return f'Manifest({self.name})'
# limit cache size because this may get called from any module with any input
@staticmethod
@functools.lru_cache(10_000)
def _get_manifest_from_addons(module: str) -> Manifest | None:
"""Get the module's manifest from a name. Searching only in addons paths."""
for adp in odoo.addons.__path__:
if manifest := Manifest._from_path(opj(adp, module)):
return manifest
return None
@staticmethod
def for_addon(module_name: str, *, display_warning: bool = True) -> Manifest | None:
"""Get the module's manifest from a name.
:param module: module's name
:param display_warning: log a warning if the module is not found
"""
if not MODULE_NAME_RE.match(module_name):
# invalid module name
return None
if mod := Manifest._get_manifest_from_addons(module_name):
return mod
if display_warning:
_logger.warning('module %s: manifest not found', module_name)
return None
@staticmethod
def _from_path(path: str, env=None) -> Manifest | None:
"""Given a path, read the manifest file."""
for manifest_name in MANIFEST_NAMES:
try:
with tools.file_open(opj(path, manifest_name), env=env) as f:
manifest_content = ast.literal_eval(f.read())
except OSError:
pass
except Exception: # noqa: BLE001
_logger.debug("Failed to parse the manifest file at %r", path, exc_info=True)
else:
return Manifest(path=path, manifest_content=manifest_content)
return None
@staticmethod
def all_addon_manifests() -> list[Manifest]:
"""Read all manifests in the addons paths."""
modules: dict[str, Manifest] = {}
for adp in odoo.addons.__path__:
if not os.path.isdir(adp):
_logger.warning("addons path is not a directory: %s", adp)
continue
for file_name in os.listdir(adp):
if file_name in modules:
continue
if mod := Manifest._from_path(opj(adp, file_name)):
assert file_name == mod.name
modules[file_name] = mod
return sorted(modules.values(), key=lambda m: m.name)
def get_module_path(module: str, display_warning: bool = True) -> str | None:
"""Return the path of the given module.
Search the addons paths and return the first path where the given
@ -168,44 +335,12 @@ def get_module_path(module, downloaded=False, display_warning=True):
path if nothing else is found.
"""
if re.search(r"[\/\\]", module):
return False
for adp in odoo.addons.__path__:
files = [opj(adp, module, manifest) for manifest in MANIFEST_NAMES] +\
[opj(adp, module + '.zip')]
if any(os.path.exists(f) for f in files):
return opj(adp, module)
# TODO deprecate
mod = Manifest.for_addon(module, display_warning=display_warning)
return mod.path if mod else None
if downloaded:
return opj(tools.config.addons_data_dir, module)
if display_warning:
_logger.warning('module %s: module not found', module)
return False
def get_resource_path(module, *args):
"""Return the full path of a resource of the given module.
:param module: module name
:param list(str) args: resource path components within module
:rtype: str
:return: absolute path to the resource
"""
warnings.warn(
f"Since 17.0: use tools.misc.file_path instead of get_resource_path({module}, {args})",
DeprecationWarning,
)
resource_path = opj(module, *args)
try:
return file_path(resource_path)
except (FileNotFoundError, ValueError):
return False
# backwards compatibility
get_module_resource = get_resource_path
check_resource_path = get_resource_path
def get_resource_from_path(path):
def get_resource_from_path(path: str) -> tuple[str, str, str] | None:
"""Tries to extract the module name and the resource's relative path
out of an absolute resource path.
@ -220,7 +355,7 @@ def get_resource_from_path(path):
:rtype: tuple
:return: tuple(module_name, relative_path, os_relative_path) if possible, else None
"""
resource = False
resource = None
sorted_paths = sorted(odoo.addons.__path__, key=len, reverse=True)
for adpath in sorted_paths:
# force trailing separator
@ -237,118 +372,94 @@ def get_resource_from_path(path):
return (module, '/'.join(relative), os.path.sep.join(relative))
return None
def get_module_icon(module):
fpath = f"{module}/static/description/icon.png"
def get_module_icon(module: str) -> str:
""" Get the path to the module's icon. Invalid module names are accepted. """
manifest = Manifest.for_addon(module, display_warning=False)
if manifest and 'icon' in manifest.__dict__:
return manifest.icon
try:
file_path(fpath)
fpath = f"{module}/static/description/icon.png"
tools.file_path(fpath)
return "/" + fpath
except FileNotFoundError:
return "/base/static/description/icon.png"
def get_module_icon_path(module):
try:
return file_path(f"{module}/static/description/icon.png")
except FileNotFoundError:
return file_path("base/static/description/icon.png")
def module_manifest(path):
"""Returns path to module manifest if one can be found under `path`, else `None`."""
if not path:
return None
for manifest_name in MANIFEST_NAMES:
candidate = opj(path, manifest_name)
if os.path.isfile(candidate):
if manifest_name == '__openerp__.py':
warnings.warn(
"__openerp__.py manifests are deprecated since 17.0, "
f"rename {candidate!r} to __manifest__.py "
"(valid since 10.0)",
category=DeprecationWarning
)
return candidate
def get_module_root(path):
"""
Get closest module's root beginning from path
# Given:
# /foo/bar/module_dir/static/src/...
get_module_root('/foo/bar/module_dir/static/')
# returns '/foo/bar/module_dir'
get_module_root('/foo/bar/module_dir/')
# returns '/foo/bar/module_dir'
get_module_root('/foo/bar')
# returns None
@param path: Path from which the lookup should start
@return: Module root path or None if not found
"""
while not module_manifest(path):
new_path = os.path.abspath(opj(path, os.pardir))
if path == new_path:
return None
path = new_path
return path
def load_manifest(module, mod_path=None):
def load_manifest(module: str, mod_path: str | None = None) -> dict:
""" Load the module manifest from the file system. """
warnings.warn("Since 19.0, use Manifest", DeprecationWarning)
if not mod_path:
mod_path = get_module_path(module, downloaded=True)
manifest_file = module_manifest(mod_path)
if not manifest_file:
if mod_path:
mod = Manifest._from_path(mod_path)
assert mod.path == mod_path
else:
mod = Manifest.for_addon(module)
if not mod:
_logger.debug('module %s: no manifest file found %s', module, MANIFEST_NAMES)
return {}
return dict(mod)
def _load_manifest(module: str, manifest_content: dict) -> dict:
""" Load and validate the module manifest.
Return a new dictionary with cleaned and validated keys.
"""
manifest = copy.deepcopy(_DEFAULT_MANIFEST)
manifest.update(manifest_content)
manifest['icon'] = get_module_icon(module)
with tools.file_open(manifest_file, mode='r') as f:
manifest.update(ast.literal_eval(f.read()))
if not manifest['description']:
readme_path = [opj(mod_path, x) for x in README
if os.path.isfile(opj(mod_path, x))]
if readme_path:
with tools.file_open(readme_path[0]) as fd:
manifest['description'] = fd.read()
if not manifest.get('author'):
# Altought contributors and maintainer are not documented, it is
# not uncommon to find them in manifest files, use them as
# alternative.
author = manifest.get('contributors') or manifest.get('maintainer') or ''
manifest['author'] = str(author)
_logger.warning("Missing `author` key in manifest for %r, defaulting to %r", module, str(author))
if not manifest.get('license'):
manifest['license'] = 'LGPL-3'
_logger.warning("Missing `license` key in manifest for %r, defaulting to LGPL-3", module)
if module == 'base':
manifest['depends'] = []
elif not manifest['depends']:
# prevent the hack `'depends': []` except 'base' module
manifest['depends'] = ['base']
depends = manifest['depends']
assert isinstance(depends, Collection)
# auto_install is either `False` (by default) in which case the module
# is opt-in, either a list of dependencies in which case the module is
# automatically installed if all dependencies are (special case: [] to
# always install the module), either `True` to auto-install the module
# in case all dependencies declared in `depends` are installed.
if isinstance(manifest['auto_install'], collections.abc.Iterable):
manifest['auto_install'] = set(manifest['auto_install'])
non_dependencies = manifest['auto_install'].difference(manifest['depends'])
assert not non_dependencies,\
"auto_install triggers must be dependencies, found " \
"non-dependencies [%s] for module %s" % (
', '.join(non_dependencies), module
)
if isinstance(manifest['auto_install'], Iterable):
manifest['auto_install'] = auto_install_set = set(manifest['auto_install'])
non_dependencies = auto_install_set.difference(depends)
assert not non_dependencies, (
"auto_install triggers must be dependencies,"
f" found non-dependencies [{', '.join(non_dependencies)}] for module {module}"
)
elif manifest['auto_install']:
manifest['auto_install'] = set(manifest['depends'])
manifest['auto_install'] = set(depends)
try:
manifest['version'] = adapt_version(manifest['version'])
manifest['version'] = adapt_version(str(manifest['version']))
except ValueError as e:
if manifest.get("installable", True):
if manifest['installable']:
raise ValueError(f"Module {module}: invalid manifest") from e
manifest['addons_path'] = normpath(opj(mod_path, os.pardir))
if manifest['installable'] and not check_version(str(manifest['version']), should_raise=False):
_logger.warning("The module %s has an incompatible version, setting installable=False", module)
manifest['installable'] = False
return manifest
def get_manifest(module, mod_path=None):
def get_manifest(module: str, mod_path: str | None = None) -> Mapping[str, typing.Any]:
"""
Get the module manifest.
@ -358,16 +469,17 @@ def get_manifest(module, mod_path=None):
addons-paths.
:returns: The module manifest as a dict or an empty dict
when the manifest was not found.
:rtype: dict
"""
return copy.deepcopy(_get_manifest_cached(module, mod_path))
@functools.lru_cache(maxsize=None)
def _get_manifest_cached(module, mod_path=None):
return load_manifest(module, mod_path)
if mod_path:
mod = Manifest._from_path(mod_path)
if mod and mod.name != module:
raise ValueError(f"Invalid path for module {module}: {mod_path}")
else:
mod = Manifest.for_addon(module, display_warning=False)
return mod if mod is not None else {}
def load_openerp_module(module_name):
def load_openerp_module(module_name: str) -> None:
""" Load an OpenERP module, if not already loaded.
This loads the module and register all of its models, thanks to either
@ -386,9 +498,9 @@ def load_openerp_module(module_name):
# Call the module's post-load hook. This can done before any model or
# data has been initialized. This is ok as the post-load hook is for
# server-wide (instead of registry-specific) functionalities.
info = get_manifest(module_name)
if info['post_load']:
getattr(sys.modules[qualname], info['post_load'])()
manifest = Manifest.for_addon(module_name)
if post_load := manifest.get('post_load'):
getattr(sys.modules[qualname], post_load)()
except AttributeError as err:
_logger.critical("Couldn't load module %s", module_name)
@ -412,69 +524,63 @@ def load_openerp_module(module_name):
_logger.critical("Couldn't load module %s", module_name)
raise
def get_modules():
"""Returns the list of module names
def get_modules() -> list[str]:
"""Get the list of module names that can be loaded.
"""
def listdir(dir):
def clean(name):
name = os.path.basename(name)
if name[-4:] == '.zip':
name = name[:-4]
return name
return [m.name for m in Manifest.all_addon_manifests()]
def is_really_module(name):
for mname in MANIFEST_NAMES:
if os.path.isfile(opj(dir, name, mname)):
return True
return [
clean(it)
for it in os.listdir(dir)
if is_really_module(it)
]
plist = []
for ad in odoo.addons.__path__:
if not os.path.exists(ad):
_logger.warning("addons path does not exist: %s", ad)
continue
plist.extend(listdir(ad))
return sorted(set(plist))
def get_modules_with_version() -> dict[str, str]:
"""Get the module list with the linked version."""
warnings.warn("Since 19.0, use Manifest.all_addon_manifests", DeprecationWarning)
return {m.name: m.version for m in Manifest.all_addon_manifests()}
def get_modules_with_version():
modules = get_modules()
res = dict.fromkeys(modules, adapt_version('1.0'))
for module in modules:
try:
info = get_manifest(module)
res[module] = info['version']
except Exception:
continue
return res
def adapt_version(version):
def adapt_version(version: str) -> str:
"""Reformat the version of the module into a canonical format."""
version_str_parts = version.split('.')
if not (2 <= len(version_str_parts) <= 5):
raise ValueError(f"Invalid version {version!r}, must have between 2 and 5 parts")
serie = release.major_version
if version == serie or not version.startswith(serie + '.'):
base_version = version
version = '%s.%s' % (serie, version)
else:
base_version = version[len(serie) + 1:]
if not re.match(r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?$", base_version):
raise ValueError(f"Invalid version {base_version!r}. Modules should have a version in format `x.y`, `x.y.z`,"
f" `{serie}.x.y` or `{serie}.x.y.z`.")
if version.startswith(serie) and not version_str_parts[0].isdigit():
# keep only digits for parsing
version_str_parts[0] = ''.join(c for c in version_str_parts[0] if c.isdigit())
try:
version_parts = [int(v) for v in version_str_parts]
except ValueError as e:
raise ValueError(f"Invalid version {version!r}") from e
if len(version_parts) <= 3 and not version.startswith(serie):
# prefix the version with serie
return f"{serie}.{version}"
return version
current_test = False
def check_version(version: str, should_raise: bool = True) -> bool:
"""Check that the version is in a valid format for the current release."""
version = adapt_version(version)
serie = release.major_version
if version.startswith(serie + '.'):
return True
if should_raise:
raise ValueError(
f"Invalid version {version!r}. Modules should have a version in format"
f" `x.y`, `x.y.z`, `{serie}.x.y` or `{serie}.x.y.z`.")
return False
def check_python_external_dependency(pydep):
class MissingDependency(Exception):
def __init__(self, msg_template: str, dependency: str):
self.dependency = dependency
super().__init__(msg_template.format(dependency=dependency))
def check_python_external_dependency(pydep: str) -> None:
try:
requirement = Requirement(pydep)
except InvalidRequirement as e:
msg = f"{pydep} is an invalid external dependency specification: {e}"
raise Exception(msg) from e
raise ValueError(msg) from e
if requirement.marker and not requirement.marker.evaluate():
_logger.debug(
"Ignored external dependency %s because environment markers do not match",
@ -491,22 +597,17 @@ def check_python_external_dependency(pydep):
return
except ImportError:
pass
msg = f"External dependency {pydep} not installed: {e}"
raise Exception(msg) from e
msg = "External dependency {dependency!r} not installed: %s" % (e,)
raise MissingDependency(msg, pydep) from e
if requirement.specifier and not requirement.specifier.contains(version):
msg = f"External dependency version mismatch: {pydep} (installed: {version})"
raise Exception(msg)
msg = f"External dependency version mismatch: {{dependency}} (installed: {version})"
raise MissingDependency(msg, pydep)
def check_manifest_dependencies(manifest):
depends = manifest.get('external_dependencies')
if not depends:
return
for pydep in depends.get('python', []):
check_python_external_dependency(pydep)
for binary in depends.get('bin', []):
try:
tools.find_in_path(binary)
except IOError:
raise Exception('Unable to find %r in path' % (binary,))
def load_script(path: str, module_name: str):
full_path = tools.file_path(path) if not os.path.isabs(path) else path
spec = importlib.util.spec_from_file_location(module_name, full_path)
assert spec and spec.loader, f"spec not found for {module_name}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module

View file

@ -0,0 +1,313 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
""" Modules dependency graph. """
from __future__ import annotations
import functools
import logging
import typing
from odoo.tools import reset_cached_properties, OrderedSet
from odoo.tools.sql import column_exists
from .module import Manifest
if typing.TYPE_CHECKING:
from collections.abc import Collection, Iterable, Iterator, Mapping
from typing import Literal
from odoo.sql_db import BaseCursor
STATES = Literal[
'uninstallable',
'uninstalled',
'installed',
'to upgrade',
'to remove',
'to install',
]
_logger = logging.getLogger(__name__)
# THE LOADING ORDER
#
# Dependency Graph:
# +---------+
# | base |
# +---------+
# ^
# |
# |
# +---------+
# | module1 | <-----+
# +---------+ |
# ^ |
# | |
# | |
# +---------+ +---------+
# +> | module2 | | module3 |
# | +---------+ +---------+
# | ^ ^ ^
# | | | |
# | | | |
# | +---------+ | | +---------+
# | | module4 | ------+ +- | module5 |
# | +---------+ +---------+
# | ^
# | |
# | |
# | +---------+
# +- | module6 |
# +---------+
#
#
# We always load module base in the zeroth phase, because
# 1. base should always be the single drain of the dependency graph
# 2. we need to use models in the base to upgrade other modules
#
# If the ModuleGraph is in the 'load' mode
# all non-base modules are loaded in the same phase
# the loading order of modules in the same phase are sorted by the (depth, order_name)
# where depth is the longest distance from the module to the base module along the dependency graph.
# For example: the depth of module6 is 4 (path: module6 -> module4 -> module2 -> module1 -> base)
# As a result, the loading order is
# phase 0: base
# phase 1: module1 -> module2 -> module3 -> module4 -> module5 -> module6
#
# If the ModuleGraph is in the 'update' mode
# For example,
# 'installed' : base, module1, module2, module3
# 'to upgrade': module4, module6
# 'to install': module5
# the updating order is
# phase 0: base
# phase 1: module1 -> module2 -> module3 -> module4 -> module6
# phase 2: module5
#
# In summary:
# phase 0: base
# phase odd: (modules: 1. don't need init; 2. all depends modules have been loaded or going to be loaded in this phase)
# phase even: (modules: 1. need init; 2. all depends modules have been loaded or going to be loaded in this phase)
#
#
# Test modules
# For a module starting with 'test_', we want it to be loaded right after its last loaded dependency in the 'load' mode,
# let's call that module 'xxx'.
# Therefore, the depth will be 'xxx.depth' and the name will be prefixed by 'xxx ' as its order_name.
#
#
# Corner case
# Sometimes the dependency may be changed for sake of upgrade
# For example
# BEFORE UPGRADE UPGRADING
#
# +---------+ +---------+
# | base | | base |
# +---------+ +---------+
# ^ installed ^ to upgrade
# | |
# | |
# +---------+ +---------+
# | module1 | | module1 | <-----+
# +---------+ +---------+ |
# ^ installed ^ to upgrade |
# | ==> | |
# | | |
# +---------+ +---------+ +---------+
# | module2 | | module2 | | module3 |
# +---------+ +---------+ +---------+
# ^ installed ^ to upgrade ^ to install
# | | |
# | | |
# +---------+ +---------+ |
# | module4 | | module4 | ------+
# +---------+ +---------+
# installed to upgrade
#
# Because of the new dependency module4 -> module3
# The module3 will be marked 'to install' while upgrading, and module4 should be loaded after module3
# As a result, the updating order is
# phase 0: base
# phase 1: module1 -> module2
# phase 2: module3
# phase 3: module4
class ModuleNode:
"""
Loading and upgrade info for an Odoo module
"""
def __init__(self, name: str, module_graph: ModuleGraph) -> None:
# manifest data
self.name: str = name
# for performance reasons, use the cached value to avoid deepcopy; it is
# acceptable in this context since we don't modify it
manifest = Manifest.for_addon(name, display_warning=False)
if manifest is not None:
manifest.manifest_cached # parse the manifest now
self.manifest: Mapping = manifest or {}
# ir_module_module data # column_name
self.id: int = 0 # id
self.state: STATES = 'uninstalled' # state
self.demo: bool = False # demo
self.installed_version: str | None = None # latest_version (attention: Incorrect field names !! in ir_module.py)
# info for upgrade
self.load_state: STATES = 'uninstalled' # the state when added to module_graph
self.load_version: str | None = None # the version when added to module_graph
# dependency
self.depends: OrderedSet[ModuleNode] = OrderedSet()
self.module_graph: ModuleGraph = module_graph
@functools.cached_property
def order_name(self) -> str:
if self.name.startswith('test_'):
# The 'space' was chosen because it's smaller than any character that can be used by the module name.
last_installed_dependency = max(self.depends, key=lambda m: (m.depth, m.order_name))
return last_installed_dependency.order_name + ' ' + self.name
return self.name
@functools.cached_property
def depth(self) -> int:
""" Return the longest distance from self to module 'base' along dependencies. """
if self.name.startswith('test_'):
last_installed_dependency = max(self.depends, key=lambda m: (m.depth, m.order_name))
return last_installed_dependency.depth
return max(module.depth for module in self.depends) + 1 if self.depends else 0
@functools.cached_property
def phase(self) -> int:
if self.name == 'base':
return 0
if self.module_graph.mode == 'load':
return 1
def not_in_the_same_phase(module: ModuleNode, dependency: ModuleNode) -> bool:
return (module.state == 'to install') ^ (dependency.state == 'to install')
return max(
dependency.phase
+ (1 if not_in_the_same_phase(self, dependency) else 0)
+ (1 if dependency.name == 'base' else 0)
for dependency in self.depends
)
@property
def demo_installable(self) -> bool:
return all(p.demo for p in self.depends)
class ModuleGraph:
"""
Sorted Odoo modules ordered by (module.phase, module.depth, module.name)
"""
def __init__(self, cr: BaseCursor, mode: Literal['load', 'update'] = 'load') -> None:
# mode 'load': for simply loading modules without updating them
# mode 'update': for loading and updating modules
self.mode: Literal['load', 'update'] = mode
self._modules: dict[str, ModuleNode] = {}
self._cr: BaseCursor = cr
def __contains__(self, name: str) -> bool:
return name in self._modules
def __getitem__(self, name: str) -> ModuleNode:
return self._modules[name]
def __iter__(self) -> Iterator[ModuleNode]:
return iter(sorted(self._modules.values(), key=lambda p: (p.phase, p.depth, p.order_name)))
def __len__(self) -> int:
return len(self._modules)
def extend(self, names: Collection[str]) -> None:
for module in self._modules.values():
reset_cached_properties(module)
names = [name for name in names if name not in self._modules]
for name in names:
module = self._modules[name] = ModuleNode(name, self)
if not module.manifest.get('installable'):
if name in self._imported_modules:
self._remove(name, log_dependents=False)
else:
_logger.warning('module %s: not installable, skipped', name)
self._remove(name)
self._update_depends(names)
self._update_depth(names)
self._update_from_database(names)
@functools.cached_property
def _imported_modules(self) -> OrderedSet[str]:
result = ['studio_customization']
if column_exists(self._cr, 'ir_module_module', 'imported'):
self._cr.execute('SELECT name FROM ir_module_module WHERE imported')
result += [m[0] for m in self._cr.fetchall()]
return OrderedSet(result)
def _update_depends(self, names: Iterable[str]) -> None:
for name in names:
if module := self._modules.get(name):
depends = module.manifest['depends']
try:
module.depends = OrderedSet(self._modules[dep] for dep in depends)
except KeyError:
_logger.info('module %s: some depends are not loaded, skipped', name)
self._remove(name)
def _update_depth(self, names: Iterable[str]) -> None:
for name in names:
if module := self._modules.get(name):
try:
module.depth
except RecursionError:
_logger.warning('module %s: in a dependency loop, skipped', name)
self._remove(name)
def _update_from_database(self, names: Iterable[str]) -> None:
names = tuple(name for name in names if name in self._modules)
if not names:
return
# update modules with values from the database (if exist)
query = '''
SELECT name, id, state, demo, latest_version AS installed_version
FROM ir_module_module
WHERE name IN %s
'''
self._cr.execute(query, [names])
for name, id_, state, demo, installed_version in self._cr.fetchall():
if state == 'uninstallable':
_logger.warning('module %s: not installable, skipped', name)
self._remove(name)
continue
if self.mode == 'load' and state in ['to install', 'uninstalled']:
_logger.info('module %s: not installed, skipped', name)
self._remove(name)
continue
if name not in self._modules:
# has been recursively removed for sake of not installable or not installed
continue
module = self._modules[name]
module.id = id_
module.state = state
module.demo = demo
module.installed_version = installed_version
module.load_version = installed_version
module.load_state = state
def _remove(self, name: str, log_dependents: bool = True) -> None:
module = self._modules.pop(name)
for another, another_module in list(self._modules.items()):
if module in another_module.depends and another_module.name in self._modules:
if log_dependents:
_logger.info('module %s: its direct/indirect dependency is skipped, skipped', another)
self._remove(another)

View file

@ -1,14 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from __future__ import annotations
import logging
import typing
from contextlib import suppress
import odoo
import logging
from odoo.tools.misc import file_open
if typing.TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from odoo.sql_db import Cursor
_logger = logging.getLogger(__name__)
def get_installed_modules(cursor):
def get_installed_modules(cursor: Cursor) -> list[str]:
cursor.execute('''
SELECT name
FROM ir_module_module
@ -16,15 +23,17 @@ def get_installed_modules(cursor):
''')
return [result[0] for result in cursor.fetchall()]
def get_neutralization_queries(modules):
def get_neutralization_queries(modules: Iterable[str]) -> Iterator[str]:
# neutralization for each module
for module in modules:
filename = f'{module}/data/neutralize.sql'
with suppress(FileNotFoundError):
with odoo.tools.misc.file_open(filename) as file:
with file_open(filename) as file:
yield file.read().strip()
def neutralize_database(cursor):
def neutralize_database(cursor: Cursor) -> None:
installed_modules = get_installed_modules(cursor)
queries = get_neutralization_queries(installed_modules)
for query in queries:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
# ruff: noqa: F401
# Exposed here so that exist code is unaffected.
from odoo.orm.registry import DummyRLock, Registry, _REGISTRY_CACHES, _CACHES_BY_KEY