MicroPython: continued implementing the file manager widget. micropython

Tue, 23 Jul 2019 19:43:14 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 23 Jul 2019 19:43:14 +0200
branch
micropython
changeset 7081
ed510767c096
parent 7080
9a3adf033f90
child 7082
ec199ef0cfc6

MicroPython: continued implementing the file manager widget.

eric6.e4p file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonFileManagerWidget.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonFileManagerWidget.ui file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonFileSystem.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonFileSystemUtilities.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonReplWidget.py file | annotate | diff | comparison | revisions
--- a/eric6.e4p	Mon Jul 22 20:17:33 2019 +0200
+++ b/eric6.e4p	Tue Jul 23 19:43:14 2019 +0200
@@ -459,6 +459,7 @@
     <Source>eric6/MicroPython/MicroPythonDevices.py</Source>
     <Source>eric6/MicroPython/MicroPythonFileManagerWidget.py</Source>
     <Source>eric6/MicroPython/MicroPythonFileSystem.py</Source>
+    <Source>eric6/MicroPython/MicroPythonFileSystemUtilities.py</Source>
     <Source>eric6/MicroPython/MicroPythonGraphWidget.py</Source>
     <Source>eric6/MicroPython/MicroPythonReplWidget.py</Source>
     <Source>eric6/MicroPython/MicroPythonSerialPort.py</Source>
@@ -2304,14 +2305,14 @@
     <Other>docs/THANKS</Other>
     <Other>docs/changelog</Other>
     <Other>eric6.e4p</Other>
-    <Other>eric6/APIs/Python/zope-2.10.7.api</Other>
-    <Other>eric6/APIs/Python/zope-2.11.2.api</Other>
-    <Other>eric6/APIs/Python/zope-3.3.1.api</Other>
     <Other>eric6/APIs/Python3/PyQt4.bas</Other>
     <Other>eric6/APIs/Python3/PyQt5.bas</Other>
     <Other>eric6/APIs/Python3/QScintilla2.bas</Other>
     <Other>eric6/APIs/Python3/eric6.api</Other>
     <Other>eric6/APIs/Python3/eric6.bas</Other>
+    <Other>eric6/APIs/Python/zope-2.10.7.api</Other>
+    <Other>eric6/APIs/Python/zope-2.11.2.api</Other>
+    <Other>eric6/APIs/Python/zope-3.3.1.api</Other>
     <Other>eric6/APIs/QSS/qss.api</Other>
     <Other>eric6/APIs/Ruby/Ruby-1.8.7.api</Other>
     <Other>eric6/APIs/Ruby/Ruby-1.8.7.bas</Other>
--- a/eric6/MicroPython/MicroPythonFileManagerWidget.py	Mon Jul 22 20:17:33 2019 +0200
+++ b/eric6/MicroPython/MicroPythonFileManagerWidget.py	Tue Jul 23 19:43:14 2019 +0200
@@ -19,8 +19,9 @@
 
 from .Ui_MicroPythonFileManagerWidget import Ui_MicroPythonFileManagerWidget
 
