Coverage for python/lsst/daf/butler/tests/cliLogTestBase.py: 30%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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/>.
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."""
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
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
45try:
46 import lsst.log as lsstLog
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
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):
74 LogLevel = namedtuple("LogLevel", ("expected", "actual", "name"))
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 (click.ClickException(f"expected {name} level to be {expected!r}, actual:{actual!r}"))
100class CliLogTestBase:
101 """Tests log initialization, reset, and setting log levels."""
103 lsstLogHandlerId = None
105 def setUp(self):
106 self.runner = LogCliRunner()
108 def tearDown(self):
109 self.lsstLogHandlerId = None
111 class PythonLogger:
112 """Keeps track of log level of a component and number of handlers
113 attached to it at the time this object was initialized."""
115 def __init__(self, component):
116 self.logger = logging.getLogger(component)
117 self.initialLevel = self.logger.level
119 class LsstLogger:
120 """Keeps track of log level for a component at the time this object was
121 initialized."""
123 def __init__(self, component):
124 self.logger = lsstLog.getLogger(component) if lsstLog else None
125 self.initialLevel = self.logger.getLevel() if lsstLog else None
127 def runTest(self, cmd):
128 """Test that the log context manager works with the butler cli to
129 initialize the logging system according to cli inputs for the duration
130 of the command execution and resets the logging system to its previous
131 state or expected state when command execution finishes."""
132 pyRoot = self.PythonLogger(None)
133 pyButler = self.PythonLogger("lsst.daf.butler")
134 pyLsstRoot = self.PythonLogger("lsst")
135 lsstRoot = self.LsstLogger("")
136 lsstButler = self.LsstLogger("lsst.daf.butler")
138 with command_test_env(
139 self.runner, "lsst.daf.butler.tests.cliLogTestBase", "command-log-settings-test"
140 ):
141 result = cmd()
142 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
144 self.assertEqual(pyRoot.logger.level, logging.WARNING)
145 self.assertEqual(pyLsstRoot.logger.level, logging.INFO)
146 self.assertEqual(pyButler.logger.level, pyButler.initialLevel)
147 if lsstLog is not None:
148 self.assertEqual(lsstRoot.logger.getLevel(), lsstLog.INFO)
149 # lsstLogLevel can either be the initial level, or uninitialized or
150 # the defined default value.
151 expectedLsstLogLevel = (
152 (lsstButler.initialLevel,)
153 if lsstButler.initialLevel != -1
154 else (-1, CliLog.defaultLsstLogLevel)
155 )
156 self.assertIn(lsstButler.logger.getLevel(), expectedLsstLogLevel)
158 def test_butlerCliLog(self):
159 """Test that the log context manager works with the butler cli to
160 initialize the logging system according to cli inputs for the duration
161 of the command execution and resets the logging system to its previous
162 state or expected state when command execution finishes."""
164 # Run with two different log level settings.
165 log_levels = (
166 # --log-level / --log-level / expected pyroot, pylsst, pybutler,
167 # lsstroot, lsstbutler, lsstx
168 (
169 "WARNING",
170 "lsst.daf.butler=DEBUG",
171 WARNING,
172 WARNING,
173 DEBUG,
174 lsstLog_WARN,
175 lsstLog_DEBUG,
176 WARNING,
177 ),
178 ("DEBUG", "lsst.daf.butler=TRACE", WARNING, DEBUG, TRACE, lsstLog_DEBUG, lsstLog_DEBUG, WARNING),
179 (".=DEBUG", "lsst.daf.butler=WARNING", DEBUG, INFO, WARNING, lsstLog_INFO, lsstLog_WARN, DEBUG),
180 (".=DEBUG", "DEBUG", DEBUG, DEBUG, DEBUG, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG),
181 (".=DEBUG", "conda=DEBUG", DEBUG, INFO, INFO, lsstLog_INFO, lsstLog_INFO, DEBUG),
182 )
184 self._test_levels(log_levels)
186 # Check that the environment variable can set additional roots.
187 log_levels = (
188 # --log-level / --log-level / expected pyroot, pylsst, pybutler,
189 # lsstroot, lsstbutler, lsstx
190 (
191 "WARNING",
192 "lsst.daf.butler=DEBUG",
193 WARNING,
194 WARNING,
195 DEBUG,
196 lsstLog_WARN,
197 lsstLog_DEBUG,
198 WARNING,
199 ),
200 ("DEBUG", "lsst.daf.butler=TRACE", WARNING, DEBUG, TRACE, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG),
201 (".=DEBUG", "lsst.daf.butler=WARNING", DEBUG, INFO, WARNING, lsstLog_INFO, lsstLog_WARN, INFO),
202 (".=DEBUG", "DEBUG", DEBUG, DEBUG, DEBUG, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG),
203 (".=DEBUG", "conda=DEBUG", DEBUG, INFO, INFO, lsstLog_INFO, lsstLog_INFO, INFO),
204 )
206 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_ROOT_LOGGER": "lsstx"}):
207 self._test_levels(log_levels)
209 def _test_levels(self, log_levels):
210 for level1, level2, x_pyroot, x_pylsst, x_pybutler, x_lsstroot, x_lsstbutler, x_lsstx in log_levels:
211 with self.subTest("Test different log levels", level1=level1, level2=level2):
212 self.runTest(
213 partial(
214 self.runner.invoke,
215 butlerCli,
216 [
217 "--log-level",
218 level1,
219 "--log-level",
220 level2,
221 "command-log-settings-test",
222 "--expected-pyroot-level",
223 x_pyroot,
224 "--expected-pylsst-level",
225 x_pylsst,
226 "--expected-pybutler-level",
227 x_pybutler,
228 "--expected-lsstroot-level",
229 x_lsstroot,
230 "--expected-lsstbutler-level",
231 x_lsstbutler,
232 "--expected-lsstx-level",
233 x_lsstx,
234 ],
235 )
236 )
238 def test_helpLogReset(self):
239 """Verify that when a command does not execute, like when the help menu
240 is printed instead, that CliLog is still reset."""
242 self.runTest(partial(self.runner.invoke, butlerCli, ["command-log-settings-test", "--help"]))
244 def testLongLog(self):
245 """Verify the timestamp is in the log messages when the --long-log
246 flag is set."""
248 # When longlog=True, loglines start with the log level and a
249 # timestamp with the following format:
250 # "year-month-day T hour-minute-second.millisecond-zoneoffset"
251 # For example: "DEBUG 2020-10-28T10:20:31-07:00 ...""
252 # The log level name can change, we verify there is an all
253 # caps word there but do not verify the word. We do not verify
254 # the rest of the log string, assume that if the timestamp is
255 # in the string that the rest of the string will appear as
256 # expected.
257 timestampRegex = re.compile(
258 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})"
259 "?([-,+][01][0-9]:[034][05]|Z) .*"
260 )
262 # When longlog=False, log lines start with the module name and
263 # log level, for example:
264 # lsst.daf.butler.core.config DEBUG: ...
265 modulesRegex = re.compile(r".* ([a-z]+\.)+[a-z]+ [A-Z]+: .*")
267 with self.runner.isolated_filesystem():
268 for longlog in (True, False):
269 # The pytest log handler interferes with the log configuration
270 # settings set up by initLog -- therefore test by using
271 # a subprocess.
272 if longlog:
273 args = ("butler", "--log-level", "DEBUG", "--long-log", "create", "here")
274 else:
275 args = ("butler", "--log-level", "DEBUG", "create", "here")
276 result = subprocess.run(args, capture_output=True)
277 # There are cases where the newlines are stripped from the log
278 # output (like in Jenkins), since we can't depend on newlines
279 # in log output they are removed here from test output.
280 output = StringIO((result.stderr.decode().replace("\n", " ")))
281 startedWithTimestamp = any([timestampRegex.match(line) for line in output.readlines()])
282 output.seek(0)
283 startedWithModule = any(modulesRegex.match(line) for line in output.readlines())
284 if longlog:
285 self.assertTrue(
286 startedWithTimestamp, msg=f"did not find timestamp in: \n{output.getvalue()}"
287 )
288 self.assertFalse(
289 startedWithModule, msg=f"found lines starting with module in: \n{output.getvalue()}"
290 )
291 else:
292 self.assertFalse(startedWithTimestamp, msg=f"found timestamp in: \n{output.getvalue()}")
293 self.assertTrue(
294 startedWithModule,
295 msg=f"did not find lines starting with module in: \n{output.getvalue()}",
296 )
298 def testFileLogging(self):
299 """Test --log-file option."""
300 with self.runner.isolated_filesystem():
301 for i, suffix in enumerate([".json", ".log"]):
302 # Get a temporary file name and immediately close it
303 fd = tempfile.NamedTemporaryFile(suffix=suffix)
304 filename = fd.name
305 fd.close()
307 args = (
308 "--log-level",
309 "DEBUG",
310 "--log-file",
311 filename,
312 "--log-label",
313 "k1=v1,k2=v2",
314 "--log-label",
315 "k3=v3",
316 "create",
317 f"here{i}",
318 )
320 result = self.runner.invoke(butlerCli, args)
321 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
323 # Record to test. Test one in the middle that we know is
324 # a DEBUG message. The first message might come from
325 # python itself since warnings are redirected to log
326 # messages.
327 num = 4
329 if suffix == ".json":
330 records = ButlerLogRecords.from_file(filename)
331 self.assertGreater(len(records), num)
332 self.assertEqual(records[num].levelname, "DEBUG", str(records[num]))
333 self.assertEqual(records[0].MDC, dict(K1="v1", K2="v2", K3="v3"))
334 else:
335 with open(filename) as fd:
336 records = fd.readlines()
337 self.assertGreater(len(records), num)
338 self.assertIn("DEBUG", records[num], str(records[num]))
339 self.assertNotIn("{", records[num], str(records[num]))
341 self.assertGreater(len(records), 5)
343 def testLogTty(self):
344 """Verify that log output to terminal can be suppressed."""
346 with self.runner.isolated_filesystem():
347 for log_tty in (True, False):
348 # The pytest log handler interferes with the log configuration
349 # settings set up by initLog -- therefore test by using
350 # a subprocess.
351 if log_tty:
352 args = ("butler", "--log-level", "DEBUG", "--log-tty", "create", "here")
353 else:
354 args = ("butler", "--log-level", "DEBUG", "--no-log-tty", "create", "here2")
355 result = subprocess.run(args, capture_output=True)
357 output = result.stderr.decode()
358 if log_tty:
359 self.assertIn("DEBUG", output)
360 else:
361 self.assertNotIn("DEBUG", output)
364if __name__ == "__main__": 364 ↛ 365line 364 didn't jump to line 365, because the condition on line 364 was never true
365 unittest.main()