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