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