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