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