Coverage for C:\leo.repo\leo-editor\leo\core\leoExternalFiles.py : 17%

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