Logo AND Algorithmique Numérique Distribuée

Public GIT Repository
TODO++ in tesh.py
[simgrid.git] / tools / tesh / tesh.py
1 #! @PYTHON_EXECUTABLE@
2 # -*- coding: utf-8 -*-
3 """
4
5 tesh -- testing shell
6 ========================
7
8 Copyright (c) 2012-2016. The SimGrid Team.
9 All rights reserved.
10
11 This program is free software; you can redistribute it and/or modify it
12 under the terms of the license (GNU LGPL) which comes with this package.
13
14
15 #TODO: child of child of child that printfs. Does it work?
16 #TODO: a child dies after its parent. What happen?
17
18 #TODO: regular expression in output
19 #ex: >> Time taken: [0-9]+s
20 #TODO: linked regular expression in output
21 #ex:
22 # >> Bytes sent: ([0-9]+)
23 # >> Bytes recv: \1
24 # then, even better:
25 # ! expect (\1 > 500)
26
27 # TODO: If the output is sorted, we should report it to the users. Corresponding perl chunk
28 # print "WARNING: Both the observed output and expected output were sorted as requested.\n";
29 # print "WARNING: Output were only sorted using the $sort_prefix first chars.\n"
30 #    if ( $sort_prefix > 0 );
31 # print "WARNING: Use <! output sort 19> to sort by simulated date and process ID only.\n";
32 #    
33 # print "----8<---------------  Begin of unprocessed observed output (as it should appear in file):\n";
34 # map {print "> $_\n"} @{$cmd{'unsorted got'}};
35 # print "--------------->8----  End of the unprocessed observed output.\n";
36
37
38 """
39
40
41 import sys, os
42 import shlex
43 import re
44 import difflib
45 import signal
46 import argparse
47
48 if sys.version_info[0] == 3:
49     import subprocess
50     import _thread
51 elif sys.version_info[0] < 3:
52     import subprocess32 as subprocess
53     import thread as _thread
54 else:
55     raise "This program has not been made to exist this long"
56
57
58
59 ##############
60 #
61 # Utilities
62 #
63 #
64
65
66 # Singleton metaclass that works in Python 2 & 3
67 # http://stackoverflow.com/questions/6760685/creating-a-singleton-in-python
68 class _Singleton(type):
69     """ A metaclass that creates a Singleton base class when called. """
70     _instances = {}
71     def __call__(cls, *args, **kwargs):
72         if cls not in cls._instances:
73             cls._instances[cls] = super(_Singleton, cls).__call__(*args, **kwargs)
74         return cls._instances[cls]
75 class Singleton(_Singleton('SingletonMeta', (object,), {})): pass
76
77 SIGNALS_TO_NAMES_DICT = dict((getattr(signal, n), n) \
78     for n in dir(signal) if n.startswith('SIG') and '_' not in n )
79
80
81
82 #exit correctly
83 def exit(errcode):
84     #If you do not flush some prints are skipped
85     sys.stdout.flush()
86     #os._exit exit even when executed within a thread
87     os._exit(errcode)
88
89
90 def fatal_error(msg):
91     print("[Tesh/CRITICAL] "+str(msg))
92     exit(1)
93
94
95 #Set an environment variable.
96 # arg must be a string with the format "variable=value"
97 def setenv(arg):
98     print("[Tesh/INFO] setenv "+arg)
99     t = arg.split("=")
100     os.environ[t[0]] = t[1]
101     #os.putenv(t[0], t[1]) does not work
102     #see http://stackoverflow.com/questions/17705419/python-os-environ-os-putenv-usr-bin-env
103
104
105 #http://stackoverflow.com/questions/30734967/how-to-expand-environment-variables-in-python-as-bash-does
106 def expandvars2(path):
107     return re.sub(r'(?<!\\)\$[A-Za-z_][A-Za-z0-9_]*', '', os.path.expandvars(path))
108
109 # https://github.com/Cadair/jupyter_environment_kernels/issues/10
110 try:
111     FileNotFoundError
112 except NameError:
113     #py2
114     FileNotFoundError = OSError
115
116
117 ##############
118 #
119 # Classes
120 #
121 #
122
123
124
125 # read file line per line (and concat line that ends with "\")
126 class FileReader(Singleton):
127     def __init__(self, filename):
128         if filename is None:
129             self.filename = "(stdin)"
130             self.f = sys.stdin
131         else:
132             self.filename_raw = filename
133             self.filename = os.path.basename(filename)
134             self.f = open(self.filename_raw)
135         
136         self.linenumber = 0
137
138     def linenumber(self):
139         return self.linenumber
140     
141     def __repr__(self):
142         return self.filename+":"+str(self.linenumber)
143     
144     def readfullline(self):
145         try:
146             line = next(self.f)
147             self.linenumber += 1
148         except StopIteration:
149             return None
150         if line[-1] == "\n":
151             txt = line[0:-1]
152         else:
153             txt = line
154         while len(line) > 1 and line[-2] == "\\":
155             txt = txt[0:-1]
156             line = next(self.f)
157             self.linenumber += 1
158             txt += line[0:-1]
159         return txt
160
161
162 #keep the state of tesh (mostly configuration values)
163 class TeshState(Singleton):
164     def __init__(self):
165         self.threads = []
166         self.args_suffix = ""
167         self.ignore_regexps_common = []
168     
169     def add_thread(self, thread):
170         self.threads.append(thread)
171     
172     def join_all_threads(self):
173         for t in self.threads:
174             t.acquire()
175             t.release()
176
177
178
179
180 #Command line object
181 class Cmd(object):
182     def __init__(self):
183         self.input_pipe = []
184         self.output_pipe_stdout = []
185         self.output_pipe_stderr = []
186         self.timeout = 5
187         self.args = None
188         self.linenumber = -1
189         
190         self.background = False
191         self.cwd = None
192         
193         self.ignore_output = False
194         self.expect_return = 0
195         
196         self.output_display = False
197         
198         self.sort = -1
199         
200         self.ignore_regexps = TeshState().ignore_regexps_common
201
202     def add_input_pipe(self, l):
203         self.input_pipe.append(l)
204
205     def add_output_pipe_stdout(self, l):
206         self.output_pipe_stdout.append(l)
207
208     def add_output_pipe_stderr(self, l):
209         self.output_pipe_stderr.append(l)
210
211     def set_cmd(self, args, linenumber):
212         self.args = args
213         self.linenumber = linenumber
214     
215     def add_ignore(self, txt):
216         self.ignore_regexps.append(re.compile(txt))
217     
218     def remove_ignored_lines(self, lines):
219         for ign in self.ignore_regexps:
220                 lines = [l for l in lines if not ign.match(l)]
221         return lines
222
223
224     def _cmd_mkfile(self, argline):
225         filename = argline[len("mkfile "):]
226         file = open(filename, "w")
227         if file is None:
228             fatal_error("Unable to create file "+filename)
229         file.write("\n".join(self.input_pipe))
230         file.close()
231
232     def _cmd_cd(self, argline):
233         args = shlex.split(argline)
234         if len(args) != 2:
235             fatal_error("Too many arguments to cd")
236         try:
237             os.chdir(args[1])
238             print("[Tesh/INFO] change directory to "+args[1])
239         except FileNotFoundError:
240             print("Chdir to "+args[1]+" failed: No such file or directory")
241             print("Test suite `"+FileReader().filename+"': NOK (system error)")
242             exit(4)
243
244
245     #Run the Cmd if possible.
246     # Return False if nothing has been ran.
247     def run_if_possible(self):
248         if self.can_run():
249             if self.background:
250                 #Python threads loose the cwd
251                 self.cwd = os.getcwd()
252                 lock = _thread.allocate_lock()
253                 lock.acquire()
254                 TeshState().add_thread(lock)
255                 _thread.start_new_thread( Cmd._run, (self, lock) )
256             else:
257                 self._run()
258             return True
259         else:
260             return False
261
262
263     def _run(self, lock=None):
264         #Python threads loose the cwd
265         if self.cwd is not None:
266             os.chdir(self.cwd)
267             self.cwd = None
268         
269         #retrocompatibility: support ${aaa:=.} variable format
270         def replace_perl_variables(m):
271             vname = m.group(1)
272             vdefault = m.group(2)
273             if vname in os.environ:
274                 return "$"+vname
275             else:
276                 return vdefault
277         self.args = re.sub(r"\${(\w+):=([^}]*)}", replace_perl_variables, self.args)
278
279         #replace bash environment variables ($THINGS) to their values
280         self.args = expandvars2(self.args)
281         
282         if re.match("^mkfile ", self.args) is not None:
283             self._cmd_mkfile(self.args)
284             if lock is not None: lock.release()
285             return
286         
287         if re.match("^cd ", self.args) is not None:
288             self._cmd_cd(self.args)
289             if lock is not None: lock.release()
290             return
291         
292         self.args += TeshState().args_suffix
293         
294         print("["+FileReader().filename+":"+str(self.linenumber)+"] "+self.args)
295         
296         args = shlex.split(self.args)
297         #print (args)
298         try:
299             proc = subprocess.Popen(args, bufsize=1, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
300         except OSError as e:
301             if e.errno == 8:
302                 e.strerror += "\nOSError: [Errno 8] Executed scripts should start with shebang line (like #!/bin/sh)"
303             raise e
304
305         try:
306             (stdout_data, stderr_data) = proc.communicate("\n".join(self.input_pipe), self.timeout)
307         except subprocess.TimeoutExpired:
308             print("Test suite `"+FileReader().filename+"': NOK (<"+FileReader().filename+":"+str(self.linenumber)+"> timeout after "+str(self.timeout)+" sec)")
309             exit(3)
310
311         if self.output_display:
312             print(stdout_data)
313
314         #remove text colors
315         ansi_escape = re.compile(r'\x1b[^m]*m')
316         stdout_data = ansi_escape.sub('', stdout_data)
317         
318         #print ((stdout_data, stderr_data))
319         
320         if self.ignore_output:
321             print("(ignoring the output of <"+FileReader().filename+":"+str(self.linenumber)+"> as requested)")
322         else:
323             stdouta = stdout_data.split("\n")
324             while len(stdouta) > 0 and stdouta[-1] == "":
325                 del stdouta[-1]
326             stdouta = self.remove_ignored_lines(stdouta)
327
328             #the "sort" bash command is case unsensitive,
329             # we mimic its behaviour
330             if self.sort == 0:
331                 stdouta.sort(key=lambda x: x.lower())
332                 self.output_pipe_stdout.sort(key=lambda x: x.lower())
333             elif self.sort > 0:
334                 stdouta.sort(key=lambda x: x[:self.sort].lower())
335                 self.output_pipe_stdout.sort(key=lambda x: x[:self.sort].lower())
336             
337             diff = list(difflib.unified_diff(self.output_pipe_stdout, stdouta,lineterm="",fromfile='expected', tofile='obtained'))
338             if len(diff) > 0: 
339                 print("Output of <"+FileReader().filename+":"+str(self.linenumber)+"> mismatch:")
340                 for line in diff:
341                     print(line)
342                 print("Test suite `"+FileReader().filename+"': NOK (<"+str(FileReader())+"> output mismatch)")
343                 if lock is not None: lock.release()
344                 exit(2)
345         
346         #print ((proc.returncode, self.expect_return))
347         
348         if proc.returncode != self.expect_return:
349             if proc.returncode >= 0:
350                 print("Test suite `"+FileReader().filename+"': NOK (<"+str(FileReader())+"> returned code "+str(proc.returncode)+")")
351                 if lock is not None: lock.release()
352                 exit(2)
353             else:
354                 print("Test suite `"+FileReader().filename+"': NOK (<"+str(FileReader())+"> got signal "+SIGNALS_TO_NAMES_DICT[-proc.returncode]+")")
355                 if lock is not None: lock.release()
356                 exit(-proc.returncode)
357             
358         if lock is not None: lock.release()
359     
360     
361     
362     def can_run(self):
363         return self.args is not None
364
365
366
367
368
369
370
371
372
373
374 ##############
375 #
376 # Main
377 #
378 #
379
380
381
382 if __name__ == '__main__':
383     
384     parser = argparse.ArgumentParser(description='tesh -- testing shell', add_help=True)
385     group1 = parser.add_argument_group('Options')
386     group1.add_argument('teshfile', nargs='?', help='Name of teshfile, stdin if omitted')
387     group1.add_argument('--cd', metavar='some/directory', help='ask tesh to switch the working directory before launching the tests')
388     group1.add_argument('--setenv', metavar='var=value', action='append', help='set a specific environment variable')
389     group1.add_argument('--cfg', metavar='arg', help='add parameter --cfg=arg to each command line')
390     group1.add_argument('--log', metavar='arg', help='add parameter --log=arg to each command line')
391     group1.add_argument('--enable-coverage', action='store_true', help='ignore output lines starting with "profiling:"')
392
393     try:
394         options = parser.parse_args()
395     except:
396         exit(1)
397
398     if options.cd is not None:
399         os.chdir(options.cd)
400
401     if options.enable_coverage:
402         print("Enable coverage")
403         TeshState().ignore_regexps_common = [re.compile("^profiling:")]
404     
405     if options.teshfile is None:
406         f = FileReader(None)
407         print("Test suite from stdin")
408     else:
409         f = FileReader(options.teshfile)
410         print("Test suite '"+f.filename+"'")
411     
412     if options.setenv is not None:
413         for e in options.setenv:
414             setenv(e)
415     
416     if options.cfg is not None:
417         TeshState().args_suffix += " --cfg="+options.cfg
418     if options.log is not None:
419         TeshState().args_suffix += " --log="+options.log
420     
421     
422     #cmd holds the current command line
423     # tech commands will add some parameters to it
424     # when ready, we execute it.
425     cmd = Cmd()
426     
427     line = f.readfullline()
428     while line is not None:
429         #print(">>============="+line+"==<<")
430         if len(line) == 0:
431             #print ("END CMD block")
432             if cmd.run_if_possible():
433                 cmd = Cmd()
434         
435         elif line[0] == "#":
436             pass
437         
438         elif line[0:2] == "p ":
439             print("["+str(FileReader())+"] "+line[2:])
440         
441         elif line[0:2] == "< ":
442             cmd.add_input_pipe(line[2:])
443         elif line[0:1] == "<":
444             cmd.add_input_pipe(line[1:])
445             
446         elif line[0:2] == "> ":
447             cmd.add_output_pipe_stdout(line[2:])
448         elif line[0:1] == ">":
449             cmd.add_output_pipe_stdout(line[1:])
450             
451         elif line[0:2] == "$ ":
452             if cmd.run_if_possible():
453                 cmd = Cmd()
454             cmd.set_cmd(line[2:], f.linenumber)
455         
456         elif line[0:2] == "& ":
457             if cmd.run_if_possible():
458                 cmd = Cmd()
459             cmd.set_cmd(line[2:], f.linenumber)
460             cmd.background = True
461         
462         elif line[0:15] == "! output ignore":
463             cmd.ignore_output = True
464             #print("cmd.ignore_output = True")
465         elif line[0:16] == "! output display":
466             cmd.output_display = True
467             cmd.ignore_output = True
468         elif line[0:15] == "! expect return":
469             cmd.expect_return = int(line[16:])
470             #print("expect return "+str(int(line[16:])))
471         elif line[0:15] == "! expect signal":
472             sig = line[16:]
473             #get the signal integer value from the signal module
474             if sig not in signal.__dict__:
475                 fatal_error("unrecognized signal '"+sig+"'")
476             sig = int(signal.__dict__[sig])
477             #popen return -signal when a process ends with a signal
478             cmd.expect_return = -sig
479         elif line[0:len("! timeout ")] == "! timeout ":
480             if "no" in line[len("! timeout "):]:
481                 cmd.timeout = None
482             else:
483                 cmd.timeout = int(line[len("! timeout "):])
484             
485         elif line[0:len("! output sort")] == "! output sort":
486             if len(line) >= len("! output sort "):
487                 sort = int(line[len("! output sort "):])
488             else:
489                 sort = 0
490             cmd.sort = sort
491         elif line[0:len("! setenv ")] == "! setenv ":
492             setenv(line[len("! setenv "):])
493         
494         elif line[0:len("! ignore ")] == "! ignore ":
495             cmd.add_ignore(line[len("! ignore "):])
496         
497         else:
498             fatal_error("UNRECOGNIZED OPTION")
499             
500         
501         line = f.readfullline()
502
503     cmd.run_if_possible()
504     
505     TeshState().join_all_threads()
506     
507     if f.filename == "(stdin)":
508         print("Test suite from stdin OK")
509     else:
510         print("Test suite `"+f.filename+"' OK")
511
512
513
514
515