5 COPYRIGHT (c) 2009, 2010, 2011, 2012
6 .. in order of first contribution
8 Initial proof-of-concept pygame implementation.
10 Help with Tkinter implementation (replacing the pygame dependency)
12 Added always-on-top to the pytddmon window
14 Print(".") will not screw up test-counting (it did before)
16 Recursive discovery of tests
17 Refactoring to increase Pylint score from 6 to 9.5 out of 10 (!)
18 Numerous refactorings & other improvements
20 Python shebang at start of script, enabling "./pytddmon.py" on unix systems
22 Use integers instead of floats in file modified time (checksum calc)
23 Auto-update of text in Details window when the log changes
25 Status bar in pytddmon window, showing either last time tests were
26 run, or "Testing..." during a test run
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:
37 The above copyright notice and this permission notice shall be included in
38 all copies or substantial portions of the Software.
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
57 import multiprocessing
61 ON_PYTHON3 = sys.version_info[0] == 3
62 ON_WINDOWS = platform.system() == "Windows"
69 "The core class, all functionality is combined into this class"
74 project_name = "<pytddmon>"
76 self.file_finder = file_finder
77 self.project_name = project_name
78 self.monitor = monitor
79 self.change_detected = False
81 self.total_tests_run = 0
82 self.total_tests_passed = 0
83 self.last_testrun_time = -1
85 self.status_message = 'n/a'
90 """Runs all tests and updates state variables with results."""
92 file_paths = self.file_finder()
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.
102 pool = multiprocessing.Pool(processes = 1)
103 results = pool.map(run_tests_in_file, file_paths)
108 self.last_testrun_time = time.time() - start
110 now = time.strftime("%H:%M:%S", time.localtime())
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
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)
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
134 def get_and_set_change_detected(self):
135 self.change_detected = self.monitor.look_for_changes()
136 return self.change_detected
139 """This is the main loop body"""
140 self.change_detected = self.monitor.look_for_changes()
141 if self.change_detected:
145 """Access the log string created during test run"""
148 def get_status_message(self):
149 """Return message in status bar"""
150 return self.status_message
153 'Looks for file changes when prompted to'
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()
161 def get_snapshot(self):
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)
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
181 "Returns all files matching given regular expression from root downwards"
183 def __init__(self, root, regexp):
184 self.root = os.path.abspath(root)
188 return self.find_files()
190 def find_files(self):
191 "recursively finds files matching regexp"
193 for path, _folder, filenames in os.walk(self.root):
194 for filename in filenames:
195 if self.re_complete_match(filename):
197 os.path.abspath(os.path.join(path, filename))
201 def re_complete_match(self, string_to_match):
202 "full string regexp check"
203 return bool(re.match(self.regexp + "$", string_to_match))
205 wildcard_to_regex = fnmatch.translate
208 ## Finding & running tests
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
218 def wrapper(*a, **k):
224 return ('Exception(%s)' % a[0] , 0, 1j, traceback.format_exc())
228 def run_tests_in_file(file_path):
229 module = file_name_to_module("", file_path)
230 return run_module(module)
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)
237 def file_name_to_module(base_path, file_name):
238 r"""Converts filenames of files in packages to import friendly dot
242 >>> print(file_name_to_module("","pytddmon.pyw"))
244 >>> print(file_name_to_module("","pytddmon.py"))
246 >>> print(file_name_to_module("","tests/pytddmon.py"))
248 >>> print(file_name_to_module("","./tests/pytddmon.py"))
250 >>> print(file_name_to_module("",".\\tests\\pytddmon.py"))
253 ... file_name_to_module(
254 ... "/User/pytddmon\\ geek/pytddmon/",
255 ... "/User/pytddmon\\ geek/pytddmon/tests/pytddmon.py"
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()
265 module_words = words[:-1]
266 module_name = '.'.join(module_words)
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))
275 def find_unittests_in_module(module):
276 test_loader = unittest.TestLoader()
277 return test_loader.loadTestsFromName(module)
279 def find_doctests_in_module(module):
281 return doctest.DocTestSuite(module, optionflags = doctest.ELLIPSIS)
283 return unittest.TestSuite()
285 def run_suite(suite):
288 import io as StringIO
291 return StringIO.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)
305 def import_tkinter():
306 "imports tkinter from python 3.x or python 2.x"
308 import Tkinter as tkinter
314 "imports tkFont from python 3.x or python 2.x"
318 from tkinter import font as tkFont
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(
330 justify=tkinter.CENTER,
331 anchor=tkinter.CENTER
333 self.bind_click(display_log_callback)
336 def bind_click(self, display_log_callback):
337 """Binds the left mous button click event to trigger the logg_windows\
351 def update(self, text, color):
352 "updates the collor and displayed text."
353 self.label.configure(
355 activebackground=color,
360 """Connect pytddmon engine to Tkinter GUI toolkit"""
361 def __init__(self, pytddmon, tkinter, tkFont):
362 self.pytddmon = pytddmon
363 self.tkinter = tkinter
365 self.color_picker = ColorPicker()
368 self.title_font = None
369 self.building_fonts()
371 self.building_frame()
372 self.button = TKGUIButton(
376 self.display_log_message
378 self.status_bar = None
379 self.building_status_bar()
381 self.message_window = None
389 width=self.title_font.measure(
390 self.pytddmon.project_name
394 self.frame.pack(expand=1, fill="both")
395 self.create_text_window()
396 self.update_text_window()
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)
403 self.root.attributes("-toolwindow", 1)
404 print("Minimize me!")
406 def building_fonts(self):
408 self.title_font = self.tkFont.nametofont("TkCaptionFont")
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(
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")
422 def building_status_bar(self):
423 """Add status bar and assign it to self.status_bar"""
424 self.status_bar = self.tkinter.Label(
428 self.status_bar.pack(expand=1, fill="both")
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,
436 light, color = self.color_picker.pick()
437 rgb = self.color_picker.translate_color(light, color)
438 self.color_picker.pulse()
442 "Calculates the text to show the user(passed/total or Error!)"
443 if self.pytddmon.total_tests_run.imag!=0:
447 self.pytddmon.total_tests_passed,
448 self.pytddmon.total_tests_run
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())
460 if self.pytddmon.change_detected:
461 self.update_text_window()
463 def update_status(self, message):
464 self.status_bar.configure(
467 self.status_bar.update_idletasks()
469 def get_text_message(self):
470 """returns the logmessage from pytddmon"""
471 message = self.pytddmon.get_log()
474 def create_text_window(self):
475 """creates new window and text widget"""
476 win = self.tkinter.Toplevel()
478 win.attributes("-toolwindow", 1)
480 self.message_window = win
481 self.text = self.tkinter.Text(win)
482 self.message_window.withdraw()
484 def update_text_window(self):
485 """inserts/replaces the log message in the text widget"""
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')
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')
499 self.message_window.state('normal')
503 if self.pytddmon.get_and_set_change_detected():
504 self.update_status('Testing...')
505 self.pytddmon.run_tests()
507 self.frame.after(750, self.loop)
510 """starts the main loop and goes into sleep"""
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.
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'
537 "returns the tuple (light, color) with the types(bool ,str)"
538 return (self.light, self.color)
541 "updates the light state"
542 self.light = not self.light
544 def reset_pulse(self):
545 "resets the light state"
548 def set_result(self, green, total):
549 "calculates what color should be used and may reset the lightness"
550 old_color = self.color
552 if green.imag or total.imag:
553 self.color = "orange"
554 elif green == total - 1:
556 elif green < total - 1:
558 if self.color != old_color:
562 def translate_color(cls, light, color):
563 """helper method to create a rgb string"""
564 return "#" + cls.color_table[(light, color)]
567 def parse_commandline():
569 returns (files, test_mode) created from the command line arguments
572 parser = optparse.OptionParser()
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)
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)
586 def get_file_modtime(file_path):
587 stat = os.stat(file_path)
589 return Monitor(file_finder, get_file_size, get_file_modtime)
593 The main function: basic initialization and program start
597 # Include current work directory in Python path
600 # Command line argument handling
601 (static_file_set, test_mode) = parse_commandline()
603 # What files to monitor?
604 if not static_file_set:
605 regex = wildcard_to_regex("*.py")
607 regex = '|'.join(static_file_set)
608 file_finder = FileFinder(cwd, regex)
610 # The change detector: Monitor
611 monitor = build_monitor(file_finder)
613 # Python engine ready to be setup
617 project_name = os.path.basename(cwd)
622 TkGUI(pytddmon, import_tkinter(), import_tkFont()).run()
625 with open("pytddmon.log", "w") as log_file:
627 "green=%r\ntotal=%r\n" % (
628 pytddmon.total_tests_passed,
629 pytddmon.total_tests_run
633 if __name__ == '__main__':