/
oyepa-filemon.py
executable file
·422 lines (260 loc) · 13 KB
/
oyepa-filemon.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
#!/usr/bin/env python2
#
# Copyright 2007, 2008, 2009, 2010 Manuel Arriaga
#
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
from __future__ import with_statement
import exceptions, os, signal, sys, time, user
from PyQt4.QtGui import *
from PyQt4.QtCore import *
from oyepa import run_cmd_on_path
import pyinotify
from fslayer import read_pending_updates, write_pending_updates, readDocDirList, split_purename_and_tags_from_filename, add_tags_in_path_to_cache, remove_tags_in_path_from_cache_and_filename_from_pending_updates, isInternalOyepaOp
from generic_code import gui_excepthook
from fileops import grename
import cfg
doc_dirs = set()
wdd = {}
wm = None
mask_dirs = 0
# this function access the file listing_doc_dirs_filepath (by default,
# ~/.oyepa-dirs) and resets this process' inotify "watches" accordingly.
def update_watches():
new_doc_dirs = readDocDirList()
for removed_doc_dir in doc_dirs.difference(new_doc_dirs):
rr = wm.rm_watch(wdd[removed_doc_dir], rec=False)
if rr[wdd[removed_doc_dir]]:
print "[oyepa_filemon] stopped watching " + removed_doc_dir
del wdd[removed_doc_dir]
pass
pass
for added_doc_dir in new_doc_dirs.difference(doc_dirs):
ra = wm.add_watch(added_doc_dir, mask_dirs, rec=False)
if ra[added_doc_dir] > 0:
wdd.update(ra)
print "[oyepa_filemon] began watching " + added_doc_dir
pass
pass
return new_doc_dirs
class Disappearance:
def __init__(self, path, time):
self.path = path
self.time = time
return
pass
class PTmp(pyinotify.ProcessEvent):
def __init__(self):
# memory of recently renamed/deleted files, probably in the course of
# an application's standard "save" process
self.recently_deleted = []
self.recently_moved = {} # self.recently_moved[pyinotify cookie] = Disappearance()
return
def resetDisappearancesMemory(self):
current_time = time.time()
# remove from cookie jar those which have timed out
for cookie in self.recently_moved.copy():
if current_time - self.recently_moved[cookie].time >= cfg.MAX_SAVE_PROCESS_DURATION:
remove_tags_in_path_from_cache_and_filename_from_pending_updates(self.recently_moved[cookie].path)
print "PERIODIC CHECK: found timed out path %s, removing its tags from tag cache"%self.recently_moved[cookie].path
del self.recently_moved[cookie]
pass
pass
# remove from paths to ignore those which have timed out
for disappearance in self.recently_deleted[:]:
if current_time - disappearance.time >= cfg.MAX_SAVE_PROCESS_DURATION:
remove_tags_in_path_from_cache_and_filename_from_pending_updates(disappearance.path)
print "PERIODIC CHECK: timed out path %s, removing its tags from tag cache"%disappearance.path
self.recently_deleted.remove(disappearance)
pass
pass
return
def process_IN_CREATE(self, event):
path = os.path.join(event.path, event.name)
if isInternalOyepaOp(path): return # filemon mustn't interfere with file moving around conducted by the oyepa UI app
if self.interesting_path(path) and not os.path.islink(path):
print "file created, path " + path
if not self.has_recently_disappeared(path):
add_tags_in_path_to_cache(path) # this path already exists in this doc_dir; update tag cache. What the user does next (in the doc tagger) is to simply *add/remove* tags on top of those; so the latter should already be in the cache
self.call_gui_tagger(path)
else: self.remove_from_recently_disappeared(path)
pass
return
def process_IN_DELETE(self, event):
path = os.path.join(event.path, event.name)
if isInternalOyepaOp(path): return # filemon mustn't interfere with file moving around conducted by the oyepa UI app
if self.interesting_path(path):
print "deleted %s, appending to recently_deleted"%path
self.recently_deleted.append(Disappearance(path, time.time()))
pass
return
def process_IN_MOVED_FROM(self, event):
path = os.path.join(event.path, event.name)
if isInternalOyepaOp(path): return # filemon mustn't interfere with file moving around conducted by the oyepa UI app
if self.interesting_path(path):
print "moved (FROM) %s, appending to recently_moved"%path
self.recently_moved[event.cookie] = Disappearance(path, time.time())
pass
return
def process_IN_MOVED_TO(self, event):
path = os.path.join(event.path, event.name)
if isInternalOyepaOp(path): return # filemon mustn't interfere with file moving around conducted by the oyepa UI app
if self.interesting_path(path):
if self.has_recently_disappeared(path):
# doc went away, doc came back: all remains the same -- just prevent tags from being removed from cache
print "moved_TO: %s has recently_disappeared and now reappeared, removing it from that list"%path
self.remove_from_recently_disappeared(path)
elif event.cookie in self.recently_moved:
# this operation was a doc being moved/renamed inside the doc repository (we don't know if it remained in *the same* doc dir). An update to the cache is already pending to remove the tags corresponding to its previous name; now we just need to ensure that we add its current (possibly different!) tags to its current (possibly different!) doc_dir
print "moved_TO %s: renaming/movement within doc repository, adding tags (in this path) to cache"%path
add_tags_in_path_to_cache(path)
else:
# file imported into doc_dir from outside; the user might want to tag it
print "moved_TO %s: importing from outside doc repository, adding tags to cache and calling tagger"%path
add_tags_in_path_to_cache(path) # this path already exists in this doc_dir; update tag cache. What the user does next (in the doc tagger) is to simply *add/remove* tags on top of those; so the latter should already be in the cache
self.call_gui_tagger(path)
pass
pass
return
def has_recently_disappeared(self, path):
ret = path in [d.path for d in self.recently_moved.values()] or \
path in [d.path for d in self.recently_deleted]
return ret
def remove_from_recently_disappeared(self, path):
print "trying to remove %s from recently_disappeared lists"%path
for cookie, disappearance in self.recently_moved.items():
if disappearance.path == path:
print " found it, removing"
del self.recently_moved[cookie]
return
pass
for disappearance in self.recently_deleted[:]:
if disappearance.path == path:
print " found it, removing"
self.recently_deleted.remove(disappearance)
return
pass
print "TROUBLE: couldn't find %s in recently_disappeared lists"%path
return
def interesting_path(self, path):
return "/." not in path and "/#" not in path and not path.endswith("~")
def call_gui_tagger(self, path):
print "calling gui_tagger"
run_cmd_on_path(cfg.OYEPA_GUI_FILENAME, path)
return
pass
# main() ################
def main():
global doc_dirs
global wdd
global wm
global mask_dirs
listing_doc_dirs_filepath = os.path.join(user.home, cfg.FILENAME_LISTING_DOC_DIRS)
# try to exit cleanly when sent a signal telling us to quit
def handle_quit_signal(signum, frame): raise KeyboardInterrupt
signal.signal(signal.SIGTERM, handle_quit_signal)
signal.signal(signal.SIGHUP, handle_quit_signal)
signal.signal(signal.SIGQUIT, handle_quit_signal)
# check if another instance of oyepa_filemon is running
if os.path.exists(cfg.FILEPATH_FILEMON_RUNNING):
msg = "OYEPA: another instance of the file monitor is already running!\n"\
" Either stop it or, if you believe it is not really running, just manually remove %s to proceed."%cfg.FILEPATH_FILEMON_RUNNING
print msg
app = QApplication(sys.argv)
QMessageBox.critical(None, sys.argv[0], msg)
sys.exit(1)
else: # otherwise, tell others I am here
f = open(cfg.FILEPATH_FILEMON_RUNNING, "w")
f.close()
pass
# setup dir watching
mask_dirs = pyinotify.IN_CREATE | pyinotify.IN_DELETE | \
pyinotify.IN_MOVED_FROM | pyinotify.IN_MOVED_TO
wm = pyinotify.WatchManager()
wdd = {}
evProcessor = PTmp()
notifier = pyinotify.Notifier(wm, evProcessor, timeout=5000)
doc_dirs = set()
print
print "starting oyepa-filemon"
# ironically, in an app using inotify the only reliable way I found to
# monitor for changes to the file listing which dirs to watch involves
# recording its mtime and stat()ing it regularly! : )
prev_mtime = 0
try:
while True:
# check if we should update the list of dirs we are watching
# [if we simply call update_watches() every time, without checking
# if it is necessary, at least on my laptop the HD will never go to
# sleep]
if os.path.exists(listing_doc_dirs_filepath):
st = os.stat(listing_doc_dirs_filepath)
if st.st_mtime > prev_mtime:
doc_dirs = update_watches()
prev_mtime = st.st_mtime
pass
pass
elif prev_mtime != -1:
doc_dirs = update_watches() # will disable all watches
prev_mtime = -1 # signals 'missing listing_doc_dirs_filepath'
pass
# now loop, prompting the user to tag any (seemingly) new docs in any of the doc_dirs
evProcessor.resetDisappearancesMemory()
notifier.process_events()
if notifier.check_events():
notifier.read_events()
pass
pass
pass
except (KeyboardInterrupt, Exception), instance:
print "stopping oyepa-filemon..."
notifier.stop()
# perform pending updates (and remove any lying around "internal ops" file)
for doc_dir in doc_dirs:
updates_dic = read_pending_updates(doc_dir)
for orig, new in updates_dic.copy().items(): # doesn not look like it is necessary, but let us iterate over a copy of the dic just to be safe (since we will be removing items)
oldpath = os.path.join(doc_dir, orig)
newpath = os.path.join(doc_dir, new)
if os.path.exists(newpath):
print "Skipping update of doc %s since newpath (%s) already exists"%(oldpath,newpath)
elif not os.path.exists(oldpath):
print "Couldn't find doc %s, won't update it"%oldpath
else:
try: grename(oldpath, newpath)
except Exception, e: print "Unable to rename %s to %s [%s]"%(oldpath,newpath,str(e))
pass
pass
write_pending_updates(doc_dir, {}) # IMPORTANT!! : )
# finally, just remove the "internal ops" list file that might be lying around
oyepa_internal_ops_filepath = \
os.path.join(doc_dir, cfg.oyepa_internal_ops_filename)
if os.path.exists(oyepa_internal_ops_filepath):
try: os.unlink(oyepa_internal_ops_filepath)
except: print "Unable to remove an immediate disappearances file [%s]"%oyepa_internal_ops_filepath
pass
pass
# other stuff to do...
if os.path.exists(cfg.FILEPATH_FILEMON_RUNNING): os.unlink(cfg.FILEPATH_FILEMON_RUNNING) # in case we were actually sent a signal
print "stopped oyepa-filemon."
if type(instance) != exceptions.KeyboardInterrupt:
app = QApplication(sys.argv)
sys.excepthook = gui_excepthook
raise
pass
return
if __name__ == "__main__": #and False:
main()
pass