Repository: ntoll/uflash

Issue:
Add command line flag to watch the script for changes and re-flash
I'm currently doing it with `inotifywait`, but I think that it might be usefull as part of uflash itself, with additional command line flag.

```bash
while inotifywait -e modify my_script.py; do
    uflash my_script.py
done
```

(BTW, :+1: for the guidelines for contribution!)

Discussion:

Patch:
diff --git a/AUTHORS b/AUTHORS
index 0817aa1..c4d08fc 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,3 +1,4 @@
 Nicholas H.Tollervey (ntoll@ntoll.org)
 Matt Wheeler (m@funkyhat.org)
 Tom Viner (uflash@viner.tv)
+Tom Gurion (nagasaki45@gmail.com)
diff --git a/README.rst b/README.rst
index 0451d63..610092f 100644
--- a/README.rst
+++ b/README.rst
@@ -57,6 +57,15 @@ the path to the Python script in as the first argument to the command::
     $ uflash my_script.py
     Flashing Python to: /media/ntoll/MICROBIT/micropython.hex
 
+You can let uflash watch for changes of your script. It will be flashed
+automatically every time you save it::
+
+    $ uflash -w my_script.py
+
+or::
+
+    $ uflash --watch my_script.py
+
 At this point uflash will try to automatically detect the path to the device.
 However, if you have several devices plugged in and/or know what the path on
 the filesystem to the BBC micro:bit already is, you can specify this as a
@@ -124,4 +133,3 @@ with development. Typing ``make`` on its own will list the options thus::
     make package - create a deployable package for the project.
     make publish - publish the project to PyPI.
     make docs - run sphinx to create project documentation.
-
diff --git a/uflash.py b/uflash.py
index 3082398..1bcb02d 100644
--- a/uflash.py
+++ b/uflash.py
@@ -12,6 +12,7 @@ import os
 import struct
 import sys
 from subprocess import check_output
+import time
 
 #: The magic start address in flash memory for a Python script.
 _SCRIPT_ADDR = 0x3e000
@@ -291,6 +292,27 @@ def extract(path_to_hex, output_path=None):
             print(python_script)
 
 
+def watch_file(path, func, *args, **kwargs):
+    """
+    Watch a file for changes by polling its last modification time. Call the
+    provided function with *args and **kwargs upon modification.
+    """
+    if not path:
+        raise ValueError('Please specify a file to watch')
+    print('Watching "{}" for changes'.format(path))
+    last_modification_time = os.path.getmtime(path)
+    try:
+        while True:
+            time.sleep(1)
+            new_modification_time = os.path.getmtime(path)
+            if new_modification_time == last_modification_time:
+                continue
+            func(*args, **kwargs)
+            last_modification_time = new_modification_time
+    except KeyboardInterrupt:
+        pass
+
+
 def main(argv=None):
     """
     Entry point for the command line tool 'uflash'.
@@ -316,10 +338,18 @@ def main(argv=None):
                             action='store_true',
                             help=("Extract python source from a hex file"
                                   " instead of creating the hex file."), )
+        parser.add_argument('-w', '--watch',
+                            action='store_true',
+                            help='Watch the source file for changes.')
         args = parser.parse_args(argv)
 
         if args.extract:
             extract(args.source, args.target)
+        elif args.watch:
+            watch_file(args.source, flash,
+                       path_to_python=args.source,
+                       paths_to_microbits=args.target,
+                       path_to_runtime=args.runtime)
         else:
             flash(path_to_python=args.source, paths_to_microbits=args.target,
                   path_to_runtime=args.runtime)


Test patch:
diff --git a/tests/test_uflash.py b/tests/test_uflash.py
index 6794b06..0428b4e 100644
--- a/tests/test_uflash.py
+++ b/tests/test_uflash.py
@@ -7,6 +7,8 @@ import os
 import os.path
 import sys
 import tempfile
+import time
+import threading
 
 import pytest
 import uflash
@@ -461,6 +463,19 @@ def test_main_named_args():
                                            path_to_runtime='baz.hex')
 
 
+def test_main_watch_flag():
+    """
+    The watch flag cause a call the correct function.
+    """
+    with mock.patch('uflash.watch_file') as mock_watch_file:
+        uflash.main(argv=['-w'])
+        mock_watch_file.assert_called_once_with(None,
+                                                uflash.flash,
+                                                path_to_python=None,
+                                                paths_to_microbits=[],
+                                                path_to_runtime=None)
+
+
 def test_extract_command():
     """
     Test the command-line script extract feature
@@ -515,3 +530,44 @@ def test_extract_command_no_source():
     """
     with pytest.raises(TypeError):
         uflash.extract(None, None)
+
+
+def test_watch_no_source():
+    """
+    If there is no source file the watch command should complain.
+    """
+    with pytest.raises(ValueError):
+        uflash.watch_file(None, lambda: "should never be called!")
+
+
+@mock.patch('uflash.time')
+@mock.patch('uflash.os')
+def test_watch_file(mock_os, mock_time):
+    """
+    Make sure that the callback is called each time the file changes.
+    """
+    # Our function will throw KeyboardInterrupt when called for the 2nd time,
+    # ending the watching gracefully.This will help in testing the
+    # watch_file function.
+    call_count = [0]
+
+    def func():
+        call_count[0] = call_count[0] + 1
+        if call_count[0] == 2:
+            raise KeyboardInterrupt()
+
+    # Instead of modifying any file, let's change the return value of
+    # os.path.getmtime. Start with initial value of 0.
+    mock_os.path.getmtime.return_value = 0
+
+    t = threading.Thread(target=uflash.watch_file,
+                         args=('path/to/file', func))
+    t.start()
+    time.sleep(0.01)
+    mock_os.path.getmtime.return_value = 1  # Simulate file change
+    time.sleep(0.01)
+    assert t.is_alive()
+    assert call_count[0] == 1
+    mock_os.path.getmtime.return_value = 2  # Simulate file change
+    t.join()
+    assert call_count[0] == 2