Coverage for adhoc-cicd-odoo-odoo / odoo / modules / module_graph.py: 68%
129 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1# Part of Odoo. See LICENSE file for full copyright and licensing details.
3""" Modules dependency graph. """
5from __future__ import annotations
7import functools
8import logging
9import typing
11from odoo.tools import reset_cached_properties, OrderedSet
12from odoo.tools.sql import column_exists
14from .module import Manifest
16if typing.TYPE_CHECKING:
17 from collections.abc import Collection, Iterable, Iterator, Mapping
18 from typing import Literal
19 from odoo.sql_db import BaseCursor
21 STATES = Literal[
22 'uninstallable',
23 'uninstalled',
24 'installed',
25 'to upgrade',
26 'to remove',
27 'to install',
28 ]
30_logger = logging.getLogger(__name__)
33# THE LOADING ORDER
34#
35# Dependency Graph:
36# +---------+
37# | base |
38# +---------+
39# ^
40# |
41# |
42# +---------+
43# | module1 | <-----+
44# +---------+ |
45# ^ |
46# | |
47# | |
48# +---------+ +---------+
49# +> | module2 | | module3 |
50# | +---------+ +---------+
51# | ^ ^ ^
52# | | | |
53# | | | |
54# | +---------+ | | +---------+
55# | | module4 | ------+ +- | module5 |
56# | +---------+ +---------+
57# | ^
58# | |
59# | |
60# | +---------+
61# +- | module6 |
62# +---------+
63#
64#
65# We always load module base in the zeroth phase, because
66# 1. base should always be the single drain of the dependency graph
67# 2. we need to use models in the base to upgrade other modules
68#
69# If the ModuleGraph is in the 'load' mode
70# all non-base modules are loaded in the same phase
71# the loading order of modules in the same phase are sorted by the (depth, order_name)
72# where depth is the longest distance from the module to the base module along the dependency graph.
73# For example: the depth of module6 is 4 (path: module6 -> module4 -> module2 -> module1 -> base)
74# As a result, the loading order is
75# phase 0: base
76# phase 1: module1 -> module2 -> module3 -> module4 -> module5 -> module6
77#
78# If the ModuleGraph is in the 'update' mode
79# For example,
80# 'installed' : base, module1, module2, module3
81# 'to upgrade': module4, module6
82# 'to install': module5
83# the updating order is
84# phase 0: base
85# phase 1: module1 -> module2 -> module3 -> module4 -> module6
86# phase 2: module5
87#
88# In summary:
89# phase 0: base
90# phase odd: (modules: 1. don't need init; 2. all depends modules have been loaded or going to be loaded in this phase)
91# phase even: (modules: 1. need init; 2. all depends modules have been loaded or going to be loaded in this phase)
92#
93#
94# Test modules
95# For a module starting with 'test_', we want it to be loaded right after its last loaded dependency in the 'load' mode,
96# let's call that module 'xxx'.
97# Therefore, the depth will be 'xxx.depth' and the name will be prefixed by 'xxx ' as its order_name.
98#
99#
100# Corner case
101# Sometimes the dependency may be changed for sake of upgrade
102# For example
103# BEFORE UPGRADE UPGRADING
104#
105# +---------+ +---------+
106# | base | | base |
107# +---------+ +---------+
108# ^ installed ^ to upgrade
109# | |
110# | |
111# +---------+ +---------+
112# | module1 | | module1 | <-----+
113# +---------+ +---------+ |
114# ^ installed ^ to upgrade |
115# | ==> | |
116# | | |
117# +---------+ +---------+ +---------+
118# | module2 | | module2 | | module3 |
119# +---------+ +---------+ +---------+
120# ^ installed ^ to upgrade ^ to install
121# | | |
122# | | |
123# +---------+ +---------+ |
124# | module4 | | module4 | ------+
125# +---------+ +---------+
126# installed to upgrade
127#
128# Because of the new dependency module4 -> module3
129# The module3 will be marked 'to install' while upgrading, and module4 should be loaded after module3
130# As a result, the updating order is
131# phase 0: base
132# phase 1: module1 -> module2
133# phase 2: module3
134# phase 3: module4
137class ModuleNode:
138 """
139 Loading and upgrade info for an Odoo module
140 """
141 def __init__(self, name: str, module_graph: ModuleGraph) -> None:
142 # manifest data
143 self.name: str = name
144 # for performance reasons, use the cached value to avoid deepcopy; it is
145 # acceptable in this context since we don't modify it
146 manifest = Manifest.for_addon(name, display_warning=False)
147 if manifest is not None: 147 ↛ 149line 147 didn't jump to line 149 because the condition on line 147 was always true
148 manifest.raw_value('') # parse the manifest now
149 self.manifest: Mapping = manifest or {}
151 # ir_module_module data # column_name
152 self.id: int = 0 # id
153 self.state: STATES = 'uninstalled' # state
154 self.demo: bool = False # demo
155 self.installed_version: str | None = None # latest_version (attention: Incorrect field names !! in ir_module.py)
157 # info for upgrade
158 self.load_state: STATES = 'uninstalled' # the state when added to module_graph
159 self.load_version: str | None = None # the version when added to module_graph
161 # dependency
162 self.depends: OrderedSet[ModuleNode] = OrderedSet()
163 self.module_graph: ModuleGraph = module_graph
165 @functools.cached_property
166 def order_name(self) -> str:
167 if self.name.startswith('test_'): 167 ↛ 169line 167 didn't jump to line 169 because the condition on line 167 was never true
168 # The 'space' was chosen because it's smaller than any character that can be used by the module name.
169 last_installed_dependency = max(self.depends, key=lambda m: (m.depth, m.order_name))
170 return last_installed_dependency.order_name + ' ' + self.name
172 return self.name
174 @functools.cached_property
175 def depth(self) -> int:
176 """ Return the longest distance from self to module 'base' along dependencies. """
177 if self.name.startswith('test_'): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 last_installed_dependency = max(self.depends, key=lambda m: (m.depth, m.order_name))
179 return last_installed_dependency.depth
181 return max(module.depth for module in self.depends) + 1 if self.depends else 0
183 @functools.cached_property
184 def phase(self) -> int:
185 if self.name == 'base':
186 return 0
188 if self.module_graph.mode == 'load': 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 return 1
191 def not_in_the_same_phase(module: ModuleNode, dependency: ModuleNode) -> bool:
192 return (module.state == 'to install') ^ (dependency.state == 'to install')
194 return max(
195 dependency.phase
196 + (1 if not_in_the_same_phase(self, dependency) else 0)
197 + (1 if dependency.name == 'base' else 0)
198 for dependency in self.depends
199 )
201 @property
202 def demo_installable(self) -> bool:
203 return all(p.demo for p in self.depends)
206class ModuleGraph:
207 """
208 Sorted Odoo modules ordered by (module.phase, module.depth, module.name)
209 """
211 def __init__(self, cr: BaseCursor, mode: Literal['load', 'update'] = 'load') -> None:
212 # mode 'load': for simply loading modules without updating them
213 # mode 'update': for loading and updating modules
214 self.mode: Literal['load', 'update'] = mode
215 self._modules: dict[str, ModuleNode] = {}
216 self._cr: BaseCursor = cr
218 def __contains__(self, name: str) -> bool:
219 return name in self._modules
221 def __getitem__(self, name: str) -> ModuleNode:
222 return self._modules[name]
224 def __iter__(self) -> Iterator[ModuleNode]:
225 return iter(sorted(self._modules.values(), key=lambda p: (p.phase, p.depth, p.order_name)))
227 def __len__(self) -> int:
228 return len(self._modules)
230 def extend(self, names: Collection[str]) -> None:
231 for module in self._modules.values():
232 reset_cached_properties(module)
234 names = [name for name in names if name not in self._modules]
236 for name in names:
237 module = self._modules[name] = ModuleNode(name, self)
238 if not module.manifest.get('installable'): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true
239 if name in self._imported_modules:
240 self._remove(name, log_dependents=False)
241 else:
242 _logger.warning('module %s: not installable, skipped', name)
243 self._remove(name)
245 self._update_depends(names)
246 self._update_depth(names)
247 self._update_from_database(names)
249 @functools.cached_property
250 def _imported_modules(self) -> OrderedSet[str]:
251 result = ['studio_customization']
252 if column_exists(self._cr, 'ir_module_module', 'imported'):
253 self._cr.execute('SELECT name FROM ir_module_module WHERE imported')
254 result += [m[0] for m in self._cr.fetchall()]
255 return OrderedSet(result)
257 def _update_depends(self, names: Iterable[str]) -> None:
258 for name in names:
259 if module := self._modules.get(name): 259 ↛ 258line 259 didn't jump to line 258 because the condition on line 259 was always true
260 depends = module.manifest['depends']
261 try:
262 module.depends = OrderedSet(self._modules[dep] for dep in depends)
263 except KeyError:
264 _logger.info('module %s: some depends are not loaded, skipped', name)
265 self._remove(name)
267 def _update_depth(self, names: Iterable[str]) -> None:
268 for name in names:
269 if module := self._modules.get(name): 269 ↛ 268line 269 didn't jump to line 268 because the condition on line 269 was always true
270 try:
271 module.depth
272 except RecursionError:
273 _logger.warning('module %s: in a dependency loop, skipped', name)
274 self._remove(name)
276 def _update_from_database(self, names: Iterable[str]) -> None:
277 names = tuple(name for name in names if name in self._modules)
278 if not names: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 return
280 # update modules with values from the database (if exist)
281 query = '''
282 SELECT name, id, state, demo, latest_version AS installed_version
283 FROM ir_module_module
284 WHERE name IN %s
285 '''
286 self._cr.execute(query, [names])
287 for name, id_, state, demo, installed_version in self._cr.fetchall():
288 if state == 'uninstallable': 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 _logger.warning('module %s: not installable, skipped', name)
290 self._remove(name)
291 continue
292 if self.mode == 'load' and state in ['to install', 'uninstalled']: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true
293 _logger.info('module %s: not installed, skipped', name)
294 self._remove(name)
295 continue
296 if name not in self._modules: 296 ↛ 298line 296 didn't jump to line 298 because the condition on line 296 was never true
297 # has been recursively removed for sake of not installable or not installed
298 continue
299 module = self._modules[name]
300 module.id = id_
301 module.state = state
302 module.demo = demo
303 module.installed_version = installed_version
304 module.load_version = installed_version
305 module.load_state = state
307 def _remove(self, name: str, log_dependents: bool = True) -> None:
308 module = self._modules.pop(name)
309 for another, another_module in list(self._modules.items()):
310 if module in another_module.depends and another_module.name in self._modules:
311 if log_dependents:
312 _logger.info('module %s: its direct/indirect dependency is skipped, skipped', another)
313 self._remove(another)