Coverage for python/lsst/daf/butler/tests/cliLogTestBase.py: 26%
136 statements
« prev ^ index » next coverage.py v7.2.4, created at 2023-04-29 02:58 -0700
« prev ^ index » next coverage.py v7.2.4, created at 2023-04-29 02:58 -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/>.
21from __future__ import annotations
23"""Unit tests for the daf_butler CliLog utility.
25Code is implemented in
26daf_butler but some only runs if lsst.log.Log can be imported so these parts of
27it can't be tested there because daf_butler does not directly depend on
28lsst.log, and only uses it if it has been setup by another package."""
30import logging
31import os
32import re
33import subprocess
34import tempfile
35import unittest
36from collections import namedtuple
37from collections.abc import Callable
38from functools import partial
39from io import StringIO
40from logging import DEBUG, INFO, WARNING
41from typing import TYPE_CHECKING, Any
43import click
44from lsst.daf.butler.cli.butler import cli as butlerCli
45from lsst.daf.butler.cli.cliLog import CliLog
46from lsst.daf.butler.cli.utils import LogCliRunner, clickResultMsg, command_test_env
47from lsst.daf.butler.core.logging import ButlerLogRecords
48from lsst.utils.logging import TRACE
50try:
51 import lsst.log as lsstLog
53 lsstLog_INFO = lsstLog.INFO
54 lsstLog_DEBUG = lsstLog.DEBUG
55 lsstLog_WARN = lsstLog.WARN
56except ModuleNotFoundError:
57 lsstLog = None
58 lsstLog_INFO = 0
59 lsstLog_DEBUG = 0
60 lsstLog_WARN = 0
63@click.command()
64@click.option("--expected-pyroot-level", type=int)
65@click.option("--expected-pylsst-level", type=int)
66@click.option("--expected-pybutler-level", type=int)
67@click.option("--expected-lsstroot-level", type=int)
68@click.option("--expected-lsstbutler-level", type=int)
69@click.option("--expected-lsstx-level", type=int)
70def command_log_settings_test(
71 expected_pyroot_level: str,
72 expected_pylsst_level: str,
73 expected_pybutler_level: str,
74 expected_lsstroot_level: str,
75 expected_lsstbutler_level: str,
76 expected_lsstx_level: str,
77) -> None:
78 LogLevel = namedtuple("LogLevel", ("expected", "actual", "name"))
80 logLevels = [
81 LogLevel(expected_pyroot_level, logging.getLogger().level, "pyRoot"),
82 LogLevel(expected_pylsst_level, logging.getLogger("lsst").getEffectiveLevel(), "pyLsst"),
83 LogLevel(
84 expected_pybutler_level, logging.getLogger("lsst.daf.butler").getEffectiveLevel(), "pyButler"
85 ),
86 LogLevel(expected_lsstx_level, logging.getLogger("lsstx").getEffectiveLevel(), "pyLsstx"),
87 ]
88 if lsstLog is not None:
89 logLevels.extend(
90 [
91 LogLevel(expected_lsstroot_level, lsstLog.getLogger("lsst").getEffectiveLevel(), "lsstRoot"),
92 LogLevel(
93 expected_lsstbutler_level,
94 lsstLog.getLogger("lsst.daf.butler").getEffectiveLevel(),
95 "lsstButler",
96 ),
97 ]
98 )
99 for expected, actual, name in logLevels:
100 if expected != actual:
101 raise (
102 click.ClickException(message=f"expected {name} level to be {expected!r}, actual:{actual!r}")
103 )
106class CliLogTestBase:
107 """Tests log initialization, reset, and setting log levels."""
109 if TYPE_CHECKING:
110 assertEqual: Callable
111 assertIn: Callable
112 assertTrue: Callable
113 assertFalse: Callable
114 assertGreater: Callable
115 subTest: Callable
116 assertNotIn: Callable
118 def setUp(self) -> None:
119 self.runner = LogCliRunner()
121 class PythonLogger:
122 """Keeps track of log level of a component and number of handlers
123 attached to it at the time this object was initialized."""
125 def __init__(self, component: str | None) -> None:
126 self.logger = logging.getLogger(component)
127 self.initialLevel = self.logger.level
129 class LsstLogger:
130 """Keeps track of log level for a component at the time this object was
131 initialized."""
133 def __init__(self, component: str) -> None:
134 self.logger = lsstLog.getLogger(component) if lsstLog else None
135 self.initialLevel = self.logger.getLevel() if lsstLog else None
137 def runTest(self, cmd: Callable) -> None:
138 """Test that the log context manager works with the butler cli to
139 initialize the logging system according to cli inputs for the duration
140 of the command execution and resets the logging system to its previous
141 state or expected state when command execution finishes."""
142 pyRoot = self.PythonLogger(None)
143 pyButler = self.PythonLogger("lsst.daf.butler")
144 pyLsstRoot = self.PythonLogger("lsst")
145 lsstRoot = self.LsstLogger("")
146 lsstButler = self.LsstLogger("lsst.daf.butler")
148 with command_test_env(
149 self.runner, "lsst.daf.butler.tests.cliLogTestBase", "command-log-settings-test"
150 ):
151 result = cmd()
152 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
154 # The test environment may have changed the python root logger
155 # so we can not assume it will be WARNING.
156 self.assertEqual(pyRoot.logger.level, logging.getLogger().getEffectiveLevel())
157 self.assertEqual(pyLsstRoot.logger.level, logging.INFO)
158 self.assertEqual(pyButler.logger.level, pyButler.initialLevel)
159 if lsstLog is not None:
160 self.assertEqual(lsstRoot.logger.getLevel(), lsstLog.INFO)
161 # lsstLogLevel can either be the initial level, or uninitialized or
162 # the defined default value.
163 expectedLsstLogLevel = (
164 (lsstButler.initialLevel,)
165 if lsstButler.initialLevel != -1
166 else (-1, CliLog.defaultLsstLogLevel)
167 )
168 self.assertIn(lsstButler.logger.getLevel(), expectedLsstLogLevel)
170 def test_butlerCliLog(self) -> None:
171 """Test that the log context manager works with the butler cli to
172 initialize the logging system according to cli inputs for the duration
173 of the command execution and resets the logging system to its previous
174 state or expected state when command execution finishes."""
176 # Run with two different log level settings.
177 log_levels = (
178 # --log-level / --log-level / expected pyroot, pylsst, pybutler,
179 # lsstroot, lsstbutler, lsstx
180 (
181 "WARNING",
182 "lsst.daf.butler=DEBUG",
183 WARNING,
184 WARNING,
185 DEBUG,
186 lsstLog_WARN,
187 lsstLog_DEBUG,
188 WARNING,
189 ),
190 ("DEBUG", "lsst.daf.butler=TRACE", WARNING, DEBUG, TRACE, lsstLog_DEBUG, lsstLog_DEBUG, WARNING),
191 (".=DEBUG", "lsst.daf.butler=WARNING", DEBUG, INFO, WARNING, lsstLog_INFO, lsstLog_WARN, DEBUG),
192 (".=DEBUG", "DEBUG", DEBUG, DEBUG, DEBUG, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG),
193 (".=DEBUG", "conda=DEBUG", DEBUG, INFO, INFO, lsstLog_INFO, lsstLog_INFO, DEBUG),
194 )
196 self._test_levels(log_levels)
198 # Check that the environment variable can set additional roots.
199 log_levels = (
200 # --log-level / --log-level / expected pyroot, pylsst, pybutler,
201 # lsstroot, lsstbutler, lsstx
202 (
203 "WARNING",
204 "lsst.daf.butler=DEBUG",
205 WARNING,
206 WARNING,
207 DEBUG,
208 lsstLog_WARN,
209 lsstLog_DEBUG,
210 WARNING,
211 ),
212 ("DEBUG", "lsst.daf.butler=TRACE", WARNING, DEBUG, TRACE, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG),
213 (".=DEBUG", "lsst.daf.butler=WARNING", DEBUG, INFO, WARNING, lsstLog_INFO, lsstLog_WARN, INFO),
214 (".=DEBUG", "DEBUG", DEBUG, DEBUG, DEBUG, lsstLog_DEBUG, lsstLog_DEBUG, DEBUG),
215 (".=DEBUG", "conda=DEBUG", DEBUG, INFO, INFO, lsstLog_INFO, lsstLog_INFO, INFO),
216 )
218 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_ROOT_LOGGER": "lsstx"}):
219 self._test_levels(log_levels)
221 def _test_levels(self, log_levels: tuple[tuple[str, str, int, int, int, Any, Any, int], ...]) -> None:
222 for level1, level2, x_pyroot, x_pylsst, x_pybutler, x_lsstroot, x_lsstbutler, x_lsstx in log_levels:
223 with self.subTest("Test different log levels", level1=level1, level2=level2):
224 self.runTest(
225 partial(
226 self.runner.invoke,
227 butlerCli,
228 [
229 "--log-level",
230 level1,
231 "--log-level",
232 level2,
233 "command-log-settings-test",
234 "--expected-pyroot-level",
235 x_pyroot,
236 "--expected-pylsst-level",
237 x_pylsst,
238 "--expected-pybutler-level",
239 x_pybutler,
240 "--expected-lsstroot-level",
241 x_lsstroot,
242 "--expected-lsstbutler-level",
243 x_lsstbutler,
244 "--expected-lsstx-level",
245 x_lsstx,
246 ],
247 )
248 )
250 def test_helpLogReset(self) -> None:
251 """Verify that when a command does not execute, like when the help menu
252 is printed instead, that CliLog is still reset."""
254 self.runTest(partial(self.runner.invoke, butlerCli, ["command-log-settings-test", "--help"]))
256 def testLongLog(self) -> None:
257 """Verify the timestamp is in the log messages when the --long-log
258 flag is set."""
260 # When longlog=True, loglines start with the log level and a
261 # timestamp with the following format:
262 # "year-month-day T hour-minute-second.millisecond-zoneoffset"
263 # For example: "DEBUG 2020-10-28T10:20:31-07:00 ...""
264 # The log level name can change, we verify there is an all
265 # caps word there but do not verify the word. We do not verify
266 # the rest of the log string, assume that if the timestamp is
267 # in the string that the rest of the string will appear as
268 # expected.
269 timestampRegex = re.compile(
270 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})"
271 "?([-,+][01][0-9]:[034][05]|Z) .*"
272 )
274 # When longlog=False, log lines start with the module name and
275 # log level, for example:
276 # lsst.daf.butler.core.config DEBUG: ...
277 modulesRegex = re.compile(r".* ([a-z]+\.)+[a-z]+ [A-Z]+: .*")
279 with self.runner.isolated_filesystem():
280 for longlog in (True, False):
281 # The pytest log handler interferes with the log configuration
282 # settings set up by initLog -- therefore test by using
283 # a subprocess.
284 args: tuple[str, ...]
285 if longlog:
286 args = ("butler", "--log-level", "DEBUG", "--long-log", "create", "here")
287 else:
288 args = ("butler", "--log-level", "DEBUG", "create", "here")
289 result = subprocess.run(args, capture_output=True)
290 # There are cases where the newlines are stripped from the log
291 # output (like in Jenkins), since we can't depend on newlines
292 # in log output they are removed here from test output.
293 output = StringIO((result.stderr.decode().replace("\n", " ")))
294 startedWithTimestamp = any([timestampRegex.match(line) for line in output.readlines()])
295 output.seek(0)
296 startedWithModule = any(modulesRegex.match(line) for line in output.readlines())
297 if longlog:
298 self.assertTrue(
299 startedWithTimestamp, msg=f"did not find timestamp in: \n{output.getvalue()}"
300 )
301 self.assertFalse(
302 startedWithModule, msg=f"found lines starting with module in: \n{output.getvalue()}"
303 )
304 else:
305 self.assertFalse(startedWithTimestamp, msg=f"found timestamp in: \n{output.getvalue()}")
306 self.assertTrue(
307 startedWithModule,
308 msg=f"did not find lines starting with module in: \n{output.getvalue()}",
309 )
311 def testFileLogging(self) -> None:
312 """Test --log-file option."""
313 with self.runner.isolated_filesystem():
314 for i, suffix in enumerate([".json", ".log"]):
315 # Get a temporary file name and immediately close it
316 fd = tempfile.NamedTemporaryFile(suffix=suffix)
317 filename = fd.name
318 fd.close()
320 args = (
321 "--log-level",
322 "DEBUG",
323 "--log-file",
324 filename,
325 "--log-label",
326 "k1=v1,k2=v2",
327 "--log-label",
328 "k3=v3",
329 "create",
330 f"here{i}",
331 )
333 result = self.runner.invoke(butlerCli, args)
334 self.assertEqual(result.exit_code, 0, clickResultMsg(result))
336 # Record to test. Test one in the middle that we know is
337 # a DEBUG message. The first message might come from
338 # python itself since warnings are redirected to log
339 # messages.
340 num = 4
342 n_records = 5
343 if suffix == ".json":
344 records = ButlerLogRecords.from_file(filename)
345 self.assertGreater(len(records), num)
346 self.assertEqual(records[num].levelname, "DEBUG", str(records[num]))
347 self.assertEqual(records[0].MDC, dict(K1="v1", K2="v2", K3="v3"))
349 self.assertGreater(len(records), n_records)
350 else:
351 with open(filename) as filed:
352 records_text = filed.readlines()
353 self.assertGreater(len(records_text), num)
354 self.assertIn("DEBUG", records_text[num], str(records_text[num]))
355 self.assertNotIn("{", records_text[num], str(records_text[num]))
357 self.assertGreater(len(records), n_records)
359 def testLogTty(self) -> None:
360 """Verify that log output to terminal can be suppressed."""
362 with self.runner.isolated_filesystem():
363 for log_tty in (True, False):
364 # The pytest log handler interferes with the log configuration
365 # settings set up by initLog -- therefore test by using
366 # a subprocess.
367 if log_tty:
368 args = ("butler", "--log-level", "DEBUG", "--log-tty", "create", "here")
369 else:
370 args = ("butler", "--log-level", "DEBUG", "--no-log-tty", "create", "here2")
371 result = subprocess.run(args, capture_output=True)
373 output = result.stderr.decode()
374 if log_tty:
375 self.assertIn("DEBUG", output)
376 else:
377 self.assertNotIn("DEBUG", output)
380if __name__ == "__main__":
381 unittest.main()