Coverage for adhoc-cicd-odoo-odoo / odoo / netsvc.py: 56%

203 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:15 +0000

1# -*- coding: utf-8 -*- 

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

3import contextlib 

4import json 

5import logging 

6import logging.handlers 

7import os 

8import platform 

9import pprint 

10import sys 

11import threading 

12import traceback 

13import warnings 

14 

15import werkzeug.serving 

16 

17from . import release 

18from . import sql_db 

19from . import tools 

20 

21_logger = logging.getLogger(__name__) 

22 

23def log(logger, level, prefix, msg, depth=None): 

24 warnings.warn( 

25 "odoo.netsvc.log is deprecated starting Odoo 18, use normal logging APIs", 

26 category=DeprecationWarning, 

27 stacklevel=2, 

28 ) 

29 indent='' 

30 indent_after=' '*len(prefix) 

31 for line in (prefix + pprint.pformat(msg, depth=depth)).split('\n'): 

32 logger.log(level, indent+line) 

33 indent=indent_after 

34 

35 

36class WatchedFileHandler(logging.handlers.WatchedFileHandler): 

37 def __init__(self, filename): 

38 self.errors = None # py38 

39 super().__init__(filename) 

40 # Unfix bpo-26789, in case the fix is present 

41 self._builtin_open = None 

42 

43 def _open(self): 

44 return open(self.baseFilename, self.mode, encoding=self.encoding, errors=self.errors) 

45 

46class PostgreSQLHandler(logging.Handler): 

47 """ PostgreSQL Logging Handler will store logs in the database, by default 

48 the current database, can be set using --log-db=DBNAME 

49 """ 

50 

51 def __init__(self): 

52 super().__init__() 

53 self._support_metadata = False 

54 if tools.config['log_db'] != '%d': 54 ↛ exitline 54 didn't return from function '__init__' because the condition on line 54 was always true

55 with contextlib.suppress(Exception), tools.mute_logger('odoo.sql_db'), sql_db.db_connect(tools.config['log_db'], allow_uri=True).cursor() as cr: 

56 cr.execute("""SELECT 1 FROM information_schema.columns WHERE table_name='ir_logging' and column_name='metadata' AND table_schema = current_schema""") 

57 self._support_metadata = bool(cr.fetchone()) 

58 

59 def emit(self, record): 

60 ct = threading.current_thread() 

61 ct_db = getattr(ct, 'dbname', None) 

62 dbname = tools.config['log_db'] if tools.config['log_db'] and tools.config['log_db'] != '%d' else ct_db 

63 if not dbname: 63 ↛ 64line 63 didn't jump to line 64 because the condition on line 63 was never true

64 return 

65 with contextlib.suppress(Exception), tools.mute_logger('odoo.sql_db'), sql_db.db_connect(dbname, allow_uri=True).cursor() as cr: 

66 # preclude risks of deadlocks 

67 cr.execute("SET LOCAL statement_timeout = 1000") 

68 msg = str(record.msg) 

69 if record.args: 69 ↛ 71line 69 didn't jump to line 71 because the condition on line 69 was always true

70 msg = msg % record.args 

71 traceback = getattr(record, 'exc_text', '') 

72 if traceback: 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 msg = f"{msg}\n{traceback}" 

74 # we do not use record.levelname because it may have been changed by ColoredFormatter. 

75 levelname = logging.getLevelName(record.levelno) 

76 

77 val = ('server', ct_db, record.name, levelname, msg, record.pathname, record.lineno, record.funcName) 

78 

79 if self._support_metadata: 79 ↛ 93line 79 didn't jump to line 93 because the condition on line 79 was always true

80 from . import modules 

81 metadata = {} 

82 if modules.module.current_test: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 with contextlib.suppress(Exception): 

84 metadata['test'] = modules.module.current_test.get_log_metadata() 

85 

86 if metadata: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 cr.execute(""" 

88 INSERT INTO ir_logging(create_date, type, dbname, name, level, message, path, line, func, metadata) 

89 VALUES (NOW() at time zone 'UTC', %s, %s, %s, %s, %s, %s, %s, %s, %s) 

90 """, (*val, json.dumps(metadata))) 

91 return 

92 

93 cr.execute(""" 

94 INSERT INTO ir_logging(create_date, type, dbname, name, level, message, path, line, func) 

95 VALUES (NOW() at time zone 'UTC', %s, %s, %s, %s, %s, %s, %s, %s) 

96 """, val) 

97 

98BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, _NOTHING, DEFAULT = range(10) 

99#The background is set with 40 plus the number of the color, and the foreground with 30 

