Coverage for C:\Repos\leo-editor\leo\core\leoExternalFiles.py: 17%
370 statements
« prev ^ index » next coverage.py v6.4, created at 2022-05-24 10:21 -0500
« prev ^ index » next coverage.py v6.4, created at 2022-05-24 10:21 -0500
1# -*- coding: utf-8 -*-
2#@+leo-ver=5-thin
3#@+node:ekr.20160306114544.1: * @file leoExternalFiles.py
4#@@first
5import getpass
6import os
7import subprocess
8import tempfile
9from leo.core import leoGlobals as g
10#@+others
11#@+node:ekr.20160306110233.1: ** class ExternalFile
12class ExternalFile:
13 """A class holding all data about an external file."""
15 def __init__(self, c, ext, p, path, time):
16 """Ctor for ExternalFile class."""
17 self.c = c
18 self.ext = ext
19 self.p = p and p.copy() # The nearest @<file> node.
20 self.path = path
21 # Inhibit endless dialog loop. See efc.idle_check_open_with_file.
22 self.time = time
24 def __repr__(self):
25 return f"<ExternalFile: {self.time:20} {g.shortFilename(self.path)}>"
27 __str__ = __repr__
28 #@+others
29 #@+node:ekr.20161011174757.1: *3* ef.shortFileName
30 def shortFileName(self):
31 return g.shortFilename(self.path)
32 #@+node:ekr.20161011174800.1: *3* ef.exists
33 def exists(self):
34 """Return True if the external file still exists."""
35 return g.os_path_exists(self.path)
36 #@-others
37#@+node:ekr.20150405073203.1: ** class ExternalFilesController
38class ExternalFilesController:
39 """
40 A class tracking changes to external files:
42 - temp files created by open-with commands.
43 - external files corresponding to @file nodes.
45 This class raises a dialog when a file changes outside of Leo.
47 **Naming conventions**:
49 - d is always a dict created by the @open-with logic.
50 This dict describes *only* how to open the file.
52 - ef is always an ExternalFiles instance.
53 """
54 #@+others
55 #@+node:ekr.20150404083533.1: *3* efc.ctor
56 def __init__(self, c=None):
57 """Ctor for ExternalFiles class."""
58 self.checksum_d = {} # Keys are full paths, values are file checksums.
59 # For efc.on_idle.
60 # Keys are commanders.
61 # Values are cached @bool check-for-changed-external-file settings.
62 self.enabled_d = {}
63 # List of ExternalFile instances created by self.open_with.
64 self.files = []
65 # Keys are commanders. Values are bools.
66 # Used only to limit traces.
67 self.has_changed_d = {}
68 # Copy of g.app.commanders()
69 self.unchecked_commanders = []
70 # Copy of self file. Only one files is checked at idle time.
71 self.unchecked_files = []
72 # Keys are full paths, values are modification times.
73 # DO NOT alter directly, use set_time(path) and
74 # get_time(path), see set_time() for notes.
75 self._time_d = {}
76 self.yesno_all_answer = None # answer, 'yes-all', or 'no-all'
77 g.app.idleTimeManager.add_callback(self.on_idle)
78 #@+node:ekr.20150405105938.1: *3* efc.entries
79 #@+node:ekr.20150405194745.1: *4* efc.check_overwrite (called from c.checkTimeStamp)
80 def check_overwrite(self, c, path):
81 """
82 Implements c.checkTimeStamp.
84 Return True if the file given by fn has not been changed
85 since Leo read it or if the user agrees to overwrite it.
86 """
87 if c.sqlite_connection and c.mFileName == path:
88 # sqlite database file is never actually overwriten by Leo
89 # so no need to check its timestamp. It is modified through
90 # sqlite methods.
91 return True
92 if self.has_changed(path):
93 val = self.ask(c, path)
94 return val in ('yes', 'yes-all') # #1888
95 return True
96 #@+node:ekr.20031218072017.2613: *4* efc.destroy_frame
97 def destroy_frame(self, frame):
98 """
99 Close all "Open With" files associated with frame.
100 Called by g.app.destroyWindow.
101 """
102 files = [ef for ef in self.files if ef.c.frame == frame]
103 paths = [ef.path for ef in files]
104 for ef in files:
105 self.destroy_temp_file(ef)
106 self.files = [z for z in self.files if z.path not in paths]
107 #@+node:ekr.20150407141838.1: *4* efc.find_path_for_node (called from vim.py)
108 def find_path_for_node(self, p):
109 """
110 Find the path corresponding to node p.
111 called from vim.py.
112 """
113 for ef in self.files:
114 if ef.p and ef.p.v == p.v:
115 path = ef.path
116 break
117 else:
118 path = None
119 return path
120 #@+node:ekr.20150330033306.1: *4* efc.on_idle & helpers
121 on_idle_count = 0
123 def on_idle(self):
124 """
125 Check for changed open-with files and all external files in commanders
126 for which @bool check_for_changed_external_file is True.
127 """
128 #
129 # #1240: Note: The "asking" dialog prevents idle time.
130 #
131 if not g.app or g.app.killed or g.app.restarting: # #1240.
132 return
133 self.on_idle_count += 1
134 # New in Leo 5.7: always handle delayed requests.
135 if g.app.windowList:
136 c = g.app.log and g.app.log.c
137 if c:
138 c.outerUpdate()
139 # Fix #262: Improve performance when @bool check-for-changed-external-files is True.
140 if self.unchecked_files:
141 # Check all external files.
142 while self.unchecked_files:
143 ef = self.unchecked_files.pop() # #1959: ensure progress.
144 self.idle_check_open_with_file(c, ef)
145 elif self.unchecked_commanders:
146 # Check the next commander for which
147 # @bool check_for_changed_external_file is True.
148 c = self.unchecked_commanders.pop()
149 self.idle_check_commander(c)
150 else:
151 # Add all commanders for which
152 # @bool check_for_changed_external_file is True.
153 self.unchecked_commanders = [
154 z for z in g.app.commanders() if self.is_enabled(z)
155 ]
156 self.unchecked_files = [z for z in self.files if z.exists()]
157 #@+node:ekr.20150404045115.1: *5* efc.idle_check_commander
158 def idle_check_commander(self, c):
159 """
160 Check all external files corresponding to @<file> nodes in c for
161 changes.
162 """
163 # #1240: Check the .leo file itself.
164 self.idle_check_leo_file(c)
165 #
166 # #1100: always scan the entire file for @<file> nodes.
167 # #1134: Nested @<file> nodes are no longer valid, but this will do no harm.
168 state = 'no'
169 for p in c.all_unique_positions():
170 if not p.isAnyAtFileNode():
171 continue
172 path = g.fullPath(c, p)
173 if not self.has_changed(path):
174 continue
175 # Prevent further checks for path.
176 self.set_time(path)
177 self.checksum_d[path] = self.checksum(path)
178 # Check file.
179 if p.isAtAsisFileNode() or p.isAtNoSentFileNode():
180 # #1081: issue a warning.
181 self.warn(c, path, p=p)
182 continue
183 if state in ('yes', 'no'):
184 state = self.ask(c, path, p=p)
185 if state in ('yes', 'yes-all'):
186 c.redraw(p=p)
187 c.refreshFromDisk(p)
188 c.redraw()
189 #@+node:ekr.20201207055713.1: *5* efc.idle_check_leo_file
190 def idle_check_leo_file(self, c):
191 """Check c's .leo file for external changes."""
192 path = c.fileName()
193 if not self.has_changed(path):
194 return
195 # Always update the path & time to prevent future warnings.
196 self.set_time(path)
197 self.checksum_d[path] = self.checksum(path)
198 # #1888:
199 val = self.ask(c, path)
200 if val in ('yes', 'yes-all'):
201 # Do a complete restart of Leo.
202 g.es_print('restarting Leo...')
203 c.restartLeo()
204 #@+node:ekr.20150407124259.1: *5* efc.idle_check_open_with_file & helper
205 def idle_check_open_with_file(self, c, ef):
206 """Update the open-with node given by ef."""
207 assert isinstance(ef, ExternalFile), ef
208 if not ef.path or not os.path.exists(ef.path):
209 return
210 time = self.get_mtime(ef.path)
211 if not time or time == ef.time:
212 return
213 # Inhibit endless dialog loop.
214 ef.time = time
215 # #1888: Handle all possible user responses to self.ask.
216 val = self.ask(c, ef.path, p=ef.p.copy())
217 if val == 'yes-all':
218 for ef in self.unchecked_files:
219 self.update_open_with_node(ef)
220 self.unchecked_files = []
221 elif val == 'no-all':
222 self.unchecked_files = []
223 elif val == 'yes':
224 self.update_open_with_node(ef)
225 elif val == 'no':
226 pass
227 #@+node:ekr.20150407205631.1: *6* efc.update_open_with_node
228 def update_open_with_node(self, ef):
229 """Update the body text of ef.p to the contents of ef.path."""
230 assert isinstance(ef, ExternalFile), ef
231 c, p = ef.c, ef.p.copy()
232 g.blue(f"updated {p.h}")
233 s, e = g.readFileIntoString(ef.path)
234 p.b = s
235 if c.config.getBool('open-with-goto-node-on-update'):
236 c.selectPosition(p)
237 if c.config.getBool('open-with-save-on-update'):
238 c.save()
239 else:
240 p.setDirty()
241 c.setChanged()
242 #@+node:ekr.20150404082344.1: *4* efc.open_with & helpers
243 def open_with(self, c, d):
244 """
245 Called by c.openWith to handle items in the Open With... menu.
247 'd' a dict created from an @openwith settings node with these keys:
249 'args': the command-line arguments to be used to open the file.
250 'ext': the file extension.
251 'kind': the method used to open the file, such as subprocess.Popen.
252 'name': menu label (used only by the menu code).
253 'p': the nearest @<file> node, or None.
254 'shortcut': menu shortcut (used only by the menu code).
255 """
256 try:
257 ext = d.get('ext')
258 if not g.doHook('openwith1', c=c, p=c.p, v=c.p.v, d=d):
259 root = d.get('p')
260 if root:
261 # Open the external file itself.
262 path = g.fullPath(c, root) # #1914.
263 self.open_file_in_external_editor(c, d, path)
264 else:
265 # Open a temp file containing just the node.
266 p = c.p
267 ext = self.compute_ext(c, p, ext)
268 path = self.compute_temp_file_path(c, p, ext)
269 if path:
270 self.remove_temp_file(p, path)
271 self.create_temp_file(c, ext, p)
272 self.open_file_in_external_editor(c, d, path)
273 g.doHook('openwith2', c=c, p=c.p, v=c.p.v, d=d)
274 except Exception:
275 g.es('unexpected exception in c.openWith')
276 g.es_exception()
277 #@+node:ekr.20031218072017.2824: *5* efc.compute_ext
278 def compute_ext(self, c, p, ext):
279 """Return the file extension to be used in the temp file."""
280 if ext:
281 for ch in ("'", '"'):
282 if ext.startswith(ch):
283 ext = ext.strip(ch)
284 if not ext:
285 # if node is part of @<file> tree, get ext from file name
286 for p2 in p.self_and_parents(copy=False):
287 if p2.isAnyAtFileNode():
288 fn = p2.h.split(None, 1)[1]
289 ext = g.os_path_splitext(fn)[1]
290 break
291 if not ext:
292 theDict = c.scanAllDirectives(c.p)
293 language = theDict.get('language')
294 ext = g.app.language_extension_dict.get(language)
295 if not ext:
296 ext = '.txt'
297 if ext[0] != '.':
298 ext = '.' + ext
299 return ext
300 #@+node:ekr.20031218072017.2832: *5* efc.compute_temp_file_path & helpers
301 def compute_temp_file_path(self, c, p, ext):
302 """Return the path to the temp file for p and ext."""
303 if c.config.getBool('open-with-clean-filenames'):
304 path = self.clean_file_name(c, ext, p)
305 else:
306 path = self.legacy_file_name(c, ext, p)
307 if not path:
308 g.error('c.temp_file_path failed')
309 return path
310 #@+node:ekr.20150406055221.2: *6* efc.clean_file_name
311 def clean_file_name(self, c, ext, p):
312 """Compute the file name when subdirectories mirror the node's hierarchy in Leo."""
313 use_extentions = c.config.getBool('open-with-uses-derived-file-extensions')
314 ancestors, found = [], False
315 for p2 in p.self_and_parents(copy=False):
316 h = p2.anyAtFileNodeName()
317 if not h:
318 h = p2.h # Not an @file node: use the entire header
319 elif use_extentions and not found:
320 # Found the nearest ancestor @<file> node.
321 found = True
322 base, ext2 = g.os_path_splitext(h)
323 if p2 == p:
324 h = base
325 if ext2:
326 ext = ext2
327 ancestors.append(g.sanitize_filename(h))
328 # The base directory is <tempdir>/Leo<id(v)>.
329 ancestors.append("Leo" + str(id(p.v)))
330 # Build temporary directories.
331 td = os.path.abspath(tempfile.gettempdir())
332 while len(ancestors) > 1:
333 td = os.path.join(td, ancestors.pop())
334 if not os.path.exists(td):
335 os.mkdir(td)
336 # Compute the full path.
337 name = ancestors.pop() + ext
338 path = os.path.join(td, name)
339 return path
340 #@+node:ekr.20150406055221.3: *6* efc.legacy_file_name
341 def legacy_file_name(self, c, ext, p):
342 """Compute a legacy file name for unsupported operating systems."""
343 try:
344 leoTempDir = getpass.getuser() + "_" + "Leo"
345 except Exception:
346 leoTempDir = "LeoTemp"
347 g.es("Could not retrieve your user name.")
348 g.es(f"Temporary files will be stored in: {leoTempDir}")
349 td = os.path.join(os.path.abspath(tempfile.gettempdir()), leoTempDir)
350 if not os.path.exists(td):
351 os.mkdir(td)
352 name = g.sanitize_filename(p.h) + '_' + str(id(p.v)) + ext
353 path = os.path.join(td, name)
354 return path
355 #@+node:ekr.20100203050306.5937: *5* efc.create_temp_file
356 def create_temp_file(self, c, ext, p):
357 """
358 Create the file used by open-with if necessary.
359 Add the corresponding ExternalFile instance to self.files
360 """
361 path = self.compute_temp_file_path(c, p, ext)
362 exists = g.os_path_exists(path)
363 # Compute encoding and s.
364 d2 = c.scanAllDirectives(p)
365 encoding = d2.get('encoding', None)
366 if encoding is None:
367 encoding = c.config.default_derived_file_encoding
368 s = g.toEncodedString(p.b, encoding, reportErrors=True)
369 # Write the file *only* if it doesn't exist.
370 # No need to read the file: recomputing s above suffices.
371 if not exists:
372 try:
373 with open(path, 'wb') as f:
374 f.write(s)
375 f.flush()
376 except IOError:
377 g.error(f"exception creating temp file: {path}")
378 g.es_exception()
379 return None
380 # Add or update the external file entry.
381 time = self.get_mtime(path)
382 self.files = [z for z in self.files if z.path != path]
383 self.files.append(ExternalFile(c, ext, p, path, time))
384 return path
385 #@+node:ekr.20031218072017.2829: *5* efc.open_file_in_external_editor
386 def open_file_in_external_editor(self, c, d, fn, testing=False):
387 """
388 Open a file fn in an external editor.
390 This will be an entire external file, or a temp file for a single node.
392 d is a dictionary created from an @openwith settings node.
394 'args': the command-line arguments to be used to open the file.
395 'ext': the file extension.
396 'kind': the method used to open the file, such as subprocess.Popen.
397 'name': menu label (used only by the menu code).
398 'p': the nearest @<file> node, or None.
399 'shortcut': menu shortcut (used only by the menu code).
400 """
401 testing = testing or g.unitTesting
402 arg_tuple = d.get('args', [])
403 arg = ' '.join(arg_tuple)
404 kind = d.get('kind')
405 try:
406 # All of these must be supported because they
407 # could exist in @open-with nodes.
408 command = '<no command>'
409 if kind in ('os.system', 'os.startfile'):
410 # New in Leo 5.7:
411 # Use subProcess.Popen(..., shell=True)
412 c_arg = self.join(arg, fn)
413 if not testing:
414 try:
415 subprocess.Popen(c_arg, shell=True)
416 except OSError:
417 g.es_print('c_arg', repr(c_arg))
418 g.es_exception()
419 elif kind == 'exec':
420 g.es_print('open-with exec no longer valid.')
421 elif kind == 'os.spawnl':
422 filename = g.os_path_basename(arg)
423 command = f"os.spawnl({arg},{filename},{fn})"
424 if not testing:
425 os.spawnl(os.P_NOWAIT, arg, filename, fn)
426 elif kind == 'os.spawnv':
427 filename = os.path.basename(arg_tuple[0])
428 vtuple = arg_tuple[1:]
429 # add the name of the program as the first argument.
430 # Change suggested by Jim Sizelove.
431 vtuple.insert(0, filename)
432 vtuple.append(fn)
433 command = f"os.spawnv({vtuple})"
434 if not testing:
435 os.spawnv(os.P_NOWAIT, arg[0], vtuple) #???
436 elif kind == 'subprocess.Popen':
437 c_arg = self.join(arg, fn)
438 command = f"subprocess.Popen({c_arg})"
439 if not testing:
440 try:
441 subprocess.Popen(c_arg, shell=True)
442 except OSError:
443 g.es_print('c_arg', repr(c_arg))
444 g.es_exception()
445 elif hasattr(kind, '__call__'):
446 # Invoke openWith like this:
447 # c.openWith(data=[func,None,None])
448 # func will be called with one arg, the filename
449 command = f"{kind}({fn})"
450 if not testing:
451 kind(fn)
452 else:
453 command = 'bad command:' + str(kind)
454 if not testing:
455 g.trace(command)
456 return command # for unit testing.
457 except Exception:
458 g.es('exception executing open-with command:', command)
459 g.es_exception()
460 return f"oops: {command}"
461 #@+node:ekr.20190123051253.1: *5* efc.remove_temp_file
462 def remove_temp_file(self, p, path):
463 """
464 Remove any existing *temp* file for p and path, updating self.files.
465 """
466 for ef in self.files:
467 if path and path == ef.path and p.v == ef.p.v:
468 self.destroy_temp_file(ef)
469 self.files = [z for z in self.files if z != ef]
470 return
471 #@+node:ekr.20150404092538.1: *4* efc.shut_down
472 def shut_down(self):
473 """
474 Destroy all temporary open-with files.
475 This may fail if the files are still open.
477 Called by g.app.finishQuit.
478 """
479 # Dont call g.es or g.trace! The log stream no longer exists.
480 for ef in self.files[:]:
481 self.destroy_temp_file(ef)
482 self.files = []
483 #@+node:ekr.20150405110219.1: *3* efc.utilities
484 # pylint: disable=no-value-for-parameter
485 #@+node:ekr.20150405200212.1: *4* efc.ask
486 def ask(self, c, path, p=None):
487 """
488 Ask user whether to overwrite an @<file> tree.
490 Return one of ('yes', 'no', 'yes-all', 'no-all')
491 """
492 if g.unitTesting:
493 return False
494 if c not in g.app.commanders():
495 return False
496 is_leo = path.endswith(('.leo', '.db'))
497 is_external_file = not is_leo
498 #
499 # Create the message.
500 message1 = f"{g.splitLongFileName(path)} has changed outside Leo.\n"
501 if is_leo:
502 message2 = 'Restart Leo?'
503 elif p:
504 message2 = f"Reload {p.h}?"
505 else:
506 for ef in self.files:
507 if ef.path == path:
508 message2 = f"Reload {ef.p.h}?"
509 break
510 else:
511 message2 = f"Reload {path}?"
512 #
513 # #1240: Note: This dialog prevents idle time.
514 result = g.app.gui.runAskYesNoDialog(c,
515 'Overwrite the version in Leo?',
516 message1 + message2,
517 yes_all=is_external_file,
518 no_all=is_external_file,
519 )
520 #
521 # #1961. Re-init the checksum to suppress concurent dialogs.
522 self.checksum_d[path] = self.checksum(path)
523 #
524 # #1888: return one of ('yes', 'no', 'yes-all', 'no-all')
525 return result.lower() if result else 'no'
526 #@+node:ekr.20150404052819.1: *4* efc.checksum
527 def checksum(self, path):
528 """Return the checksum of the file at the given path."""
529 import hashlib
530 # #1454: Explicitly close the file.
531 with open(path, 'rb') as f:
532 s = f.read()
533 return hashlib.md5(s).hexdigest()
534 #@+node:ekr.20031218072017.2614: *4* efc.destroy_temp_file
535 def destroy_temp_file(self, ef):
536 """Destroy the *temp* file corresponding to ef, an ExternalFile instance."""
537 # Do not use g.trace here.
538 if ef.path and g.os_path_exists(ef.path):
539 try:
540 os.remove(ef.path)
541 except Exception:
542 pass
543 #@+node:ekr.20150407204201.1: *4* efc.get_mtime
544 def get_mtime(self, path):
545 """Return the modification time for the path."""
546 return g.os_path_getmtime(g.os_path_realpath(path))
547 #@+node:ekr.20150405122428.1: *4* efc.get_time
548 def get_time(self, path):
549 """
550 return timestamp for path
552 see set_time() for notes
553 """
554 return self._time_d.get(g.os_path_realpath(path))
555 #@+node:ekr.20150403045207.1: *4* efc.has_changed
556 def has_changed(self, path):
557 """Return True if the file at path has changed outside of Leo."""
558 if not path:
559 return False
560 if not g.os_path_exists(path):
561 return False
562 if g.os_path_isdir(path):
563 return False
564 #
565 # First, check the modification times.
566 old_time = self.get_time(path)
567 new_time = self.get_mtime(path)
568 if not old_time:
569 # Initialize.
570 self.set_time(path, new_time)
571 self.checksum_d[path] = self.checksum(path)
572 return False
573 if old_time == new_time:
574 return False
575 #
576 # Check the checksums *only* if the mod times don't match.
577 old_sum = self.checksum_d.get(path)
578 new_sum = self.checksum(path)
579 if new_sum == old_sum:
580 # The modtime changed, but it's contents didn't.
581 # Update the time, so we don't keep checking the checksums.
582 # Return False so we don't prompt the user for an update.
583 self.set_time(path, new_time)
584 return False
585 # The file has really changed.
586 assert old_time, path
587 return True
588 #@+node:ekr.20150405104340.1: *4* efc.is_enabled
589 def is_enabled(self, c):
590 """Return the cached @bool check_for_changed_external_file setting."""
591 d = self.enabled_d
592 val = d.get(c)
593 if val is None:
594 val = c.config.getBool('check-for-changed-external-files', default=False)
595 d[c] = val
596 return val
597 #@+node:ekr.20150404083049.1: *4* efc.join
598 def join(self, s1, s2):
599 """Return s1 + ' ' + s2"""
600 return f"{s1} {s2}"
601 #@+node:tbrown.20150904102518.1: *4* efc.set_time
602 def set_time(self, path, new_time=None):
603 """
604 Implements c.setTimeStamp.
606 Update the timestamp for path.
608 NOTE: file paths with symbolic links occur with and without those links
609 resolved depending on the code call path. This inconsistency is
610 probably not Leo's fault but an underlying Python issue.
611 Hence the need to call realpath() here.
612 """
613 t = new_time or self.get_mtime(path)
614 self._time_d[g.os_path_realpath(path)] = t
615 #@+node:ekr.20190218055230.1: *4* efc.warn
616 def warn(self, c, path, p):
617 """
618 Warn that an @asis or @nosent node has been changed externally.
620 There is *no way* to update the tree automatically.
621 """
622 if g.unitTesting or c not in g.app.commanders():
623 return
624 if not p:
625 g.trace('NO P')
626 return
627 g.app.gui.runAskOkDialog(
628 c=c,
629 message='\n'.join([
630 f"{g.splitLongFileName(path)} has changed outside Leo.\n",
631 'Leo can not update this file automatically.\n',
632 f"This file was created from {p.h}.\n",
633 'Warning: refresh-from-disk will destroy all children.'
634 ]),
635 title='External file changed',
636 )
637 #@-others
638#@-others
639#@@language python
640#@@tabwidth -4
641#@@pagewidth 70
642#@-leo