Belle II Software  release-08-01-10
cli_main.py
1 #!/usr/bin/env python3
2 
3 
10 
11 """
12 This tool provides a command line interface to all the tasks related to the
13 :ref:`Conditions database <conditionsdb_overview>`: manage globaltags and iovs
14 as well as upload new payloads or download of existing payloads.
15 
16 The usage of this tool is similar to git: there are sub commands like for
17 example ``tag`` which groups all actions related to the management of
18 globaltags. All the available commands are listed below.
19 
20 Users need a valid JSON Web Token (JWT) to authenticate to the conditions
21 database when using this tool. For practical purposes, it is only necessary
22 to know that a JWT is a string containing crypted information, and that string
23 is stored in a file. More informations about what a JWT is can be found on
24 `Wikipedia <https://en.wikipedia.org/wiki/JSON_Web_Token>`_.
25 
26 The tool automatically queries the JWT issuing server
27 (https://token.belle2.org) and gets a valid token by asking the B2MMS username
28 and password. The issued "default" JWT has a validity of 1 hour; after it
29 expires, a new JWT needs to be obtained for authenticating the conditions
30 database. When retrieved by this tool, the JWT is stored locally in the file
31 ``${HOME}/b2cdb_${BELLE2_USER}.token``.
32 
33 Some selected users (e.g. the calibration managers) are granted a JWT with an
34 extended validity (30 days) to allow smooth operations with some automated
35 workflows. Such "extended" JWTs are issued by a different server
36 (https://token.belle2.org/extended). B2MMS username and password are necessary
37 for getting the extended JWT. The extended JWT differs from the default JWT
38 only by its validity and can be obtained only by manually querying the
39 alternative server. If queried via web browser, a file containing the extended
40 JWT will be downloaded in case the user is granted it. The server can also be
41 queried via command line using
42 ``wget --user USERNAME --ask-password --no-check-certificate https://token.belle2.org/extended``
43 or ``curl -u USERNAME -k https://token.belle2.org/extended``.
44 
45 If the environment variable ``${BELLE2_CDB_AUTH_TOKEN}`` is defined and points
46 to a file containing a valid JWT, the ``b2conditionsdb`` tools use this token
47 to authenticate with the CDB instead of querying the issuing server. This is
48 useful when using an extended token. Please note that, in case the JWT to which
49 ``${BELLE2_CDB_AUTH_TOKEN}`` points is not valid anymore, the
50 ``b2conditionsdb`` tools will attempt to get a new one and store it into
51 ``${BELLE2_CDB_AUTH_TOKEN}``. If a new extended token is needed, it has to be
52 manually obtained via https://token.belle2.org/extended and stored into
53 ``${BELLE2_CDB_AUTH_TOKEN}``.
54 """
55 
56 # Creation of command line parser is done semi-automatically: it looks for
57 # functions which are called command_* and will take the * as sub command name
58 # and the docstring as documentation: the first line as brief explanation and
59 # the remainder as full documentation. command_foo_bar will create subcommand
60 # bar inside command foo. The function will be called first with an instance of
61 # argument parser where the command line options required for that command
62 # should be added. If the command is run it will be called with the parsed
63 # arguments first and an instance to the database object as second argument.
64 
65 # Some of the commands are separated out in separate modules named cli_*.py
66 
67 import os
68 import re
69 import sys
70 import argparse
71 import textwrap
72 import json
73 import difflib
74 import shutil
75 import pprint
76 import requests
77 from basf2 import B2ERROR, B2WARNING, B2INFO, LogLevel, LogInfo, logging, \
78  LogPythonInterface
79 from basf2.utils import pretty_print_table
80 from terminal_utils import Pager
81 from dateutil.parser import parse as parse_date
82 from getpass import getuser
83 from conditions_db import ConditionsDB, enable_debugging, encode_name, PayloadInformation, set_cdb_authentication_token
84 from conditions_db.cli_utils import ItemFilter
85 from conditions_db.iov import IntervalOfValidity
86 # the command_* functions are imported but not used so disable warning about
87 # this if pylama/pylint is used to check
88 from conditions_db.cli_upload import command_upload # noqa
89 from conditions_db.cli_download import command_download, command_legacydownload # noqa
90 from conditions_db.cli_management import command_tag_merge, command_tag_runningupdate, command_iovs, command_iovs_delete, command_iovs_copy, command_iovs_modify # noqa
91 
92 
93 def escape_ctrl_chars(name):
94  """Remove ANSI control characters from strings"""
95  # compile the regex on first use and remember it
96  if not hasattr(escape_ctrl_chars, "_regex"):
97  escape_ctrl_chars._regex = re.compile("[\x00-\x1f\x7f-\x9f]")
98 
99  # escape the control characters by putting them in as \xFF
100  def escape(match):
101  if match.group(0).isspace():
102  return match.group(0)
103  return "\\x{:02x}".format(ord(match.group(0)))
104 
105  return escape_ctrl_chars._regex.sub(escape, name)
106 
107 
108 def command_tag(args, db=None):
109  """
110  List, show, create, modify or clone globaltags.
111 
112  This command allows to list, show, create modify or clone globaltags in the
113  central database. If no other command is given it will list all tags as if
114  "%(prog)s show" was given.
115  """
116 
117  # no arguments to define, just a group command
118  if db is not None:
119  # normal call, in this case we just divert to list all tags
120  command_tag_list(args, db)
121 
122 
123 def command_tag_list(args, db=None):
124  """
125  List all available globaltags.
126 
127  This command allows to list all globaltags, optionally limiting the output
128  to ones matching a given search term. By default invalidated globaltags
129  will not be included in the list, to show them as well please add
130  --with-invalid as option. Alternatively one can use --only-published to show
131  only tags which have been published
132 
133  If the --regex option is supplied the search term will be interpreted as a
134  python regular expression where the case is ignored.
135  """
136 
137  tagfilter = ItemFilter(args)
138  if db is None:
139  # db object is none means we should just define arguments
140  args.add_argument("--detail", action="store_true", default=False,
141  help="show detailed information for all tags instead "
142  "of summary table")
143  group = args.add_mutually_exclusive_group()
144  group.add_argument("--with-invalid", action="store_true", default=False,
145  help="if given also invalidated tags will be shown")
146  group.add_argument("--only-published", action="store_true", default=False,
147  help="if given only published tags will be shown")
148  tagfilter.add_arguments("tags")
149  return
150 
151  # see if the -f/-e/-r flags are ok
152  if not tagfilter.check_arguments():
153  return 1
154 
155  req = db.request("GET", "/globalTags", f"Getting list of globaltags{tagfilter}")
156 
157  # now let's filter the tags
158  taglist = []
159  for item in req.json():
160  if not tagfilter.check(item["name"]):
161  continue
162  tagstatus = item["globalTagStatus"]["name"]
163  if not getattr(args, "with_invalid", False) and tagstatus == "INVALID":
164  continue
165  if getattr(args, "only_published", False) and tagstatus != "PUBLISHED":
166  continue
167  taglist.append(item)
168 
169  # done, sort by name
170  taglist.sort(key=lambda x: x["name"])
171 
172  # and print, either detailed info for each tag or summary table at the end
173  table = []
174  with Pager("List of globaltags{}{}".format(tagfilter, " (detailed)" if getattr(args, "detail", False) else ""), True):
175  for item in taglist:
176  if getattr(args, "detail", False):
177  print_globaltag(db, item)
178  else:
179  table.append([
180  item["globalTagId"],
181  item["name"],
182  escape_ctrl_chars(item.get("description", "")),
183  item["globalTagType"]["name"],
184  item["globalTagStatus"]["name"],
185  item["payloadCount"],
186  ])
187 
188  if not getattr(args, "detail", False):
189  table.insert(0, ["id", "name", "description", "type", "status", "# payloads"])
190  pretty_print_table(table, [-10, 0, "*", -10, -10, -10], min_flexible_width=32)
191 
192 
193 def print_globaltag(db, *tags):
194  """ Print detailed globaltag information for the given globaltags side by side"""
195  results = [["id"], ["name"], ["description"], ["type"], ["status"],
196  ["# payloads"], ["created"], ["modified"], ["modified by"]]
197  for info in tags:
198  if info is None:
199  continue
200 
201  if isinstance(info, str):
202  try:
203  req = db.request("GET", "/globalTag/{}".format(encode_name(info)),
204  f"Getting info for globaltag {info}")
205  except ConditionsDB.RequestError as e:
206  # ok, there's an error for this one, let's continue with the other
207  # ones
208  B2ERROR(str(e))
209  continue
210 
211  info = req.json()
212 
213  created = parse_date(info["dtmIns"])
214  modified = parse_date(info["dtmMod"])
215  results[0].append(str(info["globalTagId"])),
216  results[1].append(info["name"]),
217  results[2].append(escape_ctrl_chars(info.get("description", "")))
218  results[3].append(info["globalTagType"]["name"])
219  results[4].append(info["globalTagStatus"]["name"]),
220  results[5].append(info["payloadCount"]),
221  results[6].append(created.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time"))
222  results[7].append(modified.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time"))
223  results[8].append(escape_ctrl_chars(info["modifiedBy"]))
224 
225  ntags = len(results[0]) - 1
226  if ntags > 0:
227  pretty_print_table(results, [11] + ['*'] * ntags, True)
228  return ntags
229 
230 
231 def change_state(db, tag, state, force=False):
232  """Change the state of a global tag
233 
234  If the new state is not revertible then ask for confirmation
235  """
236  state = state.upper()
237  if state in ["INVALID", "PUBLISHED"] and not force:
238  name = input(f"ATTENTION: Marking a tag as {state} cannot be undone.\n"
239  "If you are sure you want to continue it please enter the tag name again: ")
240  if name != tag:
241  B2ERROR("Names don't match, aborting")
242  return 1
243 
244  db.request("PUT", f"/globalTag/{encode_name(tag)}/updateGlobalTagStatus/{state}",
245  f"Changing globaltag state {tag} to {state}")
246 
247 
248 def command_tag_show(args, db=None):
249  """
250  Show details about globaltags
251 
252  This command will show details for the given globaltags like name,
253  description and number of payloads.
254  """
255 
256  # this one is a bit similar to command_tag_list but gets single tag
257  # information from the database and thus no need for filtering. It will
258  # always show the detailed information
259 
260  if db is None:
261  args.add_argument("tag", metavar="TAGNAME", nargs="+", help="globaltags to show")
262  return
263 
264  # we retrieved all we could, print them
265  ntags = 0
266  with Pager("globaltag Information", True):
267  for tag in args.tag:
268  ntags += print_globaltag(db, tag)
269 
270  # return the number of tags which could not get retrieved
271  return len(args.tag) - ntags
272 
273 
274 def command_tag_create(args, db=None):
275  """
276  Create a new globaltag
277 
278  This command creates a new globaltag in the database with the given name
279  and description. The name can only contain alpha-numeric characters and the
280  characters '+-_:'.
281  """
282 
283  if db is None:
284  args.add_argument("type", metavar="TYPE", help="type of the globaltag to create, usually one of DEV or RELEASE")
285  args.add_argument("tag", metavar="TAGNAME", help="name of the globaltag to create")
286  args.add_argument("description", metavar="DESCRIPTION", help="description of the globaltag")
287  args.add_argument("-u", "--user", metavar="USER", help="username who created the tag. "
288  "If not given we will try to supply a useful default")
289  return
290 
291  set_cdb_authentication_token(db, args.auth_token)
292 
293  # create tag info needed for creation
294  info = {"name": args.tag, "description": args.description, "modifiedBy": args.user, "isDefault": False}
295  # add user information if not given by command line
296  if args.user is None:
297  info["modifiedBy"] = os.environ.get("BELLE2_USER", getuser())
298 
299  typeinfo = db.get_globalTagType(args.type)
300  if typeinfo is None:
301  return 1
302 
303  req = db.request("POST", "/globalTag/{}".format(encode_name(typeinfo["name"])),
304  "Creating globaltag {name}".format(**info),
305  json=info)
306  B2INFO("Successfully created globaltag {name} (id={globalTagId})".format(**req.json()))
307 
308 
309 def command_tag_modify(args, db=None):
310  """
311  Modify a globaltag by changing name or description
312 
313  This command allows to change the name or description of an existing globaltag.
314  You can supply any combination of -n,-d,-t and only the given values will be changed
315  """
316  if db is None:
317  args.add_argument("tag", metavar="TAGNAME", help="globaltag to modify")
318  args.add_argument("-n", "--name", help="new name")
319  args.add_argument("-d", "--description", help="new description")
320  args.add_argument("-t", "--type", help="new type of the globaltag")
321  args.add_argument("-u", "--user", metavar="USER", help="username who created the tag. "
322  "If not given we will try to supply a useful default")
323  args.add_argument("-s", "--state", help="new globaltag state, see the command ``tag state`` for details")
324  return
325 
326  set_cdb_authentication_token(db, args.auth_token)
327 
328  # first we need to get the old tag information
329  req = db.request("GET", "/globalTag/{}".format(encode_name(args.tag)),
330  f"Getting info for globaltag {args.tag}")
331 
332  # now we update the tag information
333  info = req.json()
334  old_name = info["name"]
335  changed = False
336  for key in ["name", "description"]:
337  value = getattr(args, key)
338  if value is not None and value != info[key]:
339  info[key] = value
340  changed = True
341 
342  info["modifiedBy"] = os.environ.get("BELLE2_USER", os.getlogin()) if args.user is None else args.user
343 
344  if args.type is not None:
345  # for the type we need to know which types are defined
346  typeinfo = db.get_globalTagType(args.type)
347  if typeinfo is None:
348  return 1
349  # seems so, ok modify the tag info
350  if info['globalTagType'] != typeinfo:
351  info["globalTagType"] = typeinfo
352  changed = True
353 
354  # and push the changed info to the server
355  if changed:
356  db.request("PUT", "/globalTag",
357  "Modifying globaltag {} (id={globalTagId})".format(old_name, **info),
358  json=info)
359 
360  if args.state is not None:
361  name = args.name if args.name is not None else old_name
362  return change_state(db, name, args.state)
363 
364 
365 def command_tag_clone(args, db=None):
366  """
367  Clone a given globaltag including all IoVs
368 
369  This command allows to clone a given globaltag with a new name but still
370  containing all the IoVs defined in the original globaltag.
371  """
372 
373  if db is None:
374  args.add_argument("tag", metavar="TAGNAME", help="globaltag to be cloned")
375  args.add_argument("name", metavar="NEWNAME", help="name of the cloned globaltag")
376  return
377 
378  set_cdb_authentication_token(db, args.auth_token)
379 
380  # first we need to get the old tag information
381  req = db.request("GET", "/globalTag/{}".format(encode_name(args.tag)),
382  f"Getting info for globaltag {args.tag}")
383  info = req.json()
384 
385  # now we clone the tag. id came from the database so no need for escape
386  req = db.request("POST", "/globalTags/{globalTagId}".format(**info),
387  "Cloning globaltag {name} (id={globalTagId})".format(**info))
388 
389  # it gets a stupid name "{ID}_copy_of_{name}" so we change it to something
390  # nice like the user asked
391  cloned_info = req.json()
392  cloned_info["name"] = args.name
393  # and push the changed info to the server
394  db.request("PUT", "/globalTag", "Renaming globaltag {name} (id={globalTagId})".format(**cloned_info),
395  json=cloned_info)
396 
397 
398 def command_tag_state(args, db):
399  """
400  Change the state of a globaltag.
401 
402  This command changes the state of a globaltag to the given value.
403 
404  Usually the valid states are
405 
406  OPEN
407  Tag can be modified, payloads and iovs can be created and deleted. This
408  is the default state for new or cloned globaltags and is not suitable
409  for use in data analysis
410 
411  Can be transitioned to TESTING, RUNNING
412 
413  TESTING
414  Tag cannot be modified and is suitable for testing but can be reopened
415 
416  Can be transitioned to VALIDATED, OPEN
417 
418  VALIDATED
419  Tag cannot be modified and has been tested.
420 
421  Can be transitioned to PUBLISHED, OPEN
422 
423  PUBLISHED
424  Tag cannot be modified and is suitable for user analysis
425 
426  Can only be transitioned to INVALID
427 
428  RUNNING
429  Tag can only be modified by adding new runs, not modifying the payloads
430  for existing runs.
431 
432  INVALID:
433  Tag is invalid and should not be used for anything.
434 
435  This state is end of life for a globaltag and cannot be transitioned to
436  any other state.
437 
438  .. versionadded:: release-04-00-00
439  """
440  if db is None:
441  args.add_argument("tag", metavar="TAGNAME", help="globaltag to be changed")
442  args.add_argument("state", metavar="STATE", help="new state for the globaltag")
443  args.add_argument("--force", default=False, action="store_true", help=argparse.SUPPRESS)
444  return
445 
446  set_cdb_authentication_token(db, args.auth_token)
447 
448  return change_state(db, args.tag, args.state, args.force)
449 
450 
451 def remove_repeated_values(table, columns, keep=None):
452  """Strip repeated values from a table of values
453 
454  This function takes a table (a list of lists with all the same length) and
455  removes values in certain columns if they are identical in consecutive rows.
456 
457  It does this in a dependent way only if the previous columns are identical
458  will it continue stripping further columns. For example, given the table ::
459 
460  table = [
461  ["A", "a"],
462  ["B", "a"],
463  ["B", "a"],
464  ["B", "b"],
465  ]
466 
467  If we want to remove duplicates in all columns in order it would look like this:
468 
469  >>> remove_repeated_values(table, [0,1])
470  [
471  ["A", "a"],
472  ["B", "a"],
473  [ "", ""],
474  [ "", "b"],
475  ]
476 
477  But we can give selected columns to strip one after the other
478 
479  >>> remove_repeated_values(table, [1,0])
480  [
481  ["A", "a"],
482  ["B", ""],
483  [ "", ""],
484  ["B", "b"],
485  ]
486 
487  In addition, we might want to only strip some columns if previous columns
488  were identical but keep the values of the previous column. For this one can
489  supply ``keep``:
490 
491  >>> remove_repeated_values(table, [0,1,2], keep=[0])
492  [
493  ["A", "a"],
494  ["B", "a"],
495  ["B", ""],
496  ["B", "b"],
497  ]
498 
499  Parameters:
500  table (list(list(str))): 2d table of values
501  columns (list(int)): indices of columns to consider in order
502  keep (set(int)): indices of columns to not strip
503  """
504  last_values = [None] * len(columns)
505  for row in table[1:]:
506  current_values = [row[i] for i in columns]
507  for i, curr, last in zip(columns, current_values, last_values):
508  if curr != last:
509  break
510 
511  if keep and i in keep:
512  continue
513 
514  row[i] = ""
515 
516  last_values = current_values
517 
518 
519 def command_diff(args, db):
520  """Compare two globaltags
521 
522  This command allows to compare two globaltags. It will show the changes in
523  a format similar to a unified diff but by default it will not show any
524  context, only the new or removed payloads. Added payloads are marked with a
525  ``+`` in the first column, removed payloads with a ``-``
526 
527  If ``--full`` is given it will show all payloads, even the ones common to
528  both globaltags. The differences can be limited to a given run and
529  limited to a set of payloads names using ``--filter`` or ``--exclude``. If
530  the ``--regex`` option is supplied the search term will be interpreted as a
531  python regular expression where the case is ignored.
532 
533  .. versionchanged:: release-03-00-00
534  modified output structure and added ``--human-readable``
535  .. versionchanged:: after release-04-00-00
536  added parameter ``--checksums`` and ``--show-ids``
537  """
538  iovfilter = ItemFilter(args)
539  if db is None:
540  args.add_argument("--full", default=False, action="store_true",
541  help="If given print all iovs, also those which are the same in both tags")
542  args.add_argument("--run", type=int, nargs=2, metavar="N", help="exp and run numbers "
543  "to limit showing iovs to a ones present in a given run")
544  args.add_argument("--human-readable", default=False, action="store_true",
545  help="If given the iovs will be written in a more human friendly format. "
546  "Also repeated payload names will be omitted to create a more readable listing.")
547  args.add_argument("--checksums", default=False, action="store_true",
548  help="If given don't show the revision number but the md5 checksum")
549  args.add_argument("--show-ids", default=False, action="store_true",
550  help="If given also show the payload and iov ids for each iov")
551 
552  args.add_argument("tagA", metavar="TAGNAME1", help="base for comparison")
553  args.add_argument("tagB", metavar="TAGNAME2", help="tagname to compare")
554  args.add_argument("--run-range", nargs=4, default=None, type=int,
555  metavar=("FIRST_EXP", "FIRST_RUN", "FINAL_EXP", "FINAL_RUN"),
556  help="Can be four numbers to limit the run range to be compared"
557  "Only iovs overlapping, even partially, with this range will be considered.")
558  iovfilter.add_arguments("payloads")
559  return
560 
561  # check arguments
562  if not iovfilter.check_arguments():
563  return 1
564 
565  with Pager(f"Differences between globaltags {args.tagA} and {args.tagB}{iovfilter}", True):
566  print("globaltags to be compared:")
567  ntags = print_globaltag(db, args.tagA, args.tagB)
568  if ntags != 2:
569  return 1
570  print()
571  listA = [
572  e for e in db.get_all_iovs(
573  args.tagA,
574  message=str(iovfilter),
575  run_range=args.run_range) if iovfilter.check(
576  e.name)]
577  listB = [
578  e for e in db.get_all_iovs(
579  args.tagB,
580  message=str(iovfilter),
581  run_range=args.run_range) if iovfilter.check(
582  e.name)]
583 
584  B2INFO("Comparing contents ...")
585  diff = difflib.SequenceMatcher(a=listA, b=listB)
586  table = [["", "Name", "Rev" if not args.checksums else "Checksum"]]
587  columns = [1, "+", -8 if not args.checksums else -32]
588 
589  if args.human_readable:
590  table[0] += ["Iov"]
591  columns += [-36]
592  else:
593  table[0] += ["First Exp", "First Run", "Final Exp", "Final Run"]
594  columns += [6, 6, 6, 6]
595 
596  if args.show_ids:
597  table[0] += ["IovId", "PayloadId"]
598  columns += [7, 9]
599 
600  def add_payloads(opcode, payloads):
601  """Add a list of payloads to the table, filling the first column with opcode"""
602  for p in payloads:
603  row = [opcode, p.name, p.revision if not args.checksums else p.checksum]
604  if args.human_readable:
605  row += [p.readable_iov()]
606  else:
607  row += list(p.iov)
608 
609  if args.show_ids:
610  row += [p.iov_id, p.payload_id]
611  table.append(row)
612 
613  for tag, i1, i2, j1, j2 in diff.get_opcodes():
614  if tag == "equal":
615  if not args.full:
616  continue
617  add_payloads(" ", listB[j1:j2])
618  if tag in ["delete", "replace"]:
619  add_payloads("-", listA[i1:i2])
620  if tag in ["insert", "replace"]:
621  add_payloads("+", listB[j1:j2])
622 
623  if args.human_readable:
624  # strip repeated names, revision, payloadid, to make it more readable.
625  # this is dependent on the fact that the opcode is still the same but we
626  # don't want to strip the opcode ...
627  remove_repeated_values(table, [0, 1, 2] + ([-1] if args.show_ids else []), keep=[0])
628 
629  def color_row(row, widths, line):
630  if not LogPythonInterface.terminal_supports_colors():
631  return line
632  begin = {'+': '\x1b[32m', '-': '\x1b[31m'}.get(row[0], "")
633  end = '\x1b[0m'
634  return begin + line + end
635 
636  # print the table but make sure the first column is empty except for
637  # added/removed lines so that it can be copy-pasted into a diff syntax
638  # highlighting area (say pull request description)
639  print(f" Differences between {args.tagA} and {args.tagB}")
640  pretty_print_table(table, columns, transform=color_row,
641  hline_formatter=lambda w: " " + (w - 1) * '-')
642 
643 
644 def command_iov(args, db):
645  """
646  List all IoVs defined in a globaltag, optionally limited to a run range
647 
648  This command lists all IoVs defined in a given globaltag. The list can be
649  limited to a given run and optionally searched using --filter or --exclude.
650  If the --regex option is supplied the search term will be interpreted as a
651  python regular expression where the case is ignored.
652 
653  .. versionchanged:: release-03-00-00
654  modified output structure and added ``--human-readable``
655  .. versionchanged:: after release-04-00-00
656  added parameter ``--checksums`` and ``--show-ids``
657  .. versionchanged:: after release-08-00-04
658  added parameter ``--run-range``
659  """
660 
661  iovfilter = ItemFilter(args)
662 
663  if db is None:
664  args.add_argument("tag", metavar="TAGNAME", help="globaltag for which the the IoVs should be listed")
665  args.add_argument("--run", type=int, nargs=2, metavar="N", help="exp and run numbers "
666  "to limit showing iovs to a ones present in a given run")
667  args.add_argument("--detail", action="store_true", default=False,
668  help="if given show a detailed information for all "
669  "IoVs including details of the payloads")
670  args.add_argument("--human-readable", default=False, action="store_true",
671  help="If given the iovs will be written in a more human friendly format. "
672  "Also repeated payload names will be omitted to create a more readable listing.")
673  args.add_argument("--checksums", default=False, action="store_true",
674  help="If given don't show the revision number but the md5 checksum")
675  args.add_argument("--show-ids", default=False, action="store_true",
676  help="If given also show the payload and iov ids for each iov")
677  args.add_argument("--run-range", nargs=4, default=None, type=int,
678  metavar=("FIRST_EXP", "FIRST_RUN", "FINAL_EXP", "FINAL_RUN"),
679  help="Can be four numbers to limit the run range to be shown"
680  "Only iovs overlapping, even partially, with this range will be shown.")
681  iovfilter.add_arguments("payloads")
682  return
683 
684  # check arguments
685  if not iovfilter.check_arguments():
686  return 1
687 
688  # Check if the globaltag exists otherwise I get the same result for an emply global tag or for a non-existing one
689  if db.get_globalTagInfo(args.tag) is None:
690  B2ERROR(f"Globaltag '{args.tag}' doesn't exist.")
691  return False
692 
693  run_range_str = f' valid in {tuple(args.run_range)}' if args.run_range else ''
694  args.run_range = IntervalOfValidity(args.run_range) if args.run_range else None
695 
696  if args.run is not None:
697  msg = "Obtaining list of iovs for globaltag {tag}, exp={exp}, run={run}{filter}".format(
698  tag=args.tag, exp=args.run[0], run=args.run[1], filter=iovfilter)
699  req = db.request("GET", "/iovPayloads", msg, params={'gtName': args.tag, 'expNumber': args.run[0],
700  'runNumber': args.run[1]})
701  else:
702  msg = f"Obtaining list of iovs for globaltag {args.tag}{iovfilter}{run_range_str}"
703  req = db.request("GET", f"/globalTag/{encode_name(args.tag)}/globalTagPayloads", msg)
704 
705  with Pager(f"List of IoVs{iovfilter}{run_range_str}{' (detailed)' if args.detail else ''}", True):
706  payloads = []
707  for item in req.json():
708  payload = item["payload" if 'payload' in item else "payloadId"]
709  if "payloadIov" in item:
710  iovs = [item['payloadIov']]
711  else:
712  iovs = item['payloadIovs']
713 
714  if not iovfilter.check(payload['basf2Module']['name']):
715  continue
716 
717  for iov in iovs:
718  if args.run_range is not None:
719  if IntervalOfValidity(
720  iov['expStart'], iov['runStart'], iov['expEnd'], iov['runEnd']
721  ).intersect(args.run_range) is None:
722  continue
723 
724  if args.detail:
725  # detailed mode, show a table with all information for each
726  # iov
727  iov_created = parse_date(iov["dtmIns"])
728  iov_modified = parse_date(iov["dtmMod"])
729  payload_created = parse_date(payload["dtmIns"])
730  payload_modified = parse_date(payload["dtmMod"])
731  result = [
732  ["IoV Id", str(iov["payloadIovId"])],
733  ["first experiment", iov["expStart"]],
734  ["first run", iov["runStart"]],
735  ["final experiment", iov["expEnd"]],
736  ["final run", iov["runEnd"]],
737  ["IoV created", iov_created.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
738  ["IoV modified", iov_modified.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
739  ["IoV modified by", iov["modifiedBy"]],
740  ["payload Id", str(payload["payloadId"])],
741  ["name", payload["basf2Module"]["name"]],
742  ["revision", payload["revision"]],
743  ["checksum", payload["checksum"]],
744  ["payloadUrl", payload["payloadUrl"]],
745  ["baseUrl", payload.get("baseUrl", "")],
746  # print created and modified timestamps in local time zone
747  ["payload created", payload_created.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
748  ["payload modified", payload_modified.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
749  ["payload modified by", escape_ctrl_chars(payload["modifiedBy"])],
750  ]
751  print()
752  pretty_print_table(result, [-40, '*'], True)
753  else:
754  payloads.append(PayloadInformation.from_json(payload, iov))
755 
756  if not args.detail:
757  def add_ids(table, columns, payloads):
758  """Add the numerical ids to the table"""
759  if args.show_ids:
760  table[0] += ["IovId", "PayloadId"]
761  columns += [9, 9]
762  for row, p in zip(table[1:], payloads):
763  row += [p.iov_id, p.payload_id]
764  payloads.sort()
765  if args.human_readable:
766  table = [["Name", "Rev" if not args.checksums else "Checksum", "IoV"]]
767  columns = ["+", -8 if not args.checksums else -32, -32]
768  table += [[p.name, p.revision if not args.checksums else p.checksum, p.readable_iov()] for p in payloads]
769  add_ids(table, columns, payloads)
770  # strip repeated names, revision, payloadid, to make it more readable
771  remove_repeated_values(table, columns=[0, 1] + ([-1] if args.show_ids else []))
772 
773  else:
774  table = [["Name", "Rev" if not args.checksums else "Checksum", "First Exp", "First Run", "Final Exp", "Final Run"]]
775  table += [[p.name, p.revision if not args.checksums else p.checksum] + list(p.iov) for p in payloads]
776  columns = ["+", -8 if not args.checksums else -32, 6, 6, 6, 6]
777  add_ids(table, columns, payloads)
778 
779  pretty_print_table(table, columns)
780 
781 
782 def command_dump(args, db):
783  """
784  Dump the content of a given payload
785 
786  .. versionadded:: release-03-00-00
787 
788  This command will dump the payload contents stored in a given payload. One
789  can either specify the payloadId (from a previous output of
790  ``b2conditionsdb iov``), the payload name and its revision in the central
791  database, or directly specify a local database payload file.
792 
793  .. rubric:: Examples
794 
795  Dump the content of a previously downloaded payload file:
796 
797  $ b2conditionsdb dump -f centraldb/dbstore_BeamParameters_rev_59449.root
798 
799  Dump the content of a payload by name and revision directly from the central database:
800 
801  $ b2conditionsdb dump -r BeamParameters 59449
802 
803  Dump the content of the payload by name which is valid in a given globaltag
804  for a given experiment and run::
805 
806  $ b2conditionsdb dump -g BeamParameters main_2021-08-04 0 0
807 
808  Or directly by payload id from a previous call to ``b2conditionsdb iov``:
809 
810  $ b2conditionsdb dump -i 59685
811 
812  .. rubric:: Usage
813 
814  Depending on whether you want to display a payload by its id in the
815  database, its name and revision in the database or from a local file
816  provide **one** of the arguments ``-i``, ``-r``, ``-f`` or ``-g``
817 
818  .. versionchanged:: after release-04-00-00
819  added argument ``-r`` to directly dump a payload valid for a given run
820  in a given globaltag
821  """
822  if db is None:
823  group = args.add_mutually_exclusive_group(required=True)
824  choice = group.add_mutually_exclusive_group()
825  choice.add_argument("-i", "--id", metavar="PAYLOADID", help="payload id to dump")
826  choice.add_argument("-r", "--revision", metavar=("NAME", "REVISION"), nargs=2,
827  help="Name and revision of the payload to dump")
828  choice.add_argument("-f", "--file", metavar="FILENAME", help="Dump local payload file")
829  choice.add_argument("-g", "--valid", metavar=("NAME", "GLOBALTAG", "EXP", "RUN"), nargs=4,
830  help="Dump the payload valid for the given exp, run number in the given globaltag")
831  args.add_argument("--show-typenames", default=False, action="store_true",
832  help="If given show the type names of all classes. "
833  "This makes output more crowded but can be helpful for complex objects.")
834  args.add_argument("--show-streamerinfo", default=False, action="store_true",
835  help="If given show the StreamerInfo for the classes in the the payload file. "
836  "This can be helpful to find out which version of a payload object "
837  "is included and what are the members")
838 
839  return
840 
841  payload = None
842  # local file, don't query database at all
843  if args.file:
844  filename = args.file
845  if not os.path.isfile(filename):
846  B2ERROR(f"Payloadfile {filename} could not be found")
847  return 1
848 
849  match = re.match(r"^dbstore_(.*)_rev_(.*).root$", os.path.basename(filename))
850  if not match:
851  match = re.match(r"^(.*)_r(.*).root$", os.path.basename(filename))
852  if not match:
853  B2ERROR("Filename doesn't follow database convention.\n"
854  "Should be 'dbstore_${payloadname}_rev_${revision}.root' or '${payloadname}_r${revision.root}'")
855  return 1
856  name = match.group(1)
857  revision = match.group(2)
858  payloadId = "Unknown"
859  else:
860  # otherwise do just that: query the database for either payload id or
861  # the name,revision
862  if args.id:
863  req = db.request("GET", f"/payload/{args.id}", "Getting payload info")
864  payload = PayloadInformation.from_json(req.json())
865  name = payload.name
866  elif args.revision:
867  name, rev = args.revision
868  rev = int(rev)
869  req = db.request("GET", f"/module/{encode_name(name)}/payloads", "Getting payload info")
870  for p in req.json():
871  if p["revision"] == rev:
872  payload = PayloadInformation.from_json(p)
873  break
874  else:
875  B2ERROR(f"Cannot find payload {name} with revision {rev}")
876  return 1
877  elif args.valid:
878  name, globaltag, exp, run = args.valid
879  payload = None
880  for p in db.get_all_iovs(globaltag, exp, run, f", name={name}"):
881  if p.name == name and (payload is None or p.revision > payload.revision):
882  payload = p
883 
884  if payload is None:
885  B2ERROR(f"Cannot find payload {name} in globaltag {globaltag} for exp,run {exp},{run}")
886  return 1
887 
888  filename = payload.url
889  revision = payload.revision
890  payloadId = payload.payload_id
891  del payload
892 
893  # late import of ROOT because of all the side effects
894  from ROOT import TFile, TBufferJSON, cout
895 
896  # remote http opening or local file
897  tfile = TFile.Open(filename)
898  json_str = None
899  raw_contents = None
900  if not tfile or not tfile.IsOpen():
901  # could be a non-root payload file
902  contents = db._session.get(filename, stream=True)
903  if contents.status_code != requests.codes.ok:
904  B2ERROR(f"Could not open payload file {filename}")
905  return 1
906  raw_contents = contents.raw.read().decode()
907  else:
908  obj = tfile.Get(name)
909  if obj:
910  json_str = TBufferJSON.ConvertToJSON(obj)
911 
912  def drop_fbits(obj):
913  """
914  Drop some members from ROOT json output.
915 
916  We do not care about fBits, fUniqueID or the typename of sub classes,
917  we assume users are only interested in the data stored in the member
918  variables
919  """
920  obj.pop("fBits", None)
921  obj.pop("fUniqueID", None)
922  if not args.show_typenames:
923  obj.pop("_typename", None)
924  return obj
925 
926  with Pager(f"Contents of Payload {name}, revision {revision} (id {payloadId})", True):
927  if args.show_streamerinfo and tfile:
928  B2INFO("StreamerInfo of Payload {name}, revision {revision} (id {payloadId})")
929  tfile.ShowStreamerInfo()
930  # sadly this prints to std::cout or even stdout but doesn't flush ... so we have
931  # to make sure std::cout is flushed before printing anything else
932  cout.flush()
933  # and add a newline
934  print()
935 
936  if json_str is not None:
937  B2INFO(f"Contents of Payload {name}, revision {revision} (id {payloadId})")
938  # load the json as python object dropping some things we don't want to
939  # print
940  obj = json.loads(json_str.Data(), object_hook=drop_fbits)
941  # print the object content using pretty print with a certain width
942  pprint.pprint(obj, compact=True, width=shutil.get_terminal_size((80, 20))[0])
943  elif raw_contents:
944  B2INFO(f"Raw contents of Payload {name}, revision {revision} (id {payloadId})")
945  print(escape_ctrl_chars(raw_contents))
946  elif tfile:
947  B2INFO(f"ROOT contents of Payload {name}, revision {revision} (id {payloadId})")
948  B2WARNING("The payload is a valid ROOT file but doesn't contain a payload object with the expected name. "
949  " Automated display of file contents are not possible, showing just entries in the ROOT file.")
950  tfile.ls()
951 
952 
953 class FullHelpAction(argparse._HelpAction):
954  """Class to recursively show help for an ArgumentParser and all it's sub_parsers"""
955 
956  def print_subparsers(self, parser, prefix=""):
957  """Print help message for given parser and call again for all sub parsers"""
958  # retrieve subparsers from parser
959  subparsers_actions = [
960  action for action in parser._actions
961  if isinstance(action, argparse._SubParsersAction)]
962  # there will probably only be one subparser_action,
963  # but better save than sorry
964  for subparsers_action in subparsers_actions:
965  # get all subparsers and print help
966  for choice, subparser in subparsers_action.choices.items():
967  print()
968  print(f"Command '{prefix}{choice}'")
969  print(subparser.format_help())
970 
971  self.print_subparsersprint_subparsers(subparser, prefix=f"{prefix}{choice} ")
972 
973  def __call__(self, parser, namespace, values, option_string=None):
974  """Show full help message"""
975  # run in pager because amount of options will be looong
976  with Pager(f"{parser.prog} {option_string}"):
977  parser.print_help()
978  self.print_subparsersprint_subparsers(parser)
979  parser.exit()
980 
981 
982 def get_argument_parser():
983  """
984  Build a parser with all arguments of all commands
985  """
986  # extra ArgumentParser with the global options just for reusability
987  options = argparse.ArgumentParser(add_help=False)
988  options.add_argument("--debugging", action="store_true",
989  help="Enable debugging of http traffic")
990  options.add_argument("--help-full", action=FullHelpAction,
991  help="show help message for all commands and exit")
992  options.add_argument("--base-url", default=None,
993  help="URI for the base of the REST API, if not given a list of default locations is tried")
994  options.add_argument("--auth-token", type=str, default=None,
995  help="JSON Web Token necessary for authenticating to the conditions database. "
996  "Useful only for debugging, since by default the tool automatically "
997  "gets a token for you by asking the B2MMS username and password. "
998  "If the environment variable ``$BELLE2_CDB_AUTH_TOKEN`` points to a file with a valid "
999  "token, such token is used (useful for automatic workflows).")
1000 
1001  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, parents=[options])
1002  parser.set_defaults(func=lambda x, y: parser.print_help())
1003  parsers = parser.add_subparsers(
1004  title="Top level commands",
1005  description="To get additional help, run '%(prog)s COMMAND --help'"
1006  )
1007 
1008  subparsers = {}
1009  # now we go through all the functions defined which start with command_
1010  for name, func in sorted(globals().items()):
1011  if not name.startswith("command_"):
1012  continue
1013  # we interpret command_foo_bar_baz as subcommand baz of subcommand bar
1014  # of subcommand foo. So let's split this into all commands and remove
1015  # the command_
1016  parts = name.split('_')[1:]
1017  # now we need to get the subparsers instance for the parent command. if
1018  # the command is top level, e.g. foo, we just use parsers. Otherwise we
1019  # go look into the dict of subparsers for command chains.
1020  parent = parsers
1021  if(len(parts) > 1):
1022  parent_parser, parent = subparsers[tuple(parts[:-1])]
1023  # if we are the first subcommand to a given command we have to add
1024  # the subparsers. do that and add it back to the dict
1025  if parent is None:
1026  parent = parent_parser.add_subparsers(
1027  title="sub commands",
1028  description="To get additional help, run '%(prog)s COMMAND --help'"
1029  )
1030  subparsers[tuple(parts[:-1])][1] = parent
1031  # so we have our subparsers instance, now create argument parser for the
1032  # function. We use the first part of the function docstring as help text
1033  # and everything after the first empty line as description of the
1034  # command
1035  helptxt, description = textwrap.dedent(func.__doc__).split("\n\n", 1)
1036  command_parser = parent.add_parser(parts[-1], help=helptxt, add_help=True, description=description,
1037  parents=[options], formatter_class=argparse.RawDescriptionHelpFormatter)
1038  # now call the function with the parser as first argument and no
1039  # database instance. This let's them define their own arguments
1040  func(command_parser, None)
1041  # and set the function as default to be called for later
1042  command_parser.set_defaults(func=func)
1043  # also add it back to the list of subparsers
1044  subparsers[tuple(parts)] = [command_parser, None]
1045 
1046  return parser
1047 
1048 
1049 def create_symlinks(base):
1050  """Create symlinks from base to all subcommands.
1051 
1052  e.g. if the base is ``b2conditionsdb`` then this command will create symlinks
1053  like ``b2conditionsdb-tag-show`` in the same directory
1054 
1055  When adding a new command to b2conditionsdb this function needs to be executed
1056  in the framework tools directory
1057 
1058  python3 -c 'from conditions_db import cli_main; cli_main.create_symlinks("b2conditionsdb")'
1059  """
1060  import os
1061  excluded = [
1062  ['tag'] # the tag command without subcommand is not very useful
1063  ]
1064  for name in sorted(globals().keys()):
1065  if not name.startswith("command_"):
1066  continue
1067  parts = name.split("_")[1:]
1068  if parts in excluded:
1069  continue
1070  dest = base + "-".join([""] + parts)
1071 
1072  try:
1073  os.remove(dest)
1074  except FileNotFoundError:
1075  pass
1076  print(f"create symlink {dest}")
1077  os.symlink(base, dest)
1078 
1079 
1080 def main():
1081  """
1082  Main function for the command line interface.
1083 
1084  it will automatically create an ArgumentParser including all functions which
1085  start with command_ in the global namespace as sub commands. These
1086  functions should take the arguments as first argument and an instance of the
1087  ConditionsDB interface as second argument. If the db interface is None the
1088  first argument is an instance of argparse.ArgumentParser an in this case the
1089  function should just add all needed arguments to the argument parser and
1090  return.
1091  """
1092 
1093  # disable error summary
1094  logging.enable_summary(False)
1095  # log via python stdout to be able to capture
1096  logging.enable_python_logging = True
1097  # modify logging to remove the useless module: lines
1098  for level in LogLevel.values.values():
1099  logging.set_info(level, LogInfo.LEVEL | LogInfo.MESSAGE)
1100 
1101  # Ok, some people prefer `-` in the executable name for tab completion so lets
1102  # support that by just splitting the executable name
1103  sys.argv[0:1] = os.path.basename(sys.argv[0]).split('-')
1104 
1105  # parse argument definition for all sub commands
1106  parser = get_argument_parser()
1107 
1108  # done, all functions parsed. Create the database instance and call the
1109  # correct subfunction according to the selected argument
1110  args = parser.parse_args()
1111 
1112  if args.debugging:
1113  enable_debugging()
1114 
1115  # manage some common options for up and downloading. slightly hacky but
1116  # need to be given to ConditionsDB on construction so meh
1117  nprocess = getattr(args, "nprocess", 1)
1118  retries = getattr(args, "retries", 0)
1119  # need at least one worker thread
1120  if nprocess <= 0:
1121  B2WARNING("-j must be larger than zero, ignoring")
1122  args.nprocess = nprocess = 1
1123 
1124  conditions_db = ConditionsDB(args.base_url, nprocess, retries)
1125 
1126  try:
1127  return args.func(args, conditions_db)
1128  except ConditionsDB.RequestError as e:
1129  B2ERROR(str(e))
1130  return 1
def print_subparsers(self, parser, prefix="")
Definition: cli_main.py:956
def __call__(self, parser, namespace, values, option_string=None)
Definition: cli_main.py:973
int intersect(const TRGCDCLpar &lp1, const TRGCDCLpar &lp2, CLHEP::HepVector &v1, CLHEP::HepVector &v2)
intersection
Definition: Lpar.cc:249
int main(int argc, char **argv)
Run all tests.
Definition: test_main.cc:91