|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing some file system commands for MicroPython. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import stat |
|
12 import shutil |
|
13 |
|
14 from PyQt6.QtCore import pyqtSlot, pyqtSignal, QObject |
|
15 |
|
16 from .MicroPythonFileSystemUtilities import ( |
|
17 mtime2string, mode2string, decoratedName, listdirStat |
|
18 ) |
|
19 |
|
20 |
|
21 class MicroPythonFileManager(QObject): |
|
22 """ |
|
23 Class implementing an interface to the device file system commands with |
|
24 some additional sugar. |
|
25 |
|
26 @signal longListFiles(result) emitted with a tuple of tuples containing the |
|
27 name, mode, size and time for each directory entry |
|
28 @signal currentDir(dirname) emitted to report the current directory of the |
|
29 device |
|
30 @signal currentDirChanged(dirname) emitted to report back a change of the |
|
31 current directory |
|
32 @signal getFileDone(deviceFile, localFile) emitted after the file was |
|
33 fetched from the connected device and written to the local file system |
|
34 @signal putFileDone(localFile, deviceFile) emitted after the file was |
|
35 copied to the connected device |
|
36 @signal deleteFileDone(deviceFile) emitted after the file has been deleted |
|
37 on the connected device |
|
38 @signal rsyncDone(localName, deviceName) emitted after the rsync operation |
|
39 has been completed |
|
40 @signal rsyncProgressMessage(msg) emitted to send a message about what |
|
41 rsync is doing |
|
42 @signal removeDirectoryDone() emitted after a directory has been deleted |
|
43 @signal createDirectoryDone() emitted after a directory was created |
|
44 @signal fsinfoDone(fsinfo) emitted after the file system information was |
|
45 obtained |
|
46 |
|
47 @signal error(exc) emitted with a failure message to indicate a failure |
|
48 during the most recent operation |
|
49 """ |
|
50 longListFiles = pyqtSignal(tuple) |
|
51 currentDir = pyqtSignal(str) |
|
52 currentDirChanged = pyqtSignal(str) |
|
53 getFileDone = pyqtSignal(str, str) |
|
54 putFileDone = pyqtSignal(str, str) |
|
55 deleteFileDone = pyqtSignal(str) |
|
56 rsyncDone = pyqtSignal(str, str) |
|
57 rsyncProgressMessage = pyqtSignal(str) |
|
58 removeDirectoryDone = pyqtSignal() |
|
59 createDirectoryDone = pyqtSignal() |
|
60 fsinfoDone = pyqtSignal(tuple) |
|
61 |
|
62 error = pyqtSignal(str, str) |
|
63 |
|
64 def __init__(self, commandsInterface, parent=None): |
|
65 """ |
|
66 Constructor |
|
67 |
|
68 @param commandsInterface reference to the commands interface object |
|
69 @type MicroPythonCommandsInterface |
|
70 @param parent reference to the parent object |
|
71 @type QObject |
|
72 """ |
|
73 super().__init__(parent) |
|
74 |
|
75 self.__commandsInterface = commandsInterface |
|
76 |
|
77 @pyqtSlot(str) |
|
78 def lls(self, dirname, showHidden=False): |
|
79 """ |
|
80 Public slot to get a long listing of the given directory. |
|
81 |
|
82 @param dirname name of the directory to list |
|
83 @type str |
|
84 @param showHidden flag indicating to show hidden files as well |
|
85 @type bool |
|
86 """ |
|
87 try: |
|
88 filesList = self.__commandsInterface.lls( |
|
89 dirname, showHidden=showHidden) |
|
90 result = [(decoratedName(name, mode), |
|
91 mode2string(mode), |
|
92 str(size), |
|
93 mtime2string(mtime, adjustEpoch=True)) for |
|
94 name, (mode, size, mtime) in filesList] |
|
95 self.longListFiles.emit(tuple(result)) |
|
96 except Exception as exc: |
|
97 self.error.emit("lls", str(exc)) |
|
98 |
|
99 @pyqtSlot() |
|
100 def pwd(self): |
|
101 """ |
|
102 Public slot to get the current directory of the device. |
|
103 """ |
|
104 try: |
|
105 pwd = self.__commandsInterface.pwd() |
|
106 self.currentDir.emit(pwd) |
|
107 except Exception as exc: |
|
108 self.error.emit("pwd", str(exc)) |
|
109 |
|
110 @pyqtSlot(str) |
|
111 def cd(self, dirname): |
|
112 """ |
|
113 Public slot to change the current directory of the device. |
|
114 |
|
115 @param dirname name of the desired current directory |
|
116 @type str |
|
117 """ |
|
118 try: |
|
119 self.__commandsInterface.cd(dirname) |
|
120 self.currentDirChanged.emit(dirname) |
|
121 except Exception as exc: |
|
122 self.error.emit("cd", str(exc)) |
|
123 |
|
124 @pyqtSlot(str) |
|
125 @pyqtSlot(str, str) |
|
126 def get(self, deviceFileName, hostFileName=""): |
|
127 """ |
|
128 Public slot to get a file from the connected device. |
|
129 |
|
130 @param deviceFileName name of the file on the device |
|
131 @type str |
|
132 @param hostFileName name of the local file |
|
133 @type str |
|
134 """ |
|
135 if hostFileName and os.path.isdir(hostFileName): |
|
136 # only a local directory was given |
|
137 hostFileName = os.path.join(hostFileName, |
|
138 os.path.basename(deviceFileName)) |
|
139 try: |
|
140 self.__commandsInterface.get(deviceFileName, hostFileName) |
|
141 self.getFileDone.emit(deviceFileName, hostFileName) |
|
142 except Exception as exc: |
|
143 self.error.emit("get", str(exc)) |
|
144 |
|
145 @pyqtSlot(str) |
|
146 @pyqtSlot(str, str) |
|
147 def put(self, hostFileName, deviceFileName=""): |
|
148 """ |
|
149 Public slot to put a file onto the device. |
|
150 |
|
151 @param hostFileName name of the local file |
|
152 @type str |
|
153 @param deviceFileName name of the file on the connected device |
|
154 @type str |
|
155 """ |
|
156 try: |
|
157 self.__commandsInterface.put(hostFileName, deviceFileName) |
|
158 self.putFileDone.emit(hostFileName, deviceFileName) |
|
159 except Exception as exc: |
|
160 self.error.emit("put", str(exc)) |
|
161 |
|
162 @pyqtSlot(str) |
|
163 def delete(self, deviceFileName): |
|
164 """ |
|
165 Public slot to delete a file on the device. |
|
166 |
|
167 @param deviceFileName name of the file on the connected device |
|
168 @type str |
|
169 """ |
|
170 try: |
|
171 self.__commandsInterface.rm(deviceFileName) |
|
172 self.deleteFileDone.emit(deviceFileName) |
|
173 except Exception as exc: |
|
174 self.error.emit("delete", str(exc)) |
|
175 |
|
176 def __rsync(self, hostDirectory, deviceDirectory, mirror=True, |
|
177 localDevice=False, indentLevel=0): |
|
178 """ |
|
179 Private method to synchronize a local directory to the device. |
|
180 |
|
181 @param hostDirectory name of the local directory |
|
182 @type str |
|
183 @param deviceDirectory name of the directory on the device |
|
184 @type str |
|
185 @param mirror flag indicating to mirror the local directory to |
|
186 the device directory |
|
187 @type bool |
|
188 @param localDevice flag indicating device access via local file system |
|
189 @type bool |
|
190 @param indentLevel indentation level for progress messages |
|
191 @type int |
|
192 @return list of errors |
|
193 @rtype list of str |
|
194 """ |
|
195 indent = 4 * " " |
|
196 errors = [] |
|
197 |
|
198 if not os.path.isdir(hostDirectory): |
|
199 return [self.tr( |
|
200 "The given name '{0}' is not a directory or does not exist.") |
|
201 .format(hostDirectory) |
|
202 ] |
|
203 |
|
204 indentStr = indentLevel * indent |
|
205 self.rsyncProgressMessage.emit( |
|
206 self.tr("{1}Synchronizing <b>{0}</b>.") |
|
207 .format(deviceDirectory, indentStr) |
|
208 ) |
|
209 |
|
210 doneMessage = self.tr("{1}Done synchronizing <b>{0}</b>.").format( |
|
211 deviceDirectory, indentStr) |
|
212 |
|
213 sourceDict = {} |
|
214 sourceFiles = listdirStat(hostDirectory) |
|
215 for name, nstat in sourceFiles: |
|
216 sourceDict[name] = nstat |
|
217 |
|
218 destinationDict = {} |
|
219 if localDevice: |
|
220 if not os.path.isdir(deviceDirectory): |
|
221 # simply copy destination to source |
|
222 shutil.copytree(hostDirectory, deviceDirectory) |
|
223 self.rsyncProgressMessage.emit(doneMessage) |
|
224 return errors |
|
225 else: |
|
226 destinationFiles = listdirStat(deviceDirectory) |
|
227 for name, nstat in destinationFiles: |
|
228 destinationDict[name] = nstat |
|
229 else: |
|
230 try: |
|
231 destinationFiles = self.__commandsInterface.lls( |
|
232 deviceDirectory, fullstat=True) |
|
233 except Exception as exc: |
|
234 return [str(exc)] |
|
235 if destinationFiles is None: |
|
236 # the destination directory does not exist |
|
237 try: |
|
238 self.__commandsInterface.mkdir(deviceDirectory) |
|
239 except Exception as exc: |
|
240 return [str(exc)] |
|
241 else: |
|
242 for name, nstat in destinationFiles: |
|
243 destinationDict[name] = nstat |
|
244 |
|
245 destinationSet = set(destinationDict.keys()) |
|
246 sourceSet = set(sourceDict.keys()) |
|
247 toAdd = sourceSet - destinationSet # add to dev |
|
248 toDelete = destinationSet - sourceSet # delete from dev |
|
249 toUpdate = destinationSet.intersection(sourceSet) # update files |
|
250 indentStr = (indentLevel + 1) * indent |
|
251 |
|
252 if localDevice: |
|
253 for sourceBasename in toAdd: |
|
254 # name exists in source but not in device |
|
255 sourceFilename = os.path.join(hostDirectory, sourceBasename) |
|
256 destFilename = os.path.join(deviceDirectory, sourceBasename) |
|
257 self.rsyncProgressMessage.emit( |
|
258 self.tr("{1}Adding <b>{0}</b>...") |
|
259 .format(destFilename, indentStr)) |
|
260 if os.path.isfile(sourceFilename): |
|
261 shutil.copy2(sourceFilename, destFilename) |
|
262 elif os.path.isdir(sourceFilename): |
|
263 # recurse |
|
264 errs = self.__rsync(sourceFilename, destFilename, |
|
265 mirror=mirror, localDevice=localDevice, |
|
266 indentLevel=indentLevel + 1) |
|
267 # just note issues but ignore them otherwise |
|
268 errors.extend(errs) |
|
269 |
|
270 if mirror: |
|
271 for destBasename in toDelete: |
|
272 # name exists in device but not local, delete |
|
273 destFilename = os.path.join(deviceDirectory, destBasename) |
|
274 if os.path.isdir(destFilename): |
|
275 shutil.rmtree(destFilename, ignore_errors=True) |
|
276 elif os.path.isfile(destFilename): |
|
277 os.remove(destFilename) |
|
278 |
|
279 for sourceBasename in toUpdate: |
|
280 # names exist in both; do an update |
|
281 sourceStat = sourceDict[sourceBasename] |
|
282 destStat = destinationDict[sourceBasename] |
|
283 sourceFilename = os.path.join(hostDirectory, sourceBasename) |
|
284 destFilename = os.path.join(deviceDirectory, sourceBasename) |
|
285 destMode = destStat[0] |
|
286 if os.path.isdir(sourceFilename): |
|
287 if os.path.isdir(destFilename): |
|
288 # both are directories => recurs |
|
289 errs = self.__rsync(sourceFilename, destFilename, |
|
290 mirror=mirror, |
|
291 localDevice=localDevice, |
|
292 indentLevel=indentLevel + 1) |
|
293 # just note issues but ignore them otherwise |
|
294 errors.extend(errs) |
|
295 else: |
|
296 self.rsyncProgressMessage.emit( |
|
297 self.tr("Source <b>{0}</b> is a directory and" |
|
298 " destination <b>{1}</b> is a file." |
|
299 " Ignoring it.") |
|
300 .format(sourceFilename, destFilename) |
|
301 ) |
|
302 else: |
|
303 if os.path.isdir(destFilename): |
|
304 self.rsyncProgressMessage.emit( |
|
305 self.tr("Source <b>{0}</b> is a file and" |
|
306 " destination <b>{1}</b> is a directory." |
|
307 " Ignoring it.") |
|
308 .format(sourceFilename, destFilename) |
|
309 ) |
|
310 else: |
|
311 if sourceStat[8] > destStat[8]: # mtime |
|
312 self.rsyncProgressMessage.emit( |
|
313 self.tr("Updating <b>{0}</b>...") |
|
314 .format(destFilename) |
|
315 ) |
|
316 shutil.copy2(sourceFilename, destFilename) |
|
317 else: |
|
318 for sourceBasename in toAdd: |
|
319 # name exists in source but not in device |
|
320 sourceFilename = os.path.join(hostDirectory, sourceBasename) |
|
321 destFilename = ( |
|
322 "/" + sourceBasename |
|
323 if deviceDirectory == "/" else |
|
324 deviceDirectory + "/" + sourceBasename |
|
325 ) |
|
326 self.rsyncProgressMessage.emit( |
|
327 self.tr("{1}Adding <b>{0}</b>...") |
|
328 .format(destFilename, indentStr)) |
|
329 if os.path.isfile(sourceFilename): |
|
330 try: |
|
331 self.__commandsInterface.put(sourceFilename, |
|
332 destFilename) |
|
333 except Exception as exc: |
|
334 # just note issues but ignore them otherwise |
|
335 errors.append(str(exc)) |
|
336 elif os.path.isdir(sourceFilename): |
|
337 # recurse |
|
338 errs = self.__rsync(sourceFilename, destFilename, |
|
339 mirror=mirror, |
|
340 indentLevel=indentLevel + 1) |
|
341 # just note issues but ignore them otherwise |
|
342 errors.extend(errs) |
|
343 |
|
344 if mirror: |
|
345 for destBasename in toDelete: |
|
346 # name exists in device but not local, delete |
|
347 destFilename = ( |
|
348 "/" + sourceBasename |
|
349 if deviceDirectory == "/" else |
|
350 deviceDirectory + "/" + destBasename |
|
351 ) |
|
352 self.rsyncProgressMessage.emit( |
|
353 self.tr("{1}Removing <b>{0}</b>...") |
|
354 .format(destFilename, indentStr)) |
|
355 try: |
|
356 self.__commandsInterface.rmrf(destFilename, |
|
357 recursive=True, |
|
358 force=True) |
|
359 except Exception as exc: |
|
360 # just note issues but ignore them otherwise |
|
361 errors.append(str(exc)) |
|
362 |
|
363 for sourceBasename in toUpdate: |
|
364 # names exist in both; do an update |
|
365 sourceStat = sourceDict[sourceBasename] |
|
366 destStat = destinationDict[sourceBasename] |
|
367 sourceFilename = os.path.join(hostDirectory, sourceBasename) |
|
368 destFilename = ( |
|
369 "/" + sourceBasename |
|
370 if deviceDirectory == "/" else |
|
371 deviceDirectory + "/" + sourceBasename |
|
372 ) |
|
373 destMode = destStat[0] |
|
374 if os.path.isdir(sourceFilename): |
|
375 if stat.S_ISDIR(destMode): |
|
376 # both are directories => recurs |
|
377 errs = self.__rsync(sourceFilename, destFilename, |
|
378 mirror=mirror, |
|
379 indentLevel=indentLevel + 1) |
|
380 # just note issues but ignore them otherwise |
|
381 errors.extend(errs) |
|
382 else: |
|
383 self.rsyncProgressMessage.emit( |
|
384 self.tr("Source <b>{0}</b> is a directory and" |
|
385 " destination <b>{1}</b> is a file." |
|
386 " Ignoring it.") |
|
387 .format(sourceFilename, destFilename) |
|
388 ) |
|
389 else: |
|
390 if stat.S_ISDIR(destMode): |
|
391 self.rsyncProgressMessage.emit( |
|
392 self.tr("Source <b>{0}</b> is a file and" |
|
393 " destination <b>{1}</b> is a directory." |
|
394 " Ignoring it.") |
|
395 .format(sourceFilename, destFilename) |
|
396 ) |
|
397 else: |
|
398 if sourceStat[8] > destStat[8]: # mtime |
|
399 self.rsyncProgressMessage.emit( |
|
400 self.tr("{1}Updating <b>{0}</b>...") |
|
401 .format(destFilename, indentStr) |
|
402 ) |
|
403 try: |
|
404 self.__commandsInterface.put(sourceFilename, |
|
405 destFilename) |
|
406 except Exception as exc: |
|
407 errors.append(str(exc)) |
|
408 |
|
409 self.rsyncProgressMessage.emit(doneMessage) |
|
410 |
|
411 return errors |
|
412 |
|
413 @pyqtSlot(str, str) |
|
414 @pyqtSlot(str, str, bool) |
|
415 @pyqtSlot(str, str, bool, bool) |
|
416 def rsync(self, hostDirectory, deviceDirectory, mirror=True, |
|
417 localDevice=False): |
|
418 """ |
|
419 Public slot to synchronize a local directory to the device. |
|
420 |
|
421 @param hostDirectory name of the local directory |
|
422 @type str |
|
423 @param deviceDirectory name of the directory on the device |
|
424 @type str |
|
425 @param mirror flag indicating to mirror the local directory to |
|
426 the device directory |
|
427 @type bool |
|
428 @param localDevice flag indicating device access via local file system |
|
429 @type bool |
|
430 """ |
|
431 errors = self.__rsync(hostDirectory, deviceDirectory, mirror=mirror, |
|
432 localDevice=localDevice) |
|
433 if errors: |
|
434 self.error.emit("rsync", "\n".join(errors)) |
|
435 |
|
436 self.rsyncDone.emit(hostDirectory, deviceDirectory) |
|
437 |
|
438 @pyqtSlot(str) |
|
439 def mkdir(self, dirname): |
|
440 """ |
|
441 Public slot to create a new directory. |
|
442 |
|
443 @param dirname name of the directory to create |
|
444 @type str |
|
445 """ |
|
446 try: |
|
447 self.__commandsInterface.mkdir(dirname) |
|
448 self.createDirectoryDone.emit() |
|
449 except Exception as exc: |
|
450 self.error.emit("mkdir", str(exc)) |
|
451 |
|
452 @pyqtSlot(str) |
|
453 @pyqtSlot(str, bool) |
|
454 def rmdir(self, dirname, recursive=False): |
|
455 """ |
|
456 Public slot to (recursively) remove a directory. |
|
457 |
|
458 @param dirname name of the directory to be removed |
|
459 @type str |
|
460 @param recursive flag indicating a recursive removal |
|
461 @type bool |
|
462 """ |
|
463 try: |
|
464 if recursive: |
|
465 self.__commandsInterface.rmrf(dirname, recursive=True, |
|
466 force=True) |
|
467 else: |
|
468 self.__commandsInterface.rmdir(dirname) |
|
469 self.removeDirectoryDone.emit() |
|
470 except Exception as exc: |
|
471 self.error.emit("rmdir", str(exc)) |
|
472 |
|
473 def fileSystemInfo(self): |
|
474 """ |
|
475 Public method to obtain information about the currently mounted file |
|
476 systems. |
|
477 """ |
|
478 try: |
|
479 fsinfo = self.__commandsInterface.fileSystemInfo() |
|
480 self.fsinfoDone.emit(fsinfo) |
|
481 except Exception as exc: |
|
482 self.error.emit("fileSystemInfo", str(exc)) |