Coverage for python/lsst/daf/butler/tests/cliLogTestBase.py: 26%

136 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-24 23:50 -0700

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21 

22"""Unit tests for the daf_butler CliLog utility. Code is implemented in 

23daf_butler but some only runs if lsst.log.Log can be imported so these parts of 

24it can't be tested there because daf_butler does not directly depend on 

25lsst.log, and only uses it if it has been setup by another package.""" 

26 

27import logging 

28import os 

29import re 

30import subprocess 

31import tempfile 

32import unittest 

33from collections import namedtuple 

34from functools import partial 

35from io import StringIO 

36from logging import DEBUG, INFO, WARNING 

37 

38import click 

39from lsst.daf.butler.cli.butler import cli as butlerCli 

40from lsst.daf.butler.cli.cliLog import CliLog 

41from lsst.daf.butler.cli.utils import LogCliRunner, clickResultMsg, command_test_env 

42from lsst.daf.butler.core.logging import ButlerLogRecords 

43from lsst.utils.logging import TRACE 

44 

45try: 

46 import lsst.log as lsstLog 

47 

48 lsstLog_INFO = lsstLog.INFO 

49 lsstLog_DEBUG = lsstLog.DEBUG 

50 lsstLog_WARN = lsstLog.WARN 

51except ModuleNotFoundError: 

52 lsstLog = None 

53 lsstLog_INFO = 0 

54 lsstLog_DEBUG = 0 

55 lsstLog_WARN = 0 

56 

57 

58@click.command() 

59@click.option("--expected-pyroot-level", type=int) 

60@click.option("--expected-pylsst-level", type=int) 

61@click.option("--expected-pybutler-level", type=int) 

62@click.option("--expected-lsstroot-level", type=int) 

63@click.option("--expected-lsstbutler-level", type=int) 

64@click.option("--expected-lsstx-level", type=int) 

65def command_log_settings_test( 

66 expected_pyroot_level, 

67 expected_pylsst_level, 

68 expected_pybutler_level, 

69 expected_lsstroot_level, 

70 expected_lsstbutler_level, 

71 expected_lsstx_level, 

72): 

73 LogLevel = namedtuple("LogLevel", ("expected", "actual", "name")) 

74 

75 logLevels = [ 

76 LogLevel(expected_pyroot_level, logging.getLogger().level, "pyRoot"), 

77 LogLevel(expected_pylsst_level, logging.getLogger("lsst").getEffectiveLevel(), "pyLsst"), 

78 LogLevel( 

79 expected_pybutler_level, logging.getLogger("lsst.daf.butler").getEffectiveLevel(), "pyButler" 

80 ), 

81 LogLevel(expected_lsstx_level, logging.getLogger("lsstx").getEffectiveLevel(), "pyLsstx"), 

82 ] 

83 if lsstLog is not None: 

84 logLevels.extend( 

85 [ 

86 LogLevel(expected_lsstroot_level, lsstLog.getLogger("lsst").getEffectiveLevel(), "lsstRoot"), 

87 LogLevel( 

88 expected_lsstbutler_level, 

89 lsstLog.getLogger("lsst.daf.butler").getEffectiveLevel(), 

90 "lsstButler", 

91 ), 

92 ] 

93 ) 

94 for expected, actual, name in logLevels: 

95 if expected != actual: 

96 raise ( 

97 click.ClickException(message=f"expected {name} level to be {expected!r}, actual:{actual!r}") 

98 ) 

99 

100 

101class CliLogTestBase: 

102 """Tests log initialization, reset, and setting log levels.""" 

103 

104 lsstLogHandlerId = None 

105 

106 def setUp(self): 

107 self.runner = LogCliRunner() 

108 

109 def tearDown(self): 

110 self.lsstLogHandlerId = None 

111 

112 class PythonLogger: 

113 """Keeps track of log level of a component and number of handlers 

114 attached to it at the time this object was initialized.""" 

115 

116 def __init__(self, component): 

117 self.logger = logging.getLogger(component) 

118 self.initialLevel = self.logger.level 

119 

120 class LsstLogger: 

121 """Keeps track of log level for a component at the time this object was 

122 initialized.""" 

123 

124 def __init__(self, component): 

125 self.logger = lsstLog.getLogger(component) if lsstLog else None 

126 self.initialLevel = self.logger.getLevel() if lsstLog else None 

127 

128 def runTest(self, cmd): 

129 """Test that the log context manager works with the butler cli to 

130 initialize the logging system according to cli inputs for the duration 

131 of the command execution and resets the logging system to its previous 

132 state or expected state when command execution finishes.""" 

133 pyRoot = self.PythonLogger(None) 

134 pyButler = self.PythonLogger("lsst.daf.butler") 

135 pyLsstRoot = self.PythonLogger("lsst") 

136 lsstRoot = self.LsstLogger("") 

137 lsstButler = self.LsstLogger("lsst.daf.butler") 

138 

139 with command_test_env( 

140 self.runner, "lsst.daf.butler.tests.cliLogTestBase", "command-log-settings-test" 

141 ): 

