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

136 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-07 02:47 -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 

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

75 

76 logLevels = [ 

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

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

79 LogLevel( 

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

81 ), 

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

83 ] 

84 if lsstLog is not None: 

85 logLevels.extend( 

86 [ 

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

88 LogLevel( 

89 expected_lsstbutler_level, 

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

91 "lsstButler", 

92 ), 

93 ] 

94 ) 

95 for expected, actual, name in logLevels: 

96 if expected != actual: 

97 raise ( 

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

99 ) 

100 

101 

102class CliLogTestBase: 

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

104 

105 lsstLogHandlerId = None 

106 

107 def setUp(self): 

108 self.runner = LogCliRunner() 

109 

110 def tearDown(self): 

111 self.lsstLogHandlerId = None 

112 

113 class PythonLogger: 

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

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

116 

117 def __init__(self, component): 

118 self.logger = logging.getLogger(component) 

119 self.initialLevel = self.logger.level 

120 

121 class LsstLogger: 

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

123 initialized.""" 

124 

125 def __init__(self, component): 

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

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

128 

129 def runTest(self, cmd): 

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

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

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

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

134 pyRoot = self.PythonLogger(None) 

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

136 pyLsstRoot = self.PythonLogger("lsst") 

137 lsstRoot = self.LsstLogger("") 

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

139 

140 with command_test_env( 

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

142 ): 

143 result = cmd() 

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

145 

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

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

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

149 if lsstLog is not None: 

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

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

152 # the defined default value. 

153 expectedLsstLogLevel = ( 

154 (lsstButler.initialLevel,) 

155 if lsstButler.initialLevel != -1 

156 else (-1, CliLog.defaultLsstLogLevel) 

157 ) 

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

159 

160 def test_butlerCliLog(self): 

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

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

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

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

165 

166 # Run with two different log level settings. 

167 log_levels = ( 

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

169 # lsstroot, lsstbutler, lsstx 

170 ( 

171 "WARNING", 

172 "lsst.daf.butler=DEBUG", 

173 WARNING, 

174 WARNING, 

175 DEBUG, 

176 lsstLog_WARN, 

177 lsstLog_DEBUG, 

178 WARNING, 

179 ), 

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

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

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

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

184 ) 

185 

186 self._test_levels(log_levels) 

187 

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

189 log_levels = ( 

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

191 # lsstroot, lsstbutler, lsstx 

192 ( 

193 "WARNING", 

194 "lsst.daf.butler=DEBUG", 

195 WARNING, 

196 WARNING, 

197 DEBUG, 

198 lsstLog_WARN, 

199 lsstLog_DEBUG, 

200 WARNING, 

201 ), 

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

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

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

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

206 ) 

207 

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

209 self._test_levels(log_levels) 

210 

211 def _test_levels(self, log_levels): 

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

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

214 self.runTest( 

215 partial( 

216 self.runner.invoke, 

217 butlerCli, 

218 [ 

219 "--log-level", 

220 level1, 

221 "--log-level", 

222 level2, 

223 "command-log-settings-test", 

224 "--expected-pyroot-level", 

225 x_pyroot, 

226 "--expected-pylsst-level", 

227 x_pylsst, 

228 "--expected-pybutler-level", 

229 x_pybutler, 

230 "--expected-lsstroot-level", 

231 x_lsstroot, 

232 "--expected-lsstbutler-level", 

233 x_lsstbutler, 

234 "--expected-lsstx-level", 

235 x_lsstx, 

236 ], 

237 ) 

238 ) 

239 

240 def test_helpLogReset(self): 

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

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

243 

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

245 

246 def testLongLog(self): 

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

248 flag is set.""" 

249 

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

251 # timestamp with the following format: 

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

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

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

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

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

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

258 # expected. 

259 timestampRegex = re.compile( 

260 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})" 

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

262 ) 

263 

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

265 # log level, for example: 

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

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

268 

269 with self.runner.isolated_filesystem(): 

270 for longlog in (True, False): 

271 # The pytest log handler interferes with the log configuration 

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

273 # a subprocess. 

274 if longlog: 

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

276 else: 

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

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

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

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

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

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

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

284 output.seek(0) 

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

286 if longlog: 

287 self.assertTrue( 

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

289 ) 

290 self.assertFalse( 

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

292 ) 

293 else: 

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

295 self.assertTrue( 

296 startedWithModule, 

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

298 ) 

299 

300 def testFileLogging(self): 

301 """Test --log-file option.""" 

302 with self.runner.isolated_filesystem(): 

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

304 # Get a temporary file name and immediately close it 

305 fd = tempfile.NamedTemporaryFile(suffix=suffix) 

306 filename = fd.name 

307 fd.close() 

308 

309 args = ( 

310 "--log-level", 

311 "DEBUG", 

312 "--log-file", 

313 filename, 

314 "--log-label", 

315 "k1=v1,k2=v2", 

316 "--log-label", 

317 "k3=v3", 

318 "create", 

319 f"here{i}", 

320 ) 

321 

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

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

324 

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

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

327 # python itself since warnings are redirected to log 

328 # messages. 

329 num = 4 

330 

331 if suffix == ".json": 

332 records = ButlerLogRecords.from_file(filename) 

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

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

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

336 else: 

337 with open(filename) as fd: 

338 records = fd.readlines() 

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

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

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

342 

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

344 

345 def testLogTty(self): 

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

347 

348 with self.runner.isolated_filesystem(): 

349 for log_tty in (True, False): 

350 # The pytest log handler interferes with the log configuration 

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

352 # a subprocess. 

353 if log_tty: 

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

355 else: 

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

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

358 

359 output = result.stderr.decode() 

360 if log_tty: 

361 self.assertIn("DEBUG", output) 

362 else: 

363 self.assertNotIn("DEBUG", output) 

364 

365 

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

367 unittest.main()