100#These are the sequences needed to get colored output 

101RESET_SEQ = "\033[0m" 

102COLOR_SEQ = "\033[1;%dm" 

103BOLD_SEQ = "\033[1m" 

104COLOR_PATTERN = f"{COLOR_SEQ}{COLOR_SEQ}%s{RESET_SEQ}" 

105LEVEL_COLOR_MAPPING = { 

106 logging.DEBUG: (BLUE, DEFAULT), 

107 logging.INFO: (GREEN, DEFAULT), 

108 logging.WARNING: (YELLOW, DEFAULT), 

109 logging.ERROR: (RED, DEFAULT), 

110 logging.CRITICAL: (WHITE, RED), 

111} 

112 

113class PerfFilter(logging.Filter): 

114 

115 def format_perf(self, query_count, query_time, remaining_time): 

116 return (f"{query_count:d}", f"{query_time:.3f}", f"{remaining_time:.3f}") 

117 

118 def format_cursor_mode(self, cursor_mode): 

119 return cursor_mode or '-' 

120 

121 def filter(self, record): 

122 if hasattr(threading.current_thread(), "query_count"): 

123 query_count = threading.current_thread().query_count 

124 query_time = threading.current_thread().query_time 

125 perf_t0 = threading.current_thread().perf_t0 

126 remaining_time = tools.real_time() - perf_t0 - query_time 

127 record.perf_info = '%s %s %s' % self.format_perf(query_count, query_time, remaining_time) 

128 if tools.config['db_replica_host'] or 'replica' in tools.config['dev_mode']: 

129 cursor_mode = threading.current_thread().cursor_mode 

130 record.perf_info = f'{record.perf_info} {self.format_cursor_mode(cursor_mode)}' 

131 delattr(threading.current_thread(), "query_count") 

132 else: 

133 if tools.config['db_replica_host'] or 'replica' in tools.config['dev_mode']: 

134 record.perf_info = "- - - -" 

135 record.perf_info = "- - -" 

136 return True 

137 

138class ColoredPerfFilter(PerfFilter): 

139 def format_perf(self, query_count, query_time, remaining_time): 

140 def colorize_time(time, format, low=1, high=5): 

141 if time > high: 

142 return COLOR_PATTERN % (30 + RED, 40 + DEFAULT, format % time) 

143 if time > low: 

144 return COLOR_PATTERN % (30 + YELLOW, 40 + DEFAULT, format % time) 

145 return format % time 

146 return ( 

147 colorize_time(query_count, "%d", 100, 1000), 

148 colorize_time(query_time, "%.3f", 0.1, 3), 

149 colorize_time(remaining_time, "%.3f", 1, 5), 

150 ) 

151 

152 def format_cursor_mode(self, cursor_mode): 

153 cursor_mode = super().format_cursor_mode(cursor_mode) 

154 cursor_mode_color = ( 

155 RED if cursor_mode == 'ro->rw' 

156 else YELLOW if cursor_mode == 'rw' 

157 else GREEN 

158 ) 

159 return COLOR_PATTERN % (30 + cursor_mode_color, 40 + DEFAULT, cursor_mode) 

160 

161 

162class ColoredFormatter(logging.Formatter): 

163 def format(self, record): 

164 fg_color, bg_color = LEVEL_COLOR_MAPPING.get(record.levelno, (GREEN, DEFAULT)) 

165 record.levelname = COLOR_PATTERN % (30 + fg_color, 40 + bg_color, record.levelname) 

166 return super().format(record) 

167 

168 

169class LogRecord(logging.LogRecord): 

170 def __init__(self, name, level, pathname, lineno, msg, args, exc_info, func=None, sinfo=None, **kwargs): 

171 super().__init__(name, level, pathname, lineno, msg, args, exc_info, func=func, sinfo=sinfo, **kwargs) 

172 self.perf_info = "" 

173 self.pid = os.getpid() 

174 self.dbname = getattr(threading.current_thread(), 'dbname', '?') 

175 

176 

177showwarning = None 

178def init_logger(): 

179 global showwarning # noqa: PLW0603 

180 if logging.getLogRecordFactory() is LogRecord: 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true

181 return 

182 

183 logging.setLogRecordFactory(LogRecord) 

184 

185 logging.captureWarnings(True) 

186 # must be after `loggin.captureWarnings` so we override *that* instead of 

187 # the other way around 

188 showwarning = warnings.showwarning 

189 warnings.showwarning = showwarning_with_traceback 

190 

191 # enable deprecation warnings (disabled by default) 

192 warnings.simplefilter('default', category=DeprecationWarning) 

