-
Notifications
You must be signed in to change notification settings - Fork 30
/
Copy pathdaemon.py
362 lines (314 loc) · 18.1 KB
/
daemon.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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from tools import Config, CVal, check_output, reFindall, pathExists, pathJoin, LOGGER
from os import stat
from os.path import expanduser
from shutil import which
from threading import Timer as thTimer, Lock, Thread
from tempfile import gettempdir
from subprocess import CalledProcessError
from sys import exit as sysExit
# ################### Main daemon class ################### #
class YDDaemon: # Yandex.Disk daemon interface
# This is the fully automated class that serves as daemon interface.
# Public methods:
# __init__ - Handles initialization of the object and as a part - auto-start daemon if it
# is required by configuration settings.
# output - Provides daemon output (in user language) through the parameter of callback. Executed in separate thread
# start - Request to start daemon. Do nothing if it is alreday started. Executed in separate thread
# stop - Request to stop daemon. Do nothing if it is not started. Executed in separate thread
# exit - Handles 'Stop on exit' facility according to daemon configuration settings.
# change - Virtual method for handling daemon status changes. It have to be redefined by UI class.
# The parameters of the call - status values dictionary with following keys:
# 'status' - current daemon status
# 'progress' - synchronization progress or ''
# 'laststatus' - previous daemon status
# 'statchg' - True indicates that status was changed
# 'total' - total Yandex disk space
# 'used' - currently used space
# 'free' - available space
# 'trash' - size of trash
# 'szchg' - True indicates that sizes were changed
# 'lastitems' - list of last synchronized items or []
# 'lastchg' - True indicates that lastitems was changed
# 'error' - error message
# 'path' - path of error
# error - Virtual method for error handling. It have to be redefined by UI class.
# Class interface variables:
# ID - the daemon identity string (empty in single daemon configuration)
# config - The daemon configuration dictionary (object of _DConfig(Config) class)
# ################### Virtual methods ################# #
# they have to be implemented in GUI part of code
def error(self, errStr, cfgPath):
# Error handler
LOGGER.debug('%sError %s , path %s', self.ID, errStr, cfgPath)
return 0
def change(self, vals):
# Updates handler
LOGGER.debug('%sUpdate event: %s', self.ID, str(vals))
# ################### Private classes ################### #
class __Watcher:
# File changes watcher implementation
def __init__(self, path, handler, *args, **kwargs):
self.path = path
self.handler = handler
self.args = args
self.kwargs = kwargs
# Don't start timer initially
self.status = False
self.mark = None
self.timer = None
def start(self): # Activate iNotify watching
if self.status:
return
if not pathExists(self.path):
LOGGER.info("Watcher was not started: path '%s' was not found.", self.path)
return
self.mark = stat(self.path).st_ctime_ns
def wHandler():
st = stat(self.path).st_ctime_ns
if st != self.mark:
self.mark = st
self.handler(*self.args, **self.kwargs)
self.timer = thTimer(0.5, wHandler)
self.timer.start()
self.timer = thTimer(0.5, wHandler)
self.timer.start()
self.status = True
def stop(self):
if not self.status:
return
self.timer.cancel()
class __DConfig(Config):
# Redefined class for daemon config
def save(self): # Update daemon config file
# Make a new Config object
fileConfig = Config(self.fileName, load=False)
# Copy values that could be changed to the new Config object and convert representation
ro = self.get('read-only', False)
fileConfig['read-only'] = '' if ro else None
fileConfig['overwrite'] = '' if self.get('overwrite', False) and ro else None
fileConfig['startonstartofindicator'] = self.get('startonstartofindicator', True)
fileConfig['stoponexitfromindicator'] = self.get('stoponexitfromindicator', False)
exList = self.get('exclude-dirs', None)
fileConfig['exclude-dirs'] = (None if exList is None else
', '.join(v for v in CVal(exList)))
# Store changed values
fileConfig.save()
self.changed = False
def load(self): # Get daemon config from its config file
if super().load(): # Load config from file
# Convert values representations
self['read-only'] = (self.get('read-only', None) == '')
self['overwrite'] = (self.get('overwrite', None) == '')
self.setdefault('startonstartofindicator', True) # New value to start daemon individually
self.setdefault('stoponexitfromindicator', False) # New value to stop daemon individually
exDirs = self.setdefault('exclude-dirs', None)
if exDirs is not None and not isinstance(exDirs, list):
# Additional parsing required when quoted value like "dir,dir,dir" is specified.
# When the value specified without quotes it will be already list value [dir, dir, dir].
self['exclude-dirs'] = self.getValue(exDirs)
return True
return False
# ################### Private methods ################### #
def __init__(self, cfgFile, ID): # Check that daemon installed and configured and initialize object
# cfgFile - full path to config file
# ID - identity string '#<n> ' in multi-instance environment or '' in single instance environment
self.ID = ID # Remember daemon identity
self.__YDC = which('yandex-disk')
if self.__YDC is None:
self.error('', '')
sysExit(1)
# Try to read Yandex.Disk configuration file and make sure that it is correctly configured
self.config = self.__DConfig(cfgFile, load=False)
while True:
# Check the daemon configuration and prepare error message according the detected problem
if not self.config.load():
errorStr = "Error: the file %s is missing or has wrong structure" % cfgFile
else:
d = self.config.get('dir', "")
a = self.config.get('auth', "")
if not d or not a:
errorStr = ("Error: " + ("option 'dir'" if not d else "") + (" and " if not d and not a else "") +
("option 'auth'" if not a else "") + (" are " if not a and not d else " is ") +
"missing in the daemon configuration file %s" % cfgFile)
else:
dp = expanduser(d)
dne = not pathExists(dp)
ap = expanduser(a)
ane = not pathExists(ap)
if ane or dne:
errorStr = ("Error: " + ("path %s" % dp if dne else "") + (" and " if dne and ane else "") +
("path %s" % ap if ane else "") + (" are " if ane and dne else " is ") + "not exist")
else:
break # no config problems was found, go on
# some configuration problem was found and errorStr contains the detailed description of the problem
if self.error(errorStr, cfgFile) != 0:
if ID != '':
self.config['dir'] = ""
break # Exit from loop in multi-instance configuration
sysExit(1)
self.tmpDir = gettempdir()
# Set initial daemon status values
self.__v = {'status': 'unknown', 'progress': '', 'laststatus': 'unknown', 'statchg': True,
'total': '...', 'used': '...', 'free': '...', 'trash': '...', 'szchg': True,
'error': '', 'path': '', 'lastitems': [], 'lastchg': True}
# Declare event handler staff for callback from watcher and timer
self.__tCnt = 0 # Timer event counter
self.__lock = Lock() # event handler lock
def eventHandler(watch):
# Handles watcher (when watch=False) and and timer (when watch=True) events.
# After receiving and parsing the daemon output it raises outside change event if daemon changes
# at least one of its status values.
# Enter to critical section through acquiring of the lock as it can be called from two different threads
self.__lock.acquire()
# Parse fresh daemon output. Parsing returns true when something changed
if self.__parseOutput(self.__getOutput()):
LOGGER.debug('%sEvent raised by %s', self.ID, (' Watcher' if watch else ' Timer'))
self.change(self.__v) # Call the callback of update event handler
# --- Handle timer delays ---
self.__timer.cancel() # Cancel timer if it still active
if watch or self.__v['status'] == 'busy':
delay = 2 # Initial delay
self.__tCnt = 0 # Reset counter
else: # It called by timer
delay = 2 + self.__tCnt # Increase interval up to 10 sec (2 + 8)
self.__tCnt += 1 # Increase counter to increase delay next activation.
if self.__tCnt < 9: # Don't start timer after 10 seconds delay
self.__timer = thTimer(delay, eventHandler, (False,))
self.__timer.start()
# Leave the critical section
self.__lock.release()
# Initialize watcher staff
self.__watcher = self.__Watcher(pathJoin(expanduser(self.config['dir']), '.sync/cli.log'), eventHandler, (True,))
# Initialize timer staff
self.__timer = thTimer(0.3, eventHandler, (False,))
self.__timer.start()
# Start daemon if it is required in configuration
if self.config.get('startonstartofindicator', True):
self.start()
else:
self.__watcher.start() # try to activate file watcher
def __getOutput(self, userLang=False): # Get result of 'yandex-disk status'
cmd = [self.__YDC, 'status', '-c', self.config.fileName]
if not userLang: # Change locale settings when it required
cmd = ['env', '-i', "TMPDIR=%s" % self.tmpDir] + cmd
# LOGGER.debug('cmd = %s', str(cmd))
try:
output = check_output(cmd, universal_newlines=True)
except:
output = '' # daemon is not running or bad
# LOGGER.debug('Status output = %s', output)
return output
def __parseOutput(self, out): # Parse the daemon output
# It parses the daemon output and check that something changed from last daemon status.
# The self.__v dictionary is updated with new daemon statuses. It returns True is something changed
# Daemon status is converted form daemon raw statuses into internal representation.
# Internal status can be on of the following: 'busy', 'idle', 'paused', 'none', 'no_net', 'error'.
# Conversion is done by following rules:
# - empty status (daemon is not running) converted to 'none'
# - statuses 'busy', 'idle', 'paused' are passed 'as is'
# - 'index' is ignored (previous status is kept)
# - 'no internet access' converted to 'no_net'
# - 'error' covers all other errors, except 'no internet access'
self.__v['statchg'] = False
self.__v['szchg'] = False
self.__v['lastchg'] = False
# Split output on two parts: list of named values and file list
output = out.split('Last synchronized items:')
if len(output) == 2:
files = output[1]
else:
files = ''
output = output[0].splitlines()
# Make a dictionary from named values (use only lines containing ':')
res = dict(reFindall(r'\s*(.+):\s*(.*)', l)[0] for l in output if ':' in l)
# Parse named status values
for srch, key in (('Synchronization core status', 'status'), ('Sync progress', 'progress'),
('Total', 'total'), ('Used', 'used'), ('Available', 'free'),
('Trash size', 'trash'), ('Error', 'error'), ('Path', 'path')):
val = res.get(srch, '')
if key == 'status': # Convert status to internal representation
# LOGGER.debug('Raw status: \'%s\', previous status: %s', val, self.__v['status'])
# Store previous status
self.__v['laststatus'] = self.__v['status']
# Convert daemon raw status to internal representation
val = ('none' if val == '' else
# Ignore index status
'busy' if val == 'index' and self.__v['laststatus'] == "unknown" else
self.__v['laststatus'] if val == 'index' and self.__v['laststatus'] != "unknown" else
# Rename long error status
'no_net' if val == 'no internet access' else
# pass 'busy', 'idle' and 'paused' statuses 'as is'
val if val in ['busy', 'idle', 'paused'] else
# Status 'error' covers 'error', 'failed to connect to daemon process' and other.
'error')
elif key != 'progress' and val == '': # 'progress' can be '' the rest - can't
val = '...' # Make default filling for empty values
# Check value change and store changed
if self.__v[key] != val: # Check change of value
self.__v[key] = val # Store new value
if key == 'status':
self.__v['statchg'] = True # Remember that status changed
elif key == 'progress':
self.__v['statchg'] = True # Remember that progress changed
else:
self.__v['szchg'] = True # Remember that something changed in sizes values
# Parse last synchronized items
buf = reFindall(r".*: '(.*)'\n", files)
# Check if file list has been changed
if self.__v['lastitems'] != buf:
self.__v['lastitems'] = buf # Store the new file list
self.__v['lastchg'] = True # Remember that it is changed
# return True when something changed, if nothing changed - return False
return self.__v['statchg'] or self.__v['szchg'] or self.__v['lastchg']
# ################### Interface methods ################### #
def output(self, callBack): # Receive daemon output in separate thread and pass it back through the callback
Thread(target=lambda: callBack(self.__getOutput(True))).start()
def start(self, wait=False): # Execute 'yandex-disk start' in separate thread
# Execute 'yandex-disk start' in separate thread
# Additionally it starts watcher in case of success start
def do_start():
if self.__getOutput() != "":
LOGGER.info('Daemon is already started')
self.__watcher.start() # Activate file watcher
return
try: # Try to start
cmd = [self.__YDC, 'start', '-c', self.config.fileName]
msg = check_output(cmd, universal_newlines=True)
LOGGER.info('Daemon started, message: %s', msg)
except CalledProcessError as e:
LOGGER.error('Daemon start failed with code %d: %s', e.returncode, e.output)
self.__v = {'status': 'error', 'progress': '', 'laststatus': self.__v['status'], 'statchg': True,
'total': '...', 'used': '...', 'free': '...', 'trash': '...', 'szchg': True,
'error': msg.split('\n')[0], 'path': '', 'lastitems': [], 'lastchg': True}
self.change(self.__v)
return
self.__watcher.start() # Activate file watcher
if wait:
do_start()
else:
Thread(target=do_start).start()
def stop(self, wait=False): # Execute 'yandex-disk stop' in separate thread
def do_stop():
if self.__getOutput() == "":
LOGGER.info('Daemon is not started')
return
try:
msg = check_output([self.__YDC, 'stop', '-c', self.config.fileName], universal_newlines=True)
LOGGER.info('Daemon stopped, message: %s', msg)
except:
LOGGER.info('Daemon stop failed')
if wait:
do_stop()
else:
Thread(target=do_stop).start()
def exit(self): # Handle daemon/indicator closing
LOGGER.debug("Daemon inerface %sexit started: ", self.ID)
self.__watcher.stop()
self.__timer.cancel() # stop event timer if it is running
# Stop yandex-disk daemon if it is required by its configuration
if self.config.get('stoponexitfromindicator', False):
self.stop(wait=True)
LOGGER.info('Demon %sstopped', self.ID)
LOGGER.debug('Daemon inerface %sexited', self.ID)