Permite valores vazios para OptionForm.
[cascardo/irpf-gui.git] / test / pytddmon.py
1 #! /usr/bin/env python
2 #coding: utf-8
3
4 '''
5 COPYRIGHT (c) 2009, 2010, 2011, 2012
6 .. in order of first contribution
7 Olof Bjarnason
8     Initial proof-of-concept pygame implementation.
9 Fredrik Wendt
10     Help with Tkinter implementation (replacing the pygame dependency)
11 Krunoslav Saho
12     Added always-on-top to the pytddmon window
13 Samuel Ytterbrink
14     Print(".") will not screw up test-counting (it did before)
15     Docstring support
16     Recursive discovery of tests
17     Refactoring to increase Pylint score from 6 to 9.5 out of 10 (!)
18     Numerous refactorings & other improvements
19 Rafael Capucho
20     Python shebang at start of script, enabling "./pytddmon.py" on unix systems
21 Ilian Iliev
22     Use integers instead of floats in file modified time (checksum calc)
23     Auto-update of text in Details window when the log changes
24 Henrik Bohre
25     Status bar in pytddmon window, showing either last time tests were
26     run, or "Testing..." during a test run
27     
28
29 LICENSE
30 Permission is hereby granted, free of charge, to any person obtaining a copy
31 of this software and associated documentation files (the "Software"), to deal
32 in the Software without restriction, including without limitation the rights
33 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
34 copies of the Software, and to permit persons to whom the Software is
35 furnished to do so, subject to the following conditions:
36
37 The above copyright notice and this permission notice shall be included in
38 all copies or substantial portions of the Software.
39
40 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
42 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
43 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
44 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
45 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
46 THE SOFTWARE.
47 '''
48
49 import os
50 import sys
51 import platform
52 import optparse
53 import re
54 import unittest
55 import doctest
56 import time
57 import multiprocessing
58 import fnmatch
59 import functools
60
61 ON_PYTHON3 = sys.version_info[0] == 3
62 ON_WINDOWS = platform.system() == "Windows"
63
64 ####
65 ## Core
66 ####
67
68 class Pytddmon:
69     "The core class, all functionality is combined into this class"
70     def __init__(
71         self,
72         file_finder,
73         monitor,
74         project_name = "<pytddmon>"
75     ):
76         self.file_finder = file_finder
77         self.project_name = project_name
78         self.monitor = monitor
79         self.change_detected = False
80
81         self.total_tests_run = 0
82         self.total_tests_passed = 0
83         self.last_testrun_time = -1
84         self.log = ""
85         self.status_message = 'n/a'
86
87         self.run_tests()
88
89     def run_tests(self):
90         """Runs all tests and updates state variables with results."""
91         
92         file_paths = self.file_finder()
93         
94         # We need to run the tests in a separate process, since
95         # Python caches loaded modules, and unittest/doctest
96         # imports modules to run them.
97         # However, we do not want to assume users' unit tests
98         # are thread-safe, so we only run one test module at a
99         # time, using processes = 1.
100         start = time.time()
101         if file_paths:
102             pool = multiprocessing.Pool(processes = 1)
103             results = pool.map(run_tests_in_file, file_paths)
104             pool.close()
105             pool.join()
106         else:
107             results = []
108         self.last_testrun_time = time.time() - start
109         
110         now = time.strftime("%H:%M:%S", time.localtime())
111         self.log = ""
112         self.log += "Monitoring folder %s.\n" % self.project_name
113         self.log += "Found <TOTALTESTS> tests in %i files.\n" % len(results)
114         self.log += "Last change detected at %s.\n" % now
115         self.log += "Test run took %.2f seconds.\n" % self.last_testrun_time
116         self.log += "\n"
117         self.total_tests_passed = 0
118         self.total_tests_run = 0
119         module_logs = []  # Summary for each module with errors first
120         for packed in results:
121             (module, green, total, logtext) = packed
122             self.total_tests_passed += green
123             self.total_tests_run += total
124             module_log = "\nLog from " + module + ":\n" + logtext
125             if not isinstance(total, int) or total - green > 0:
126                 module_logs.insert(0, module_log)
127             else:
128                 module_logs.append(module_log)
129         self.log += ''.join(module_logs)
130         self.log = self.log.replace('<TOTALTESTS>', 
131                 str(int(self.total_tests_run.real)))
132         self.status_message = now
133
134     def get_and_set_change_detected(self):
135         self.change_detected = self.monitor.look_for_changes()
136         return self.change_detected
137
138     def main(self):
139         """This is the main loop body"""
140         self.change_detected = self.monitor.look_for_changes()
141         if self.change_detected:
142             self.run_tests()
143
144     def get_log(self):
145         """Access the log string created during test run"""
146         return self.log
147
148     def get_status_message(self):
149         """Return message in status bar"""
150         return self.status_message
151
152 class Monitor:
153     'Looks for file changes when prompted to'
154     
155     def __init__(self, file_finder, get_file_size, get_file_modtime):
156         self.file_finder = file_finder
157         self.get_file_size = get_file_size
158         self.get_file_modtime = get_file_modtime
159         self.snapshot = self.get_snapshot()
160
161     def get_snapshot(self):
162         snapshot = {}
163         for file in self.file_finder():
164             file_size = self.get_file_size(file)
165             file_modtime = self.get_file_modtime(file)
166             snapshot[file] = (file_size, file_modtime)
167         return snapshot
168
169     def look_for_changes(self):
170         new_snapshot = self.get_snapshot()
171         change_detected = new_snapshot != self.snapshot
172         self.snapshot = new_snapshot
173         return change_detected
174
175
176 ####
177 ## Finding files
178 ####
179
180 class FileFinder:
181     "Returns all files matching given regular expression from root downwards"
182     
183     def __init__(self, root, regexp):
184         self.root = os.path.abspath(root)
185         self.regexp = regexp
186         
187     def __call__(self):
188         return self.find_files()
189
190     def find_files(self):
191         "recursively finds files matching regexp"
192         file_paths = set()
193         for path, _folder, filenames in os.walk(self.root):
194             for filename in filenames:
195                 if self.re_complete_match(filename):
196                     file_paths.add(
197                         os.path.abspath(os.path.join(path, filename))
198                     )
199         return file_paths
200         
201     def re_complete_match(self, string_to_match):
202         "full string regexp check"
203         return bool(re.match(self.regexp + "$", string_to_match))
204
205 wildcard_to_regex = fnmatch.translate
206
207 ####
208 ## Finding & running tests
209 ####
210
211 def log_exceptions(func):
212     """Decorator that forwards the error message from an exception to the log
213     slot of the return value, and also returns a complexnumber to signal that
214     the result is an error."""
215     wraps = functools.wraps
216
217     @wraps(func)
218     def wrapper(*a, **k):
219         "Docstring"
220         try:
221             return func(*a, **k)
222         except:
223             import traceback
224             return ('Exception(%s)' % a[0] , 0, 1j, traceback.format_exc())
225     return wrapper
226
227 @log_exceptions
228 def run_tests_in_file(file_path):
229     module = file_name_to_module("", file_path)
230     return run_module(module)
231
232 def run_module(module):
233     suite = find_tests_in_module(module)
234     (green, total, log) = run_suite(suite)
235     return (module, green, total, log)
236
237 def file_name_to_module(base_path, file_name):
238     r"""Converts filenames of files in packages to import friendly dot
239     separated paths.
240
241     Examples:
242     >>> print(file_name_to_module("","pytddmon.pyw"))
243     pytddmon
244     >>> print(file_name_to_module("","pytddmon.py"))
245     pytddmon
246     >>> print(file_name_to_module("","tests/pytddmon.py"))
247     tests.pytddmon
248     >>> print(file_name_to_module("","./tests/pytddmon.py"))
249     tests.pytddmon
250     >>> print(file_name_to_module("",".\\tests\\pytddmon.py"))
251     tests.pytddmon
252     >>> print(
253     ...     file_name_to_module(
254     ...         "/User/pytddmon\\ geek/pytddmon/",
255     ...         "/User/pytddmon\\ geek/pytddmon/tests/pytddmon.py"
256     ...     )
257     ... )
258     tests.pytddmon
259     """
260     symbol_stripped = os.path.relpath(file_name, base_path)
261     for symbol in r"/\.":
262         symbol_stripped = symbol_stripped.replace(symbol, " ")
263     words = symbol_stripped.split()
264     # remove .py/.pyw
265     module_words = words[:-1]
266     module_name = '.'.join(module_words)
267     return module_name
268
269 def find_tests_in_module(module):
270     suite = unittest.TestSuite()
271     suite.addTests(find_unittests_in_module(module))
272     suite.addTests(find_doctests_in_module(module))
273     return suite
274
275 def find_unittests_in_module(module):
276     test_loader = unittest.TestLoader()
277     return test_loader.loadTestsFromName(module)
278
279 def find_doctests_in_module(module):
280     try:
281         return doctest.DocTestSuite(module, optionflags = doctest.ELLIPSIS)
282     except ValueError:
283         return unittest.TestSuite()
284
285 def run_suite(suite):
286     def StringIO():
287         if ON_PYTHON3:
288             import io as StringIO
289         else:
290             import StringIO 
291         return StringIO.StringIO()
292     err_log = StringIO()
293     text_test_runner = unittest.TextTestRunner(stream = err_log, verbosity = 1)
294     result = text_test_runner.run(suite)
295     green = result.testsRun - len(result.failures) - len(result.errors)
296     total = result.testsRun
297     log = err_log.getvalue() if green<total else "All %i tests passed\n" % green
298     return (green, total, log)
299
300
301 ####
302 ## GUI
303 ####
304
305 def import_tkinter():
306     "imports tkinter from python 3.x or python 2.x"
307     if not ON_PYTHON3:
308         import Tkinter as tkinter
309     else:
310         import tkinter
311     return tkinter
312
313 def import_tkFont():
314     "imports tkFont from python 3.x or python 2.x"
315     if not ON_PYTHON3:
316         import tkFont
317     else:
318         from tkinter import font as tkFont 
319     return tkFont
320     
321 class TKGUIButton(object):
322     """Encapsulate the button(label)"""
323     def __init__(self, tkinter, tkFont, toplevel, display_log_callback):
324         self.font = tkFont.Font(name="Helvetica", size=28)
325         self.label = tkinter.Label(
326             toplevel,
327             text="loading...",
328             relief='raised',
329             font=self.font,
330             justify=tkinter.CENTER,
331             anchor=tkinter.CENTER
332         )
333         self.bind_click(display_log_callback)
334         self.pack()
335
336     def bind_click(self, display_log_callback):
337         """Binds the left mous button click event to trigger the logg_windows\
338         diplay method"""
339         self.label.bind(
340             '<Button-1>',
341             display_log_callback
342         )
343
344     def pack(self):
345         "packs the lable"
346         self.label.pack(
347             expand=1,
348             fill='both'
349         )
350
351     def update(self, text, color):
352         "updates the collor and displayed text."
353         self.label.configure(
354             bg=color,
355             activebackground=color,
356             text=text
357         )
358
359 class TkGUI(object):
360     """Connect pytddmon engine to Tkinter GUI toolkit"""
361     def __init__(self, pytddmon, tkinter, tkFont):
362         self.pytddmon = pytddmon
363         self.tkinter = tkinter
364         self.tkFont = tkFont
365         self.color_picker = ColorPicker()
366         self.root = None
367         self.building_root()
368         self.title_font = None
369         self.building_fonts()
370         self.frame = None
371         self.building_frame()
372         self.button = TKGUIButton(
373             tkinter,
374             tkFont,
375             self.frame,
376             self.display_log_message
377         )
378         self.status_bar = None
379         self.building_status_bar()
380         self.frame.grid()
381         self.message_window = None
382         self.text = None
383
384         if ON_WINDOWS:
385             buttons_width = 25
386         else:
387             buttons_width = 75
388         self.root.minsize(
389             width=self.title_font.measure(
390                 self.pytddmon.project_name
391             ) + buttons_width, 
392             height=0
393         )
394         self.frame.pack(expand=1, fill="both")
395         self.create_text_window()
396         self.update_text_window()
397
398     def building_root(self):
399         """take hold of the tk root object as self.root"""
400         self.root = self.tkinter.Tk()
401         self.root.wm_attributes("-topmost", 1)
402         if ON_WINDOWS:
403             self.root.attributes("-toolwindow", 1)
404             print("Minimize me!")
405
406     def building_fonts(self):
407         "building fonts"
408         self.title_font = self.tkFont.nametofont("TkCaptionFont")
409
410     def building_frame(self):
411         """Creates a frame and assigns it to self.frame"""
412         # Calculate the width of the tilte + buttons
413         self.frame = self.tkinter.Frame(
414             self.root
415         )
416         # Sets the title of the gui
417         self.frame.master.title(self.pytddmon.project_name)
418         # Forces the window to not be resizeable
419         self.frame.master.resizable(False, False)
420         self.frame.pack(expand=1, fill="both")
421
422     def building_status_bar(self):
423         """Add status bar and assign it to self.status_bar"""
424         self.status_bar = self.tkinter.Label(
425             self.frame,
426             text="n/a"
427         )
428         self.status_bar.pack(expand=1, fill="both")
429
430     def _update_and_get_color(self):
431         "Calculate the current color and trigger pulse"
432         self.color_picker.set_result(
433             self.pytddmon.total_tests_passed,
434             self.pytddmon.total_tests_run,
435         )
436         light, color = self.color_picker.pick()
437         rgb = self.color_picker.translate_color(light, color)
438         self.color_picker.pulse()
439         return rgb
440
441     def _get_text(self):
442         "Calculates the text to show the user(passed/total or Error!)"
443         if self.pytddmon.total_tests_run.imag!=0:
444             text = "?ERROR"
445         else:
446             text = "%r/%r" % (
447                 self.pytddmon.total_tests_passed,
448                 self.pytddmon.total_tests_run
449             )
450         return text
451
452     def update(self):
453         """updates the tk gui"""
454         rgb = self._update_and_get_color()
455         text = self._get_text()
456         self.button.update(text, rgb)
457         self.root.configure(bg=rgb)
458         self.update_status(self.pytddmon.get_status_message())
459     
460         if self.pytddmon.change_detected:
461             self.update_text_window()
462
463     def update_status(self, message):
464         self.status_bar.configure(
465             text=message
466         )
467         self.status_bar.update_idletasks()
468
469     def get_text_message(self):
470         """returns the logmessage from pytddmon"""
471         message = self.pytddmon.get_log()
472         return message
473
474     def create_text_window(self):
475         """creates new window and text widget""" 
476         win = self.tkinter.Toplevel()
477         if ON_WINDOWS:
478             win.attributes("-toolwindow", 1)
479         win.title('Details')
480         self.message_window = win
481         self.text = self.tkinter.Text(win)
482         self.message_window.withdraw()
483
484     def update_text_window(self):
485         """inserts/replaces the log message in the text widget"""
486         text = self.text
487         text['state'] = self.tkinter.NORMAL
488         text.delete(1.0, self.tkinter.END)
489         text.insert(self.tkinter.INSERT, self.get_text_message())
490         text['state'] = self.tkinter.DISABLED
491         text.pack(expand=1, fill='both')
492         text.focus_set()
493
494     def display_log_message(self, _arg):
495         """displays/close the logmessage from pytddmon in a window"""
496         if self.message_window.state() == 'normal':
497             self.message_window.state('iconic')
498         else:
499             self.message_window.state('normal')
500
501     def loop(self):
502         """the main loop"""
503         if self.pytddmon.get_and_set_change_detected():
504             self.update_status('Testing...')
505             self.pytddmon.run_tests()
506         self.update()
507         self.frame.after(750, self.loop)
508
509     def run(self):
510         """starts the main loop and goes into sleep"""
511         self.loop()
512         self.root.mainloop()
513
514 class ColorPicker:
515     """
516     ColorPicker decides the background color the pytddmon window,
517     based on the number of green tests, and the total number of
518     tests. Also, there is a "pulse" (light color, dark color),
519     to increase the feeling of continous testing.
520     """
521     color_table = {
522         (True, 'green'): '0f0',
523         (False, 'green'): '0c0',
524         (True, 'red'): 'f00',
525         (False, 'red'): 'c00',
526         (True, 'orange'): 'fc0',
527         (False, 'orange'): 'ca0',
528         (True, 'gray'): '999',
529         (False, 'gray'): '555'
530     }
531
532     def __init__(self):
533         self.color = 'green'
534         self.light = True
535
536     def pick(self):
537         "returns the tuple (light, color) with the types(bool ,str)"
538         return (self.light, self.color)
539
540     def pulse(self):
541         "updates the light state"
542         self.light = not self.light
543
544     def reset_pulse(self):
545         "resets the light state"
546         self.light = True
547
548     def set_result(self, green, total):
549         "calculates what color should be used and may reset the lightness"
550         old_color = self.color
551         self.color = 'green'
552         if green.imag or total.imag:
553             self.color = "orange"
554         elif green == total - 1:
555             self.color = 'red'
556         elif green < total - 1:
557             self.color = 'gray'
558         if self.color != old_color:
559             self.reset_pulse()
560
561     @classmethod
562     def translate_color(cls, light, color):
563         """helper method to create a rgb string"""
564         return "#" + cls.color_table[(light, color)]
565
566
567 def parse_commandline():
568     """
569     returns (files, test_mode) created from the command line arguments
570     passed to pytddmon.
571     """
572     parser = optparse.OptionParser()
573     parser.add_option(
574         "--log-and-exit",
575         action="store_true",
576         default=False,
577         help='Run all tests, write the results to "pytddmon.log" and exit.')
578     (options, args) = parser.parse_args()
579     return (args, options.log_and_exit)
580
581 def build_monitor(file_finder):
582     os.stat_float_times(False)
583     def get_file_size(file_path):
584         stat = os.stat(file_path)
585         return stat.st_size
586     def get_file_modtime(file_path):
587         stat = os.stat(file_path)
588         return stat.st_mtime
589     return Monitor(file_finder, get_file_size, get_file_modtime)
590
591 def run():
592     """
593     The main function: basic initialization and program start
594     """
595     cwd = os.getcwd()
596     
597     # Include current work directory in Python path
598     sys.path[:0] = [cwd]
599     
600     # Command line argument handling
601     (static_file_set, test_mode) = parse_commandline()
602     
603     # What files to monitor?
604     if not static_file_set:
605         regex = wildcard_to_regex("*.py")
606     else:
607         regex = '|'.join(static_file_set)
608     file_finder = FileFinder(cwd, regex)
609     
610     # The change detector: Monitor
611     monitor = build_monitor(file_finder)
612     
613     # Python engine ready to be setup
614     pytddmon = Pytddmon(
615         file_finder,
616         monitor,
617         project_name = os.path.basename(cwd)
618     )
619     
620     # Start the engine!
621     if not test_mode:
622         TkGUI(pytddmon, import_tkinter(), import_tkFont()).run()
623     else:
624         pytddmon.main()
625         with open("pytddmon.log", "w") as log_file:
626             log_file.write(
627                 "green=%r\ntotal=%r\n" % (
628                     pytddmon.total_tests_passed,
629                     pytddmon.total_tests_run
630                 )
631             )
632
633 if __name__ == '__main__':
634     run()