The Gaudi Framework  master (da3d77e1)
GaudiExeTest.py
Go to the documentation of this file.
1 
11 import difflib
12 import inspect
13 import json
14 import os
15 import re
16 import time
17 from pathlib import Path
18 from textwrap import dedent
19 from typing import Callable, Dict, List
20 
21 import pytest
22 
23 from GaudiTesting.preprocessors import normalizeTestSuite
24 from GaudiTesting.SubprocessBaseTest import SubprocessBaseTest
25 from GaudiTesting.utils import (
26  CodeWrapper,
27  filter_dict,
28  find_histos_summaries,
29  find_ttree_summaries,
30 )
31 
32 NO_ERROR_MESSAGES = {"ERROR": 0, "FATAL": 0}
33 
34 
36  """
37  An extension of SubprocessBaseTest tailored to the Gaudi/LHCb workflow.
38  It includes additional functionalities for handling options,
39  preprocessing output, and validating against platform-specific reference files.
40  """
41 
42  options: Callable = None
43  options_code = None
44  preprocessor: Callable = normalizeTestSuite
45 
46  @classmethod
47  def _prepare_command(cls, tmp_path=Path()) -> List[str]:
48  """
49  Override the base class to include options.
50  """
51  command = super()._prepare_command(tmp_path=tmp_path)
52 
53  def generate_unique_options_filename(extension, directory):
54  timestamp = time.strftime("%Y%m%d_%H%M%S")
55  return directory / f"options_{timestamp}.{extension}"
56 
57  if hasattr(cls, "options") and cls.options is not None:
58  options = cls.options
59  filename = None
60 
61  # Check if options is a function
62  if callable(options):
63  source_lines = inspect.getsource(options).splitlines()
64  clean_source = dedent(
65  "\n".join(source_lines[1:])
66  ) # Skip the first line (def options():)
67  filename = generate_unique_options_filename("py", tmp_path)
68  with open(filename, "w") as file:
69  file.write(clean_source)
70  cls.options_code = CodeWrapper(clean_source, "python")
71 
72  # Check if options is a dictionary
73  elif isinstance(options, dict):
74  filename = generate_unique_options_filename("json", tmp_path)
75  with open(filename, "w") as file:
76  json.dump(options, file, indent=4)
77  cls.options_code = CodeWrapper(options, "json")
78 
79  # Check if options is a string
80  elif isinstance(options, str):
81  options = dedent(options)
82  filename = generate_unique_options_filename("opts", tmp_path)
83  with open(filename, "w") as file:
84  file.write(options)
85  cls.options_code = CodeWrapper(options, "cpp")
86 
87  else:
88  raise ValueError(f"invalid options type '{type(options).__name__}'")
89 
90  if filename:
91  command.append(str(filename))
92  return command
93 
94  @staticmethod
96  reference_data: dict,
97  output: str,
98  preprocessor: Callable[[str], str] = lambda x: x,
99  ) -> str:
100  """
101  Compute the difference between the reference data and the current output.
102  """
103  expected_output = (
104  reference_data.splitlines()
105  if hasattr(reference_data, "splitlines")
106  else reference_data
107  )
108  actual_output = preprocessor(output).splitlines()
109  return "\n".join(
110  difflib.unified_diff(
111  expected_output,
112  actual_output,
113  fromfile="expected",
114  tofile="actual",
115  lineterm="",
116  )
117  )
118 
119  @classmethod
121  cls,
122  data: bytes,
123  key: str,
124  reference: Dict,
125  record_property: Callable[[str, str], None],
126  ) -> None:
127  """
128  Validate the given data against a reference file for the specified key.
129  """
130  if cls.reference:
131  try:
132  if key in reference:
133  # If the data is less than 100k, we can compare it directly
134  # and let pytest generate a nice diff, otherwise we hide the values
135  # to pytest to avoid that the test (practically) hangs.
136  #
137  # See
138  # - https://gitlab.cern.ch/lhcb/LHCb/-/issues/252
139  # - https://gitlab.cern.ch/gaudi/Gaudi/-/merge_requests/1375
140  # - https://gitlab.cern.ch/lhcb/LHCb/-/merge_requests/4773#note_9075367
141  if len(data) < 100000:
142  assert data == reference[key]
143  else:
144  same_as_reference = data == reference[key]
145  assert same_as_reference, "data is different from reference"
146  except AssertionError:
147  record_property(
148  f"{key}_diff",
149  CodeWrapper(
150  cls._output_diff(reference[key] or "", data, cls.preprocessor),
151  "diff",
152  ),
153  )
154  reference[key] = data
155  if os.environ.get("GAUDI_TEST_IGNORE_STDOUT_VALIDATION") == "1":
156  pytest.xfail("Ignoring stdout validation")
157  raise
158  else:
159  pytest.skip("No reference file provided")
160 
161  @classmethod
163  cls, output_file: str, reference_file: str, detailed=True
164  ):
165  """
166  Validate the JSON output against a reference JSON file.
167  """
168  assert os.path.isfile(output_file)
169 
170  try:
171  with open(output_file) as f:
172  output = json.load(f)
173  except json.JSONDecodeError as err:
174  pytest.fail(f"json parser error in {output_file}: {err}")
175 
176  lreference = cls.resolve_path(reference_file)
177  assert lreference, "reference file not set"
178  assert os.path.isfile(lreference)
179 
180  try:
181  with open(lreference) as f:
182  expected = json.load(f)
183  except json.JSONDecodeError as err:
184  pytest.fail(f"JSON parser error in {lreference}: {err}")
185 
186  if not detailed:
187  assert output == expected
188 
189  expected = sorted(expected, key=lambda item: (item["component"], item["name"]))
190  output = sorted(output, key=lambda item: (item["component"], item["name"]))
191  assert output == expected
192 
193  @classmethod
195  cls,
196  reference_block: str,
197  preprocessor: Callable = None,
198  signature: str = None,
199  signature_offset: int = 0,
200  ):
201  def assert_function(
202  cls,
203  stdout,
204  record_property,
205  preprocessor=preprocessor,
206  signature=signature,
207  signature_offset=signature_offset,
208  ):
209  processed_stdout = (
210  preprocessor(stdout.decode("utf-8"))
211  if preprocessor
212  else stdout.decode("utf-8")
213  )
214  stdout_lines = processed_stdout.strip().split("\n")
215  reference_lines = dedent(reference_block).strip().split("\n")
216 
217  if signature is None and signature_offset is not None:
218  if signature_offset < 0:
219  signature_offset = len(reference_lines) + signature_offset
220  signature = reference_lines[signature_offset]
221 
222  try:
223  start_index = stdout_lines.index(signature)
224  end_index = start_index + len(reference_lines)
225  observed_block = stdout_lines[start_index:end_index]
226 
227  if observed_block != reference_lines:
228  diff = list(
229  difflib.unified_diff(
230  reference_lines,
231  observed_block,
232  fromfile="expected",
233  tofile="actual",
234  )
235  )
236  diff_text = "\n".join(diff)
237  record_property("block_diff", CodeWrapper(diff_text, "diff"))
238  raise AssertionError(
239  "The observed block does not match the reference."
240  )
241  except ValueError:
242  raise AssertionError(
243  f"Signature '{signature}' not found in the output."
244  )
245 
246  return assert_function
247 
248  @pytest.mark.do_not_collect_source
249  def test_count_messages(self, reference, stdout, record_property):
250  """
251  Test the count of error messages in the stdout against expected values.
252  """
253  expected_messages = (
254  None if reference is None else reference.get("messages_count")
255  )
256  if expected_messages is None:
257  pytest.skip()
258 
259  if not isinstance(expected_messages, dict):
260  raise ValueError("reference['messages_count'] must be a dict")
261  if not expected_messages:
262  # an empty dict doesn't make sense, let's assume we are bootstrapping the reference
263  # and start from the default
264  expected_messages = NO_ERROR_MESSAGES
265  reference["messages_count"] = expected_messages
266 
267  outlines = self.preprocessor(
268  stdout.decode("utf-8", errors="backslashreplace")
269  ).splitlines()
270 
271  messages = {key: [] for key in expected_messages}
272  for n, line in enumerate(outlines, 1):
273  words = line.split()
274  if len(words) >= 2 and words[1] in messages:
275  messages[words[1]].append((n, line.rstrip()))
276 
277  messages_count = {key: len(value) for key, value in messages.items()}
278  try:
279  assert messages_count == expected_messages
280  except AssertionError:
281  reference["messages_count"] = messages_count
282  record_property("unexpected_messages_count", messages)
283  raise
284 
285  @pytest.mark.do_not_collect_source
287  self, stdout: bytes, record_property: Callable, reference: Dict
288  ) -> None:
289  """
290  Test the standard output against the reference.
291  """
292  out = self.preprocessor(stdout.decode("utf-8", errors="backslashreplace"))
293  self.validate_with_reference(out, "stdout", reference, record_property)
294 
295  @pytest.mark.do_not_collect_source
297  self, stdout: bytes, record_property: Callable, reference: Dict
298  ) -> None:
299  """
300  Test the TTree summaries against the reference.
301  """
302  if not self.reference or reference.get("ttrees") is None:
303  pytest.skip()
304 
305  ttrees = filter_dict(
306  find_ttree_summaries(stdout.decode()),
307  re.compile(r"Basket|.*size|Compression"),
308  )
309  try:
310  assert ttrees == reference["ttrees"]
311  except AssertionError:
312  reference["ttrees"] = ttrees
313  if os.environ.get("GAUDI_TEST_IGNORE_STDOUT_VALIDATION") == "1":
314  pytest.xfail("Ignoring stdout validation")
315  raise
316 
317  @pytest.mark.do_not_collect_source
319  self, stdout: bytes, record_property: Callable, reference: Dict
320  ) -> None:
321  """
322  Test the histogram summaries against the reference.
323  """
324  if not self.reference or reference.get("histos") is None:
325  pytest.skip()
326 
327  histos = filter_dict(
328  find_histos_summaries(stdout.decode()),
329  re.compile(r"Basket|.*size|Compression"),
330  )
331  try:
332  assert histos == reference["histos"]
333  except AssertionError:
334  reference["histos"] = histos
335  if os.environ.get("GAUDI_TEST_IGNORE_STDOUT_VALIDATION") == "1":
336  pytest.xfail("Ignoring stdout validation")
337  raise
338 
339  @pytest.mark.do_not_collect_source
341  self, stderr: bytes, record_property: Callable, reference: Dict
342  ) -> None:
343  """
344  Test the standard error output against the reference.
345  """
346  err = self.preprocessor(stderr.decode("utf-8", errors="backslashreplace"))
347  if self.reference and reference.get("stderr") is not None:
348  self.validate_with_reference(err, "stderr", reference, record_property)
349 
350  else:
351  assert not err.strip(), "Expected no standard error output, but got some."
352 
353  @pytest.mark.do_not_collect_source
354  def test_record_options(self, record_property: Callable):
355  if self.options_code:
356  record_property("options", self.options_code)
GaudiTesting.GaudiExeTest.GaudiExeTest._output_diff
str _output_diff(dict reference_data, str output, Callable[[str], str] preprocessor=lambda x:x)
Definition: GaudiExeTest.py:95
GaudiTesting.GaudiExeTest.GaudiExeTest
Definition: GaudiExeTest.py:35
GaudiTesting.utils.find_ttree_summaries
def find_ttree_summaries(stdout)
Definition: utils.py:374
GaudiTesting.GaudiExeTest.GaudiExeTest.options_code
options_code
Definition: GaudiExeTest.py:43
GaudiTesting.GaudiExeTest.GaudiExeTest.test_histos
None test_histos(self, bytes stdout, Callable record_property, Dict reference)
Definition: GaudiExeTest.py:318
GaudiTesting.GaudiExeTest.GaudiExeTest.test_stdout
None test_stdout(self, bytes stdout, Callable record_property, Dict reference)
Definition: GaudiExeTest.py:286
GaudiTesting.SubprocessBaseTest.SubprocessBaseTest.str
str
Definition: SubprocessBaseTest.py:41
GaudiTesting.GaudiExeTest.GaudiExeTest.test_count_messages
def test_count_messages(self, reference, stdout, record_property)
Definition: GaudiExeTest.py:249
GaudiTesting.SubprocessBaseTest.SubprocessBaseTest.resolve_path
str resolve_path(cls, Union[Path, str] path)
Definition: SubprocessBaseTest.py:53
GaudiTesting.SubprocessBaseTest.SubprocessBaseTest
Definition: SubprocessBaseTest.py:33
GaudiTesting.utils
Definition: utils.py:1
GaudiTesting.GaudiExeTest.GaudiExeTest.find_reference_block
def find_reference_block(cls, str reference_block, Callable preprocessor=None, str signature=None, int signature_offset=0)
Definition: GaudiExeTest.py:194
GaudiTesting.SubprocessBaseTest
Definition: SubprocessBaseTest.py:1
GaudiTesting.GaudiExeTest.GaudiExeTest.test_ttrees
None test_ttrees(self, bytes stdout, Callable record_property, Dict reference)
Definition: GaudiExeTest.py:296
GaudiTesting.GaudiExeTest.GaudiExeTest.test_record_options
def test_record_options(self, Callable record_property)
Definition: GaudiExeTest.py:354
GaudiTesting.GaudiExeTest.GaudiExeTest.validate_with_reference
None validate_with_reference(cls, bytes data, str key, Dict reference, Callable[[str, str], None] record_property)
Definition: GaudiExeTest.py:120
GaudiTesting.GaudiExeTest.GaudiExeTest._prepare_command
List[str] _prepare_command(cls, tmp_path=Path())
Definition: GaudiExeTest.py:47
GaudiTesting.utils.find_histos_summaries
def find_histos_summaries(stdout)
Definition: utils.py:351
GaudiTesting.utils.CodeWrapper
Definition: utils.py:22
GaudiTesting.preprocessors
Definition: preprocessors.py:1
GaudiTesting.GaudiExeTest.GaudiExeTest.validate_json_with_reference
def validate_json_with_reference(cls, str output_file, str reference_file, detailed=True)
Definition: GaudiExeTest.py:162
GaudiTesting.GaudiExeTest.GaudiExeTest.test_stderr
None test_stderr(self, bytes stderr, Callable record_property, Dict reference)
Definition: GaudiExeTest.py:340
GaudiTesting.utils.filter_dict
Dict[str, Any] filter_dict(Dict[str, Any] d, re.Pattern ignore_re)
Definition: utils.py:166