193 # https://github.com/urllib3/urllib3/issues/2680 

194 warnings.filterwarnings('ignore', r'^\'urllib3.contrib.pyopenssl\' module is deprecated.+', category=DeprecationWarning) 

195 # ignore a bunch of warnings we can't really fix ourselves 

196 for module in [ 

197 'babel.util', # deprecated parser module, no release yet 

198 'zeep.loader',# zeep using defusedxml.lxml 

199 'reportlab.lib.rl_safe_eval',# reportlab importing ABC from collections 

200 'ofxparse',# ofxparse importing ABC from collections 

201 'astroid', # deprecated imp module (fixed in 2.5.1) 

202 'requests_toolbelt', # importing ABC from collections (fixed in 0.9) 

203 ]: 

204 warnings.filterwarnings('ignore', category=DeprecationWarning, module=module) 

205 

206 # rsjmin triggers this with Python 3.10+ (that warning comes from the C code and has no `module`) 

207 warnings.filterwarnings('ignore', r'^PyUnicode_FromUnicode\(NULL, size\) is deprecated', category=DeprecationWarning) 

208 # reportlab<4.0.6 triggers this in Py3.10/3.11 

209 warnings.filterwarnings('ignore', r'the load_module\(\) method is deprecated', category=DeprecationWarning, module='importlib._bootstrap') 

210 # the SVG guesser thing always compares str and bytes, ignore it 

211 warnings.filterwarnings('ignore', category=BytesWarning, module='odoo.tools.image') 

212 # reportlab does a bunch of bytes/str mixing in a hashmap 

213 warnings.filterwarnings('ignore', category=BytesWarning, module='reportlab.platypus.paraparser') 

214 

215 # need to be adapted later but too muchwork for this pr. 

216 warnings.filterwarnings('ignore', r'^datetime.datetime.utcnow\(\) is deprecated and scheduled for removal in a future version.*', category=DeprecationWarning) 

217 

218 # pkg_ressouce is used in google-auth < 1.23.0 (removed in https://github.com/googleapis/google-auth-library-python/pull/596) 

219 # unfortunately, in ubuntu jammy and noble, the google-auth version is 1.5.1 

220 # starting from noble, the default pkg_ressource version emits a warning on import, triggered when importing 

221 # google-auth 

222 warnings.filterwarnings('ignore', r'pkg_resources is deprecated as an API.+', category=DeprecationWarning) 

223 warnings.filterwarnings('ignore', r'Deprecated call to \`pkg_resources.declare_namespace.+', category=DeprecationWarning) 

224 

225 # This warning is triggered library only during the python precompilation which does not occur on readonly filesystem 

226 warnings.filterwarnings("ignore", r'invalid escape sequence', category=DeprecationWarning, module=".*vobject") 

227 warnings.filterwarnings("ignore", r'invalid escape sequence', category=SyntaxWarning, module=".*vobject") 

228 from .tools.translate import resetlocale 

229 resetlocale() 

230 

231 # create a format for log messages and dates 

232 format = '%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s %(perf_info)s' 

233 # Normal Handler on stderr 

234 handler = logging.StreamHandler() 

235 

236 if tools.config['syslog']: 236 ↛ 238line 236 didn't jump to line 238 because the condition on line 236 was never true

237 # SysLog Handler 

238 if os.name == 'nt': 

239 handler = logging.handlers.NTEventLogHandler(f"{release.description} {release.version}") 

240 elif platform.system() == 'Darwin': 

241 handler = logging.handlers.SysLogHandler('/var/run/log') 

242 else: 

243 handler = logging.handlers.SysLogHandler('/dev/log') 

244 format = f'{release.description} {release.version}:%(dbname)s:%(levelname)s:%(name)s:%(message)s' 

245 

246 elif tools.config['logfile']: 246 ↛ 248line 246 didn't jump to line 248 because the condition on line 246 was never true

247 # LogFile Handler 

248 logf = tools.config['logfile'] 

249 try: 

250 # We check we have the right location for the log files 

251 dirname = os.path.dirname(logf) 

252 if dirname and not os.path.isdir(dirname): 

253 os.makedirs(dirname) 

254 if os.name == 'posix': 

255 handler = WatchedFileHandler(logf) 

256 else: 

257 handler = logging.FileHandler(logf) 

258 except Exception: 

259 sys.stderr.write("ERROR: couldn't create the logfile directory. Logging to the standard output.\n") 

260 

261 # Check that handler.stream has a fileno() method: when running OpenERP 

