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