142 result = cmd() 

143 self.assertEqual(result.exit_code, 0, clickResultMsg(result)) 

144 

145 self.assertEqual(pyRoot.logger.level, logging.WARNING) 

146 self.assertEqual(pyLsstRoot.logger.level, logging.INFO) 

147 self.assertEqual(pyButler.logger.level, pyButler.initialLevel) 

148 if lsstLog is not None: 

149 self.assertEqual(lsstRoot.logger.getLevel(), lsstLog.INFO) 

150 # lsstLogLevel can either be the initial level, or uninitialized or 

151 # the defined default value. 

152 expectedLsstLogLevel = ( 

153 (lsstButler.initialLevel,) 

154 if lsstButler.initialLevel != -1 

155 else (-1, CliLog.defaultLsstLogLevel) 

156 ) 

157 self.assertIn(lsstButler.logger.getLevel(), expectedLsstLogLevel) 

158 

159 def test_butlerCliLog(self): 

160 """Test that the log context manager works with the butler cli to 

161 initialize the logging system according to cli inputs for the duration 

162 of the command execution and resets the logging system to its previous 

163 state or expected state when command execution finishes.""" 

164 

165 # Run with two different log level settings. 

166 log_levels = ( 

167 # --log-level / --log-level / expected pyroot, pylsst, pybutler, 

168 # lsstroot, lsstbutler, lsstx 

169 ( 

170 "WARNING", 

171 "lsst.daf.butler=DEBUG", 

172 WARNING, 

173 WARNING, 

174 DEBUG, 

175 lsstLog_WARN, 

176 lsstLog_DEBUG, 

177 WARNING, 

178 ), 

179 ("DEBUG", "lsst.daf.butler=TRACE", WARNING, DEBUG, TRACE, lsstLog_DEBUG, lsstLog_DEBUG, WARNING), 

180 (".=DEBUG", "lsst.daf.butler=WARNING", DEBUG, INFO, WARNING, lsstLog_INFO, lsstLog_WARN, DEBUG), 

181 (".=DEBUG", "DEBUG", DEBUG, DEBUG, DEBUG, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG), 

182 (".=DEBUG", "conda=DEBUG", DEBUG, INFO, INFO, lsstLog_INFO, lsstLog_INFO, DEBUG), 

183 ) 

184 

185 self._test_levels(log_levels) 

186 

187 # Check that the environment variable can set additional roots. 

188 log_levels = ( 

189 # --log-level / --log-level / expected pyroot, pylsst, pybutler, 

190 # lsstroot, lsstbutler, lsstx 

191 ( 

192 "WARNING", 

193 "lsst.daf.butler=DEBUG", 

194 WARNING, 

195 WARNING, 

196 DEBUG, 

197 lsstLog_WARN, 

198 lsstLog_DEBUG, 

199 WARNING, 

200 ), 

201 ("DEBUG", "lsst.daf.butler=TRACE", WARNING, DEBUG, TRACE, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG), 

202 (".=DEBUG", "lsst.daf.butler=WARNING", DEBUG, INFO, WARNING, lsstLog_INFO, lsstLog_WARN, INFO), 

203 (".=DEBUG", "DEBUG", DEBUG, DEBUG, DEBUG, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG), 

204 (".=DEBUG", "conda=DEBUG", DEBUG, INFO, INFO, lsstLog_INFO, lsstLog_INFO, INFO), 

205 ) 

206 

207 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_ROOT_LOGGER": "lsstx"}): 

208 self._test_levels(log_levels) 

209 

210 def _test_levels(self, log_levels): 

211 for level1, level2, x_pyroot, x_pylsst, x_pybutler, x_lsstroot, x_lsstbutler, x_lsstx in log_levels: 

212 with self.subTest("Test different log levels", level1=level1, level2=level2): 

213 self.runTest( 

214 partial( 

215 self.runner.invoke, 

216 butlerCli, 

217 [ 

218 "--log-level", 

219 level1, 

220 "--log-level", 

221 level2, 

222 "command-log-settings-test", 

223 "--expected-pyroot-level", 

224 x_pyroot, 

225 "--expected-pylsst-level", 

226 x_pylsst, 

227 "--expected-pybutler-level", 

228 x_pybutler, 

229 "--expected-lsstroot-level", 

230 x_lsstroot, 

231 "--expected-lsstbutler-level", 

232 x_lsstbutler, 

233 "--expected-lsstx-level", 

234 x_lsstx, 

235 ], 

236 ) 

237 ) 

238 

239 def test_helpLogReset(self): 

240 """Verify that when a command does not execute, like when the help menu 

241 is printed instead, that CliLog is still reset.""" 

242 

243 self.runTest(partial(self.runner.invoke, butlerCli, ["command-log-settings-test", "--help"])) 

244 

245 def testLongLog(self): 

246 """Verify the timestamp is in the log messages when the --long-log 

247 flag is set.""" 

248 

249 # When longlog=True, loglines start with the log level and a 

250 # timestamp with the following format: 

