--- 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)