Belle II Software  release-06-00-14
classversion.py
1 
8 """
9 b2test_utils.classversion - Helper functions to inspect and verify ROOT dictionaries
10 ------------------------------------------------------------------------------------
11 
12 This module contains some functions to make sure classes which have a ROOT
13 dictionary are healthy by allowing to check that
14 
15 * All base classes are fully defined and not forward declared
16 * All members have a dictionary
17 * The class version and checksum is as expected to avoid mistakes where the
18  classdef is not increased after class changes
19 
20 Users should only need to call ``b2code-classversion-check`` and
21 ``b2code-classversion-update``. The documentation of
22 ``b2code-classversion-check`` also contains a bit more detailed explanation of
23 class versions and checksums
24 """
25 
26 import re
27 import os
28 import sys
29 import ROOT
30 from basf2 import B2INFO, B2ERROR, B2WARNING
31 
32 
33 class ClassVersionError(Exception):
34  """Exception to report class version errors"""
35 
36 
37 class ErrorWithExtraVariables(Exception):
38  """Exception class with extra keyword arguments to show in log message"""
39 
40 
41  def __init__(self, *args, **argk):
42  super().__init__(*args)
43 
44  self.variablesvariables = argk
45 
46 
47 def check_base_classes(tclass):
48  """Recursively check all base classes of a TClass to make sure all are well defined"""
49  bases = tclass.GetListOfBases()
50  if bases is None:
51  raise ClassVersionError("Cannot get list of base classes.")
52  for base in bases:
53  baseclass = base.GetClassPointer()
54  if not baseclass:
55  raise ClassVersionError(f"incomplete base class {base.GetName()}")
56  check_base_classes(baseclass)
57 
58 
59 def check_dictionary(classname):
60  """Make sure we have a dictionary for the class and all its members"""
61  tclass = ROOT.TClass.GetClass(classname)
62  if not tclass:
63  raise ClassVersionError("Cannot find TClass object")
64  streamerinfo = tclass.GetStreamerInfo()
65  if streamerinfo:
66  for element in streamerinfo.GetElements():
67  elementclass = element.GetClassPointer()
68  if elementclass and not elementclass.IsLoaded():
69  raise ClassVersionError(f"Missing dictionary for member {element.GetName()}, "
70  f"type {elementclass.GetName()}")
71 
72 
73 def get_class_version(classname):
74  """Get the Class version and checksum for a fully qualified C++ class name"""
75  tclass = ROOT.TClass.GetClass(classname)
76  if not tclass:
77  raise ClassVersionError("Cannot find TClass object")
78  # good time to also check base classes
79  check_base_classes(tclass)
80  version = tclass.GetClassVersion()
81  checksum = tclass.GetCheckSum()
82  return version, checksum
83 
84 
85 def iterate_linkdef(filename):
86  """Iterate through the lines of a ROOT linkdef file.
87 
88  This function is a generator that foreach line returns a tuple where the
89  first element is True if there was a class link pragma on that line and
90  False otherwise.
91 
92  If it was a class link pragma the second element will be a tuple
93  ``(line, startcolumn, classname, linkoptions, options)`` where
94 
95  * ``line`` is the content of the line
96  * ``startcolumn`` is the column the content of the comment started in the line.
97  Everything before should be kept as is
98  * ``classname`` is the class name
99  * ``clingflags`` is the link flags requested by either the optional +-! after
100  the class or the ``options=`` in front of the class and a set of the value
101  "evolution", "nostreamer", and "noinputoper"
102  * ``options`` is a dictionary of the options found in the comment. The
103  content of the comment is split at commas and the result are the options.
104  If a options has a value after ``=`` that will be assigned, otherwise the
105  value is None. For example a comment ``// implicit, version=bar`` would
106  return ``{'implicit':None, 'version':'bar}``
107 
108  If it wasn't link pragma the second element is just the unmodified line
109  """
110  # regular expression to find link class pragmas
111  class_regex = re.compile(r"^\s*#pragma\s+link\s+C\+\+\s+(?:options=(.*?)\s)?\s*class\s+(.*?)([+\-!]*);(\s*//\s*(.*))?$")
112  with open(filename) as linkdef:
113  for line in linkdef:
114  match = class_regex.match(line)
115  # if the line doesn't match return it unchanged
116  if not match:
117  yield False, line
118  else:
119  # Otherwise extract the fields
120  linkflags, classname, flags, comment, content = match.groups()
121  start = match.start(4) if comment else len(line) - 1 # no comment: start of comment is end of line minus newline
122  # parse the linkflags
123  clingflags = set()
124  for char in flags:
125  clingflags.add({"+": "evolution", "-": "nostreamer", "!": "noinputoper"}.get(char))
126  if linkflags is not None:
127  for flag in linkflags.split(","):
128  clingflags.add(flag.strip())
129  # and parse the comment
130  options = {}
131  if content is not None:
132  optionlist = (e.strip().split("=", 1) for e in content.split(","))
133  options = {e[0]: e[1] if len(e) > 1 else None for e in optionlist}
134  # and return the separate parts
135  yield True, (line, start, classname, clingflags, options)
136 
137 
138 def check_linkdef(filename, message_style="belle2"):
139  """Check a linkdef file for expected versions/checksums
140 
141  * If the class has ``version=N, checksum=0x1234`` in the comment in the
142  linkdef compare that to what ROOT has created
143  * If not warn about the missing version and checksum
144  * But skip classes which are not stored to file.
145  """
146  errors = 0
147  relative_filename = os.path.relpath(filename)
148 
149  def print_message(severity, text, **argk):
150  """Print a message similar to GCC or Clang: ``filename:line:column: error: text``
151  so that it gets picked up by the build system
152  """
153  nonlocal message_style, errors, relative_filename, nr, column, classname
154 
155  if message_style == "gcc":
156  print(f"{relative_filename}:{nr}:{column}: {severity}: class {classname}: {text}", file=sys.stderr, flush=True)
157  else:
158  variables = {"linkdef": f"{relative_filename}:{nr}", "classname": classname}
159  variables.update(argk)
160  if severity == "error":
161  B2ERROR(text, **variables)
162  else:
163  B2WARNING(text, **variables)
164 
165  if severity == "error":
166  errors += 1
167 
168  def get_int(container, key):
169  """Get the value from a container if it exists and try to convert to int.
170  Otherwise show error"""
171  value = options.get(key, None)
172  if value is not None:
173  try:
174  return int(value, 0)
175  except ValueError as e:
176  print_message("error", f"expected {key} is not valid: {e}")
177 
178  # loop over all lines in the linkdef
179  for nr, (isclass, content) in enumerate(iterate_linkdef(filename)):
180  # and ignore everything not a class
181  if not isclass:
182  continue
183 
184  line, column, classname, clingflags, options = content
185 
186  # no need to check anything else if we don't have storage enabled
187  if "nostreamer" in clingflags:
188  if "evolution" in clingflags:
189  print_message("error", "both, no streamer and class evolution requested.")
190  # current ROOT code lets "evolution win so lets continue as if
191  # nostreamer wouldn't be present, we flagged the error"
192  else:
193  continue
194  # but warn if we use old style streamer without evolution rules
195  elif "evolution" not in clingflags:
196  print_message("warning", "using old ROOT3 streamer format without evolution. "
197  "Please add + or - (for classes not to be written to file) after the classname.")
198 
199  # check if we can actually load the class
200  try:
201  version, checksum = get_class_version(classname)
202  except ClassVersionError as e:
203  print_message("error", e)
204 
205  # This class seems to be intended to be serialized so make sure we can
206  if "version" in locals() and version != 0:
207  try:
208  check_dictionary(classname)
209  except ClassVersionError as e:
210  print_message("error", e)
211  else:
212  continue
213 
214  # and check expected version/checksum
215  expected_version = get_int(options, "version")
216  expected_checksum = get_int(options, "checksum")
217  if expected_version is None or expected_checksum is None:
218  print_message("warning", "no expected version and checksum, cannot spot changes to class")
219  continue
220 
221  # And now we know what we have ... so check the version
222  if version != expected_version:
223  print_message("error", "class version has changed, please update the linkdef",
224  expected_version=expected_version, actual_Version=version)
225  # And if it's non-zero also check the checksum. Zero means deactivated streamer
226  elif checksum != expected_checksum and version != 0:
227  print_message("error", "class checksum has changed! "
228  "Did you forget increasing the linkdef version?",
229  expected_checksum=expected_checksum, actual_checksum=checksum)
230 
231  return errors == 0
232 
233 
234 def verify_classlink_version(classname, options):
235  """This function verifies the version and checksum of a class from a given
236  linkdef classlink line and returns new values if they can be updated
237 
238  Parameters:
239  classname (str): The classname as in the linkdef file
240  options (dict): The options currently present in the linkdef file.
241  Previous version and checksum are taken from here if they exist
242 
243  * If there's nothing to do it will return None
244  * If there's any problem it will raise an exception
245  * Otherwise it will return a dictionary containing the new ``version`` and
246  ``checksum`` to be put in the file
247  """
248  # Try to get the actual values, if that fails it will raise which is handled
249  # by caller
250  version, checksum = get_class_version(classname)
251 
252  # We have the actual values but what do we know about previous
253  # values?
254  if "version" in options:
255  previous_version = int(options['version'], 0)
256  # Don't allow decrease of class version
257  if previous_version > version:
259  "actual class version lower than previous version in linkdef. "
260  "Class version cannot decrease",
261  previous_version=previous_version, actual_version=version,
262  )
263  # Also please don't just increase by a massive amount unless the previous
264  # version number was negative for "no explicit number"
265  elif previous_version > -1 and version > (previous_version + 1):
267  "actual class version increased by more than one compared to the "
268  "previous version set in linkdef. Please increase ClassDef in single steps",
269  previous_version=previous_version, actual_version=version,
270  )
271  # Otherwise if we have a checksum make sure it is the same
272  # if the version didn't change
273  if "checksum" in options:
274  previous_checksum = int(options['checksum'], 0)
275  if version == previous_version and checksum != previous_checksum:
277  "checksum changed but class version didn't change. "
278  "Please increase the class version if you modified the class contents",
279  version=version, actual_checksum=hex(checksum),
280  previous_checksum=hex(previous_checksum),
281  )
282  # but if both are the same just keep the line unchanged
283  elif (version, checksum) == (previous_version, previous_checksum):
284  return
285 
286  # Ok, we have our values, return them
287  return {"version": version, "checksum": hex(checksum)}
288 
289 
290 def update_linkdef(filename):
291  """
292  Update a given linkdef file and update all the versions and checksums
293 
294  Parameters:
295  filename (str): The linkdef.h file to be updated
296  """
297 
298  lines = []
299  # Loop over all lines
300  for nr, (isclass, content) in enumerate(iterate_linkdef(filename)):
301  # And if it's not a class link pragma keep the line unmodified
302  if not isclass:
303  lines.append(content)
304  continue
305 
306  # Otherwise check if we need a version and checksum
307  line, column, classname, clingflags, options = content
308  try:
309  # only for classes to be streamed ...
310  if "nostreamer" not in clingflags:
311  # verify the version ...
312  version = verify_classlink_version(classname, options)
313  if version is not None:
314  options.update(version)
315  B2INFO(f"Updating ROOT class version and checksum for {classname}",
316  linkdef=f"{os.path.relpath(filename)}:{nr}", **version)
317  # convert the options to a comment string
318  optionstr = []
319  for key, value in sorted(options.items()):
320  optionstr.append(key + (f"={value}" if value is not None else ""))
321  comment = " // " + ", ".join(optionstr)
322  lines.append(f"{line[:column]}{comment}\n")
323  continue
324 
325  except Exception as e:
326  variables = {"classname": classname, "linkdef": f"{os.path.relpath(filename)}:{nr}"}
327  if isinstance(e, ErrorWithExtraVariables):
328  variables.update(e.variables)
329  B2ERROR(str(e), **variables)
330 
331  # Finally, keep the line in case of errors or no change
332  lines.append(line)
333 
334  # And then replace the file
335  with open(filename, "w") as newlinkdef:
336  newlinkdef.writelines(lines)
def __init__(self, *args, **argk)
Initialize the class.
Definition: classversion.py:41