262 # behind Apache with mod_wsgi, handler.stream will have type mod_wsgi.Log, 

263 # which has no fileno() method. (mod_wsgi.Log is what is being bound to 

264 # sys.stderr when the logging.StreamHandler is being constructed above.) 

265 def is_a_tty(stream): 

266 return hasattr(stream, 'fileno') and os.isatty(stream.fileno()) 

267 

268 if os.name == 'posix' and isinstance(handler, logging.StreamHandler) and (is_a_tty(handler.stream) or os.environ.get("ODOO_PY_COLORS")): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 formatter = ColoredFormatter(format) 

270 perf_filter = ColoredPerfFilter() 

271 else: 

272 formatter = logging.Formatter(format) 

273 perf_filter = PerfFilter() 

274 werkzeug.serving._log_add_style = False 

275 handler.setFormatter(formatter) 

276 logging.getLogger().addHandler(handler) 

277 logging.getLogger('werkzeug').addFilter(perf_filter) 

278 

279 if tools.config['log_db']: 279 ↛ 292line 279 didn't jump to line 292 because the condition on line 279 was always true

280 db_levels = { 

281 'debug': logging.DEBUG, 

282 'info': logging.INFO, 

283 'warning': logging.WARNING, 

284 'error': logging.ERROR, 

285 'critical': logging.CRITICAL, 

286 } 

287 postgresqlHandler = PostgreSQLHandler() 

288 postgresqlHandler.setLevel(int(db_levels.get(tools.config['log_db_level'], tools.config['log_db_level']))) 

289 logging.getLogger().addHandler(postgresqlHandler) 

290 

291 # Configure loggers levels 

292 pseudo_config = PSEUDOCONFIG_MAPPER.get(tools.config['log_level'], []) 

293 

294 logconfig = tools.config['log_handler'] 

295 

296 logging_configurations = DEFAULT_LOG_CONFIGURATION + pseudo_config + logconfig 

297 for logconfig_item in logging_configurations: 

298 loggername, level = logconfig_item.strip().split(':') 

299 level = getattr(logging, level, logging.INFO) 

300 logger = logging.getLogger(loggername) 

301 logger.setLevel(level) 

302 

303 for logconfig_item in logging_configurations: 

304 _logger.debug('logger level set: "%s"', logconfig_item) 

305 

306 

307DEFAULT_LOG_CONFIGURATION = [ 

308 'odoo.http.rpc.request:INFO', 

309 'odoo.http.rpc.response:INFO', 

310 ':INFO', 

311] 

312PSEUDOCONFIG_MAPPER = { 

313 'debug_rpc_answer': ['odoo:DEBUG', 'odoo.sql_db:INFO', 'odoo.http.rpc:DEBUG'], 

314 'debug_rpc': ['odoo:DEBUG', 'odoo.sql_db:INFO', 'odoo.http.rpc.request:DEBUG'], 

315 'debug': ['odoo:DEBUG', 'odoo.sql_db:INFO'], 

316 'debug_sql': ['odoo.sql_db:DEBUG'], 

317 'info': [], 

318 'runbot': ['odoo:RUNBOT', 'werkzeug:WARNING'], 

319 'warn': ['odoo:WARNING', 'werkzeug:WARNING'], 

320 'error': ['odoo:ERROR', 'werkzeug:ERROR'], 

321 'critical': ['odoo:CRITICAL', 'werkzeug:CRITICAL'], 

322} 

323 

324logging.RUNBOT = 25 

325logging.addLevelName(logging.RUNBOT, "INFO") # displayed as info in log 

326IGNORE = { 

327 'Comparison between bytes and int', # a.foo != False or some shit, we don't care 

328} 

329def showwarning_with_traceback(message, category, filename, lineno, file=None, line=None): 

330 if category is BytesWarning and message.args[0] in IGNORE: 

331 return 

332 

333 # find the stack frame matching (filename, lineno) 

334 filtered = [] 

335 for frame in traceback.extract_stack(): 

336 if frame.name == '__call__' and frame.filename.endswith('/odoo/http.py'): 

337 # we don't care about the frames above our wsgi entrypoint 

338 filtered.clear() 

339 if 'importlib' not in frame.filename: 

340 filtered.append(frame) 

341 if frame.filename == filename and frame.lineno == lineno: 

342 break 

343 return showwarning( 

344 message, category, filename, lineno, 

345 file=file, 

346 line=''.join(traceback.format_list(filtered)) 

347 ) 

348 

349def runbot(self, message, *args, **kws): 

350 self.log(logging.RUNBOT, message, *args, **kws) 

351logging.Logger.runbot = runbot