Belle II Software development
cli.py
1#!/usr/bin/env python3
2
3
10import argparse
11import os
12import json
13from concurrent.futures import ThreadPoolExecutor
14import difflib
15import pandas as pd
16
17import basf2
18from conditions_db import cli_download, ConditionsDB, encode_name
19from softwaretrigger import db_access
20
21
22class 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 = []
37
38 self._experiment = 99999
39
40 self._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, self._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 = 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 = list(map(normalize, self._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
82
83 basf2.conditions.override_globaltags()
84
85 for database in self._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),
92 exp_number=int(self._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_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
108def 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
141def 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
161def 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
175def 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
222def 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
238def 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
252def 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
305def main():
306 """
307 Main function to be called from b2hlt_triggers.
308 """
309 parser = argparse.ArgumentParser(
310 description="""
311Execute different actions on stored trigger menus in the database.
312
313Call with `%(prog)s [command] --help` to get a description on each command.
314Please also see the examples at the end of this help.
315
316Many commands require one (or many) specified databases. Different formats are possible.
317All 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
328Examples:
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="""
376Compare the two trigger menus present in the two specified databases
377(or database chains) for the given exp/run combination (or the latest
378version).
379Every line in the output is one trigger line. A "+" in front means the
380trigger line is present in the second database, but not in the first.
381A "-" means exactly the opposite. Updates trigger lines will show up
382as both "-" and "+" (with different parameters).
383
384The two databases (or database chains) can be specified as describes
385in the general help (check b2hlt_triggers --help).
386By default, the latest version of the online database will be
387compared 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="""
401Print the defined trigger menu and trigger cuts in a human-friendly
402(default) or machine-friendly way.
403The database (or database chain) needs to be specified in the general
404help (check b2hlt_triggers --help).
405
406For additional formatting options please install the tabulate package with
407
408 pip3 install --user tabulate
409
410By default the latest version on the online database and what is defined on
411top 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="""
432Generate the required b2hlt_trigger commands to reproduce an online globaltag for a given exp/run
433number 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="""
445Add a cut with the given properties and upload it into the localdb database.
446After that, you can upload it to the central database, to e.g. staging_online.
447
448As a base line for editing, a database much be specified in the usual format
449(check b2hlt_triggers --help).
450It defaults to the latest version online and the already present changes in
451localdb.
452Please 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="""
472Remove a cut with the given base and cut identifier from the trigger menu
473and upload the new trigger menu to the localdb.
474After that, you can upload it to the central database, to e.g. staging_online.
475
476As a base line for editing, a database much be specified in the usual format
477(check b2hlt_triggers --help).
478It defaults to the latest version online and the already present changes in
479localdb.
480Please note that the IoV of the created trigger menu is set to infinite.
481
482The old cut payload will not be deleted from the database. This is not
483needed 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="""
498Download all software trigger related payloads from the specified database
499into the folder localdb and create a localdb/database.txt. This is
500especially useful when doing local trigger studies which should use the
501latest version of the online triggers. By default, the latest
502version of the online GT will be downloaded.
503
504Attention: this script will override a database defined in the destination
505folder (default localdb)!
506Attention 2: all IoVs of the downloaded triggers will be set to 0, 0, -1, -1
507so you can use the payloads from your local studies for whatever run you want.
508This should not (never!) be used to upload or edit new triggers and
509is purely a convenience function to synchronize your local studies
510with the online database!
511
512Please 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
Definition: main.py:1