xref: /petsc/config/testparse.py (revision d5c9c0c4eebc2f2a01a1bd0c86fca87e2acd2a03)
1#!/usr/bin/env python
2"""
3Parse the test file and return a dictionary.
4
5Quick usage::
6
7  lib/petsc/bin/maint/testparse.py -t src/ksp/ksp/tutorials/ex1.c
8
9From the command line, it prints out the dictionary.
10This is meant to be used by other scripts, but it is
11useful to debug individual files.
12
13Example language
14----------------
15
16/*TEST
17   build:
18     requires: moab
19   # This is equivalent to test:
20   testset:
21      args: -pc_type mg -ksp_type fgmres -da_refine 2 -ksp_monitor_short -mg_levels_ksp_monitor_short -mg_levels_ksp_norm_type unpreconditioned -ksp_view -pc_mg_type full
22
23   testset:
24      suffix: 2
25      nsize: 2
26      args: -pc_type mg -ksp_type fgmres -da_refine 2 -ksp_monitor_short -mg_levels_ksp_monitor_short -mg_levels_ksp_norm_type unpreconditioned -ksp_view -pc_mg_type full
27
28   testset:
29      suffix: 2
30      nsize: 2
31      args: -pc_type mg -ksp_type fgmres -da_refine 2 -ksp_monitor_short -mg_levels_ksp_monitor_short -mg_levels_ksp_norm_type unpreconditioned -ksp_view -pc_mg_type full
32      test:
33
34TEST*/
35
36"""
37from __future__ import print_function
38
39import os, re, glob, types
40import sys
41import logging
42sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
43
44import inspect
45thisscriptdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
46maintdir=os.path.join(os.path.join(os.path.dirname(thisscriptdir),'bin'),'maint')
47sys.path.insert(0,maintdir)
48
49# These are special keys describing build
50buildkeys="requires TODO SKIP depends".split()
51
52acceptedkeys="test nsize requires command suffix args filter filter_output localrunfiles comments TODO SKIP output_file timeoutfactor".split()
53appendlist="args requires comments".split()
54
55import re
56
57def getDefaultOutputFileRoot(testname):
58  """
59  Given testname, give DefaultRoot and DefaultOutputFilename
60  e.g., runex1 gives ex1_1, output/ex1_1.out
61  """
62  defroot=(re.sub("run","",testname) if testname.startswith("run") else testname)
63  if not "_" in defroot: defroot=defroot+"_1"
64  return defroot
65
66def _stripIndent(block,srcfile,entireBlock=False,fileNums=[]):
67  """
68  Go through and remove a level of indentation
69  Also strip of trailing whitespace
70  """
71  # The first entry should be test: but it might be indented.
72  ext=os.path.splitext(srcfile)[1]
73  stripstr=" "
74  if len(fileNums)>0: lineNum=fileNums[0]-1
75  for lline in block.split("\n"):
76    if len(fileNums)>0: lineNum+=1
77    line=lline[1:] if lline.startswith("!") else lline
78    if not line.strip(): continue
79    if line.strip().startswith('#'): continue
80    if entireBlock:
81      var=line.split(":")[0].strip()
82      if not var in ['test','testset','build']:
83        raise Exception("Formatting error: Cannot find test in file: "+srcfile+" at line: "+str(lineNum)+"\n")
84    nspace=len(line)-len(line.lstrip(stripstr))
85    newline=line[nspace:]
86    break
87
88  # Strip off any indentation for the whole string and any trailing
89  # whitespace for convenience
90  newTestStr="\n"
91  if len(fileNums)>0: lineNum=fileNums[0]-1
92  firstPass=True
93  for lline in block.split("\n"):
94    if len(fileNums)>0: lineNum+=1
95    line=lline[1:] if lline.startswith("!") else lline
96    if not line.strip(): continue
97    if line.strip().startswith('#'):
98      newTestStr+=line+'\n'
99    else:
100      newline=line[nspace:]
101      newTestStr+=newline.rstrip()+"\n"
102    # Do some basic indentation checks
103    if entireBlock:
104      # Don't need to check comment lines
105      if line.strip().startswith('#'): continue
106      if not newline.startswith(" "):
107        var=newline.split(":")[0].strip()
108        if not var in ['test','testset','build']:
109          err="Formatting error in file "+srcfile+" at line: " +line+"\n"
110          if len(fileNums)>0:
111            raise Exception(err+"Check indentation at line number: "+str(lineNum))
112          else:
113            raise Exception(err)
114      else:
115        var=line.split(":")[0].strip()
116        if var in ['test','testset','build']:
117          subnspace=len(line)-len(line.lstrip(stripstr))
118          if firstPass:
119            firstsubnspace=subnspace
120            firstPass=False
121          else:
122            if firstsubnspace!=subnspace:
123              err="Formatting subtest error in file "+srcfile+" at line: " +line+"\n"
124              if len(fileNums)>0:
125                raise Exception(err+"Check indentation at line number: "+str(lineNum))
126              else:
127                raise Exception(err)
128
129  # Allow line continuation character '\'
130  return newTestStr.replace('\\\n', ' ')
131
132def parseLoopArgs(varset):
133  """
134  Given:   String containing loop variables
135  Return: tuple containing separate/shared and string of loop vars
136  """
137  keynm=varset.split("{{")[0].strip().lstrip('-')
138  if not keynm.strip(): keynm='nsize'
139  lvars=varset.split('{{')[1].split('}')[0]
140  suffx=varset.split('{{')[1].split('}')[1]
141  ftype='separate' if suffx.startswith('separate') else 'shared'
142  return keynm,lvars,ftype
143
144def _getSeparateTestvars(testDict):
145  """
146  Given: dictionary that may have
147  Return:  Variables that cause a test split
148  """
149  vals=None
150  sepvars=[]
151  # Check nsize
152  if 'nsize' in testDict:
153    varset=testDict['nsize']
154    if '{{' in varset:
155      keynm,lvars,ftype=parseLoopArgs(varset)
156      if ftype=='separate': sepvars.append(keynm)
157
158  # Now check args
159  if 'args' not in testDict: return sepvars
160  for varset in re.split('(^|\W)-(?=[a-zA-Z])',testDict['args']):
161    if not varset.strip(): continue
162    if '{{' in varset:
163      # Assuming only one for loop per var specification
164      keynm,lvars,ftype=parseLoopArgs(varset)
165      if ftype=='separate': sepvars.append(keynm)
166
167  return sepvars
168
169def _getNewArgs(args):
170  """
171  Given: String that has args that might have loops in them
172  Return:  All of the arguments/values that do not have
173             for 'separate output' in for loops
174  """
175  newargs=''
176  if not args.strip(): return args
177  for varset in re.split('(^|\W)-(?=[a-zA-Z])',args):
178    if not varset.strip(): continue
179    if '{{' in varset and 'separate' in varset: continue
180    newargs+="-"+varset.strip()+" "
181
182  return newargs
183
184def _getVarVals(findvar,testDict):
185  """
186  Given: variable that is either nsize or in args
187  Return:  Values to loop over and the other arguments
188    Note that we keep the other arguments even if they have
189    for loops to enable stepping through all of the for lops
190  """
191  save_vals=None
192  if findvar=='nsize':
193    varset=testDict[findvar]
194    keynm,save_vals,ftype=parseLoopArgs('nsize '+varset)
195  else:
196    varlist=[]
197    for varset in re.split('-(?=[a-zA-Z])',testDict['args']):
198      if not varset.strip(): continue
199      if '{{' not in varset: continue
200      keyvar,vals,ftype=parseLoopArgs(varset)
201      if keyvar==findvar:
202        save_vals=vals
203
204  if not save_vals: raise Exception("Could not find separate_testvar: "+findvar)
205  return save_vals
206
207def genTestsSeparateTestvars(intests,indicts,final=False):
208  """
209  Given: testname, sdict with 'separate_testvars
210  Return: testnames,sdicts: List of generated tests
211    The tricky part here is the {{ ... }separate output}
212    that can be used multiple times
213  """
214  testnames=[]; sdicts=[]
215  for i in range(len(intests)):
216    testname=intests[i]; sdict=indicts[i]; i+=1
217    separate_testvars=_getSeparateTestvars(sdict)
218    if len(separate_testvars)>0:
219      sep_dicts=[sdict.copy()]
220      if 'args' in sep_dicts[0]:
221        sep_dicts[0]['args']=_getNewArgs(sdict['args'])
222      sep_testnames=[testname]
223      for kvar in separate_testvars:
224        kvals=_getVarVals(kvar,sdict)
225
226        # Have to do loop over previous var/val combos as well
227        # and accumulate as we go
228        val_testnames=[]; val_dicts=[]
229        for val in kvals.split():
230          gensuffix="_"+kvar+"-"+val.replace(',','__')
231          for kvaltestnm in sep_testnames:
232            val_testnames.append(kvaltestnm+gensuffix)
233          for kv in sep_dicts:
234            kvardict=kv.copy()
235            # If the last var then we have the final version
236            if 'suffix' in sdict:
237              kvardict['suffix']+=gensuffix
238            else:
239              kvardict['suffix']=gensuffix
240            if kvar=='nsize':
241              kvardict[kvar]=val
242            else:
243              kvardict['args']+="-"+kvar+" "+val+" "
244            val_dicts.append(kvardict)
245        sep_testnames=val_testnames
246        sep_dicts=val_dicts
247      testnames+=sep_testnames
248      sdicts+=sep_dicts
249    else:
250      # These are plain vanilla tests (no subtests, no loops) that
251      # do not have a suffix.  This makes the targets match up with
252      # the output file (testname_1.out)
253      if final:
254          if '_' not in testname: testname+='_1'
255      testnames.append(testname)
256      sdicts.append(sdict)
257  return testnames,sdicts
258
259def genTestsSubtestSuffix(testnames,sdicts):
260  """
261  Given: testname, sdict with separate_testvars
262  Return: testnames,sdicts: List of generated tests
263  """
264  tnms=[]; sdcts=[]
265  for i in range(len(testnames)):
266    testname=testnames[i]
267    rmsubtests=[]; keepSubtests=False
268    if 'subtests' in sdicts[i]:
269      for stest in sdicts[i]["subtests"]:
270        if 'suffix' in sdicts[i][stest]:
271          rmsubtests.append(stest)
272          gensuffix="_"+sdicts[i][stest]['suffix']
273          newtestnm=testname+gensuffix
274          tnms.append(newtestnm)
275          newsdict=sdicts[i].copy()
276          del newsdict['subtests']
277          # Have to hand update
278          # Append
279          for kup in appendlist:
280            if kup in sdicts[i][stest]:
281              if kup in sdicts[i]:
282                newsdict[kup]=sdicts[i][kup]+" "+sdicts[i][stest][kup]
283              else:
284                newsdict[kup]=sdicts[i][stest][kup]
285          # Promote
286          for kup in acceptedkeys:
287            if kup in appendlist: continue
288            if kup in sdicts[i][stest]:
289              newsdict[kup]=sdicts[i][stest][kup]
290          # Cleanup
291          for st in sdicts[i]["subtests"]: del newsdict[st]
292          sdcts.append(newsdict)
293        else:
294          keepSubtests=True
295    else:
296      tnms.append(testnames[i])
297      sdcts.append(sdicts[i])
298    # If a subtest without a suffix exists, then save it
299    if keepSubtests:
300      tnms.append(testnames[i])
301      newsdict=sdicts[i].copy()
302      # Prune the tests to prepare for keeping
303      for rmtest in rmsubtests:
304        newsdict['subtests'].remove(rmtest)
305        del newsdict[rmtest]
306      sdcts.append(newsdict)
307    i+=1
308  return tnms,sdcts
309
310def splitTests(testname,sdict):
311  """
312  Given: testname and dictionary generated from the YAML-like definition
313  Return: list of names and dictionaries corresponding to each test
314          given that the YAML-like language allows for multiple tests
315  """
316
317  # Order: Parent sep_tv, subtests suffix, subtests sep_tv
318  testnames,sdicts=genTestsSeparateTestvars([testname],[sdict])
319  testnames,sdicts=genTestsSubtestSuffix(testnames,sdicts)
320  testnames,sdicts=genTestsSeparateTestvars(testnames,sdicts,final=True)
321
322  # Because I am altering the list, I do this in passes.  Inelegant
323
324  return testnames, sdicts
325
326
327def testSplit(striptest):
328  """
329  Split up a test into lines, but use a shell parser to detect when newlines are within quotation marks
330  and keep those together
331  """
332  import shlex
333
334  sl = shlex.shlex()
335  sl.whitespace_split = True # only split at whitespace
336  sl.commenters = ''
337  sl.push_source(striptest)
338  last_pos = sl.instream.tell()
339  try:
340    last_token = sl.read_token()
341  except ValueError:
342    print(striptest)
343    raise ValueError
344  last_line = ''
345  while last_token != '':
346    new_pos = sl.instream.tell()
347    block = striptest[last_pos:new_pos]
348    token_start = block.find(last_token)
349    leading = block[0:token_start]
350    trailing = block[(token_start + len(last_token)):]
351    leading_split = leading.split('\n')
352    if len(leading_split) > 1:
353      yield last_line
354      last_line = ''
355    last_line += leading_split[-1]
356    last_line += last_token
357    trailing_split = trailing.split('\n')
358    last_line += trailing_split[0]
359    if len(trailing_split) > 1:
360      yield last_line
361      last_line = ''
362    last_pos = new_pos
363    try:
364      last_token = sl.read_token()
365    except ValueError:
366      print(striptest)
367      raise ValueError
368  yield last_line
369
370
371def parseTest(testStr,srcfile,verbosity):
372  """
373  This parses an individual test
374  Our YAML-like language is hierarchial so should use a state machine in the general case,
375  but in practice we only support two levels of test:
376  """
377  basename=os.path.basename(srcfile)
378  # Handle the new at the begininng
379  bn=re.sub("new_","",basename)
380  # This is the default
381  testname="run"+os.path.splitext(bn)[0]
382
383  # Tests that have default everything (so empty effectively)
384  if len(testStr)==0: return [testname], [{}]
385
386  striptest=_stripIndent(testStr,srcfile)
387
388  # go through and parse
389  subtestnum=0
390  subdict={}
391  comments=[]
392  indentlevel=0
393  for ln in testSplit(striptest):
394    line=ln.split('#')[0].rstrip()
395    if verbosity>2: print(line)
396    comment=("" if len(ln.split("#"))>0 else " ".join(ln.split("#")[1:]).strip())
397    if comment: comments.append(comment)
398    if not line.strip(): continue
399    lsplit=line.split(':')
400    if len(lsplit)==0: raise Exception("Missing : in line: "+line)
401    indentcount=lsplit[0].count(" ")
402    var=lsplit[0].strip()
403    val=line[line.find(':')+1:].strip()
404    if not var in acceptedkeys: raise Exception("Not a defined key: "+var+" from:  "+line)
405    # Start by seeing if we are in a subtest
406    if line.startswith(" "):
407      if var in subdict[subtestname]:
408        subdict[subtestname][var]+=" "+val
409      else:
410        subdict[subtestname][var]=val
411      if not indentlevel: indentlevel=indentcount
412      #if indentlevel!=indentcount: print("Error in indentation:", ln)
413    # Determine subtest name and make dict
414    elif var=="test":
415      subtestname="test"+str(subtestnum)
416      subdict[subtestname]={}
417      if "subtests" not in subdict: subdict["subtests"]=[]
418      subdict["subtests"].append(subtestname)
419      subtestnum=subtestnum+1
420    # The rest are easy
421    else:
422      # For convenience, it is sometimes convenient to list twice
423      if var in subdict:
424        if var in appendlist:
425          subdict[var]+=" "+val
426        else:
427          raise Exception(var+" entered twice: "+line)
428      else:
429        subdict[var]=val
430      if var=="suffix":
431        if len(val)>0:
432          testname+="_"+val
433
434  if len(comments): subdict['comments']="\n".join(comments).lstrip("\n")
435
436  # A test block can create multiple tests.  This does that logic
437  testnames,subdicts=splitTests(testname,subdict)
438  return testnames,subdicts
439
440def parseTests(testStr,srcfile,fileNums,verbosity):
441  """
442  Parse the YAML-like string describing tests and return
443  a dictionary with the info in the form of:
444    testDict[test][subtest]
445  """
446
447  testDict={}
448
449  # The first entry should be test: but it might be indented.
450  newTestStr=_stripIndent(testStr,srcfile,entireBlock=True,fileNums=fileNums)
451  if verbosity>2: print(srcfile)
452
453  ## Check and see if we have build requirements
454  addToRunRequirements=None
455  if "\nbuild:" in newTestStr:
456    testDict['build']={}
457    # The file info is already here and need to append
458    Part1=newTestStr.split("build:")[1]
459    fileInfo=re.split("\ntest(?:set)?:",newTestStr)[0]
460    for bkey in buildkeys:
461      if bkey+":" in fileInfo:
462        testDict['build'][bkey]=fileInfo.split(bkey+":")[1].split("\n")[0].strip()
463        #if verbosity>1: bkey+": "+testDict['build'][bkey]
464      # If a runtime requires are put into build, push them down to all run tests
465      # At this point, we are working with strings and not lists
466      if 'requires' in testDict['build']:
467         if 'datafilespath' in testDict['build']['requires']:
468             newreqs=re.sub('datafilespath','',testDict['build']['requires'])
469             testDict['build']['requires']=newreqs.strip()
470             addToRunRequirements='datafilespath'
471
472
473  # Now go through each test.  First elem in split is blank
474  for test in re.split("\ntest(?:set)?:",newTestStr)[1:]:
475    testnames,subdicts=parseTest(test,srcfile,verbosity)
476    for i in range(len(testnames)):
477      if testnames[i] in testDict:
478        raise RuntimeError("Multiple test names specified: "+testnames[i]+" in file: "+srcfile)
479      # Add in build requirements that need to be moved
480      if addToRunRequirements:
481          if 'requires' in subdicts[i]:
482              subdicts[i]['requires']+=addToRunRequirements
483          else:
484              subdicts[i]['requires']=addToRunRequirements
485      testDict[testnames[i]]=subdicts[i]
486
487  return testDict
488
489def parseTestFile(srcfile,verbosity):
490  """
491  Parse single example files and return dictionary of the form:
492    testDict[srcfile][test][subtest]
493  """
494  debug=False
495  basename=os.path.basename(srcfile)
496  if basename=='makefile': return {}
497
498  curdir=os.path.realpath(os.path.curdir)
499  basedir=os.path.dirname(os.path.realpath(srcfile))
500  os.chdir(basedir)
501
502  testDict={}
503  sh=open(basename,"r"); fileStr=sh.read(); sh.close()
504
505  ## Start with doing the tests
506  #
507  fsplit=fileStr.split("/*TEST\n")[1:]
508  fstart=len(fileStr.split("/*TEST\n")[0].split("\n"))+1
509  # Allow for multiple "/*TEST" blocks even though it really should be
510  # one
511  srcTests=[]
512  for t in fsplit: srcTests.append(t.split("TEST*/")[0])
513  testString=" ".join(srcTests)
514  flen=len(testString.split("\n"))
515  fend=fstart+flen-1
516  fileNums=range(fstart,fend)
517  testDict[basename]=parseTests(testString,srcfile,fileNums,verbosity)
518  # Massage dictionary for build requirements
519  if 'build' in testDict[basename]:
520    testDict[basename].update(testDict[basename]['build'])
521    del testDict[basename]['build']
522
523
524  os.chdir(curdir)
525  return testDict
526
527def parseTestDir(directory,verbosity):
528  """
529  Parse single example files and return dictionary of the form:
530    testDict[srcfile][test][subtest]
531  """
532  curdir=os.path.realpath(os.path.curdir)
533  basedir=os.path.realpath(directory)
534  os.chdir(basedir)
535
536  tDict={}
537  for test_file in sorted(glob.glob("new_ex*.*")):
538    tDict.update(parseTestFile(test_file,verbosity))
539
540  os.chdir(curdir)
541  return tDict
542
543def printExParseDict(rDict):
544  """
545  This is useful for debugging
546  """
547  indent="   "
548  for sfile in rDict:
549    print(sfile)
550    sortkeys=list(rDict[sfile].keys())
551    sortkeys.sort()
552    for runex in sortkeys:
553      if runex == 'requires':
554        print(indent+runex+':'+str(rDict[sfile][runex]))
555        continue
556      print(indent+runex)
557      if type(rDict[sfile][runex])==bytes:
558        print(indent*2+rDict[sfile][runex])
559      else:
560        for var in rDict[sfile][runex]:
561          if var.startswith("test"): continue
562          print(indent*2+var+": "+str(rDict[sfile][runex][var]))
563        if 'subtests' in rDict[sfile][runex]:
564          for var in rDict[sfile][runex]['subtests']:
565            print(indent*2+var)
566            for var2 in rDict[sfile][runex][var]:
567              print(indent*3+var2+": "+str(rDict[sfile][runex][var][var2]))
568      print("\n")
569  return
570
571def main(directory='',test_file='',verbosity=0):
572
573    if directory:
574      tDict=parseTestDir(directory,verbosity)
575    else:
576      tDict=parseTestFile(test_file,verbosity)
577    if verbosity>0: printExParseDict(tDict)
578
579    return
580
581if __name__ == '__main__':
582    import optparse
583    parser = optparse.OptionParser()
584    parser.add_option('-d', '--directory', dest='directory',
585                      default="", help='Directory containing files to parse')
586    parser.add_option('-t', '--test_file', dest='test_file',
587                      default="", help='Test file, e.g., ex1.c, to parse')
588    parser.add_option('-v', '--verbosity', dest='verbosity',
589                      help='Verbosity of output by level: 1, 2, or 3', default=0)
590    opts, extra_args = parser.parse_args()
591
592    if extra_args:
593        import sys
594        sys.stderr.write('Unknown arguments: %s\n' % ' '.join(extra_args))
595        exit(1)
596    if not opts.test_file and not opts.directory:
597      print("test file or directory is required")
598      parser.print_usage()
599      sys.exit()
600
601    # Need verbosity to be an integer
602    try:
603      verbosity=int(opts.verbosity)
604    except:
605      raise Exception("Error: Verbosity must be integer")
606
607    main(directory=opts.directory,test_file=opts.test_file,verbosity=verbosity)
608