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

1# Part of Odoo. See LICENSE file for full copyright and licensing details. 

2 

3""" Modules dependency graph. """ 

4 

5from __future__ import annotations 

6 

7import functools 

8import logging 

9import typing 

10 

11from odoo.tools import reset_cached_properties, OrderedSet 

12from odoo.tools.sql import column_exists 

13 

14from .module import Manifest 

15 

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 

20 

21 STATES = Literal[ 

22 'uninstallable', 

23 'uninstalled', 

24 'installed', 

25 'to upgrade', 

26 'to remove', 

27 'to install', 

28 ] 

29 

30_logger = logging.getLogger(__name__) 

31 

32 

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 

135 

136 

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 {} 

150 

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) 

156 

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 

160 

161 # dependency 

162 self.depends: OrderedSet[ModuleNode] = OrderedSet() 

163 self.module_graph: ModuleGraph = module_graph 

164 

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 

171 

172 return self.name 

173 

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 

180 

181 return max(module.depth for module in self.depends) + 1 if self.depends else 0 

182 

183 @functools.cached_property 

184 def phase(self) -> int: 

185 if self.name == 'base': 

186 return 0 

187 

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 

190 

191 def not_in_the_same_phase(module: ModuleNode, dependency: ModuleNode) -> bool: 

192 return (module.state == 'to install') ^ (dependency.state == 'to install') 

193 

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 ) 

200 

201 @property 

202 def demo_installable(self) -> bool: 

203 return all(p.demo for p in self.depends) 

204 

205 

206class ModuleGraph: 

207 """ 

208 Sorted Odoo modules ordered by (module.phase, module.depth, module.name) 

209 """ 

210 

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 

217 

218 def __contains__(self, name: str) -> bool: 

219 return name in self._modules 

220 

221 def __getitem__(self, name: str) -> ModuleNode: 

222 return self._modules[name] 

223 

224 def __iter__(self) -> Iterator[ModuleNode]: 

225 return iter(sorted(self._modules.values(), key=lambda p: (p.phase, p.depth, p.order_name))) 

226 

227 def __len__(self) -> int: 

228 return len(self._modules) 

229 

230 def extend(self, names: Collection[str]) -> None: 

231 for module in self._modules.values(): 

232 reset_cached_properties(module) 

233 

234 names = [name for name in names if name not in self._modules] 

235 

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) 

244 

245 self._update_depends(names) 

246 self._update_depth(names) 

247 self._update_from_database(names) 

248 

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) 

256 

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) 

266 

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) 

275 

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 

306 

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)