Belle II Software  light-2212-foldex
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 remove_repeated_values(table, columns, keep=None):
408  """Strip repeated values from a table of values
409 
410  This function takes a table (a list of lists with all the same length) and
411  removes values in certain columns if they are identical in consecutive rows.
412 
413  It does this in a dependent way only if the previous columns are identical
414  will it continue stripping further columns. For example, given the table ::
415 
416  table = [
417  ["A", "a"],
418  ["B", "a"],
419  ["B", "a"],
420  ["B", "b"],
421  ]
422 
423  If we want to remove duplicates in all columns in order it would look like this:
424 
425  >>> remove_repeated_values(table, [0,1])
426  [
427  ["A", "a"],
428  ["B", "a"],
429  [ "", ""],
430  [ "", "b"],
431  ]
432 
433  But we can give selected columns to strip one after the other
434 
435  >>> remove_repeated_values(table, [1,0])
436  [
437  ["A", "a"],
438  ["B", ""],
439  [ "", ""],
440  ["B", "b"],
441  ]
442 
443  In addition, we might want to only strip some columns if previous columns
444  were identical but keep the values of the previous column. For this one can
445  supply ``keep``:
446 
447  >>> remove_repeated_values(table, [0,1,2], keep=[0])
448  [
449  ["A", "a"],
450  ["B", "a"],
451  ["B", ""],
452  ["B", "b"],
453  ]
454 
455  Parameters:
456  table (list(list(str))): 2d table of values
457  columns (list(int)): indices of columns to consider in order
458  keep (set(int)): indices of columns to not strip
459  """
460  last_values = [None] * len(columns)
461  for row in table[1:]:
462  current_values = [row[i] for i in columns]
463  for i, curr, last in zip(columns, current_values, last_values):
464  if curr != last:
465  break
466 
467  if keep and i in keep:
468  continue
469 
470  row[i] = ""
471 
472  last_values = current_values
473 
474 
475 def command_diff(args, db):
476  """Compare two globaltags
477 
478  This command allows to compare two globaltags. It will show the changes in
479  a format similar to a unified diff but by default it will not show any
480  context, only the new or removed payloads. Added payloads are marked with a
481  ``+`` in the first column, removed payloads with a ``-``
482 
483  If ``--full`` is given it will show all payloads, even the ones common to
484  both globaltags. The differences can be limited to a given run and
485  limited to a set of payloads names using ``--filter`` or ``--exclude``. If
486  the ``--regex`` option is supplied the search term will be interpreted as a
487  python regular expression where the case is ignored.
488 
489  .. versionchanged:: release-03-00-00
490  modified output structure and added ``--human-readable``
491  .. versionchanged:: after release-04-00-00
492  added parameter ``--checksums`` and ``--show-ids``
493  """
494  iovfilter = ItemFilter(args)
495  if db is None:
496  args.add_argument("--full", default=False, action="store_true",
497  help="If given print all iovs, also those which are the same in both tags")
498  args.add_argument("--run", type=int, nargs=2, metavar="N", help="exp and run numbers "
499  "to limit showing iovs to a ones present in a given run")
500  args.add_argument("--human-readable", default=False, action="store_true",
501  help="If given the iovs will be written in a more human friendly format. "
502  "Also repeated payload names will be omitted to create a more readable listing.")
503  args.add_argument("--checksums", default=False, action="store_true",
504  help="If given don't show the revision number but the md5 checksum")
505  args.add_argument("--show-ids", default=False, action="store_true",
506  help="If given also show the payload and iov ids for each iov")
507 
508  args.add_argument("tagA", metavar="TAGNAME1", help="base for comparison")
509  args.add_argument("tagB", metavar="TAGNAME2", help="tagname to compare")
510  iovfilter.add_arguments("payloads")
511  return
512 
513  # check arguments
514  if not iovfilter.check_arguments():
515  return 1
516 
517  with Pager(f"Differences between globaltags {args.tagA} and {args.tagB}{iovfilter}", True):
518  print("globaltags to be compared:")
519  ntags = print_globaltag(db, args.tagA, args.tagB)
520  if ntags != 2:
521  return 1
522  print()
523  listA = [e for e in db.get_all_iovs(args.tagA, message=str(iovfilter)) if iovfilter.check(e.name)]
524  listB = [e for e in db.get_all_iovs(args.tagB, message=str(iovfilter)) if iovfilter.check(e.name)]
525 
526  B2INFO("Comparing contents ...")
527  diff = difflib.SequenceMatcher(a=listA, b=listB)
528  table = [["", "Name", "Rev" if not args.checksums else "Checksum"]]
529  columns = [1, "+", -8 if not args.checksums else -32]
530 
531  if args.human_readable:
532  table[0] += ["Iov"]
533  columns += [-36]
534  else:
535  table[0] += ["First Exp", "First Run", "Final Exp", "Final Run"]
536  columns += [6, 6, 6, 6]
537 
538  if args.show_ids:
539  table[0] += ["IovId", "PayloadId"]
540  columns += [7, 9]
541 
542  def add_payloads(opcode, payloads):
543  """Add a list of payloads to the table, filling the first column with opcode"""
544  for p in payloads:
545  row = [opcode, p.name, p.revision if not args.checksums else p.checksum]
546  if args.human_readable:
547  row += [p.readable_iov()]
548  else:
549  row += list(p.iov)
550 
551  if args.show_ids:
552  row += [p.iov_id, p.payload_id]
553  table.append(row)
554 
555  for tag, i1, i2, j1, j2 in diff.get_opcodes():
556  if tag == "equal":
557  if not args.full:
558  continue
559  add_payloads(" ", listB[j1:j2])
560  if tag in ["delete", "replace"]:
561  add_payloads("-", listA[i1:i2])
562  if tag in ["insert", "replace"]:
563  add_payloads("+", listB[j1:j2])
564 
565  if args.human_readable:
566  # strip repeated names, revision, payloadid, to make it more readable.
567  # this is dependent on the fact that the opcode is still the same but we
568  # don't want to strip the opcode ...
569  remove_repeated_values(table, [0, 1, 2] + ([-1] if args.show_ids else []), keep=[0])
570 
571  def color_row(row, widths, line):
572  if not LogPythonInterface.terminal_supports_colors():
573  return line
574  begin = {'+': '\x1b[32m', '-': '\x1b[31m'}.get(row[0], "")
575  end = '\x1b[0m'
576  return begin + line + end
577 
578  # print the table but make sure the first column is empty except for
579  # added/removed lines so that it can be copy-pasted into a diff syntax
580  # highlighting area (say pull request description)
581  print(f" Differences between {args.tagA} and {args.tagB}")
582  pretty_print_table(table, columns, transform=color_row,
583  hline_formatter=lambda w: " " + (w - 1) * '-')
584 
585 
586 def command_iov(args, db):
587  """
588  List all IoVs defined in a globaltag, optionally limited to a run range
589 
590  This command lists all IoVs defined in a given globaltag. The list can be
591  limited to a given run and optionally searched using --filter or --exclude.
592  If the --regex option is supplied the search term will be interpreted as a
593  python regular expression where the case is ignored.
594 
595  .. versionchanged:: release-03-00-00
596  modified output structure and added ``--human-readable``
597  .. versionchanged:: after release-04-00-00
598  added parameter ``--checksums`` and ``--show-ids``
599  """
600 
601  iovfilter = ItemFilter(args)
602 
603  if db is None:
604  args.add_argument("tag", metavar="TAGNAME", help="globaltag for which the the IoVs should be listed")
605  args.add_argument("--run", type=int, nargs=2, metavar="N", help="exp and run numbers "
606  "to limit showing iovs to a ones present in a given run")
607  args.add_argument("--detail", action="store_true", default=False,
608  help="if given show a detailed information for all "
609  "IoVs including details of the payloads")
610  args.add_argument("--human-readable", default=False, action="store_true",
611  help="If given the iovs will be written in a more human friendly format. "
612  "Also repeated payload names will be omitted to create a more readable listing.")
613  args.add_argument("--checksums", default=False, action="store_true",
614  help="If given don't show the revision number but the md5 checksum")
615  args.add_argument("--show-ids", default=False, action="store_true",
616  help="If given also show the payload and iov ids for each iov")
617  iovfilter.add_arguments("payloads")
618  return
619 
620  # check arguments
621  if not iovfilter.check_arguments():
622  return 1
623 
624  if args.run is not None:
625  msg = "Obtaining list of iovs for globaltag {tag}, exp={exp}, run={run}{filter}".format(
626  tag=args.tag, exp=args.run[0], run=args.run[1], filter=iovfilter)
627  req = db.request("GET", "/iovPayloads", msg, params={'gtName': args.tag, 'expNumber': args.run[0],
628  'runNumber': args.run[1]})
629  else:
630  msg = f"Obtaining list of iovs for globaltag {args.tag}{iovfilter}"
631  req = db.request("GET", "/globalTag/{}/globalTagPayloads".format(encode_name(args.tag)), msg)
632 
633  with Pager("List of IoVs{}{}".format(iovfilter, " (detailed)" if args.detail else ""), True):
634  payloads = []
635  for item in req.json():
636  payload = item["payload" if 'payload' in item else "payloadId"]
637  if "payloadIov" in item:
638  iovs = [item['payloadIov']]
639  else:
640  iovs = item['payloadIovs']
641 
642  if not iovfilter.check(payload['basf2Module']['name']):
643  continue
644 
645  for iov in iovs:
646  if args.detail:
647  # detailed mode, show a table with all information for each
648  # iov
649  iov_created = parse_date(iov["dtmIns"])
650  iov_modified = parse_date(iov["dtmMod"])
651  payload_created = parse_date(payload["dtmIns"])
652  payload_modified = parse_date(payload["dtmMod"])
653  result = [
654  ["IoV Id", str(iov["payloadIovId"])],
655  ["first experiment", iov["expStart"]],
656  ["first run", iov["runStart"]],
657  ["final experiment", iov["expEnd"]],
658  ["final run", iov["runEnd"]],
659  ["IoV created", iov_created.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
660  ["IoV modified", iov_modified.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
661  ["IoV modified by", iov["modifiedBy"]],
662  ["payload Id", str(payload["payloadId"])],
663  ["name", payload["basf2Module"]["name"]],
664  ["revision", payload["revision"]],
665  ["checksum", payload["checksum"]],
666  ["payloadUrl", payload["payloadUrl"]],
667  ["baseUrl", payload.get("baseUrl", "")],
668  # print created and modified timestamps in local time zone
669  ["payload created", payload_created.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
670  ["payload modified", payload_modified.astimezone(tz=None).strftime("%Y-%m-%d %H:%M:%S local time")],
671  ["payload modified by", escape_ctrl_chars(payload["modifiedBy"])],
672  ]
673  print()
674  pretty_print_table(result, [-40, '*'], True)
675  else:
676  payloads.append(PayloadInformation.from_json(payload, iov))
677 
678  if not args.detail:
679  def add_ids(table, columns, payloads):
680  """Add the numerical ids to the table"""
681  if args.show_ids:
682  table[0] += ["IovId", "PayloadId"]
683  columns += [7, 9]
684  for row, p in zip(table[1:], payloads):
685  row += [p.iov_id, p.payload_id]
686  payloads.sort()
687  if args.human_readable:
688  table = [["Name", "Rev" if not args.checksums else "Checksum", "IoV"]]
689  columns = ["+", -8 if not args.checksums else -32, -32]
690  table += [[p.name, p.revision if not args.checksums else p.checksum, p.readable_iov()] for p in payloads]
691  add_ids(table, columns, payloads)
692  # strip repeated names, revision, payloadid, to make it more readable
693  remove_repeated_values(table, columns=[0, 1] + ([-1] if args.show_ids else []))
694 
695  else:
696  table = [["Name", "Rev" if not args.checksums else "Checksum", "First Exp", "First Run", "Final Exp", "Final Run"]]
697  table += [[p.name, p.revision if not args.checksums else p.checksum] + list(p.iov) for p in payloads]
698  columns = ["+", -8 if not args.checksums else -32, 6, 6, 6, 6]
699  add_ids(table, columns, payloads)
700 
701  pretty_print_table(table, columns)
702 
703 
704 def command_dump(args, db):
705  """
706  Dump the content of a given payload
707 
708  .. versionadded:: release-03-00-00
709 
710  This command will dump the payload contents stored in a given payload. One
711  can either specify the payloadId (from a previous output of
712  ``b2conditionsdb iov``), the payload name and its revision in the central
713  database, or directly specify a local database payload file.
714 
715  .. rubric:: Examples
716 
717  Dump the content of a previously downloaded payload file:
718 
719  $ b2conditionsdb dump -f centraldb/dbstore_BeamParameters_rev_59449.root
720 
721  Dump the content of a payload by name and revision directly from the central database:
722 
723  $ b2conditionsdb dump -r BeamParameters 59449
724 
725  Dump the content of the payload by name which is valid in a given globaltag
726  for a given experiment and run::
727 
728  $ b2conditionsdb dump -g BeamParameters main_2021-08-04 0 0
729 
730  Or directly by payload id from a previous call to ``b2conditionsdb iov``:
731 
732  $ b2conditionsdb dump -i 59685
733 
734  .. rubric:: Usage
735 
736  Depending on whether you want to display a payload by its id in the
737  database, its name and revision in the database or from a local file
738  provide **one** of the arguments ``-i``, ``-r``, ``-f`` or ``-g``
739 
740  .. versionchanged:: after release-04-00-00
741  added argument ``-r`` to directly dump a payload valid for a given run
742  in a given globaltag
743  """
744  if db is None:
745  group = args.add_mutually_exclusive_group(required=True)
746  choice = group.add_mutually_exclusive_group()
747  choice.add_argument("-i", "--id", metavar="PAYLOADID", help="payload id to dump")
748  choice.add_argument("-r", "--revision", metavar=("NAME", "REVISION"), nargs=2,
749  help="Name and revision of the payload to dump")
750  choice.add_argument("-f", "--file", metavar="FILENAME", help="Dump local payload file")
751  choice.add_argument("-g", "--valid", metavar=("NAME", "GLOBALTAG", "EXP", "RUN"), nargs=4,
752  help="Dump the payload valid for the given exp, run number in the given globaltag")
753  args.add_argument("--show-typenames", default=False, action="store_true",
754  help="If given show the type names of all classes. "
755  "This makes output more crowded but can be helpful for complex objects.")
756  args.add_argument("--show-streamerinfo", default=False, action="store_true",
757  help="If given show the StreamerInfo for the classes in the the payload file. "
758  "This can be helpful to find out which version of a payload object "
759  "is included and what are the members")
760 
761  return
762 
763  payload = None
764  # local file, don't query database at all
765  if args.file:
766  filename = args.file
767  if not os.path.isfile(filename):
768  B2ERROR(f"Payloadfile {filename} could not be found")
769  return 1
770 
771  match = re.match(r"^dbstore_(.*)_rev_(.*).root$", os.path.basename(filename))
772  if not match:
773  match = re.match(r"^(.*)_r(.*).root$", os.path.basename(filename))
774  if not match:
775  B2ERROR("Filename doesn't follow database convention.\n"
776  "Should be 'dbstore_${payloadname}_rev_${revision}.root' or '${payloadname}_r${revision.root}'")
777  return 1
778  name = match.group(1)
779  revision = match.group(2)
780  payloadId = "Unknown"
781  else:
782  # otherwise do just that: query the database for either payload id or
783  # the name,revision
784  if args.id:
785  req = db.request("GET", f"/payload/{args.id}", "Getting payload info")
786  payload = PayloadInformation.from_json(req.json())
787  name = payload.name
788  elif args.revision:
789  name, rev = args.revision
790  rev = int(rev)
791  req = db.request("GET", f"/module/{encode_name(name)}/payloads", "Getting payload info")
792  for p in req.json():
793  if p["revision"] == rev:
794  payload = PayloadInformation.from_json(p)
795  break
796  else:
797  B2ERROR(f"Cannot find payload {name} with revision {rev}")
798  return 1
799  elif args.valid:
800  name, globaltag, exp, run = args.valid
801  payload = None
802  for p in db.get_all_iovs(globaltag, exp, run, f", name={name}"):
803  if p.name == name and (payload is None or p.revision > payload.revision):
804  payload = p
805 
806  if payload is None:
807  B2ERROR(f"Cannot find payload {name} in globaltag {globaltag} for exp,run {exp},{run}")
808  return 1
809 
810  filename = payload.url
811  revision = payload.revision
812  payloadId = payload.payload_id
813  del payload
814 
815  # late import of ROOT because of all the side effects
816  from ROOT import TFile, TBufferJSON, cout
817 
818  # remote http opening or local file
819  tfile = TFile.Open(filename)
820  json_str = None
821  raw_contents = None
822  if not tfile or not tfile.IsOpen():
823  # could be a non-root payload file
824  contents = db._session.get(filename, stream=True)
825  if contents.status_code != requests.codes.ok:
826  B2ERROR(f"Could not open payload file {filename}")
827  return 1
828  raw_contents = contents.raw.read().decode()
829  else:
830  obj = tfile.Get(name)
831  if obj:
832  json_str = TBufferJSON.ConvertToJSON(obj)
833 
834  def drop_fbits(obj):
835  """
836  Drop some members from ROOT json output.
837 
838  We do not care about fBits, fUniqueID or the typename of sub classes,
839  we assume users are only interested in the data stored in the member
840  variables
841  """
842  obj.pop("fBits", None)
843  obj.pop("fUniqueID", None)
844  if not args.show_typenames:
845  obj.pop("_typename", None)
846  return obj
847 
848  with Pager(f"Contents of Payload {name}, revision {revision} (id {payloadId})", True):
849  if args.show_streamerinfo and tfile:
850  B2INFO("StreamerInfo of Payload {name}, revision {revision} (id {payloadId})")
851  tfile.ShowStreamerInfo()
852  # sadly this prints to std::cout or even stdout but doesn't flush ... so we have
853  # to make sure std::cout is flushed before printing anything else
854  cout.flush()
855  # and add a newline
856  print()
857 
858  if json_str is not None:
859  B2INFO(f"Contents of Payload {name}, revision {revision} (id {payloadId})")
860  # load the json as python object dropping some things we don't want to
861  # print
862  obj = json.loads(json_str.Data(), object_hook=drop_fbits)
863  # print the object content using pretty print with a certain width
864  pprint.pprint(obj, compact=True, width=shutil.get_terminal_size((80, 20))[0])
865  elif raw_contents:
866  B2INFO(f"Raw contents of Payload {name}, revision {revision} (id {payloadId})")
867  print(escape_ctrl_chars(raw_contents))
868  elif tfile:
869  B2INFO(f"ROOT contents of Payload {name}, revision {revision} (id {payloadId})")
870  B2WARNING("The payload is a valid ROOT file but doesn't contain a payload object with the expected name. "
871  " Automated display of file contents are not possible, showing just entries in the ROOT file.")
872  tfile.ls()
873 
874 
875 class FullHelpAction(argparse._HelpAction):
876  """Class to recursively show help for an ArgumentParser and all it's sub_parsers"""
877 
878  def print_subparsers(self, parser, prefix=""):
879  """Print help message for given parser and call again for all sub parsers"""
880  # retrieve subparsers from parser
881  subparsers_actions = [
882  action for action in parser._actions
883  if isinstance(action, argparse._SubParsersAction)]
884  # there will probably only be one subparser_action,
885  # but better save than sorry
886  for subparsers_action in subparsers_actions:
887  # get all subparsers and print help
888  for choice, subparser in subparsers_action.choices.items():
889  print()
890  print(f"Command '{prefix}{choice}'")
891  print(subparser.format_help())
892 
893  self.print_subparsersprint_subparsers(subparser, prefix=f"{prefix}{choice} ")
894 
895  def __call__(self, parser, namespace, values, option_string=None):
896  """Show full help message"""
897  # run in pager because amount of options will be looong
898  with Pager(f"{parser.prog} {option_string}"):
899  parser.print_help()
900  self.print_subparsersprint_subparsers(parser)
901  parser.exit()
902 
903 
904 def get_argument_parser():
905  """
906  Build a parser with all arguments of all commands
907  """
908  # extra ArgumentParser with the global options just for reusability
909  options = argparse.ArgumentParser(add_help=False)
910  options.add_argument("--debugging", action="store_true",
911  help="Enable debugging of http traffic")
912  options.add_argument("--help-full", action=FullHelpAction,
913  help="show help message for all commands and exit")
914  options.add_argument("--base-url", default=None,
915  help="URI for the base of the REST API, if not given a list of default locations is tried")
916  options.add_argument("--http-auth", choices=["none", "basic", "digest"], default="basic",
917  help=argparse.SUPPRESS)
918  options.add_argument("--http-user", default="commonDBUser", help=argparse.SUPPRESS)
919  options.add_argument("--http-password", default="Eil9ohphoo2quot", help=argparse.SUPPRESS)
920 
921  parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, parents=[options])
922  parser.set_defaults(func=lambda x, y: parser.print_help())
923  parsers = parser.add_subparsers(
924  title="Top level commands",
925  description="To get additional help, run '%(prog)s COMMAND --help'"
926  )
927 
928  subparsers = {}
929  # now we go through all the functions defined which start with command_
930  for name, func in sorted(globals().items()):
931  if not name.startswith("command_"):
932  continue
933  # we interpret command_foo_bar_baz as subcommand baz of subcommand bar
934  # of subcommand foo. So let's split this into all commands and remove
935  # the command_
936  parts = name.split('_')[1:]
937  # now we need to get the subparsers instance for the parent command. if
938  # the command is top level, e.g. foo, we just use parsers. Otherwise we
939  # go look into the dict of subparsers for command chains.
940  parent = parsers
941  if(len(parts) > 1):
942  parent_parser, parent = subparsers[tuple(parts[:-1])]
943  # if we are the first subcommand to a given command we have to add
944  # the subparsers. do that and add it back to the dict
945  if parent is None:
946  parent = parent_parser.add_subparsers(
947  title="sub commands",
948  description="To get additional help, run '%(prog)s COMMAND --help'"
949  )
950  subparsers[tuple(parts[:-1])][1] = parent
951  # so we have our subparsers instance, now create argument parser for the
952  # function. We use the first part of the function docstring as help text
953  # and everything after the first empty line as description of the
954  # command
955  helptxt, description = textwrap.dedent(func.__doc__).split("\n\n", 1)
956  command_parser = parent.add_parser(parts[-1], help=helptxt, add_help=True, description=description,
957  parents=[options], formatter_class=argparse.RawDescriptionHelpFormatter)
958  # now call the function with the parser as first argument and no
959  # database instance. This let's them define their own arguments
960  func(command_parser, None)
961  # and set the function as default to be called for later
962  command_parser.set_defaults(func=func)
963  # also add it back to the list of subparsers
964  subparsers[tuple(parts)] = [command_parser, None]
965 
966  return parser
967 
968 
969 def create_symlinks(base):
970  """Create symlinks from base to all subcommands.
971 
972  e.g. if the base is ``b2conditionsdb`` then this command will create symlinks
973  like ``b2conditionsdb-tag-show`` in the same directory
974 
975  When adding a new command to b2conditionsdb this function needs to be executed
976  in the framework tools directory
977 
978  python3 -c 'from conditions_db import cli_main; cli_main.create_symlinks("b2conditionsdb")'
979  """
980  import os
981  excluded = [
982  ['tag'] # the tag command without subcommand is not very useful
983  ]
984  for name in sorted(globals().keys()):
985  if not name.startswith("command_"):
986  continue
987  parts = name.split("_")[1:]
988  if parts in excluded:
989  continue
990  dest = base + "-".join([""] + parts)
991 
992  try:
993  os.remove(dest)
994  except FileNotFoundError:
995  pass
996  print(f"create symlink {dest}")
997  os.symlink(base, dest)
998 
999 
1000 def main():
1001  """
1002  Main function for the command line interface.
1003 
1004  it will automatically create an ArgumentParser including all functions which
1005  start with command_ in the global namespace as sub commands. These
1006  functions should take the arguments as first argument and an instance of the
1007  ConditionsDB interface as second argument. If the db interface is None the
1008  first argument is an instance of argparse.ArgumentParser an in this case the
1009  function should just add all needed arguments to the argument parser and
1010  return.
1011  """
1012 
1013  # disable error summary
1014  logging.enable_summary(False)
1015  # log via python stdout to be able to capture
1016  logging.enable_python_logging = True
1017  # modify logging to remove the useless module: lines
1018  for level in LogLevel.values.values():
1019  logging.set_info(level, LogInfo.LEVEL | LogInfo.MESSAGE)
1020 
1021  # Ok, some people prefer `-` in the executable name for tab completion so lets
1022  # support that by just splitting the executable name
1023  sys.argv[0:1] = os.path.basename(sys.argv[0]).split('-')
1024 
1025  # parse argument definition for all sub commands
1026  parser = get_argument_parser()
1027 
1028  # done, all functions parsed. Create the database instance and call the
1029  # correct subfunction according to the selected argument
1030  args = parser.parse_args()
1031 
1032  if args.debugging:
1033  enable_debugging()
1034 
1035  # manage some common options for up and downloading. slightly hacky but
1036  # need to be given to ConditionsDB on construction so meh
1037  nprocess = getattr(args, "nprocess", 1)
1038  retries = getattr(args, "retries", 0)
1039  # need at least one worker thread
1040  if nprocess <= 0:
1041  B2WARNING("-j must be larger than zero, ignoring")
1042  args.nprocess = nprocess = 1
1043 
1044  conditions_db = ConditionsDB(args.base_url, nprocess, retries)
1045 
1046  if args.http_auth != "none":
1047  conditions_db.set_authentication(args.http_user, args.http_password, args.http_auth == "basic")
1048 
1049  try:
1050  return args.func(args, conditions_db)
1051  except ConditionsDB.RequestError as e:
1052  B2ERROR(str(e))
1053  return 1
def print_subparsers(self, parser, prefix="")
Definition: cli_main.py:878
def __call__(self, parser, namespace, values, option_string=None)
Definition: cli_main.py:895
int main(int argc, char **argv)
Run all tests.
Definition: test_main.cc:75