251 # "year-month-day T hour-minute-second.millisecond-zoneoffset" 

252 # For example: "DEBUG 2020-10-28T10:20:31-07:00 ..."" 

253 # The log level name can change, we verify there is an all 

254 # caps word there but do not verify the word. We do not verify 

255 # the rest of the log string, assume that if the timestamp is 

256 # in the string that the rest of the string will appear as 

257 # expected. 

258 timestampRegex = re.compile( 

259 r".*[A-Z]+ [0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(.[0-9]{3})" 

260 "?([-,+][01][0-9]:[034][05]|Z) .*" 

261 ) 

262 

263 # When longlog=False, log lines start with the module name and 

264 # log level, for example: 

265 # lsst.daf.butler.core.config DEBUG: ... 

266 modulesRegex = re.compile(r".* ([a-z]+\.)+[a-z]+ [A-Z]+: .*") 

267 

268 with self.runner.isolated_filesystem(): 

269 for longlog in (True, False): 

270 # The pytest log handler interferes with the log configuration 

271 # settings set up by initLog -- therefore test by using 

272 # a subprocess. 

273 if longlog: 

274 args = ("butler", "--log-level", "DEBUG", "--long-log", "create", "here") 

275 else: 

276 args = ("butler", "--log-level", "DEBUG", "create", "here") 

277 result = subprocess.run(args, capture_output=True) 

278 # There are cases where the newlines are stripped from the log 

279 # output (like in Jenkins), since we can't depend on newlines 

280 # in log output they are removed here from test output. 

281 output = StringIO((result.stderr.decode().replace("\n", " "))) 

282 startedWithTimestamp = any([timestampRegex.match(line) for line in output.readlines()]) 

283 output.seek(0) 

284 startedWithModule = any(modulesRegex.match(line) for line in output.readlines()) 

285 if longlog: 

286 self.assertTrue( 

287 startedWithTimestamp, msg=f"did not find timestamp in: \n{output.getvalue()}" 

288 ) 

289 self.assertFalse( 

290 startedWithModule, msg=f"found lines starting with module in: \n{output.getvalue()}" 

291 ) 

292 else: 

293 self.assertFalse(startedWithTimestamp, msg=f"found timestamp in: \n{output.getvalue()}") 

294 self.assertTrue( 

295 startedWithModule, 

296 msg=f"did not find lines starting with module in: \n{output.getvalue()}", 

297 ) 

298 

299 def testFileLogging(self): 

300 """Test --log-file option.""" 

301 with self.runner.isolated_filesystem(): 

302 for i, suffix in enumerate([".json", ".log"]): 

303 # Get a temporary file name and immediately close it 

304 fd = tempfile.NamedTemporaryFile(suffix=suffix) 

305 filename = fd.name 

306 fd.close() 

307 

308 args = ( 

309 "--log-level", 

310 "DEBUG", 

311 "--log-file", 

312 filename, 

313 "--log-label", 

314 "k1=v1,k2=v2", 

315 "--log-label", 

316 "k3=v3", 

317 "create", 

318 f"here{i}", 

319 ) 

320 

321 result = self.runner.invoke(butlerCli, args) 

322 self.assertEqual(result.exit_code, 0, clickResultMsg(result)) 

323 

324 # Record to test. Test one in the middle that we know is 

325 # a DEBUG message. The first message might come from 

326 # python itself since warnings are redirected to log 

327 # messages. 

328 num = 4 

329 

330 if suffix == ".json": 

331 records = ButlerLogRecords.from_file(filename) 

332 self.assertGreater(len(records), num) 

333 self.assertEqual(records[num].levelname, "DEBUG", str(records[num])) 

334 self.assertEqual(records[0].MDC, dict(K1="v1", K2="v2", K3="v3")) 

335 else: 

336 with open(filename) as fd: 

337 records = fd.readlines() 

338 self.assertGreater(len(records), num) 

339 self.assertIn("DEBUG", records[num], str(records[num])) 

340 self.assertNotIn("{", records[num], str(records[num])) 

341 

342 self.assertGreater(len(records), 5) 

343 

344 def testLogTty(self): 

345 """Verify that log output to terminal can be suppressed.""" 

346 

347 with self.runner.isolated_filesystem(): 

348 for log_tty in (True, False): 

349 # The pytest log handler interferes with the log configuration 

350 # settings set up by initLog -- therefore test by using 

351 # a subprocess. 

352 if log_tty: 

353 args = ("butler", "--log-level", "DEBUG", "--log-tty", "create", "here") 

354 else: 

355 args = ("butler", "--log-level", "DEBUG", "--no-log-tty", "create", "here2") 

356 result = subprocess.run(args, capture_output=True) 

357 

358 output = result.stderr.decode() 

359 if log_tty: 

360 self.assertIn("DEBUG", output) 

361 else: 

362 self.assertNotIn("DEBUG", output) 

363 

364 

365if __name__ == "__main__": 365 ↛ 366line 365 didn't jump to line 366, because the condition on line 365 was never true

366 unittest.main()