7
from datetime import datetime, timedelta
9
from sqlalchemy.orm import sessionmaker
10
from sqlalchemy.ext.declarative import declarative_base
11
from sqlalchemy.pool import SingletonThreadPool
12
from flexget.event import fire_event
13
from flexget import validator
15
log = logging.getLogger('manager')
17
Base = declarative_base()
18
Session = sessionmaker()
20
DB_CLEANUP_INTERVAL = timedelta(days=7)
22
# Validator that handles root structure of config.
23
_config_validator = validator.factory('dict')
26
def register_config_key(key, validator, required=False):
27
""" Registers a valid root level key for the config.
30
Name of the root level key being registered.
32
Validator for the key.
33
Accepts: :class:`flexget.validator.Validator` instance, function returning
34
Validator instance, or validator type string.
36
Specify whether this is a mandatory key.
38
_config_validator.accept(validator, key=key, required=required)
41
def useExecLogging(func):
43
def wrapper(self, *args, **kw):
44
# Set the feed name in the logger
45
from flexget import logger
47
logger.set_execution(str(time.time()))
49
return func(self, *args, **kw)
51
logger.set_execution('')
58
"""Manager class for FlexGet
64
After manager has been initialized. This is when application becomes ready to use
68
Upgrade plugin database schemas etc
70
* manager.execute.started
72
When execute is about the be started, this happens before any feed phases occur
73
including on_process_start
75
* manager.execute.completed
77
After manager has executed all Feeds
81
When the manager is exiting
87
def __init__(self, options):
89
:param options: optparse parsed options object
92
assert not manager, 'Only one instance of Manager should be created at a time!'
94
self.options = options
95
self.config_base = None
96
self.config_name = None
97
self.db_filename = None
100
self.database_uri = None
107
# cannot be imported at module level because of circular references
108
from flexget.utils.simple_persistence import SimplePersistence
109
self.persist = SimplePersistence('manager')
111
log.debug('sys.defaultencoding: %s' % sys.getdefaultencoding())
112
log.debug('sys.getfilesystemencoding: %s' % sys.getfilesystemencoding())
113
log.debug('os.path.supports_unicode_filenames: %s' % os.path.supports_unicode_filenames)
115
fire_event('manager.upgrade', self)
116
fire_event('manager.startup', self)
123
def initialize(self):
124
"""Separated from __init__ so that unit tests can modify options before loading config."""
128
self.init_sqlalchemy()
129
errors = self.validate_config()
136
def setup_yaml(self):
137
""" Set up the yaml loader to return unicode objects for strings by default
140
def construct_yaml_str(self, node):
141
# Override the default string handling function
142
# to always return unicode objects
143
return self.construct_scalar(node)
144
yaml.Loader.add_constructor(u'tag:yaml.org,2002:str', construct_yaml_str)
145
yaml.SafeLoader.add_constructor(u'tag:yaml.org,2002:str', construct_yaml_str)
147
# Set up the dumper to not tag every string with !!python/unicode
148
def unicode_representer(dumper, uni):
149
node = yaml.ScalarNode(tag=u'tag:yaml.org,2002:str', value=uni)
151
yaml.add_representer(unicode, unicode_representer)
153
# Set up the dumper to increase the indent for lists
154
def increase_indent_wrapper(func):
156
def increase_indent(self, flow=False, indentless=False):
157
func(self, flow, False)
158
return increase_indent
160
yaml.Dumper.increase_indent = increase_indent_wrapper(yaml.Dumper.increase_indent)
161
yaml.SafeDumper.increase_indent = increase_indent_wrapper(yaml.SafeDumper.increase_indent)
163
def find_config(self):
164
"""Find the configuration file and then call :meth:`.load_config` to load it"""
165
startup_path = os.path.dirname(os.path.abspath(sys.path[0]))
166
home_path = os.path.join(os.path.expanduser('~'), '.flexget')
167
current_path = os.getcwd()
168
exec_path = sys.path[0]
170
config_path = os.path.dirname(self.options.config)
171
path_given = config_path != ''
175
# explicit path given, don't try anything too fancy
176
possible.append(self.options.config)
178
log.debug('Figuring out config load paths')
179
# normal lookup locations
180
possible.append(startup_path)
181
possible.append(home_path)
182
# for virtualenv / dev sandbox
183
from flexget import __version__ as version
184
if version == '{subversion}':
185
log.debug('Running subversion, adding virtualenv / sandbox paths')
186
possible.append(os.path.join(exec_path, '..'))
187
possible.append(current_path)
188
possible.append(exec_path)
190
for path in possible:
191
config = os.path.join(path, self.options.config)
192
if os.path.exists(config):
193
log.debug('Found config: %s' % config)
194
self.load_config(config)
196
log.info('Tried to read from: %s' % ', '.join(possible))
197
raise IOError('Failed to find configuration file %s' % self.options.config)
199
def load_config(self, config):
203
Calls sys.exit(1) if configuration file could not be loaded.
204
This is something we probably want to change.
206
:param string config: Path to configuration file
208
if not self.options.quiet:
209
# pre-check only when running without --cron
210
self.pre_check_config(config)
212
self.config = yaml.safe_load(file(config)) or {}
217
print ' Malformed configuration file, common reasons:'
220
print ' o Indentation error'
221
print ' o Missing : from end of the line'
222
print ' o Non ASCII characters (use UTF8)'
223
print ' o If text contains any of :[]{}% characters it must be single-quoted (eg. value{1} should be \'value{1}\')\n'
225
# Not very good practice but we get several kind of exceptions here, I'm not even sure all of them
226
# At least: ReaderError, YmlScannerError (or something like that)
227
if hasattr(e, 'problem') and hasattr(e, 'context_mark') and hasattr(e, 'problem_mark'):
229
if e.problem is not None:
230
print ' Reason: %s\n' % e.problem
231
if e.problem == 'mapping values are not allowed here':
232
print ' ----> MOST LIKELY REASON: Missing : from end of the line!'
234
if e.context_mark is not None:
235
print ' Check configuration near line %s, column %s' % (e.context_mark.line, e.context_mark.column)
237
if e.problem_mark is not None:
238
print ' Check configuration near line %s, column %s' % (e.problem_mark.line, e.problem_mark.column)
243
print ' Fault is almost always in this or previous line\n'
245
print ' Fault is almost always in one of these lines or previous ones\n'
247
# When --debug escalate to full stacktrace
248
if self.options.debug:
252
# config loaded successfully
253
self.config_name = os.path.splitext(os.path.basename(config))[0]
254
self.config_base = os.path.normpath(os.path.dirname(config))
255
self.lockfile = os.path.join(self.config_base, '.%s-lock' % self.config_name)
256
log.debug('config_name: %s' % self.config_name)
257
log.debug('config_base: %s' % self.config_base)
259
def save_config(self):
260
"""Dumps current config to yaml config file"""
261
config_file = file(os.path.join(self.config_base, self.config_name) + '.yml', 'w')
263
config_file.write(yaml.dump(self.config, default_flow_style=False))
267
def pre_check_config(self, fn):
268
"""Checks configuration file for common mistakes that are easily detectable"""
270
def get_indentation(line):
272
while i < n and line[i] == ' ':
287
list_open = False # multiline list with [
294
if line.strip() == '':
297
if line.strip().startswith('#'):
299
indentation = get_indentation(line)
302
if indentation <= prev_indentation:
307
cur_list = line.strip().startswith('-')
309
# skipping lines as long as multiline compact list is open
311
if line.strip().endswith(']'):
313
# print 'closed list at line %s' % line
316
list_open = line.strip().endswith(': [') or line.strip().endswith(':[')
318
# print 'list open at line %s' % line
321
# print '#%i: %s' % (line_num, line)
322
# print 'indentation: %s, prev_ind: %s, prev_mapping: %s, prev_list: %s, cur_list: %s' % \
323
# (indentation, prev_indentation, prev_mapping, prev_list, cur_list)
326
log.warning('Line %s has tabs, use only spaces!' % line_num)
327
if isodd(indentation):
328
log.warning('Config line %s has odd (uneven) indentation' % line_num)
329
if indentation > prev_indentation and not prev_mapping:
330
# line increases indentation, but previous didn't start mapping
331
log.warning('Config line %s is likely missing \':\' at the end' % (line_num - 1))
332
if indentation > prev_indentation + 2 and prev_mapping and not prev_list:
333
# mapping value after non list indented more than 2
334
log.warning('Config line %s is indented too much' % line_num)
335
if indentation <= prev_indentation + (2 * (not cur_list)) and prev_mapping and prev_list:
336
log.warning('Config line %s is not indented enough' % line_num)
337
if prev_mapping and cur_list:
338
# list after opening mapping
339
if indentation < prev_indentation or indentation > prev_indentation + 2 + (2 * prev_list):
340
log.warning('Config line %s containing list element is indented incorrectly' % line_num)
341
elif prev_mapping and indentation <= prev_indentation:
342
# after opening a map, indentation doesn't increase
343
log.warning('Config line %s is indented incorrectly (previous line ends with \':\')' % line_num)
345
# notify if user is trying to set same key multiple times in a feed (a common mistake)
346
for level in duplicates.iterkeys():
347
# when indentation goes down, delete everything indented more than that
348
if indentation < level:
349
duplicates[level] = {}
351
name = line.split(':', 1)[0].strip()
352
ns = duplicates.setdefault(indentation, {})
354
log.warning('Trying to set value for `%s` in line %s, but it is already defined in line %s!' % (name, line_num, ns[name]))
357
prev_indentation = indentation
358
# this line is a mapping (ends with :)
359
prev_mapping = line[-1] == ':'
360
prev_scalar = line[-1] in '|>'
361
# this line is a list
362
prev_list = line.strip()[0] == '-'
364
# This line is in a list, so clear the duplicates, as duplicates are not always wrong in a list. see #697
365
duplicates[indentation] = {}
368
log.debug('Pre-checked %s configuration lines' % line_num)
370
def validate_config(self):
371
"""Check all root level keywords are valid."""
372
_config_validator.validate(self.config)
373
return _config_validator.errors.messages
375
def init_sqlalchemy(self):
376
"""Initialize SQLAlchemy"""
378
if [int(part) for part in sqlalchemy.__version__.split('.')] < [0, 7, 0]:
379
print >> sys.stderr, 'FATAL: SQLAlchemy 0.7.0 or newer required. Please upgrade your SQLAlchemy.'
381
except ValueError, e:
382
log.critical('Failed to check SQLAlchemy version, you may need to upgrade it')
385
if self.database_uri is None:
386
self.db_filename = os.path.join(self.config_base, 'db-%s.sqlite' % self.config_name)
387
if self.options.test:
388
db_test_filename = os.path.join(self.config_base, 'test-%s.sqlite' % self.config_name)
389
log.info('Test mode, creating a copy from database ...')
390
if os.path.exists(self.db_filename):
391
shutil.copy(self.db_filename, db_test_filename)
392
self.db_filename = db_test_filename
393
log.info('Test database created')
395
# in case running on windows, needs double \\
396
filename = self.db_filename.replace('\\', '\\\\')
397
self.database_uri = 'sqlite:///%s' % filename
400
log.debug('Connecting to: %s' % self.database_uri)
402
self.engine = sqlalchemy.create_engine(self.database_uri,
403
echo=self.options.debug_sql,
404
poolclass=SingletonThreadPool)
406
print >> sys.stderr, ('FATAL: Unable to use SQLite. Are you running Python 2.5 - 2.7 ?\n'
407
'Python should normally have SQLite support built in.\n'
408
'If you\'re running correct version of Python then it is not equipped with SQLite.\n'
409
'You can try installing `pysqlite`. If you have compiled python yourself, recompile it with SQLite support.')
411
Session.configure(bind=self.engine)
412
# create all tables, doesn't do anything to existing tables
413
from sqlalchemy.exc import OperationalError
415
if self.options.reset or self.options.del_db:
416
Base.metadata.drop_all(bind=self.engine)
417
Base.metadata.create_all(bind=self.engine)
418
except OperationalError, e:
419
if os.path.exists(self.db_filename):
420
print >> sys.stderr, '%s - make sure you have write permissions to file %s' % (e.message, self.db_filename)
422
print >> sys.stderr, '%s - make sure you have write permissions to directory %s' % (e.message, self.config_base)
423
raise Exception(e.message)
425
def check_lock(self):
426
"""Checks if there is already a lock, returns True if there is."""
427
if os.path.exists(self.lockfile):
429
lock_time = datetime.fromtimestamp(os.path.getmtime(self.lockfile))
430
if (datetime.now() - lock_time).seconds > 36000:
431
log.warning('Lock file over 10 hour in age, ignoring it ...')
436
def acquire_lock(self):
437
if self.options.log_start:
438
log.info('FlexGet started (PID: %s)' % os.getpid())
440
# Exit if there is an existing lock.
441
if self.check_lock():
442
if not self.options.quiet:
443
f = file(self.lockfile)
446
print >> sys.stderr, 'Another process (%s) is running, will exit.' % pid.strip()
447
print >> sys.stderr, 'If you\'re sure there is no other instance running, delete %s' % self.lockfile
450
f = file(self.lockfile, 'w')
451
f.write('PID: %s\n' % os.getpid())
453
atexit.register(self.release_lock)
455
def release_lock(self):
456
if self.options.log_start:
457
log.info('FlexGet stopped (PID: %s)' % os.getpid())
458
if os.path.exists(self.lockfile):
459
os.remove(self.lockfile)
460
log.debug('Removed %s' % self.lockfile)
462
log.debug('Lockfile %s not found' % self.lockfile)
464
def create_feeds(self):
465
"""Creates instances of all configured feeds"""
466
from flexget.feed import Feed
470
# construct feed list
471
feeds = self.config.get('feeds', {}).keys()
474
feed = Feed(self, name, self.config['feeds'][name])
475
# if feed name is prefixed with _ it's disabled
476
if name.startswith('_'):
478
self.feeds[name] = feed
480
def disable_feeds(self):
481
"""Disables all feeds."""
482
for feed in self.feeds.itervalues():
485
def enable_feeds(self):
486
"""Enables all feeds."""
487
for feed in self.feeds.itervalues():
490
def process_start(self, feeds=None):
491
"""Execute process_start for feeds.
493
:param list feeds: Optional list of :class:`~flexget.feed.Feed` instances, defaults to all.
496
feeds = self.feeds.values()
502
log.trace('calling process_start on a feed %s' % feed.name)
503
feed._process_start()
506
log.exception('Feed %s process_start: %s' % (feed.name, e))
508
def process_end(self, feeds=None):
509
"""Execute process_end for all feeds.
511
:param list feeds: Optional list of :class:`~flexget.feed.Feed` instances, defaults to all.
514
feeds = self.feeds.values()
522
log.trace('calling process_end on a feed %s' % feed.name)
525
log.exception('Feed %s process_end: %s' % (feed.name, e))
528
def execute(self, feeds=None, disable_phases=None, entries=None):
530
Iterate trough feeds and run them. If --learn is used download and output
533
:param list feeds: Optional list of feed names to run, all feeds otherwise.
534
:param list disable_phases: Optional list of phases to disabled
535
:param list entries: Optional list of entries to pass into feed(s).
536
This will also cause feed to disable input phase.
538
# Make a list of Feed instances to execute
540
# Default to all feeds if none are specified
541
run_feeds = self.feeds.values()
543
# Turn the list of feed names or instances into a list of instances
546
if isinstance(feed, basestring):
547
if feed in self.feeds:
548
run_feeds.append(self.feeds[feed])
550
log.error('Feed `%s` does not exist.' % feed)
552
run_feeds.append(feed)
555
log.warning('There are no feeds to execute, please add some feeds')
558
disable_phases = disable_phases or []
559
# when learning, skip few phases
560
if self.options.learn:
561
log.info('Disabling download and output phases because of %s' %
562
('--reset' if self.options.reset else '--learn'))
563
disable_phases.extend(['download', 'output'])
565
fire_event('manager.execute.started', self)
566
self.process_start(feeds=run_feeds)
568
for feed in sorted(run_feeds):
569
if not feed.enabled or feed._abort:
572
feed.execute(disable_phases=disable_phases, entries=entries)
575
log.exception('Feed %s: %s' % (feed.name, e))
576
except KeyboardInterrupt:
577
# show real stack trace in debug mode
578
if self.options.debug:
580
print '**** Keyboard Interrupt ****'
583
self.process_end(feeds=run_feeds)
584
fire_event('manager.execute.completed', self)
586
def db_cleanup(self):
587
""" Perform database cleanup if cleanup interval has been met.
589
if (self.options.db_cleanup or not self.persist.get('last_cleanup') or
590
self.persist['last_cleanup'] < datetime.now() - DB_CLEANUP_INTERVAL):
591
log.info('Running database cleanup.')
593
fire_event('manager.db_cleanup', session)
596
self.persist['last_cleanup'] = datetime.now()
598
log.debug('Not running db cleanup, last run %s' % self.persist.get('last_cleanup'))
601
""" Application is being exited
603
fire_event('manager.shutdown', self)
604
if not self.unit_test: # don't scroll "nosetests" summary results when logging is enabled
605
log.debug('Shutting down')
606
self.engine.dispose()
607
# remove temporary database used in test mode
608
if self.options.test:
609
if not 'test' in self.db_filename:
610
raise Exception('trying to delete non test database?')
611
os.remove(self.db_filename)
612
log.info('Removed test database')
613
if not self.unit_test: # don't scroll "nosetests" summary results when logging is enabled
614
log.debug('Shutdown completed')