Belle II Software  release-08-01-10
cli.py
1 #!/usr/bin/env python3
2 
3 
10 import argparse
11 import os
12 import json
13 from concurrent.futures import ThreadPoolExecutor
14 import difflib
15 import pandas as pd
16 
17 import basf2
18 from conditions_db import cli_download, ConditionsDB, encode_name
19 from softwaretrigger import db_access
20 
21 
22 class HashableCut(dict):
23  """Small helper class as the difflib does not understand dicts directly (as they are not hashable)"""
24 
25  def __hash__(self):
26  """Create a hash for the object out of the json string"""
27  return hash(json.dumps(self))
28 
29 
31  """Helper class to translate the user-specified database(s) into parameters for basf2"""
32 
33  def __init__(self, command_argument):
34  """Init the stored databases and exp/run from the specified command argument"""
35 
36  self._database_database = []
37 
38  self._experiment_experiment = 99999
39 
40  self._run_run = 99999
41 
42  # If given, use the experiment run from the command_argument
43  split_argument = command_argument.split(":")
44  if len(split_argument) == 2:
45  command_argument, exp_run = split_argument
46 
47  if exp_run != "latest":
48  try:
49  self._experiment_experiment, self._run_run = map(int, exp_run.split("/"))
50  except BaseException:
51  raise argparse.ArgumentTypeError(
52  f"Do not understand the exp/run argument '{exp_run}'")
53 
54  elif len(split_argument) != 1:
55  raise argparse.ArgumentTypeError(
56  f"Do not understand the database argument '{command_argument}'")
57 
58  # Now split up the databases
59  self._database_database = command_argument.split(",")
60 
61  # However make sure we have them in the correct format
62  def normalize(database):
63  # In case a local file is specified we can just use it directly
64  if os.path.exists(database):
65  if os.path.basename(database) != "database.txt":
66  database = os.path.join(database, "database.txt")
67 
68  return database
69 
70  self._database_database = list(map(normalize, self._database_database))
71 
72  def set_database(self):
73  """
74  Set the basf2 database chain according to the specified databases.
75  Before that, clean up and invalidate everything from th database.
76 
77  The distinction between file databases and global databases is done
78  via the fact of a file/folder with this name exists or not.
79  """
80  from ROOT import Belle2
81  Belle2.DBStore.Instance().reset()
82 
83  basf2.conditions.override_globaltags()
84 
85  for database in self._database_database:
86  if os.path.exists(database):
87  basf2.conditions.prepend_testing_payloads(database)
88  else:
89  basf2.conditions.prepend_globaltag(database)
90 
91  db_access.set_event_number(evt_number=0, run_number=int(self._run_run),
92  exp_number=int(self._experiment_experiment))
93 
94  def get_all_cuts(self):
95  """
96  Get all cuts stored in the database(s)
97  and sort them according to base_identifier, cut_identifier.
98  """
99  self.set_databaseset_database()
100 
101  all_cuts = db_access.get_all_cuts()
102  all_cuts = sorted(all_cuts,
103  key=lambda cut: (cut["Base Identifier"], cut["Cut Identifier"]))
104  all_cuts = list(map(HashableCut, all_cuts))
105  return all_cuts
106 
107 
108 def diff_function(args):
109  """
110  Show the diff between two specified databases.
111  """
112  first_database_cuts = args.first_database.get_all_cuts()
113  second_database_cuts = args.second_database.get_all_cuts()
114 
115  diff = difflib.SequenceMatcher(
116  a=list(map(str, first_database_cuts)), b=list(map(str, second_database_cuts)))
117 
118  def print_cut(cut, prefix=" "):
119  if prefix == "-":
120  print("\x1b[31m", end="")
121  elif prefix == "+":
122  print("\x1b[32m", end="")
123  print(prefix, cut)
124  print("\x1b[0m", end="")
125 
126  def print_cuts(prefix, cuts):
127  for c in cuts:
128  print_cut(c, prefix)
129 
130  for tag, i1, i2, j1, j2 in diff.get_opcodes():
131  if tag == "equal":
132  if args.only_changes:
133  continue
134  print_cuts(" ", diff.b[j1:j2])
135  if tag in ["delete", "replace"]:
136  print_cuts("-", diff.a[i1:i2])
137  if tag in ["insert", "replace"]:
138  print_cuts("+", diff.b[j1:j2])
139 
140 
141 def add_cut_function(args):
142  """
143  Add a cut with the given parameters and also add it to the trigger menu.
144  """
145  args.database.set_database()
146 
147  db_access.upload_cut_to_db(cut_string=args.cut_string, base_identifier=args.base_identifier,
148  cut_identifier=args.cut_identifier, prescale_factor=args.prescale_factor,
149  reject_cut=args.reject_cut.lower() == "true", iov=None)
150  trigger_menu = db_access.download_trigger_menu_from_db(args.base_identifier,
151  do_set_event_number=False)
152  cuts = [str(cut) for cut in trigger_menu.getCutIdentifiers()]
153 
154  if args.cut_identifier not in cuts:
155  cuts.append(args.cut_identifier)
156 
157  db_access.upload_trigger_menu_to_db(args.base_identifier, cuts,
158  accept_mode=trigger_menu.isAcceptMode(), iov=None)
159 
160 
161 def remove_cut_function(args):
162  """
163  Remove a cut with the given name from the trigger menu.
164  """
165  args.database.set_database()
166 
167  trigger_menu = db_access.download_trigger_menu_from_db(
168  args.base_identifier, do_set_event_number=False)
169  cuts = [str(cut) for cut in trigger_menu.getCutIdentifiers() if str(cut) != args.cut_identifier]
170 
171  db_access.upload_trigger_menu_to_db(
172  args.base_identifier, cuts, accept_mode=trigger_menu.isAcceptMode(), iov=None)
173 
174 
175 def print_function(args):
176  """
177  Print the cuts stored in the database(s).
178  """
179  cuts = args.database.get_all_cuts()
180  df = pd.DataFrame(cuts)
181 
182  if args.format == "pandas":
183  pd.set_option("display.max_rows", 500)
184  pd.set_option("display.max_colwidth", 200)
185  pd.set_option('display.max_columns', 500)
186  pd.set_option('display.width', 1000)
187  print(df)
188  elif args.format in ["github", "gitlab"]:
189  from tabulate import tabulate
190  print(tabulate(df, tablefmt="github", showindex=False, headers="keys"))
191  elif args.format == "grid":
192  from tabulate import tabulate
193  print(tabulate(df, tablefmt="grid", showindex=False, headers="keys"))
194  elif args.format == "json":
195  import json
196  print(json.dumps(df.to_dict("records"), indent=2))
197  elif args.format == "list":
198  for base_identifier, cuts in df.groupby("Base Identifier"):
199  for _, cut in cuts.iterrows():
200  print(cut["Base Identifier"], cut["Cut Identifier"])
201  elif args.format == "human-readable":
202  print("Currently, the following menus and triggers are in the database")
203  for base_identifier, cuts in df.groupby("Base Identifier"):
204  print(base_identifier)
205  print("")
206  print("\tUsed triggers:\n\t\t" +
207  ", ".join(list(cuts["Cut Identifier"])))
208  print("\tIs in accept mode:\n\t\t" +
209  str(cuts["Reject Menu"].iloc[0]))
210  for _, cut in cuts.iterrows():
211  print("\t\tCut Name:\n\t\t\t" + cut["Cut Identifier"])
212  print("\t\tCut condition:\n\t\t\t" + cut["Cut Condition"])
213  print("\t\tCut prescaling\n\t\t\t" +
214  str(cut["Cut Prescaling"]))
215  print("\t\tCut is a reject cut:\n\t\t\t" +
216  str(cut["Reject Cut"]))
217  print()
218  else:
219  raise AttributeError(f"Do not understand format {args.format}")
220 
221 
222 def create_script_function(args):
223  """
224  Print the b2hlt_trigger commands to create a lobal database copy.
225  """
226  cuts = args.database.get_all_cuts()
227  df = pd.DataFrame(cuts)
228 
229  sfmt = 'b2hlt_triggers add_cut \
230 "{Base Identifier}" "{Cut Identifier}" "{Cut Condition}" "{Cut Prescaling}" "{Reject Cut}"'.format
231  if args.filename is None:
232  df.apply(lambda x: print(sfmt(**x)), 1)
233  else:
234  with open(args.filename, 'w') as f:
235  df.apply(lambda x: f.write(sfmt(**x) + '\n'), 1)
236 
237 
238 def iov_includes(iov_list, exp, run):
239  """
240  Comparison function between two IoVs (start, end) stored in the database and
241  the given exp/run combination.
242  """
243  # Dirty hack: replace -1 by infinity to make the comparison easier
244  copied_iov_list = iov_list[2:]
245  copied_iov_list = list(map(lambda x: x if x != -1 else float("inf"), copied_iov_list))
246 
247  exp_start, run_start, exp_end, run_end = copied_iov_list
248 
249  return (exp_start, run_start) <= (exp, run) <= (exp_end, run_end)
250 
251 
252 def download_function(args):
253  """
254  Download the trigger cuts in the given database to disk and set their IoV to infinity.
255  """
256  if len(args.database._database) != 1:
257  raise AttributeError("Can only download from a single database! Please do not specify more than one.")
258 
259  global_tag = args.database._database[0]
260 
261  # The following is an adapted version of cli_download
262  os.makedirs(args.destination, exist_ok=True)
263 
264  db = ConditionsDB()
265  req = db.request("GET", f"/globalTag/{encode_name(global_tag)}/globalTagPayloads",
266  f"Downloading list of payloads for {global_tag} tag")
267 
268  download_list = {}
269  for payload in req.json():
270  name = payload["payloadId"]["basf2Module"]["name"]
271  if not name.startswith("software_trigger_cut"):
272  continue
273 
274  local_file, remote_file, checksum, iovlist = cli_download.check_payload(args.destination, payload)
275 
276  new_iovlist = list(filter(lambda iov: iov_includes(iov, args.database._experiment, args.database._run), iovlist))
277  if not new_iovlist:
278  continue
279 
280  if local_file in download_list:
281  download_list[local_file][-1] += iovlist
282  else:
283  download_list[local_file] = [local_file, remote_file, checksum, iovlist]
284 
285  # do the downloading
286  full_iovlist = []
287  failed = 0
288  with ThreadPoolExecutor(max_workers=20) as pool:
289  for iovlist in pool.map(lambda x: cli_download.download_file(db, *x), download_list.values()):
290  if iovlist is None:
291  failed += 1
292  continue
293 
294  full_iovlist += iovlist
295 
296  dbfile = []
297  for iov in sorted(full_iovlist):
298  # Set the IoV intentionally to 0, 0, -1, -1
299  iov = [iov[0], iov[1], 0, 0, -1, -1]
300  dbfile.append("dbstore/{} {} {},{},{},{}\n".format(*iov))
301  with open(os.path.join(args.destination, "database.txt"), "w") as txtfile:
302  txtfile.writelines(dbfile)
303 
304 
305 def main():
306  """
307  Main function to be called from b2hlt_triggers.
308  """
309  parser = argparse.ArgumentParser(
310  description="""
311 Execute different actions on stored trigger menus in the database.
312 
313 Call with `%(prog)s [command] --help` to get a description on each command.
314 Please also see the examples at the end of this help.
315 
316 Many commands require one (or many) specified databases. Different formats are possible.
317 All arguments need to be written in quotation marks.
318 * "online" Use the latest version in the "online" database
319  (or any other specified global tag).
320 * "online:latest" Same as just "online", makes things a bit clearer.
321 * "online:8/345" Use the version in the "online" database (or any other specified global tag)
322  which was present in exp 8 run 345.
323 * "localdb:4/42" Use the local database specified in the given folder for the given exp/run.
324 * "localdb/database.txt" It is also possible to specify a file directly.
325 * "online,localdb" First look in localdb, then in the online GT
326 * "online,localdb:9/1" Can also be combined with the exp/run (It is then valid for all database accesses)
327 
328 Examples:
329 
330 * Check what has changed between 8/1 and 9/1 in the online GT.
331 
332  %(prog)s diff --first-database "online:8/1" --second-database "online:9/1" --only-changes
333 
334 * Especially useful while editing trigger cuts and menus: check what has changed between the latest
335  version online and what is currently additionally in localdb
336 
337  %(prog)s diff --first-database "online:latest" --second-database "online,localdb:latest"
338 
339  This use case is so common, it is even the default
340 
341  %(prog)s diff
342 
343 * Print the latest version of the cuts in online (plus what is defined in the localdb) in a human-friendly way
344 
345  %(prog)s print
346 
347 * Print the version of the cuts which was present in 8/1 online in a format understandable by GitLab
348  (you need to have the tabulate package installed)
349 
350  %(prog)s print --database "online:8/1" --format plain
351 
352 * Add a new skim cut named "accept_b2bcluster_3D" with the specified parameters and upload it to localdb
353 
354  %(prog)s add_cut skim accept_b2bcluster_3D "[[nB2BCC3DLE >= 1] and [G1CMSBhabhaLE < 2]]" 1 False
355 
356 * Remove the cut "accept_bhabha" from the trigger menu "skim"
357 
358  %(prog)s remove_cut skim accept_bhabha
359 
360 * Download the latest state of the triggers into the folder "localdb", e.g. to be used for local studies
361 
362  %(prog)s download
363 
364  """,
365  formatter_class=argparse.RawDescriptionHelpFormatter,
366  usage="%(prog)s command"
367  )
368  parser.set_defaults(func=lambda *args: parser.print_help())
369  subparsers = parser.add_subparsers(title="command",
370  description="Choose the command to execute")
371 
372  # diff command
373  diff_parser = subparsers.add_parser("diff", help="Compare the trigger menu in two different databases.",
374  formatter_class=argparse.RawDescriptionHelpFormatter,
375  description="""
376 Compare the two trigger menus present in the two specified databases
377 (or database chains) for the given exp/run combination (or the latest
378 version).
379 Every line in the output is one trigger line. A "+" in front means the
380 trigger line is present in the second database, but not in the first.
381 A "-" means exactly the opposite. Updates trigger lines will show up
382 as both "-" and "+" (with different parameters).
383 
384 The two databases (or database chains) can be specified as describes
385 in the general help (check b2hlt_triggers --help).
386 By default, the latest version of the online database will be
387 compared with what is defined on top in the localdb.
388  """)
389  diff_parser.add_argument("--first-database", help="First database to compare. Defaults to 'online:latest'.",
390  type=DownloadableDatabase, default=DownloadableDatabase("online:latest"))
391  diff_parser.add_argument("--second-database", help="Second database to compare. Defaults to 'online,localdb:latest'.",
392  type=DownloadableDatabase, default=DownloadableDatabase("online,localdb:latest"))
393  diff_parser.add_argument(
394  "--only-changes", help="Do not show unchanged lines.", action="store_true")
395  diff_parser.set_defaults(func=diff_function)
396 
397  # print command
398  print_parser = subparsers.add_parser("print", help="Print the cuts stored in the given database.",
399  formatter_class=argparse.RawDescriptionHelpFormatter,
400  description="""
401 Print the defined trigger menu and trigger cuts in a human-friendly
402 (default) or machine-friendly way.
403 The database (or database chain) needs to be specified in the general
404 help (check b2hlt_triggers --help).
405 
406 For additional formatting options please install the tabulate package with
407 
408  pip3 install --user tabulate
409 
410 By default the latest version on the online database and what is defined on
411 top in the localdb will be shown.
412  """)
413  print_parser.add_argument("--database", help="Which database to print. Defaults to 'online,localdb:latest'.",
414  type=DownloadableDatabase, default=DownloadableDatabase("online,localdb:latest"))
415  choices = ["human-readable", "json", "list", "pandas"]
416  try:
417  from tabulate import tabulate # noqa
418  choices += ['github', 'gitlab', 'grid']
419  except ImportError:
420  pass
421  print_parser.add_argument("--format", help="Choose the format how to print the trigger cuts. "
422  "To get access to more options please install the tabulate package using pip",
423  choices=choices, default="human-readable")
424  print_parser.set_defaults(func=print_function)
425 
426  # create-script command
427  create_script_parser = subparsers.add_parser(
428  "create_script",
429  help="Create b2hlt_triggers command to create a online globaltag copy.",
430  formatter_class=argparse.RawDescriptionHelpFormatter,
431  description="""
432 Generate the required b2hlt_trigger commands to reproduce an online globaltag for a given exp/run
433 number to create a local database version of it.
434  """)
435  create_script_parser.add_argument("--database", help="Which database to print. Defaults to 'online:latest'.",
436  type=DownloadableDatabase, default=DownloadableDatabase("online:latest"))
437  create_script_parser.add_argument("--filename", default=None,
438  help="Write to given filename instead of stdout.")
439  create_script_parser.set_defaults(func=create_script_function)
440 
441  # add_cut command
442  add_cut_parser = subparsers.add_parser("add_cut", help="Add a new cut.",
443  formatter_class=argparse.RawDescriptionHelpFormatter,
444  description="""
445 Add a cut with the given properties and upload it into the localdb database.
446 After that, you can upload it to the central database, to e.g. staging_online.
447 
448 As a base line for editing, a database much be specified in the usual format
449 (check b2hlt_triggers --help).
450 It defaults to the latest version online and the already present changes in
451 localdb.
452 Please note that the IoV of the created trigger line and menu is set to infinite.
453  """)
454  add_cut_parser.add_argument("--database", help="Where to take the trigger menu from. Defaults to 'online,localdb:latest'.",
455  type=DownloadableDatabase, default=DownloadableDatabase("online,localdb:latest"))
456  add_cut_parser.add_argument("base_identifier",
457  help="base_identifier of the cut to add", choices=["filter", "skim"])
458  add_cut_parser.add_argument("cut_identifier",
459  help="cut_identifier of the cut to add")
460  add_cut_parser.add_argument("cut_string",
461  help="cut_string of the cut to add")
462  add_cut_parser.add_argument("prescale_factor", type=int,
463  help="prescale of the cut to add")
464  add_cut_parser.add_argument(
465  "reject_cut", help="Is the new cut a reject cut?")
466  add_cut_parser.set_defaults(func=add_cut_function)
467 
468  # remove_cut command
469  remove_cut_parser = subparsers.add_parser("remove_cut", help="Remove a cut of the given name.",
470  formatter_class=argparse.RawDescriptionHelpFormatter,
471  description="""
472 Remove a cut with the given base and cut identifier from the trigger menu
473 and upload the new trigger menu to the localdb.
474 After that, you can upload it to the central database, to e.g. staging_online.
475 
476 As a base line for editing, a database much be specified in the usual format
477 (check b2hlt_triggers --help).
478 It defaults to the latest version online and the already present changes in
479 localdb.
480 Please note that the IoV of the created trigger menu is set to infinite.
481 
482 The old cut payload will not be deleted from the database. This is not
483 needed as only cuts specified in a trigger menu are used.
484  """)
485  remove_cut_parser.add_argument("base_identifier",
486  help="base_identifier of the cut to delete", choices=["filter", "skim"])
487  remove_cut_parser.add_argument("cut_identifier",
488  help="cut_identifier of the cut to delete")
489  remove_cut_parser.add_argument("--database",
490  help="Where to take the trigger menu from. Defaults to 'online,localdb:latest'.",
491  type=DownloadableDatabase, default=DownloadableDatabase("online,localdb:latest"))
492  remove_cut_parser.set_defaults(func=remove_cut_function)
493 
494  # download command
495  download_parser = subparsers.add_parser("download", help="Download the trigger menu from the database.",
496  formatter_class=argparse.RawDescriptionHelpFormatter,
497  description="""
498 Download all software trigger related payloads from the specified database
499 into the folder localdb and create a localdb/database.txt. This is
500 especially useful when doing local trigger studies which should use the
501 latest version of the online triggers. By default, the latest
502 version of the online GT will be downloaded.
503 
504 Attention: this script will override a database defined in the destination
505 folder (default localdb)!
506 Attention 2: all IoVs of the downloaded triggers will be set to 0, 0, -1, -1
507 so you can use the payloads from your local studies for whatever run you want.
508 This should not (never!) be used to upload or edit new triggers and
509 is purely a convenience function to synchronize your local studies
510 with the online database!
511 
512 Please note that for this command you can only specify a single database
513 (all others can work with multiple databases).
514  """)
515  download_parser.add_argument("--database",
516  help="Single database where to take the trigger menu from. Defaults to 'online:latest'.",
517  type=DownloadableDatabase, default=DownloadableDatabase("online:latest"))
518  download_parser.add_argument("--destination",
519  help="In which folder to store the output", default="localdb")
520  download_parser.set_defaults(func=download_function)
521 
522  args = parser.parse_args()
523  args.func(args)
_experiment
the experiment number, default (= latest) is 99999
Definition: cli.py:38
_database
the specified databases
Definition: cli.py:36
_run
the run number, default (= latest) is 99999
Definition: cli.py:40
def __init__(self, command_argument)
Definition: cli.py:33
static DBStore & Instance()
Instance of a singleton DBStore.
Definition: DBStore.cc:28
std::map< ExpRun, std::pair< double, double > > filter(const std::map< ExpRun, std::pair< double, double >> &runs, double cut, std::map< ExpRun, std::pair< double, double >> &runsRemoved)
filter events to remove runs shorter than cut, it stores removed runs in runsRemoved
Definition: Splitter.cc:38
int main(int argc, char **argv)
Run all tests.
Definition: test_main.cc:91