-from .MicroPythonFileSystem import (
-    MicroPythonFileManager, decoratedName, mode2string, mtime2string
+from .MicroPythonFileSystem import MicroPythonFileManager
+from .MicroPythonFileSystemUtilities import (
+    mtime2string, mode2string, decoratedName, listdirStat
 )
 
 import UI.PixmapCache
@@ -44,6 +45,7 @@
         super(MicroPythonFileManagerWidget, self).__init__(parent)
         self.setupUi(self)
         
+        self.syncButton.setIcon(UI.PixmapCache.getIcon("2rightarrow"))
         self.putButton.setIcon(UI.PixmapCache.getIcon("1rightarrow"))
         self.getButton.setIcon(UI.PixmapCache.getIcon("1leftarrow"))
         self.localUpButton.setIcon(UI.PixmapCache.getIcon("1uparrow"))
@@ -61,9 +63,35 @@
         
         self.__fileManager.longListFiles.connect(self.__handleLongListFiles)
         self.__fileManager.currentDir.connect(self.__handleCurrentDir)
+        self.__fileManager.currentDirChanged.connect(self.__handleCurrentDir)
+        self.__fileManager.putFileDone.connect(self.__handlePutDone)
+        self.__fileManager.getFileDone.connect(self.__handleGetDone)
+        self.__fileManager.rsyncDone.connect(self.__handleRsyncDone)
+        self.__fileManager.rsyncMessages.connect(self.__handleRsyncMessages)
         
         self.__fileManager.longListFilesFailed.connect(self.__handleError)
         self.__fileManager.currentDirFailed.connect(self.__handleError)
+        self.__fileManager.currentDirChangeFailed.connect(self.__handleError)
+        self.__fileManager.putFileFailed.connect(self.__handleError)
+        self.__fileManager.getFileFailed.connect(self.__handleError)
+        self.__fileManager.rsyncFailed.connect(self.__handleError)
+        
+        # TODO: add context menus for panes (separate menus)
+        # local pane:
+        #  Change Directory
+        #
+        # device pane:
+        #  Change Directory
+        #  Create Directory
+        #  Delete Directory
+        #  Delete Directory Tree (= recursive delete)
+        #  ----------------------------
+        #  Delete File
+        #  ----------------------------
+        #  Synchronize Time
+        #  Show Time
+        #  ----------------------------
+        #  Show Version
     
     def start(self):
         """
@@ -139,28 +167,6 @@
         @param dirname name of the local directory to be listed
         @type str
         """     # __IGNORE_WARNING_D234__
-        def isvisible(name):
-            return not name.startswith(".") and not name.endswith("~")
-        
-        def stat(filename):
-            try:
-                rstat = os.lstat(filename)
-            except Exception:
-                rstat = os.stat(filename)
-            return tuple(rstat)
-        
-        def listdir_stat(dirname):
-            try:
-                if dirname:
-                    files = os.listdir(dirname)
-                else:
-                    files = os.listdir()
-            except OSError:
-                return []
-            if dirname in ('', '/'):
-                return [(f, stat(f)) for f in files if isvisible(f)]
-            return [(f, stat(os.path.join(dirname, f))) for f in files
-                    if isvisible(f)]
         
         if not dirname:
             dirname = os.getcwd()
@@ -168,7 +174,7 @@
             dirname = dirname[:-1]
         self.localCwd.setText(dirname)
         
-        filesStatList = listdir_stat(dirname)
+        filesStatList = listdirStat(dirname)
         filesList = [(
             decoratedName(f, s[0], os.path.isdir(os.path.join(dirname, f))),
             mode2string(s[0]),
@@ -198,7 +204,7 @@
         name = os.path.join(self.localCwd.text(), item.text(0))
         if name.endswith("/"):
             # directory names end with a '/'
-            self.__listLocalFiles(name)
+            self.__listLocalFiles(name[:-1])
         elif Utilities.MimeTypes.isTextFile(name):
             e5App().getObject("ViewManager").getEditor(name)
     
@@ -226,41 +232,177 @@
     @pyqtSlot(QTreeWidgetItem, int)
     def on_deviceFileTreeWidget_itemActivated(self, item, column):
         """
-        Slot documentation goes here.
+        Private slot to handle the activation of a device item.
         
-        @param item DESCRIPTION
+        If the item is a directory, the current working directory is changed
+        and the list will be re-populated for this directory.
+        
+        @param item reference to the activated item
         @type QTreeWidgetItem
-        @param column DESCRIPTION
+        @param column column of the activation
         @type int
         """
-        # TODO: not implemented yet
-        # chdir to activated directory triggering a pwd triggering a lls
+        name = os.path.join(self.deviceCwd.text(), item.text(0))
+        if name.endswith("/"):
+            # directory names end with a '/'
+            self.__fileManager.cd(name[:-1])
     
     @pyqtSlot()
     def on_deviceFileTreeWidget_itemSelectionChanged(self):
         """
-        Slot documentation goes here.
+        Private slot handling a change of selection in the local pane.
         """
-        # TODO: not implemented yet
+        enable = bool(len(self.deviceFileTreeWidget.selectedItems()))
+        if enable:
+            enable &= not (
+                self.deviceFileTreeWidget.selectedItems()[0].text(0)
+                .endswith("/"))
+        self.getButton.setEnabled(enable)
     
     @pyqtSlot()
     def on_deviceUpButton_clicked(self):
         """
-        Slot documentation goes here.
+        Private slot to go up one directory level on the device.
+        """
+        cwd = self.deviceCwd.text()
+        dirname = os.path.dirname(cwd)
+        self.__fileManager.cd(dirname)
+    
+    def __isFileInList(self, filename, treeWidget):
         """
-        # TODO: not implemented yet
-        raise NotImplementedError
+        Private method to check, if a file name is contained in a tree widget.
+        
+        @param filename name of the file to check
+        @type str
+        @param treeWidget reference to the tree widget to be checked against
+        @return flag indicating that the file name is present
+        @rtype bool
+        """
+        itemCount = treeWidget.topLevelItemCount()
+        if itemCount:
+            for row in range(itemCount):
+                if treeWidget.topLevelItem(row).text(0) == filename:
+                    return True
+        
+        return False
     
     @pyqtSlot()
     def on_putButton_clicked(self):
         """
-        Slot documentation goes here.
+        Private slot to copy the selected file to the connected device.
         """
-        # TODO: not implemented yet
+        selectedItems = self.localFileTreeWidget.selectedItems()
+        if selectedItems:
+            filename = selectedItems[0].text(0).strip()
+            if not filename.endswith("/"):
+                # it is really a file
+                if self.__isFileInList(filename, self.deviceFileTreeWidget):
+                    # ask for overwrite permission
+                    ok = E5MessageBox.yesNo(
+                        self,
+                        self.tr("Copy File to Device"),
+                        self.tr("<p>The file <b>{0}</b> exists on the"
+                                " connected device. Overwrite it?</p>")
+                        .format(filename)
+                    )
+                    if not ok:
+                        return
+                    # TODO: allow to rename the new file
+                
+                self.__fileManager.put(
+                    os.path.join(self.localCwd.text(), filename),
+                    os.path.join(self.deviceCwd.text(), filename)
+                )
     
     @pyqtSlot()
     def on_getButton_clicked(self):
         """
-        Slot documentation goes here.
+        Private slot to copy the selected file from the connected device.
+        """
+        selectedItems = self.deviceFileTreeWidget.selectedItems()
+        if selectedItems:
+            filename = selectedItems[0].text(0).strip()
+            if not filename.endswith("/"):
+                # it is really a file
+                if self.__isFileInList(filename, self.localFileTreeWidget):
+                    # ask for overwrite permission
+                    ok = E5MessageBox.yesNo(
+                        self,
+                        self.tr("Copy File from Device"),
+                        self.tr("<p>The file <b>{0}</b> exists locally."
+                                " Overwrite it?</p>")
+                        .format(filename)
+                    )
+                    if not ok:
+                        return
+                    # TODO: allow to rename the new file
+                
+                self.__fileManager.get(
+                    os.path.join(self.deviceCwd.text(), filename),
+                    os.path.join(self.localCwd.text(), filename)
+                )
+    
+    @pyqtSlot(str, str)
+    def __handlePutDone(self, localFile, deviceFile):
+        """
+        Private slot handling a successful copy of a file to the device.
+        
+        @param localFile name of the local file
+        @type str
+        @param deviceFile name of the file on the device
+        @type str
+        """
+        self.__fileManager.lls(self.deviceCwd.text())
+    
+    @pyqtSlot(str, str)
+    def __handleGetDone(self, deviceFile, localFile):
         """
-        # TODO: not implemented yet
+        Private slot handling a successful copy of a file from the device.
+        
+        @param deviceFile name of the file on the device
+        @type str
+        @param localFile name of the local file
+        @type str
+        """
+        self.__listLocalFiles(self.localCwd.text())
+    
+    @pyqtSlot()
+    def on_syncButton_clicked(self):
+        """
+        Private slot to synchronize the local directory to the device.
+        """
+        self.__fileManager.rsync(
+            self.localCwd.text(),
+            self.deviceCwd.text(),
+            mirror=True
+        )
+    
+    @pyqtSlot(str, str)
+    def __handleRsyncDone(self, localDir, deviceDir):
+        """
+        Private method to handle the completion of the rsync operation.
+        
+        @param localDir name of the local directory
+        @type str
+        @param deviceDir name of the device directory
+        @type str
+        """
+        self.__listLocalFiles(self.localCwd.text())
+        self.__fileManager.lls(self.deviceCwd.text())
+    
+    @pyqtSlot(list)
+    def __handleRsyncMessages(self, messages):
+        """
+        Private slot to handle messages from the rsync operation.
+        
+        @param messages list of message generated by the rsync operation
+        @type list
+        """
+        E5MessageBox.information(
+            self,
+            self.tr("rsync Messages"),
+            self.tr("""<p>rsync gave the following messages</p>"""
+                    """<ul><li>{0}</li></ul>""").format(
+                "</li><li>".join(messages)
+            )
+        )
--- a/eric6/MicroPython/MicroPythonFileManagerWidget.ui	Mon Jul 22 20:17:33 2019 +0200
+++ b/eric6/MicroPython/MicroPythonFileManagerWidget.ui	Tue Jul 23 19:43:14 2019 +0200
@@ -92,6 +92,13 @@
       </spacer>
      </item>
      <item>
+      <widget class="QToolButton" name="syncButton">
+       <property name="toolTip">
+        <string>Press to sync the local directory to the device directory</string>
+       </property>
+      </widget>
+     </item>
+     <item>
       <widget class="QToolButton" name="putButton">
        <property name="toolTip">
         <string>Press to copy the selected file to the device</string>
@@ -206,6 +213,7 @@
  <tabstops>
   <tabstop>localFileTreeWidget</tabstop>
   <tabstop>deviceFileTreeWidget</tabstop>
+  <tabstop>syncButton</tabstop>
   <tabstop>putButton</tabstop>
   <tabstop>getButton</tabstop>
   <tabstop>localUpButton</tabstop>
--- a/eric6/MicroPython/MicroPythonFileSystem.py	Mon Jul 22 20:17:33 2019 +0200
+++ b/eric6/MicroPython/MicroPythonFileSystem.py	Tue Jul 23 19:43:14 2019 +0200
@@ -11,20 +11,23 @@
 
 import ast
 import time
+import os
 import stat
-import os
 
 from PyQt5.QtCore import pyqtSlot, pyqtSignal, QObject, QThread
 
 from .MicroPythonSerialPort import MicroPythonSerialPort
+from .MicroPythonFileSystemUtilities import (
+    mtime2string, mode2string, decoratedName, listdirStat
+)
 
 
 class MicroPythonFileSystem(QObject):
     """
     Class implementing some file system commands for MicroPython.
     
-    Some FTP like commands are provided to perform operations on the file
-    system of a connected MicroPython device. Supported commands are:
+    Commands are provided to perform operations on the file system of a
+    connected MicroPython device. Supported commands are:
     <ul>
     <li>ls: directory listing</li>
     <li>lls: directory listing with meta data</li>
@@ -33,9 +36,16 @@
     <li>put: copy a file to the connected device</li>
     <li>get: get a file from the connected device</li>
     <li>rm: remove a file from the connected device</li>
+    <li>rmrf: remove a file/directory recursively (like 'rm -rf' in bash)
     <li>mkdir: create a new directory</li>
     <li>rmdir: remove an empty directory</li>
+    </ul>
+    
+    There are additional commands related to time and version.
+    <ul>
     <li>version: get version info about MicroPython</li>
+    <li>syncTime: synchronize the time of the connected device</li>
+    <li>showTime: show the current time of the connected device</li>
     </ul>
     """
     def __init__(self, parent=None):
@@ -176,16 +186,20 @@
             raise IOError(self.__shortError(err))
         return ast.literal_eval(out.decode("utf-8"))
     
-    def lls(self, dirname=""):
+    def lls(self, dirname="", fullstat=False):
         """
         Public method to get a long directory listing of the connected device
         including meta data.
         
         @param dirname name of the directory to be listed
         @type str
-        @return list containing the the directory listing with tuple entries
-            of the name and and a tuple of mode, size and time
-        @rtype tuple of str
+        @param fullstat flag indicating to return the full stat() tuple
+        @type bool
+        @return list containing the directory listing with tuple entries of
+            the name and and a tuple of mode, size and time (if fullstat is
+            false) or the complete stat() tuple. 'None' is returned in case the
+            directory doesn't exist.
+        @rtype tuple of (str, tuple)
         @exception IOError raised to indicate an issue with the device
         """
         commands = [
@@ -203,7 +217,7 @@
                 "    try:",
                 "        files = os.listdir(dirname)",
                 "    except OSError:",
-                "        return []",
+                "        return None",
                 "    if dirname in ('', '/'):",
                 "        return list((f, stat(f)) for f in files)",
                 "    return list((f, stat(dirname + '/' + f)) for f in files)",
@@ -214,7 +228,13 @@
         if err:
             raise IOError(self.__shortError(err))
         fileslist = ast.literal_eval(out.decode("utf-8"))
-        return [(f, (s[0], s[6], s[8])) for f, s in fileslist]
+        if fileslist is None:
+            return None
+        else:
+            if fullstat:
+                return fileslist
+            else:
+                return [(f, (s[0], s[6], s[8])) for f, s in fileslist]
     
     def cd(self, dirname):
         """
@@ -269,6 +289,54 @@
         if err:
             raise IOError(self.__shortError(err))
     
+    def rmrf(self, name, recursive=False, force=False):
+        """
+        Public method to remove a file or directory recursively.
+        
+        @param name of the file or directory to remove
+        @type str
+        @param recursive flag indicating a recursive deletion
+        @type bool
+        @param force flag indicating to ignore errors
+        @type bool
+        @return flag indicating success
+        @rtype bool
+        @exception IOError raised to indicate an issue with the device
+        """
+        assert name
+        
+        commands = [
+            "import os"
+            "\n".join([
+                "def remove_file(name, recursive=False, force=False):",
+                "    try:",
+                "        mode = os.stat(name)[0]",
+                "        if mode & 0x4000 != 0:",
+                "            if recursive:",
+                "                for file in os.listdir(name):",
+                "                    success = remove_file(name + '/' + file,"
+                " recursive, force)",
+                "                    if not success and not force:",
+                "                        return False",
+                "                os.rmdir(name)",
+                "            else:",
+                "                if not force:",
+                "                    return False",
+                "        else:",
+                "            os.remove(name)",
+                "    except:",
+                "        if not force:",
+                "            return False",
+                "    return True",
+            ]),
+            "print(remove_file('{0}', {1}, {2}))".format(name, recursive,
+                                                         force),
+        ]
+        out, err = self.__execute(commands)
+        if err:
+            raise IOError(self.__shortError(err))
+        return ast.literal_eval(out.decode("utf-8"))
+    
     def mkdir(self, dirname):
         """
         Public method to create a new directory.
@@ -376,7 +444,7 @@
             "result = True",
             "\n".join([
                 "while result:",
-                "    result = r(32)"
+                "    result = r(32)",
                 "    if result:",
                 "        u.write(result)",
             ]),
@@ -391,8 +459,6 @@
             hostFile.write(out)
         return True
     
-    # TODO: add rsync function
-    
     def version(self):
         """
         Public method to get the MicroPython version information of the
@@ -464,6 +530,24 @@
         out, err = self.__execute(commands)
         if err:
             raise IOError(self.__shortError(err))
+    
+    def showTime(self):
+        """
+        Public method to get the current time of the device.
+        
+        @return time of the device
+        @rtype str
+        @exception IOError raised to indicate an issue with the device
+        """
+        commands = [
+            "import time",
+            "print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime()))",
+            # __IGNORE_WARNING_M601__
+        ]
+        out, err = self.__execute(commands)
+        if err:
+            raise IOError(self.__shortError(err))
+        return out.decode("utf-8").strip()
 
 
 class MicroPythonFileManager(QObject):
@@ -475,35 +559,49 @@
         name, mode, size and time for each directory entry
     @signal currentDir(dirname) emitted to report the current directory of the
         device
+    @signal currentDirChanged(dirname) emitted to report back a change of the
+        current directory
     @signal getFileDone(deviceFile, localFile) emitted after the file was
         fetched from the connected device and written to the local file system
     @signal putFileDone(localFile, deviceFile) emitted after the file was
         copied to the connected device
     @signal deleteFileDone(deviceFile) emitted after the file has been deleted
         on the connected device
+    @signal rsyncDone(localName, deviceName) emitted after the rsync operation
+        has been completed
+    @signal rsyncMessages(list) emitted with a list of messages
     
     @signal longListFilesFailed(exc) emitted with a failure message to indicate
         a failed long listing operation
     @signal currentDirFailed(exc) emitted with a failure message to indicate
         that the current directory is not available
+    @signal currentDirChangeFailed(exc) emitted with a failure message to
+        indicate that the current directory could not be changed
     @signal getFileFailed(exc) emitted with a failure message to indicate that
         the file could not be fetched
     @signal putFileFailed(exc) emitted with a failure message to indicate that
         the file could not be copied
     @signal deleteFileFailed(exc) emitted with a failure message to indicate
         that the file could not be deleted on the device
+    @signal rsyncFailed(exc) emitted with a failure message to indicate that
+        the rsync operation could not be completed
     """
     longListFiles = pyqtSignal(tuple)
     currentDir = pyqtSignal(str)
+    currentDirChanged = pyqtSignal(str)
     getFileDone = pyqtSignal(str, str)
     putFileDone = pyqtSignal(str, str)
     deleteFileDone = pyqtSignal(str)
+    rsyncDone = pyqtSignal(str, str)
+    rsyncMessages = pyqtSignal(list)
     
     longListFilesFailed = pyqtSignal(str)
     currentDirFailed = pyqtSignal(str)
+    currentDirChangeFailed = pyqtSignal(str)
     getFileFailed = pyqtSignal(str)
     putFileFailed = pyqtSignal(str)
     deleteFileFailed = pyqtSignal(str)
+    rsyncFailed = pyqtSignal(str)
     
     def __init__(self, port, parent=None):
         """
@@ -566,6 +664,20 @@
             self.currentDirFailed.emit(str(exc))
     
     @pyqtSlot(str)
+    def cd(self, dirname):
+        """
+        Public slot to change the current directory of the device.
+        
+        @param dirname name of the desired current directory
+        @type str
+        """
+        try:
+            self.__fs.cd(dirname)
+            self.currentDirChanged.emit(dirname)
+        except Exception as exc:
+            self.currentDirChangeFailed.emit(str(exc))
+    
+    @pyqtSlot(str)
     @pyqtSlot(str, str)
     def get(self, deviceFileName, hostFileName=""):
         """
@@ -599,9 +711,9 @@
         """
         try:
             self.__fs.put(hostFileName, deviceFileName)
-            self.putFileDone(hostFileName, deviceFileName)
+            self.putFileDone.emit(hostFileName, deviceFileName)
         except Exception as exc:
-            self.putFileFailed(str(exc))
+            self.putFileFailed.emit(str(exc))
     
     @pyqtSlot(str)
     def delete(self, deviceFileName):
@@ -615,53 +727,138 @@
             self.__fs.rm(deviceFileName)
             self.deleteFileDone.emit(deviceFileName)
         except Exception as exc:
-            self.deleteFileFailed(str(exc))
-
-##################################################################
-## Utility methods below
-##################################################################
-
-
-def mtime2string(mtime):
-    """
-    Function to convert a time value to a string representation.
-    
-    @param mtime time value
-    @type int
-    @return string representation of the given time
-    @rtype str
-    """
-    return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
-
-
-def mode2string(mode):
-    """
-    Function to convert a mode value to a string representation.
+            self.deleteFileFailed.emit(str(exc))
     
-    @param mode mode value
-    @type int
-    @return string representation of the given mode value
-    @rtype str
-    """
-    return stat.filemode(mode)
-
-
-def decoratedName(name, mode, isDir=False):
-    """
-    Function to decorate the given name according to the given mode.
+    def __rsync(self, hostDirectory, deviceDirectory, mirror=True):
+        """
+        Private method to synchronize a local directory to the device.
+        
+        @param hostDirectory name of the local directory
+        @type str
+        @param deviceDirectory name of the directory on the device
+        @type str
+        @param mirror flag indicating to mirror the local directory to
+            the device directory
+        @type bool
+        @return tuple containing a list of messages and list of errors
+        @rtype tuple of (list of str, list of str)
+        """
+        messages = []
+        errors = []
+        
+        if not os.isdir(hostDirectory):
+            return ([], [self.tr(
+                "The given name '{0}' is not a directory or does not exist.")
+                .format(hostDirectory)
+            ])
+        
+        sourceDict = {}
+        sourceFiles = listdirStat(hostDirectory)
+        for name, nstat in sourceFiles:
+            sourceDict[name] = nstat
+        
+        destinationDict = {}
+        try:
+            destinationFiles = self.__fs.lls(deviceDirectory, fullstat=True)
+        except Exception as exc:
+            return ([], [str(exc)])
+        if destinationFiles is None:
+            # the destination directory does not exist
+            try:
+                self.__fs.mkdir(deviceDirectory)
+            except Exception as exc:
+                return ([], [str(exc)])
+        else:
+            for name, nstat in destinationFiles:
+                destinationDict[name] = nstat
+        
+        destinationSet = set(destinationDict.keys())
+        sourceSet = set(sourceDict.keys())
+        toAdd = sourceSet - destinationSet                  # add to dev
+        toDelete = destinationSet - sourceSet               # delete from dev
+        toUpdate = destinationSet.intersection(sourceSet)   # update files
+        
+        for sourceBasename in toAdd:
+            # name exists in source but not in device
+            sourceFilename = os.path.join(hostDirectory, sourceBasename)
+            destFilename = deviceDirectory + "/" + sourceBasename
+            if os.path.isfile(sourceFilename):
+                try:
+                    self.__fs.put(sourceFilename, destFilename)
+                except Exception as exc:
+                    messages.append(str(exc))
+            if os.path.isdir(sourceFilename):
+                # recurse
+                msg, err = self.__rsync(sourceFilename, destFilename,
+                                        mirror=mirror)
+                messages.extend(msg)
+                errors.extend(err)
+        
+        if mirror:
+            for destBasename in toDelete:
+                # name exists in device but not local, delete
+                destFilename = deviceDirectory + "/" + destBasename
+                try:
+                    self.__fs.rmrf(destFilename, recursive=True, force=True)
+                except Exception as exc:
+                    # ignore errors here
+                    messages.append(str(exc))
+        
+        for sourceBasename in toUpdate:
+            # names exist in both; do an update
+            sourceStat = sourceDict[sourceBasename]
+            destStat = destinationDict[sourceBasename]
+            sourceFilename = os.path.join(hostDirectory, sourceBasename)
+            destFilename = deviceDirectory + "/" + sourceBasename
+            destMode = destStat[0]
+            if os.path.isdir(sourceFilename):
+                if stat.S_ISDIR(destMode):
+                    # both are directories => recurse
+                    msg, err = self.__rsync(sourceFilename, destFilename,
+                                            mirror=mirror)
+                    messages.extend(msg)
+                    errors.extend(err)
+                else:
+                    messages.append(self.tr(
+                        "Source '{0}' is a directory and destination '{1}'"
+                        " is a file. Ignoring it."
+                    ).format(sourceFilename, destFilename))
+            else:
+                if stat.S_ISDIR(destMode):
+                    messages.append(self.tr(
+                        "Source '{0}' is a file and destination '{1}' is"
+                        " a directory. Ignoring it."
+                    ).format(sourceFilename, destFilename))
+                else:
+                    if sourceStat[8] > destStat[8]:     # mtime
+                        messages.append(self.tr(
+                            "'{0}' is newer than '{1}' - copying"
+                        ).format(sourceFilename, destFilename))
+                        try:
+                            self.__fs.put(sourceFilename, destFilename)
+                        except Exception as exc:
+                            messages.append(str(exc))
+        
+        return messages, errors
     
-    @param name file or directory name
-    @type str
-    @param mode mode value
-    @type int
-    @param isDir flag indicating that name is a directory
-    @type bool
-    @return decorated file or directory name
-    @rtype str
-    """
-    if stat.S_ISDIR(mode) or isDir:
-        # append a '/' for directories
-        return name + "/"
-    else:
-        # no change
-        return name
+    def rsync(self, hostDirectory, deviceDirectory, mirror=True):
+        """
+        Public method to synchronize a local directory to the device.
+        
+        @param hostDirectory name of the local directory
+        @type str
+        @param deviceDirectory name of the directory on the device
+        @type str
+        @param mirror flag indicating to mirror the local directory to
+            the device directory
+        @type bool
+        """
+        messages, errors = self.__rsync(hostDirectory, deviceDirectory,
+                                        mirror=mirror)
+        if errors:
+            self.rsyncFailed.emit("\n".join(errors))
+        
+        if messages:
+            self.rsyncMessages.emit(messages)
+        
+        self.rsyncDone.emit(hostDirectory, deviceDirectory)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric6/MicroPython/MicroPythonFileSystemUtilities.py	Tue Jul 23 19:43:14 2019 +0200
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing some file system utility functions.
+"""
+
+from __future__ import unicode_literals
+
+import time
+import stat
+import os
+
+
+def mtime2string(mtime):
+    """
+    Function to convert a time value to a string representation.
+    
+    @param mtime time value
+    @type int
+    @return string representation of the given time
+    @rtype str
+    """
+    return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
+
+
+def mode2string(mode):
+    """
+    Function to convert a mode value to a string representation.
+    
+    @param mode mode value
+    @type int
+    @return string representation of the given mode value
+    @rtype str
+    """
+    return stat.filemode(mode)
+
+
+def decoratedName(name, mode, isDir=False):
+    """
+    Function to decorate the given name according to the given mode.
+    
+    @param name file or directory name
+    @type str
+    @param mode mode value
+    @type int
+    @param isDir flag indicating that name is a directory
+    @type bool
+    @return decorated file or directory name
+    @rtype str
+    """
+    if stat.S_ISDIR(mode) or isDir:
+        # append a '/' for directories
+        return name + "/"
+    else:
+        # no change
+        return name
+
+
+def isVisible(name):
+    """
+    Function to check, if a filesystem entry is a hidden file or directory.
+    
+    @param name name to be checked
+    @type str
+    @return flag indicating a visible filesystem entry
+    @rtype bool
+    """
+    return not name.startswith(".") and not name.endswith("~")
+
+
+def fstat(filename):
+    """
+    Function to get the stat() of file.
+    
+    @param filename name of the file
+    @type str
+    @return tuple containing the stat() result
+    @rtype tuple
+    """
+    try:
+        rstat = os.lstat(filename)
+    except Exception:
+        rstat = os.stat(filename)
+    return tuple(rstat)
+
+
+def listdirStat(dirname):
+    """
+    Function to get a list of directory entries and associated stat() tuples.
+    
+    @param dirname name of the directory to list
+    @type str
+    @return list of tuples containing the entry name and the associated
+        stat() tuple
+    @rtype list of tuple of (str, tuple)
+    """
+    try:
+        if dirname:
+            files = os.listdir(dirname)
+        else:
+            files = os.listdir()
+    except OSError:
+        return []
+    
+    if dirname in ('', '/'):
+        return [(f, fstat(f)) for f in files if isVisible(f)]
+    
+    return [(f, fstat(os.path.join(dirname, f))) for f in files
+            if isVisible(f)]
--- a/eric6/MicroPython/MicroPythonReplWidget.py	Mon Jul 22 20:17:33 2019 +0200
+++ b/eric6/MicroPython/MicroPythonReplWidget.py	Tue Jul 23 19:43:14 2019 +0200
@@ -334,6 +334,7 @@
         menu.addAction(self.tr("Copy"), self.replEdit.copy, copyKeys)
         menu.addAction(self.tr("Paste"), self.__paste, pasteKeys)
         menu.addSeparator()
+        # TODO: add device specific context menu entries
         menu.exec_(self.replEdit.mapToGlobal(pos))
     
     def setConnected(self, connected):
@@ -416,8 +417,6 @@
         
         if self.__plotterRunning:
             self.on_chartButton_clicked()
-        
-        # TODO: add more
     
     def __disconnectSerial(self):
         """

eric ide

mercurial