diff --git a/couchpotato/__init__.py b/couchpotato/__init__.py index 3e283639..c3b1bd81 100644 --- a/couchpotato/__init__.py +++ b/couchpotato/__init__.py @@ -69,10 +69,10 @@ def getApiKey(): @app.errorhandler(404) def page_not_found(error): index_url = url_for('web.index') - url = getattr(request, 'path')[len(index_url):] + url = request.path[len(index_url):] if url[:3] != 'api': - return redirect(index_url + '#' + url) + return redirect(request.url.replace(request.path, index_url + '#' + url)) else: time.sleep(0.1) return 'Wrong API key used', 404 diff --git a/couchpotato/core/_base/_core/main.py b/couchpotato/core/_base/_core/main.py index 23deedfa..bac1efed 100644 --- a/couchpotato/core/_base/_core/main.py +++ b/couchpotato/core/_base/_core/main.py @@ -96,7 +96,7 @@ class Core(Plugin): while loop: log.debug('Asking who is running') still_running = fireEvent('plugin.running', merge = True) - log.debug('Still running: %s' % still_running) + log.debug('Still running: %s', still_running) if len(still_running) == 0: break @@ -105,7 +105,7 @@ class Core(Plugin): running = list(set(still_running) - set(self.ignore_restart)) if len(running) > 0: - log.info('Waiting on plugins to finish: %s' % running) + log.info('Waiting on plugins to finish: %s', running) else: loop = False @@ -118,7 +118,7 @@ class Core(Plugin): except RuntimeError: pass except: - log.error('Failed shutting down the server: %s' % traceback.format_exc()) + log.error('Failed shutting down the server: %s', traceback.format_exc()) fireEvent('app.after_shutdown', restart = restart) diff --git a/couchpotato/core/_base/scheduler/main.py b/couchpotato/core/_base/scheduler/main.py index d09efedb..d442722d 100644 --- a/couchpotato/core/_base/scheduler/main.py +++ b/couchpotato/core/_base/scheduler/main.py @@ -28,7 +28,7 @@ class Scheduler(Plugin): for type in ['interval', 'cron']: try: self.sched.unschedule_job(getattr(self, type)[identifier]['job']) - log.debug('%s unscheduled %s' % (type.capitalize(), identifier)) + log.debug('%s unscheduled %s', (type.capitalize(), identifier)) except: pass @@ -45,7 +45,7 @@ class Scheduler(Plugin): job = self.sched.add_cron_job(cron['handle'], day = cron['day'], hour = cron['hour'], minute = cron['minute']) cron['job'] = job except ValueError, e: - log.error("Failed adding cronjob: %s" % e) + log.error('Failed adding cronjob: %s', e) # Intervals for identifier in self.intervals: @@ -55,7 +55,7 @@ class Scheduler(Plugin): job = self.sched.add_interval_job(interval['handle'], hours = interval['hours'], minutes = interval['minutes'], seconds = interval['seconds']) interval['job'] = job except ValueError, e: - log.error("Failed adding interval cronjob: %s" % e) + log.error('Failed adding interval cronjob: %s', e) # Start it log.debug('Starting scheduler') @@ -75,7 +75,7 @@ class Scheduler(Plugin): self.started = False def cron(self, identifier = '', handle = None, day = '*', hour = '*', minute = '*'): - log.info('Scheduling "%s", cron: day = %s, hour = %s, minute = %s' % (identifier, day, hour, minute)) + log.info('Scheduling "%s", cron: day = %s, hour = %s, minute = %s', (identifier, day, hour, minute)) self.remove(identifier) self.crons[identifier] = { @@ -86,7 +86,7 @@ class Scheduler(Plugin): } def interval(self, identifier = '', handle = None, hours = 0, minutes = 0, seconds = 0): - log.info('Scheduling %s, interval: hours = %s, minutes = %s, seconds = %s' % (identifier, hours, minutes, seconds)) + log.info('Scheduling %s, interval: hours = %s, minutes = %s, seconds = %s', (identifier, hours, minutes, seconds)) self.remove(identifier) self.intervals[identifier] = { diff --git a/couchpotato/core/_base/updater/main.py b/couchpotato/core/_base/updater/main.py index 174f1ff4..4c75c59a 100644 --- a/couchpotato/core/_base/updater/main.py +++ b/couchpotato/core/_base/updater/main.py @@ -1,5 +1,6 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent, fireEventAsync +from couchpotato.core.helpers.encoding import ss from couchpotato.core.helpers.request import jsonified from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin @@ -120,7 +121,7 @@ class BaseUpdater(Plugin): def deletePyc(self, only_excess = True): - for root, dirs, files in os.walk(Env.get('app_dir')): + for root, dirs, files in os.walk(ss(Env.get('app_dir'))): pyc_files = filter(lambda filename: filename.endswith('.pyc'), files) py_files = set(filter(lambda filename: filename.endswith('.py'), files)) @@ -128,11 +129,11 @@ class BaseUpdater(Plugin): for excess_pyc_file in excess_pyc_files: full_path = os.path.join(root, excess_pyc_file) - log.debug('Removing old PYC file: %s' % full_path) + log.debug('Removing old PYC file: %s', full_path) try: os.remove(full_path) except: - log.error('Couldn\'t remove %s: %s' % (full_path, traceback.format_exc())) + log.error('Couldn\'t remove %s: %s', (full_path, traceback.format_exc())) for dir_name in dirs: full_path = os.path.join(root, dir_name) @@ -140,7 +141,7 @@ class BaseUpdater(Plugin): try: os.rmdir(full_path) except: - log.error('Couldn\'t remove empty directory %s: %s' % (full_path, traceback.format_exc())) + log.error('Couldn\'t remove empty directory %s: %s', (full_path, traceback.format_exc())) @@ -170,7 +171,7 @@ class GitUpdater(BaseUpdater): return True except: - log.error('Failed updating via GIT: %s' % traceback.format_exc()) + log.error('Failed updating via GIT: %s', traceback.format_exc()) self.update_failed = True @@ -181,14 +182,14 @@ class GitUpdater(BaseUpdater): if not self.version: try: output = self.repo.getHead() # Yes, please - log.debug('Git version output: %s' % output.hash) + log.debug('Git version output: %s', output.hash) self.version = { 'hash': output.hash[:8], 'date': output.getDate(), 'type': 'git', } except Exception, e: - log.error('Failed using GIT updater, running from source, you need to have GIT installed. %s' % e) + log.error('Failed using GIT updater, running from source, you need to have GIT installed. %s', e) return 'No GIT' return self.version @@ -198,7 +199,7 @@ class GitUpdater(BaseUpdater): if self.update_version: return - log.info('Checking for new version on github for %s' % self.repo_name) + log.info('Checking for new version on github for %s', self.repo_name) if not Env.get('dev'): self.repo.fetch() @@ -210,7 +211,7 @@ class GitUpdater(BaseUpdater): local = self.repo.getHead() remote = branch.getHead() - log.info('Versions, local:%s, remote:%s' % (local.hash[:8], remote.hash[:8])) + log.info('Versions, local:%s, remote:%s', (local.hash[:8], remote.hash[:8])) if local.getDate() < remote.getDate(): self.update_version = { @@ -263,13 +264,13 @@ class SourceUpdater(BaseUpdater): return True except: - log.error('Failed updating: %s' % traceback.format_exc()) + log.error('Failed updating: %s', traceback.format_exc()) self.update_failed = True return False def replaceWith(self, path): - app_dir = Env.get('app_dir') + app_dir = ss(Env.get('app_dir')) # Get list of files we want to overwrite self.deletePyc(only_excess = False) @@ -296,7 +297,7 @@ class SourceUpdater(BaseUpdater): except ValueError: pass except Exception, e: - log.error('Failed overwriting file: %s' % e) + log.error('Failed overwriting file: %s', e) def removeDir(self, path): @@ -315,11 +316,11 @@ class SourceUpdater(BaseUpdater): output = json.loads(f.read()) f.close() - log.debug('Source version output: %s' % output) + log.debug('Source version output: %s', output) self.version = output self.version['type'] = 'source' except Exception, e: - log.error('Failed using source updater. %s' % e) + log.error('Failed using source updater. %s', e) return {} return self.version @@ -336,7 +337,7 @@ class SourceUpdater(BaseUpdater): self.last_check = time.time() except: - log.error('Failed updating via source: %s' % traceback.format_exc()) + log.error('Failed updating via source: %s', traceback.format_exc()) return self.update_version is not None @@ -351,7 +352,7 @@ class SourceUpdater(BaseUpdater): 'date': int(time.mktime(parse(commit['commit']['committer']['date']).timetuple())), } except: - log.error('Failed getting latest request from github: %s' % traceback.format_exc()) + log.error('Failed getting latest request from github: %s', traceback.format_exc()) return {} @@ -372,7 +373,7 @@ class DesktopUpdater(BaseUpdater): if e['status'] == 'done': fireEventAsync('app.restart') else: - log.error('Failed updating desktop: %s' % e['exception']) + log.error('Failed updating desktop: %s', e['exception']) self.update_failed = True self.desktop._esky.auto_update(callback = do_restart) @@ -403,7 +404,7 @@ class DesktopUpdater(BaseUpdater): self.last_check = time.time() except: - log.error('Failed updating desktop: %s' % traceback.format_exc()) + log.error('Failed updating desktop: %s', traceback.format_exc()) return self.update_version is not None diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index c12c8c6e..a3f03617 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -16,7 +16,7 @@ class Blackhole(Downloader): directory = self.conf('directory') if not directory or not os.path.isdir(directory): - log.error('No directory set for blackhole %s download.' % data.get('type')) + log.error('No directory set for blackhole %s download.', data.get('type')) else: try: if not filedata or len(filedata) < 50: @@ -27,19 +27,19 @@ class Blackhole(Downloader): try: if not os.path.isfile(fullPath): - log.info('Downloading %s to %s.' % (data.get('type'), fullPath)) + log.info('Downloading %s to %s.', (data.get('type'), fullPath)) with open(fullPath, 'wb') as f: f.write(filedata) return True else: - log.info('File %s already exists.' % fullPath) + log.info('File %s already exists.', fullPath) return True except: - log.error('Failed to download to blackhole %s' % traceback.format_exc()) + log.error('Failed to download to blackhole %s', traceback.format_exc()) pass except: - log.info('Failed to download file %s: %s' % (data.get('name'), traceback.format_exc())) + log.info('Failed to download file %s: %s', (data.get('name'), traceback.format_exc())) return False return False diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index 904a3a4e..0d9c52aa 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -20,10 +20,10 @@ class NZBGet(Downloader): return if not filedata: - log.error('Unable to get NZB file: %s' % traceback.format_exc()) + log.error('Unable to get NZB file: %s', traceback.format_exc()) return False - log.info('Sending "%s" to NZBGet.' % data.get('name')) + log.info('Sending "%s" to NZBGet.', data.get('name')) url = self.url % {'host': self.conf('host'), 'password': self.conf('password')} nzb_name = '%s.nzb' % self.createNzbName(data, movie) @@ -41,12 +41,12 @@ class NZBGet(Downloader): if e.errcode == 401: log.error('Password is incorrect.') else: - log.error('Protocol Error: %s' % e) + log.error('Protocol Error: %s', e) return False if rpc.append(nzb_name, self.conf('category'), False, standard_b64encode(filedata.strip())): log.info('NZB sent successfully to NZBGet') return True else: - log.error('NZBGet could not add %s to the queue.' % nzb_name) + log.error('NZBGet could not add %s to the queue.', nzb_name) return False diff --git a/couchpotato/core/downloaders/sabnzbd/__init__.py b/couchpotato/core/downloaders/sabnzbd/__init__.py index 23918fe2..ac0ce05c 100644 --- a/couchpotato/core/downloaders/sabnzbd/__init__.py +++ b/couchpotato/core/downloaders/sabnzbd/__init__.py @@ -33,12 +33,6 @@ config = [{ 'label': 'Category', 'description': 'The category CP places the nzb in. Like movies or couchpotato', }, - { - 'advanced': True, - 'name': 'pp_directory', - 'type': 'directory', - 'description': 'Your Post-Processing Script directory, set in Sabnzbd > Config > Directories.', - }, { 'name': 'manual', 'default': 0, diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index d6c5af6c..09325704 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -2,11 +2,6 @@ from couchpotato.core.downloaders.base import Downloader from couchpotato.core.helpers.encoding import tryUrlencode from couchpotato.core.helpers.variable import cleanHost from couchpotato.core.logger import CPLog -from inspect import ismethod, isfunction -from tempfile import mkstemp -import base64 -import os -import re import traceback log = CPLog(__name__) @@ -20,20 +15,7 @@ class Sabnzbd(Downloader): if self.isDisabled(manual) or not self.isCorrectType(data.get('type')): return - log.info("Sending '%s' to SABnzbd." % data.get('name')) - - if self.conf('ppDir') and data.get('imdb_id'): - try: - pp_script_fn = self.buildPp(data.get('imdb_id')) - except: - log.info("Failed to create post-processing script.") - pp_script_fn = False - if not pp_script_fn: - pp = False - else: - pp = True - else: - pp = False + log.info('Sending "%s" to SABnzbd.', data.get('name')) params = { 'apikey': self.conf('api_key'), @@ -53,9 +35,6 @@ class Sabnzbd(Downloader): else: params['name'] = data.get('url') - if pp: - params['script'] = pp_script_fn - url = cleanHost(self.conf('host')) + "api?" + tryUrlencode(params) try: @@ -82,48 +61,3 @@ class Sabnzbd(Downloader): else: log.error("Unknown error: " + result[:40]) return False - - def buildPp(self, imdb_id): - - pp_script_path = self.getPpFile() - - scriptB64 = '''IyEvdXNyL2Jpbi9weXRob24KaW1wb3J0IG9zCmltcG9ydCBzeXMKcHJpbnQgIkNyZWF0aW5nIGNwLmNw -bmZvIGZvciAlcyIgJSBzeXMuYXJndlsxXQppbWRiSWQgPSB7W0lNREJJREhFUkVdfQpwYXRoID0gb3Mu -cGF0aC5qb2luKHN5cy5hcmd2WzFdLCAiY3AuY3BuZm8iKQp0cnk6CiBmID0gb3BlbihwYXRoLCAndycp -CmV4Y2VwdCBJT0Vycm9yOgogcHJpbnQgIlVuYWJsZSB0byBvcGVuICVzIGZvciB3cml0aW5nIiAlIHBh -dGgKIHN5cy5leGl0KDEpCnRyeToKIGYud3JpdGUob3MucGF0aC5iYXNlbmFtZShzeXMuYXJndlswXSkr -IlxuIitpbWRiSWQpCmV4Y2VwdDoKIHByaW50ICJVbmFibGUgdG8gd3JpdGUgdG8gZmlsZTogJXMiICUg -cGF0aAogc3lzLmV4aXQoMikKZi5jbG9zZSgpCnByaW50ICJXcm90ZSBpbWRiIGlkLCAlcywgdG8gZmls -ZTogJXMiICUgKGltZGJJZCwgcGF0aCkK''' - - script = re.sub(r"\{\[IMDBIDHERE\]\}", "'%s'" % imdb_id, base64.b64decode(scriptB64)) - - try: - f = open(pp_script_path, 'wb') - except: - log.info("Unable to open post-processing script for writing. Check permissions: %s" % pp_script_path) - return False - - try: - f.write(script) - f.close() - except: - log.info("Unable to write to post-processing script. Check permissions: %s" % pp_script_path) - return False - - log.info("Wrote post-processing script to: %s" % pp_script_path) - - return os.path.basename(pp_script_path) - - def getPpFile(self): - - pp_script_handle, pp_script_path = mkstemp(suffix = '.py', dir = self.conf('ppDir')) - pp_sh = os.fdopen(pp_script_handle) - pp_sh.close() - - try: - os.chmod(pp_script_path, int('777', 8)) - except: - log.info("Unable to set post-processing script permissions to 777 (may still work correctly): %s" % pp_script_path) - - return pp_script_path diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py index 55e4e305..d8fb6f6c 100644 --- a/couchpotato/core/downloaders/transmission/main.py +++ b/couchpotato/core/downloaders/transmission/main.py @@ -16,7 +16,7 @@ class Transmission(Downloader): if self.isDisabled(manual) or not self.isCorrectType(data.get('type')): return - log.info('Sending "%s" to Transmission.' % data.get('name')) + log.info('Sending "%s" to Transmission.', data.get('name')) # Load host from config and split out port. host = self.conf('host').split(':') @@ -42,10 +42,10 @@ class Transmission(Downloader): torrent.seed_ratio_limit = self.conf('ratio') torrent.seed_ratio_mode = 'single' if self.conf('ratio') else 'global' except transmissionrpc.TransmissionError, e: - log.error('Failed to change settings for transfer in transmission: %s' % e) + log.error('Failed to change settings for transfer in transmission: %s', e) return True except transmissionrpc.TransmissionError, e: - log.error('Failed to send link to transmission: %s' % e) + log.error('Failed to send link to transmission: %s', e) return False diff --git a/couchpotato/core/event.py b/couchpotato/core/event.py index b7bed808..8319150a 100644 --- a/couchpotato/core/event.py +++ b/couchpotato/core/event.py @@ -12,7 +12,7 @@ def runHandler(name, handler, *args, **kwargs): return handler(*args, **kwargs) except: from couchpotato.environment import Env - log.error('Error in event "%s", that wasn\'t caught: %s%s' % (name, traceback.format_exc(), Env.all())) + log.error('Error in event "%s", that wasn\'t caught: %s%s', (name, traceback.format_exc(), Env.all())) def addEvent(name, handler, priority = 100): @@ -43,7 +43,7 @@ def removeEvent(name, handler): def fireEvent(name, *args, **kwargs): if not events.get(name): return - #log.debug('Firing event %s' % name) + #log.debug('Firing event %s', name) try: # Fire after event @@ -100,7 +100,7 @@ def fireEvent(name, *args, **kwargs): elif r[1]: errorHandler(r[1]) else: - log.debug('Assume disabled eventhandler for: %s' % name) + log.debug('Assume disabled eventhandler for: %s', name) else: results = [] @@ -130,7 +130,7 @@ def fireEvent(name, *args, **kwargs): modified_results = fireEvent('result.modify.%s' % name, results, single = True) if modified_results: - log.debug('Return modified results for %s' % name) + log.debug('Return modified results for %s', name) results = modified_results if not is_after_event: @@ -143,7 +143,7 @@ def fireEvent(name, *args, **kwargs): except KeyError, e: pass except Exception: - log.error('%s: %s' % (name, traceback.format_exc())) + log.error('%s: %s', (name, traceback.format_exc())) def fireEventAsync(*args, **kwargs): try: @@ -152,7 +152,7 @@ def fireEventAsync(*args, **kwargs): my_thread.start() return True except Exception, e: - log.error('%s: %s' % (args[0], e)) + log.error('%s: %s', (args[0], e)) def errorHandler(error): etype, value, tb = error diff --git a/couchpotato/core/helpers/encoding.py b/couchpotato/core/helpers/encoding.py index a9f09edd..9b1f575c 100644 --- a/couchpotato/core/helpers/encoding.py +++ b/couchpotato/core/helpers/encoding.py @@ -31,10 +31,14 @@ def toUnicode(original, *args): except: raise except UnicodeDecodeError: - log.error('Unable to decode value: %s... ' % repr(original)[:20]) + log.error('Unable to decode value: %s... ', repr(original)[:20]) ascii_text = str(original).encode('string_escape') return toUnicode(ascii_text) +def ss(original, *args): + from couchpotato.environment import Env + return toUnicode(original, *args).encode(Env.get('encoding')) + def ek(original, *args): if isinstance(original, (str, unicode)): try: diff --git a/couchpotato/core/helpers/rss.py b/couchpotato/core/helpers/rss.py index 582d38ac..d88fdb53 100644 --- a/couchpotato/core/helpers/rss.py +++ b/couchpotato/core/helpers/rss.py @@ -47,5 +47,5 @@ class RSS(object): try: return XMLTree.parse(data).findall(path) except Exception, e: - log.error('Error parsing RSS. %s' % e) + log.error('Error parsing RSS. %s', e) return [] diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index 493ac79f..0482fb40 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -118,10 +118,10 @@ def getTitle(library_dict): try: return library_dict['titles'][0]['title'] except: - log.error('Could not get title for %s' % library_dict['identifier']) + log.error('Could not get title for %s', library_dict['identifier']) return None except: - log.error('Could not get title for library item: %s' % library_dict) + log.error('Could not get title for library item: %s', library_dict) return None def randomString(size = 8, chars = string.ascii_uppercase + string.digits): diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index 957a6997..30395195 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -45,7 +45,7 @@ class Loader(object): try: m = getattr(self.loadModule(module_name), plugin.get('name')) - log.info("Loading %s: %s" % (plugin['type'], plugin['name'])) + log.info('Loading %s: %s', (plugin['type'], plugin['name'])) # Save default settings for plugin/provider did_save += self.loadSettings(m, module_name, save = False) @@ -57,10 +57,10 @@ class Loader(object): log.error(e.message) pass # todo:: this needs to be more descriptive. - log.error('Import error, remove the empty folder: %s' % plugin.get('module')) - log.debug('Can\'t import %s: %s' % (module_name, traceback.format_exc())) + log.error('Import error, remove the empty folder: %s', plugin.get('module')) + log.debug('Can\'t import %s: %s', (module_name, traceback.format_exc())) except: - log.error('Can\'t import %s: %s' % (module_name, traceback.format_exc())) + log.error('Can\'t import %s: %s', (module_name, traceback.format_exc())) if did_save: fireEvent('settings.save') @@ -84,7 +84,7 @@ class Loader(object): fireEvent('settings.register', section_name = section['name'], options = options, save = save) return True except: - log.debug("Failed loading settings for '%s': %s" % (name, traceback.format_exc())) + log.debug('Failed loading settings for "%s": %s', (name, traceback.format_exc())) return False def loadPlugins(self, module, name): @@ -97,7 +97,7 @@ class Loader(object): return True except Exception, e: - log.error("Failed loading plugin '%s': %s" % (module.__file__, traceback.format_exc())) + log.error('Failed loading plugin "%s": %s', (module.__file__, traceback.format_exc())) return False def addModule(self, priority, plugin_type, module, name): diff --git a/couchpotato/core/logger.py b/couchpotato/core/logger.py index 2c539060..1f5206ac 100644 --- a/couchpotato/core/logger.py +++ b/couchpotato/core/logger.py @@ -1,5 +1,6 @@ import logging import re +import traceback class CPLog(object): @@ -13,31 +14,42 @@ class CPLog(object): self.context = context self.logger = logging.getLogger() - def info(self, msg): - self.logger.info(self.addContext(msg)) + def info(self, msg, replace_tuple = ()): + self.logger.info(self.addContext(msg, replace_tuple)) - def debug(self, msg): - self.logger.debug(self.addContext(msg)) + def debug(self, msg, replace_tuple = ()): + self.logger.debug(self.addContext(msg, replace_tuple)) - def error(self, msg): - self.logger.error(self.addContext(msg)) + def error(self, msg, replace_tuple = ()): + self.logger.error(self.addContext(msg, replace_tuple)) - def warning(self, msg): - self.logger.warning(self.addContext(msg)) + def warning(self, msg, replace_tuple = ()): + self.logger.warning(self.addContext(msg, replace_tuple)) - def critical(self, msg): - self.logger.critical(self.addContext(msg), exc_info = 1) + def critical(self, msg, replace_tuple = ()): + self.logger.critical(self.addContext(msg, replace_tuple), exc_info = 1) - def addContext(self, msg): - return '[%+25.25s] %s' % (self.context[-25:], self.removePrivateData(msg)) + def addContext(self, msg, replace_tuple = ()): + return '[%+25.25s] %s' % (self.context[-25:], self.safeMessage(msg, replace_tuple)) - def removePrivateData(self, msg): - try: - msg = unicode(msg) - except: - pass + def safeMessage(self, msg, replace_tuple = ()): from couchpotato.environment import Env + from couchpotato.core.helpers.encoding import ss + + msg = ss(msg) + + try: + msg = msg % replace_tuple + except: + try: + if isinstance(replace_tuple, tuple): + msg = msg % tuple([ss(x) for x in list(replace_tuple)]) + else: + msg = msg % ss(replace_tuple) + except: + self.error('Failed encoding stuff to log: %s' % traceback.format_exc()) + if not Env.get('dev'): for replace in self.replace_private: diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py index 2663c333..c5f01ce4 100644 --- a/couchpotato/core/notifications/base.py +++ b/couchpotato/core/notifications/base.py @@ -41,7 +41,7 @@ class Notification(Plugin): test_type = self.testNotifyName() - log.info('Sending test to %s' % test_type) + log.info('Sending test to %s', test_type) success = self.notify( message = self.test_message, diff --git a/couchpotato/core/notifications/growl/main.py b/couchpotato/core/notifications/growl/main.py index 72ba2a56..4aa1c312 100644 --- a/couchpotato/core/notifications/growl/main.py +++ b/couchpotato/core/notifications/growl/main.py @@ -38,7 +38,7 @@ class Growl(Notification): self.growl.register() self.registered = True except: - log.error('Failed register of growl: %s' % traceback.format_exc()) + log.error('Failed register of growl: %s', traceback.format_exc()) def notify(self, message = '', data = {}, listener = None): if self.isDisabled(): return diff --git a/couchpotato/core/notifications/nmj/main.py b/couchpotato/core/notifications/nmj/main.py index 89aa8729..8f0e201d 100644 --- a/couchpotato/core/notifications/nmj/main.py +++ b/couchpotato/core/notifications/nmj/main.py @@ -33,10 +33,10 @@ class NMJ(Notification): try: terminal = telnetlib.Telnet(host) except Exception: - log.error('Warning: unable to get a telnet session to %s' % (host)) + log.error('Warning: unable to get a telnet session to %s', (host)) return self.failed() - log.debug('Connected to %s via telnet' % (host)) + log.debug('Connected to %s via telnet', (host)) terminal.read_until('sh-3.00# ') terminal.write('cat /tmp/source\n') terminal.write('cat /tmp/netshare\n') @@ -48,9 +48,9 @@ class NMJ(Notification): if match: database = match.group(1) device = match.group(2) - log.info('Found NMJ database %s on device %s' % (database, device)) + log.info('Found NMJ database %s on device %s', (database, device)) else: - log.error('Could not get current NMJ database on %s, NMJ is probably not running!' % (host)) + log.error('Could not get current NMJ database on %s, NMJ is probably not running!', (host)) return self.failed() if device.startswith('NETWORK_SHARE/'): @@ -58,7 +58,7 @@ class NMJ(Notification): if match: mount = match.group().replace('127.0.0.1', host) - log.info('Found mounting url on the Popcorn Hour in configuration: %s' % (mount)) + log.info('Found mounting url on the Popcorn Hour in configuration: %s', (mount)) else: log.error('Detected a network share on the Popcorn Hour, but could not get the mounting url') return self.failed() @@ -77,7 +77,7 @@ class NMJ(Notification): database = self.conf('database') if self.mount: - log.debug('Try to mount network drive via url: %s' % (mount)) + log.debug('Try to mount network drive via url: %s', (mount)) try: data = self.urlopen(mount) except: @@ -102,11 +102,11 @@ class NMJ(Notification): et = etree.fromstring(response) result = et.findtext('returnValue') except SyntaxError, e: - log.error('Unable to parse XML returned from the Popcorn Hour: %s' % (e)) + log.error('Unable to parse XML returned from the Popcorn Hour: %s', (e)) return False if int(result) > 0: - log.error('Popcorn Hour returned an errorcode: %s' % (result)) + log.error('Popcorn Hour returned an errorcode: %s', (result)) return False else: log.info('NMJ started background scan') diff --git a/couchpotato/core/notifications/notifo/main.py b/couchpotato/core/notifications/notifo/main.py index e372f28c..64eee5ec 100644 --- a/couchpotato/core/notifications/notifo/main.py +++ b/couchpotato/core/notifications/notifo/main.py @@ -32,7 +32,7 @@ class Notifo(Notification): raise Exception except: - log.error('Notification failed: %s' % traceback.format_exc()) + log.error('Notification failed: %s', traceback.format_exc()) return False log.info('Notifo notification successful.') diff --git a/couchpotato/core/notifications/notifymyandroid/main.py b/couchpotato/core/notifications/notifymyandroid/main.py index 195278e8..a6a316da 100644 --- a/couchpotato/core/notifications/notifymyandroid/main.py +++ b/couchpotato/core/notifications/notifymyandroid/main.py @@ -23,6 +23,6 @@ class NotifyMyAndroid(Notification): for key in keys: if not response[str(key)]['code'] == u'200': - log.error('Could not send notification to NotifyMyAndroid (%s). %s' % (key, response[key]['message'])) + log.error('Could not send notification to NotifyMyAndroid (%s). %s', (key, response[key]['message'])) return response diff --git a/couchpotato/core/notifications/notifymywp/main.py b/couchpotato/core/notifications/notifymywp/main.py index 17252c1a..fafffd55 100644 --- a/couchpotato/core/notifications/notifymywp/main.py +++ b/couchpotato/core/notifications/notifymywp/main.py @@ -17,7 +17,7 @@ class NotifyMyWP(Notification): for key in keys: if not response[key]['Code'] == u'200': - log.error('Could not send notification to NotifyMyWindowsPhone (%s). %s' % (key, response[key]['message'])) + log.error('Could not send notification to NotifyMyWindowsPhone (%s). %s', (key, response[key]['message'])) return False return response diff --git a/couchpotato/core/notifications/plex/__init__.py b/couchpotato/core/notifications/plex/__init__.py index 6714aba9..f908cbb2 100644 --- a/couchpotato/core/notifications/plex/__init__.py +++ b/couchpotato/core/notifications/plex/__init__.py @@ -17,8 +17,9 @@ config = [{ }, { 'name': 'host', - 'default': 'localhost:32400', - 'description': 'Default should be on localhost:32400', + 'default': 'localhost', + 'description': 'Default should be on localhost', + 'advanced': True, }, ], } diff --git a/couchpotato/core/notifications/plex/main.py b/couchpotato/core/notifications/plex/main.py index 72fdef17..ce0dc1a8 100644 --- a/couchpotato/core/notifications/plex/main.py +++ b/couchpotato/core/notifications/plex/main.py @@ -1,4 +1,5 @@ from couchpotato.core.event import addEvent +from couchpotato.core.helpers.encoding import tryUrlencode from couchpotato.core.helpers.variable import cleanHost from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification @@ -11,13 +12,14 @@ log = CPLog(__name__) class Plex(Notification): def __init__(self): + super(Plex, self).__init__() addEvent('renamer.after', self.addToLibrary) def addToLibrary(self, group = {}): if self.isDisabled(): return log.info('Sending notification to Plex') - hosts = [cleanHost(x.strip()) for x in self.conf('host').split(",")] + hosts = [cleanHost(x.strip() + ':32400') for x in self.conf('host').split(",")] for host in hosts: @@ -36,7 +38,30 @@ class Plex(Notification): x = self.urlopen(url) except: - log.error('Plex library update failed for %s: %s' % (host, traceback.format_exc())) + log.error('Plex library update failed for %s: %s', (host, traceback.format_exc())) return False return True + + def notify(self, message = '', data = {}, listener = None): + if self.isDisabled(): return + + for host in [x.strip() + ':3000' for x in self.conf('host').split(",")]: + self.send({'command': 'ExecBuiltIn', 'parameter': 'Notification(CouchPotato, %s)' % message}, host) + + return True + + def send(self, command, host): + + url = 'http://%s/xbmcCmds/xbmcHttp/?%s' % (host, tryUrlencode(command)) + + headers = {} + + try: + self.urlopen(url, headers = headers, show_error = False) + except: + log.error("Couldn't sent command to Plex") + return False + + log.info('Plex notification to %s successful.', host) + return True diff --git a/couchpotato/core/notifications/prowl/main.py b/couchpotato/core/notifications/prowl/main.py index 5f24d4e6..715965db 100644 --- a/couchpotato/core/notifications/prowl/main.py +++ b/couchpotato/core/notifications/prowl/main.py @@ -32,7 +32,7 @@ class Prowl(Notification): log.info('Prowl notifications sent.') return True elif request_status == 401: - log.error('Prowl auth failed: %s' % response.reason) + log.error('Prowl auth failed: %s', response.reason) return False else: log.error('Prowl notification failed.') diff --git a/couchpotato/core/notifications/pushover/main.py b/couchpotato/core/notifications/pushover/main.py index be99df12..bcd3245c 100644 --- a/couchpotato/core/notifications/pushover/main.py +++ b/couchpotato/core/notifications/pushover/main.py @@ -35,7 +35,7 @@ class Pushover(Notification): log.info('Pushover notifications sent.') return True elif request_status == 401: - log.error('Pushover auth failed: %s' % response.reason) + log.error('Pushover auth failed: %s', response.reason) return False else: log.error('Pushover notification failed.') diff --git a/couchpotato/core/notifications/synoindex/main.py b/couchpotato/core/notifications/synoindex/main.py index 9d06b781..93d6cdbd 100644 --- a/couchpotato/core/notifications/synoindex/main.py +++ b/couchpotato/core/notifications/synoindex/main.py @@ -15,14 +15,14 @@ class Synoindex(Notification): if self.isDisabled(): return command = ['/usr/syno/bin/synoindex', '-A', group.get('destination_dir')] - log.info(u'Executing synoindex command: %s ' % command) + log.info(u'Executing synoindex command: %s ', command) try: p = subprocess.Popen(command, stdout = subprocess.PIPE, stderr = subprocess.STDOUT) out = p.communicate() - log.info('Result from synoindex: %s' % str(out)) + log.info('Result from synoindex: %s', str(out)) return True except OSError, e: - log.error('Unable to run synoindex: %s' % e) + log.error('Unable to run synoindex: %s', e) return False return True diff --git a/couchpotato/core/notifications/twitter/main.py b/couchpotato/core/notifications/twitter/main.py index d800369d..a5c16a73 100644 --- a/couchpotato/core/notifications/twitter/main.py +++ b/couchpotato/core/notifications/twitter/main.py @@ -55,7 +55,7 @@ class Twitter(Notification): else: api.PostUpdate('[%s] %s' % (self.default_title, message)) except Exception, e: - log.error('Error sending tweet: %s' % e) + log.error('Error sending tweet: %s', e) return False return True @@ -71,7 +71,7 @@ class Twitter(Notification): resp, content = oauth_client.request(self.urls['request'], 'POST', body = tryUrlencode({'oauth_callback': callback_url})) if resp['status'] != '200': - log.error('Invalid response from Twitter requesting temp token: %s' % resp['status']) + log.error('Invalid response from Twitter requesting temp token: %s', resp['status']) return jsonified({ 'success': False, }) @@ -80,7 +80,7 @@ class Twitter(Notification): auth_url = self.urls['authorize'] + ("?oauth_token=%s" % self.request_token['oauth_token']) - log.info('Redirecting to "%s"' % auth_url) + log.info('Redirecting to "%s"', auth_url) return jsonified({ 'success': True, 'url': auth_url, @@ -100,10 +100,10 @@ class Twitter(Notification): access_token = dict(parse_qsl(content)) if resp['status'] != '200': - log.error('The request for an access token did not succeed: %s' % resp['status']) + log.error('The request for an access token did not succeed: %s', resp['status']) return 'Twitter auth failed' else: - log.debug('Tokens: %s, %s' % (access_token['oauth_token'], access_token['oauth_token_secret'])) + log.debug('Tokens: %s, %s', (access_token['oauth_token'], access_token['oauth_token_secret'])) self.conf('access_token_key', value = access_token['oauth_token']) self.conf('access_token_secret', value = access_token['oauth_token_secret']) diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index eff591ad..4f6e62a7 100644 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -35,5 +35,5 @@ class XBMC(Notification): log.error("Couldn't sent command to XBMC") return False - log.info('XBMC notification to %s successful.' % host) + log.info('XBMC notification to %s successful.', host) return True diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 142c1eba..2ad616b3 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -1,6 +1,6 @@ from couchpotato import addView from couchpotato.core.event import fireEvent, addEvent -from couchpotato.core.helpers.encoding import tryUrlencode, simplifyString +from couchpotato.core.helpers.encoding import tryUrlencode, simplifyString, ss from couchpotato.core.helpers.variable import getExt from couchpotato.core.logger import CPLog from couchpotato.environment import Env @@ -71,6 +71,7 @@ class Plugin(object): return send_from_directory(d, filename) def createFile(self, path, content, binary = False): + path = ss(path) self.makeDir(os.path.dirname(path)) @@ -80,15 +81,16 @@ class Plugin(object): f.close() os.chmod(path, Env.getPermission('file')) except Exception, e: - log.error('Unable writing to file "%s": %s' % (path, e)) + log.error('Unable writing to file "%s": %s', (path, e)) def makeDir(self, path): + path = ss(path) try: if not os.path.isdir(path): os.makedirs(path, Env.getPermission('folder')) return True except Exception, e: - log.error('Unable to create folder "%s": %s' % (path, e)) + log.error('Unable to create folder "%s": %s', (path, e)) return False @@ -106,7 +108,7 @@ class Plugin(object): # Don't try for failed requests if self.http_failed_disabled.get(host, 0) > 0: if self.http_failed_disabled[host] > (time.time() - 900): - log.info('Disabled calls to %s for 15 minutes because so many failed requests.' % host) + log.info('Disabled calls to %s for 15 minutes because so many failed requests.', host) raise Exception else: del self.http_failed_request[host] @@ -116,7 +118,7 @@ class Plugin(object): try: if multipart: - log.info('Opening multipart url: %s, params: %s' % (url, [x for x in params.iterkeys()])) + log.info('Opening multipart url: %s, params: %s', (url, [x for x in params.iterkeys()])) request = urllib2.Request(url, params, headers) cookies = cookielib.CookieJar() @@ -124,7 +126,7 @@ class Plugin(object): data = opener.open(request, timeout = timeout).read() else: - log.info('Opening url: %s, params: %s' % (url, [x for x in params.iterkeys()])) + log.info('Opening url: %s, params: %s', (url, [x for x in params.iterkeys()])) data = tryUrlencode(params) if len(params) > 0 else None request = urllib2.Request(url, data, headers) @@ -133,7 +135,7 @@ class Plugin(object): self.http_failed_request[host] = 0 except IOError: if show_error: - log.error('Failed opening url in %s: %s %s' % (self.getName(), url, traceback.format_exc(1))) + log.error('Failed opening url in %s: %s %s', (self.getName(), url, traceback.format_exc(1))) # Save failed requests by hosts try: @@ -147,7 +149,7 @@ class Plugin(object): self.http_failed_disabled[host] = time.time() except: - log.debug('Failed logging failed requests for %s: %s' % (url, traceback.format_exc())) + log.debug('Failed logging failed requests for %s: %s', (url, traceback.format_exc())) raise @@ -163,7 +165,7 @@ class Plugin(object): wait = math.ceil(last_use - now + self.http_time_between_calls) if wait > 0: - log.debug('Waiting for %s, %d seconds' % (self.getName(), wait)) + log.debug('Waiting for %s, %d seconds', (self.getName(), wait)) time.sleep(last_use - now + self.http_time_between_calls) def beforeCall(self, handler): @@ -202,7 +204,7 @@ class Plugin(object): cache_key = simplifyString(cache_key) cache = Env.get('cache').get(cache_key) if cache: - if not Env.get('dev'): log.debug('Getting cache %s' % cache_key) + if not Env.get('dev'): log.debug('Getting cache %s', cache_key) return cache if url: @@ -214,13 +216,14 @@ class Plugin(object): del kwargs['cache_timeout'] data = self.urlopen(url, **kwargs) - self.setCache(cache_key, data, timeout = cache_timeout) + if data: + self.setCache(cache_key, data, timeout = cache_timeout) return data except: pass def setCache(self, cache_key, value, timeout = 300): - log.debug('Setting cache %s' % cache_key) + log.debug('Setting cache %s', cache_key) Env.get('cache').set(cache_key, value, timeout) return value diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index 125748fb..5f381198 100644 --- a/couchpotato/core/plugins/file/main.py +++ b/couchpotato/core/plugins/file/main.py @@ -8,6 +8,7 @@ from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import FileType, File from couchpotato.environment import Env import os.path +import traceback log = CPLog(__name__) @@ -46,6 +47,7 @@ class FileManager(Plugin): try: filedata = self.urlopen(url, **urlopen_kwargs) except: + log.error('Failed downloading file %s: %s', (url, traceback.format_exc())) return False self.createFile(dest, filedata, binary = True) diff --git a/couchpotato/core/plugins/library/main.py b/couchpotato/core/plugins/library/main.py index 741f48bd..d24dfca6 100644 --- a/couchpotato/core/plugins/library/main.py +++ b/couchpotato/core/plugins/library/main.py @@ -78,7 +78,7 @@ class LibraryPlugin(Plugin): except: pass if not info or len(info) == 0: - log.error('Could not update, no movie info to work with: %s' % identifier) + log.error('Could not update, no movie info to work with: %s', identifier) return False # Main info @@ -95,7 +95,7 @@ class LibraryPlugin(Plugin): db.commit() titles = info.get('titles', []) - log.debug('Adding titles: %s' % titles) + log.debug('Adding titles: %s', titles) for title in titles: if not title: continue @@ -117,13 +117,14 @@ class LibraryPlugin(Plugin): continue file_path = fireEvent('file.download', url = image, single = True) - file_obj = fireEvent('file.add', path = file_path, type_tuple = ('image', type), single = True) - try: - file_obj = db.query(File).filter_by(id = file_obj.get('id')).one() - library.files.append(file_obj) - db.commit() - except: - log.debug('Failed to attach to library: %s' % traceback.format_exc()) + if file_path: + file_obj = fireEvent('file.add', path = file_path, type_tuple = ('image', type), single = True) + try: + file_obj = db.query(File).filter_by(id = file_obj.get('id')).one() + library.files.append(file_obj) + db.commit() + except: + log.debug('Failed to attach to library: %s', traceback.format_exc()) library_dict = library.to_dict(self.default_dict) diff --git a/couchpotato/core/plugins/log/main.py b/couchpotato/core/plugins/log/main.py index a29f1e12..83e190ab 100644 --- a/couchpotato/core/plugins/log/main.py +++ b/couchpotato/core/plugins/log/main.py @@ -1,5 +1,7 @@ from couchpotato.api import addApiView +from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.helpers.request import jsonified, getParam, getParams +from couchpotato.core.helpers.variable import tryInt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env @@ -78,7 +80,7 @@ class Logging(Plugin): def partial(self): log_type = getParam('type', 'all') - total_lines = getParam('lines', 30) + total_lines = tryInt(getParam('lines', 30)) log_lines = [] @@ -92,13 +94,12 @@ class Logging(Plugin): reversed_lines = [] f = open(path, 'r') - reversed_lines = f.read().split('[0m\n') + reversed_lines = toUnicode(f.read()).split('[0m\n') reversed_lines.reverse() brk = False for line in reversed_lines: - #print '%s ' % log_type in line.lower() if log_type == 'all' or '%s ' % log_type.upper() in line: log_lines.append(line) @@ -149,7 +150,7 @@ class Logging(Plugin): except: log.error(log_message) except: - log.error('Couldn\'t log via API: %s' % params) + log.error('Couldn\'t log via API: %s', params) return jsonified({ diff --git a/couchpotato/core/plugins/manage/main.py b/couchpotato/core/plugins/manage/main.py index 73e10693..bea2fe4f 100644 --- a/couchpotato/core/plugins/manage/main.py +++ b/couchpotato/core/plugins/manage/main.py @@ -1,6 +1,6 @@ from couchpotato.api import addApiView from couchpotato.core.event import fireEvent, addEvent, fireEventAsync -from couchpotato.core.helpers.request import jsonified, getParams +from couchpotato.core.helpers.request import jsonified, getParam from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env @@ -25,13 +25,14 @@ class Manage(Plugin): }) if not Env.get('dev'): - addEvent('app.load', self.updateLibrary) + def updateLibrary(): + self.updateLibrary(full = False) + addEvent('app.load', updateLibrary) def updateLibraryView(self): - params = getParams() - - fireEventAsync('manage.update', full = params.get('full', True)) + full = getParam('full', default = 1) + fireEventAsync('manage.update', full = True if full == '1' else False) return jsonified({ 'success': True @@ -51,11 +52,11 @@ class Manage(Plugin): if not os.path.isdir(directory): if len(directory) > 0: - log.error('Directory doesn\'t exist: %s' % directory) + log.error('Directory doesn\'t exist: %s', directory) continue - log.info('Updating manage library: %s' % directory) - identifiers = fireEvent('scanner.folder', folder = directory, newer_than = last_update, single = True) + log.info('Updating manage library: %s', directory) + identifiers = fireEvent('scanner.folder', folder = directory, newer_than = last_update if not full else 0, single = True) if identifiers: added_identifiers.extend(identifiers) @@ -67,11 +68,11 @@ class Manage(Plugin): if self.conf('cleanup') and full and not self.shuttingDown(): # Get movies with done status - done_movies = fireEvent('movie.list', status = 'done', single = True) + total_movies, done_movies = fireEvent('movie.list', status = 'done', single = True) for done_movie in done_movies: if done_movie['library']['identifier'] not in added_identifiers: - fireEvent('movie.delete', movie_id = done_movie['id']) + fireEvent('movie.delete', movie_id = done_movie['id'], delete_from = 'all') Env.prop('manage.last_update', time.time()) diff --git a/couchpotato/core/plugins/movie/main.py b/couchpotato/core/plugins/movie/main.py index bcd5d63e..62d6769f 100644 --- a/couchpotato/core/plugins/movie/main.py +++ b/couchpotato/core/plugins/movie/main.py @@ -130,6 +130,8 @@ class MoviePlugin(Plugin): .filter(or_(*[Movie.status.has(identifier = s) for s in status])) \ .group_by(Movie.id) + total_count = q.count() + filter_or = [] if starts_with: starts_with = toUnicode(starts_with.lower()) @@ -156,8 +158,7 @@ class MoviePlugin(Plugin): .options(joinedload_all('library.titles')) \ .options(joinedload_all('library.files')) \ .options(joinedload_all('status')) \ - .options(joinedload_all('files')) \ - + .options(joinedload_all('files')) if limit_offset: splt = [x.strip() for x in limit_offset.split(',')] @@ -165,7 +166,6 @@ class MoviePlugin(Plugin): offset = 0 if len(splt) is 1 else splt[1] q2 = q2.limit(limit).offset(offset) - results = q2.all() movies = [] for movie in results: @@ -178,7 +178,7 @@ class MoviePlugin(Plugin): movies.append(temp) #db.close() - return movies + return (total_count, movies) def availableChars(self, status = ['active']): @@ -214,11 +214,12 @@ class MoviePlugin(Plugin): starts_with = params.get('starts_with', None) search = params.get('search', None) - movies = self.list(status = status, limit_offset = limit_offset, starts_with = starts_with, search = search) + total_movies, movies = self.list(status = status, limit_offset = limit_offset, starts_with = starts_with, search = search) return jsonified({ 'success': True, 'empty': len(movies) == 0, + 'total': total_movies, 'movies': movies, }) @@ -279,6 +280,10 @@ class MoviePlugin(Plugin): def add(self, params = {}, force_readd = True, search_after = True): + if not params.get('identifier'): + log.error('Can\'t add movie without imdb identifier.') + return False + library = fireEvent('library.add', single = True, attrs = params, update_after = False) # Status @@ -314,7 +319,7 @@ class MoviePlugin(Plugin): m.profile_id = params.get('profile_id', default_profile.get('id')) else: - log.debug('Movie already exists, not updating: %s' % params) + log.debug('Movie already exists, not updating: %s', params) added = False if force_readd: @@ -351,7 +356,7 @@ class MoviePlugin(Plugin): return jsonified({ 'success': True, - 'added': True, + 'added': True if movie_dict else False, 'movie': movie_dict, }) @@ -436,6 +441,7 @@ class MoviePlugin(Plugin): db.commit() elif new_movie_status: new_status = fireEvent('status.get', new_movie_status, single = True) + movie.profile_id = None movie.status_id = new_status.get('id') db.commit() else: @@ -456,7 +462,7 @@ class MoviePlugin(Plugin): log.debug('Can\'t restatus movie, doesn\'t seem to exist.') return False - log.debug('Changing status for %s' % (m.library.titles[0].title)) + log.debug('Changing status for %s', (m.library.titles[0].title)) if not m.profile: m.status_id = done_status.get('id') else: diff --git a/couchpotato/core/plugins/movie/static/list.js b/couchpotato/core/plugins/movie/static/list.js index 52afbc20..38fc7109 100644 --- a/couchpotato/core/plugins/movie/static/list.js +++ b/couchpotato/core/plugins/movie/static/list.js @@ -72,7 +72,7 @@ var MovieList = new Class({ self.created = true; }, - addMovies: function(movies){ + addMovies: function(movies, total){ var self = this; if(!self.created) self.create(); @@ -86,8 +86,19 @@ var MovieList = new Class({ Object.each(movies, function(movie){ self.createMovie(movie); }); + + self.setCounter(total); }, + + setCounter: function(count){ + var self = this; + + if(!self.navigation_counter) return; + + self.navigation_counter.set('text', (count || 0)); + + }, createMovie: function(movie, inject_at){ var self = this; @@ -118,6 +129,7 @@ var MovieList = new Class({ self.navigation = new Element('div.alph_nav').adopt( self.navigation_actions = new Element('ul.inlay.actions.reversed'), + self.navigation_counter = new Element('span.counter[title=Total]'), self.navigation_alpha = new Element('ul.numbers', { 'events': { 'click:relay(li)': function(e, el){ @@ -443,7 +455,7 @@ var MovieList = new Class({ }, self.filter), 'onComplete': function(json){ self.store(json.movies); - self.addMovies(json.movies); + self.addMovies(json.movies, json.total); self.load_more.set('text', 'load more movies'); if(self.scrollspy) self.scrollspy.start(); } diff --git a/couchpotato/core/plugins/movie/static/movie.css b/couchpotato/core/plugins/movie/static/movie.css index f626f2ab..8f584609 100644 --- a/couchpotato/core/plugins/movie/static/movie.css +++ b/couchpotato/core/plugins/movie/static/movie.css @@ -13,6 +13,7 @@ overflow: hidden; width: 100%; transition: all 0.2s linear; + transform: translateZ(0); } .movies .movie.list_view, .movies .movie.mass_edit_view { margin: 1px 0; @@ -24,6 +25,10 @@ .movies .movie.list_view:hover, .movies .movie.mass_edit_view:hover { background: rgba(255,255,255,0.03); } + + .movies .movie_container { + overflow: hidden; + } .movies .data { padding: 20px; @@ -32,8 +37,8 @@ position: relative; float: right; border-radius: 0; - overflow: hidden; transition: all 0.2s linear; + transform: translateZ(0); } .movies .list_view .data, .movies .mass_edit_view .data { height: 30px; @@ -62,6 +67,7 @@ height: 180px; border-radius: 4px 0 0 4px; transition: all 0.2s linear; + transform: translateZ(0); } .movies .list_view .poster, .movies .mass_edit_view .poster { @@ -84,6 +90,7 @@ float: left; width: 90%; transition: all 0.2s linear; + transform: translateZ(0); } .movies .list_view .info .title, .movies .mass_edit_view .info .title { font-size: 16px; @@ -100,6 +107,7 @@ width: 10%; text-align: right; transition: all 0.2s linear; + transform: translateZ(0); } .movies .list_view .info .year, .movies .mass_edit_view .info .year { font-size: 16px; @@ -310,6 +318,35 @@ padding-bottom: 4px; height: auto; } + + .movies .movie .trailer_container { + width: 100%; + background: #000; + text-align: center; + transition: all .6s cubic-bezier(0.9,0,0.1,1); + transform: translateZ(0); + overflow: hidden; + } + .movies .movie .trailer_container.hide { + height: 0 !important; + } + + .movies .movie .hide_trailer { + position: absolute; + top: 0; + left: 50%; + margin-left: -50px; + width: 100px; + text-align: center; + padding: 3px 10px; + background: #4e5969; + border-radius: 0 0 2px 2px; + transition: all .6s cubic-bezier(0.9,0,0.1,1) .2s; + transform: translateZ(0); + } + .movies .movie .hide_trailer.hide { + top: -30px; + } .movies .load_more { display: block; @@ -323,6 +360,7 @@ .movies .alph_nav { transition: box-shadow .4s linear; + transform: translateZ(0); position: fixed; z-index: 2; top: 0; @@ -338,7 +376,9 @@ background: #4e5969; } -.movies .alph_nav ul.numbers, .movies .alph_nav ul.actions { +.movies .alph_nav ul.numbers, +.movies .alph_nav .counter, +.movies .alph_nav ul.actions { list-style: none; padding: 0 0 1px; margin: 0; @@ -346,10 +386,15 @@ user-select: none; } + .movies .alph_nav .counter { + width: 60px; + text-align: center; + } + .movies .alph_nav .numbers li, .movies .alph_nav .actions li { display: inline-block; vertical-align: top; - width: 22px; + width: 20px; height: 24px; line-height: 26px; text-align: center; @@ -357,11 +402,11 @@ color: rgba(255,255,255,0.2); border: 1px solid transparent; transition: all 0.1s ease-in-out; + transform: translateZ(0); text-shadow: none; } .movies .alph_nav .numbers li:first-child { width: 43px; - margin-left: 7px; } .movies .alph_nav li.available { color: rgba(255,255,255,0.8); @@ -370,8 +415,8 @@ } .movies .alph_nav li.active.available, .movies .alph_nav li.available:hover { color: #fff; - font-size: 24px; - line-height: 24px; + font-size: 20px; + line-height: 20px; } .movies .alph_nav input { diff --git a/couchpotato/core/plugins/movie/static/movie.js b/couchpotato/core/plugins/movie/static/movie.js index 9877b12f..69afe9db 100644 --- a/couchpotato/core/plugins/movie/static/movie.js +++ b/couchpotato/core/plugins/movie/static/movie.js @@ -77,7 +77,7 @@ var Movie = new Class({ self.profile = Quality.getProfile(self.data.profile_id) || {}; self.create(); - + self.busy(false); }, @@ -246,6 +246,10 @@ var Movie = new Class({ isSelected: function(){ return this.select_checkbox.get('checked'); + }, + + toElement: function(){ + return this.el; } }); @@ -425,4 +429,109 @@ var ReleaseAction = new Class({ } +}); + +var TrailerAction = new Class({ + + Extends: MovieAction, + id: null, + + create: function(){ + var self = this; + + self.el = new Element('a.trailer', { + 'title': 'Watch the trailer of ' + self.movie.getTitle(), + 'events': { + 'click': self.watch.bind(self) + } + }); + + }, + + watch: function(offset){ + var self = this; + + var data_url = 'http://gdata.youtube.com/feeds/videos?vq="{title}" {year} trailer&max-results=1&alt=json-in-script&orderby=relevance&sortorder=descending&format=5&fmt=18' + var url = data_url.substitute({ + 'title': self.movie.getTitle(), + 'year': self.movie.get('year'), + 'offset': offset || 1 + }), + size = $(self.movie).getSize(), + height = (size.x/16)*9, + id = 'trailer-'+randomString(); + + self.player_container = new Element('div[id='+id+']'); + self.container = new Element('div.hide.trailer_container') + .adopt(self.player_container) + .inject(self.movie.container, 'top'); + + self.container.setStyle('height', 0); + self.container.removeClass('hide'); + + self.close_button = new Element('a.hide.hide_trailer', { + 'text': 'Hide trailer', + 'events': { + 'click': self.stop.bind(self) + } + }).inject(self.movie); + + setTimeout(function(){ + $(self.movie).setStyle('max-height', height); + self.container.setStyle('height', height); + }, 100) + + new Request.JSONP({ + 'url': url, + 'onComplete': function(json){ + var video_url = json.feed.entry[0].id.$t.split('/'), + video_id = video_url[video_url.length-1]; + + self.player = new YT.Player(id, { + 'height': height, + 'width': size.x, + 'videoId': video_id, + 'playerVars': { + 'autoplay': 1, + 'showsearch': 0, + 'wmode': 'transparent', + 'iv_load_policy': 3 + } + }); + + self.close_button.removeClass('hide'); + + var quality_set = false; + var change_quality = function(state){ + if(!quality_set && (state.data == 1 || state.data || 2)){ + try { + self.player.setPlaybackQuality('hd720'); + quality_set = true; + } + catch(e){ + + } + } + } + self.player.addEventListener('onStateChange', change_quality); + + } + }).send() + + }, + + stop: function(){ + var self = this; + + self.player.stopVideo(); + self.container.addClass('hide'); + self.close_button.addClass('hide'); + + setTimeout(function(){ + self.container.destroy() + self.close_button.destroy(); + }, 1800) + } + + }); \ No newline at end of file diff --git a/couchpotato/core/plugins/movie/static/search.js b/couchpotato/core/plugins/movie/static/search.js index bfb3b0d4..6c6c94e9 100644 --- a/couchpotato/core/plugins/movie/static/search.js +++ b/couchpotato/core/plugins/movie/static/search.js @@ -301,11 +301,11 @@ Block.Search.Item = new Class({ 'title': self.title_select.get('value'), 'profile_id': self.profile_select.get('value') }, - 'onComplete': function(){ + 'onComplete': function(json){ self.options.empty(); self.options.adopt( new Element('div.message', { - 'text': 'Movie succesfully added.' + 'text': json.added ? 'Movie succesfully added.' : 'Movie didn\'t add properly. Check logs' }) ); }, diff --git a/couchpotato/core/plugins/profile/main.py b/couchpotato/core/plugins/profile/main.py index 279d6507..76fb37c5 100644 --- a/couchpotato/core/plugins/profile/main.py +++ b/couchpotato/core/plugins/profile/main.py @@ -135,8 +135,7 @@ class ProfilePlugin(Plugin): success = True except Exception, e: - message = 'Failed deleting Profile: %s' % e - log.error(message) + message = log.error('Failed deleting Profile: %s', e) #db.close() @@ -163,7 +162,7 @@ class ProfilePlugin(Plugin): # Create default quality profile order = -2 for profile in profiles: - log.info('Creating default profile: %s' % profile.get('label')) + log.info('Creating default profile: %s', profile.get('label')) p = Profile( label = toUnicode(profile.get('label')), order = order diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index fd4f1f65..84ac80a8 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -17,8 +17,8 @@ class QualityPlugin(Plugin): qualities = [ {'identifier': 'bd50', 'hd': True, 'size': (15000, 60000), 'label': 'BR-Disk', 'alternative': ['bd25'], 'allow': ['1080p'], 'ext':[], 'tags': ['bdmv', 'certificate', ('complete', 'bluray')]}, - {'identifier': '1080p', 'hd': True, 'size': (5000, 20000), 'label': '1080P', 'width': 1920, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts']}, - {'identifier': '720p', 'hd': True, 'size': (3500, 10000), 'label': '720P', 'width': 1280, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts', 'ts']}, + {'identifier': '1080p', 'hd': True, 'size': (5000, 20000), 'label': '1080P', 'width': 1920, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts'], 'tags': ['m2ts']}, + {'identifier': '720p', 'hd': True, 'size': (3500, 10000), 'label': '720P', 'width': 1280, 'alternative': [], 'allow': [], 'ext':['mkv', 'ts']}, {'identifier': 'brrip', 'hd': True, 'size': (700, 7000), 'label': 'BR-Rip', 'alternative': ['bdrip'], 'allow': ['720p'], 'ext':['avi']}, {'identifier': 'dvdr', 'size': (3000, 10000), 'label': 'DVD-R', 'alternative': [], 'allow': [], 'ext':['iso', 'img'], 'tags': ['pal', 'ntsc', 'video_ts', 'audio_ts']}, {'identifier': 'dvdrip', 'size': (600, 2400), 'label': 'DVD-Rip', 'width': 720, 'alternative': ['dvdrip'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, @@ -26,7 +26,7 @@ class QualityPlugin(Plugin): {'identifier': 'r5', 'size': (600, 1000), 'label': 'R5', 'alternative': [], 'allow': ['dvdr'], 'ext':['avi', 'mpg', 'mpeg']}, {'identifier': 'tc', 'size': (600, 1000), 'label': 'TeleCine', 'alternative': ['telecine'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, {'identifier': 'ts', 'size': (600, 1000), 'label': 'TeleSync', 'alternative': ['telesync'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']}, - {'identifier': 'cam', 'size': (600, 1000), 'label': 'Cam', 'alternative': [], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']} + {'identifier': 'cam', 'size': (600, 1000), 'label': 'Cam', 'alternative': ['camrip', 'hdcam'], 'allow': [], 'ext':['avi', 'mpg', 'mpeg']} ] pre_releases = ['cam', 'ts', 'tc', 'r5', 'scr'] @@ -116,7 +116,7 @@ class QualityPlugin(Plugin): quality = db.query(Quality).filter_by(identifier = q.get('identifier')).first() if not quality: - log.info('Creating quality: %s' % q.get('label')) + log.info('Creating quality: %s', q.get('label')) quality = Quality() db.add(quality) @@ -133,7 +133,7 @@ class QualityPlugin(Plugin): ).all() if not profile: - log.info('Creating profile: %s' % q.get('label')) + log.info('Creating profile: %s', q.get('label')) profile = Profile( core = True, label = toUnicode(quality.label), @@ -170,20 +170,20 @@ class QualityPlugin(Plugin): # Check tags if quality['identifier'] in words: - log.debug('Found via identifier "%s" in %s' % (quality['identifier'], cur_file)) + log.debug('Found via identifier "%s" in %s', (quality['identifier'], cur_file)) return self.setCache(hash, quality) if list(set(quality.get('alternative', [])) & set(words)): - log.debug('Found %s via alt %s in %s' % (quality['identifier'], quality.get('alternative'), cur_file)) + log.debug('Found %s via alt %s in %s', (quality['identifier'], quality.get('alternative'), cur_file)) return self.setCache(hash, quality) for tag in quality.get('tags', []): if isinstance(tag, tuple) and '.'.join(tag) in '.'.join(words): - log.debug('Found %s via tag %s in %s' % (quality['identifier'], quality.get('tags'), cur_file)) + log.debug('Found %s via tag %s in %s', (quality['identifier'], quality.get('tags'), cur_file)) return self.setCache(hash, quality) if list(set(quality.get('tags', [])) & set(words)): - log.debug('Found %s via tag %s in %s' % (quality['identifier'], quality.get('tags'), cur_file)) + log.debug('Found %s via tag %s in %s', (quality['identifier'], quality.get('tags'), cur_file)) return self.setCache(hash, quality) # Try again with loose testing @@ -191,7 +191,7 @@ class QualityPlugin(Plugin): if quality: return self.setCache(hash, quality) - log.debug('Could not identify quality for: %s' % files) + log.debug('Could not identify quality for: %s', files) return None def guessLoose(self, hash, extra): @@ -200,7 +200,7 @@ class QualityPlugin(Plugin): # Last check on resolution only if quality.get('width', 480) == extra.get('resolution_width', 0): - log.debug('Found %s via resolution_width: %s == %s' % (quality['identifier'], quality.get('width', 480), extra.get('resolution_width', 0))) + log.debug('Found %s via resolution_width: %s == %s', (quality['identifier'], quality.get('width', 480), extra.get('resolution_width', 0))) return self.setCache(hash, quality) if 480 <= extra.get('resolution_width', 0) <= 720: diff --git a/couchpotato/core/plugins/release/main.py b/couchpotato/core/plugins/release/main.py index fff53135..1fc11d53 100644 --- a/couchpotato/core/plugins/release/main.py +++ b/couchpotato/core/plugins/release/main.py @@ -79,7 +79,7 @@ class Release(Plugin): rel.files.append(added_file) db.commit() except Exception, e: - log.debug('Failed to attach "%s" to release: %s' % (cur_file, e)) + log.debug('Failed to attach "%s" to release: %s', (cur_file, e)) fireEvent('movie.restatus', movie.id) @@ -158,7 +158,7 @@ class Release(Plugin): 'success': True }) else: - log.error('Couldn\'t find release with id: %s' % id) + log.error('Couldn\'t find release with id: %s', id) #db.close() return jsonified({ diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index d33ba89f..e46addf0 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -100,7 +100,7 @@ class Renamer(Plugin): else: group['library'] = fireEvent('library.update', identifier = group['library']['identifier'], single = True) if not group['library']: - log.error('Could not rename, no library item to work with: %s' % group_identifier) + log.error('Could not rename, no library item to work with: %s', group_identifier) continue library = group['library'] @@ -138,7 +138,7 @@ class Renamer(Plugin): # Move nfo depending on settings if file_type is 'nfo' and not self.conf('rename_nfo'): - log.debug('Skipping, renaming of %s disabled' % file_type) + log.debug('Skipping, renaming of %s disabled', file_type) if self.conf('cleanup'): for current_file in group['files'][file_type]: remove_files.append(current_file) @@ -197,7 +197,7 @@ class Renamer(Plugin): break if not found: - log.error('Could not determine dvd structure for: %s' % current_file) + log.error('Could not determine dvd structure for: %s', current_file) # Do rename others else: @@ -272,7 +272,7 @@ class Renamer(Plugin): movie.status_id = done_status.get('id') db.commit() except Exception, e: - log.error('Failed marking movie finished: %s %s' % (e, traceback.format_exc())) + log.error('Failed marking movie finished: %s %s', (e, traceback.format_exc())) # Go over current movie releases for release in movie.releases: @@ -282,20 +282,20 @@ class Renamer(Plugin): # This is where CP removes older, lesser quality releases if release.quality.order > group['meta_data']['quality']['order']: - log.info('Removing lesser quality %s for %s.' % (movie.library.titles[0].title, release.quality.label)) + log.info('Removing lesser quality %s for %s.', (movie.library.titles[0].title, release.quality.label)) for current_file in release.files: remove_files.append(current_file) remove_releases.append(release) # Same quality, but still downloaded, so maybe repack/proper/unrated/directors cut etc elif release.quality.order is group['meta_data']['quality']['order']: - log.info('Same quality release already exists for %s, with quality %s. Assuming repack.' % (movie.library.titles[0].title, release.quality.label)) + log.info('Same quality release already exists for %s, with quality %s. Assuming repack.', (movie.library.titles[0].title, release.quality.label)) for current_file in release.files: remove_files.append(current_file) remove_releases.append(release) # Downloaded a lower quality, rename the newly downloaded files/folder to exclude them from scan else: - log.info('Better quality release already exists for %s, with quality %s' % (movie.library.titles[0].title, release.quality.label)) + log.info('Better quality release already exists for %s, with quality %s', (movie.library.titles[0].title, release.quality.label)) # Add _EXISTS_ to the parent dir if group['dirname']: @@ -333,18 +333,18 @@ class Renamer(Plugin): if isinstance(src, File): src = src.path - log.info('Removing "%s"' % src) + log.info('Removing "%s"', src) try: os.remove(src) except: - log.error('Failed removing %s: %s' % (src, traceback.format_exc())) + log.error('Failed removing %s: %s', (src, traceback.format_exc())) # Rename all files marked group['renamed_files'] = [] for src in rename_files: if rename_files[src]: dst = rename_files[src] - log.info('Renaming "%s" to "%s"' % (src, dst)) + log.info('Renaming "%s" to "%s"', (src, dst)) # Create dir self.makeDir(os.path.dirname(dst)) @@ -353,22 +353,22 @@ class Renamer(Plugin): self.moveFile(src, dst) group['renamed_files'].append(dst) except: - log.error('Failed moving the file "%s" : %s' % (os.path.basename(src), traceback.format_exc())) + log.error('Failed moving the file "%s" : %s', (os.path.basename(src), traceback.format_exc())) # Remove matching releases for release in remove_releases: - log.debug('Removing release %s' % release.identifier) + log.debug('Removing release %s', release.identifier) try: db.delete(release) except: - log.error('Failed removing %s: %s' % (release.identifier, traceback.format_exc())) + log.error('Failed removing %s: %s', (release.identifier, traceback.format_exc())) if group['dirname'] and group['parentdir']: try: - log.info('Deleting folder: %s' % group['parentdir']) + log.info('Deleting folder: %s', group['parentdir']) self.deleteEmptyFolder(group['parentdir']) except: - log.error('Failed removing %s: %s' % (group['parentdir'], traceback.format_exc())) + log.error('Failed removing %s: %s', (group['parentdir'], traceback.format_exc())) if not unknown: # Search for trailers etc @@ -406,12 +406,12 @@ class Renamer(Plugin): shutil.move(old, dest) try: - os.chmod(dest, Env.getPermission('folder')) + os.chmod(dest, Env.getPermission('file')) except: - log.error('Failed setting permissions for file: %s' % dest) + log.error('Failed setting permissions for file: %s, %s', (dest, traceback.format_exc(1))) except: - log.error("Couldn't move file '%s' to '%s': %s" % (old, dest, traceback.format_exc())) + log.error('Couldn\'t move file "%s" to "%s": %s', (old, dest, traceback.format_exc())) raise Exception return True @@ -447,9 +447,9 @@ class Renamer(Plugin): try: os.rmdir(full_path) except: - log.error('Couldn\'t remove empty directory %s: %s' % (full_path, traceback.format_exc())) + log.error('Couldn\'t remove empty directory %s: %s', (full_path, traceback.format_exc())) try: os.rmdir(folder) except: - log.error('Couldn\'t remove empty directory %s: %s' % (folder, traceback.format_exc())) + log.error('Couldn\'t remove empty directory %s: %s', (folder, traceback.format_exc())) diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index 6fa3f7d5..1dda624b 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -1,11 +1,10 @@ from couchpotato import get_session from couchpotato.core.event import fireEvent, addEvent -from couchpotato.core.helpers.encoding import toUnicode, simplifyString +from couchpotato.core.helpers.encoding import toUnicode, simplifyString, ss from couchpotato.core.helpers.variable import getExt, getImdb, tryInt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.core.settings.model import File, Movie -from couchpotato.environment import Env from enzyme.exceptions import NoParserError, ParseError from guessit import guess_movie_info from subliminal.videos import Video @@ -89,9 +88,9 @@ class Scanner(Plugin): addEvent('scanner.partnumber', self.getPartNumber) def after_rename(group): - return self.scanFilesToLibrary(self, folder = group['destination_dir'], files = group['renamed_files']) + return self.scanFilesToLibrary(folder = group['destination_dir'], files = group['renamed_files']) - addEvent('rename.after', after_rename) + addEvent('renamer.after', after_rename) def scanFilesToLibrary(self, folder = None, files = None): @@ -103,14 +102,14 @@ class Scanner(Plugin): if group['library']: fireEvent('release.add', group = group) - def scanFolderToLibrary(self, folder = None, newer_than = None, simple = True): + def scanFolderToLibrary(self, folder = None, newer_than = 0, simple = True): folder = os.path.normpath(folder) if not os.path.isdir(folder): return - groups = self.scan(folder = folder, simple = simple) + groups = self.scan(folder = folder, simple = simple, newer_than = newer_than) added_identifier = [] while True and not self.shuttingDown(): @@ -131,12 +130,12 @@ class Scanner(Plugin): return added_identifier - def scan(self, folder = None, files = [], simple = False): + def scan(self, folder = None, files = [], simple = False, newer_than = 0): - folder = os.path.normpath(folder) + folder = ss(os.path.normpath(folder)) if not folder or not os.path.isdir(folder): - log.error('Folder doesn\'t exists: %s' % folder) + log.error('Folder doesn\'t exists: %s', folder) return {} # Get movie "master" files @@ -147,19 +146,11 @@ class Scanner(Plugin): if len(files) == 0: try: files = [] - for root, dirs, walk_files in os.walk(toUnicode(folder)): + for root, dirs, walk_files in os.walk(folder): for filename in walk_files: files.append(os.path.join(root, filename)) except: - try: - files = [] - folder = toUnicode(folder).encode(Env.get('encoding')) - log.info('Trying to convert unicode to str path: %s, %s' % (folder, type(folder))) - for root, dirs, walk_files in os.walk(folder): - for filename in walk_files: - files.append(os.path.join(root, filename)) - except: - log.error('Failed getting files from %s: %s' % (folder, traceback.format_exc())) + log.error('Failed getting files from %s: %s', (folder, traceback.format_exc())) db = get_session() @@ -215,7 +206,7 @@ class Scanner(Plugin): for identifier, group in movie_files.iteritems(): if identifier not in group['identifiers'] and len(identifier) > 0: group['identifiers'].append(identifier) - log.debug('Grouping files: %s' % identifier) + log.debug('Grouping files: %s', identifier) for file_path in group['unsorted_files']: wo_ext = file_path[:-(len(getExt(file_path)) + 1)] @@ -241,7 +232,7 @@ class Scanner(Plugin): # Group the files based on the identifier delete_identifiers = [] for identifier, found_files in self.path_identifiers.iteritems(): - log.debug('Grouping files on identifier: %s' % identifier) + log.debug('Grouping files on identifier: %s', identifier) group = movie_files.get(identifier) if group: @@ -257,13 +248,14 @@ class Scanner(Plugin): # Cleaning up used for identifier in delete_identifiers: - del self.path_identifiers[identifier] + if self.path_identifiers.get(identifier): + del self.path_identifiers[identifier] del delete_identifiers # Group based on folder delete_identifiers = [] for identifier, found_files in self.path_identifiers.iteritems(): - log.debug('Grouping files on foldername: %s' % identifier) + log.debug('Grouping files on foldername: %s', identifier) for ff in found_files: new_identifier = self.createStringIdentifier(os.path.dirname(ff), folder) @@ -282,7 +274,8 @@ class Scanner(Plugin): # Cleaning up used for identifier in delete_identifiers: - del self.path_identifiers[identifier] + if self.path_identifiers.get(identifier): + del self.path_identifiers[identifier] del delete_identifiers # Determine file types @@ -296,13 +289,38 @@ class Scanner(Plugin): # Check if movie is fresh and maybe still unpacking, ignore files new then 1 minute file_too_new = False for cur_file in group['unsorted_files']: - file_time = os.path.getmtime(cur_file) - if file_time > time.time() - 60: - file_too_new = tryInt(time.time() - file_time) + if not os.path.isfile(cur_file): + file_too_new = time.time() + break + file_time = [os.path.getmtime(cur_file), os.path.getctime(cur_file)] + for t in file_time: + if t > time.time() - 60: + file_too_new = tryInt(time.time() - t) + break + + if file_too_new: break if file_too_new: - log.info('Files seem to be still unpacking or just unpacked (created on %s), ignoring for now: %s' % (time.ctime(file_time), identifier)) + log.info('Files seem to be still unpacking or just unpacked (created on %s), ignoring for now: %s', (time.ctime(file_time[0]), identifier)) + + # Delete the unsorted list + del group['unsorted_files'] + + continue + + # Only process movies newer than x + if newer_than and newer_than > 0: + for cur_file in group['unsorted_files']: + file_time = [os.path.getmtime(cur_file), os.path.getctime(cur_file)] + if file_time[0] > time.time() or file_time[1] > time.time(): + break + + log.debug('None of the files have changed since %s for %s, skipping.', (time.ctime(newer_than), identifier)) + + # Delete the unsorted list + del group['unsorted_files'] + continue # Group extra (and easy) files first @@ -324,10 +342,10 @@ class Scanner(Plugin): group['files']['movie'] = self.getMediaFiles(group['unsorted_files']) if len(group['files']['movie']) == 0: - log.error('Couldn\t find any movie files for %s' % identifier) + log.error('Couldn\t find any movie files for %s', identifier) continue - log.debug('Getting metadata for %s' % identifier) + log.debug('Getting metadata for %s', identifier) group['meta_data'] = self.getMetaData(group) # Subtitle meta @@ -360,7 +378,7 @@ class Scanner(Plugin): # Determine movie group['library'] = self.determineMovie(group) if not group['library']: - log.error('Unable to determine movie: %s' % group['identifiers']) + log.error('Unable to determine movie: %s', group['identifiers']) else: movie = db.query(Movie).filter_by(library_id = group['library']['id']).first() group['movie_id'] = None if not movie else movie.id @@ -373,9 +391,9 @@ class Scanner(Plugin): self.path_identifiers = {} if len(processed_movies) > 0: - log.info('Found %s movies in the folder %s' % (len(processed_movies), folder)) + log.info('Found %s movies in the folder %s', (len(processed_movies), folder)) else: - log.debug('Found no movies in the folder %s' % (folder)) + log.debug('Found no movies in the folder %s', (folder)) return processed_movies def getMetaData(self, group): @@ -395,7 +413,7 @@ class Scanner(Plugin): data['resolution_height'] = meta.get('resolution_height', 480) data['aspect'] = meta.get('resolution_width', 720) / meta.get('resolution_height', 480) except: - log.debug('Error parsing metadata: %s %s' % (cur_file, traceback.format_exc())) + log.debug('Error parsing metadata: %s %s', (cur_file, traceback.format_exc())) pass if data.get('audio'): break @@ -423,11 +441,11 @@ class Scanner(Plugin): 'resolution_height': tryInt(p.video[0].height), } except ParseError: - log.debug('Failed to parse meta for %s' % filename) + log.debug('Failed to parse meta for %s', filename) except NoParserError: - log.debug('No parser found for %s' % filename) + log.debug('No parser found for %s', filename) except: - log.debug('Failed parsing %s' % filename) + log.debug('Failed parsing %s', filename) return {} @@ -449,7 +467,7 @@ class Scanner(Plugin): if s.language and s.path not in paths: detected_languages[s.path] = [s.language] except: - log.debug('Failed parsing subtitle languages for %s: %s' % (paths, traceback.format_exc())) + log.debug('Failed parsing subtitle languages for %s: %s', (paths, traceback.format_exc())) # IDX for extra in group['files']['subtitle_extra']: @@ -465,7 +483,7 @@ class Scanner(Plugin): if len(idx_langs) > 0 and os.path.isfile(sub_file): detected_languages[sub_file] = idx_langs except: - log.error('Failed parsing subtitle idx for %s: %s' % (extra, traceback.format_exc())) + log.error('Failed parsing subtitle idx for %s: %s', (extra, traceback.format_exc())) return detected_languages @@ -478,7 +496,7 @@ class Scanner(Plugin): for cur_file in files['movie']: imdb_id = self.getCPImdb(cur_file) if imdb_id: - log.debug('Found movie via CP tag: %s' % cur_file) + log.debug('Found movie via CP tag: %s', cur_file) break # Check and see if nfo contains the imdb-id @@ -487,7 +505,7 @@ class Scanner(Plugin): for nfo_file in files['nfo']: imdb_id = getImdb(nfo_file) if imdb_id: - log.debug('Found movie via nfo file: %s' % nfo_file) + log.debug('Found movie via nfo file: %s', nfo_file) break except: pass @@ -499,7 +517,7 @@ class Scanner(Plugin): for filetype_file in files[filetype]: imdb_id = getImdb(filetype_file, check_inside = False) if imdb_id: - log.debug('Found movie via imdb in filename: %s' % nfo_file) + log.debug('Found movie via imdb in filename: %s', nfo_file) break except: pass @@ -511,7 +529,7 @@ class Scanner(Plugin): f = db.query(File).filter_by(path = toUnicode(cur_file)).first() try: imdb_id = f.library[0].identifier - log.debug('Found movie via database: %s' % cur_file) + log.debug('Found movie via database: %s', cur_file) break except: pass @@ -525,7 +543,7 @@ class Scanner(Plugin): if len(movie) > 0: imdb_id = movie[0]['imdb'] if imdb_id: - log.debug('Found movie via OpenSubtitleHash: %s' % cur_file) + log.debug('Found movie via OpenSubtitleHash: %s', cur_file) break # Search based on identifiers @@ -542,17 +560,17 @@ class Scanner(Plugin): if len(movie) > 0: imdb_id = movie[0]['imdb'] - log.debug('Found movie via search: %s' % cur_file) + log.debug('Found movie via search: %s', cur_file) if imdb_id: break else: - log.debug('Identifier to short to use for search: %s' % identifier) + log.debug('Identifier to short to use for search: %s', identifier) if imdb_id: return fireEvent('library.add', attrs = { 'identifier': imdb_id }, update_after = False, single = True) - log.error('No imdb_id found for %s. Add a NFO file with IMDB id or add the year to the filename.' % group['identifiers']) + log.error('No imdb_id found for %s. Add a NFO file with IMDB id or add the year to the filename.', group['identifiers']) return {} def getCPImdb(self, string): @@ -641,17 +659,17 @@ class Scanner(Plugin): # ignoredpaths for i in self.ignored_in_path: if i in filename.lower(): - log.debug('Ignored "%s" contains "%s".' % (filename, i)) + log.debug('Ignored "%s" contains "%s".', (filename, i)) return False # Sample file if self.isSampleFile(filename): - log.debug('Is sample file "%s".' % filename) + log.debug('Is sample file "%s".', filename) return False # Minimal size if self.filesizeBetween(filename, self.minimal_filesize['media']): - log.debug('File to small: %s' % filename) + log.debug('File to small: %s', filename) return False # All is OK @@ -659,14 +677,14 @@ class Scanner(Plugin): def isSampleFile(self, filename): is_sample = re.search('(^|[\W_])sample\d*[\W_]', filename.lower()) - if is_sample: log.debug('Is sample file: %s' % filename) + if is_sample: log.debug('Is sample file: %s', filename) return is_sample def filesizeBetween(self, file, min = 0, max = 100000): try: return (min * 1048576) < os.path.getsize(file) < (max * 1048576) except: - log.error('Couldn\'t get filesize of %s.' % file) + log.error('Couldn\'t get filesize of %s.', file) return False @@ -769,7 +787,7 @@ class Scanner(Plugin): 'year': guess.get('year'), } except: - log.debug('Could not detect via guessit "%s": %s' % (file_name, traceback.format_exc())) + log.debug('Could not detect via guessit "%s": %s', (file_name, traceback.format_exc())) # Backup to simple cleaned = ' '.join(re.split('\W+', simplifyString(release_name))) diff --git a/couchpotato/core/plugins/score/main.py b/couchpotato/core/plugins/score/main.py index 8b4fedb4..f6f3232c 100644 --- a/couchpotato/core/plugins/score/main.py +++ b/couchpotato/core/plugins/score/main.py @@ -4,7 +4,8 @@ from couchpotato.core.helpers.variable import getTitle from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.core.plugins.score.scores import nameScore, nameRatioScore, \ - sizeScore, providerScore, duplicateScore + sizeScore, providerScore, duplicateScore, partialIgnoredScore, namePositionScore, \ + halfMultipartScore log = CPLog(__name__) @@ -21,6 +22,7 @@ class Score(Plugin): for movie_title in movie['library']['titles']: score += nameRatioScore(toUnicode(nzb['name']), toUnicode(movie_title['title'])) + score += namePositionScore(toUnicode(nzb['name']), toUnicode(movie_title['title'])) score += sizeScore(nzb['size']) @@ -38,6 +40,12 @@ class Score(Plugin): # Duplicates in name score += duplicateScore(nzb['name'], getTitle(movie['library'])) + # Partial ignored words + score += partialIgnoredScore(nzb['name'], getTitle(movie['library'])) + + # Ignore single downloads from multipart + score += halfMultipartScore(nzb['name']) + # Extra provider specific check extra_score = nzb.get('extra_score') if extra_score: diff --git a/couchpotato/core/plugins/score/scores.py b/couchpotato/core/plugins/score/scores.py index 2cace048..ddff25b1 100644 --- a/couchpotato/core/plugins/score/scores.py +++ b/couchpotato/core/plugins/score/scores.py @@ -1,5 +1,7 @@ from couchpotato.core.event import fireEvent from couchpotato.core.helpers.encoding import simplifyString +from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.plugins.scanner.main import Scanner from couchpotato.environment import Env import re @@ -16,11 +18,12 @@ name_scores = [ 'german:-10', 'french:-10', 'spanish:-10', 'swesub:-20', 'danish:-10', 'dutch:-10', # Release groups 'imbt:1', 'cocain:1', 'vomit:1', 'fico:1', 'arrow:1', 'pukka:1', 'prism:1', 'devise:1', 'esir:1', 'ctrlhd:1', - 'metis:1', 'diamond:1', 'wiki:1', 'cbgb:1', 'crossbow:1', 'sinners:1', 'amiable:1', 'refined:1', 'twizted:1', 'felony:1', 'hubris:1', 'machd:1', + 'metis:10', 'diamond:10', 'wiki:10', 'cbgb:10', 'crossbow:1', 'sinners:10', 'amiable:10', 'refined:1', 'twizted:1', 'felony:1', 'hubris:1', 'machd:1', # Extras 'extras:-40', 'trilogy:-40', ] + def nameScore(name, year): ''' Calculate score for words in the NZB name ''' @@ -47,8 +50,8 @@ def nameScore(name, year): return score -def nameRatioScore(nzb_name, movie_name): +def nameRatioScore(nzb_name, movie_name): nzb_words = re.split('\W+', fireEvent('scanner.create_file_identifier', nzb_name, single = True)) movie_words = re.split('\W+', simplifyString(movie_name)) @@ -56,15 +59,68 @@ def nameRatioScore(nzb_name, movie_name): return 10 - len(left_over) +def namePositionScore(nzb_name, movie_name): + score = 0 + + nzb_words = re.split('\W+', simplifyString(nzb_name)) + qualities = fireEvent('quality.all', single = True) + + try: + nzb_name = re.search(r'([\'"])[^\1]*\1', nzb_name).group(0) + except: + pass + + name_year = fireEvent('scanner.name_year', nzb_name, single = True) + + # Give points for movies beginning with the correct name + name_split = simplifyString(nzb_name).split(simplifyString(movie_name)) + if name_split[0].strip() == '': + score += 10 + + # If year is second in line, give more points + if len(name_split) > 1 and name_year: + after_name = name_split[1].strip() + if tryInt(after_name[:4]) == name_year.get('year', None): + score += 10 + after_name = after_name[4:] + + # Give -point to crap between year and quality + found_quality = None + for quality in qualities: + # Main in words + if quality['identifier'] in nzb_words: + found_quality = quality['identifier'] + + # Alt in words + for alt in quality['alternative']: + if alt in nzb_words: + found_quality = alt + break + + if not found_quality: + return score - 20 + + allowed = [] + for value in name_scores: + name, sc = value.split(':') + allowed.append(name) + + inbetween = re.split('\W+', after_name.split(found_quality)[0].strip()) + + score -= (10 * len(set(inbetween) - set(allowed))) + + return score + + def sizeScore(size): return 0 if size else -20 def providerScore(provider): if provider in ['NZBMatrix', 'Nzbs', 'Newzbin']: - return 30 + return 20 - if provider in ['Newznab', 'Moovee', 'X264']: + if provider in ['Newznab']: return 10 return 0 @@ -79,3 +135,31 @@ def duplicateScore(nzb_name, movie_name): duplicates = [x for i, x in enumerate(nzb_words) if nzb_words[i:].count(x) > 1] return len(list(set(duplicates) - set(movie_words))) * -4 + + +def partialIgnoredScore(nzb_name, movie_name): + + nzb_name = nzb_name.lower() + movie_name = movie_name.lower() + + ignored_words = [x.strip().lower() for x in Env.setting('ignored_words', section = 'searcher').split(',')] + + score = 0 + for ignored_word in ignored_words: + if ignored_word in nzb_name and ignored_word not in movie_name: + score -= 5 + + return score + +def halfMultipartScore(nzb_name): + + wrong_found = 0 + for nr in [1, 2, 3, 4, 5, 'i', 'ii', 'iii', 'iv', 'v', 'a', 'b', 'c', 'd', 'e']: + for wrong in ['cd', 'part', 'dis', 'disc', 'dvd']: + if '%s%s' % (wrong, nr) in nzb_name.lower(): + wrong_found += 1 + + if wrong_found == 1: + return -30 + + return 0 diff --git a/couchpotato/core/plugins/searcher/main.py b/couchpotato/core/plugins/searcher/main.py index 93eb55ed..da77908d 100644 --- a/couchpotato/core/plugins/searcher/main.py +++ b/couchpotato/core/plugins/searcher/main.py @@ -56,7 +56,7 @@ class Searcher(Plugin): except IndexError: fireEvent('library.update', movie_dict['library']['identifier'], force = True) except: - log.error('Search failed for %s: %s' % (movie_dict['library']['identifier'], traceback.format_exc())) + log.error('Search failed for %s: %s', (movie_dict['library']['identifier'], traceback.format_exc())) # Break if CP wants to shut down if self.shuttingDown(): @@ -88,7 +88,7 @@ class Searcher(Plugin): ret = False for quality_type in movie['profile']['types']: if not self.couldBeReleased(quality_type['quality']['identifier'], release_dates, pre_releases): - log.info('To early to search for %s, %s' % (quality_type['quality']['identifier'], default_title)) + log.info('To early to search for %s, %s', (quality_type['quality']['identifier'], default_title)) continue has_better_quality = 0 @@ -101,12 +101,12 @@ class Searcher(Plugin): # Don't search for quality lower then already available. if has_better_quality is 0: - log.info('Search for %s in %s' % (default_title, quality_type['quality']['label'])) + log.info('Search for %s in %s', (default_title, quality_type['quality']['label'])) quality = fireEvent('quality.single', identifier = quality_type['quality']['identifier'], single = True) results = fireEvent('yarr.search', movie, quality, merge = True) sorted_results = sorted(results, key = lambda k: k['score'], reverse = True) if len(sorted_results) == 0: - log.debug('Nothing found for %s in %s' % (default_title, quality_type['quality']['label'])) + log.debug('Nothing found for %s in %s', (default_title, quality_type['quality']['label'])) # Check if movie isn't deleted while searching if not db.query(Movie).filter_by(id = movie.get('id')).first(): @@ -141,10 +141,13 @@ class Searcher(Plugin): rls.info.append(rls_info) db.commit() except InterfaceError: - log.debug('Couldn\'t add %s to ReleaseInfo: %s' % (info, traceback.format_exc())) + log.debug('Couldn\'t add %s to ReleaseInfo: %s', (info, traceback.format_exc())) for nzb in sorted_results: + if nzb['score'] <= 0: + log.debug('No more releases with score higher than 0') + break downloaded = self.download(data = nzb, movie = movie) if downloaded is True: ret = True @@ -152,7 +155,7 @@ class Searcher(Plugin): elif downloaded != 'try_next': break else: - log.info('Better quality (%s) already available or snatched for %s' % (quality_type['quality']['label'], default_title)) + log.info('Better quality (%s) already available or snatched for %s', (quality_type['quality']['label'], default_title)) fireEvent('movie.restatus', movie['id']) break @@ -200,7 +203,7 @@ class Searcher(Plugin): if movie['status_id'] == active_status.get('id'): for profile_type in movie['profile']['types']: if profile_type['quality_id'] == rls.quality.id and profile_type['finish']: - log.info('Renamer disabled, marking movie as finished: %s' % log_movie) + log.info('Renamer disabled, marking movie as finished: %s', log_movie) # Mark release done rls.status_id = done_status.get('id') @@ -211,7 +214,7 @@ class Searcher(Plugin): mvie.status_id = done_status.get('id') db.commit() except Exception, e: - log.error('Failed marking movie finished: %s %s' % (e, traceback.format_exc())) + log.error('Failed marking movie finished: %s %s', (e, traceback.format_exc())) #db.close() return True @@ -226,7 +229,7 @@ class Searcher(Plugin): retention = Env.setting('retention', section = 'nzb') if nzb.get('seeds') is None and retention < nzb.get('age', 0): - log.info('Wrong: Outside retention, age is %s, needs %s or lower: %s' % (nzb['age'], retention, nzb['name'])) + log.info('Wrong: Outside retention, age is %s, needs %s or lower: %s', (nzb['age'], retention, nzb['name'])) return False movie_name = getTitle(movie['library']) @@ -245,10 +248,10 @@ class Searcher(Plugin): log.info("Wrong: '%s' blacklisted words: %s" % (nzb['name'], ", ".join(blacklisted))) return False - pron_tags = ['xxx', 'sex', 'anal', 'tits', 'fuck', 'porn', 'orgy', 'milf', 'boobs'] + pron_tags = ['xxx', 'sex', 'anal', 'tits', 'fuck', 'porn', 'orgy', 'milf', 'boobs', 'erotica', 'erotic'] for p_tag in pron_tags: if p_tag in nzb_words and p_tag not in movie_words: - log.info('Wrong: %s, probably pr0n' % (nzb['name'])) + log.info('Wrong: %s, probably pr0n', (nzb['name'])) return False #qualities = fireEvent('quality.all', single = True) @@ -256,18 +259,18 @@ class Searcher(Plugin): # Contains lower quality string if self.containsOtherQuality(nzb, movie_year = movie['library']['year'], preferred_quality = preferred_quality, single_category = single_category): - log.info('Wrong: %s, looking for %s' % (nzb['name'], quality['label'])) + log.info('Wrong: %s, looking for %s', (nzb['name'], quality['label'])) return False # File to small if nzb['size'] and preferred_quality['size_min'] > nzb['size']: - log.info('"%s" is too small to be %s. %sMB instead of the minimal of %sMB.' % (nzb['name'], preferred_quality['label'], nzb['size'], preferred_quality['size_min'])) + log.info('"%s" is too small to be %s. %sMB instead of the minimal of %sMB.', (nzb['name'], preferred_quality['label'], nzb['size'], preferred_quality['size_min'])) return False # File to large if nzb['size'] and preferred_quality.get('size_max') < nzb['size']: - log.info('"%s" is too large to be %s. %sMB instead of the maximum of %sMB.' % (nzb['name'], preferred_quality['label'], nzb['size'], preferred_quality['size_max'])) + log.info('"%s" is too large to be %s. %sMB instead of the maximum of %sMB.', (nzb['name'], preferred_quality['label'], nzb['size'], preferred_quality['size_max'])) return False @@ -406,17 +409,19 @@ class Searcher(Plugin): if dates.get('theater') - 604800 < now: return True else: - # 6 weeks after theater release - if dates.get('theater') + 3628800 < now: + # 12 weeks after theater release + if dates.get('theater') > 0 and dates.get('theater') + 7257600 < now: return True - # 6 weeks before dvd release - if dates.get('dvd') - 3628800 < now: - return True + if dates.get('dvd') > 0: - # Dvd should be released - if dates.get('dvd') > 0 and dates.get('dvd') < now: - return True + # 3 weeks before dvd release + if dates.get('dvd') - 1814400 < now: + return True + + # Dvd should be released + if dates.get('dvd') < now: + return True return False diff --git a/couchpotato/core/plugins/status/main.py b/couchpotato/core/plugins/status/main.py index edf2753e..af2e8792 100644 --- a/couchpotato/core/plugins/status/main.py +++ b/couchpotato/core/plugins/status/main.py @@ -92,7 +92,7 @@ class StatusPlugin(Plugin): for identifier, label in self.statuses.iteritems(): s = db.query(Status).filter_by(identifier = identifier).first() if not s: - log.info('Creating status: %s' % label) + log.info('Creating status: %s', label) s = Status( identifier = identifier, label = toUnicode(label) diff --git a/couchpotato/core/plugins/trailer/main.py b/couchpotato/core/plugins/trailer/main.py index 7c6d5d3f..05246845 100644 --- a/couchpotato/core/plugins/trailer/main.py +++ b/couchpotato/core/plugins/trailer/main.py @@ -18,7 +18,7 @@ class Trailer(Plugin): trailers = fireEvent('trailer.search', group = group, merge = True) if not trailers or trailers == []: - log.info('No trailers found for: %s' % getTitle(group['library'])) + log.info('No trailers found for: %s', getTitle(group['library'])) return for trailer in trailers.get(self.conf('quality'), []): @@ -26,7 +26,7 @@ class Trailer(Plugin): if not os.path.isfile(destination): fireEvent('file.download', url = trailer, dest = destination, urlopen_kwargs = {'headers': {'User-Agent': 'Quicktime'}}, single = True) else: - log.debug('Trailer already exists: %s' % destination) + log.debug('Trailer already exists: %s', destination) # Download first and break break diff --git a/couchpotato/core/plugins/userscript/main.py b/couchpotato/core/plugins/userscript/main.py index 359fd552..9c5f05f0 100644 --- a/couchpotato/core/plugins/userscript/main.py +++ b/couchpotato/core/plugins/userscript/main.py @@ -73,7 +73,7 @@ class Userscript(Plugin): 'movie': fireEvent('userscript.get_movie_via_url', url = url, single = True) } if not isDict(params['movie']): - log.error('Failed adding movie via url: %s' % url) + log.error('Failed adding movie via url: %s', url) params['error'] = params['movie'] if params['movie'] else 'Failed getting movie info' return jsonified(params) diff --git a/couchpotato/core/providers/automation/base.py b/couchpotato/core/providers/automation/base.py index f2e94cec..71ed988c 100644 --- a/couchpotato/core/providers/automation/base.py +++ b/couchpotato/core/providers/automation/base.py @@ -20,7 +20,7 @@ class Automation(Plugin): def _getMovies(self): if not self.canCheck(): - log.debug('Just checked, skipping %s' % self.getName()) + log.debug('Just checked, skipping %s', self.getName()) return [] self.last_checked = time.time() @@ -43,7 +43,7 @@ class Automation(Plugin): type_value = movie.get(minimal_type, 0) type_min = self.getMinimal(minimal_type) if type_value < type_min: - log.info('%s to low for %s, need %s has %s' % (minimal_type, identifier, type_min, type_value)) + log.info('%s to low for %s, need %s has %s', (minimal_type, identifier, type_min, type_value)) return False return True diff --git a/couchpotato/core/providers/automation/imdb/main.py b/couchpotato/core/providers/automation/imdb/main.py index 6364a75f..6b8cbf79 100644 --- a/couchpotato/core/providers/automation/imdb/main.py +++ b/couchpotato/core/providers/automation/imdb/main.py @@ -31,12 +31,13 @@ class IMDB(Automation, RSS): if not enablers[index]: continue elif 'rss.imdb' not in rss_url: - log.error('This isn\'t the correct url.: %s' % rss_url) + log.error('This isn\'t the correct url.: %s', rss_url) continue prop_name = 'automation.imdb.last_update.%s' % md5(rss_url) last_update = float(Env.prop(prop_name, default = 0)) + last_movie_added = 0 try: cache_key = 'imdb.rss.%s' % md5(rss_url) @@ -48,14 +49,17 @@ class IMDB(Automation, RSS): created = int(time.mktime(parse(self.getTextElement(movie, "pubDate")).timetuple())) imdb = getImdb(self.getTextElement(movie, "link")) - if not imdb or created < last_update: + if created > last_movie_added: + last_movie_added = created + + if not imdb or created <= last_update: continue movies.append(imdb) except: - log.error('Failed loading IMDB watchlist: %s %s' % (rss_url, traceback.format_exc())) + log.error('Failed loading IMDB watchlist: %s %s', (rss_url, traceback.format_exc())) - Env.prop(prop_name, time.time()) + Env.prop(prop_name, last_movie_added) return movies diff --git a/couchpotato/core/providers/automation/trakt/main.py b/couchpotato/core/providers/automation/trakt/main.py index e623b8fe..764f6cfc 100644 --- a/couchpotato/core/providers/automation/trakt/main.py +++ b/couchpotato/core/providers/automation/trakt/main.py @@ -41,13 +41,19 @@ class Trakt(Automation): def call(self, method_url): - if self.conf('automation_password'): - headers = { - 'Authorization': "Basic %s" % base64.encodestring('%s:%s' % (self.conf('automation_username'), self.conf('automation_password')))[:-1] - } - else: - headers = {} + try: + if self.conf('automation_password'): + headers = { + 'Authorization': 'Basic %s' % base64.encodestring('%s:%s' % (self.conf('automation_username'), self.conf('automation_password')))[:-1] + } + else: + headers = {} - cache_key = 'trakt.%s' % md5(method_url) - json_string = self.getCache(cache_key, self.urls['base'] + method_url, headers = headers) - return json.loads(json_string) + cache_key = 'trakt.%s' % md5(method_url) + json_string = self.getCache(cache_key, self.urls['base'] + method_url, headers = headers) + if json_string: + return json.loads(json_string) + except: + log.error('Failed to get data from trakt, check your login.') + + return [] diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index 826c7d96..0397784c 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -31,7 +31,7 @@ class Provider(Plugin): self.urlopen(test_url, 30) self.is_available[host] = True except: - log.error('"%s" unavailable, trying again in an 15 minutes.' % host) + log.error('"%s" unavailable, trying again in an 15 minutes.', host) self.is_available[host] = False return self.is_available.get(host, False) @@ -73,7 +73,7 @@ class YarrProvider(Provider): if hostname in download_url: return self except: - log.debug('Url % s doesn\'t belong to %s' % (url, self.getName())) + log.debug('Url % s doesn\'t belong to %s', (url, self.getName())) return @@ -106,4 +106,4 @@ class YarrProvider(Provider): return [self.cat_backup_id] def found(self, new): - log.info('Found: score(%(score)s) on %(provider)s: %(name)s' % new) + log.info('Found: score(%(score)s) on %(provider)s: %(name)s', new) diff --git a/couchpotato/core/providers/metadata/base.py b/couchpotato/core/providers/metadata/base.py index d7e9e035..a10f9c82 100644 --- a/couchpotato/core/providers/metadata/base.py +++ b/couchpotato/core/providers/metadata/base.py @@ -19,14 +19,14 @@ class MetaDataBase(Plugin): def create(self, release): if self.isDisabled(): return - log.info('Creating %s metadata.' % self.getName()) + log.info('Creating %s metadata.', self.getName()) # Update library to get latest info try: updated_library = fireEvent('library.update', release['library']['identifier'], force = True, single = True) release['library'] = mergeDicts(release['library'], updated_library) except: - log.error('Failed to update movie, before creating metadata: %s' % traceback.format_exc()) + log.error('Failed to update movie, before creating metadata: %s', traceback.format_exc()) root_name = self.getRootName(release) meta_name = os.path.basename(root_name) @@ -44,14 +44,14 @@ class MetaDataBase(Plugin): # Get file content content = getattr(self, 'get' + file_type.capitalize())(movie_info = movie_info, data = release) if content: - log.debug('Creating %s file: %s' % (file_type, name)) + log.debug('Creating %s file: %s', (file_type, name)) if os.path.isfile(content): shutil.copy2(content, name) else: self.createFile(name, content) except: - log.error('Unable to create %s file: %s' % (file_type, traceback.format_exc())) + log.error('Unable to create %s file: %s', (file_type, traceback.format_exc())) def getRootName(self, data): return diff --git a/couchpotato/core/providers/metadata/xbmc/main.py b/couchpotato/core/providers/metadata/xbmc/main.py index d6c9bed9..361ec120 100644 --- a/couchpotato/core/providers/metadata/xbmc/main.py +++ b/couchpotato/core/providers/metadata/xbmc/main.py @@ -77,7 +77,7 @@ class XBMC(MetaDataBase): votes.text = str(v) break except: - log.debug('Failed adding rating info from %s: %s' % (rating_type, traceback.format_exc())) + log.debug('Failed adding rating info from %s: %s', (rating_type, traceback.format_exc())) # Genre for genre in movie_info.get('genres', []): diff --git a/couchpotato/core/providers/movie/_modifier/main.py b/couchpotato/core/providers/movie/_modifier/main.py index 4b2ebb66..d0886702 100644 --- a/couchpotato/core/providers/movie/_modifier/main.py +++ b/couchpotato/core/providers/movie/_modifier/main.py @@ -68,7 +68,7 @@ class MovieResultModifier(Plugin): if release.status_id == done_status['id']: temp['in_library'] = fireEvent('movie.get', movie.id, single = True) except: - log.error('Tried getting more info on searched movies: %s' % traceback.format_exc()) + log.error('Tried getting more info on searched movies: %s', traceback.format_exc()) #db.close() return temp diff --git a/couchpotato/core/providers/movie/couchpotatoapi/main.py b/couchpotato/core/providers/movie/couchpotatoapi/main.py index 298c06bf..4379bb89 100644 --- a/couchpotato/core/providers/movie/couchpotatoapi/main.py +++ b/couchpotato/core/providers/movie/couchpotatoapi/main.py @@ -32,10 +32,10 @@ class CouchPotatoApi(MovieProvider): headers = {'X-CP-Version': fireEvent('app.version', single = True)} data = self.urlopen((self.api_url % ('eta')) + (identifier + '/'), headers = headers) dates = json.loads(data) - log.debug('Found ETA for %s: %s' % (identifier, dates)) + log.debug('Found ETA for %s: %s', (identifier, dates)) return dates except Exception, e: - log.error('Error getting ETA for %s: %s' % (identifier, e)) + log.error('Error getting ETA for %s: %s', (identifier, e)) return {} @@ -43,9 +43,9 @@ class CouchPotatoApi(MovieProvider): try: data = self.urlopen((self.api_url % ('suggest')) + ','.join(movies) + '/' + ','.join(ignore) + '/') suggestions = json.loads(data) - log.info('Found Suggestions for %s' % (suggestions)) + log.info('Found Suggestions for %s', (suggestions)) except Exception, e: - log.error('Error getting suggestions for %s: %s' % (movies, e)) + log.error('Error getting suggestions for %s: %s', (movies, e)) return suggestions diff --git a/couchpotato/core/providers/movie/imdbapi/main.py b/couchpotato/core/providers/movie/imdbapi/main.py index 7456a3c9..ad3f78ff 100644 --- a/couchpotato/core/providers/movie/imdbapi/main.py +++ b/couchpotato/core/providers/movie/imdbapi/main.py @@ -36,7 +36,7 @@ class IMDBAPI(MovieProvider): if cached: result = self.parseMovie(cached) if result.get('titles') and len(result.get('titles')) > 0: - log.info('Found: %s' % result['titles'][0] + ' (' + str(result['year']) + ')') + log.info('Found: %s', result['titles'][0] + ' (' + str(result['year']) + ')') return [result] return [] @@ -54,7 +54,7 @@ class IMDBAPI(MovieProvider): if cached: result = self.parseMovie(cached) if result.get('titles') and len(result.get('titles')) > 0: - log.info('Found: %s' % result['titles'][0] + ' (' + str(result['year']) + ')') + log.info('Found: %s', result['titles'][0] + ' (' + str(result['year']) + ')') return result return {} @@ -103,7 +103,7 @@ class IMDBAPI(MovieProvider): 'actors': movie.get('Actors', '').split(','), } except: - log.error('Failed parsing IMDB API json: %s' % traceback.format_exc()) + log.error('Failed parsing IMDB API json: %s', traceback.format_exc()) return movie_data diff --git a/couchpotato/core/providers/movie/themoviedb/main.py b/couchpotato/core/providers/movie/themoviedb/main.py index b1a1b62b..9f9fe4fe 100644 --- a/couchpotato/core/providers/movie/themoviedb/main.py +++ b/couchpotato/core/providers/movie/themoviedb/main.py @@ -28,7 +28,7 @@ class TheMovieDb(MovieProvider): results = self.getCache(cache_key) if not results: - log.debug('Searching for movie by hash: %s' % file) + log.debug('Searching for movie by hash: %s', file) try: raw = tmdb.searchByHashingFile(file) @@ -36,15 +36,15 @@ class TheMovieDb(MovieProvider): if raw: try: results = self.parseMovie(raw) - log.info('Found: %s' % results['titles'][0] + ' (' + str(results['year']) + ')') + log.info('Found: %s', results['titles'][0] + ' (' + str(results['year']) + ')') self.setCache(cache_key, results) return results except SyntaxError, e: - log.error('Failed to parse XML response: %s' % e) + log.error('Failed to parse XML response: %s', e) return False except: - log.debug('No movies known by hash for: %s' % file) + log.debug('No movies known by hash for: %s', file) pass return results @@ -60,7 +60,7 @@ class TheMovieDb(MovieProvider): results = self.getCache(cache_key) if not results: - log.debug('Searching for movie: %s' % q) + log.debug('Searching for movie: %s', q) raw = tmdb.search(search_string) results = [] @@ -75,12 +75,12 @@ class TheMovieDb(MovieProvider): if nr == limit: break - log.info('Found: %s' % [result['titles'][0] + ' (' + str(result['year']) + ')' for result in results]) + log.info('Found: %s', [result['titles'][0] + ' (' + str(result['year']) + ')' for result in results]) self.setCache(cache_key, results) return results except SyntaxError, e: - log.error('Failed to parse XML response: %s' % e) + log.error('Failed to parse XML response: %s', e) return False return results @@ -98,7 +98,7 @@ class TheMovieDb(MovieProvider): movie = None try: - log.debug('Getting info: %s' % cache_key) + log.debug('Getting info: %s', cache_key) movie = tmdb.imdbLookup(id = identifier) except: pass @@ -119,7 +119,7 @@ class TheMovieDb(MovieProvider): movie = None try: - log.debug('Getting info: %s' % cache_key) + log.debug('Getting info: %s', cache_key) movie = tmdb.getMovieInfo(id = id) except: pass diff --git a/couchpotato/core/providers/nzb/mysterbin/main.py b/couchpotato/core/providers/nzb/mysterbin/main.py index 5e619792..008f24f8 100644 --- a/couchpotato/core/providers/nzb/mysterbin/main.py +++ b/couchpotato/core/providers/nzb/mysterbin/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup from couchpotato.core.event import fireEvent from couchpotato.core.helpers.encoding import toUnicode, tryUrlencode, \ simplifyString @@ -49,21 +49,21 @@ class Mysterbin(NZBProvider): try: html = BeautifulSoup(data) resultable = html.find('table', attrs = {'class':'t'}) - for result in resultable.findAll('tr'): + for result in resultable.find_all('tr'): try: myster_id = result.find('input', attrs = {'class': 'check4nzb'})['value'] # Age age = '' - for temp in result.find('td', attrs = {'class': 'cdetail'}).findAll(text = True): + for temp in result.find('td', attrs = {'class': 'cdetail'}).find_all(text = True): if 'days' in temp: age = tryInt(temp.split(' ')[0]) break # size size = None - for temp in result.find('div', attrs = {'class': 'cdetail'}).findAll(text = True): + for temp in result.find('div', attrs = {'class': 'cdetail'}).find_all(text = True): if 'gb' in temp.lower() or 'mb' in temp.lower() or 'kb' in temp.lower(): size = self.parseSize(temp) break @@ -74,7 +74,7 @@ class Mysterbin(NZBProvider): new = { 'id': myster_id, - 'name': ''.join(result.find('span', attrs = {'class': 'cname'}).findAll(text = True)), + 'name': ''.join(result.find('span', attrs = {'class': 'cname'}).find_all(text = True)), 'type': 'nzb', 'provider': self.getName(), 'age': age, diff --git a/couchpotato/core/providers/nzb/newzbin/main.py b/couchpotato/core/providers/nzb/newzbin/main.py index 9239e84f..06a09a94 100644 --- a/couchpotato/core/providers/nzb/newzbin/main.py +++ b/couchpotato/core/providers/nzb/newzbin/main.py @@ -57,6 +57,8 @@ class Newzbin(NZBProvider, RSS): 'category': '6', 'ps_rb_video_format': str(cat_id), 'ps_rb_source': str(format_id), + 'u_post_larger_than': quality.get('size_min'), + 'u_post_smaller_than': quality.get('size_max'), }) url = "%s?%s" % (self.urls['search'], arguments) @@ -80,7 +82,7 @@ class Newzbin(NZBProvider, RSS): data = XMLTree.fromstring(data) nzbs = self.getElements(data, 'channel/item') except Exception, e: - log.debug('%s, %s' % (self.getName(), e)) + log.debug('%s, %s', (self.getName(), e)) return results for nzb in nzbs: @@ -131,7 +133,7 @@ class Newzbin(NZBProvider, RSS): def download(self, url = '', nzb_id = ''): try: - log.info('Download nzb from newzbin, report id: %s ' % nzb_id) + log.info('Download nzb from newzbin, report id: %s ', nzb_id) return self.urlopen(self.urls['download'], params = { 'username' : self.conf('username'), @@ -139,7 +141,7 @@ class Newzbin(NZBProvider, RSS): 'reportid' : nzb_id }, show_error = False) except Exception, e: - log.error('Failed downloading from newzbin, check credit: %s' % e) + log.error('Failed downloading from newzbin, check credit: %s', e) return False def getFormatId(self, format): diff --git a/couchpotato/core/providers/nzb/newznab/main.py b/couchpotato/core/providers/nzb/newznab/main.py index 448ab35e..55c0f308 100644 --- a/couchpotato/core/providers/nzb/newznab/main.py +++ b/couchpotato/core/providers/nzb/newznab/main.py @@ -112,7 +112,7 @@ class Newznab(NZBProvider, RSS): data = XMLTree.fromstring(data) nzbs = self.getElements(data, 'channel/item') except Exception, e: - log.debug('%s, %s' % (self.getName(), e)) + log.debug('%s, %s', (self.getName(), e)) return results results = [] @@ -126,8 +126,8 @@ class Newznab(NZBProvider, RSS): elif item.attrib.get('name') == 'usenetdate': date = item.attrib.get('value') - if date is '': log.debug('Date not parsed properly or not available for %s: %s' % (host['host'], self.getTextElement(nzb, "title"))) - if size is 0: log.debug('Size not parsed properly or not available for %s: %s' % (host['host'], self.getTextElement(nzb, "title"))) + if date is '': log.debug('Date not parsed properly or not available for %s: %s', (host['host'], self.getTextElement(nzb, "title"))) + if size is 0: log.debug('Size not parsed properly or not available for %s: %s', (host['host'], self.getTextElement(nzb, "title"))) id = self.getTextElement(nzb, "guid").split('/')[-1:].pop() new = { @@ -157,7 +157,7 @@ class Newznab(NZBProvider, RSS): return results except SyntaxError: - log.error('Failed to parse XML response from Newznab: %s' % host) + log.error('Failed to parse XML response from Newznab: %s', host) return results def getHosts(self): @@ -216,9 +216,9 @@ class Newznab(NZBProvider, RSS): response = e.read().lower() if 'maximum api' in response or 'download limit' in response: if not self.limits_reached.get(host): - log.error('Limit reached for newznab provider: %s' % host) + log.error('Limit reached for newznab provider: %s', host) self.limits_reached[host] = time.time() return 'try_next' - log.error('Failed download from %s' % (host, traceback.format_exc())) + log.error('Failed download from %s', (host, traceback.format_exc())) raise diff --git a/couchpotato/core/providers/nzb/nzbclub/main.py b/couchpotato/core/providers/nzb/nzbclub/main.py index e6dbad90..39835ded 100644 --- a/couchpotato/core/providers/nzb/nzbclub/main.py +++ b/couchpotato/core/providers/nzb/nzbclub/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup from couchpotato.core.event import fireEvent from couchpotato.core.helpers.encoding import toUnicode, tryUrlencode, \ simplifyString @@ -28,7 +28,7 @@ class NZBClub(NZBProvider, RSS): if self.isDisabled(): return results - q = '"%s" %s %s' % (simplifyString(getTitle(movie['library'])), movie['library']['year'], quality.get('identifier')) + q = '"%s %s" %s' % (simplifyString(getTitle(movie['library'])), movie['library']['year'], quality.get('identifier')) for ignored in Env.setting('ignored_words', 'searcher').split(','): q = '%s -%s' % (q, ignored.strip()) @@ -49,7 +49,7 @@ class NZBClub(NZBProvider, RSS): data = XMLTree.fromstring(data) nzbs = self.getElements(data, 'channel/item') except Exception, e: - log.debug('%s, %s' % (self.getName(), e)) + log.debug('%s, %s', (self.getName(), e)) return results for nzb in nzbs: @@ -62,9 +62,10 @@ class NZBClub(NZBProvider, RSS): def extra_check(item): full_description = self.getCache('nzbclub.%s' % nzbclub_id, item['detail_url'], cache_timeout = 25920000) - if 'ARCHIVE inside ARCHIVE' in full_description: - log.info('Wrong: Seems to be passworded files: %s' % new['name']) - return False + for ignored in ['ARCHIVE inside ARCHIVE', 'Incomplete', 'repair impossible']: + if ignored in full_description: + log.info('Wrong: Seems to be passworded or corrupted files: %s', new['name']) + return False return True @@ -111,7 +112,7 @@ class NZBClub(NZBProvider, RSS): full_description = self.getCache('nzbclub.%s' % item['id'], item['detail_url'], cache_timeout = 25920000) if 'ARCHIVE inside ARCHIVE' in full_description: - log.info('Wrong: Seems to be passworded files: %s' % item['name']) + log.info('Wrong: Seems to be passworded files: %s', item['name']) return False return True diff --git a/couchpotato/core/providers/nzb/nzbindex/main.py b/couchpotato/core/providers/nzb/nzbindex/main.py index fd53cdde..89f68bee 100644 --- a/couchpotato/core/providers/nzb/nzbindex/main.py +++ b/couchpotato/core/providers/nzb/nzbindex/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup from couchpotato.core.event import fireEvent from couchpotato.core.helpers.encoding import toUnicode, tryUrlencode, \ simplifyString @@ -30,7 +30,7 @@ class NzbIndex(NZBProvider, RSS): if self.isDisabled(): return results - q = '%s %s %s' % (simplifyString(getTitle(movie['library'])), movie['library']['year'], quality.get('identifier')) + q = '"%s %s" %s' % (simplifyString(getTitle(movie['library'])), movie['library']['year'], quality.get('identifier')) arguments = tryUrlencode({ 'q': q, 'age': Env.setting('retention', 'nzb'), @@ -53,7 +53,7 @@ class NzbIndex(NZBProvider, RSS): data = XMLTree.fromstring(data) nzbs = self.getElements(data, 'channel/item') except Exception, e: - log.debug('%s, %s' % (self.getName(), e)) + log.debug('%s, %s', (self.getName(), e)) return results for nzb in nzbs: @@ -67,6 +67,13 @@ class NzbIndex(NZBProvider, RSS): except: description = '' + def extra_check(new): + if '#c20000' in new['description'].lower(): + log.info('Wrong: Seems to be passworded: %s', new['name']) + return False + + return True + new = { 'id': nzbindex_id, 'type': 'nzb', @@ -79,6 +86,7 @@ class NzbIndex(NZBProvider, RSS): 'detail_url': enclosure['url'].replace('/download/', '/release/'), 'description': description, 'get_more_info': self.getMoreInfo, + 'extra_check': extra_check, 'check_nzb': True, } diff --git a/couchpotato/core/providers/nzb/nzbmatrix/main.py b/couchpotato/core/providers/nzb/nzbmatrix/main.py index b512790d..203ada1d 100644 --- a/couchpotato/core/providers/nzb/nzbmatrix/main.py +++ b/couchpotato/core/providers/nzb/nzbmatrix/main.py @@ -58,7 +58,7 @@ class NZBMatrix(NZBProvider, RSS): data = XMLTree.fromstring(data) nzbs = self.getElements(data, 'channel/item') except Exception, e: - log.debug('%s, %s' % (self.getName(), e)) + log.debug('%s, %s', (self.getName(), e)) return results for nzb in nzbs: @@ -99,6 +99,9 @@ class NZBMatrix(NZBProvider, RSS): return results + def download(self, url = '', nzb_id = ''): + return self.urlopen(url, headers = {'User-Agent': Env.getIdentifier()}) + def getApiExt(self): return '&username=%s&apikey=%s' % (self.conf('username'), self.conf('api_key')) diff --git a/couchpotato/core/providers/torrent/kickasstorrents/main.py b/couchpotato/core/providers/torrent/kickasstorrents/main.py index 1101d321..2c9b8939 100644 --- a/couchpotato/core/providers/torrent/kickasstorrents/main.py +++ b/couchpotato/core/providers/torrent/kickasstorrents/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup from couchpotato.core.event import fireEvent from couchpotato.core.helpers.variable import tryInt, getTitle from couchpotato.core.logger import CPLog @@ -47,14 +47,14 @@ class KickAssTorrents(TorrentProvider): try: html = BeautifulSoup(data) resultdiv = html.find('div', attrs = {'class':'tabs'}) - for result in resultdiv.findAll('div', recursive = False): + for result in resultdiv.find_all('div', recursive = False): if result.get('id').lower() not in cat_ids: continue try: try: - for temp in result.findAll('tr'): + for temp in result.find_all('tr'): if temp['class'] is 'firstr' or not temp.get('id'): continue @@ -68,15 +68,15 @@ class KickAssTorrents(TorrentProvider): } nr = 0 - for td in temp.findAll('td'): + for td in temp.find_all('td'): column_name = table_order[nr] if column_name: if column_name is 'name': - link = td.find('div', {'class': 'torrentname'}).findAll('a')[1] + link = td.find('div', {'class': 'torrentname'}).find_all('a')[1] new['id'] = temp.get('id')[-8:] new['name'] = link.text - new['url'] = td.findAll('a', 'idownload')[1]['href'] + new['url'] = td.find_all('a', 'idownload')[1]['href'] if new['url'][:2] == '//': new['url'] = 'http:%s' % new['url'] new['score'] = 20 if td.find('a', 'iverif') else 0 @@ -99,7 +99,7 @@ class KickAssTorrents(TorrentProvider): results.append(new) self.found(new) except: - log.error('Failed parsing KickAssTorrents: %s' % traceback.format_exc()) + log.error('Failed parsing KickAssTorrents: %s', traceback.format_exc()) except: pass diff --git a/couchpotato/core/providers/torrent/thepiratebay/main.py b/couchpotato/core/providers/torrent/thepiratebay/main.py index 74055df8..3664ffcd 100644 --- a/couchpotato/core/providers/torrent/thepiratebay/main.py +++ b/couchpotato/core/providers/torrent/thepiratebay/main.py @@ -36,11 +36,11 @@ class ThePirateBay(TorrentProvider): url = self.apiUrl % (quote_plus(self.toSearchString(movie.name + ' ' + quality) + self.makeIgnoreString(type)), self.getCatId(type)) - log.info('Searching: %s' % url) + log.info('Searching: %s', url) data = self.urlopen(url) if not data: - log.error('Failed to get data from %s.' % url) + log.error('Failed to get data from %s.', url) return results try: @@ -104,7 +104,7 @@ class ThePirateBay(TorrentProvider): new.content = self.getInfo(new.detailUrl) if self.isCorrectMovie(new, movie, type): results.append(new) - log.info('Found: %s' % new.name) + log.info('Found: %s', new.name) return results @@ -127,11 +127,11 @@ class ThePirateBay(TorrentProvider): def getInfo(self, url): - log.debug('Getting info: %s' % url) + log.debug('Getting info: %s', url) data = self.urlopen(url) if not data: - log.error('Failed to get data from %s.' % url) + log.error('Failed to get data from %s.', url) return '' div = SoupStrainer('div') diff --git a/couchpotato/core/providers/trailer/hdtrailers/main.py b/couchpotato/core/providers/trailer/hdtrailers/main.py index b68f76f7..d11f9231 100644 --- a/couchpotato/core/providers/trailer/hdtrailers/main.py +++ b/couchpotato/core/providers/trailer/hdtrailers/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import SoupStrainer, BeautifulSoup +from bs4 import SoupStrainer, BeautifulSoup from couchpotato.core.helpers.encoding import tryUrlencode from couchpotato.core.helpers.variable import mergeDicts, getTitle from couchpotato.core.logger import CPLog @@ -51,13 +51,13 @@ class HDTrailers(TrailerProvider): try: tables = SoupStrainer('div') - html = BeautifulSoup(data, parseOnlyThese = tables) - result_table = html.findAll('h2', text = re.compile(movie_name)) + html = BeautifulSoup(data, parse_only = tables) + result_table = html.find_all('h2', text = re.compile(movie_name)) for h2 in result_table: if 'trailer' in h2.lower(): parent = h2.parent.parent.parent - trailerLinks = parent.findAll('a', text = re.compile('480p|720p|1080p')) + trailerLinks = parent.find_all('a', text = re.compile('480p|720p|1080p')) try: for trailer in trailerLinks: results[trailer].insert(0, trailer.parent['href']) @@ -74,11 +74,11 @@ class HDTrailers(TrailerProvider): results = {'480p':[], '720p':[], '1080p':[]} try: tables = SoupStrainer('table') - html = BeautifulSoup(data, parseOnlyThese = tables) + html = BeautifulSoup(data, parse_only = tables) result_table = html.find('table', attrs = {'class':'bottomTable'}) - for tr in result_table.findAll('tr'): + for tr in result_table.find_all('tr'): trtext = str(tr).lower() if 'clips' in trtext: break @@ -86,7 +86,7 @@ class HDTrailers(TrailerProvider): nr = 0 if 'trailer' not in tr.find('span', 'standardTrailerName').text.lower(): continue - resolutions = tr.findAll('td', attrs = {'class':'bottomTableResolution'}) + resolutions = tr.find_all('td', attrs = {'class':'bottomTableResolution'}) for res in resolutions: results[str(res.a.contents[0])].insert(0, res.a['href']) nr += 1 @@ -94,7 +94,7 @@ class HDTrailers(TrailerProvider): return results except AttributeError: - log.debug('No trailers found in provider %s.' % provider) + log.debug('No trailers found in provider %s.', provider) results['404'] = True return results diff --git a/couchpotato/core/providers/userscript/allocine/main.py b/couchpotato/core/providers/userscript/allocine/main.py index 8213ac2f..890ae223 100644 --- a/couchpotato/core/providers/userscript/allocine/main.py +++ b/couchpotato/core/providers/userscript/allocine/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup from couchpotato.core.providers.userscript.base import UserscriptBase class AlloCine(UserscriptBase): diff --git a/couchpotato/core/providers/userscript/rottentomatoes/main.py b/couchpotato/core/providers/userscript/rottentomatoes/main.py index 1d685903..cd869b8f 100644 --- a/couchpotato/core/providers/userscript/rottentomatoes/main.py +++ b/couchpotato/core/providers/userscript/rottentomatoes/main.py @@ -1,4 +1,4 @@ -from BeautifulSoup import BeautifulSoup +from bs4 import BeautifulSoup from couchpotato.core.event import fireEvent from couchpotato.core.providers.userscript.base import UserscriptBase diff --git a/couchpotato/core/providers/userscript/youteather/main.py b/couchpotato/core/providers/userscript/youteather/main.py index 314495f2..3efd3686 100644 --- a/couchpotato/core/providers/userscript/youteather/main.py +++ b/couchpotato/core/providers/userscript/youteather/main.py @@ -5,8 +5,8 @@ class YouTheater(UserscriptBase): id_re = re.compile("view\.php\?id=(\d+)") includes = ['http://www.youtheater.com/view.php?id=*', 'http://youtheater.com/view.php?id=*', 'http://www.sratim.co.il/view.php?id=*', 'http://sratim.co.il/view.php?id=*'] - + def getMovie(self, url): id = self.id_re.findall(url)[0] - url = "http://www.youtheater.com/view.php?id=%s" % id - return super(YouTheater, self).getMovie(url) \ No newline at end of file + url = 'http://www.youtheater.com/view.php?id=%s' % id + return super(YouTheater, self).getMovie(url) diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 35a3bf96..05541802 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -155,10 +155,10 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En import color_logs from couchpotato.core.logger import CPLog log = CPLog(__name__) - log.debug('Started with options %s' % options) + log.debug('Started with options %s', options) def customwarn(message, category, filename, lineno, file = None, line = None): - log.warning('%s %s %s line:%s' % (category, message, filename, lineno)) + log.warning('%s %s %s line:%s', (category, message, filename, lineno)) warnings.showwarning = customwarn @@ -185,7 +185,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En current_db_version = db_version(db, repo) if current_db_version < latest_db_version and not debug: - log.info('Doing database upgrade. From %d to %d' % (current_db_version, latest_db_version)) + log.info('Doing database upgrade. From %d to %d', (current_db_version, latest_db_version)) upgrade(db, repo) # Configure Database @@ -220,7 +220,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En app.register_blueprint(api, url_prefix = '%s/api/%s/' % (url_base, api_key)) # Some logging and fire load event - try: log.info('Starting server on port %(port)s' % config) + try: log.info('Starting server on port %(port)s', config) except: pass fireEventAsync('app.load') @@ -248,7 +248,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En try: nr, msg = e if nr == 48: - log.info('Already in use, try %s more time after few seconds' % restart_tries) + log.info('Already in use, try %s more time after few seconds', restart_tries) time.sleep(1) restart_tries -= 1 diff --git a/couchpotato/static/images/icon.trailer.png b/couchpotato/static/images/icon.trailer.png new file mode 100644 index 00000000..6a382dc7 Binary files /dev/null and b/couchpotato/static/images/icon.trailer.png differ diff --git a/couchpotato/static/scripts/page/manage.js b/couchpotato/static/scripts/page/manage.js index 0385ee0f..aefbb42e 100644 --- a/couchpotato/static/scripts/page/manage.js +++ b/couchpotato/static/scripts/page/manage.js @@ -41,7 +41,7 @@ Page.Manage = new Class({ Api.request('manage.update', { 'data': { - 'full': full ? 1 : null + 'full': +full } }) diff --git a/couchpotato/static/scripts/page/settings.js b/couchpotato/static/scripts/page/settings.js index 91848556..ed75cfd7 100644 --- a/couchpotato/static/scripts/page/settings.js +++ b/couchpotato/static/scripts/page/settings.js @@ -926,14 +926,19 @@ Option.Choice = new Class({ var mtches = [] if(matches) matches.each(function(match, mnr){ - var msplit = value.split(match); - msplit.each(function(matchsplit, snr){ - if(msplit.length-1 == snr) - value = matchsplit; - mtches.append([value == matchsplit ? match : matchsplit]); + var pos = value.indexOf(match), + msplit = [value.substr(0, pos), value.substr(pos, match.length), value.substr(pos+match.length)]; - if(matches.length*2 == mtches.length) - mtches.append([value]); + msplit.each(function(matchsplit, snr){ + if(msplit.length-1 == snr){ + value = matchsplit; + + if(matches.length-1 == mnr) + mtches.append([value]); + + return; + } + mtches.append([value == matchsplit ? match : matchsplit]); }); }); diff --git a/couchpotato/static/scripts/page/wanted.js b/couchpotato/static/scripts/page/wanted.js index 26d04666..d768a124 100644 --- a/couchpotato/static/scripts/page/wanted.js +++ b/couchpotato/static/scripts/page/wanted.js @@ -28,8 +28,9 @@ var MovieActions = {}; window.addEvent('domready', function(){ MovieActions.Wanted = { - 'IMBD': IMDBAction - ,'releases': ReleaseAction + 'IMDB': IMDBAction + ,'Trailer': TrailerAction + ,'Releases': ReleaseAction ,'Edit': new Class({ @@ -236,12 +237,12 @@ window.addEvent('domready', function(){ }; MovieActions.Snatched = { - 'IMBD': IMDBAction + 'IMDB': IMDBAction ,'Delete': MovieActions.Wanted.Delete }; MovieActions.Done = { - 'IMBD': IMDBAction + 'IMDB': IMDBAction ,'Edit': MovieActions.Wanted.Edit ,'Files': new Class({ diff --git a/couchpotato/static/style/main.css b/couchpotato/static/style/main.css index 679748b6..c2a525a1 100644 --- a/couchpotato/static/style/main.css +++ b/couchpotato/static/style/main.css @@ -154,6 +154,7 @@ body > .spinner, .mask{ .icon.rating { background-image: url('../images/icon.rating.png'); } .icon.files { background-image: url('../images/icon.files.png'); } .icon.info { background-image: url('../images/icon.info.png'); } +.icon.trailer { background-image: url('../images/icon.trailer.png'); } /*** Navigation ***/ .header { @@ -166,6 +167,7 @@ body > .spinner, .mask{ z-index: 5; box-shadow: 0 20px 30px -30px rgba(0,0,0,0.05); transition: box-shadow .4s cubic-bezier(0.9,0,0.1,1); + transform: translateZ(0); } .header.with_shadow { box-shadow: 0 20px 30px -30px rgba(0,0,0,0.3); @@ -215,6 +217,7 @@ body > .spinner, .mask{ outline: none; box-shadow: inset 0 1px 8px rgba(0,0,0,0.05), 0 1px 0px rgba(255,255,255,0.15); transition: all .4s cubic-bezier(0.9,0,0.1,1); + transform: translateZ(0); } .header .navigation li:hover a:after { background-color: #047792; } @@ -311,7 +314,7 @@ body > .spinner, .mask{ .header .message.update { text-align: center; position: relative; - top: -70px; + top: -100px; padding: 2px 0; background: #ff6134; font-size: 12px; @@ -522,6 +525,7 @@ body > .spinner, .mask{ width: 25px; border: 1px solid rgba(0,0,0,0.3); transition: all 0.3s ease-in-out; + transform: translateZ(0); } .more_menu.show > a:not(:active), .more_menu > a:hover:not(:active) { background-color: #406db8; diff --git a/couchpotato/static/style/page/settings.css b/couchpotato/static/style/page/settings.css index 59a03c82..feea6bbe 100644 --- a/couchpotato/static/style/page/settings.css +++ b/couchpotato/static/style/page/settings.css @@ -35,6 +35,7 @@ padding: 11px 15px; font-weight: normal; transition: all 0.1s ease-in-out; + transform: translateZ(0); color: rgba(255, 255, 255, 0.8); } .page.settings .tabs a:hover, .page.settings .tabs .active a { @@ -49,6 +50,7 @@ padding: 0; overflow: hidden; transition: all 1s ease-in-out; + transform: translateZ(0); max-height: 0; } .page.settings .tabs > .active .subtabs { diff --git a/couchpotato/templates/_desktop.html b/couchpotato/templates/_desktop.html index e6d1ccb2..6dcc2c02 100644 --- a/couchpotato/templates/_desktop.html +++ b/couchpotato/templates/_desktop.html @@ -45,6 +45,8 @@ + + ') + # => <script> do_nasty_stuff() </script> + # sanitize_html('Click here for $100') + # => Click here for $100 + def sanitize_token(self, token): + + # accommodate filters which use token_type differently + token_type = token["type"] + if token_type in tokenTypes.keys(): + token_type = tokenTypes[token_type] + + if token_type in (tokenTypes["StartTag"], tokenTypes["EndTag"], + tokenTypes["EmptyTag"]): + if token["name"] in self.allowed_elements: + if token.has_key("data"): + attrs = dict([(name,val) for name,val in + token["data"][::-1] + if name in self.allowed_attributes]) + for attr in self.attr_val_is_uri: + if not attrs.has_key(attr): + continue + val_unescaped = re.sub("[`\000-\040\177-\240\s]+", '', + unescape(attrs[attr])).lower() + #remove replacement characters from unescaped characters + val_unescaped = val_unescaped.replace(u"\ufffd", "") + if (re.match("^[a-z0-9][-+.a-z0-9]*:",val_unescaped) and + (val_unescaped.split(':')[0] not in + self.allowed_protocols)): + del attrs[attr] + for attr in self.svg_attr_val_allows_ref: + if attr in attrs: + attrs[attr] = re.sub(r'url\s*\(\s*[^#\s][^)]+?\)', + ' ', + unescape(attrs[attr])) + if (token["name"] in self.svg_allow_local_href and + 'xlink:href' in attrs and re.search('^\s*[^#\s].*', + attrs['xlink:href'])): + del attrs['xlink:href'] + if attrs.has_key('style'): + attrs['style'] = self.sanitize_css(attrs['style']) + token["data"] = [[name,val] for name,val in attrs.items()] + return token + else: + if token_type == tokenTypes["EndTag"]: + token["data"] = "" % token["name"] + elif token["data"]: + attrs = ''.join([' %s="%s"' % (k,escape(v)) for k,v in token["data"]]) + token["data"] = "<%s%s>" % (token["name"],attrs) + else: + token["data"] = "<%s>" % token["name"] + if token.get("selfClosing"): + token["data"]=token["data"][:-1] + "/>" + + if token["type"] in tokenTypes.keys(): + token["type"] = "Characters" + else: + token["type"] = tokenTypes["Characters"] + + del token["name"] + return token + elif token_type == tokenTypes["Comment"]: + pass + else: + return token + + def sanitize_css(self, style): + # disallow urls + style=re.compile('url\s*\(\s*[^\s)]+?\s*\)\s*').sub(' ',style) + + # gauntlet + if not re.match("""^([:,;#%.\sa-zA-Z0-9!]|\w-\w|'[\s\w]+'|"[\s\w]+"|\([\d,\s]+\))*$""", style): return '' + if not re.match("^\s*([-\w]+\s*:[^:;]*(;\s*|$))*$", style): return '' + + clean = [] + for prop,value in re.findall("([-\w]+)\s*:\s*([^:;]*)",style): + if not value: continue + if prop.lower() in self.allowed_css_properties: + clean.append(prop + ': ' + value + ';') + elif prop.split('-')[0].lower() in ['background','border','margin', + 'padding']: + for keyword in value.split(): + if not keyword in self.acceptable_css_keywords and \ + not re.match("^(#[0-9a-f]+|rgb\(\d+%?,\d*%?,?\d*%?\)?|\d{0,2}\.?\d{0,2}(cm|em|ex|in|mm|pc|pt|px|%|,|\))?)$",keyword): + break + else: + clean.append(prop + ': ' + value + ';') + elif prop.lower() in self.allowed_svg_properties: + clean.append(prop + ': ' + value + ';') + + return ' '.join(clean) + +class HTMLSanitizer(HTMLTokenizer, HTMLSanitizerMixin): + def __init__(self, stream, encoding=None, parseMeta=True, useChardet=True, + lowercaseElementName=False, lowercaseAttrName=False, parser=None): + #Change case matching defaults as we only output lowercase html anyway + #This solution doesn't seem ideal... + HTMLTokenizer.__init__(self, stream, encoding, parseMeta, useChardet, + lowercaseElementName, lowercaseAttrName, parser=parser) + + def __iter__(self): + for token in HTMLTokenizer.__iter__(self): + token = self.sanitize_token(token) + if token: + yield token diff --git a/libs/html5lib/serializer/__init__.py b/libs/html5lib/serializer/__init__.py new file mode 100644 index 00000000..1b746655 --- /dev/null +++ b/libs/html5lib/serializer/__init__.py @@ -0,0 +1,17 @@ + +from html5lib import treewalkers + +from htmlserializer import HTMLSerializer +from xhtmlserializer import XHTMLSerializer + +def serialize(input, tree="simpletree", format="html", encoding=None, + **serializer_opts): + # XXX: Should we cache this? + walker = treewalkers.getTreeWalker(tree) + if format == "html": + s = HTMLSerializer(**serializer_opts) + elif format == "xhtml": + s = XHTMLSerializer(**serializer_opts) + else: + raise ValueError, "type must be either html or xhtml" + return s.render(walker(input), encoding) diff --git a/libs/html5lib/serializer/htmlserializer.py b/libs/html5lib/serializer/htmlserializer.py new file mode 100644 index 00000000..8dd0a815 --- /dev/null +++ b/libs/html5lib/serializer/htmlserializer.py @@ -0,0 +1,312 @@ +try: + frozenset +except NameError: + # Import from the sets module for python 2.3 + from sets import ImmutableSet as frozenset + +import gettext +_ = gettext.gettext + +from html5lib.constants import voidElements, booleanAttributes, spaceCharacters +from html5lib.constants import rcdataElements, entities, xmlEntities +from html5lib import utils +from xml.sax.saxutils import escape + +spaceCharacters = u"".join(spaceCharacters) + +try: + from codecs import register_error, xmlcharrefreplace_errors +except ImportError: + unicode_encode_errors = "strict" +else: + unicode_encode_errors = "htmlentityreplace" + + from html5lib.constants import entities + + encode_entity_map = {} + is_ucs4 = len(u"\U0010FFFF") == 1 + for k, v in entities.items(): + #skip multi-character entities + if ((is_ucs4 and len(v) > 1) or + (not is_ucs4 and len(v) > 2)): + continue + if v != "&": + if len(v) == 2: + v = utils.surrogatePairToCodepoint(v) + else: + try: + v = ord(v) + except: + print v + raise + if not v in encode_entity_map or k.islower(): + # prefer < over < and similarly for &, >, etc. + encode_entity_map[v] = k + + def htmlentityreplace_errors(exc): + if isinstance(exc, (UnicodeEncodeError, UnicodeTranslateError)): + res = [] + codepoints = [] + skip = False + for i, c in enumerate(exc.object[exc.start:exc.end]): + if skip: + skip = False + continue + index = i + exc.start + if utils.isSurrogatePair(exc.object[index:min([exc.end, index+2])]): + codepoint = utils.surrogatePairToCodepoint(exc.object[index:index+2]) + skip = True + else: + codepoint = ord(c) + codepoints.append(codepoint) + for cp in codepoints: + e = encode_entity_map.get(cp) + if e: + res.append("&") + res.append(e) + if not e.endswith(";"): + res.append(";") + else: + res.append("&#x%s;"%(hex(cp)[2:])) + return (u"".join(res), exc.end) + else: + return xmlcharrefreplace_errors(exc) + + register_error(unicode_encode_errors, htmlentityreplace_errors) + + del register_error + + +class HTMLSerializer(object): + + # attribute quoting options + quote_attr_values = False + quote_char = u'"' + use_best_quote_char = True + + # tag syntax options + omit_optional_tags = True + minimize_boolean_attributes = True + use_trailing_solidus = False + space_before_trailing_solidus = True + + # escaping options + escape_lt_in_attrs = False + escape_rcdata = False + resolve_entities = True + + # miscellaneous options + inject_meta_charset = True + strip_whitespace = False + sanitize = False + + options = ("quote_attr_values", "quote_char", "use_best_quote_char", + "minimize_boolean_attributes", "use_trailing_solidus", + "space_before_trailing_solidus", "omit_optional_tags", + "strip_whitespace", "inject_meta_charset", "escape_lt_in_attrs", + "escape_rcdata", "resolve_entities", "sanitize") + + def __init__(self, **kwargs): + """Initialize HTMLSerializer. + + Keyword options (default given first unless specified) include: + + inject_meta_charset=True|False + Whether it insert a meta element to define the character set of the + document. + quote_attr_values=True|False + Whether to quote attribute values that don't require quoting + per HTML5 parsing rules. + quote_char=u'"'|u"'" + Use given quote character for attribute quoting. Default is to + use double quote unless attribute value contains a double quote, + in which case single quotes are used instead. + escape_lt_in_attrs=False|True + Whether to escape < in attribute values. + escape_rcdata=False|True + Whether to escape characters that need to be escaped within normal + elements within rcdata elements such as style. + resolve_entities=True|False + Whether to resolve named character entities that appear in the + source tree. The XML predefined entities < > & " ' + are unaffected by this setting. + strip_whitespace=False|True + Whether to remove semantically meaningless whitespace. (This + compresses all whitespace to a single space except within pre.) + minimize_boolean_attributes=True|False + Shortens boolean attributes to give just the attribute value, + for example becomes . + use_trailing_solidus=False|True + Includes a close-tag slash at the end of the start tag of void + elements (empty elements whose end tag is forbidden). E.g.
. + space_before_trailing_solidus=True|False + Places a space immediately before the closing slash in a tag + using a trailing solidus. E.g.
. Requires use_trailing_solidus. + sanitize=False|True + Strip all unsafe or unknown constructs from output. + See `html5lib user documentation`_ + omit_optional_tags=True|False + Omit start/end tags that are optional. + + .. _html5lib user documentation: http://code.google.com/p/html5lib/wiki/UserDocumentation + """ + if kwargs.has_key('quote_char'): + self.use_best_quote_char = False + for attr in self.options: + setattr(self, attr, kwargs.get(attr, getattr(self, attr))) + self.errors = [] + self.strict = False + + def encode(self, string): + assert(isinstance(string, unicode)) + if self.encoding: + return string.encode(self.encoding, unicode_encode_errors) + else: + return string + + def encodeStrict(self, string): + assert(isinstance(string, unicode)) + if self.encoding: + return string.encode(self.encoding, "strict") + else: + return string + + def serialize(self, treewalker, encoding=None): + self.encoding = encoding + in_cdata = False + self.errors = [] + if encoding and self.inject_meta_charset: + from html5lib.filters.inject_meta_charset import Filter + treewalker = Filter(treewalker, encoding) + # XXX: WhitespaceFilter should be used before OptionalTagFilter + # for maximum efficiently of this latter filter + if self.strip_whitespace: + from html5lib.filters.whitespace import Filter + treewalker = Filter(treewalker) + if self.sanitize: + from html5lib.filters.sanitizer import Filter + treewalker = Filter(treewalker) + if self.omit_optional_tags: + from html5lib.filters.optionaltags import Filter + treewalker = Filter(treewalker) + for token in treewalker: + type = token["type"] + if type == "Doctype": + doctype = u"= 0: + if token["systemId"].find(u"'") >= 0: + self.serializeError(_("System identifer contains both single and double quote characters")) + quote_char = u"'" + else: + quote_char = u'"' + doctype += u" %s%s%s" % (quote_char, token["systemId"], quote_char) + + doctype += u">" + yield self.encodeStrict(doctype) + + elif type in ("Characters", "SpaceCharacters"): + if type == "SpaceCharacters" or in_cdata: + if in_cdata and token["data"].find("= 0: + self.serializeError(_("Unexpected \"'=", False) + v = v.replace(u"&", u"&") + if self.escape_lt_in_attrs: v = v.replace(u"<", u"<") + if quote_attr: + quote_char = self.quote_char + if self.use_best_quote_char: + if u"'" in v and u'"' not in v: + quote_char = u'"' + elif u'"' in v and u"'" not in v: + quote_char = u"'" + if quote_char == u"'": + v = v.replace(u"'", u"'") + else: + v = v.replace(u'"', u""") + yield self.encodeStrict(quote_char) + yield self.encode(v) + yield self.encodeStrict(quote_char) + else: + yield self.encode(v) + if name in voidElements and self.use_trailing_solidus: + if self.space_before_trailing_solidus: + yield self.encodeStrict(u" /") + else: + yield self.encodeStrict(u"/") + yield self.encode(u">") + + elif type == "EndTag": + name = token["name"] + if name in rcdataElements: + in_cdata = False + elif in_cdata: + self.serializeError(_("Unexpected child element of a CDATA element")) + yield self.encodeStrict(u"" % name) + + elif type == "Comment": + data = token["data"] + if data.find("--") >= 0: + self.serializeError(_("Comment contains --")) + yield self.encodeStrict(u"" % token["data"]) + + elif type == "Entity": + name = token["name"] + key = name + ";" + if not key in entities: + self.serializeError(_("Entity %s not recognized" % name)) + if self.resolve_entities and key not in xmlEntities: + data = entities[key] + else: + data = u"&%s;" % name + yield self.encodeStrict(data) + + else: + self.serializeError(token["data"]) + + def render(self, treewalker, encoding=None): + if encoding: + return "".join(list(self.serialize(treewalker, encoding))) + else: + return u"".join(list(self.serialize(treewalker))) + + def serializeError(self, data="XXX ERROR MESSAGE NEEDED"): + # XXX The idea is to make data mandatory. + self.errors.append(data) + if self.strict: + raise SerializeError + +def SerializeError(Exception): + """Error in serialized tree""" + pass diff --git a/libs/html5lib/serializer/xhtmlserializer.py b/libs/html5lib/serializer/xhtmlserializer.py new file mode 100644 index 00000000..7fdce47b --- /dev/null +++ b/libs/html5lib/serializer/xhtmlserializer.py @@ -0,0 +1,9 @@ +from htmlserializer import HTMLSerializer + +class XHTMLSerializer(HTMLSerializer): + quote_attr_values = True + minimize_boolean_attributes = False + use_trailing_solidus = True + escape_lt_in_attrs = True + omit_optional_tags = False + escape_rcdata = True diff --git a/libs/html5lib/tokenizer.py b/libs/html5lib/tokenizer.py new file mode 100644 index 00000000..7e9eca88 --- /dev/null +++ b/libs/html5lib/tokenizer.py @@ -0,0 +1,1744 @@ +try: + frozenset +except NameError: + # Import from the sets module for python 2.3 + from sets import Set as set + from sets import ImmutableSet as frozenset +try: + from collections import deque +except ImportError: + from utils import deque + +from constants import spaceCharacters +from constants import entitiesWindows1252, entities +from constants import asciiLowercase, asciiLetters, asciiUpper2Lower +from constants import digits, hexDigits, EOF +from constants import tokenTypes, tagTokenTypes +from constants import replacementCharacters + +from inputstream import HTMLInputStream + +# Group entities by their first character, for faster lookups +entitiesByFirstChar = {} +for e in entities: + entitiesByFirstChar.setdefault(e[0], []).append(e) + +class HTMLTokenizer(object): + """ This class takes care of tokenizing HTML. + + * self.currentToken + Holds the token that is currently being processed. + + * self.state + Holds a reference to the method to be invoked... XXX + + * self.stream + Points to HTMLInputStream object. + """ + + def __init__(self, stream, encoding=None, parseMeta=True, useChardet=True, + lowercaseElementName=True, lowercaseAttrName=True, parser=None): + + self.stream = HTMLInputStream(stream, encoding, parseMeta, useChardet) + self.parser = parser + + #Perform case conversions? + self.lowercaseElementName = lowercaseElementName + self.lowercaseAttrName = lowercaseAttrName + + # Setup the initial tokenizer state + self.escapeFlag = False + self.lastFourChars = [] + self.state = self.dataState + self.escape = False + + # The current token being created + self.currentToken = None + super(HTMLTokenizer, self).__init__() + + def __iter__(self): + """ This is where the magic happens. + + We do our usually processing through the states and when we have a token + to return we yield the token which pauses processing until the next token + is requested. + """ + self.tokenQueue = deque([]) + # Start processing. When EOF is reached self.state will return False + # instead of True and the loop will terminate. + while self.state(): + while self.stream.errors: + yield {"type": tokenTypes["ParseError"], "data": self.stream.errors.pop(0)} + while self.tokenQueue: + yield self.tokenQueue.popleft() + + def consumeNumberEntity(self, isHex): + """This function returns either U+FFFD or the character based on the + decimal or hexadecimal representation. It also discards ";" if present. + If not present self.tokenQueue.append({"type": tokenTypes["ParseError"]}) is invoked. + """ + + allowed = digits + radix = 10 + if isHex: + allowed = hexDigits + radix = 16 + + charStack = [] + + # Consume all the characters that are in range while making sure we + # don't hit an EOF. + c = self.stream.char() + while c in allowed and c is not EOF: + charStack.append(c) + c = self.stream.char() + + # Convert the set of characters consumed to an int. + charAsInt = int("".join(charStack), radix) + + # Certain characters get replaced with others + if charAsInt in replacementCharacters: + char = replacementCharacters[charAsInt] + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "illegal-codepoint-for-numeric-entity", + "datavars": {"charAsInt": charAsInt}}) + elif ((0xD800 <= charAsInt <= 0xDFFF) or + (charAsInt > 0x10FFFF)): + char = u"\uFFFD" + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "illegal-codepoint-for-numeric-entity", + "datavars": {"charAsInt": charAsInt}}) + else: + #Should speed up this check somehow (e.g. move the set to a constant) + if ((0x0001 <= charAsInt <= 0x0008) or + (0x000E <= charAsInt <= 0x001F) or + (0x007F <= charAsInt <= 0x009F) or + (0xFDD0 <= charAsInt <= 0xFDEF) or + charAsInt in frozenset([0x000B, 0xFFFE, 0xFFFF, 0x1FFFE, + 0x1FFFF, 0x2FFFE, 0x2FFFF, 0x3FFFE, + 0x3FFFF, 0x4FFFE, 0x4FFFF, 0x5FFFE, + 0x5FFFF, 0x6FFFE, 0x6FFFF, 0x7FFFE, + 0x7FFFF, 0x8FFFE, 0x8FFFF, 0x9FFFE, + 0x9FFFF, 0xAFFFE, 0xAFFFF, 0xBFFFE, + 0xBFFFF, 0xCFFFE, 0xCFFFF, 0xDFFFE, + 0xDFFFF, 0xEFFFE, 0xEFFFF, 0xFFFFE, + 0xFFFFF, 0x10FFFE, 0x10FFFF])): + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": + "illegal-codepoint-for-numeric-entity", + "datavars": {"charAsInt": charAsInt}}) + try: + # Try/except needed as UCS-2 Python builds' unichar only works + # within the BMP. + char = unichr(charAsInt) + except ValueError: + char = eval("u'\\U%08x'" % charAsInt) + + # Discard the ; if present. Otherwise, put it back on the queue and + # invoke parseError on parser. + if c != u";": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "numeric-entity-without-semicolon"}) + self.stream.unget(c) + + return char + + def consumeEntity(self, allowedChar=None, fromAttribute=False): + # Initialise to the default output for when no entity is matched + output = u"&" + + charStack = [self.stream.char()] + if (charStack[0] in spaceCharacters or charStack[0] in (EOF, u"<", u"&") + or (allowedChar is not None and allowedChar == charStack[0])): + self.stream.unget(charStack[0]) + + elif charStack[0] == u"#": + # Read the next character to see if it's hex or decimal + hex = False + charStack.append(self.stream.char()) + if charStack[-1] in (u"x", u"X"): + hex = True + charStack.append(self.stream.char()) + + # charStack[-1] should be the first digit + if (hex and charStack[-1] in hexDigits) \ + or (not hex and charStack[-1] in digits): + # At least one digit found, so consume the whole number + self.stream.unget(charStack[-1]) + output = self.consumeNumberEntity(hex) + else: + # No digits found + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "expected-numeric-entity"}) + self.stream.unget(charStack.pop()) + output = u"&" + u"".join(charStack) + + else: + # At this point in the process might have named entity. Entities + # are stored in the global variable "entities". + # + # Consume characters and compare to these to a substring of the + # entity names in the list until the substring no longer matches. + filteredEntityList = entitiesByFirstChar.get(charStack[0], []) + + def entitiesStartingWith(name): + return [e for e in filteredEntityList if e.startswith(name)] + + while (charStack[-1] is not EOF and + entitiesStartingWith("".join(charStack))): + charStack.append(self.stream.char()) + + # At this point we have a string that starts with some characters + # that may match an entity + entityName = None + + # Try to find the longest entity the string will match to take care + # of ¬i for instance. + for entityLength in xrange(len(charStack)-1, 1, -1): + possibleEntityName = "".join(charStack[:entityLength]) + if possibleEntityName in entities: + entityName = possibleEntityName + break + + if entityName is not None: + if entityName[-1] != ";": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "named-entity-without-semicolon"}) + if (entityName[-1] != ";" and fromAttribute and + (charStack[entityLength] in asciiLetters or + charStack[entityLength] in digits or + charStack[entityLength] == "=")): + self.stream.unget(charStack.pop()) + output = u"&" + u"".join(charStack) + else: + output = entities[entityName] + self.stream.unget(charStack.pop()) + output += u"".join(charStack[entityLength:]) + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-named-entity"}) + self.stream.unget(charStack.pop()) + output = u"&" + u"".join(charStack) + + if fromAttribute: + self.currentToken["data"][-1][1] += output + else: + if output in spaceCharacters: + tokenType = "SpaceCharacters" + else: + tokenType = "Characters" + self.tokenQueue.append({"type": tokenTypes[tokenType], "data": output}) + + def processEntityInAttribute(self, allowedChar): + """This method replaces the need for "entityInAttributeValueState". + """ + self.consumeEntity(allowedChar=allowedChar, fromAttribute=True) + + def emitCurrentToken(self): + """This method is a generic handler for emitting the tags. It also sets + the state to "data" because that's what's needed after a token has been + emitted. + """ + token = self.currentToken + # Add token to the queue to be yielded + if (token["type"] in tagTokenTypes): + if self.lowercaseElementName: + token["name"] = token["name"].translate(asciiUpper2Lower) + if token["type"] == tokenTypes["EndTag"]: + if token["data"]: + self.tokenQueue.append({"type":tokenTypes["ParseError"], + "data":"attributes-in-end-tag"}) + if token["selfClosing"]: + self.tokenQueue.append({"type":tokenTypes["ParseError"], + "data":"self-closing-flag-on-end-tag"}) + self.tokenQueue.append(token) + self.state = self.dataState + + + # Below are the various tokenizer states worked out. + + def dataState(self): + data = self.stream.char() + if data == "&": + self.state = self.entityDataState + elif data == "<": + self.state = self.tagOpenState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data":"invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\u0000"}) + elif data is EOF: + # Tokenization ends. + return False + elif data in spaceCharacters: + # Directly after emitting a token you switch back to the "data + # state". At that point spaceCharacters are important so they are + # emitted separately. + self.tokenQueue.append({"type": tokenTypes["SpaceCharacters"], "data": + data + self.stream.charsUntil(spaceCharacters, True)}) + # No need to update lastFourChars here, since the first space will + # have already been appended to lastFourChars and will have broken + # any sequences + else: + chars = self.stream.charsUntil((u"&", u"<", u"\u0000")) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": + data + chars}) + return True + + def entityDataState(self): + self.consumeEntity() + self.state = self.dataState + return True + + def rcdataState(self): + data = self.stream.char() + if data == "&": + self.state = self.characterReferenceInRcdata + elif data == "<": + self.state = self.rcdataLessThanSignState + elif data == EOF: + # Tokenization ends. + return False + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + elif data in spaceCharacters: + # Directly after emitting a token you switch back to the "data + # state". At that point spaceCharacters are important so they are + # emitted separately. + self.tokenQueue.append({"type": tokenTypes["SpaceCharacters"], "data": + data + self.stream.charsUntil(spaceCharacters, True)}) + # No need to update lastFourChars here, since the first space will + # have already been appended to lastFourChars and will have broken + # any sequences + else: + chars = self.stream.charsUntil((u"&", u"<")) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": + data + chars}) + return True + + def characterReferenceInRcdata(self): + self.consumeEntity() + self.state = self.rcdataState + return True + + def rawtextState(self): + data = self.stream.char() + if data == "<": + self.state = self.rawtextLessThanSignState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + elif data == EOF: + # Tokenization ends. + return False + else: + chars = self.stream.charsUntil((u"<", u"\u0000")) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": + data + chars}) + return True + + def scriptDataState(self): + data = self.stream.char() + if data == "<": + self.state = self.scriptDataLessThanSignState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + elif data == EOF: + # Tokenization ends. + return False + else: + chars = self.stream.charsUntil((u"<", u"\u0000")) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": + data + chars}) + return True + + def plaintextState(self): + data = self.stream.char() + if data == EOF: + # Tokenization ends. + return False + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": + data + self.stream.charsUntil(u"\u0000")}) + return True + + def tagOpenState(self): + data = self.stream.char() + if data == u"!": + self.state = self.markupDeclarationOpenState + elif data == u"/": + self.state = self.closeTagOpenState + elif data in asciiLetters: + self.currentToken = {"type": tokenTypes["StartTag"], + "name": data, "data": [], + "selfClosing": False, + "selfClosingAcknowledged": False} + self.state = self.tagNameState + elif data == u">": + # XXX In theory it could be something besides a tag name. But + # do we really care? + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-tag-name-but-got-right-bracket"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<>"}) + self.state = self.dataState + elif data == u"?": + # XXX In theory it could be something besides a tag name. But + # do we really care? + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-tag-name-but-got-question-mark"}) + self.stream.unget(data) + self.state = self.bogusCommentState + else: + # XXX + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-tag-name"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"}) + self.stream.unget(data) + self.state = self.dataState + return True + + def closeTagOpenState(self): + data = self.stream.char() + if data in asciiLetters: + self.currentToken = {"type": tokenTypes["EndTag"], "name": data, + "data": [], "selfClosing":False} + self.state = self.tagNameState + elif data == u">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-closing-tag-but-got-right-bracket"}) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-closing-tag-but-got-eof"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"": + self.emitCurrentToken() + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-tag-name"}) + self.state = self.dataState + elif data == u"/": + self.state = self.selfClosingStartTagState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["name"] += u"\uFFFD" + else: + self.currentToken["name"] += data + # (Don't use charsUntil here, because tag names are + # very short and it's faster to not do anything fancy) + return True + + def rcdataLessThanSignState(self): + data = self.stream.char() + if data == "/": + self.temporaryBuffer = "" + self.state = self.rcdataEndTagOpenState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"}) + self.stream.unget(data) + self.state = self.rcdataState + return True + + def rcdataEndTagOpenState(self): + data = self.stream.char() + if data in asciiLetters: + self.temporaryBuffer += data + self.state = self.rcdataEndTagNameState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"" and appropriate: + self.currentToken = {"type": tokenTypes["EndTag"], + "name": self.temporaryBuffer, + "data": [], "selfClosing":False} + self.emitCurrentToken() + self.state = self.dataState + elif data in asciiLetters: + self.temporaryBuffer += data + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"" and appropriate: + self.currentToken = {"type": tokenTypes["EndTag"], + "name": self.temporaryBuffer, + "data": [], "selfClosing":False} + self.emitCurrentToken() + self.state = self.dataState + elif data in asciiLetters: + self.temporaryBuffer += data + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"" and appropriate: + self.currentToken = {"type": tokenTypes["EndTag"], + "name": self.temporaryBuffer, + "data": [], "selfClosing":False} + self.emitCurrentToken() + self.state = self.dataState + elif data in asciiLetters: + self.temporaryBuffer += data + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u">"}) + self.state = self.scriptDataState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + self.state = self.scriptDataEscapedState + elif data == EOF: + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + self.state = self.scriptDataEscapedState + return True + + def scriptDataEscapedLessThanSignState(self): + data = self.stream.char() + if data == "/": + self.temporaryBuffer = "" + self.state = self.scriptDataEscapedEndTagOpenState + elif data in asciiLetters: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<" + data}) + self.temporaryBuffer = data + self.state = self.scriptDataDoubleEscapeStartState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"}) + self.stream.unget(data) + self.state = self.scriptDataEscapedState + return True + + def scriptDataEscapedEndTagOpenState(self): + data = self.stream.char() + if data in asciiLetters: + self.temporaryBuffer = data + self.state = self.scriptDataEscapedEndTagNameState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"" and appropriate: + self.currentToken = {"type": tokenTypes["EndTag"], + "name": self.temporaryBuffer, + "data": [], "selfClosing":False} + self.emitCurrentToken() + self.state = self.dataState + elif data in asciiLetters: + self.temporaryBuffer += data + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u""))): + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + if self.temporaryBuffer.lower() == "script": + self.state = self.scriptDataDoubleEscapedState + else: + self.state = self.scriptDataEscapedState + elif data in asciiLetters: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + self.temporaryBuffer += data + else: + self.stream.unget(data) + self.state = self.scriptDataEscapedState + return True + + def scriptDataDoubleEscapedState(self): + data = self.stream.char() + if data == "-": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"}) + self.state = self.scriptDataDoubleEscapedDashState + elif data == "<": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"}) + self.state = self.scriptDataDoubleEscapedLessThanSignState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + elif data == EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-script-in-script"}) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + return True + + def scriptDataDoubleEscapedDashState(self): + data = self.stream.char() + if data == "-": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"}) + self.state = self.scriptDataDoubleEscapedDashDashState + elif data == "<": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"}) + self.state = self.scriptDataDoubleEscapedLessThanSignState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + self.state = self.scriptDataDoubleEscapedState + elif data == EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-script-in-script"}) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + self.state = self.scriptDataDoubleEscapedState + return True + + def scriptDataDoubleEscapedDashState(self): + data = self.stream.char() + if data == "-": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"}) + elif data == "<": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"}) + self.state = self.scriptDataDoubleEscapedLessThanSignState + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u">"}) + self.state = self.scriptDataState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": u"\uFFFD"}) + self.state = self.scriptDataDoubleEscapedState + elif data == EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-script-in-script"}) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + self.state = self.scriptDataDoubleEscapedState + return True + + def scriptDataDoubleEscapedLessThanSignState(self): + data = self.stream.char() + if data == "/": + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"/"}) + self.temporaryBuffer = "" + self.state = self.scriptDataDoubleEscapeEndState + else: + self.stream.unget(data) + self.state = self.scriptDataDoubleEscapedState + return True + + def scriptDataDoubleEscapeEndState(self): + data = self.stream.char() + if data in (spaceCharacters | frozenset(("/", ">"))): + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + if self.temporaryBuffer.lower() == "script": + self.state = self.scriptDataEscapedState + else: + self.state = self.scriptDataDoubleEscapedState + elif data in asciiLetters: + self.tokenQueue.append({"type": tokenTypes["Characters"], "data": data}) + self.temporaryBuffer += data + else: + self.stream.unget(data) + self.state = self.scriptDataDoubleEscapedState + return True + + def beforeAttributeNameState(self): + data = self.stream.char() + if data in spaceCharacters: + self.stream.charsUntil(spaceCharacters, True) + elif data in asciiLetters: + self.currentToken["data"].append([data, ""]) + self.state = self.attributeNameState + elif data == u">": + self.emitCurrentToken() + elif data == u"/": + self.state = self.selfClosingStartTagState + elif data in (u"'", u'"', u"=", u"<"): + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "invalid-character-in-attribute-name"}) + self.currentToken["data"].append([data, ""]) + self.state = self.attributeNameState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"].append([u"\uFFFD", ""]) + self.state = self.attributeNameState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-attribute-name-but-got-eof"}) + self.state = self.dataState + else: + self.currentToken["data"].append([data, ""]) + self.state = self.attributeNameState + return True + + def attributeNameState(self): + data = self.stream.char() + leavingThisState = True + emitToken = False + if data == u"=": + self.state = self.beforeAttributeValueState + elif data in asciiLetters: + self.currentToken["data"][-1][0] += data +\ + self.stream.charsUntil(asciiLetters, True) + leavingThisState = False + elif data == u">": + # XXX If we emit here the attributes are converted to a dict + # without being checked and when the code below runs we error + # because data is a dict not a list + emitToken = True + elif data in spaceCharacters: + self.state = self.afterAttributeNameState + elif data == u"/": + self.state = self.selfClosingStartTagState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"][-1][0] += u"\uFFFD" + leavingThisState = False + elif data in (u"'", u'"', u"<"): + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": + "invalid-character-in-attribute-name"}) + self.currentToken["data"][-1][0] += data + leavingThisState = False + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "eof-in-attribute-name"}) + self.state = self.dataState + else: + self.currentToken["data"][-1][0] += data + leavingThisState = False + + if leavingThisState: + # Attributes are not dropped at this stage. That happens when the + # start tag token is emitted so values can still be safely appended + # to attributes, but we do want to report the parse error in time. + if self.lowercaseAttrName: + self.currentToken["data"][-1][0] = ( + self.currentToken["data"][-1][0].translate(asciiUpper2Lower)) + for name, value in self.currentToken["data"][:-1]: + if self.currentToken["data"][-1][0] == name: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "duplicate-attribute"}) + break + # XXX Fix for above XXX + if emitToken: + self.emitCurrentToken() + return True + + def afterAttributeNameState(self): + data = self.stream.char() + if data in spaceCharacters: + self.stream.charsUntil(spaceCharacters, True) + elif data == u"=": + self.state = self.beforeAttributeValueState + elif data == u">": + self.emitCurrentToken() + elif data in asciiLetters: + self.currentToken["data"].append([data, ""]) + self.state = self.attributeNameState + elif data == u"/": + self.state = self.selfClosingStartTagState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"].append([u"\uFFFD", ""]) + self.state = self.attributeNameState + elif data in (u"'", u'"', u"<"): + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "invalid-character-after-attribute-name"}) + self.currentToken["data"].append([data, ""]) + self.state = self.attributeNameState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-end-of-tag-but-got-eof"}) + self.state = self.dataState + else: + self.currentToken["data"].append([data, ""]) + self.state = self.attributeNameState + return True + + def beforeAttributeValueState(self): + data = self.stream.char() + if data in spaceCharacters: + self.stream.charsUntil(spaceCharacters, True) + elif data == u"\"": + self.state = self.attributeValueDoubleQuotedState + elif data == u"&": + self.state = self.attributeValueUnQuotedState + self.stream.unget(data); + elif data == u"'": + self.state = self.attributeValueSingleQuotedState + elif data == u">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-attribute-value-but-got-right-bracket"}) + self.emitCurrentToken() + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"][-1][1] += u"\uFFFD" + self.state = self.attributeValueUnQuotedState + elif data in (u"=", u"<", u"`"): + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "equals-in-unquoted-attribute-value"}) + self.currentToken["data"][-1][1] += data + self.state = self.attributeValueUnQuotedState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-attribute-value-but-got-eof"}) + self.state = self.dataState + else: + self.currentToken["data"][-1][1] += data + self.state = self.attributeValueUnQuotedState + return True + + def attributeValueDoubleQuotedState(self): + data = self.stream.char() + if data == "\"": + self.state = self.afterAttributeValueState + elif data == u"&": + self.processEntityInAttribute(u'"') + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"][-1][1] += u"\uFFFD" + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-attribute-value-double-quote"}) + self.state = self.dataState + else: + self.currentToken["data"][-1][1] += data +\ + self.stream.charsUntil(("\"", u"&")) + return True + + def attributeValueSingleQuotedState(self): + data = self.stream.char() + if data == "'": + self.state = self.afterAttributeValueState + elif data == u"&": + self.processEntityInAttribute(u"'") + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"][-1][1] += u"\uFFFD" + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-attribute-value-single-quote"}) + self.state = self.dataState + else: + self.currentToken["data"][-1][1] += data +\ + self.stream.charsUntil(("'", u"&")) + return True + + def attributeValueUnQuotedState(self): + data = self.stream.char() + if data in spaceCharacters: + self.state = self.beforeAttributeNameState + elif data == u"&": + self.processEntityInAttribute(">") + elif data == u">": + self.emitCurrentToken() + elif data in (u'"', u"'", u"=", u"<", u"`"): + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-character-in-unquoted-attribute-value"}) + self.currentToken["data"][-1][1] += data + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"][-1][1] += u"\uFFFD" + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-attribute-value-no-quotes"}) + self.state = self.dataState + else: + self.currentToken["data"][-1][1] += data + self.stream.charsUntil( + frozenset((u"&", u">", u'"', u"'", u"=", u"<", u"`")) | spaceCharacters) + return True + + def afterAttributeValueState(self): + data = self.stream.char() + if data in spaceCharacters: + self.state = self.beforeAttributeNameState + elif data == u">": + self.emitCurrentToken() + elif data == u"/": + self.state = self.selfClosingStartTagState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-EOF-after-attribute-value"}) + self.stream.unget(data) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-character-after-attribute-value"}) + self.stream.unget(data) + self.state = self.beforeAttributeNameState + return True + + def selfClosingStartTagState(self): + data = self.stream.char() + if data == ">": + self.currentToken["selfClosing"] = True + self.emitCurrentToken() + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": + "unexpected-EOF-after-solidus-in-tag"}) + self.stream.unget(data) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-character-after-soldius-in-tag"}) + self.stream.unget(data) + self.state = self.beforeAttributeNameState + return True + + def bogusCommentState(self): + # Make a new comment token and give it as value all the characters + # until the first > or EOF (charsUntil checks for EOF automatically) + # and emit it. + data = self.stream.charsUntil(u">") + data = data.replace(u"\u0000", u"\uFFFD") + self.tokenQueue.append( + {"type": tokenTypes["Comment"], "data": data}) + + # Eat the character directly after the bogus comment which is either a + # ">" or an EOF. + self.stream.char() + self.state = self.dataState + return True + + def markupDeclarationOpenState(self): + charStack = [self.stream.char()] + if charStack[-1] == u"-": + charStack.append(self.stream.char()) + if charStack[-1] == u"-": + self.currentToken = {"type": tokenTypes["Comment"], "data": u""} + self.state = self.commentStartState + return True + elif charStack[-1] in (u'd', u'D'): + matched = True + for expected in ((u'o', u'O'), (u'c', u'C'), (u't', u'T'), + (u'y', u'Y'), (u'p', u'P'), (u'e', u'E')): + charStack.append(self.stream.char()) + if charStack[-1] not in expected: + matched = False + break + if matched: + self.currentToken = {"type": tokenTypes["Doctype"], + "name": u"", + "publicId": None, "systemId": None, + "correct": True} + self.state = self.doctypeState + return True + elif (charStack[-1] == "[" and + self.parser is not None and + self.parser.tree.openElements and + self.parser.tree.openElements[-1].namespace != self.parser.tree.defaultNamespace): + matched = True + for expected in ["C", "D", "A", "T", "A", "["]: + charStack.append(self.stream.char()) + if charStack[-1] != expected: + matched = False + break + if matched: + self.state = self.cdataSectionState + return True + + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-dashes-or-doctype"}) + + while charStack: + self.stream.unget(charStack.pop()) + self.state = self.bogusCommentState + return True + + def commentStartState(self): + data = self.stream.char() + if data == "-": + self.state = self.commentStartDashState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"] += u"\uFFFD" + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "incorrect-comment"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-comment"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["data"] += data + self.state = self.commentState + return True + + def commentStartDashState(self): + data = self.stream.char() + if data == "-": + self.state = self.commentEndState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"] += u"-\uFFFD" + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "incorrect-comment"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-comment"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["data"] += "-" + data + self.state = self.commentState + return True + + + def commentState(self): + data = self.stream.char() + if data == u"-": + self.state = self.commentEndDashState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"] += u"\uFFFD" + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "eof-in-comment"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["data"] += data + \ + self.stream.charsUntil((u"-", u"\u0000")) + return True + + def commentEndDashState(self): + data = self.stream.char() + if data == u"-": + self.state = self.commentEndState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"] += u"-\uFFFD" + self.state = self.commentState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-comment-end-dash"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["data"] += u"-" + data + self.state = self.commentState + return True + + def commentEndState(self): + data = self.stream.char() + if data == u">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"] += u"--\uFFFD" + self.state = self.commentState + elif data == "!": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-bang-after-double-dash-in-comment"}) + self.state = self.commentEndBangState + elif data == u"-": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-dash-after-double-dash-in-comment"}) + self.currentToken["data"] += data + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-comment-double-dash"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + # XXX + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-comment"}) + self.currentToken["data"] += u"--" + data + self.state = self.commentState + return True + + def commentEndBangState(self): + data = self.stream.char() + if data == u">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data == u"-": + self.currentToken["data"] += "--!" + self.state = self.commentEndDashState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["data"] += u"--!\uFFFD" + self.state = self.commentState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-comment-end-bang-state"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["data"] += u"--!" + data + self.state = self.commentState + return True + + def doctypeState(self): + data = self.stream.char() + if data in spaceCharacters: + self.state = self.beforeDoctypeNameState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-doctype-name-but-got-eof"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "need-space-after-doctype"}) + self.stream.unget(data) + self.state = self.beforeDoctypeNameState + return True + + def beforeDoctypeNameState(self): + data = self.stream.char() + if data in spaceCharacters: + pass + elif data == u">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-doctype-name-but-got-right-bracket"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["name"] = u"\uFFFD" + self.state = self.doctypeNameState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-doctype-name-but-got-eof"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["name"] = data + self.state = self.doctypeNameState + return True + + def doctypeNameState(self): + data = self.stream.char() + if data in spaceCharacters: + self.currentToken["name"] = self.currentToken["name"].translate(asciiUpper2Lower) + self.state = self.afterDoctypeNameState + elif data == u">": + self.currentToken["name"] = self.currentToken["name"].translate(asciiUpper2Lower) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["name"] += u"\uFFFD" + self.state = self.doctypeNameState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype-name"}) + self.currentToken["correct"] = False + self.currentToken["name"] = self.currentToken["name"].translate(asciiUpper2Lower) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["name"] += data + return True + + def afterDoctypeNameState(self): + data = self.stream.char() + if data in spaceCharacters: + pass + elif data == u">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.currentToken["correct"] = False + self.stream.unget(data) + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + if data in (u"p", u"P"): + matched = True + for expected in ((u"u", u"U"), (u"b", u"B"), (u"l", u"L"), + (u"i", u"I"), (u"c", u"C")): + data = self.stream.char() + if data not in expected: + matched = False + break + if matched: + self.state = self.afterDoctypePublicKeywordState + return True + elif data in (u"s", u"S"): + matched = True + for expected in ((u"y", u"Y"), (u"s", u"S"), (u"t", u"T"), + (u"e", u"E"), (u"m", u"M")): + data = self.stream.char() + if data not in expected: + matched = False + break + if matched: + self.state = self.afterDoctypeSystemKeywordState + return True + + # All the characters read before the current 'data' will be + # [a-zA-Z], so they're garbage in the bogus doctype and can be + # discarded; only the latest character might be '>' or EOF + # and needs to be ungetted + self.stream.unget(data) + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "expected-space-or-right-bracket-in-doctype", "datavars": + {"data": data}}) + self.currentToken["correct"] = False + self.state = self.bogusDoctypeState + + return True + + def afterDoctypePublicKeywordState(self): + data = self.stream.char() + if data in spaceCharacters: + self.state = self.beforeDoctypePublicIdentifierState + elif data in ("'", '"'): + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.stream.unget(data) + self.state = self.beforeDoctypePublicIdentifierState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.stream.unget(data) + self.state = self.beforeDoctypePublicIdentifierState + return True + + def beforeDoctypePublicIdentifierState(self): + data = self.stream.char() + if data in spaceCharacters: + pass + elif data == "\"": + self.currentToken["publicId"] = u"" + self.state = self.doctypePublicIdentifierDoubleQuotedState + elif data == "'": + self.currentToken["publicId"] = u"" + self.state = self.doctypePublicIdentifierSingleQuotedState + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-end-of-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["correct"] = False + self.state = self.bogusDoctypeState + return True + + def doctypePublicIdentifierDoubleQuotedState(self): + data = self.stream.char() + if data == "\"": + self.state = self.afterDoctypePublicIdentifierState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["publicId"] += u"\uFFFD" + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-end-of-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["publicId"] += data + return True + + def doctypePublicIdentifierSingleQuotedState(self): + data = self.stream.char() + if data == "'": + self.state = self.afterDoctypePublicIdentifierState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["publicId"] += u"\uFFFD" + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-end-of-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["publicId"] += data + return True + + def afterDoctypePublicIdentifierState(self): + data = self.stream.char() + if data in spaceCharacters: + self.state = self.betweenDoctypePublicAndSystemIdentifiersState + elif data == ">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data == '"': + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["systemId"] = u"" + self.state = self.doctypeSystemIdentifierDoubleQuotedState + elif data == "'": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["systemId"] = u"" + self.state = self.doctypeSystemIdentifierSingleQuotedState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["correct"] = False + self.state = self.bogusDoctypeState + return True + + def betweenDoctypePublicAndSystemIdentifiersState(self): + data = self.stream.char() + if data in spaceCharacters: + pass + elif data == ">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data == '"': + self.currentToken["systemId"] = u"" + self.state = self.doctypeSystemIdentifierDoubleQuotedState + elif data == "'": + self.currentToken["systemId"] = u"" + self.state = self.doctypeSystemIdentifierSingleQuotedState + elif data == EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["correct"] = False + self.state = self.bogusDoctypeState + return True + + def afterDoctypeSystemKeywordState(self): + data = self.stream.char() + if data in spaceCharacters: + self.state = self.beforeDoctypeSystemIdentifierState + elif data in ("'", '"'): + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.stream.unget(data) + self.state = self.beforeDoctypeSystemIdentifierState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.stream.unget(data) + self.state = self.beforeDoctypeSystemIdentifierState + return True + + def beforeDoctypeSystemIdentifierState(self): + data = self.stream.char() + if data in spaceCharacters: + pass + elif data == "\"": + self.currentToken["systemId"] = u"" + self.state = self.doctypeSystemIdentifierDoubleQuotedState + elif data == "'": + self.currentToken["systemId"] = u"" + self.state = self.doctypeSystemIdentifierSingleQuotedState + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.currentToken["correct"] = False + self.state = self.bogusDoctypeState + return True + + def doctypeSystemIdentifierDoubleQuotedState(self): + data = self.stream.char() + if data == "\"": + self.state = self.afterDoctypeSystemIdentifierState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["systemId"] += u"\uFFFD" + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-end-of-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["systemId"] += data + return True + + def doctypeSystemIdentifierSingleQuotedState(self): + data = self.stream.char() + if data == "'": + self.state = self.afterDoctypeSystemIdentifierState + elif data == u"\u0000": + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + self.currentToken["systemId"] += u"\uFFFD" + elif data == ">": + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-end-of-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.currentToken["systemId"] += data + return True + + def afterDoctypeSystemIdentifierState(self): + data = self.stream.char() + if data in spaceCharacters: + pass + elif data == ">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "eof-in-doctype"}) + self.currentToken["correct"] = False + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + self.tokenQueue.append({"type": tokenTypes["ParseError"], "data": + "unexpected-char-in-doctype"}) + self.state = self.bogusDoctypeState + return True + + def bogusDoctypeState(self): + data = self.stream.char() + if data == u">": + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + elif data is EOF: + # XXX EMIT + self.stream.unget(data) + self.tokenQueue.append(self.currentToken) + self.state = self.dataState + else: + pass + return True + + def cdataSectionState(self): + data = [] + while True: + data.append(self.stream.charsUntil(u"]")) + charStack = [] + + for expected in ["]", "]", ">"]: + charStack.append(self.stream.char()) + matched = True + if charStack[-1] == EOF: + data.extend(charStack[:-1]) + break + elif charStack[-1] != expected: + matched = False + data.extend(charStack) + break + + if matched: + break + data = "".join(data) + #Deal with null here rather than in the parser + nullCount = data.count(u"\u0000") + if nullCount > 0: + for i in xrange(nullCount): + self.tokenQueue.append({"type": tokenTypes["ParseError"], + "data": "invalid-codepoint"}) + data = data.replace(u"\u0000", u"\uFFFD") + if data: + self.tokenQueue.append({"type": tokenTypes["Characters"], + "data": data}) + self.state = self.dataState + return True diff --git a/libs/html5lib/treebuilders/__init__.py b/libs/html5lib/treebuilders/__init__.py new file mode 100755 index 00000000..14f66d40 --- /dev/null +++ b/libs/html5lib/treebuilders/__init__.py @@ -0,0 +1,96 @@ +"""A collection of modules for building different kinds of tree from +HTML documents. + +To create a treebuilder for a new type of tree, you need to do +implement several things: + +1) A set of classes for various types of elements: Document, Doctype, +Comment, Element. These must implement the interface of +_base.treebuilders.Node (although comment nodes have a different +signature for their constructor, see treebuilders.simpletree.Comment) +Textual content may also be implemented as another node type, or not, as +your tree implementation requires. + +2) A treebuilder object (called TreeBuilder by convention) that +inherits from treebuilders._base.TreeBuilder. This has 4 required attributes: +documentClass - the class to use for the bottommost node of a document +elementClass - the class to use for HTML Elements +commentClass - the class to use for comments +doctypeClass - the class to use for doctypes +It also has one required method: +getDocument - Returns the root node of the complete document tree + +3) If you wish to run the unit tests, you must also create a +testSerializer method on your treebuilder which accepts a node and +returns a string containing Node and its children serialized according +to the format used in the unittests + +The supplied simpletree module provides a python-only implementation +of a full treebuilder and is a useful reference for the semantics of +the various methods. +""" + +treeBuilderCache = {} + +import sys + +def getTreeBuilder(treeType, implementation=None, **kwargs): + """Get a TreeBuilder class for various types of tree with built-in support + + treeType - the name of the tree type required (case-insensitive). Supported + values are "simpletree", "dom", "etree" and "beautifulsoup" + + "simpletree" - a built-in DOM-ish tree type with support for some + more pythonic idioms. + "dom" - A generic builder for DOM implementations, defaulting to + a xml.dom.minidom based implementation for the sake of + backwards compatibility (as releases up until 0.10 had a + builder called "dom" that was a minidom implemenation). + "etree" - A generic builder for tree implementations exposing an + elementtree-like interface (known to work with + ElementTree, cElementTree and lxml.etree). + "beautifulsoup" - Beautiful soup (if installed) + + implementation - (Currently applies to the "etree" and "dom" tree types). A + module implementing the tree type e.g. + xml.etree.ElementTree or lxml.etree.""" + + treeType = treeType.lower() + if treeType not in treeBuilderCache: + if treeType == "dom": + import dom + # XXX: Keep backwards compatibility by using minidom if no implementation is given + if implementation == None: + from xml.dom import minidom + implementation = minidom + # XXX: NEVER cache here, caching is done in the dom submodule + return dom.getDomModule(implementation, **kwargs).TreeBuilder + elif treeType == "simpletree": + import simpletree + treeBuilderCache[treeType] = simpletree.TreeBuilder + elif treeType == "beautifulsoup": + import soup + treeBuilderCache[treeType] = soup.TreeBuilder + elif treeType == "lxml": + import etree_lxml + treeBuilderCache[treeType] = etree_lxml.TreeBuilder + elif treeType == "etree": + # Come up with a sane default + if implementation == None: + try: + import xml.etree.cElementTree as ET + except ImportError: + try: + import xml.etree.ElementTree as ET + except ImportError: + try: + import cElementTree as ET + except ImportError: + import elementtree.ElementTree as ET + implementation = ET + import etree + # NEVER cache here, caching is done in the etree submodule + return etree.getETreeModule(implementation, **kwargs).TreeBuilder + else: + raise ValueError("""Unrecognised treebuilder "%s" """%treeType) + return treeBuilderCache.get(treeType) diff --git a/libs/html5lib/treebuilders/_base.py b/libs/html5lib/treebuilders/_base.py new file mode 100755 index 00000000..f3782d28 --- /dev/null +++ b/libs/html5lib/treebuilders/_base.py @@ -0,0 +1,377 @@ +from html5lib.constants import scopingElements, tableInsertModeElements, namespaces +try: + frozenset +except NameError: + # Import from the sets module for python 2.3 + from sets import Set as set + from sets import ImmutableSet as frozenset + +# The scope markers are inserted when entering object elements, +# marquees, table cells, and table captions, and are used to prevent formatting +# from "leaking" into tables, object elements, and marquees. +Marker = None + +class Node(object): + def __init__(self, name): + """Node representing an item in the tree. + name - The tag name associated with the node + parent - The parent of the current node (or None for the document node) + value - The value of the current node (applies to text nodes and + comments + attributes - a dict holding name, value pairs for attributes of the node + childNodes - a list of child nodes of the current node. This must + include all elements but not necessarily other node types + _flags - A list of miscellaneous flags that can be set on the node + """ + self.name = name + self.parent = None + self.value = None + self.attributes = {} + self.childNodes = [] + self._flags = [] + + def __unicode__(self): + attributesStr = " ".join(["%s=\"%s\""%(name, value) + for name, value in + self.attributes.iteritems()]) + if attributesStr: + return "<%s %s>"%(self.name,attributesStr) + else: + return "<%s>"%(self.name) + + def __repr__(self): + return "<%s>" % (self.name) + + def appendChild(self, node): + """Insert node as a child of the current node + """ + raise NotImplementedError + + def insertText(self, data, insertBefore=None): + """Insert data as text in the current node, positioned before the + start of node insertBefore or to the end of the node's text. + """ + raise NotImplementedError + + def insertBefore(self, node, refNode): + """Insert node as a child of the current node, before refNode in the + list of child nodes. Raises ValueError if refNode is not a child of + the current node""" + raise NotImplementedError + + def removeChild(self, node): + """Remove node from the children of the current node + """ + raise NotImplementedError + + def reparentChildren(self, newParent): + """Move all the children of the current node to newParent. + This is needed so that trees that don't store text as nodes move the + text in the correct way + """ + #XXX - should this method be made more general? + for child in self.childNodes: + newParent.appendChild(child) + self.childNodes = [] + + def cloneNode(self): + """Return a shallow copy of the current node i.e. a node with the same + name and attributes but with no parent or child nodes + """ + raise NotImplementedError + + + def hasContent(self): + """Return true if the node has children or text, false otherwise + """ + raise NotImplementedError + +class ActiveFormattingElements(list): + def append(self, node): + equalCount = 0 + if node != Marker: + for element in self[::-1]: + if element == Marker: + break + if self.nodesEqual(element, node): + equalCount += 1 + if equalCount == 3: + self.remove(element) + break + list.append(self, node) + + def nodesEqual(self, node1, node2): + if not node1.nameTuple == node2.nameTuple: + return False + + if not node1.attributes == node2.attributes: + return False + + return True + +class TreeBuilder(object): + """Base treebuilder implementation + documentClass - the class to use for the bottommost node of a document + elementClass - the class to use for HTML Elements + commentClass - the class to use for comments + doctypeClass - the class to use for doctypes + """ + + #Document class + documentClass = None + + #The class to use for creating a node + elementClass = None + + #The class to use for creating comments + commentClass = None + + #The class to use for creating doctypes + doctypeClass = None + + #Fragment class + fragmentClass = None + + def __init__(self, namespaceHTMLElements): + if namespaceHTMLElements: + self.defaultNamespace = "http://www.w3.org/1999/xhtml" + else: + self.defaultNamespace = None + self.reset() + + def reset(self): + self.openElements = [] + self.activeFormattingElements = ActiveFormattingElements() + + #XXX - rename these to headElement, formElement + self.headPointer = None + self.formPointer = None + + self.insertFromTable = False + + self.document = self.documentClass() + + def elementInScope(self, target, variant=None): + + #If we pass a node in we match that. if we pass a string + #match any node with that name + exactNode = hasattr(target, "nameTuple") + + listElementsMap = { + None:(scopingElements, False), + "button":(scopingElements | set([(namespaces["html"], "button")]), False), + "list":(scopingElements | set([(namespaces["html"], "ol"), + (namespaces["html"], "ul")]), False), + "table":(set([(namespaces["html"], "html"), + (namespaces["html"], "table")]), False), + "select":(set([(namespaces["html"], "optgroup"), + (namespaces["html"], "option")]), True) + } + listElements, invert = listElementsMap[variant] + + for node in reversed(self.openElements): + if (node.name == target and not exactNode or + node == target and exactNode): + return True + elif (invert ^ (node.nameTuple in listElements)): + return False + + assert False # We should never reach this point + + def reconstructActiveFormattingElements(self): + # Within this algorithm the order of steps described in the + # specification is not quite the same as the order of steps in the + # code. It should still do the same though. + + # Step 1: stop the algorithm when there's nothing to do. + if not self.activeFormattingElements: + return + + # Step 2 and step 3: we start with the last element. So i is -1. + i = len(self.activeFormattingElements) - 1 + entry = self.activeFormattingElements[i] + if entry == Marker or entry in self.openElements: + return + + # Step 6 + while entry != Marker and entry not in self.openElements: + if i == 0: + #This will be reset to 0 below + i = -1 + break + i -= 1 + # Step 5: let entry be one earlier in the list. + entry = self.activeFormattingElements[i] + + while True: + # Step 7 + i += 1 + + # Step 8 + entry = self.activeFormattingElements[i] + clone = entry.cloneNode() #Mainly to get a new copy of the attributes + + # Step 9 + element = self.insertElement({"type":"StartTag", + "name":clone.name, + "namespace":clone.namespace, + "data":clone.attributes}) + + # Step 10 + self.activeFormattingElements[i] = element + + # Step 11 + if element == self.activeFormattingElements[-1]: + break + + def clearActiveFormattingElements(self): + entry = self.activeFormattingElements.pop() + while self.activeFormattingElements and entry != Marker: + entry = self.activeFormattingElements.pop() + + def elementInActiveFormattingElements(self, name): + """Check if an element exists between the end of the active + formatting elements and the last marker. If it does, return it, else + return false""" + + for item in self.activeFormattingElements[::-1]: + # Check for Marker first because if it's a Marker it doesn't have a + # name attribute. + if item == Marker: + break + elif item.name == name: + return item + return False + + def insertRoot(self, token): + element = self.createElement(token) + self.openElements.append(element) + self.document.appendChild(element) + + def insertDoctype(self, token): + name = token["name"] + publicId = token["publicId"] + systemId = token["systemId"] + + doctype = self.doctypeClass(name, publicId, systemId) + self.document.appendChild(doctype) + + def insertComment(self, token, parent=None): + if parent is None: + parent = self.openElements[-1] + parent.appendChild(self.commentClass(token["data"])) + + def createElement(self, token): + """Create an element but don't insert it anywhere""" + name = token["name"] + namespace = token.get("namespace", self.defaultNamespace) + element = self.elementClass(name, namespace) + element.attributes = token["data"] + return element + + def _getInsertFromTable(self): + return self._insertFromTable + + def _setInsertFromTable(self, value): + """Switch the function used to insert an element from the + normal one to the misnested table one and back again""" + self._insertFromTable = value + if value: + self.insertElement = self.insertElementTable + else: + self.insertElement = self.insertElementNormal + + insertFromTable = property(_getInsertFromTable, _setInsertFromTable) + + def insertElementNormal(self, token): + name = token["name"] + assert type(name) == unicode, "Element %s not unicode"%name + namespace = token.get("namespace", self.defaultNamespace) + element = self.elementClass(name, namespace) + element.attributes = token["data"] + self.openElements[-1].appendChild(element) + self.openElements.append(element) + return element + + def insertElementTable(self, token): + """Create an element and insert it into the tree""" + element = self.createElement(token) + if self.openElements[-1].name not in tableInsertModeElements: + return self.insertElementNormal(token) + else: + #We should be in the InTable mode. This means we want to do + #special magic element rearranging + parent, insertBefore = self.getTableMisnestedNodePosition() + if insertBefore is None: + parent.appendChild(element) + else: + parent.insertBefore(element, insertBefore) + self.openElements.append(element) + return element + + def insertText(self, data, parent=None): + """Insert text data.""" + if parent is None: + parent = self.openElements[-1] + + if (not self.insertFromTable or (self.insertFromTable and + self.openElements[-1].name + not in tableInsertModeElements)): + parent.insertText(data) + else: + # We should be in the InTable mode. This means we want to do + # special magic element rearranging + parent, insertBefore = self.getTableMisnestedNodePosition() + parent.insertText(data, insertBefore) + + def getTableMisnestedNodePosition(self): + """Get the foster parent element, and sibling to insert before + (or None) when inserting a misnested table node""" + # The foster parent element is the one which comes before the most + # recently opened table element + # XXX - this is really inelegant + lastTable=None + fosterParent = None + insertBefore = None + for elm in self.openElements[::-1]: + if elm.name == "table": + lastTable = elm + break + if lastTable: + # XXX - we should really check that this parent is actually a + # node here + if lastTable.parent: + fosterParent = lastTable.parent + insertBefore = lastTable + else: + fosterParent = self.openElements[ + self.openElements.index(lastTable) - 1] + else: + fosterParent = self.openElements[0] + return fosterParent, insertBefore + + def generateImpliedEndTags(self, exclude=None): + name = self.openElements[-1].name + # XXX td, th and tr are not actually needed + if (name in frozenset(("dd", "dt", "li", "option", "optgroup", "p", "rp", "rt")) + and name != exclude): + self.openElements.pop() + # XXX This is not entirely what the specification says. We should + # investigate it more closely. + self.generateImpliedEndTags(exclude) + + def getDocument(self): + "Return the final tree" + return self.document + + def getFragment(self): + "Return the final fragment" + #assert self.innerHTML + fragment = self.fragmentClass() + self.openElements[0].reparentChildren(fragment) + return fragment + + def testSerializer(self, node): + """Serialize the subtree of node in the format required by unit tests + node - the node from which to start serializing""" + raise NotImplementedError diff --git a/libs/html5lib/treebuilders/dom.py b/libs/html5lib/treebuilders/dom.py new file mode 100644 index 00000000..9578da2b --- /dev/null +++ b/libs/html5lib/treebuilders/dom.py @@ -0,0 +1,291 @@ + +from xml.dom import minidom, Node, XML_NAMESPACE, XMLNS_NAMESPACE +try: + from types import ModuleType +except: + from new import module as ModuleType +import re +import weakref + +import _base +from html5lib import constants, ihatexml +from html5lib.constants import namespaces + +moduleCache = {} + +def getDomModule(DomImplementation): + name = "_" + DomImplementation.__name__+"builder" + if name in moduleCache: + return moduleCache[name] + else: + mod = ModuleType(name) + objs = getDomBuilder(DomImplementation) + mod.__dict__.update(objs) + moduleCache[name] = mod + return mod + +def getDomBuilder(DomImplementation): + Dom = DomImplementation + class AttrList(object): + def __init__(self, element): + self.element = element + def __iter__(self): + return self.element.attributes.items().__iter__() + def __setitem__(self, name, value): + self.element.setAttribute(name, value) + def __len__(self): + return len(self.element.attributes.items()) + def items(self): + return [(item[0], item[1]) for item in + self.element.attributes.items()] + def keys(self): + return self.element.attributes.keys() + def __getitem__(self, name): + return self.element.getAttribute(name) + + def __contains__(self, name): + if isinstance(name, tuple): + raise NotImplementedError + else: + return self.element.hasAttribute(name) + + class NodeBuilder(_base.Node): + def __init__(self, element): + _base.Node.__init__(self, element.nodeName) + self.element = element + + namespace = property(lambda self:hasattr(self.element, "namespaceURI") + and self.element.namespaceURI or None) + + def appendChild(self, node): + node.parent = self + self.element.appendChild(node.element) + + def insertText(self, data, insertBefore=None): + text = self.element.ownerDocument.createTextNode(data) + if insertBefore: + self.element.insertBefore(text, insertBefore.element) + else: + self.element.appendChild(text) + + def insertBefore(self, node, refNode): + self.element.insertBefore(node.element, refNode.element) + node.parent = self + + def removeChild(self, node): + if node.element.parentNode == self.element: + self.element.removeChild(node.element) + node.parent = None + + def reparentChildren(self, newParent): + while self.element.hasChildNodes(): + child = self.element.firstChild + self.element.removeChild(child) + newParent.element.appendChild(child) + self.childNodes = [] + + def getAttributes(self): + return AttrList(self.element) + + def setAttributes(self, attributes): + if attributes: + for name, value in attributes.items(): + if isinstance(name, tuple): + if name[0] is not None: + qualifiedName = (name[0] + ":" + name[1]) + else: + qualifiedName = name[1] + self.element.setAttributeNS(name[2], qualifiedName, + value) + else: + self.element.setAttribute( + name, value) + attributes = property(getAttributes, setAttributes) + + def cloneNode(self): + return NodeBuilder(self.element.cloneNode(False)) + + def hasContent(self): + return self.element.hasChildNodes() + + def getNameTuple(self): + if self.namespace == None: + return namespaces["html"], self.name + else: + return self.namespace, self.name + + nameTuple = property(getNameTuple) + + class TreeBuilder(_base.TreeBuilder): + def documentClass(self): + self.dom = Dom.getDOMImplementation().createDocument(None,None,None) + return weakref.proxy(self) + + def insertDoctype(self, token): + name = token["name"] + publicId = token["publicId"] + systemId = token["systemId"] + + domimpl = Dom.getDOMImplementation() + doctype = domimpl.createDocumentType(name, publicId, systemId) + self.document.appendChild(NodeBuilder(doctype)) + if Dom == minidom: + doctype.ownerDocument = self.dom + + def elementClass(self, name, namespace=None): + if namespace is None and self.defaultNamespace is None: + node = self.dom.createElement(name) + else: + node = self.dom.createElementNS(namespace, name) + + return NodeBuilder(node) + + def commentClass(self, data): + return NodeBuilder(self.dom.createComment(data)) + + def fragmentClass(self): + return NodeBuilder(self.dom.createDocumentFragment()) + + def appendChild(self, node): + self.dom.appendChild(node.element) + + def testSerializer(self, element): + return testSerializer(element) + + def getDocument(self): + return self.dom + + def getFragment(self): + return _base.TreeBuilder.getFragment(self).element + + def insertText(self, data, parent=None): + data=data + if parent <> self: + _base.TreeBuilder.insertText(self, data, parent) + else: + # HACK: allow text nodes as children of the document node + if hasattr(self.dom, '_child_node_types'): + if not Node.TEXT_NODE in self.dom._child_node_types: + self.dom._child_node_types=list(self.dom._child_node_types) + self.dom._child_node_types.append(Node.TEXT_NODE) + self.dom.appendChild(self.dom.createTextNode(data)) + + name = None + + def testSerializer(element): + element.normalize() + rv = [] + def serializeElement(element, indent=0): + if element.nodeType == Node.DOCUMENT_TYPE_NODE: + if element.name: + if element.publicId or element.systemId: + publicId = element.publicId or "" + systemId = element.systemId or "" + rv.append( """|%s"""%( + ' '*indent, element.name, publicId, systemId)) + else: + rv.append("|%s"%(' '*indent, element.name)) + else: + rv.append("|%s"%(' '*indent,)) + elif element.nodeType == Node.DOCUMENT_NODE: + rv.append("#document") + elif element.nodeType == Node.DOCUMENT_FRAGMENT_NODE: + rv.append("#document-fragment") + elif element.nodeType == Node.COMMENT_NODE: + rv.append("|%s"%(' '*indent, element.nodeValue)) + elif element.nodeType == Node.TEXT_NODE: + rv.append("|%s\"%s\"" %(' '*indent, element.nodeValue)) + else: + if (hasattr(element, "namespaceURI") and + element.namespaceURI != None): + name = "%s %s"%(constants.prefixes[element.namespaceURI], + element.nodeName) + else: + name = element.nodeName + rv.append("|%s<%s>"%(' '*indent, name)) + if element.hasAttributes(): + attributes = [] + for i in range(len(element.attributes)): + attr = element.attributes.item(i) + name = attr.nodeName + value = attr.value + ns = attr.namespaceURI + if ns: + name = "%s %s"%(constants.prefixes[ns], attr.localName) + else: + name = attr.nodeName + attributes.append((name, value)) + + for name, value in sorted(attributes): + rv.append('|%s%s="%s"' % (' '*(indent+2), name, value)) + indent += 2 + for child in element.childNodes: + serializeElement(child, indent) + serializeElement(element, 0) + + return "\n".join(rv) + + def dom2sax(node, handler, nsmap={'xml':XML_NAMESPACE}): + if node.nodeType == Node.ELEMENT_NODE: + if not nsmap: + handler.startElement(node.nodeName, node.attributes) + for child in node.childNodes: dom2sax(child, handler, nsmap) + handler.endElement(node.nodeName) + else: + attributes = dict(node.attributes.itemsNS()) + + # gather namespace declarations + prefixes = [] + for attrname in node.attributes.keys(): + attr = node.getAttributeNode(attrname) + if (attr.namespaceURI == XMLNS_NAMESPACE or + (attr.namespaceURI == None and attr.nodeName.startswith('xmlns'))): + prefix = (attr.nodeName != 'xmlns' and attr.nodeName or None) + handler.startPrefixMapping(prefix, attr.nodeValue) + prefixes.append(prefix) + nsmap = nsmap.copy() + nsmap[prefix] = attr.nodeValue + del attributes[(attr.namespaceURI, attr.nodeName)] + + # apply namespace declarations + for attrname in node.attributes.keys(): + attr = node.getAttributeNode(attrname) + if attr.namespaceURI == None and ':' in attr.nodeName: + prefix = attr.nodeName.split(':')[0] + if nsmap.has_key(prefix): + del attributes[(attr.namespaceURI, attr.nodeName)] + attributes[(nsmap[prefix],attr.nodeName)]=attr.nodeValue + + # SAX events + ns = node.namespaceURI or nsmap.get(None,None) + handler.startElementNS((ns,node.nodeName), node.nodeName, attributes) + for child in node.childNodes: dom2sax(child, handler, nsmap) + handler.endElementNS((ns, node.nodeName), node.nodeName) + for prefix in prefixes: handler.endPrefixMapping(prefix) + + elif node.nodeType in [Node.TEXT_NODE, Node.CDATA_SECTION_NODE]: + handler.characters(node.nodeValue) + + elif node.nodeType == Node.DOCUMENT_NODE: + handler.startDocument() + for child in node.childNodes: dom2sax(child, handler, nsmap) + handler.endDocument() + + elif node.nodeType == Node.DOCUMENT_FRAGMENT_NODE: + for child in node.childNodes: dom2sax(child, handler, nsmap) + + else: + # ATTRIBUTE_NODE + # ENTITY_NODE + # PROCESSING_INSTRUCTION_NODE + # COMMENT_NODE + # DOCUMENT_TYPE_NODE + # NOTATION_NODE + pass + + return locals() + +# Keep backwards compatibility with things that directly load +# classes/functions from this module +for key, value in getDomModule(minidom).__dict__.items(): + globals()[key] = value diff --git a/libs/html5lib/treebuilders/etree.py b/libs/html5lib/treebuilders/etree.py new file mode 100755 index 00000000..95be4755 --- /dev/null +++ b/libs/html5lib/treebuilders/etree.py @@ -0,0 +1,344 @@ +try: + from types import ModuleType +except: + from new import module as ModuleType +import re +import types + +import _base +from html5lib import ihatexml +from html5lib import constants +from html5lib.constants import namespaces + +tag_regexp = re.compile("{([^}]*)}(.*)") + +moduleCache = {} + +def getETreeModule(ElementTreeImplementation, fullTree=False): + name = "_" + ElementTreeImplementation.__name__+"builder" + if name in moduleCache: + return moduleCache[name] + else: + mod = ModuleType("_" + ElementTreeImplementation.__name__+"builder") + objs = getETreeBuilder(ElementTreeImplementation, fullTree) + mod.__dict__.update(objs) + moduleCache[name] = mod + return mod + +def getETreeBuilder(ElementTreeImplementation, fullTree=False): + ElementTree = ElementTreeImplementation + class Element(_base.Node): + def __init__(self, name, namespace=None): + self._name = name + self._namespace = namespace + self._element = ElementTree.Element(self._getETreeTag(name, + namespace)) + if namespace is None: + self.nameTuple = namespaces["html"], self._name + else: + self.nameTuple = self._namespace, self._name + self.parent = None + self._childNodes = [] + self._flags = [] + + def _getETreeTag(self, name, namespace): + if namespace is None: + etree_tag = name + else: + etree_tag = "{%s}%s"%(namespace, name) + return etree_tag + + def _setName(self, name): + self._name = name + self._element.tag = self._getETreeTag(self._name, self._namespace) + + def _getName(self): + return self._name + + name = property(_getName, _setName) + + def _setNamespace(self, namespace): + self._namespace = namespace + self._element.tag = self._getETreeTag(self._name, self._namespace) + + def _getNamespace(self): + return self._namespace + + namespace = property(_getNamespace, _setNamespace) + + def _getAttributes(self): + return self._element.attrib + + def _setAttributes(self, attributes): + #Delete existing attributes first + #XXX - there may be a better way to do this... + for key in self._element.attrib.keys(): + del self._element.attrib[key] + for key, value in attributes.iteritems(): + if isinstance(key, tuple): + name = "{%s}%s"%(key[2], key[1]) + else: + name = key + self._element.set(name, value) + + attributes = property(_getAttributes, _setAttributes) + + def _getChildNodes(self): + return self._childNodes + def _setChildNodes(self, value): + del self._element[:] + self._childNodes = [] + for element in value: + self.insertChild(element) + + childNodes = property(_getChildNodes, _setChildNodes) + + def hasContent(self): + """Return true if the node has children or text""" + return bool(self._element.text or len(self._element)) + + def appendChild(self, node): + self._childNodes.append(node) + self._element.append(node._element) + node.parent = self + + def insertBefore(self, node, refNode): + index = list(self._element).index(refNode._element) + self._element.insert(index, node._element) + node.parent = self + + def removeChild(self, node): + self._element.remove(node._element) + node.parent=None + + def insertText(self, data, insertBefore=None): + if not(len(self._element)): + if not self._element.text: + self._element.text = "" + self._element.text += data + elif insertBefore is None: + #Insert the text as the tail of the last child element + if not self._element[-1].tail: + self._element[-1].tail = "" + self._element[-1].tail += data + else: + #Insert the text before the specified node + children = list(self._element) + index = children.index(insertBefore._element) + if index > 0: + if not self._element[index-1].tail: + self._element[index-1].tail = "" + self._element[index-1].tail += data + else: + if not self._element.text: + self._element.text = "" + self._element.text += data + + def cloneNode(self): + element = type(self)(self.name, self.namespace) + for name, value in self.attributes.iteritems(): + element.attributes[name] = value + return element + + def reparentChildren(self, newParent): + if newParent.childNodes: + newParent.childNodes[-1]._element.tail += self._element.text + else: + if not newParent._element.text: + newParent._element.text = "" + if self._element.text is not None: + newParent._element.text += self._element.text + self._element.text = "" + _base.Node.reparentChildren(self, newParent) + + class Comment(Element): + def __init__(self, data): + #Use the superclass constructor to set all properties on the + #wrapper element + self._element = ElementTree.Comment(data) + self.parent = None + self._childNodes = [] + self._flags = [] + + def _getData(self): + return self._element.text + + def _setData(self, value): + self._element.text = value + + data = property(_getData, _setData) + + class DocumentType(Element): + def __init__(self, name, publicId, systemId): + Element.__init__(self, "") + self._element.text = name + self.publicId = publicId + self.systemId = systemId + + def _getPublicId(self): + return self._element.get(u"publicId", "") + + def _setPublicId(self, value): + if value is not None: + self._element.set(u"publicId", value) + + publicId = property(_getPublicId, _setPublicId) + + def _getSystemId(self): + return self._element.get(u"systemId", "") + + def _setSystemId(self, value): + if value is not None: + self._element.set(u"systemId", value) + + systemId = property(_getSystemId, _setSystemId) + + class Document(Element): + def __init__(self): + Element.__init__(self, "") + + class DocumentFragment(Element): + def __init__(self): + Element.__init__(self, "") + + def testSerializer(element): + rv = [] + finalText = None + def serializeElement(element, indent=0): + if not(hasattr(element, "tag")): + element = element.getroot() + if element.tag == "": + if element.get("publicId") or element.get("systemId"): + publicId = element.get("publicId") or "" + systemId = element.get("systemId") or "" + rv.append( """"""%( + element.text, publicId, systemId)) + else: + rv.append(""%(element.text,)) + elif element.tag == "": + rv.append("#document") + if element.text: + rv.append("|%s\"%s\""%(' '*(indent+2), element.text)) + if element.tail: + finalText = element.tail + elif element.tag == ElementTree.Comment: + rv.append("|%s"%(' '*indent, element.text)) + else: + assert type(element.tag) in types.StringTypes, "Expected unicode, got %s"%type(element.tag) + nsmatch = tag_regexp.match(element.tag) + + if nsmatch is None: + name = element.tag + else: + ns, name = nsmatch.groups() + prefix = constants.prefixes[ns] + name = "%s %s"%(prefix, name) + rv.append("|%s<%s>"%(' '*indent, name)) + + if hasattr(element, "attrib"): + attributes = [] + for name, value in element.attrib.iteritems(): + nsmatch = tag_regexp.match(name) + if nsmatch is not None: + ns, name = nsmatch.groups() + prefix = constants.prefixes[ns] + attr_string = "%s %s"%(prefix, name) + else: + attr_string = name + attributes.append((attr_string, value)) + + for name, value in sorted(attributes): + rv.append('|%s%s="%s"' % (' '*(indent+2), name, value)) + if element.text: + rv.append("|%s\"%s\"" %(' '*(indent+2), element.text)) + indent += 2 + for child in element: + serializeElement(child, indent) + if element.tail: + rv.append("|%s\"%s\"" %(' '*(indent-2), element.tail)) + serializeElement(element, 0) + + if finalText is not None: + rv.append("|%s\"%s\""%(' '*2, finalText)) + + return "\n".join(rv) + + def tostring(element): + """Serialize an element and its child nodes to a string""" + rv = [] + finalText = None + filter = ihatexml.InfosetFilter() + def serializeElement(element): + if type(element) == type(ElementTree.ElementTree): + element = element.getroot() + + if element.tag == "": + if element.get("publicId") or element.get("systemId"): + publicId = element.get("publicId") or "" + systemId = element.get("systemId") or "" + rv.append( """"""%( + element.text, publicId, systemId)) + else: + rv.append(""%(element.text,)) + elif element.tag == "": + if element.text: + rv.append(element.text) + if element.tail: + finalText = element.tail + + for child in element: + serializeElement(child) + + elif type(element.tag) == type(ElementTree.Comment): + rv.append(""%(element.text,)) + else: + #This is assumed to be an ordinary element + if not element.attrib: + rv.append("<%s>"%(filter.fromXmlName(element.tag),)) + else: + attr = " ".join(["%s=\"%s\""%( + filter.fromXmlName(name), value) + for name, value in element.attrib.iteritems()]) + rv.append("<%s %s>"%(element.tag, attr)) + if element.text: + rv.append(element.text) + + for child in element: + serializeElement(child) + + rv.append(""%(element.tag,)) + + if element.tail: + rv.append(element.tail) + + serializeElement(element) + + if finalText is not None: + rv.append("%s\""%(' '*2, finalText)) + + return "".join(rv) + + class TreeBuilder(_base.TreeBuilder): + documentClass = Document + doctypeClass = DocumentType + elementClass = Element + commentClass = Comment + fragmentClass = DocumentFragment + + def testSerializer(self, element): + return testSerializer(element) + + def getDocument(self): + if fullTree: + return self.document._element + else: + if self.defaultNamespace is not None: + return self.document._element.find( + "{%s}html"%self.defaultNamespace) + else: + return self.document._element.find("html") + + def getFragment(self): + return _base.TreeBuilder.getFragment(self)._element + + return locals() diff --git a/libs/html5lib/treebuilders/etree_lxml.py b/libs/html5lib/treebuilders/etree_lxml.py new file mode 100644 index 00000000..eee1e3b2 --- /dev/null +++ b/libs/html5lib/treebuilders/etree_lxml.py @@ -0,0 +1,336 @@ +import warnings +import re + +import _base +from html5lib.constants import DataLossWarning +import html5lib.constants as constants +import etree as etree_builders +from html5lib import ihatexml + +try: + import lxml.etree as etree +except ImportError: + pass + +fullTree = True +tag_regexp = re.compile("{([^}]*)}(.*)") + +"""Module for supporting the lxml.etree library. The idea here is to use as much +of the native library as possible, without using fragile hacks like custom element +names that break between releases. The downside of this is that we cannot represent +all possible trees; specifically the following are known to cause problems: + +Text or comments as siblings of the root element +Docypes with no name + +When any of these things occur, we emit a DataLossWarning +""" + +class DocumentType(object): + def __init__(self, name, publicId, systemId): + self.name = name + self.publicId = publicId + self.systemId = systemId + +class Document(object): + def __init__(self): + self._elementTree = None + self._childNodes = [] + + def appendChild(self, element): + self._elementTree.getroot().addnext(element._element) + + def _getChildNodes(self): + return self._childNodes + + childNodes = property(_getChildNodes) + +def testSerializer(element): + rv = [] + finalText = None + filter = ihatexml.InfosetFilter() + def serializeElement(element, indent=0): + if not hasattr(element, "tag"): + if hasattr(element, "getroot"): + #Full tree case + rv.append("#document") + if element.docinfo.internalDTD: + if not (element.docinfo.public_id or + element.docinfo.system_url): + dtd_str = ""%element.docinfo.root_name + else: + dtd_str = """"""%( + element.docinfo.root_name, + element.docinfo.public_id, + element.docinfo.system_url) + rv.append("|%s%s"%(' '*(indent+2), dtd_str)) + next_element = element.getroot() + while next_element.getprevious() is not None: + next_element = next_element.getprevious() + while next_element is not None: + serializeElement(next_element, indent+2) + next_element = next_element.getnext() + elif isinstance(element, basestring): + #Text in a fragment + rv.append("|%s\"%s\""%(' '*indent, element)) + else: + #Fragment case + rv.append("#document-fragment") + for next_element in element: + serializeElement(next_element, indent+2) + elif type(element.tag) == type(etree.Comment): + rv.append("|%s"%(' '*indent, element.text)) + else: + nsmatch = etree_builders.tag_regexp.match(element.tag) + if nsmatch is not None: + ns = nsmatch.group(1) + tag = nsmatch.group(2) + prefix = constants.prefixes[ns] + rv.append("|%s<%s %s>"%(' '*indent, prefix, + filter.fromXmlName(tag))) + else: + rv.append("|%s<%s>"%(' '*indent, + filter.fromXmlName(element.tag))) + + if hasattr(element, "attrib"): + attributes = [] + for name, value in element.attrib.iteritems(): + nsmatch = tag_regexp.match(name) + if nsmatch is not None: + ns, name = nsmatch.groups() + name = filter.fromXmlName(name) + prefix = constants.prefixes[ns] + attr_string = "%s %s"%(prefix, name) + else: + attr_string = filter.fromXmlName(name) + attributes.append((attr_string, value)) + + for name, value in sorted(attributes): + rv.append('|%s%s="%s"' % (' '*(indent+2), name, value)) + + if element.text: + rv.append("|%s\"%s\"" %(' '*(indent+2), element.text)) + indent += 2 + for child in element.getchildren(): + serializeElement(child, indent) + if hasattr(element, "tail") and element.tail: + rv.append("|%s\"%s\"" %(' '*(indent-2), element.tail)) + serializeElement(element, 0) + + if finalText is not None: + rv.append("|%s\"%s\""%(' '*2, finalText)) + + return "\n".join(rv) + +def tostring(element): + """Serialize an element and its child nodes to a string""" + rv = [] + finalText = None + def serializeElement(element): + if not hasattr(element, "tag"): + if element.docinfo.internalDTD: + if element.docinfo.doctype: + dtd_str = element.docinfo.doctype + else: + dtd_str = ""%element.docinfo.root_name + rv.append(dtd_str) + serializeElement(element.getroot()) + + elif type(element.tag) == type(etree.Comment): + rv.append(""%(element.text,)) + + else: + #This is assumed to be an ordinary element + if not element.attrib: + rv.append("<%s>"%(element.tag,)) + else: + attr = " ".join(["%s=\"%s\""%(name, value) + for name, value in element.attrib.iteritems()]) + rv.append("<%s %s>"%(element.tag, attr)) + if element.text: + rv.append(element.text) + + for child in element.getchildren(): + serializeElement(child) + + rv.append(""%(element.tag,)) + + if hasattr(element, "tail") and element.tail: + rv.append(element.tail) + + serializeElement(element) + + if finalText is not None: + rv.append("%s\""%(' '*2, finalText)) + + return "".join(rv) + + +class TreeBuilder(_base.TreeBuilder): + documentClass = Document + doctypeClass = DocumentType + elementClass = None + commentClass = None + fragmentClass = Document + + def __init__(self, namespaceHTMLElements, fullTree = False): + builder = etree_builders.getETreeModule(etree, fullTree=fullTree) + filter = self.filter = ihatexml.InfosetFilter() + self.namespaceHTMLElements = namespaceHTMLElements + + class Attributes(dict): + def __init__(self, element, value={}): + self._element = element + dict.__init__(self, value) + for key, value in self.iteritems(): + if isinstance(key, tuple): + name = "{%s}%s"%(key[2], filter.coerceAttribute(key[1])) + else: + name = filter.coerceAttribute(key) + self._element._element.attrib[name] = value + + def __setitem__(self, key, value): + dict.__setitem__(self, key, value) + if isinstance(key, tuple): + name = "{%s}%s"%(key[2], filter.coerceAttribute(key[1])) + else: + name = filter.coerceAttribute(key) + self._element._element.attrib[name] = value + + class Element(builder.Element): + def __init__(self, name, namespace): + name = filter.coerceElement(name) + builder.Element.__init__(self, name, namespace=namespace) + self._attributes = Attributes(self) + + def _setName(self, name): + self._name = filter.coerceElement(name) + self._element.tag = self._getETreeTag( + self._name, self._namespace) + + def _getName(self): + return filter.fromXmlName(self._name) + + name = property(_getName, _setName) + + def _getAttributes(self): + return self._attributes + + def _setAttributes(self, attributes): + self._attributes = Attributes(self, attributes) + + attributes = property(_getAttributes, _setAttributes) + + def insertText(self, data, insertBefore=None): + data = filter.coerceCharacters(data) + builder.Element.insertText(self, data, insertBefore) + + def appendChild(self, child): + builder.Element.appendChild(self, child) + + + class Comment(builder.Comment): + def __init__(self, data): + data = filter.coerceComment(data) + builder.Comment.__init__(self, data) + + def _setData(self, data): + data = filter.coerceComment(data) + self._element.text = data + + def _getData(self): + return self._element.text + + data = property(_getData, _setData) + + self.elementClass = Element + self.commentClass = builder.Comment + #self.fragmentClass = builder.DocumentFragment + _base.TreeBuilder.__init__(self, namespaceHTMLElements) + + def reset(self): + _base.TreeBuilder.reset(self) + self.insertComment = self.insertCommentInitial + self.initial_comments = [] + self.doctype = None + + def testSerializer(self, element): + return testSerializer(element) + + def getDocument(self): + if fullTree: + return self.document._elementTree + else: + return self.document._elementTree.getroot() + + def getFragment(self): + fragment = [] + element = self.openElements[0]._element + if element.text: + fragment.append(element.text) + fragment.extend(element.getchildren()) + if element.tail: + fragment.append(element.tail) + return fragment + + def insertDoctype(self, token): + name = token["name"] + publicId = token["publicId"] + systemId = token["systemId"] + + if not name or ihatexml.nonXmlNameBMPRegexp.search(name) or name[0] == '"': + warnings.warn("lxml cannot represent null or non-xml doctype", DataLossWarning) + + doctype = self.doctypeClass(name, publicId, systemId) + self.doctype = doctype + + def insertCommentInitial(self, data, parent=None): + self.initial_comments.append(data) + + def insertRoot(self, token): + """Create the document root""" + #Because of the way libxml2 works, it doesn't seem to be possible to + #alter information like the doctype after the tree has been parsed. + #Therefore we need to use the built-in parser to create our iniial + #tree, after which we can add elements like normal + docStr = "" + if self.doctype and self.doctype.name and not self.doctype.name.startswith('"'): + docStr += "" + docStr += "" + + try: + root = etree.fromstring(docStr) + except etree.XMLSyntaxError: + print docStr + raise + + #Append the initial comments: + for comment_token in self.initial_comments: + root.addprevious(etree.Comment(comment_token["data"])) + + #Create the root document and add the ElementTree to it + self.document = self.documentClass() + self.document._elementTree = root.getroottree() + + # Give the root element the right name + name = token["name"] + namespace = token.get("namespace", self.defaultNamespace) + if namespace is None: + etree_tag = name + else: + etree_tag = "{%s}%s"%(namespace, name) + root.tag = etree_tag + + #Add the root element to the internal child/open data structures + root_element = self.elementClass(name, namespace) + root_element._element = root + self.document._childNodes.append(root_element) + self.openElements.append(root_element) + + #Reset to the default insert comment function + self.insertComment = super(TreeBuilder, self).insertComment diff --git a/libs/html5lib/treebuilders/simpletree.py b/libs/html5lib/treebuilders/simpletree.py new file mode 100755 index 00000000..67fe7583 --- /dev/null +++ b/libs/html5lib/treebuilders/simpletree.py @@ -0,0 +1,256 @@ +import _base +from html5lib.constants import voidElements, namespaces, prefixes +from xml.sax.saxutils import escape + +# Really crappy basic implementation of a DOM-core like thing +class Node(_base.Node): + type = -1 + def __init__(self, name): + self.name = name + self.parent = None + self.value = None + self.childNodes = [] + self._flags = [] + + def __iter__(self): + for node in self.childNodes: + yield node + for item in node: + yield item + + def __unicode__(self): + return self.name + + def toxml(self): + raise NotImplementedError + + def printTree(self, indent=0): + tree = '\n|%s%s' % (' '* indent, unicode(self)) + for child in self.childNodes: + tree += child.printTree(indent + 2) + return tree + + def appendChild(self, node): + assert isinstance(node, Node) + if (isinstance(node, TextNode) and self.childNodes and + isinstance(self.childNodes[-1], TextNode)): + self.childNodes[-1].value += node.value + else: + self.childNodes.append(node) + node.parent = self + + def insertText(self, data, insertBefore=None): + assert isinstance(data, unicode), "data %s is of type %s expected unicode"%(repr(data), type(data)) + if insertBefore is None: + self.appendChild(TextNode(data)) + else: + self.insertBefore(TextNode(data), insertBefore) + + def insertBefore(self, node, refNode): + index = self.childNodes.index(refNode) + if (isinstance(node, TextNode) and index > 0 and + isinstance(self.childNodes[index - 1], TextNode)): + self.childNodes[index - 1].value += node.value + else: + self.childNodes.insert(index, node) + node.parent = self + + def removeChild(self, node): + try: + self.childNodes.remove(node) + except: + # XXX + raise + node.parent = None + + def cloneNode(self): + raise NotImplementedError + + def hasContent(self): + """Return true if the node has children or text""" + return bool(self.childNodes) + + def getNameTuple(self): + if self.namespace == None: + return namespaces["html"], self.name + else: + return self.namespace, self.name + + nameTuple = property(getNameTuple) + +class Document(Node): + type = 1 + def __init__(self): + Node.__init__(self, None) + + def __str__(self): + return "#document" + + def __unicode__(self): + return str(self) + + def appendChild(self, child): + Node.appendChild(self, child) + + def toxml(self, encoding="utf=8"): + result = "" + for child in self.childNodes: + result += child.toxml() + return result.encode(encoding) + + def hilite(self, encoding="utf-8"): + result = "
"
+        for child in self.childNodes:
+            result += child.hilite()
+        return result.encode(encoding) + "
" + + def printTree(self): + tree = unicode(self) + for child in self.childNodes: + tree += child.printTree(2) + return tree + + def cloneNode(self): + return Document() + +class DocumentFragment(Document): + type = 2 + def __str__(self): + return "#document-fragment" + + def __unicode__(self): + return str(self) + + def cloneNode(self): + return DocumentFragment() + +class DocumentType(Node): + type = 3 + def __init__(self, name, publicId, systemId): + Node.__init__(self, name) + self.publicId = publicId + self.systemId = systemId + + def __unicode__(self): + if self.publicId or self.systemId: + publicId = self.publicId or "" + systemId = self.systemId or "" + return """"""%( + self.name, publicId, systemId) + + else: + return u"" % self.name + + + toxml = __unicode__ + + def hilite(self): + return '<!DOCTYPE %s>' % self.name + + def cloneNode(self): + return DocumentType(self.name, self.publicId, self.systemId) + +class TextNode(Node): + type = 4 + def __init__(self, value): + Node.__init__(self, None) + self.value = value + + def __unicode__(self): + return u"\"%s\"" % self.value + + def toxml(self): + return escape(self.value) + + hilite = toxml + + def cloneNode(self): + return TextNode(self.value) + +class Element(Node): + type = 5 + def __init__(self, name, namespace=None): + Node.__init__(self, name) + self.namespace = namespace + self.attributes = {} + + def __unicode__(self): + if self.namespace == None: + return u"<%s>" % self.name + else: + return u"<%s %s>"%(prefixes[self.namespace], self.name) + + def toxml(self): + result = '<' + self.name + if self.attributes: + for name,value in self.attributes.iteritems(): + result += u' %s="%s"' % (name, escape(value,{'"':'"'})) + if self.childNodes: + result += '>' + for child in self.childNodes: + result += child.toxml() + result += u'' % self.name + else: + result += u'/>' + return result + + def hilite(self): + result = '<%s' % self.name + if self.attributes: + for name, value in self.attributes.iteritems(): + result += ' %s="%s"' % (name, escape(value, {'"':'"'})) + if self.childNodes: + result += ">" + for child in self.childNodes: + result += child.hilite() + elif self.name in voidElements: + return result + ">" + return result + '</%s>' % self.name + + def printTree(self, indent): + tree = '\n|%s%s' % (' '*indent, unicode(self)) + indent += 2 + if self.attributes: + for name, value in sorted(self.attributes.iteritems()): + if isinstance(name, tuple): + name = "%s %s"%(name[0], name[1]) + tree += '\n|%s%s="%s"' % (' ' * indent, name, value) + for child in self.childNodes: + tree += child.printTree(indent) + return tree + + def cloneNode(self): + newNode = Element(self.name) + if hasattr(self, 'namespace'): + newNode.namespace = self.namespace + for attr, value in self.attributes.iteritems(): + newNode.attributes[attr] = value + return newNode + +class CommentNode(Node): + type = 6 + def __init__(self, data): + Node.__init__(self, None) + self.data = data + + def __unicode__(self): + return "" % self.data + + def toxml(self): + return "" % self.data + + def hilite(self): + return '<!--%s-->' % escape(self.data) + + def cloneNode(self): + return CommentNode(self.data) + +class TreeBuilder(_base.TreeBuilder): + documentClass = Document + doctypeClass = DocumentType + elementClass = Element + commentClass = CommentNode + fragmentClass = DocumentFragment + + def testSerializer(self, node): + return node.printTree() diff --git a/libs/html5lib/treebuilders/soup.py b/libs/html5lib/treebuilders/soup.py new file mode 100644 index 00000000..9bc5ff0e --- /dev/null +++ b/libs/html5lib/treebuilders/soup.py @@ -0,0 +1,236 @@ +import warnings + +warnings.warn("BeautifulSoup 3.x (as of 3.1) is not fully compatible with html5lib and support will be removed in the future", DeprecationWarning) + +from BeautifulSoup import BeautifulSoup, Tag, NavigableString, Comment, Declaration + +import _base +from html5lib.constants import namespaces, DataLossWarning + +class AttrList(object): + def __init__(self, element): + self.element = element + self.attrs = dict(self.element.attrs) + def __iter__(self): + return self.attrs.items().__iter__() + def __setitem__(self, name, value): + "set attr", name, value + self.element[name] = value + def items(self): + return self.attrs.items() + def keys(self): + return self.attrs.keys() + def __getitem__(self, name): + return self.attrs[name] + def __contains__(self, name): + return name in self.attrs.keys() + def __eq__(self, other): + if len(self.keys()) != len(other.keys()): + return False + for item in self.keys(): + if item not in other: + return False + if self[item] != other[item]: + return False + return True + +class Element(_base.Node): + def __init__(self, element, soup, namespace): + _base.Node.__init__(self, element.name) + self.element = element + self.soup = soup + self.namespace = namespace + + def _nodeIndex(self, node, refNode): + # Finds a node by identity rather than equality + for index in range(len(self.element.contents)): + if id(self.element.contents[index]) == id(refNode.element): + return index + return None + + def appendChild(self, node): + if (node.element.__class__ == NavigableString and self.element.contents + and self.element.contents[-1].__class__ == NavigableString): + # Concatenate new text onto old text node + # (TODO: This has O(n^2) performance, for input like "aaa...") + newStr = NavigableString(self.element.contents[-1]+node.element) + + # Remove the old text node + # (Can't simply use .extract() by itself, because it fails if + # an equal text node exists within the parent node) + oldElement = self.element.contents[-1] + del self.element.contents[-1] + oldElement.parent = None + oldElement.extract() + + self.element.insert(len(self.element.contents), newStr) + else: + self.element.insert(len(self.element.contents), node.element) + node.parent = self + + def getAttributes(self): + return AttrList(self.element) + + def setAttributes(self, attributes): + if attributes: + for name, value in attributes.items(): + self.element[name] = value + + attributes = property(getAttributes, setAttributes) + + def insertText(self, data, insertBefore=None): + text = TextNode(NavigableString(data), self.soup) + if insertBefore: + self.insertBefore(text, insertBefore) + else: + self.appendChild(text) + + def insertBefore(self, node, refNode): + index = self._nodeIndex(node, refNode) + if (node.element.__class__ == NavigableString and self.element.contents + and self.element.contents[index-1].__class__ == NavigableString): + # (See comments in appendChild) + newStr = NavigableString(self.element.contents[index-1]+node.element) + oldNode = self.element.contents[index-1] + del self.element.contents[index-1] + oldNode.parent = None + oldNode.extract() + + self.element.insert(index-1, newStr) + else: + self.element.insert(index, node.element) + node.parent = self + + def removeChild(self, node): + index = self._nodeIndex(node.parent, node) + del node.parent.element.contents[index] + node.element.parent = None + node.element.extract() + node.parent = None + + def reparentChildren(self, newParent): + while self.element.contents: + child = self.element.contents[0] + child.extract() + if isinstance(child, Tag): + newParent.appendChild(Element(child, self.soup, namespaces["html"])) + else: + newParent.appendChild(TextNode(child, self.soup)) + + def cloneNode(self): + node = Element(Tag(self.soup, self.element.name), self.soup, self.namespace) + for key,value in self.attributes: + node.attributes[key] = value + return node + + def hasContent(self): + return self.element.contents + + def getNameTuple(self): + if self.namespace == None: + return namespaces["html"], self.name + else: + return self.namespace, self.name + + nameTuple = property(getNameTuple) + +class TextNode(Element): + def __init__(self, element, soup): + _base.Node.__init__(self, None) + self.element = element + self.soup = soup + + def cloneNode(self): + raise NotImplementedError + +class TreeBuilder(_base.TreeBuilder): + def __init__(self, namespaceHTMLElements): + if namespaceHTMLElements: + warnings.warn("BeautifulSoup cannot represent elements in any namespace", DataLossWarning) + _base.TreeBuilder.__init__(self, namespaceHTMLElements) + + def documentClass(self): + self.soup = BeautifulSoup("") + return Element(self.soup, self.soup, None) + + def insertDoctype(self, token): + name = token["name"] + publicId = token["publicId"] + systemId = token["systemId"] + + if publicId: + self.soup.insert(0, Declaration("DOCTYPE %s PUBLIC \"%s\" \"%s\""%(name, publicId, systemId or ""))) + elif systemId: + self.soup.insert(0, Declaration("DOCTYPE %s SYSTEM \"%s\""% + (name, systemId))) + else: + self.soup.insert(0, Declaration("DOCTYPE %s"%name)) + + def elementClass(self, name, namespace): + if namespace is not None: + warnings.warn("BeautifulSoup cannot represent elements in any namespace", DataLossWarning) + return Element(Tag(self.soup, name), self.soup, namespace) + + def commentClass(self, data): + return TextNode(Comment(data), self.soup) + + def fragmentClass(self): + self.soup = BeautifulSoup("") + self.soup.name = "[document_fragment]" + return Element(self.soup, self.soup, None) + + def appendChild(self, node): + self.soup.insert(len(self.soup.contents), node.element) + + def testSerializer(self, element): + return testSerializer(element) + + def getDocument(self): + return self.soup + + def getFragment(self): + return _base.TreeBuilder.getFragment(self).element + +def testSerializer(element): + import re + rv = [] + def serializeElement(element, indent=0): + if isinstance(element, Declaration): + doctype_regexp = r'DOCTYPE\s+(?P[^\s]*)( PUBLIC "(?P.*)" "(?P.*)"| SYSTEM "(?P.*)")?' + m = re.compile(doctype_regexp).match(element.string) + assert m is not None, "DOCTYPE did not match expected format" + name = m.group('name') + publicId = m.group('publicId') + if publicId is not None: + systemId = m.group('systemId1') or "" + else: + systemId = m.group('systemId2') + + if publicId is not None or systemId is not None: + rv.append("""|%s"""% + (' '*indent, name, publicId or "", systemId or "")) + else: + rv.append("|%s"%(' '*indent, name)) + + elif isinstance(element, BeautifulSoup): + if element.name == "[document_fragment]": + rv.append("#document-fragment") + else: + rv.append("#document") + + elif isinstance(element, Comment): + rv.append("|%s"%(' '*indent, element.string)) + elif isinstance(element, unicode): + rv.append("|%s\"%s\"" %(' '*indent, element)) + else: + rv.append("|%s<%s>"%(' '*indent, element.name)) + if element.attrs: + for name, value in sorted(element.attrs): + rv.append('|%s%s="%s"' % (' '*(indent+2), name, value)) + indent += 2 + if hasattr(element, "contents"): + for child in element.contents: + serializeElement(child, indent) + serializeElement(element, 0) + + return "\n".join(rv) diff --git a/libs/html5lib/treewalkers/__init__.py b/libs/html5lib/treewalkers/__init__.py new file mode 100644 index 00000000..3a606a8b --- /dev/null +++ b/libs/html5lib/treewalkers/__init__.py @@ -0,0 +1,52 @@ +"""A collection of modules for iterating through different kinds of +tree, generating tokens identical to those produced by the tokenizer +module. + +To create a tree walker for a new type of tree, you need to do +implement a tree walker object (called TreeWalker by convention) that +implements a 'serialize' method taking a tree as sole argument and +returning an iterator generating tokens. +""" + +treeWalkerCache = {} + +def getTreeWalker(treeType, implementation=None, **kwargs): + """Get a TreeWalker class for various types of tree with built-in support + + treeType - the name of the tree type required (case-insensitive). Supported + values are "simpletree", "dom", "etree" and "beautifulsoup" + + "simpletree" - a built-in DOM-ish tree type with support for some + more pythonic idioms. + "dom" - The xml.dom.minidom DOM implementation + "pulldom" - The xml.dom.pulldom event stream + "etree" - A generic walker for tree implementations exposing an + elementtree-like interface (known to work with + ElementTree, cElementTree and lxml.etree). + "lxml" - Optimized walker for lxml.etree + "beautifulsoup" - Beautiful soup (if installed) + "genshi" - a Genshi stream + + implementation - (Currently applies to the "etree" tree type only). A module + implementing the tree type e.g. xml.etree.ElementTree or + cElementTree.""" + + treeType = treeType.lower() + if treeType not in treeWalkerCache: + if treeType in ("dom", "pulldom", "simpletree"): + mod = __import__(treeType, globals()) + treeWalkerCache[treeType] = mod.TreeWalker + elif treeType == "genshi": + import genshistream + treeWalkerCache[treeType] = genshistream.TreeWalker + elif treeType == "beautifulsoup": + import soup + treeWalkerCache[treeType] = soup.TreeWalker + elif treeType == "lxml": + import lxmletree + treeWalkerCache[treeType] = lxmletree.TreeWalker + elif treeType == "etree": + import etree + # XXX: NEVER cache here, caching is done in the etree submodule + return etree.getETreeModule(implementation, **kwargs).TreeWalker + return treeWalkerCache.get(treeType) diff --git a/libs/html5lib/treewalkers/_base.py b/libs/html5lib/treewalkers/_base.py new file mode 100644 index 00000000..5929ba05 --- /dev/null +++ b/libs/html5lib/treewalkers/_base.py @@ -0,0 +1,176 @@ +import gettext +_ = gettext.gettext + +from html5lib.constants import voidElements, spaceCharacters +spaceCharacters = u"".join(spaceCharacters) + +class TreeWalker(object): + def __init__(self, tree): + self.tree = tree + + def __iter__(self): + raise NotImplementedError + + def error(self, msg): + return {"type": "SerializeError", "data": msg} + + def normalizeAttrs(self, attrs): + newattrs = {} + if attrs: + #TODO: treewalkers should always have attrs + for (namespace,name),value in attrs.iteritems(): + namespace = unicode(namespace) if namespace else None + name = unicode(name) + value = unicode(value) + newattrs[(namespace,name)] = value + return newattrs + + def emptyTag(self, namespace, name, attrs, hasChildren=False): + yield {"type": "EmptyTag", "name": unicode(name), + "namespace":unicode(namespace), + "data": self.normalizeAttrs(attrs)} + if hasChildren: + yield self.error(_("Void element has children")) + + def startTag(self, namespace, name, attrs): + return {"type": "StartTag", + "name": unicode(name), + "namespace":unicode(namespace), + "data": self.normalizeAttrs(attrs)} + + def endTag(self, namespace, name): + return {"type": "EndTag", + "name": unicode(name), + "namespace":unicode(namespace), + "data": {}} + + def text(self, data): + data = unicode(data) + middle = data.lstrip(spaceCharacters) + left = data[:len(data)-len(middle)] + if left: + yield {"type": "SpaceCharacters", "data": left} + data = middle + middle = data.rstrip(spaceCharacters) + right = data[len(middle):] + if middle: + yield {"type": "Characters", "data": middle} + if right: + yield {"type": "SpaceCharacters", "data": right} + + def comment(self, data): + return {"type": "Comment", "data": unicode(data)} + + def doctype(self, name, publicId=None, systemId=None, correct=True): + return {"type": "Doctype", + "name": name is not None and unicode(name) or u"", + "publicId": publicId, + "systemId": systemId, + "correct": correct} + + def entity(self, name): + return {"type": "Entity", "name": unicode(name)} + + def unknown(self, nodeType): + return self.error(_("Unknown node type: ") + nodeType) + +class RecursiveTreeWalker(TreeWalker): + def walkChildren(self, node): + raise NodeImplementedError + + def element(self, node, namespace, name, attrs, hasChildren): + if name in voidElements: + for token in self.emptyTag(namespace, name, attrs, hasChildren): + yield token + else: + yield self.startTag(name, attrs) + if hasChildren: + for token in self.walkChildren(node): + yield token + yield self.endTag(name) + +from xml.dom import Node + +DOCUMENT = Node.DOCUMENT_NODE +DOCTYPE = Node.DOCUMENT_TYPE_NODE +TEXT = Node.TEXT_NODE +ELEMENT = Node.ELEMENT_NODE +COMMENT = Node.COMMENT_NODE +ENTITY = Node.ENTITY_NODE +UNKNOWN = "<#UNKNOWN#>" + +class NonRecursiveTreeWalker(TreeWalker): + def getNodeDetails(self, node): + raise NotImplementedError + + def getFirstChild(self, node): + raise NotImplementedError + + def getNextSibling(self, node): + raise NotImplementedError + + def getParentNode(self, node): + raise NotImplementedError + + def __iter__(self): + currentNode = self.tree + while currentNode is not None: + details = self.getNodeDetails(currentNode) + type, details = details[0], details[1:] + hasChildren = False + endTag = None + + if type == DOCTYPE: + yield self.doctype(*details) + + elif type == TEXT: + for token in self.text(*details): + yield token + + elif type == ELEMENT: + namespace, name, attributes, hasChildren = details + if name in voidElements: + for token in self.emptyTag(namespace, name, attributes, + hasChildren): + yield token + hasChildren = False + else: + endTag = name + yield self.startTag(namespace, name, attributes) + + elif type == COMMENT: + yield self.comment(details[0]) + + elif type == ENTITY: + yield self.entity(details[0]) + + elif type == DOCUMENT: + hasChildren = True + + else: + yield self.unknown(details[0]) + + if hasChildren: + firstChild = self.getFirstChild(currentNode) + else: + firstChild = None + + if firstChild is not None: + currentNode = firstChild + else: + while currentNode is not None: + details = self.getNodeDetails(currentNode) + type, details = details[0], details[1:] + if type == ELEMENT: + namespace, name, attributes, hasChildren = details + if name not in voidElements: + yield self.endTag(namespace, name) + if self.tree is currentNode: + currentNode = None + break + nextSibling = self.getNextSibling(currentNode) + if nextSibling is not None: + currentNode = nextSibling + break + else: + currentNode = self.getParentNode(currentNode) diff --git a/libs/html5lib/treewalkers/dom.py b/libs/html5lib/treewalkers/dom.py new file mode 100644 index 00000000..383b46cb --- /dev/null +++ b/libs/html5lib/treewalkers/dom.py @@ -0,0 +1,41 @@ +from xml.dom import Node + +import gettext +_ = gettext.gettext + +import _base +from html5lib.constants import voidElements + +class TreeWalker(_base.NonRecursiveTreeWalker): + def getNodeDetails(self, node): + if node.nodeType == Node.DOCUMENT_TYPE_NODE: + return _base.DOCTYPE, node.name, node.publicId, node.systemId + + elif node.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE): + return _base.TEXT, node.nodeValue + + elif node.nodeType == Node.ELEMENT_NODE: + attrs = {} + for attr in node.attributes.keys(): + attr = node.getAttributeNode(attr) + attrs[(attr.namespaceURI,attr.localName)] = attr.value + return (_base.ELEMENT, node.namespaceURI, node.nodeName, + attrs, node.hasChildNodes()) + + elif node.nodeType == Node.COMMENT_NODE: + return _base.COMMENT, node.nodeValue + + elif node.nodeType in (Node.DOCUMENT_NODE, Node.DOCUMENT_FRAGMENT_NODE): + return (_base.DOCUMENT,) + + else: + return _base.UNKNOWN, node.nodeType + + def getFirstChild(self, node): + return node.firstChild + + def getNextSibling(self, node): + return node.nextSibling + + def getParentNode(self, node): + return node.parentNode diff --git a/libs/html5lib/treewalkers/etree.py b/libs/html5lib/treewalkers/etree.py new file mode 100644 index 00000000..13b03194 --- /dev/null +++ b/libs/html5lib/treewalkers/etree.py @@ -0,0 +1,141 @@ +import gettext +_ = gettext.gettext + +try: + from types import ModuleType +except: + from new import module as ModuleType +import copy +import re + +import _base +from html5lib.constants import voidElements + +tag_regexp = re.compile("{([^}]*)}(.*)") + +moduleCache = {} + +def getETreeModule(ElementTreeImplementation): + name = "_" + ElementTreeImplementation.__name__+"builder" + if name in moduleCache: + return moduleCache[name] + else: + mod = ModuleType("_" + ElementTreeImplementation.__name__+"builder") + objs = getETreeBuilder(ElementTreeImplementation) + mod.__dict__.update(objs) + moduleCache[name] = mod + return mod + +def getETreeBuilder(ElementTreeImplementation): + ElementTree = ElementTreeImplementation + + class TreeWalker(_base.NonRecursiveTreeWalker): + """Given the particular ElementTree representation, this implementation, + to avoid using recursion, returns "nodes" as tuples with the following + content: + + 1. The current element + + 2. The index of the element relative to its parent + + 3. A stack of ancestor elements + + 4. A flag "text", "tail" or None to indicate if the current node is a + text node; either the text or tail of the current element (1) + """ + def getNodeDetails(self, node): + if isinstance(node, tuple): # It might be the root Element + elt, key, parents, flag = node + if flag in ("text", "tail"): + return _base.TEXT, getattr(elt, flag) + else: + node = elt + + if not(hasattr(node, "tag")): + node = node.getroot() + + if node.tag in ("", ""): + return (_base.DOCUMENT,) + + elif node.tag == "": + return (_base.DOCTYPE, node.text, + node.get("publicId"), node.get("systemId")) + + elif node.tag == ElementTree.Comment: + return _base.COMMENT, node.text + + else: + assert type(node.tag) in (str, unicode), type(node.tag) + #This is assumed to be an ordinary element + match = tag_regexp.match(node.tag) + if match: + namespace, tag = match.groups() + else: + namespace = None + tag = node.tag + attrs = {} + for name, value in node.attrib.items(): + match = tag_regexp.match(name) + if match: + attrs[(match.group(1),match.group(2))] = value + else: + attrs[(None,name)] = value + return (_base.ELEMENT, namespace, tag, + attrs, len(node) or node.text) + + def getFirstChild(self, node): + if isinstance(node, tuple): + element, key, parents, flag = node + else: + element, key, parents, flag = node, None, [], None + + if flag in ("text", "tail"): + return None + else: + if element.text: + return element, key, parents, "text" + elif len(element): + parents.append(element) + return element[0], 0, parents, None + else: + return None + + def getNextSibling(self, node): + if isinstance(node, tuple): + element, key, parents, flag = node + else: + return None + + if flag == "text": + if len(element): + parents.append(element) + return element[0], 0, parents, None + else: + return None + else: + if element.tail and flag != "tail": + return element, key, parents, "tail" + elif key < len(parents[-1]) - 1: + return parents[-1][key+1], key+1, parents, None + else: + return None + + def getParentNode(self, node): + if isinstance(node, tuple): + element, key, parents, flag = node + else: + return None + + if flag == "text": + if not parents: + return element + else: + return element, key, parents, None + else: + parent = parents.pop() + if not parents: + return parent + else: + return parent, list(parents[-1]).index(parent), parents, None + + return locals() diff --git a/libs/html5lib/treewalkers/genshistream.py b/libs/html5lib/treewalkers/genshistream.py new file mode 100644 index 00000000..ef71a83e --- /dev/null +++ b/libs/html5lib/treewalkers/genshistream.py @@ -0,0 +1,70 @@ +from genshi.core import START, END, XML_NAMESPACE, DOCTYPE, TEXT +from genshi.core import START_NS, END_NS, START_CDATA, END_CDATA, PI, COMMENT +from genshi.output import NamespaceFlattener + +import _base + +from html5lib.constants import voidElements + +class TreeWalker(_base.TreeWalker): + def __iter__(self): + depth = 0 + ignore_until = None + previous = None + for event in self.tree: + if previous is not None: + if previous[0] == START: + depth += 1 + if ignore_until <= depth: + ignore_until = None + if ignore_until is None: + for token in self.tokens(previous, event): + yield token + if token["type"] == "EmptyTag": + ignore_until = depth + if previous[0] == END: + depth -= 1 + previous = event + if previous is not None: + if ignore_until is None or ignore_until <= depth: + for token in self.tokens(previous, None): + yield token + elif ignore_until is not None: + raise ValueError("Illformed DOM event stream: void element without END_ELEMENT") + + def tokens(self, event, next): + kind, data, pos = event + if kind == START: + tag, attrib = data + name = tag.localname + namespace = tag.namespace + if tag in voidElements: + for token in self.emptyTag(namespace, name, list(attrib), + not next or next[0] != END + or next[1] != tag): + yield token + else: + yield self.startTag(namespace, name, list(attrib)) + + elif kind == END: + name = data.localname + namespace = data.namespace + if name not in voidElements: + yield self.endTag(namespace, name) + + elif kind == COMMENT: + yield self.comment(data) + + elif kind == TEXT: + for token in self.text(data): + yield token + + elif kind == DOCTYPE: + yield self.doctype(*data) + + elif kind in (XML_NAMESPACE, DOCTYPE, START_NS, END_NS, \ + START_CDATA, END_CDATA, PI): + pass + + else: + yield self.unknown(kind) diff --git a/libs/html5lib/treewalkers/lxmletree.py b/libs/html5lib/treewalkers/lxmletree.py new file mode 100644 index 00000000..5f328059 --- /dev/null +++ b/libs/html5lib/treewalkers/lxmletree.py @@ -0,0 +1,186 @@ +from lxml import etree +from html5lib.treebuilders.etree import tag_regexp + +from gettext import gettext +_ = gettext + +import _base + +from html5lib.constants import voidElements +from html5lib import ihatexml + +class Root(object): + def __init__(self, et): + self.elementtree = et + self.children = [] + if et.docinfo.internalDTD: + self.children.append(Doctype(self, et.docinfo.root_name, + et.docinfo.public_id, + et.docinfo.system_url)) + root = et.getroot() + node = root + + while node.getprevious() is not None: + node = node.getprevious() + while node is not None: + self.children.append(node) + node = node.getnext() + + self.text = None + self.tail = None + + def __getitem__(self, key): + return self.children[key] + + def getnext(self): + return None + + def __len__(self): + return 1 + +class Doctype(object): + def __init__(self, root_node, name, public_id, system_id): + self.root_node = root_node + self.name = name + self.public_id = public_id + self.system_id = system_id + + self.text = None + self.tail = None + + def getnext(self): + return self.root_node.children[1] + +class FragmentRoot(Root): + def __init__(self, children): + self.children = [FragmentWrapper(self, child) for child in children] + self.text = self.tail = None + + def getnext(self): + return None + +class FragmentWrapper(object): + def __init__(self, fragment_root, obj): + self.root_node = fragment_root + self.obj = obj + if hasattr(self.obj, 'text'): + self.text = self.obj.text + else: + self.text = None + if hasattr(self.obj, 'tail'): + self.tail = self.obj.tail + else: + self.tail = None + self.isstring = isinstance(obj, basestring) + + def __getattr__(self, name): + return getattr(self.obj, name) + + def getnext(self): + siblings = self.root_node.children + idx = siblings.index(self) + if idx < len(siblings) - 1: + return siblings[idx + 1] + else: + return None + + def __getitem__(self, key): + return self.obj[key] + + def __nonzero__(self): + return bool(self.obj) + + def getparent(self): + return None + + def __str__(self): + return str(self.obj) + + def __unicode__(self): + return unicode(self.obj) + + def __len__(self): + return len(self.obj) + + +class TreeWalker(_base.NonRecursiveTreeWalker): + def __init__(self, tree): + if hasattr(tree, "getroot"): + tree = Root(tree) + elif isinstance(tree, list): + tree = FragmentRoot(tree) + _base.NonRecursiveTreeWalker.__init__(self, tree) + self.filter = ihatexml.InfosetFilter() + def getNodeDetails(self, node): + if isinstance(node, tuple): # Text node + node, key = node + assert key in ("text", "tail"), _("Text nodes are text or tail, found %s") % key + return _base.TEXT, getattr(node, key) + + elif isinstance(node, Root): + return (_base.DOCUMENT,) + + elif isinstance(node, Doctype): + return _base.DOCTYPE, node.name, node.public_id, node.system_id + + elif isinstance(node, FragmentWrapper) and node.isstring: + return _base.TEXT, node + + elif node.tag == etree.Comment: + return _base.COMMENT, node.text + + elif node.tag == etree.Entity: + return _base.ENTITY, node.text[1:-1] # strip &; + + else: + #This is assumed to be an ordinary element + match = tag_regexp.match(node.tag) + if match: + namespace, tag = match.groups() + else: + namespace = None + tag = node.tag + attrs = {} + for name, value in node.attrib.items(): + match = tag_regexp.match(name) + if match: + attrs[(match.group(1),match.group(2))] = value + else: + attrs[(None,name)] = value + return (_base.ELEMENT, namespace, self.filter.fromXmlName(tag), + attrs, len(node) > 0 or node.text) + + def getFirstChild(self, node): + assert not isinstance(node, tuple), _("Text nodes have no children") + + assert len(node) or node.text, "Node has no children" + if node.text: + return (node, "text") + else: + return node[0] + + def getNextSibling(self, node): + if isinstance(node, tuple): # Text node + node, key = node + assert key in ("text", "tail"), _("Text nodes are text or tail, found %s") % key + if key == "text": + # XXX: we cannot use a "bool(node) and node[0] or None" construct here + # because node[0] might evaluate to False if it has no child element + if len(node): + return node[0] + else: + return None + else: # tail + return node.getnext() + + return node.tail and (node, "tail") or node.getnext() + + def getParentNode(self, node): + if isinstance(node, tuple): # Text node + node, key = node + assert key in ("text", "tail"), _("Text nodes are text or tail, found %s") % key + if key == "text": + return node + # else: fallback to "normal" processing + + return node.getparent() diff --git a/libs/html5lib/treewalkers/pulldom.py b/libs/html5lib/treewalkers/pulldom.py new file mode 100644 index 00000000..1f8b95b8 --- /dev/null +++ b/libs/html5lib/treewalkers/pulldom.py @@ -0,0 +1,60 @@ +from xml.dom.pulldom import START_ELEMENT, END_ELEMENT, \ + COMMENT, IGNORABLE_WHITESPACE, CHARACTERS + +import _base + +from html5lib.constants import voidElements + +class TreeWalker(_base.TreeWalker): + def __iter__(self): + ignore_until = None + previous = None + for event in self.tree: + if previous is not None and \ + (ignore_until is None or previous[1] is ignore_until): + if previous[1] is ignore_until: + ignore_until = None + for token in self.tokens(previous, event): + yield token + if token["type"] == "EmptyTag": + ignore_until = previous[1] + previous = event + if ignore_until is None or previous[1] is ignore_until: + for token in self.tokens(previous, None): + yield token + elif ignore_until is not None: + raise ValueError("Illformed DOM event stream: void element without END_ELEMENT") + + def tokens(self, event, next): + type, node = event + if type == START_ELEMENT: + name = node.nodeName + namespace = node.namespaceURI + attrs = {} + for attr in node.attributes.keys(): + attr = node.getAttributeNode(attr) + attrs[(attr.namespaceURI,attr.localName)] = attr.value + if name in voidElements: + for token in self.emptyTag(namespace, + name, + attrs, + not next or next[1] is not node): + yield token + else: + yield self.startTag(namespace, name, attrs) + + elif type == END_ELEMENT: + name = node.nodeName + namespace = node.namespaceURI + if name not in voidElements: + yield self.endTag(namespace, name) + + elif type == COMMENT: + yield self.comment(node.nodeValue) + + elif type in (IGNORABLE_WHITESPACE, CHARACTERS): + for token in self.text(node.nodeValue): + yield token + + else: + yield self.unknown(type) diff --git a/libs/html5lib/treewalkers/simpletree.py b/libs/html5lib/treewalkers/simpletree.py new file mode 100644 index 00000000..9e6bd4c5 --- /dev/null +++ b/libs/html5lib/treewalkers/simpletree.py @@ -0,0 +1,78 @@ +import gettext +_ = gettext.gettext + +import _base + +class TreeWalker(_base.NonRecursiveTreeWalker): + """Given that simpletree has no performant way of getting a node's + next sibling, this implementation returns "nodes" as tuples with the + following content: + + 1. The parent Node (Element, Document or DocumentFragment) + + 2. The child index of the current node in its parent's children list + + 3. A list used as a stack of all ancestors. It is a pair tuple whose + first item is a parent Node and second item is a child index. + """ + + def getNodeDetails(self, node): + if isinstance(node, tuple): # It might be the root Node + parent, idx, parents = node + node = parent.childNodes[idx] + + # testing node.type allows us not to import treebuilders.simpletree + if node.type in (1, 2): # Document or DocumentFragment + return (_base.DOCUMENT,) + + elif node.type == 3: # DocumentType + return _base.DOCTYPE, node.name, node.publicId, node.systemId + + elif node.type == 4: # TextNode + return _base.TEXT, node.value + + elif node.type == 5: # Element + attrs = {} + for name, value in node.attributes.items(): + if isinstance(name, tuple): + attrs[(name[2],name[1])] = value + else: + attrs[(None,name)] = value + return (_base.ELEMENT, node.namespace, node.name, + attrs, node.hasContent()) + + elif node.type == 6: # CommentNode + return _base.COMMENT, node.data + + else: + return _node.UNKNOWN, node.type + + def getFirstChild(self, node): + if isinstance(node, tuple): # It might be the root Node + parent, idx, parents = node + parents.append((parent, idx)) + node = parent.childNodes[idx] + else: + parents = [] + + assert node.hasContent(), "Node has no children" + return (node, 0, parents) + + def getNextSibling(self, node): + assert isinstance(node, tuple), "Node is not a tuple: " + str(node) + parent, idx, parents = node + idx += 1 + if len(parent.childNodes) > idx: + return (parent, idx, parents) + else: + return None + + def getParentNode(self, node): + assert isinstance(node, tuple) + parent, idx, parents = node + if parents: + parent, idx = parents.pop() + return parent, idx, parents + else: + # HACK: We could return ``parent`` but None will stop the algorithm the same way + return None diff --git a/libs/html5lib/treewalkers/soup.py b/libs/html5lib/treewalkers/soup.py new file mode 100644 index 00000000..fca65ecb --- /dev/null +++ b/libs/html5lib/treewalkers/soup.py @@ -0,0 +1,60 @@ +import re +import gettext +_ = gettext.gettext + +from BeautifulSoup import BeautifulSoup, Declaration, Comment, Tag +from html5lib.constants import namespaces +import _base + +class TreeWalker(_base.NonRecursiveTreeWalker): + doctype_regexp = re.compile( + r'DOCTYPE\s+(?P[^\s]*)(\s*PUBLIC\s*"(?P.*)"\s*"(?P.*)"|\s*SYSTEM\s*"(?P.*)")?') + def getNodeDetails(self, node): + if isinstance(node, BeautifulSoup): # Document or DocumentFragment + return (_base.DOCUMENT,) + + elif isinstance(node, Declaration): # DocumentType + string = unicode(node.string) + #Slice needed to remove markup added during unicode conversion, + #but only in some versions of BeautifulSoup/Python + if string.startswith(''): + string = string[2:-1] + m = self.doctype_regexp.match(string) + #This regexp approach seems wrong and fragile + #but beautiful soup stores the doctype as a single thing and we want the seperate bits + #It should work as long as the tree is created by html5lib itself but may be wrong if it's + #been modified at all + #We could just feed to it a html5lib tokenizer, I guess... + assert m is not None, "DOCTYPE did not match expected format" + + name = m.group('name') + publicId = m.group('publicId') + if publicId is not None: + systemId = m.group('systemId1') + else: + systemId = m.group('systemId2') + return _base.DOCTYPE, name, publicId or "", systemId or "" + + elif isinstance(node, Comment): + string = unicode(node.string) + if string.startswith(''): + string = string[4:-3] + return _base.COMMENT, string + + elif isinstance(node, unicode): # TextNode + return _base.TEXT, node + + elif isinstance(node, Tag): # Element + return (_base.ELEMENT, namespaces["html"], node.name, + dict(node.attrs).items(), node.contents) + else: + return _base.UNKNOWN, node.__class__.__name__ + + def getFirstChild(self, node): + return node.contents[0] + + def getNextSibling(self, node): + return node.nextSibling + + def getParentNode(self, node): + return node.parent diff --git a/libs/html5lib/utils.py b/libs/html5lib/utils.py new file mode 100644 index 00000000..d53f6788 --- /dev/null +++ b/libs/html5lib/utils.py @@ -0,0 +1,175 @@ +try: + frozenset +except NameError: + #Import from the sets module for python 2.3 + from sets import Set as set + from sets import ImmutableSet as frozenset + +class MethodDispatcher(dict): + """Dict with 2 special properties: + + On initiation, keys that are lists, sets or tuples are converted to + multiple keys so accessing any one of the items in the original + list-like object returns the matching value + + md = MethodDispatcher({("foo", "bar"):"baz"}) + md["foo"] == "baz" + + A default value which can be set through the default attribute. + """ + + def __init__(self, items=()): + # Using _dictEntries instead of directly assigning to self is about + # twice as fast. Please do careful performance testing before changing + # anything here. + _dictEntries = [] + for name,value in items: + if type(name) in (list, tuple, frozenset, set): + for item in name: + _dictEntries.append((item, value)) + else: + _dictEntries.append((name, value)) + dict.__init__(self, _dictEntries) + self.default = None + + def __getitem__(self, key): + return dict.get(self, key, self.default) + +#Pure python implementation of deque taken from the ASPN Python Cookbook +#Original code by Raymond Hettinger + +class deque(object): + + def __init__(self, iterable=(), maxsize=-1): + if not hasattr(self, 'data'): + self.left = self.right = 0 + self.data = {} + self.maxsize = maxsize + self.extend(iterable) + + def append(self, x): + self.data[self.right] = x + self.right += 1 + if self.maxsize != -1 and len(self) > self.maxsize: + self.popleft() + + def appendleft(self, x): + self.left -= 1 + self.data[self.left] = x + if self.maxsize != -1 and len(self) > self.maxsize: + self.pop() + + def pop(self): + if self.left == self.right: + raise IndexError('cannot pop from empty deque') + self.right -= 1 + elem = self.data[self.right] + del self.data[self.right] + return elem + + def popleft(self): + if self.left == self.right: + raise IndexError('cannot pop from empty deque') + elem = self.data[self.left] + del self.data[self.left] + self.left += 1 + return elem + + def clear(self): + self.data.clear() + self.left = self.right = 0 + + def extend(self, iterable): + for elem in iterable: + self.append(elem) + + def extendleft(self, iterable): + for elem in iterable: + self.appendleft(elem) + + def rotate(self, n=1): + if self: + n %= len(self) + for i in xrange(n): + self.appendleft(self.pop()) + + def __getitem__(self, i): + if i < 0: + i += len(self) + try: + return self.data[i + self.left] + except KeyError: + raise IndexError + + def __setitem__(self, i, value): + if i < 0: + i += len(self) + try: + self.data[i + self.left] = value + except KeyError: + raise IndexError + + def __delitem__(self, i): + size = len(self) + if not (-size <= i < size): + raise IndexError + data = self.data + if i < 0: + i += size + for j in xrange(self.left+i, self.right-1): + data[j] = data[j+1] + self.pop() + + def __len__(self): + return self.right - self.left + + def __cmp__(self, other): + if type(self) != type(other): + return cmp(type(self), type(other)) + return cmp(list(self), list(other)) + + def __repr__(self, _track=[]): + if id(self) in _track: + return '...' + _track.append(id(self)) + r = 'deque(%r)' % (list(self),) + _track.remove(id(self)) + return r + + def __getstate__(self): + return (tuple(self),) + + def __setstate__(self, s): + self.__init__(s[0]) + + def __hash__(self): + raise TypeError + + def __copy__(self): + return self.__class__(self) + + def __deepcopy__(self, memo={}): + from copy import deepcopy + result = self.__class__() + memo[id(self)] = result + result.__init__(deepcopy(tuple(self), memo)) + return result + +#Some utility functions to dal with weirdness around UCS2 vs UCS4 +#python builds + +def encodingType(): + if len() == 2: + return "UCS2" + else: + return "UCS4" + +def isSurrogatePair(data): + return (len(data) == 2 and + ord(data[0]) >= 0xD800 and ord(data[0]) <= 0xDBFF and + ord(data[1]) >= 0xDC00 and ord(data[1]) <= 0xDFFF) + +def surrogatePairToCodepoint(data): + char_val = (0x10000 + (ord(data[0]) - 0xD800) * 0x400 + + (ord(data[1]) - 0xDC00)) + return char_val diff --git a/libs/oauthlib/__init__.py b/libs/oauthlib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/oauthlib/common.py b/libs/oauthlib/common.py new file mode 100644 index 00000000..4cdfd0d4 --- /dev/null +++ b/libs/oauthlib/common.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +""" +oauthlib.common +~~~~~~~~~~~~~~ + +This module provides data structures and utilities common +to all implementations of OAuth. +""" + +import re +import urllib +import urlparse + + +always_safe = (u'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + u'abcdefghijklmnopqrstuvwxyz' + u'0123456789' u'_.-') + + +def quote(s, safe=u'/'): + encoded = s.encode("utf-8") + quoted = urllib.quote(encoded, safe) + return quoted.decode("utf-8") + + +def unquote(s): + encoded = s.encode("utf-8") + unquoted = urllib.unquote(encoded) + return unquoted.decode("utf-8") + + +def urlencode(params): + utf8_params = encode_params_utf8(params) + urlencoded = urllib.urlencode(utf8_params) + return urlencoded.decode("utf-8") + + +def encode_params_utf8(params): + """Ensures that all parameters in a list of 2-element tuples are encoded to + bytestrings using UTF-8 + """ + encoded = [] + for k, v in params: + encoded.append(( + k.encode('utf-8') if isinstance(k, unicode) else k, + v.encode('utf-8') if isinstance(v, unicode) else v)) + return encoded + + +def decode_params_utf8(params): + """Ensures that all parameters in a list of 2-element tuples are decoded to + unicode using UTF-8. + """ + decoded = [] + for k, v in params: + decoded.append(( + k.decode('utf-8') if isinstance(k, str) else k, + v.decode('utf-8') if isinstance(v, str) else v)) + return decoded + + +urlencoded = set(always_safe) | set(u'=&;%+~') + + +def urldecode(query): + """Decode a query string in x-www-form-urlencoded format into a sequence + of two-element tuples. + + Unlike urlparse.parse_qsl(..., strict_parsing=True) urldecode will enforce + correct formatting of the query string by validation. If validation fails + a ValueError will be raised. urllib.parse_qsl will only raise errors if + any of name-value pairs omits the equals sign. + """ + # Check if query contains invalid characters + if query and not set(query) <= urlencoded: + raise ValueError('Invalid characters in query string.') + + # Check for correctly hex encoded values using a regular expression + # All encoded values begin with % followed by two hex characters + # correct = %00, %A0, %0A, %FF + # invalid = %G0, %5H, %PO + invalid_hex = u'%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]' + if len(re.findall(invalid_hex, query)): + raise ValueError('Invalid hex encoding in query string.') + + query = query.decode('utf-8') if isinstance(query, str) else query + # We want to allow queries such as "c2" whereas urlparse.parse_qsl + # with the strict_parsing flag will not. + params = urlparse.parse_qsl(query, keep_blank_values=True) + + # unicode all the things + return decode_params_utf8(params) + + +def extract_params(raw): + """Extract parameters and return them as a list of 2-tuples. + + Will successfully extract parameters from urlencoded query strings, + dicts, or lists of 2-tuples. Empty strings/dicts/lists will return an + empty list of parameters. Any other input will result in a return + value of None. + """ + if isinstance(raw, basestring): + try: + params = urldecode(raw) + except ValueError: + params = None + elif hasattr(raw, '__iter__'): + try: + dict(raw) + except ValueError: + params = None + except TypeError: + params = None + else: + params = list(raw.items() if isinstance(raw, dict) else raw) + params = decode_params_utf8(params) + else: + params = None + + return params + + +class Request(object): + """A malleable representation of a signable HTTP request. + + Body argument may contain any data, but parameters will only be decoded if + they are one of: + + * urlencoded query string + * dict + * list of 2-tuples + + Anything else will be treated as raw body data to be passed through + unmolested. + """ + + def __init__(self, uri, http_method=u'GET', body=None, headers=None): + self.uri = uri + self.http_method = http_method + self.headers = headers or {} + self.body = body + self.decoded_body = extract_params(body) + self.oauth_params = [] + + @property + def uri_query(self): + return urlparse.urlparse(self.uri).query + + @property + def uri_query_params(self): + return urlparse.parse_qsl(self.uri_query, keep_blank_values=True, + strict_parsing=True) diff --git a/libs/oauthlib/oauth1/__init__.py b/libs/oauthlib/oauth1/__init__.py new file mode 100644 index 00000000..ef692b57 --- /dev/null +++ b/libs/oauthlib/oauth1/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +""" +oauthlib.oauth1 +~~~~~~~~~~~~~~ + +This module is a wrapper for the most recent implementation of OAuth 1.0 Client +and Server classes. +""" + +from .rfc5849 import Client, Server + diff --git a/libs/oauthlib/oauth1/rfc5849/__init__.py b/libs/oauthlib/oauth1/rfc5849/__init__.py new file mode 100644 index 00000000..03fb8b25 --- /dev/null +++ b/libs/oauthlib/oauth1/rfc5849/__init__.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +""" +oauthlib.oauth1.rfc5849 +~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" + +import logging +import urlparse + +from oauthlib.common import Request, urlencode +from . import parameters, signature, utils + +SIGNATURE_HMAC = u"HMAC-SHA1" +SIGNATURE_RSA = u"RSA-SHA1" +SIGNATURE_PLAINTEXT = u"PLAINTEXT" +SIGNATURE_METHODS = (SIGNATURE_HMAC, SIGNATURE_RSA, SIGNATURE_PLAINTEXT) + +SIGNATURE_TYPE_AUTH_HEADER = u'AUTH_HEADER' +SIGNATURE_TYPE_QUERY = u'QUERY' +SIGNATURE_TYPE_BODY = u'BODY' + +CONTENT_TYPE_FORM_URLENCODED = u'application/x-www-form-urlencoded' + + +class Client(object): + """A client used to sign OAuth 1.0 RFC 5849 requests""" + def __init__(self, client_key, + client_secret=None, + resource_owner_key=None, + resource_owner_secret=None, + callback_uri=None, + signature_method=SIGNATURE_HMAC, + signature_type=SIGNATURE_TYPE_AUTH_HEADER, + rsa_key=None, verifier=None): + self.client_key = client_key + self.client_secret = client_secret + self.resource_owner_key = resource_owner_key + self.resource_owner_secret = resource_owner_secret + self.signature_method = signature_method + self.signature_type = signature_type + self.callback_uri = callback_uri + self.rsa_key = rsa_key + self.verifier = verifier + + if self.signature_method == SIGNATURE_RSA and self.rsa_key is None: + raise ValueError('rsa_key is required when using RSA signature method.') + + def get_oauth_signature(self, request): + """Get an OAuth signature to be used in signing a request + """ + if self.signature_method == SIGNATURE_PLAINTEXT: + # fast-path + return signature.sign_plaintext(self.client_secret, + self.resource_owner_secret) + + uri, headers, body = self._render(request) + + collected_params = signature.collect_parameters( + uri_query=urlparse.urlparse(uri).query, + body=body, + headers=headers) + logging.debug("Collected params: {0}".format(collected_params)) + + normalized_params = signature.normalize_parameters(collected_params) + normalized_uri = signature.normalize_base_string_uri(request.uri) + logging.debug("Normalized params: {0}".format(normalized_params)) + logging.debug("Normalized URI: {0}".format(normalized_uri)) + + base_string = signature.construct_base_string(request.http_method, + normalized_uri, normalized_params) + + logging.debug("Base signing string: {0}".format(base_string)) + + if self.signature_method == SIGNATURE_HMAC: + sig = signature.sign_hmac_sha1(base_string, self.client_secret, + self.resource_owner_secret) + elif self.signature_method == SIGNATURE_RSA: + sig = signature.sign_rsa_sha1(base_string, self.rsa_key) + else: + sig = signature.sign_plaintext(self.client_secret, + self.resource_owner_secret) + + logging.debug("Signature: {0}".format(sig)) + return sig + + def get_oauth_params(self): + """Get the basic OAuth parameters to be used in generating a signature. + """ + params = [ + (u'oauth_nonce', utils.generate_nonce()), + (u'oauth_timestamp', utils.generate_timestamp()), + (u'oauth_version', u'1.0'), + (u'oauth_signature_method', self.signature_method), + (u'oauth_consumer_key', self.client_key), + ] + if self.resource_owner_key: + params.append((u'oauth_token', self.resource_owner_key)) + if self.callback_uri: + params.append((u'oauth_callback', self.callback_uri)) + if self.verifier: + params.append((u'oauth_verifier', self.verifier)) + + return params + + def _render(self, request, formencode=False): + """Render a signed request according to signature type + + Returns a 3-tuple containing the request URI, headers, and body. + + If the formencode argument is True and the body contains parameters, it + is escaped and returned as a valid formencoded string. + """ + # TODO what if there are body params on a header-type auth? + # TODO what if there are query params on a body-type auth? + + uri, headers, body = request.uri, request.headers, request.body + + # TODO: right now these prepare_* methods are very narrow in scope--they + # only affect their little thing. In some cases (for example, with + # header auth) it might be advantageous to allow these methods to touch + # other parts of the request, like the headers—so the prepare_headers + # method could also set the Content-Type header to x-www-form-urlencoded + # like the spec requires. This would be a fundamental change though, and + # I'm not sure how I feel about it. + if self.signature_type == SIGNATURE_TYPE_AUTH_HEADER: + headers = parameters.prepare_headers(request.oauth_params, request.headers) + elif self.signature_type == SIGNATURE_TYPE_BODY and request.decoded_body is not None: + body = parameters.prepare_form_encoded_body(request.oauth_params, request.decoded_body) + if formencode: + body = urlencode(body) + headers['Content-Type'] = u'application/x-www-form-urlencoded' + elif self.signature_type == SIGNATURE_TYPE_QUERY: + uri = parameters.prepare_request_uri_query(request.oauth_params, request.uri) + else: + raise ValueError('Unknown signature type specified.') + + return uri, headers, body + + def sign(self, uri, http_method=u'GET', body=None, headers=None): + """Sign a request + + Signs an HTTP request with the specified parts. + + Returns a 3-tuple of the signed request's URI, headers, and body. + Note that http_method is not returned as it is unaffected by the OAuth + signing process. + + The body argument may be a dict, a list of 2-tuples, or a formencoded + string. The Content-Type header must be 'application/x-www-form-urlencoded' + if it is present. + + If the body argument is not one of the above, it will be returned + verbatim as it is unaffected by the OAuth signing process. Attempting to + sign a request with non-formencoded data using the OAuth body signature + type is invalid and will raise an exception. + + If the body does contain parameters, it will be returned as a properly- + formatted formencoded string. + + All string data MUST be unicode. This includes strings inside body + dicts, for example. + """ + # normalize request data + request = Request(uri, http_method, body, headers) + + # sanity check + content_type = request.headers.get('Content-Type', None) + multipart = content_type and content_type.startswith('multipart/') + should_have_params = content_type == CONTENT_TYPE_FORM_URLENCODED + has_params = request.decoded_body is not None + # 3.4.1.3.1. Parameter Sources + # [Parameters are collected from the HTTP request entity-body, but only + # if [...]: + # * The entity-body is single-part. + if multipart and has_params: + raise ValueError("Headers indicate a multipart body but body contains parameters.") + # * The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # [W3C.REC-html40-19980424]. + elif should_have_params and not has_params: + raise ValueError("Headers indicate a formencoded body but body was not decodable.") + # * The HTTP request entity-header includes the "Content-Type" + # header field set to "application/x-www-form-urlencoded". + elif not should_have_params and has_params: + raise ValueError("Body contains parameters but Content-Type header was not set.") + + # 3.5.2. Form-Encoded Body + # Protocol parameters can be transmitted in the HTTP request entity- + # body, but only if the following REQUIRED conditions are met: + # o The entity-body is single-part. + # o The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # [W3C.REC-html40-19980424]. + # o The HTTP request entity-header includes the "Content-Type" header + # field set to "application/x-www-form-urlencoded". + elif self.signature_type == SIGNATURE_TYPE_BODY and not ( + should_have_params and has_params and not multipart): + raise ValueError('Body signatures may only be used with form-urlencoded content') + + # generate the basic OAuth parameters + request.oauth_params = self.get_oauth_params() + + # generate the signature + request.oauth_params.append((u'oauth_signature', self.get_oauth_signature(request))) + + # render the signed request and return it + return self._render(request, formencode=True) + + +class Server(object): + """A server used to verify OAuth 1.0 RFC 5849 requests""" + def __init__(self, signature_method=SIGNATURE_HMAC, rsa_key=None): + self.signature_method = signature_method + self.rsa_key = rsa_key + + def get_client_secret(self, client_key): + raise NotImplementedError("Subclasses must implement this function.") + + def get_resource_owner_secret(self, resource_owner_key): + raise NotImplementedError("Subclasses must implement this function.") + + def get_signature_type_and_params(self, uri_query, headers, body): + signature_types_with_oauth_params = filter(lambda s: s[1], ( + (SIGNATURE_TYPE_AUTH_HEADER, utils.filter_oauth_params( + signature.collect_parameters(headers=headers, + exclude_oauth_signature=False))), + (SIGNATURE_TYPE_BODY, utils.filter_oauth_params( + signature.collect_parameters(body=body, + exclude_oauth_signature=False))), + (SIGNATURE_TYPE_QUERY, utils.filter_oauth_params( + signature.collect_parameters(uri_query=uri_query, + exclude_oauth_signature=False))), + )) + + if len(signature_types_with_oauth_params) > 1: + raise ValueError('oauth_ params must come from only 1 signature type but were found in %s' % ', '.join( + [s[0] for s in signature_types_with_oauth_params])) + try: + signature_type, params = signature_types_with_oauth_params[0] + except IndexError: + raise ValueError('oauth_ params are missing. Could not determine signature type.') + + return signature_type, dict(params) + + def check_client_key(self, client_key): + raise NotImplementedError("Subclasses must implement this function.") + + def check_resource_owner_key(self, client_key, resource_owner_key): + raise NotImplementedError("Subclasses must implement this function.") + + def check_timestamp_and_nonce(self, timestamp, nonce): + raise NotImplementedError("Subclasses must implement this function.") + + def check_request_signature(self, uri, http_method=u'GET', body='', + headers=None): + """Check a request's supplied signature to make sure the request is + valid. + + Servers should return HTTP status 400 if a ValueError exception + is raised and HTTP status 401 on return value False. + + Per `section 3.2`_ of the spec. + + .. _`section 3.2`: http://tools.ietf.org/html/rfc5849#section-3.2 + """ + headers = headers or {} + signature_type = None + # FIXME: urlparse does not return unicode! + uri_query = urlparse.urlparse(uri).query + + signature_type, params = self.get_signature_type_and_params(uri_query, + headers, body) + + # the parameters may not include duplicate oauth entries + filtered_params = utils.filter_oauth_params(params) + if len(filtered_params) != len(params): + raise ValueError("Duplicate OAuth entries.") + + params = dict(params) + request_signature = params.get(u'oauth_signature') + client_key = params.get(u'oauth_consumer_key') + resource_owner_key = params.get(u'oauth_token') + nonce = params.get(u'oauth_nonce') + timestamp = params.get(u'oauth_timestamp') + callback_uri = params.get(u'oauth_callback') + verifier = params.get(u'oauth_verifier') + signature_method = params.get(u'oauth_signature_method') + + # ensure all mandatory parameters are present + if not all((request_signature, client_key, nonce, + timestamp, signature_method)): + raise ValueError("Missing OAuth parameters.") + + # if version is supplied, it must be "1.0" + if u'oauth_version' in params and params[u'oauth_version'] != u'1.0': + raise ValueError("Invalid OAuth version.") + + # signature method must be valid + if not signature_method in SIGNATURE_METHODS: + raise ValueError("Invalid signature method.") + + # ensure client key is valid + if not self.check_client_key(client_key): + return False + + # ensure resource owner key is valid and not expired + if not self.check_resource_owner_key(client_key, resource_owner_key): + return False + + # ensure the nonce and timestamp haven't been used before + if not self.check_timestamp_and_nonce(timestamp, nonce): + return False + + # FIXME: extract realm, then self.check_realm + + # oauth_client parameters depend on client chosen signature method + # which may vary for each request, section 3.4 + # HMAC-SHA1 and PLAINTEXT share parameters + if signature_method == SIGNATURE_RSA: + oauth_client = Client(client_key, + resource_owner_key=resource_owner_key, + callback_uri=callback_uri, + signature_method=signature_method, + signature_type=signature_type, + rsa_key=self.rsa_key, verifier=verifier) + else: + client_secret = self.get_client_secret(client_key) + resource_owner_secret = self.get_resource_owner_secret( + resource_owner_key) + oauth_client = Client(client_key, + client_secret=client_secret, + resource_owner_key=resource_owner_key, + resource_owner_secret=resource_owner_secret, + callback_uri=callback_uri, + signature_method=signature_method, + signature_type=signature_type, + verifier=verifier) + + request = Request(uri, http_method, body, headers) + request.oauth_params = params + + client_signature = oauth_client.get_oauth_signature(request) + + # FIXME: use near constant time string compare to avoid timing attacks + return client_signature == request_signature diff --git a/libs/oauthlib/oauth1/rfc5849/parameters.py b/libs/oauthlib/oauth1/rfc5849/parameters.py new file mode 100644 index 00000000..dee23a43 --- /dev/null +++ b/libs/oauthlib/oauth1/rfc5849/parameters.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +""" +oauthlib.parameters +~~~~~~~~~~~~~~~~~~~ + +This module contains methods related to `section 3.5`_ of the OAuth 1.0a spec. + +.. _`section 3.5`: http://tools.ietf.org/html/rfc5849#section-3.5 +""" + +from urlparse import urlparse, urlunparse +from . import utils +from oauthlib.common import extract_params, urlencode + + +# TODO: do we need filter_params now that oauth_params are handled by Request? +# We can easily pass in just oauth protocol params. +@utils.filter_params +def prepare_headers(oauth_params, headers=None, realm=None): + """**Prepare the Authorization header.** + Per `section 3.5.1`_ of the spec. + + Protocol parameters can be transmitted using the HTTP "Authorization" + header field as defined by `RFC2617`_ with the auth-scheme name set to + "OAuth" (case insensitive). + + For example:: + + Authorization: OAuth realm="Example", + oauth_consumer_key="0685bd9184jfhq22", + oauth_token="ad180jjd733klru7", + oauth_signature_method="HMAC-SHA1", + oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D", + oauth_timestamp="137131200", + oauth_nonce="4572616e48616d6d65724c61686176", + oauth_version="1.0" + + + .. _`section 3.5.1`: http://tools.ietf.org/html/rfc5849#section-3.5.1 + .. _`RFC2617`: http://tools.ietf.org/html/rfc2617 + """ + headers = headers or {} + + # Protocol parameters SHALL be included in the "Authorization" header + # field as follows: + authorization_header_parameters_parts = [] + for oauth_parameter_name, value in oauth_params: + # 1. Parameter names and values are encoded per Parameter Encoding + # (`Section 3.6`_) + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + escaped_name = utils.escape(oauth_parameter_name) + escaped_value = utils.escape(value) + + # 2. Each parameter's name is immediately followed by an "=" character + # (ASCII code 61), a """ character (ASCII code 34), the parameter + # value (MAY be empty), and another """ character (ASCII code 34). + part = u'{0}="{1}"'.format(escaped_name, escaped_value) + + authorization_header_parameters_parts.append(part) + + # 3. Parameters are separated by a "," character (ASCII code 44) and + # OPTIONAL linear whitespace per `RFC2617`_. + # + # .. _`RFC2617`: http://tools.ietf.org/html/rfc2617 + authorization_header_parameters = ', '.join( + authorization_header_parameters_parts) + + # 4. The OPTIONAL "realm" parameter MAY be added and interpreted per + # `RFC2617 section 1.2`_. + # + # .. _`RFC2617 section 1.2`: http://tools.ietf.org/html/rfc2617#section-1.2 + if realm: + # NOTE: realm should *not* be escaped + authorization_header_parameters = (u'realm="%s", ' % realm + + authorization_header_parameters) + + # the auth-scheme name set to "OAuth" (case insensitive). + authorization_header = u'OAuth %s' % authorization_header_parameters + + # contribute the Authorization header to the given headers + full_headers = {} + full_headers.update(headers) + full_headers[u'Authorization'] = authorization_header + return full_headers + + +def _append_params(oauth_params, params): + """Append OAuth params to an existing set of parameters. + + Both params and oauth_params is must be lists of 2-tuples. + + Per `section 3.5.2`_ and `3.5.3`_ of the spec. + + .. _`section 3.5.2`: http://tools.ietf.org/html/rfc5849#section-3.5.2 + .. _`3.5.3`: http://tools.ietf.org/html/rfc5849#section-3.5.3 + + """ + merged = list(params) + merged.extend(oauth_params) + # The request URI / entity-body MAY include other request-specific + # parameters, in which case, the protocol parameters SHOULD be appended + # following the request-specific parameters, properly separated by an "&" + # character (ASCII code 38) + merged.sort(key=lambda i: i[0].startswith('oauth_')) + return merged + + +def prepare_form_encoded_body(oauth_params, body): + """Prepare the Form-Encoded Body. + + Per `section 3.5.2`_ of the spec. + + .. _`section 3.5.2`: http://tools.ietf.org/html/rfc5849#section-3.5.2 + + """ + # append OAuth params to the existing body + return _append_params(oauth_params, body) + + +def prepare_request_uri_query(oauth_params, uri): + """Prepare the Request URI Query. + + Per `section 3.5.3`_ of the spec. + + .. _`section 3.5.3`: http://tools.ietf.org/html/rfc5849#section-3.5.3 + + """ + # append OAuth params to the existing set of query components + sch, net, path, par, query, fra = urlparse(uri) + query = urlencode(_append_params(oauth_params, extract_params(query) or [])) + return urlunparse((sch, net, path, par, query, fra)) diff --git a/libs/oauthlib/oauth1/rfc5849/signature.py b/libs/oauthlib/oauth1/rfc5849/signature.py new file mode 100644 index 00000000..99101d43 --- /dev/null +++ b/libs/oauthlib/oauth1/rfc5849/signature.py @@ -0,0 +1,501 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +""" +oauthlib.oauth1.rfc5849.signature +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module represents a direct implementation of `section 3.4`_ of the spec. + +Terminology: + * Client: software interfacing with an OAuth API + * Server: the API provider + * Resource Owner: the user who is granting authorization to the client + +Steps for signing a request: + +1. Collect parameters from the uri query, auth header, & body +2. Normalize those parameters +3. Normalize the uri +4. Pass the normalized uri, normalized parameters, and http method to + construct the base string +5. Pass the base string and any keys needed to a signing function + +.. _`section 3.4`: http://tools.ietf.org/html/rfc5849#section-3.4 +""" +import binascii +import hashlib +import hmac +import urlparse +from . import utils +from oauthlib.common import extract_params + + +def construct_base_string(http_method, base_string_uri, + normalized_encoded_request_parameters): + """**String Construction** + Per `section 3.4.1.1`_ of the spec. + + For example, the HTTP request:: + + POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 + Host: example.com + Content-Type: application/x-www-form-urlencoded + Authorization: OAuth realm="Example", + oauth_consumer_key="9djdj82h48djs9d2", + oauth_token="kkk9d7dh3k39sjv7", + oauth_signature_method="HMAC-SHA1", + oauth_timestamp="137131201", + oauth_nonce="7d8f3e4a", + oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D" + + c2&a3=2+q + + is represented by the following signature base string (line breaks + are for display purposes only):: + + POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q + %26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_ + key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m + ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk + 9d7dh3k39sjv7 + + .. _`section 3.4.1.1`: http://tools.ietf.org/html/rfc5849#section-3.4.1.1 + """ + + # The signature base string is constructed by concatenating together, + # in order, the following HTTP request elements: + + # 1. The HTTP request method in uppercase. For example: "HEAD", + # "GET", "POST", etc. If the request uses a custom HTTP method, it + # MUST be encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + base_string = utils.escape(http_method.upper()) + + # 2. An "&" character (ASCII code 38). + base_string += u'&' + + # 3. The base string URI from `Section 3.4.1.2`_, after being encoded + # (`Section 3.6`_). + # + # .. _`Section 3.4.1.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.2 + # .. _`Section 3.4.6`: http://tools.ietf.org/html/rfc5849#section-3.4.6 + base_string += utils.escape(base_string_uri) + + # 4. An "&" character (ASCII code 38). + base_string += u'&' + + # 5. The request parameters as normalized in `Section 3.4.1.3.2`_, after + # being encoded (`Section 3.6`). + # + # .. _`Section 3.4.1.3.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + # .. _`Section 3.4.6`: http://tools.ietf.org/html/rfc5849#section-3.4.6 + base_string += utils.escape(normalized_encoded_request_parameters) + + return base_string + + +def normalize_base_string_uri(uri): + """**Base String URI** + Per `section 3.4.1.2`_ of the spec. + + For example, the HTTP request:: + + GET /r%20v/X?id=123 HTTP/1.1 + Host: EXAMPLE.COM:80 + + is represented by the base string URI: "http://example.com/r%20v/X". + + In another example, the HTTPS request:: + + GET /?q=1 HTTP/1.1 + Host: www.example.net:8080 + + is represented by the base string URI: "https://www.example.net:8080/". + + .. _`section 3.4.1.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.2 + """ + if not isinstance(uri, unicode): + raise ValueError('uri must be a unicode object.') + + # FIXME: urlparse does not support unicode + scheme, netloc, path, params, query, fragment = urlparse.urlparse(uri) + + # The scheme, authority, and path of the request resource URI `RFC3986` + # are included by constructing an "http" or "https" URI representing + # the request resource (without the query or fragment) as follows: + # + # .. _`RFC2616`: http://tools.ietf.org/html/rfc3986 + + # 1. The scheme and host MUST be in lowercase. + scheme = scheme.lower() + netloc = netloc.lower() + + # 2. The host and port values MUST match the content of the HTTP + # request "Host" header field. + # TODO: enforce this constraint + + # 3. The port MUST be included if it is not the default port for the + # scheme, and MUST be excluded if it is the default. Specifically, + # the port MUST be excluded when making an HTTP request `RFC2616`_ + # to port 80 or when making an HTTPS request `RFC2818`_ to port 443. + # All other non-default port numbers MUST be included. + # + # .. _`RFC2616`: http://tools.ietf.org/html/rfc2616 + # .. _`RFC2818`: http://tools.ietf.org/html/rfc2818 + default_ports = ( + (u'http', u'80'), + (u'https', u'443'), + ) + if u':' in netloc: + host, port = netloc.split(u':', 1) + if (scheme, port) in default_ports: + netloc = host + + return urlparse.urlunparse((scheme, netloc, path, u'', u'', u'')) + + +# ** Request Parameters ** +# +# Per `section 3.4.1.3`_ of the spec. +# +# In order to guarantee a consistent and reproducible representation of +# the request parameters, the parameters are collected and decoded to +# their original decoded form. They are then sorted and encoded in a +# particular manner that is often different from their original +# encoding scheme, and concatenated into a single string. +# +# .. _`section 3.4.1.3`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3 + +def collect_parameters(uri_query='', body=[], headers=None, + exclude_oauth_signature=True): + """**Parameter Sources** + + Parameters starting with `oauth_` will be unescaped. + + Body parameters must be supplied as a dict, a list of 2-tuples, or a + formencoded query string. + + Headers must be supplied as a dict. + + Per `section 3.4.1.3.1`_ of the spec. + + For example, the HTTP request:: + + POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 + Host: example.com + Content-Type: application/x-www-form-urlencoded + Authorization: OAuth realm="Example", + oauth_consumer_key="9djdj82h48djs9d2", + oauth_token="kkk9d7dh3k39sjv7", + oauth_signature_method="HMAC-SHA1", + oauth_timestamp="137131201", + oauth_nonce="7d8f3e4a", + oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D" + + c2&a3=2+q + + contains the following (fully decoded) parameters used in the + signature base sting:: + + +------------------------+------------------+ + | Name | Value | + +------------------------+------------------+ + | b5 | =%3D | + | a3 | a | + | c@ | | + | a2 | r b | + | oauth_consumer_key | 9djdj82h48djs9d2 | + | oauth_token | kkk9d7dh3k39sjv7 | + | oauth_signature_method | HMAC-SHA1 | + | oauth_timestamp | 137131201 | + | oauth_nonce | 7d8f3e4a | + | c2 | | + | a3 | 2 q | + +------------------------+------------------+ + + Note that the value of "b5" is "=%3D" and not "==". Both "c@" and + "c2" have empty values. While the encoding rules specified in this + specification for the purpose of constructing the signature base + string exclude the use of a "+" character (ASCII code 43) to + represent an encoded space character (ASCII code 32), this practice + is widely used in "application/x-www-form-urlencoded" encoded values, + and MUST be properly decoded, as demonstrated by one of the "a3" + parameter instances (the "a3" parameter is used twice in this + request). + + .. _`section 3.4.1.3.1`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 + """ + headers = headers or {} + params = [] + + # The parameters from the following sources are collected into a single + # list of name/value pairs: + + # * The query component of the HTTP request URI as defined by + # `RFC3986, Section 3.4`_. The query component is parsed into a list + # of name/value pairs by treating it as an + # "application/x-www-form-urlencoded" string, separating the names + # and values and decoding them as defined by + # `W3C.REC-html40-19980424`_, Section 17.13.4. + # + # .. _`RFC3986, Section 3.4`: http://tools.ietf.org/html/rfc3986#section-3.4 + # .. _`W3C.REC-html40-19980424`: http://tools.ietf.org/html/rfc5849#ref-W3C.REC-html40-19980424 + if uri_query: + params.extend(urlparse.parse_qsl(uri_query, keep_blank_values=True)) + + # * The OAuth HTTP "Authorization" header field (`Section 3.5.1`_) if + # present. The header's content is parsed into a list of name/value + # pairs excluding the "realm" parameter if present. The parameter + # values are decoded as defined by `Section 3.5.1`_. + # + # .. _`Section 3.5.1`: http://tools.ietf.org/html/rfc5849#section-3.5.1 + if headers: + headers_lower = dict((k.lower(), v) for k, v in headers.items()) + authorization_header = headers_lower.get(u'authorization') + if authorization_header is not None: + params.extend([i for i in utils.parse_authorization_header( + authorization_header) if i[0] != u'realm']) + + # * The HTTP request entity-body, but only if all of the following + # conditions are met: + # * The entity-body is single-part. + # + # * The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # `W3C.REC-html40-19980424`_. + + # * The HTTP request entity-header includes the "Content-Type" + # header field set to "application/x-www-form-urlencoded". + # + # .._`W3C.REC-html40-19980424`: http://tools.ietf.org/html/rfc5849#ref-W3C.REC-html40-19980424 + + # TODO: enforce header param inclusion conditions + bodyparams = extract_params(body) or [] + params.extend(bodyparams) + + # ensure all oauth params are unescaped + unescaped_params = [] + for k, v in params: + if k.startswith(u'oauth_'): + v = utils.unescape(v) + unescaped_params.append((k, v)) + + # The "oauth_signature" parameter MUST be excluded from the signature + # base string if present. + if exclude_oauth_signature: + unescaped_params = filter(lambda i: i[0] != u'oauth_signature', + unescaped_params) + + return unescaped_params + + +def normalize_parameters(params): + """**Parameters Normalization** + Per `section 3.4.1.3.2`_ of the spec. + + For example, the list of parameters from the previous section would + be normalized as follows: + + Encoded:: + + +------------------------+------------------+ + | Name | Value | + +------------------------+------------------+ + | b5 | %3D%253D | + | a3 | a | + | c%40 | | + | a2 | r%20b | + | oauth_consumer_key | 9djdj82h48djs9d2 | + | oauth_token | kkk9d7dh3k39sjv7 | + | oauth_signature_method | HMAC-SHA1 | + | oauth_timestamp | 137131201 | + | oauth_nonce | 7d8f3e4a | + | c2 | | + | a3 | 2%20q | + +------------------------+------------------+ + + Sorted:: + + +------------------------+------------------+ + | Name | Value | + +------------------------+------------------+ + | a2 | r%20b | + | a3 | 2%20q | + | a3 | a | + | b5 | %3D%253D | + | c%40 | | + | c2 | | + | oauth_consumer_key | 9djdj82h48djs9d2 | + | oauth_nonce | 7d8f3e4a | + | oauth_signature_method | HMAC-SHA1 | + | oauth_timestamp | 137131201 | + | oauth_token | kkk9d7dh3k39sjv7 | + +------------------------+------------------+ + + Concatenated Pairs:: + + +-------------------------------------+ + | Name=Value | + +-------------------------------------+ + | a2=r%20b | + | a3=2%20q | + | a3=a | + | b5=%3D%253D | + | c%40= | + | c2= | + | oauth_consumer_key=9djdj82h48djs9d2 | + | oauth_nonce=7d8f3e4a | + | oauth_signature_method=HMAC-SHA1 | + | oauth_timestamp=137131201 | + | oauth_token=kkk9d7dh3k39sjv7 | + +-------------------------------------+ + + and concatenated together into a single string (line breaks are for + display purposes only):: + + a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj + dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1 + &oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7 + + .. _`section 3.4.1.3.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + """ + + # The parameters collected in `Section 3.4.1.3`_ are normalized into a + # single string as follows: + # + # .. _`Section 3.4.1.3`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3 + + # 1. First, the name and value of each parameter are encoded + # (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + key_values = [(utils.escape(k), utils.escape(v)) for k, v in params] + + # 2. The parameters are sorted by name, using ascending byte value + # ordering. If two or more parameters share the same name, they + # are sorted by their value. + key_values.sort() + + # 3. The name of each parameter is concatenated to its corresponding + # value using an "=" character (ASCII code 61) as a separator, even + # if the value is empty. + parameter_parts = [u'{0}={1}'.format(k, v) for k, v in key_values] + + # 4. The sorted name/value pairs are concatenated together into a + # single string by using an "&" character (ASCII code 38) as + # separator. + return u'&'.join(parameter_parts) + + +def sign_hmac_sha1(base_string, client_secret, resource_owner_secret): + """**HMAC-SHA1** + + The "HMAC-SHA1" signature method uses the HMAC-SHA1 signature + algorithm as defined in `RFC2104`_:: + + digest = HMAC-SHA1 (key, text) + + Per `section 3.4.2`_ of the spec. + + .. _`RFC2104`: http://tools.ietf.org/html/rfc2104 + .. _`section 3.4.2`: http://tools.ietf.org/html/rfc5849#section-3.4.2 + """ + + # The HMAC-SHA1 function variables are used in following way: + + # text is set to the value of the signature base string from + # `Section 3.4.1.1`_. + # + # .. _`Section 3.4.1.1`: http://tools.ietf.org/html/rfc5849#section-3.4.1.1 + text = base_string + + # key is set to the concatenated values of: + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + key = utils.escape(client_secret or u'') + + # 2. An "&" character (ASCII code 38), which MUST be included + # even when either secret is empty. + key += u'&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + key += utils.escape(resource_owner_secret or u'') + + # FIXME: HMAC does not support unicode! + key_utf8 = key.encode('utf-8') + text_utf8 = text.encode('utf-8') + signature = hmac.new(key_utf8, text_utf8, hashlib.sha1) + + # digest is used to set the value of the "oauth_signature" protocol + # parameter, after the result octet string is base64-encoded + # per `RFC2045, Section 6.8`. + # + # .. _`RFC2045, Section 6.8`: http://tools.ietf.org/html/rfc2045#section-6.8 + return binascii.b2a_base64(signature.digest())[:-1].decode('utf-8') + + +def sign_rsa_sha1(base_string, rsa_private_key): + """**RSA-SHA1** + + Per `section 3.4.3`_ of the spec. + + The "RSA-SHA1" signature method uses the RSASSA-PKCS1-v1_5 signature + algorithm as defined in `RFC3447, Section 8.2`_ (also known as + PKCS#1), using SHA-1 as the hash function for EMSA-PKCS1-v1_5. To + use this method, the client MUST have established client credentials + with the server that included its RSA public key (in a manner that is + beyond the scope of this specification). + + NOTE: this method requires the python-rsa library. + + .. _`section 3.4.3`: http://tools.ietf.org/html/rfc5849#section-3.4.3 + .. _`RFC3447, Section 8.2`: http://tools.ietf.org/html/rfc3447#section-8.2 + + """ + + # TODO: finish RSA documentation + + import rsa + key = rsa.PrivateKey.load_pkcs1(rsa_private_key) + sig = rsa.sign(base_string, key, 'SHA-1') + return binascii.b2a_base64(sig)[:-1] + + +def sign_plaintext(client_secret, resource_owner_secret): + """Sign a request using plaintext. + + Per `section 3.4.4`_ of the spec. + + The "PLAINTEXT" method does not employ a signature algorithm. It + MUST be used with a transport-layer mechanism such as TLS or SSL (or + sent over a secure channel with equivalent protections). It does not + utilize the signature base string or the "oauth_timestamp" and + "oauth_nonce" parameters. + + .. _`section 3.4.4`: http://tools.ietf.org/html/rfc5849#section-3.4.4 + + """ + + # The "oauth_signature" protocol parameter is set to the concatenated + # value of: + + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + signature = utils.escape(client_secret or u'') + + # 2. An "&" character (ASCII code 38), which MUST be included even + # when either secret is empty. + signature += u'&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + signature += utils.escape(resource_owner_secret or u'') + + return signature + diff --git a/libs/oauthlib/oauth1/rfc5849/utils.py b/libs/oauthlib/oauth1/rfc5849/utils.py new file mode 100644 index 00000000..6db446fd --- /dev/null +++ b/libs/oauthlib/oauth1/rfc5849/utils.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- + +""" +oauthlib.utils +~~~~~~~~~~~~~~ + +This module contains utility methods used by various parts of the OAuth +spec. +""" + +import string +import time +import urllib2 +from random import getrandbits, choice + +from oauthlib.common import quote, unquote + +UNICODE_ASCII_CHARACTER_SET = (string.ascii_letters.decode('ascii') + + string.digits.decode('ascii')) + + +def filter_params(target): + """Decorator which filters params to remove non-oauth_* parameters + + Assumes the decorated method takes a params dict or list of tuples as its + first argument. + """ + def wrapper(params, *args, **kwargs): + params = filter_oauth_params(params) + return target(params, *args, **kwargs) + + wrapper.__doc__ = target.__doc__ + return wrapper + + +def filter_oauth_params(params): + """Removes all non oauth parameters from a dict or a list of params.""" + is_oauth = lambda kv: kv[0].startswith(u"oauth_") + if isinstance(params, dict): + return filter(is_oauth, params.items()) + else: + return filter(is_oauth, params) + + +def generate_timestamp(): + """Get seconds since epoch (UTC). + + Per `section 3.3`_ of the spec. + + .. _`section 3.3`: http://tools.ietf.org/html/rfc5849#section-3.3 + """ + return unicode(int(time.time())) + + +def generate_nonce(): + """Generate pseudorandom nonce that is unlikely to repeat. + + Per `section 3.3`_ of the spec. + + A random 64-bit number is appended to the epoch timestamp for both + randomness and to decrease the likelihood of collisions. + + .. _`section 3.3`: http://tools.ietf.org/html/rfc5849#section-3.3 + """ + return unicode(getrandbits(64)) + generate_timestamp() + + +def generate_token(length=20, chars=UNICODE_ASCII_CHARACTER_SET): + """Generates a generic OAuth token + + According to `section 2`_ of the spec, the method of token + construction is undefined. This implementation is simply a random selection + of `length` choices from `chars`. + + Credit to Ignacio Vazquez-Abrams for his excellent `Stackoverflow answer`_ + + .. _`Stackoverflow answer` : http://stackoverflow.com/questions/2257441/ + python-random-string-generation-with-upper-case-letters-and-digits + + """ + return u''.join(choice(chars) for x in range(length)) + + +def escape(u): + """Escape a unicode string in an OAuth-compatible fashion. + + Per `section 3.6`_ of the spec. + + .. _`section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + + """ + if not isinstance(u, unicode): + raise ValueError('Only unicode objects are escapable.') + # Letters, digits, and the characters '_.-' are already treated as safe + # by urllib.quote(). We need to add '~' to fully support rfc5849. + return quote(u, safe='~') + + +def unescape(u): + if not isinstance(u, unicode): + raise ValueError('Only unicode objects are unescapable.') + return unquote(u) + + +def urlencode(query): + """Encode a sequence of two-element tuples or dictionary into a URL query string. + + Operates using an OAuth-safe escape() method, in contrast to urllib.urlencode. + """ + # Convert dictionaries to list of tuples + if isinstance(query, dict): + query = query.items() + return u"&".join([u'='.join([escape(k), escape(v)]) for k, v in query]) + + +def parse_keqv_list(l): + """A unicode-safe version of urllib2.parse_keqv_list""" + encoded_list = [u.encode('utf-8') for u in l] + encoded_parsed = urllib2.parse_keqv_list(encoded_list) + return dict((k.decode('utf-8'), + v.decode('utf-8')) for k,v in encoded_parsed.items()) + + +def parse_http_list(u): + """A unicode-safe version of urllib2.parse_http_list""" + encoded_str = u.encode('utf-8') + encoded_list = urllib2.parse_http_list(encoded_str) + return [s.decode('utf-8') for s in encoded_list] + + +def parse_authorization_header(authorization_header): + """Parse an OAuth authorization header into a list of 2-tuples""" + auth_scheme = u'OAuth ' + if authorization_header.startswith(auth_scheme): + authorization_header = authorization_header.replace(auth_scheme, u'', 1) + items = parse_http_list(authorization_header) + try: + return parse_keqv_list(items).items() + except ValueError: + raise ValueError('Malformed authorization header') + diff --git a/libs/oauthlib/oauth2/__init__.py b/libs/oauthlib/oauth2/__init__.py new file mode 100644 index 00000000..0e8933cf --- /dev/null +++ b/libs/oauthlib/oauth2/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +""" +oauthlib.oauth2 +~~~~~~~~~~~~~~ + +This module is a wrapper for the most recent implementation of OAuth 2.0 Client +and Server classes. +""" + +from .draft25 import Client, Server + diff --git a/libs/oauthlib/oauth2/draft25/__init__.py b/libs/oauthlib/oauth2/draft25/__init__.py new file mode 100644 index 00000000..3e50a18f --- /dev/null +++ b/libs/oauthlib/oauth2/draft25/__init__.py @@ -0,0 +1,14 @@ +""" +oauthlib.oauth2.draft_25 +~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 2.0 draft 25 requests. +""" + +class Client(object): + pass + +class Server(object): + pass + diff --git a/libs/oauthlib/oauth2/draft25/tokens.py b/libs/oauthlib/oauth2/draft25/tokens.py new file mode 100644 index 00000000..9b5f5868 --- /dev/null +++ b/libs/oauthlib/oauth2/draft25/tokens.py @@ -0,0 +1,131 @@ +from __future__ import absolute_import +""" +oauthlib.oauth2.draft25.tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains methods for adding two types of access tokens to requests. + +- Bearer http://tools.ietf.org/html/draft-ietf-oauth-saml2-bearer-08 +- MAC http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-00 + +""" +from binascii import b2a_base64 +import hashlib +import hmac +from urlparse import urlparse + +from . import utils + + +def prepare_mac_header(token, uri, key, http_method, nonce=None, headers=None, + body=None, ext=u'', hash_algorithm=u'hmac-sha-1'): + """Add an `MAC Access Authentication`_ signature to headers. + + Unlike OAuth 1, this HMAC signature does not require inclusion of the request + payload/body, neither does it use a combination of client_secret and + token_secret but rather a mac_key provided together with the access token. + + Currently two algorithms are supported, "hmac-sha-1" and "hmac-sha-256", + `extension algorithms`_ are not supported. + + Example MAC Authorization header, linebreaks added for clarity + + Authorization: MAC id="h480djs93hd8", + nonce="1336363200:dj83hs9s", + mac="bhCQXTVyfj5cmA9uKkPFx1zeOXM=" + + .. _`MAC Access Authentication`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01 + .. _`extension algorithms`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-7.1 + + :param uri: Request URI. + :param headers: Request headers as a dictionary. + :param http_method: HTTP Request method. + :param key: MAC given provided by token endpoint. + :param algorithm: HMAC algorithm provided by token endpoint. + :return: headers dictionary with the authorization field added. + """ + http_method = http_method.upper() + host, port = utils.host_from_uri(uri) + + if hash_algorithm.lower() == u'hmac-sha-1': + h = hashlib.sha1 + else: + h = hashlib.sha256 + + nonce = nonce or u'{0}:{1}'.format(utils.generate_nonce(), utils.generate_timestamp()) + sch, net, path, par, query, fra = urlparse(uri) + + if query: + request_uri = path + u'?' + query + else: + request_uri = path + + # Hash the body/payload + if body is not None: + bodyhash = b2a_base64(h(body).digest())[:-1].decode('utf-8') + else: + bodyhash = u'' + + # Create the normalized base string + base = [] + base.append(nonce) + base.append(http_method.upper()) + base.append(request_uri) + base.append(host) + base.append(port) + base.append(bodyhash) + base.append(ext) + base_string = '\n'.join(base) + u'\n' + + # hmac struggles with unicode strings - http://bugs.python.org/issue5285 + if isinstance(key, unicode): + key = key.encode('utf-8') + sign = hmac.new(key, base_string, h) + sign = b2a_base64(sign.digest())[:-1].decode('utf-8') + + header = [] + header.append(u'MAC id="%s"' % token) + header.append(u'nonce="%s"' % nonce) + if bodyhash: + header.append(u'bodyhash="%s"' % bodyhash) + if ext: + header.append(u'ext="%s"' % ext) + header.append(u'mac="%s"' % sign) + + headers = headers or {} + headers[u'Authorization'] = u', '.join(header) + return headers + + +def prepare_bearer_uri(token, uri): + """Add a `Bearer Token`_ to the request URI. + Not recommended, use only if client can't use authorization header or body. + + http://www.example.com/path?access_token=h480djs93hd8 + + .. _`Bearer Token`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-18 + """ + return utils.add_params_to_uri(uri, [((u'access_token', token))]) + + +def prepare_bearer_headers(token, headers=None): + """Add a `Bearer Token`_ to the request URI. + Recommended method of passing bearer tokens. + + Authorization: Bearer h480djs93hd8 + + .. _`Bearer Token`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-18 + """ + headers = headers or {} + headers[u'Authorization'] = u'Bearer %s' % token + return headers + + +def prepare_bearer_body(token, body=u''): + """Add a `Bearer Token`_ to the request body. + + access_token=h480djs93hd8 + + .. _`Bearer Token`: http://tools.ietf.org/html/draft-ietf-oauth-v2-bearer-18 + """ + return utils.add_params_to_qs(body, [((u'access_token', token))]) diff --git a/libs/oauthlib/oauth2/draft25/utils.py b/libs/oauthlib/oauth2/draft25/utils.py new file mode 100644 index 00000000..48b4ea1d --- /dev/null +++ b/libs/oauthlib/oauth2/draft25/utils.py @@ -0,0 +1,128 @@ +""" +oauthlib.utils +~~~~~~~~~~~~~~ + +This module contains utility methods used by various parts of the OAuth 2 spec. +""" + +import random +import string +import time +import urllib +from urlparse import urlparse, urlunparse, parse_qsl + +UNICODE_ASCII_CHARACTER_SET = (string.ascii_letters.decode('ascii') + + string.digits.decode('ascii')) + +def add_params_to_qs(query, params): + """Extend a query with a list of two-tuples. + + :param query: Query string. + :param params: List of two-tuples. + :return: extended query + """ + queryparams = parse_qsl(query, keep_blank_values=True) + queryparams.extend(params) + return urlencode(queryparams) + + +def add_params_to_uri(uri, params): + """Add a list of two-tuples to the uri query components. + + :param uri: Full URI. + :param params: List of two-tuples. + :return: uri with extended query + """ + sch, net, path, par, query, fra = urlparse(uri) + query = add_params_to_qs(query, params) + return urlunparse((sch, net, path, par, query, fra)) + + +def escape(u): + """Escape a string in an OAuth-compatible fashion. + + Per `section 3.6`_ of the spec. + + .. _`section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + + """ + if not isinstance(u, unicode): + raise ValueError('Only unicode objects are escapable.') + return urllib.quote(u.encode('utf-8'), safe='~') + + +def generate_nonce(): + """Generate pseudorandom nonce that is unlikely to repeat. + + Per `section 3.2.1`_ of the MAC Access Authentication spec. + + A random 64-bit number is appended to the epoch timestamp for both + randomness and to decrease the likelihood of collisions. + + .. _`section 3.2.1`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-3.2.1 + """ + return unicode(unicode(random.getrandbits(64)) + generate_timestamp()) + + +def generate_timestamp(): + """Get seconds since epoch (UTC). + + Per `section 3.2.1`_ of the MAC Access Authentication spec. + + .. _`section 3.2.1`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-3.2.1 + """ + return unicode(int(time.time())) + + +def generate_token(length=20, chars=UNICODE_ASCII_CHARACTER_SET): + """Generates a generic OAuth 2 token + + According to `section 1.4`_ and `section 1.5` of the spec, the method of token + construction is undefined. This implementation is simply a random selection + of `length` choices from `chars`. SystemRandom is used since it provides + higher entropy than random.choice. + + .. _`section 1.4`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-1.4 + .. _`section 1.5`: http://tools.ietf.org/html/draft-ietf-oauth-v2-25#section-1.5 + """ + rand = random.SystemRandom() + return u''.join(rand.choice(chars) for x in range(length)) + + +def host_from_uri(uri): + """Extract hostname and port from URI. + + Will use default port for HTTP and HTTPS if none is present in the URI. + + >>> host_from_uri(u'https://www.example.com/path?query') + u'www.example.com', u'443' + >>> host_from_uri(u'http://www.example.com:8080/path?query') + u'www.example.com', u'8080' + + :param uri: Full URI. + :param http_method: HTTP request method. + :return: hostname, port + """ + default_ports = { + u'HTTP' : u'80', + u'HTTPS' : u'443', + } + + sch, netloc, path, par, query, fra = urlparse(uri) + if u':' in netloc: + netloc, port = netloc.split(u':', 1) + else: + port = default_ports.get(sch.upper()) + + return netloc, port + + +def urlencode(query): + """Encode a sequence of two-element tuples or dictionary into a URL query string. + + Operates using an OAuth-safe escape() method, in contrast to urllib.urlenocde. + """ + # Convert dictionaries to list of tuples + if isinstance(query, dict): + query = query.items() + return "&".join(['='.join([escape(k), escape(v)]) for k, v in query]) diff --git a/libs/subliminal/api.py b/libs/subliminal/api.py index baff5f1a..a7baeab6 100755 --- a/libs/subliminal/api.py +++ b/libs/subliminal/api.py @@ -18,7 +18,8 @@ from .core import (SERVICES, LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE, create_list_tasks, consume_task, create_download_tasks, group_by_video, key_subtitles) -from .languages import list_languages +import guessit +from guessit.language import ALL_LANGUAGES import logging @@ -26,7 +27,7 @@ __all__ = ['list_subtitles', 'download_subtitles'] logger = logging.getLogger(__name__) -def list_subtitles(paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3): +def list_subtitles(paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3, scan_filter=None): """List subtitles in given paths according to the criteria :param paths: path(s) to video file or folder @@ -37,19 +38,20 @@ def list_subtitles(paths, languages=None, services=None, force=True, multi=False :param bool multi: search multiple languages for the same video :param string cache_dir: path to the cache directory to use :param int max_depth: maximum depth for scanning entries + :param function scan_filter: filter function that takes a path as argument and returns a boolean indicating whether it has to be filtered out (``True``) or not (``False``) :return: found subtitles :rtype: dict of :class:`~subliminal.videos.Video` => [:class:`~subliminal.subtitles.ResultSubtitle`] """ services = services or SERVICES - languages = set(languages or list_languages(1)) + languages = set(map(guessit.Language, languages or []) or ALL_LANGUAGES) if isinstance(paths, basestring): paths = [paths] if any([not isinstance(p, unicode) for p in paths]): logger.warning(u'Not all entries are unicode') results = [] service_instances = {} - tasks = create_list_tasks(paths, languages, services, force, multi, cache_dir, max_depth) + tasks = create_list_tasks(paths, languages, services, force, multi, cache_dir, max_depth, scan_filter) for task in tasks: try: result = consume_task(task, service_instances) @@ -61,7 +63,7 @@ def list_subtitles(paths, languages=None, services=None, force=True, multi=False return group_by_video(results) -def download_subtitles(paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3, order=None): +def download_subtitles(paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3, scan_filter=None, order=None): """Download subtitles in given paths according to the criteria :param paths: path(s) to video file or folder @@ -72,6 +74,7 @@ def download_subtitles(paths, languages=None, services=None, force=True, multi=F :param bool multi: search multiple languages for the same video :param string cache_dir: path to the cache directory to use :param int max_depth: maximum depth for scanning entries + :param function scan_filter: filter function that takes a path as argument and returns a boolean indicating whether it has to be filtered out (``True``) or not (``False``) :param order: preferred order for subtitles sorting :type list: list of :data:`~subliminal.core.LANGUAGE_INDEX`, :data:`~subliminal.core.SERVICE_INDEX`, :data:`~subliminal.core.SERVICE_CONFIDENCE`, :data:`~subliminal.core.MATCHING_CONFIDENCE` :return: found subtitles @@ -79,11 +82,11 @@ def download_subtitles(paths, languages=None, services=None, force=True, multi=F """ services = services or SERVICES - languages = languages or list_languages(1) + languages = map(guessit.Language, languages or []) or list(ALL_LANGUAGES) if isinstance(paths, basestring): paths = [paths] order = order or [LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE] - subtitles_by_video = list_subtitles(paths, set(languages), services, force, multi, cache_dir, max_depth) + subtitles_by_video = list_subtitles(paths, set(languages), services, force, multi, cache_dir, max_depth, scan_filter) for video, subtitles in subtitles_by_video.iteritems(): subtitles.sort(key=lambda s: key_subtitles(s, video, languages, services, order), reverse=True) results = [] diff --git a/libs/subliminal/async.py b/libs/subliminal/async.py index ce18a278..e125bbf9 100755 --- a/libs/subliminal/async.py +++ b/libs/subliminal/async.py @@ -18,7 +18,7 @@ from .core import (consume_task, LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE, SERVICES, create_list_tasks, create_download_tasks, group_by_video, key_subtitles) -from .languages import list_languages +from guessit.language import ALL_LANGUAGES from .tasks import StopTask import Queue import logging @@ -108,29 +108,29 @@ class Pool(object): break return results - def list_subtitles(self, paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3): + def list_subtitles(self, paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3, scan_filter=None): """See :meth:`subliminal.list_subtitles`""" services = services or SERVICES - languages = set(languages or list_languages(1)) + languages = set(languages or ALL_LANGUAGES) if isinstance(paths, basestring): paths = [paths] if any([not isinstance(p, unicode) for p in paths]): logger.warning(u'Not all entries are unicode') - tasks = create_list_tasks(paths, languages, services, force, multi, cache_dir, max_depth) + tasks = create_list_tasks(paths, languages, services, force, multi, cache_dir, max_depth, scan_filter) for task in tasks: self.tasks.put(task) self.join() results = self.collect() return group_by_video(results) - def download_subtitles(self, paths, languages=None, services=None, cache_dir=None, max_depth=3, force=True, multi=False, order=None): + def download_subtitles(self, paths, languages=None, services=None, force=True, multi=False, cache_dir=None, max_depth=3, scan_filter=None, order=None): """See :meth:`subliminal.download_subtitles`""" services = services or SERVICES - languages = languages or list_languages(1) + languages = languages or list(ALL_LANGUAGES) if isinstance(paths, basestring): paths = [paths] order = order or [LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE] - subtitles_by_video = self.list_subtitles(paths, set(languages), services, force, multi, cache_dir, max_depth) + subtitles_by_video = self.list_subtitles(paths, set(languages), services, force, multi, cache_dir, max_depth, scan_filter) for video, subtitles in subtitles_by_video.iteritems(): subtitles.sort(key=lambda s: key_subtitles(s, video, languages, services, order), reverse=True) tasks = create_download_tasks(subtitles_by_video, multi) diff --git a/libs/subliminal/cache.py b/libs/subliminal/cache.py new file mode 100755 index 00000000..f4d7d947 --- /dev/null +++ b/libs/subliminal/cache.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Copyright 2012 Nicolas Wack +# +# This file is part of subliminal. +# +# subliminal is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# subliminal 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with subliminal. If not, see . +import os.path +from collections import defaultdict +import threading +from functools import wraps +import logging +try: + import cPickle as pickle +except ImportError: + import pickle + + +logger = logging.getLogger(__name__) + + +class Cache(object): + """A Cache object contains cached values for methods. It can have + separate internal caches, one for each service. + """ + + def __init__(self, cache_dir): + self.cache_dir = cache_dir + self.cache = defaultdict(dict) + self.lock = threading.RLock() + + def __del__(self): + for service_name in self.cache: + self.save(service_name) + + def cache_location(self, service_name): + return os.path.join(self.cache_dir, 'subliminal_%s.cache' % service_name) + + def load(self, service_name): + with self.lock: + if service_name in self.cache: + # already loaded + return + + self.cache[service_name] = defaultdict(dict) + filename = self.cache_location(service_name) + logger.debug(u'Cache: loading cache from %s' % filename) + try: + self.cache[service_name] = pickle.load(open(filename, 'rb')) + except IOError: + logger.info('Cache: Cache file "%s" doesn\'t exist, creating it' % filename) + except EOFError: + logger.error('Cache: cache file "%s" is corrupted... Removing it.' % filename) + os.remove(filename) + + def save(self, service_name): + filename = self.cache_location(service_name) + logger.debug(u'Cache: saving cache to %s' % filename) + with self.lock: + pickle.dump(self.cache[service_name], open(filename, 'wb')) + + def clear(self, service_name): + try: + os.remove(self.cache_location(service_name)) + except OSError: + pass + self.cache[service_name] = defaultdict(dict) + + def cached_func_key(self, func, cls=None): + try: + cls = func.im_class + except: + pass + return ('%s.%s' % (cls.__module__, cls.__name__), func.__name__) + + def function_cache(self, service_name, func): + func_key = self.cached_func_key(func) + return self.cache[service_name][func_key] + + def cache_for(self, service_name, func, args, result): + # no need to lock here, dict ops are atomic + self.function_cache(service_name, func)[args] = result + + def cached_value(self, service_name, func, args): + """Raises KeyError if not found""" + # no need to lock here, dict ops are atomic + return self.function_cache(service_name, func)[args] + + +def cachedmethod(function): + """Decorator to make a method use the cache. + + WARNING: this can NOT be used with static functions, it has to be used on + methods of some class.""" + + @wraps(function) + def cached(*args): + c = args[0].config.cache + service_name = args[0].__class__.__name__ + func_key = c.cached_func_key(function, cls=args[0].__class__) + func_cache = c.cache[service_name][func_key] + + # we need to remove the first element of args for the key, as it is the + # instance pointer and we don't want the cache to know which instance + # called it, it is shared among all instances of the same class + key = args[1:] + + if key in func_cache: + result = func_cache[key] + logger.debug(u'Using cached value for %s(%s), returns: %s' % (func_key, key, result)) + return result + + result = function(*args) + + # note: another thread could have already cached a value in the + # meantime, but that's ok as we prefer to keep the latest value in + # the cache + func_cache[key] = result + + return result + + return cached diff --git a/libs/subliminal/core.py b/libs/subliminal/core.py index 56f4347e..9bbc1cd8 100755 --- a/libs/subliminal/core.py +++ b/libs/subliminal/core.py @@ -20,6 +20,8 @@ from .services import ServiceConfig from .tasks import DownloadTask, ListTask from .utils import get_keywords from .videos import Episode, Movie, scan +from guessit.language import lang_set +import bs4 from collections import defaultdict from itertools import groupby import guessit @@ -30,11 +32,11 @@ __all__ = ['SERVICES', 'LANGUAGE_INDEX', 'SERVICE_INDEX', 'SERVICE_CONFIDENCE', 'create_list_tasks', 'create_download_tasks', 'consume_task', 'matching_confidence', 'key_subtitles', 'group_by_video'] logger = logging.getLogger(__name__) -SERVICES = ['opensubtitles', 'bierdopje', 'subswiki', 'subtitulos', 'thesubdb'] +SERVICES = ['opensubtitles', 'bierdopje', 'subswiki', 'subtitulos', 'thesubdb', 'addic7ed', 'tvsubtitles'] LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE = range(4) -def create_list_tasks(paths, languages, services, force, multi, cache_dir, max_depth): +def create_list_tasks(paths, languages, services, force, multi, cache_dir, max_depth, scan_filter): """Create a list of :class:`~subliminal.tasks.ListTask` from one or more paths using the given criteria :param paths: path(s) to video file or folder @@ -45,18 +47,20 @@ def create_list_tasks(paths, languages, services, force, multi, cache_dir, max_d :param bool multi: search multiple languages for the same video :param string cache_dir: path to the cache directory to use :param int max_depth: maximum depth for scanning entries + :param function scan_filter: filter function that takes a path as argument and returns a boolean indicating whether it has to be filtered out (``True``) or not (``False``) :return: the created tasks :rtype: list of :class:`~subliminal.tasks.ListTask` """ scan_result = [] for p in paths: - scan_result.extend(scan(p, max_depth)) + scan_result.extend(scan(p, max_depth, scan_filter)) logger.debug(u'Found %d videos in %r with maximum depth %d' % (len(scan_result), paths, max_depth)) tasks = [] config = ServiceConfig(multi, cache_dir) + services = filter_services(services) for video, detected_subtitles in scan_result: - detected_languages = set([s.language for s in detected_subtitles]) + detected_languages = set(s.language for s in detected_subtitles) wanted_languages = languages.copy() if not force and multi: wanted_languages -= detected_languages @@ -70,14 +74,9 @@ def create_list_tasks(paths, languages, services, force, multi, cache_dir, max_d for service_name in services: mod = __import__('services.' + service_name, globals=globals(), locals=locals(), fromlist=['Service'], level=-1) service = mod.Service - service_languages = wanted_languages & service.available_languages() - if not service_languages: - logger.debug(u'Skipping %r: none of wanted languages %r available for service %s' % (video, wanted_languages, service_name)) + if not service.check_validity(video, wanted_languages): continue - if not service.is_valid_video(video): - logger.debug(u'Skipping %r: not part of supported videos %r for service %s' % (video, service.videos, service_name)) - continue - task = ListTask(video, service_languages, service_name, config) + task = ListTask(video, wanted_languages & service.languages, service_name, config) logger.debug(u'Created task %r' % task) tasks.append(task) return tasks @@ -128,25 +127,19 @@ def consume_task(task, services=None): logger.info(u'Consuming %r' % task) result = None if isinstance(task, ListTask): - if task.service not in services: - mod = __import__('services.' + task.service, globals=globals(), locals=locals(), fromlist=['Service'], level=-1) - services[task.service] = mod.Service(task.config) - services[task.service].init() - subtitles = services[task.service].list(task.video, task.languages) - result = subtitles + service = get_service(services, task.service, config=task.config) + result = service.list(task.video, task.languages) elif isinstance(task, DownloadTask): for subtitle in task.subtitles: - if subtitle.service not in services: - mod = __import__('services.' + subtitle.service, globals=globals(), locals=locals(), fromlist=['Service'], level=-1) - services[subtitle.service] = mod.Service() - services[subtitle.service].init() + service = get_service(services, subtitle.service) try: - services[subtitle.service].download(subtitle) + service.download(subtitle) result = subtitle break except DownloadFailedError: logger.warning(u'Could not download subtitle %r, trying next' % subtitle) continue + if result is None: logger.error(u'No subtitles could be downloaded for video %r' % task.video) return result @@ -193,6 +186,26 @@ def matching_confidence(video, subtitle): return confidence +def get_service(services, service_name, config=None): + """Get a service from its name in the service dict with the specified config. + If the service does not exist in the service dict, it is created and added to the dict. + + :param dict services: dict where to get existing services or put created ones + :param string service_name: name of the service to get + :param config: config to use for the service + :type config: :class:`~subliminal.services.ServiceConfig` or None + :return: the corresponding service + :rtype: :class:`~subliminal.services.ServiceBase` + + """ + if service_name not in services: + mod = __import__('services.' + service_name, globals=globals(), locals=locals(), fromlist=['Service'], level=-1) + services[service_name] = mod.Service() + services[service_name].init() + services[service_name].config = config + return services[service_name] + + def key_subtitles(subtitle, video, languages, services, order): """Create a key to sort subtitle using the given order @@ -238,3 +251,21 @@ def group_by_video(list_results): for video, subtitles in list_results: result[video] += subtitles return result + + +def filter_services(services): + """Filter out services that are not available because of a missing feature + + :param list services: service names to filter + :return: a copy of the initial list of service names without unavailable ones + :rtype: list + + """ + filtered_services = services[:] + for service_name in services: + mod = __import__('services.' + service_name, globals=globals(), locals=locals(), fromlist=['Service'], level=-1) + service = mod.Service + if service.required_features is not None and bs4.builder_registry.lookup(*service.required_features) is None: + logger.warning(u'Service %s not available: none of available features could be used. One of %r required' % (service_name, service.required_features)) + filtered_services.remove(service_name) + return filtered_services diff --git a/libs/subliminal/infos.py b/libs/subliminal/infos.py index b28fda00..9958234e 100755 --- a/libs/subliminal/infos.py +++ b/libs/subliminal/infos.py @@ -15,4 +15,4 @@ # # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . -__version__ = '0.5.1' +__version__ = '0.6.0' diff --git a/libs/subliminal/languages.py b/libs/subliminal/languages.py deleted file mode 100755 index f743953f..00000000 --- a/libs/subliminal/languages.py +++ /dev/null @@ -1,547 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2011-2012 Antoine Bertin -# -# This file is part of subliminal. -# -# subliminal is free software; you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# subliminal 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 Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with subliminal. If not, see . -__all__ = ['convert_language', 'list_languages', 'LANGUAGES'] - - -def convert_language(language, to_iso, from_iso=None): - """Convert a language into another format - - :param string language: language - :param int to_iso: convert language to ISO-639-x - :param int from_iso: convert language from ISO-639-x - :return: converted language - :rtype: string - - """ - if from_iso == None: # if no from_iso is given, try to guess it - if language.startswith(language[:1].upper()): - from_iso = 0 - elif len(language) == 2: - from_iso = 1 - elif len(language) == 3: - from_iso = 2 - else: - raise ValueError('Invalid input language format') - if isinstance(language, unicode): - language = language.encode('utf-8') - converted_language = None - for language_tuple in LANGUAGES: - if language_tuple[from_iso] == language and language_tuple[to_iso]: - converted_language = language_tuple[to_iso] - break - return converted_language - - -def list_languages(iso): - """List languages in the given ISO-639-x format - - :param int iso: ISO-639-x format to list - :return: languages in the requested format - :rtype: list - - """ - return [l[iso] for l in LANGUAGES if l[iso]] - -#: ISO-639-2 languages list from http://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt -#: + ('Brazilian', 'po', 'pob') -LANGUAGES = [('Afar', 'aa', 'aar'), - ('Abkhazian', 'ab', 'abk'), - ('Achinese', '', 'ace'), - ('Acoli', '', 'ach'), - ('Adangme', '', 'ada'), - ('Adyghe; Adygei', '', 'ady'), - ('Afro-Asiatic languages', '', 'afa'), - ('Afrihili', '', 'afh'), - ('Afrikaans', 'af', 'afr'), - ('Ainu', '', 'ain'), - ('Akan', 'ak', 'aka'), - ('Akkadian', '', 'akk'), - ('Albanian', 'sq', 'alb'), - ('Aleut', '', 'ale'), - ('Algonquian languages', '', 'alg'), - ('Southern Altai', '', 'alt'), - ('Amharic', 'am', 'amh'), - ('English, Old (ca.450-1100)', '', 'ang'), - ('Angika', '', 'anp'), - ('Apache languages', '', 'apa'), - ('Arabic', 'ar', 'ara'), - ('Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)', '', 'arc'), - ('Aragonese', 'an', 'arg'), - ('Armenian', 'hy', 'arm'), - ('Mapudungun; Mapuche', '', 'arn'), - ('Arapaho', '', 'arp'), - ('Artificial languages', '', 'art'), - ('Arawak', '', 'arw'), - ('Assamese', 'as', 'asm'), - ('Asturian; Bable; Leonese; Asturleonese', '', 'ast'), - ('Athapascan languages', '', 'ath'), - ('Australian languages', '', 'aus'), - ('Avaric', 'av', 'ava'), - ('Avestan', 'ae', 'ave'), - ('Awadhi', '', 'awa'), - ('Aymara', 'ay', 'aym'), - ('Azerbaijani', 'az', 'aze'), - ('Banda languages', '', 'bad'), - ('Bamileke languages', '', 'bai'), - ('Bashkir', 'ba', 'bak'), - ('Baluchi', '', 'bal'), - ('Bambara', 'bm', 'bam'), - ('Balinese', '', 'ban'), - ('Basque', 'eu', 'baq'), - ('Basa', '', 'bas'), - ('Baltic languages', '', 'bat'), - ('Beja; Bedawiyet', '', 'bej'), - ('Belarusian', 'be', 'bel'), - ('Bemba', '', 'bem'), - ('Bengali', 'bn', 'ben'), - ('Berber languages', '', 'ber'), - ('Bhojpuri', '', 'bho'), - ('Bihari languages', 'bh', 'bih'), - ('Bikol', '', 'bik'), - ('Bini; Edo', '', 'bin'), - ('Bislama', 'bi', 'bis'), - ('Siksika', '', 'bla'), - ('Bantu (Other)', '', 'bnt'), - ('Bosnian', 'bs', 'bos'), - ('Braj', '', 'bra'), - ('Breton', 'br', 'bre'), - ('Batak languages', '', 'btk'), - ('Buriat', '', 'bua'), - ('Buginese', '', 'bug'), - ('Bulgarian', 'bg', 'bul'), - ('Burmese', 'my', 'bur'), - ('Blin; Bilin', '', 'byn'), - ('Caddo', '', 'cad'), - ('Central American Indian languages', '', 'cai'), - ('Galibi Carib', '', 'car'), - ('Catalan; Valencian', 'ca', 'cat'), - ('Caucasian languages', '', 'cau'), - ('Cebuano', '', 'ceb'), - ('Celtic languages', '', 'cel'), - ('Chamorro', 'ch', 'cha'), - ('Chibcha', '', 'chb'), - ('Chechen', 'ce', 'che'), - ('Chagatai', '', 'chg'), - ('Chinese', 'zh', 'chi'), - ('Chuukese', '', 'chk'), - ('Mari', '', 'chm'), - ('Chinook jargon', '', 'chn'), - ('Choctaw', '', 'cho'), - ('Chipewyan; Dene Suline', '', 'chp'), - ('Cherokee', '', 'chr'), - ('Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic', 'cu', 'chu'), - ('Chuvash', 'cv', 'chv'), - ('Cheyenne', '', 'chy'), - ('Chamic languages', '', 'cmc'), - ('Coptic', '', 'cop'), - ('Cornish', 'kw', 'cor'), - ('Corsican', 'co', 'cos'), - ('Creoles and pidgins, English based', '', 'cpe'), - ('Creoles and pidgins, French-based ', '', 'cpf'), - ('Creoles and pidgins, Portuguese-based ', '', 'cpp'), - ('Cree', 'cr', 'cre'), - ('Crimean Tatar; Crimean Turkish', '', 'crh'), - ('Creoles and pidgins ', '', 'crp'), - ('Kashubian', '', 'csb'), - ('Cushitic languages', '', 'cus'), - ('Czech', 'cs', 'cze'), - ('Dakota', '', 'dak'), - ('Danish', 'da', 'dan'), - ('Dargwa', '', 'dar'), - ('Land Dayak languages', '', 'day'), - ('Delaware', '', 'del'), - ('Slave (Athapascan)', '', 'den'), - ('Dogrib', '', 'dgr'), - ('Dinka', '', 'din'), - ('Divehi; Dhivehi; Maldivian', 'dv', 'div'), - ('Dogri', '', 'doi'), - ('Dravidian languages', '', 'dra'), - ('Lower Sorbian', '', 'dsb'), - ('Duala', '', 'dua'), - ('Dutch, Middle (ca.1050-1350)', '', 'dum'), - ('Dutch; Flemish', 'nl', 'dut'), - ('Dyula', '', 'dyu'), - ('Dzongkha', 'dz', 'dzo'), - ('Efik', '', 'efi'), - ('Egyptian (Ancient)', '', 'egy'), - ('Ekajuk', '', 'eka'), - ('Elamite', '', 'elx'), - ('English', 'en', 'eng'), - ('English, Middle (1100-1500)', '', 'enm'), - ('Esperanto', 'eo', 'epo'), - ('Estonian', 'et', 'est'), - ('Ewe', 'ee', 'ewe'), - ('Ewondo', '', 'ewo'), - ('Fang', '', 'fan'), - ('Faroese', 'fo', 'fao'), - ('Fanti', '', 'fat'), - ('Fijian', 'fj', 'fij'), - ('Filipino; Pilipino', '', 'fil'), - ('Finnish', 'fi', 'fin'), - ('Finno-Ugrian languages', '', 'fiu'), - ('Fon', '', 'fon'), - ('French', 'fr', 'fre'), - ('French, Middle (ca.1400-1600)', '', 'frm'), - ('French, Old (842-ca.1400)', '', 'fro'), - ('Northern Frisian', '', 'frr'), - ('Eastern Frisian', '', 'frs'), - ('Western Frisian', 'fy', 'fry'), - ('Fulah', 'ff', 'ful'), - ('Friulian', '', 'fur'), - ('Ga', '', 'gaa'), - ('Gayo', '', 'gay'), - ('Gbaya', '', 'gba'), - ('Germanic languages', '', 'gem'), - ('Georgian', 'ka', 'geo'), - ('German', 'de', 'ger'), - ('Geez', '', 'gez'), - ('Gilbertese', '', 'gil'), - ('Gaelic; Scottish Gaelic', 'gd', 'gla'), - ('Irish', 'ga', 'gle'), - ('Galician', 'gl', 'glg'), - ('Manx', 'gv', 'glv'), - ('German, Middle High (ca.1050-1500)', '', 'gmh'), - ('German, Old High (ca.750-1050)', '', 'goh'), - ('Gondi', '', 'gon'), - ('Gorontalo', '', 'gor'), - ('Gothic', '', 'got'), - ('Grebo', '', 'grb'), - ('Greek, Ancient (to 1453)', '', 'grc'), - ('Greek, Modern (1453-)', 'el', 'gre'), - ('Guarani', 'gn', 'grn'), - ('Swiss German; Alemannic; Alsatian', '', 'gsw'), - ('Gujarati', 'gu', 'guj'), - ('Gwich\'in', '', 'gwi'), - ('Haida', '', 'hai'), - ('Haitian; Haitian Creole', 'ht', 'hat'), - ('Hausa', 'ha', 'hau'), - ('Hawaiian', '', 'haw'), - ('Hebrew', 'he', 'heb'), - ('Herero', 'hz', 'her'), - ('Hiligaynon', '', 'hil'), - ('Himachali languages; Western Pahari languages', '', 'him'), - ('Hindi', 'hi', 'hin'), - ('Hittite', '', 'hit'), - ('Hmong; Mong', '', 'hmn'), - ('Hiri Motu', 'ho', 'hmo'), - ('Croatian', 'hr', 'hrv'), - ('Upper Sorbian', '', 'hsb'), - ('Hungarian', 'hu', 'hun'), - ('Hupa', '', 'hup'), - ('Iban', '', 'iba'), - ('Igbo', 'ig', 'ibo'), - ('Icelandic', 'is', 'ice'), - ('Ido', 'io', 'ido'), - ('Sichuan Yi; Nuosu', 'ii', 'iii'), - ('Ijo languages', '', 'ijo'), - ('Inuktitut', 'iu', 'iku'), - ('Interlingue; Occidental', 'ie', 'ile'), - ('Iloko', '', 'ilo'), - ('Interlingua (International Auxiliary Language Association)', 'ia', 'ina'), - ('Indic languages', '', 'inc'), - ('Indonesian', 'id', 'ind'), - ('Indo-European languages', '', 'ine'), - ('Ingush', '', 'inh'), - ('Inupiaq', 'ik', 'ipk'), - ('Iranian languages', '', 'ira'), - ('Iroquoian languages', '', 'iro'), - ('Italian', 'it', 'ita'), - ('Javanese', 'jv', 'jav'), - ('Lojban', '', 'jbo'), - ('Japanese', 'ja', 'jpn'), - ('Judeo-Persian', '', 'jpr'), - ('Judeo-Arabic', '', 'jrb'), - ('Kara-Kalpak', '', 'kaa'), - ('Kabyle', '', 'kab'), - ('Kachin; Jingpho', '', 'kac'), - ('Kalaallisut; Greenlandic', 'kl', 'kal'), - ('Kamba', '', 'kam'), - ('Kannada', 'kn', 'kan'), - ('Karen languages', '', 'kar'), - ('Kashmiri', 'ks', 'kas'), - ('Kanuri', 'kr', 'kau'), - ('Kawi', '', 'kaw'), - ('Kazakh', 'kk', 'kaz'), - ('Kabardian', '', 'kbd'), - ('Khasi', '', 'kha'), - ('Khoisan languages', '', 'khi'), - ('Central Khmer', 'km', 'khm'), - ('Khotanese; Sakan', '', 'kho'), - ('Kikuyu; Gikuyu', 'ki', 'kik'), - ('Kinyarwanda', 'rw', 'kin'), - ('Kirghiz; Kyrgyz', 'ky', 'kir'), - ('Kimbundu', '', 'kmb'), - ('Konkani', '', 'kok'), - ('Komi', 'kv', 'kom'), - ('Kongo', 'kg', 'kon'), - ('Korean', 'ko', 'kor'), - ('Kosraean', '', 'kos'), - ('Kpelle', '', 'kpe'), - ('Karachay-Balkar', '', 'krc'), - ('Karelian', '', 'krl'), - ('Kru languages', '', 'kro'), - ('Kurukh', '', 'kru'), - ('Kuanyama; Kwanyama', 'kj', 'kua'), - ('Kumyk', '', 'kum'), - ('Kurdish', 'ku', 'kur'), - ('Kutenai', '', 'kut'), - ('Ladino', '', 'lad'), - ('Lahnda', '', 'lah'), - ('Lamba', '', 'lam'), - ('Lao', 'lo', 'lao'), - ('Latin', 'la', 'lat'), - ('Latvian', 'lv', 'lav'), - ('Lezghian', '', 'lez'), - ('Limburgan; Limburger; Limburgish', 'li', 'lim'), - ('Lingala', 'ln', 'lin'), - ('Lithuanian', 'lt', 'lit'), - ('Mongo', '', 'lol'), - ('Lozi', '', 'loz'), - ('Luxembourgish; Letzeburgesch', 'lb', 'ltz'), - ('Luba-Lulua', '', 'lua'), - ('Luba-Katanga', 'lu', 'lub'), - ('Ganda', 'lg', 'lug'), - ('Luiseno', '', 'lui'), - ('Lunda', '', 'lun'), - ('Luo (Kenya and Tanzania)', '', 'luo'), - ('Lushai', '', 'lus'), - ('Macedonian', 'mk', 'mac'), - ('Madurese', '', 'mad'), - ('Magahi', '', 'mag'), - ('Marshallese', 'mh', 'mah'), - ('Maithili', '', 'mai'), - ('Makasar', '', 'mak'), - ('Malayalam', 'ml', 'mal'), - ('Mandingo', '', 'man'), - ('Maori', 'mi', 'mao'), - ('Austronesian languages', '', 'map'), - ('Marathi', 'mr', 'mar'), - ('Masai', '', 'mas'), - ('Malay', 'ms', 'may'), - ('Moksha', '', 'mdf'), - ('Mandar', '', 'mdr'), - ('Mende', '', 'men'), - ('Irish, Middle (900-1200)', '', 'mga'), - ('Mi\'kmaq; Micmac', '', 'mic'), - ('Minangkabau', '', 'min'), - ('Uncoded languages', '', 'mis'), - ('Mon-Khmer languages', '', 'mkh'), - ('Malagasy', 'mg', 'mlg'), - ('Maltese', 'mt', 'mlt'), - ('Manchu', '', 'mnc'), - ('Manipuri', '', 'mni'), - ('Manobo languages', '', 'mno'), - ('Mohawk', '', 'moh'), - ('Mongolian', 'mn', 'mon'), - ('Mossi', '', 'mos'), - ('Multiple languages', '', 'mul'), - ('Munda languages', '', 'mun'), - ('Creek', '', 'mus'), - ('Mirandese', '', 'mwl'), - ('Marwari', '', 'mwr'), - ('Mayan languages', '', 'myn'), - ('Erzya', '', 'myv'), - ('Nahuatl languages', '', 'nah'), - ('North American Indian languages', '', 'nai'), - ('Neapolitan', '', 'nap'), - ('Nauru', 'na', 'nau'), - ('Navajo; Navaho', 'nv', 'nav'), - ('Ndebele, South; South Ndebele', 'nr', 'nbl'), - ('Ndebele, North; North Ndebele', 'nd', 'nde'), - ('Ndonga', 'ng', 'ndo'), - ('Low German; Low Saxon; German, Low; Saxon, Low', '', 'nds'), - ('Nepali', 'ne', 'nep'), - ('Nepal Bhasa; Newari', '', 'new'), - ('Nias', '', 'nia'), - ('Niger-Kordofanian languages', '', 'nic'), - ('Niuean', '', 'niu'), - ('Norwegian Nynorsk; Nynorsk, Norwegian', 'nn', 'nno'), - ('Bokmål, Norwegian; Norwegian Bokmål', 'nb', 'nob'), - ('Nogai', '', 'nog'), - ('Norse, Old', '', 'non'), - ('Norwegian', 'no', 'nor'), - ('N\'Ko', '', 'nqo'), - ('Pedi; Sepedi; Northern Sotho', '', 'nso'), - ('Nubian languages', '', 'nub'), - ('Classical Newari; Old Newari; Classical Nepal Bhasa', '', 'nwc'), - ('Chichewa; Chewa; Nyanja', 'ny', 'nya'), - ('Nyamwezi', '', 'nym'), - ('Nyankole', '', 'nyn'), - ('Nyoro', '', 'nyo'), - ('Nzima', '', 'nzi'), - ('Occitan (post 1500); Provençal', 'oc', 'oci'), - ('Ojibwa', 'oj', 'oji'), - ('Oriya', 'or', 'ori'), - ('Oromo', 'om', 'orm'), - ('Osage', '', 'osa'), - ('Ossetian; Ossetic', 'os', 'oss'), - ('Turkish, Ottoman (1500-1928)', '', 'ota'), - ('Otomian languages', '', 'oto'), - ('Papuan languages', '', 'paa'), - ('Pangasinan', '', 'pag'), - ('Pahlavi', '', 'pal'), - ('Pampanga; Kapampangan', '', 'pam'), - ('Panjabi; Punjabi', 'pa', 'pan'), - ('Papiamento', '', 'pap'), - ('Palauan', '', 'pau'), - ('Persian, Old (ca.600-400 B.C.)', '', 'peo'), - ('Persian', 'fa', 'per'), - ('Philippine languages', '', 'phi'), - ('Phoenician', '', 'phn'), - ('Pali', 'pi', 'pli'), - ('Polish', 'pl', 'pol'), - ('Pohnpeian', '', 'pon'), - ('Portuguese', 'pt', 'por'), - ('Prakrit languages', '', 'pra'), - ('Provençal, Old (to 1500)', '', 'pro'), - ('Pushto; Pashto', 'ps', 'pus'), - ('Reserved for local use', '', 'qaa-qtz'), - ('Quechua', 'qu', 'que'), - ('Rajasthani', '', 'raj'), - ('Rapanui', '', 'rap'), - ('Rarotongan; Cook Islands Maori', '', 'rar'), - ('Romance languages', '', 'roa'), - ('Romansh', 'rm', 'roh'), - ('Romany', '', 'rom'), - ('Romanian; Moldavian; Moldovan', 'ro', 'rum'), - ('Rundi', 'rn', 'run'), - ('Aromanian; Arumanian; Macedo-Romanian', '', 'rup'), - ('Russian', 'ru', 'rus'), - ('Sandawe', '', 'sad'), - ('Sango', 'sg', 'sag'), - ('Yakut', '', 'sah'), - ('South American Indian (Other)', '', 'sai'), - ('Salishan languages', '', 'sal'), - ('Samaritan Aramaic', '', 'sam'), - ('Sanskrit', 'sa', 'san'), - ('Sasak', '', 'sas'), - ('Santali', '', 'sat'), - ('Sicilian', '', 'scn'), - ('Scots', '', 'sco'), - ('Selkup', '', 'sel'), - ('Semitic languages', '', 'sem'), - ('Irish, Old (to 900)', '', 'sga'), - ('Sign Languages', '', 'sgn'), - ('Shan', '', 'shn'), - ('Sidamo', '', 'sid'), - ('Sinhala; Sinhalese', 'si', 'sin'), - ('Siouan languages', '', 'sio'), - ('Sino-Tibetan languages', '', 'sit'), - ('Slavic languages', '', 'sla'), - ('Slovak', 'sk', 'slo'), - ('Slovenian', 'sl', 'slv'), - ('Southern Sami', '', 'sma'), - ('Northern Sami', 'se', 'sme'), - ('Sami languages', '', 'smi'), - ('Lule Sami', '', 'smj'), - ('Inari Sami', '', 'smn'), - ('Samoan', 'sm', 'smo'), - ('Skolt Sami', '', 'sms'), - ('Shona', 'sn', 'sna'), - ('Sindhi', 'sd', 'snd'), - ('Soninke', '', 'snk'), - ('Sogdian', '', 'sog'), - ('Somali', 'so', 'som'), - ('Songhai languages', '', 'son'), - ('Sotho, Southern', 'st', 'sot'), - ('Spanish; Castilian', 'es', 'spa'), - ('Sardinian', 'sc', 'srd'), - ('Sranan Tongo', '', 'srn'), - ('Serbian', 'sr', 'srp'), - ('Serer', '', 'srr'), - ('Nilo-Saharan languages', '', 'ssa'), - ('Swati', 'ss', 'ssw'), - ('Sukuma', '', 'suk'), - ('Sundanese', 'su', 'sun'), - ('Susu', '', 'sus'), - ('Sumerian', '', 'sux'), - ('Swahili', 'sw', 'swa'), - ('Swedish', 'sv', 'swe'), - ('Classical Syriac', '', 'syc'), - ('Syriac', '', 'syr'), - ('Tahitian', 'ty', 'tah'), - ('Tai languages', '', 'tai'), - ('Tamil', 'ta', 'tam'), - ('Tatar', 'tt', 'tat'), - ('Telugu', 'te', 'tel'), - ('Timne', '', 'tem'), - ('Tereno', '', 'ter'), - ('Tetum', '', 'tet'), - ('Tajik', 'tg', 'tgk'), - ('Tagalog', 'tl', 'tgl'), - ('Thai', 'th', 'tha'), - ('Tibetan', 'bo', 'tib'), - ('Tigre', '', 'tig'), - ('Tigrinya', 'ti', 'tir'), - ('Tiv', '', 'tiv'), - ('Tokelau', '', 'tkl'), - ('Klingon; tlhIngan-Hol', '', 'tlh'), - ('Tlingit', '', 'tli'), - ('Tamashek', '', 'tmh'), - ('Tonga (Nyasa)', '', 'tog'), - ('Tonga (Tonga Islands)', 'to', 'ton'), - ('Tok Pisin', '', 'tpi'), - ('Tsimshian', '', 'tsi'), - ('Tswana', 'tn', 'tsn'), - ('Tsonga', 'ts', 'tso'), - ('Turkmen', 'tk', 'tuk'), - ('Tumbuka', '', 'tum'), - ('Tupi languages', '', 'tup'), - ('Turkish', 'tr', 'tur'), - ('Altaic languages', '', 'tut'), - ('Tuvalu', '', 'tvl'), - ('Twi', 'tw', 'twi'), - ('Tuvinian', '', 'tyv'), - ('Udmurt', '', 'udm'), - ('Ugaritic', '', 'uga'), - ('Uighur; Uyghur', 'ug', 'uig'), - ('Ukrainian', 'uk', 'ukr'), - ('Umbundu', '', 'umb'), - ('Undetermined', '', 'und'), - ('Urdu', 'ur', 'urd'), - ('Uzbek', 'uz', 'uzb'), - ('Vai', '', 'vai'), - ('Venda', 've', 'ven'), - ('Vietnamese', 'vi', 'vie'), - ('Volapük', 'vo', 'vol'), - ('Votic', '', 'vot'), - ('Wakashan languages', '', 'wak'), - ('Walamo', '', 'wal'), - ('Waray', '', 'war'), - ('Washo', '', 'was'), - ('Welsh', 'cy', 'wel'), - ('Sorbian languages', '', 'wen'), - ('Walloon', 'wa', 'wln'), - ('Wolof', 'wo', 'wol'), - ('Kalmyk; Oirat', '', 'xal'), - ('Xhosa', 'xh', 'xho'), - ('Yao', '', 'yao'), - ('Yapese', '', 'yap'), - ('Yiddish', 'yi', 'yid'), - ('Yoruba', 'yo', 'yor'), - ('Yupik languages', '', 'ypk'), - ('Zapotec', '', 'zap'), - ('Blissymbols; Blissymbolics; Bliss', '', 'zbl'), - ('Zenaga', '', 'zen'), - ('Zhuang; Chuang', 'za', 'zha'), - ('Zande languages', '', 'znd'), - ('Zulu', 'zu', 'zul'), - ('Zuni', '', 'zun'), - ('No linguistic content; Not applicable', '', 'zxx'), - ('Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', '', 'zza'), - ('Brazilian', 'po', 'pob')] diff --git a/libs/subliminal/services/__init__.py b/libs/subliminal/services/__init__.py index 67e457fe..e1aea2d4 100755 --- a/libs/subliminal/services/__init__.py +++ b/libs/subliminal/services/__init__.py @@ -15,11 +15,15 @@ # # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . -from ..exceptions import MissingLanguageError, DownloadFailedError +from .. import cache +from ..exceptions import MissingLanguageError, DownloadFailedError, ServiceError +from ..subtitles import EXTENSIONS +from guessit.language import lang_set, UNDETERMINED import logging import os import requests import threading +import zipfile __all__ = ['ServiceBase', 'ServiceConfig'] @@ -37,7 +41,7 @@ class ServiceBase(object): server_url = '' #: User Agent for any HTTP-based requests - user_agent = 'subliminal v0.5' + user_agent = 'subliminal v0.6' #: Whether based on an API or not api_based = False @@ -45,21 +49,18 @@ class ServiceBase(object): #: Timeout for web requests timeout = 5 - #: Lock for cache interactions - lock = threading.Lock() - #: Mapping to Service's language codes and subliminal's languages = {} - #: Whether the mapping is reverted or not - reverted_languages = False - #: Accepted video classes (:class:`~subliminal.videos.Episode`, :class:`~subliminal.videos.Movie`, :class:`~subliminal.videos.UnknownVideo`) videos = [] #: Whether the video has to exist or not require_video = False + #: List of required features for BeautifulSoup + required_features = None + def __init__(self, config=None): self.config = config or ServiceConfig() @@ -75,6 +76,30 @@ class ServiceBase(object): logger.debug(u'Initializing %s' % self.__class__.__name__) self.session = requests.session(timeout=10, headers={'User-Agent': self.user_agent}) + def init_cache(self): + """Initialize cache, make sure it is loaded from disk""" + if not self.config or not self.config.cache: + raise ServiceError('Cache directory is required') + + service_name = self.__class__.__name__ + self.config.cache.load(service_name) + + def save_cache(self): + service_name = self.__class__.__name__ + self.config.cache.save(service_name) + + def clear_cache(self): + service_name = self.__class__.__name__ + self.config.cache.clear(service_name) + + def cache_for(self, func, args, result): + service_name = self.__class__.__name__ + return self.config.cache.cache_for(service_name, func, args, result) + + def cached_value(self, func, args): + service_name = self.__class__.__name__ + return self.config.cache.cached_value(service_name, func, args) + def terminate(self): """Terminate connection""" logger.debug(u'Terminating %s' % self.__class__.__name__) @@ -84,25 +109,20 @@ class ServiceBase(object): pass def list(self, video, languages): - """List subtitles""" - pass + """List subtitles + + As a service writer, you can either override this method or implement + :meth:`list_checked` instead to have the languages pre-filtered for you + + """ + if not self.check_validity(video, languages): + return [] + return self.list_checked(video, languages) def download(self, subtitle): """Download a subtitle""" self.download_file(subtitle.link, subtitle.path) - @classmethod - def available_languages(cls): - """Available languages in the Service - - :return: available languages - :rtype: set - - """ - if not cls.reverted_languages: - return set(cls.languages.keys()) - return set(cls.languages.values()) - @classmethod def check_validity(cls, video, languages): """Check for video and languages validity in the Service @@ -113,72 +133,15 @@ class ServiceBase(object): :rtype: bool """ - languages &= cls.available_languages() + languages = (lang_set(languages) & cls.languages) - set([UNDETERMINED]) if not languages: - logger.debug(u'No language available for service %s' % cls.__class__.__name__.lower()) + logger.debug(u'No language available for service %s' % cls.__name__.lower()) return False - if not cls.is_valid_video(video): - logger.debug(u'%r is not valid for service %s' % (video, cls.__class__.__name__.lower())) + if cls.require_video and not video.exists or not isinstance(video, tuple(cls.videos)): + logger.debug(u'%r is not valid for service %s' % (video, cls.__name__.lower())) return False return True - @classmethod - def is_valid_video(cls, video): - """Check if video is valid in the Service - - :param video: the video to check - :type video: :class:`~subliminal.videos.Video` - :rtype: bool - - """ - if cls.require_video and not video.exists: - return False - if not isinstance(video, tuple(cls.videos)): - return False - return True - - @classmethod - def is_valid_language(cls, language): - """Check if language is valid in the Service - - :param string language: the language to check - :rtype: bool - - """ - if language in cls.available_languages(): - return True - return False - - @classmethod - def get_revert_language(cls, language): - """ISO-639-1 language code from service language code - - :param string language: service language code - :return: ISO-639-1 language code - :rtype: string - - """ - if not cls.reverted_languages and language in cls.languages.values(): - return [k for k, v in cls.languages.iteritems() if v == language][0] - if cls.reverted_languages and language in cls.languages.keys(): - return cls.languages[language] - raise MissingLanguageError(language) - - @classmethod - def get_language(cls, language): - """Service language code from ISO-639-1 language code - - :param string language: ISO-639-1 language code - :return: service language code - :rtype: string - - """ - if not cls.reverted_languages and language in cls.languages.keys(): - return cls.languages[language] - if cls.reverted_languages and language in cls.languages.values(): - return [k for k, v in cls.languages.iteritems() if v == language][0] - raise MissingLanguageError(language) - def download_file(self, url, filepath): """Attempt to download a file and remove it in case of failure @@ -198,6 +161,43 @@ class ServiceBase(object): raise DownloadFailedError(str(e)) logger.debug(u'Download finished for file %s. Size: %s' % (filepath, os.path.getsize(filepath))) + def download_zip_file(self, url, filepath): + """Attempt to download a zip file and extract any subtitle file from it, if any. + This cleans up after itself if anything fails. + + :param string url: URL of the zip file to download + :param string filepath: destination path for the subtitle + + """ + logger.info(u'Downloading %s' % url) + try: + zippath = filepath + '.zip' + r = self.session.get(url, headers={'Referer': url, 'User-Agent': self.user_agent}) + with open(zippath, 'wb') as f: + f.write(r.content) + if not zipfile.is_zipfile(zippath): + # TODO: could check if maybe we already have a text file and + # download it directly + raise DownloadFailedError('Downloaded file is not a zip file') + zipsub = zipfile.ZipFile(zippath) + for subfile in zipsub.namelist(): + if os.path.splitext(subfile)[1] in EXTENSIONS: + open(filepath, 'w').write(zipsub.open(subfile).read()) + break + else: + logger.debug(u'No subtitles found in zip file') + raise DownloadFailedError('No subtitles found in zip file') + os.remove(zippath) + logger.debug(u'Download finished for file %s. Size: %s' % (filepath, os.path.getsize(filepath))) + return + except Exception as e: + logger.error(u'Download %s failed: %s' % (url, e)) + if os.path.exists(zippath): + os.remove(zippath) + if os.path.exists(filepath): + os.remove(filepath) + raise DownloadFailedError(str(e)) + class ServiceConfig(object): """Configuration for any :class:`Service` @@ -209,6 +209,9 @@ class ServiceConfig(object): def __init__(self, multi=False, cache_dir=None): self.multi = multi self.cache_dir = cache_dir + self.cache = None + if cache_dir is not None: + self.cache = cache.Cache(cache_dir) def __repr__(self): - return 'ServiceConfig(%r, %s)' % (self.multi, self.cache_dir) + return 'ServiceConfig(%r, %s)' % (self.multi, self.cache.cache_dir) diff --git a/libs/subliminal/services/addic7ed.py b/libs/subliminal/services/addic7ed.py new file mode 100755 index 00000000..c754de55 --- /dev/null +++ b/libs/subliminal/services/addic7ed.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +# Copyright 2012 Olivier Leveau +# +# This file is part of subliminal. +# +# subliminal is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# subliminal 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with subliminal. If not, see . +from . import ServiceBase +from ..cache import cachedmethod +from ..subtitles import get_subtitle_path, ResultSubtitle +from ..videos import Episode +from bs4 import BeautifulSoup +from guessit.language import lang_set +from subliminal.utils import get_keywords +import guessit +import logging +import re + + +logger = logging.getLogger(__name__) + + +def match(pattern, string): + try: + return re.search(pattern, string).group(1) + except AttributeError: + logger.debug(u'Could not match %r on %r' % (pattern, string)) + return None + + +def matches(pattern, string): + try: + return re.search(pattern, string).group(1, 2) + except AttributeError: + logger.debug(u'Could not match %r on %r' % (pattern, string)) + return None + + +class Addic7ed(ServiceBase): + server_url = 'http://www.addic7ed.com' + api_based = False + languages = lang_set([u'English', u'Italian', u'Portuguese', + u'Portuguese (Brazilian)', u'Romanian', + u'Spanish', u'French', u'Greek', u'Arabic', + u'German', u'Croatian', u'Indonesian', u'Hebrew', + u'Russian', u'Turkish', u'Swedish', u'Czech', + u'Dutch', u'Hungarian', u'Norwegian', u'Polish', + u'Persian'], strict=True) + videos = [Episode] + require_video = False + required_features = ['permissive'] + + @cachedmethod + def get_likely_series_id(self, name): + r = self.session.get('%s/shows.php' % self.server_url) + soup = BeautifulSoup(r.content, self.required_features) + for elem in soup.find_all('h3'): + show_name = elem.a.text.lower() + show_id = int(match('show/([0-9]+)', elem.a['href'])) + # we could just return the id of the queried show, but as we + # already downloaded the whole page we might as well fill in the + # information for all the shows + self.cache_for(self.get_likely_series_id, args=(show_name,), result=show_id) + return self.cached_value(self.get_likely_series_id, args=(name,)) + + @cachedmethod + def get_episode_url(self, series_id, season, number): + """Get the Addic7ed id for the given episode. Raises KeyError if none + could be found + + """ + # download the page of the show, contains ids for all episodes all seasons + r = self.session.get('%s/show/%d' % (self.server_url, series_id)) + soup = BeautifulSoup(r.content, self.required_features) + form = soup.find('form', attrs={'name': 'multidl'}) + for table in form.find_all('table'): + for row in table.find_all('tr'): + cell = row.find('td', 'MultiDldS') + if not cell: + continue + m = matches('/serie/.+/([0-9]+)/([0-9]+)/', cell.a['href']) + if not m: + continue + episode_url = cell.a['href'] + season_number = int(m[0]) + episode_number = int(m[1]) + # we could just return the url of the queried episode, but as we + # already downloaded the whole page we might as well fill in the + # information for all the episodes of the show + self.cache_for(self.get_episode_url, args=(series_id, season_number, episode_number), result=episode_url) + # raises KeyError if not found + return self.cached_value(self.get_episode_url, args=(series_id, season, number)) + + # Do not cache this method in order to always check for the most recent + # subtitles + def get_sub_urls(self, episode_url): + suburls = [] + r = self.session.get('%s/%s' % (self.server_url, episode_url)) + epsoup = BeautifulSoup(r.content, self.required_features) + for releaseTable in epsoup.find_all('table', 'tabel95'): + releaseRow = releaseTable.find('td', 'NewsTitle') + if not releaseRow: + continue + release = releaseRow.text.strip() + for row in releaseTable.find_all('tr'): + link = row.find('a', 'buttonDownload') + if not link: + continue + if 'href' not in link.attrs or not (link['href'].startswith('/original') or link['href'].startswith('/updated')): + continue + suburl = link['href'] + lang = guessit.Language(row.find('td', 'language').text.strip()) + result = {'suburl': suburl, 'language': lang, 'release': release} + suburls.append(result) + return suburls + + def list_checked(self, video, languages): + return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) + + def query(self, filepath, languages, keywords, series, season, episode): + logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) + self.init_cache() + try: + sid = self.get_likely_series_id(series.lower()) + except KeyError: + logger.debug(u'Could not find series id for %s' % series) + return [] + + try: + ep_url = self.get_episode_url(sid, season, episode) + except KeyError: + logger.debug(u'Could not find episode id for %s season %d episode %d' % (series, season, episode)) + return [] + suburls = self.get_sub_urls(ep_url) + + # filter the subtitles with our queried languages + subtitles = [] + for suburl in suburls: + language = suburl['language'] + if language not in languages: + continue + + path = get_subtitle_path(filepath, language, self.config.multi) + subtitle = ResultSubtitle(path, language, self.__class__.__name__.lower(), + '%s/%s' % (self.server_url, suburl['suburl']), + keywords=[suburl['release']]) + subtitles.append(subtitle) + return subtitles + + +Service = Addic7ed diff --git a/libs/subliminal/services/bierdopje.py b/libs/subliminal/services/bierdopje.py index 15401ada..ab6c577c 100755 --- a/libs/subliminal/services/bierdopje.py +++ b/libs/subliminal/services/bierdopje.py @@ -16,13 +16,14 @@ # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . from . import ServiceBase +from ..cache import cachedmethod from ..exceptions import ServiceError from ..subtitles import get_subtitle_path, ResultSubtitle -from ..videos import Episode from ..utils import to_unicode -import BeautifulSoup +from ..videos import Episode +from bs4 import BeautifulSoup +from guessit.language import lang_set import logging -import os.path import urllib try: import cPickle as pickle @@ -36,30 +37,23 @@ logger = logging.getLogger(__name__) class BierDopje(ServiceBase): server_url = 'http://api.bierdopje.com/A2B638AC5D804C2E/' api_based = True - languages = {'en': 'en', 'nl': 'nl'} - reverted_languages = False + languages = lang_set(['en', 'nl']) videos = [Episode] require_video = False + required_features = ['xml'] - def __init__(self, config=None): - super(BierDopje, self).__init__(config) - self.showids = {} - if self.config and self.config.cache_dir: - self.init_cache() + @cachedmethod + def get_show_id(self, series): + r = self.session.get('%sGetShowByName/%s' % (self.server_url, urllib.quote(series.lower()))) + if r.status_code != 200: + logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) + return None + soup = BeautifulSoup(r.content, self.required_features) + if soup.status.contents[0] == 'false': + logger.debug(u'Could not find show %s' % series) + return None - def init_cache(self): - logger.debug(u'Initializing cache...') - if not self.config or not self.config.cache_dir: - raise ServiceError('Cache directory is required') - self.showids_cache = os.path.join(self.config.cache_dir, 'bierdopje_showids.cache') - if not os.path.exists(self.showids_cache): - self.save_cache() - - def save_cache(self): - logger.debug(u'Saving showids to cache...') - with self.lock: - with open(self.showids_cache, 'w') as f: - pickle.dump(self.showids, f) + return int(soup.showid.contents[0]) def load_cache(self): logger.debug(u'Loading showids from cache...') @@ -67,25 +61,12 @@ class BierDopje(ServiceBase): with open(self.showids_cache, 'r') as f: self.showids = pickle.load(f) - def query(self, season, episode, languages, filepath, tvdbid=None, series=None): - self.load_cache() + def query(self, filepath, season, episode, languages, tvdbid=None, series=None): + self.init_cache() if series: - if series.lower() in self.showids: # from cache - request_id = self.showids[series.lower()] - logger.debug(u'Retreived showid %d for %s from cache' % (request_id, series)) - else: # query to get showid - logger.debug(u'Getting showid from show name %s...' % series) - r = self.session.get('%sGetShowByName/%s' % (self.server_url, urllib.quote(series.lower()))) - if r.status_code != 200: - logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) - return [] - soup = BeautifulSoup.BeautifulStoneSoup(r.content) - if soup.status.contents[0] == 'false': - logger.debug(u'Could not find show %s' % series) - return [] - request_id = int(soup.showid.contents[0]) - self.showids[series.lower()] = request_id - self.save_cache() + request_id = self.get_show_id(series.lower()) + if request_id is None: + return [] request_source = 'showid' request_is_tvdbid = 'false' elif tvdbid: @@ -96,14 +77,14 @@ class BierDopje(ServiceBase): raise ServiceError('One or more parameter missing') subtitles = [] for language in languages: - logger.debug(u'Getting subtitles for %s %d season %d episode %d with language %s' % (request_source, request_id, season, episode, language)) - r = self.session.get('%sGetAllSubsFor/%s/%s/%s/%s/%s' % (self.server_url, request_id, season, episode, language, request_is_tvdbid)) + logger.debug(u'Getting subtitles for %s %d season %d episode %d with language %s' % (request_source, request_id, season, episode, language.alpha2)) + r = self.session.get('%sGetAllSubsFor/%s/%s/%s/%s/%s' % (self.server_url, request_id, season, episode, language.alpha2, request_is_tvdbid)) if r.status_code != 200: logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) return [] - soup = BeautifulSoup.BeautifulStoneSoup(r.content) + soup = BeautifulSoup(r.content, self.required_features) if soup.status.contents[0] == 'false': - logger.debug(u'Could not find subtitles for %s %d season %d episode %d with language %s' % (request_source, request_id, season, episode, language)) + logger.debug(u'Could not find subtitles for %s %d season %d episode %d with language %s' % (request_source, request_id, season, episode, language.alpha2)) continue path = get_subtitle_path(filepath, language, self.config.multi) for result in soup.results('result'): @@ -112,11 +93,8 @@ class BierDopje(ServiceBase): subtitles.append(subtitle) return subtitles - def list(self, video, languages): - if not self.check_validity(video, languages): - return [] - results = self.query(video.season, video.episode, languages, video.path or video.release, video.tvdbid, video.series) - return results + def list_checked(self, video, languages): + return self.query(video.path or video.release, video.season, video.episode, languages, video.tvdbid, video.series) Service = BierDopje diff --git a/libs/subliminal/services/opensubtitles.py b/libs/subliminal/services/opensubtitles.py index 9dee27b9..cf493805 100755 --- a/libs/subliminal/services/opensubtitles.py +++ b/libs/subliminal/services/opensubtitles.py @@ -18,8 +18,10 @@ from . import ServiceBase from ..exceptions import ServiceError, DownloadFailedError from ..subtitles import get_subtitle_path, ResultSubtitle -from ..videos import Episode, Movie from ..utils import to_unicode +from ..videos import Episode, Movie +from guessit.language import lang_set +import guessit import gzip import logging import os.path @@ -32,34 +34,71 @@ logger = logging.getLogger(__name__) class OpenSubtitles(ServiceBase): server_url = 'http://api.opensubtitles.org/xml-rpc' api_based = True - languages = {'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'alb', 'am': 'amh', 'ar': 'ara', - 'an': 'arg', 'hy': 'arm', 'as': 'asm', 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', - 'ba': 'bak', 'bm': 'bam', 'eu': 'baq', 'be': 'bel', 'bn': 'ben', 'bh': 'bih', 'bi': 'bis', - 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'bur', 'ca': 'cat', 'ch': 'cha', 'ce': 'che', - 'zh': 'chi', 'cu': 'chu', 'cv': 'chv', 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'cze', - 'da': 'dan', 'dv': 'div', 'nl': 'dut', 'dz': 'dzo', 'en': 'eng', 'eo': 'epo', 'et': 'est', - 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin', 'fr': 'fre', 'fy': 'fry', 'ff': 'ful', - 'ka': 'geo', 'de': 'ger', 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell', - 'gn': 'grn', 'gu': 'guj', 'ht': 'hat', 'ha': 'hau', 'he': 'heb', 'hz': 'her', 'hi': 'hin', - 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun', 'ig': 'ibo', 'is': 'ice', 'io': 'ido', 'ii': 'iii', - 'iu': 'iku', 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita', 'jv': 'jav', - 'ja': 'jpn', 'kl': 'kal', 'kn': 'kan', 'ks': 'kas', 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', - 'ki': 'kik', 'rw': 'kin', 'ky': 'kir', 'kv': 'kom', 'kg': 'kon', 'ko': 'kor', 'kj': 'kua', - 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim', 'ln': 'lin', 'lt': 'lit', - 'lb': 'ltz', 'lu': 'lub', 'lg': 'lug', 'mk': 'mac', 'mh': 'mah', 'ml': 'mal', 'mi': 'mao', - 'mr': 'mar', 'ms': 'may', 'mg': 'mlg', 'mt': 'mlt', 'mo': 'mol', 'mn': 'mon', 'na': 'nau', - 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep', 'nn': 'nno', 'nb': 'nob', - 'no': 'nor', 'ny': 'nya', 'oc': 'oci', 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', - 'pa': 'pan', 'fa': 'per', 'pi': 'pli', 'pl': 'pol', 'pt': 'por', 'ps': 'pus', 'qu': 'que', - 'rm': 'roh', 'rn': 'run', 'ru': 'rus', 'sg': 'sag', 'sa': 'san', 'sr': 'scc', 'si': 'sin', - 'sk': 'slo', 'sl': 'slv', 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som', - 'st': 'sot', 'es': 'spa', 'sc': 'srd', 'ss': 'ssw', 'su': 'sun', 'sw': 'swa', 'sv': 'swe', - 'ty': 'tah', 'ta': 'tam', 'tt': 'tat', 'te': 'tel', 'tg': 'tgk', 'tl': 'tgl', 'th': 'tha', - 'bo': 'tib', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso', 'tk': 'tuk', 'tr': 'tur', - 'tw': 'twi', 'ug': 'uig', 'uk': 'ukr', 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', - 'vo': 'vol', 'cy': 'wel', 'wa': 'wln', 'wo': 'wol', 'xh': 'xho', 'yi': 'yid', 'yo': 'yor', - 'za': 'zha', 'zu': 'zul', 'ro': 'rum', 'po': 'pob', 'un': 'unk', 'ay': 'ass'} - reverted_languages = False + # language list fetched from: + # http://www.opensubtitles.org/addons/export_languages.php + languages = lang_set(['aar', 'abk', 'ace', 'ach', 'ada', 'ady', 'afa', 'afh', + 'afr', 'ain', 'aka', 'akk', 'alb', 'ale', 'alg', 'alt', + 'amh', 'ang', 'apa', 'ara', 'arc', 'arg', 'arm', 'arn', + 'arp', 'art', 'arw', 'asm', 'ast', 'ath', 'aus', 'ava', + 'ave', 'awa', 'aym', 'aze', 'bad', 'bai', 'bak', 'bal', + 'bam', 'ban', 'baq', 'bas', 'bat', 'bej', 'bel', 'bem', + 'ben', 'ber', 'bho', 'bih', 'bik', 'bin', 'bis', 'bla', + 'bnt', 'bod', 'bos', 'bra', 'bre', 'btk', 'bua', 'bug', + 'bul', 'bur', 'byn', 'cad', 'cai', 'car', 'cat', 'cau', + 'ceb', 'cel', 'cha', 'chb', 'che', 'chg', 'chi', 'chk', + 'chm', 'chn', 'cho', 'chp', 'chr', 'chu', 'chv', 'chy', + 'cmc', 'cop', 'cor', 'cos', 'cpe', 'cpf', 'cpp', 'cre', + 'crh', 'crp', 'csb', 'cus', 'cym', 'cze', 'dak', 'dan', + 'dar', 'day', 'del', 'den', 'deu', 'dgr', 'din', 'div', + 'doi', 'dra', 'dua', 'dum', 'dut', 'dyu', 'dzo', 'efi', + 'egy', 'eka', 'elx', 'eng', 'enm', 'epo', 'est', 'eus', + 'ewe', 'ewo', 'fan', 'fao', 'fas', 'fat', 'fij', 'fil', + 'fin', 'fiu', 'fon', 'fra', 'fre', 'frm', 'fro', 'fry', + 'ful', 'fur', 'gaa', 'gay', 'gba', 'gem', 'geo', 'ger', + 'gez', 'gil', 'gla', 'gle', 'glg', 'glv', 'gmh', 'goh', + 'gon', 'gor', 'got', 'grb', 'grc', 'ell', 'grn', 'guj', + 'gwi', 'hai', 'hat', 'hau', 'haw', 'heb', 'her', 'hil', + 'him', 'hin', 'hit', 'hmn', 'hmo', 'hrv', 'hun', 'hup', + 'hye', 'iba', 'ibo', 'ice', 'ido', 'iii', 'ijo', 'iku', + 'ile', 'ilo', 'ina', 'inc', 'ind', 'ine', 'inh', 'ipk', + 'ira', 'iro', 'isl', 'ita', 'jav', 'jpn', 'jpr', 'jrb', + 'kaa', 'kab', 'kac', 'kal', 'kam', 'kan', 'kar', 'kas', + 'kat', 'kau', 'kaw', 'kaz', 'kbd', 'kha', 'khi', 'khm', + 'kho', 'kik', 'kin', 'kir', 'kmb', 'kok', 'kom', 'kon', + 'kor', 'kos', 'kpe', 'krc', 'kro', 'kru', 'kua', 'kum', + 'kur', 'kut', 'lad', 'lah', 'lam', 'lao', 'lat', 'lav', + 'lez', 'lim', 'lin', 'lit', 'lol', 'loz', 'ltz', 'lua', + 'lub', 'lug', 'lui', 'lun', 'luo', 'lus', 'mac', 'mad', + 'mag', 'mah', 'mai', 'mak', 'mal', 'man', 'mao', 'map', + 'mar', 'mas', 'may', 'mdf', 'mdr', 'men', 'mga', 'mic', + 'min', 'mis', 'mkd', 'mkh', 'mlg', 'mlt', 'mnc', 'mni', + 'mno', 'moh', 'mol', 'mon', 'mos', 'mri', 'msa', 'mwl', + 'mul', 'mun', 'mus', 'mwr', 'mya', 'myn', 'myv', 'nah', + 'nai', 'nap', 'nau', 'nav', 'nbl', 'nde', 'ndo', 'nds', + 'nep', 'new', 'nia', 'nic', 'niu', 'nld', 'nno', 'nob', + 'nog', 'non', 'nor', 'nso', 'nub', 'nwc', 'nya', 'nym', + 'nyn', 'nyo', 'nzi', 'oci', 'oji', 'ori', 'orm', 'osa', + 'oss', 'ota', 'oto', 'paa', 'pag', 'pal', 'pam', 'pan', + 'pap', 'pau', 'peo', 'per', 'phi', 'phn', 'pli', 'pol', + 'pon', 'por', 'pra', 'pro', 'pus', 'que', 'raj', 'rap', + 'rar', 'roa', 'roh', 'rom', 'ron', 'run', 'rup', 'rus', + 'sad', 'sag', 'sah', 'sai', 'sal', 'sam', 'san', 'sas', + 'sat', 'scc', 'scn', 'sco', 'scr', 'sel', 'sem', 'sga', + 'sgn', 'shn', 'sid', 'sin', 'sio', 'sit', 'sla', 'slk', + 'slo', 'slv', 'sma', 'sme', 'smi', 'smj', 'smn', 'smo', + 'sms', 'sna', 'snd', 'snk', 'sog', 'som', 'son', 'sot', + 'spa', 'sqi', 'srd', 'srp', 'srr', 'ssa', 'ssw', 'suk', + 'sun', 'sus', 'sux', 'swa', 'swe', 'syr', 'tah', 'tai', + 'tam', 'tat', 'tel', 'tem', 'ter', 'tet', 'tgk', 'tgl', + 'tha', 'tib', 'tig', 'tir', 'tiv', 'tkl', 'tlh', 'tli', + 'tmh', 'tog', 'ton', 'tpi', 'tsi', 'tsn', 'tso', 'tuk', + 'tum', 'tup', 'tur', 'tut', 'tvl', 'twi', 'tyv', 'udm', + 'uga', 'uig', 'ukr', 'umb', 'und', 'urd', 'uzb', 'vai', + 'ven', 'vie', 'vol', 'vot', 'wak', 'wal', 'war', 'was', + 'wel', 'wen', 'wln', 'wol', 'xal', 'xho', 'yao', 'yap', + 'yid', 'yor', 'ypk', 'zap', 'zen', 'zha', 'zho', 'znd', + 'zul', 'zun', 'rum', 'pob', 'unk', 'ass']) + videos = [Episode, Movie] require_video = False confidence_order = ['moviehash', 'imdbid', 'fulltext'] @@ -92,7 +131,7 @@ class OpenSubtitles(ServiceBase): if not searches: raise ServiceError('One or more parameter missing') for search in searches: - search['sublanguageid'] = ','.join([self.get_language(l) for l in languages]) + search['sublanguageid'] = ','.join(l.opensubtitles for l in languages) logger.debug(u'Getting subtitles %r with token %s' % (searches, self.token)) results = self.server.SearchSubtitles(self.token, searches) if not results['data']: @@ -100,7 +139,7 @@ class OpenSubtitles(ServiceBase): return [] subtitles = [] for result in results['data']: - language = self.get_revert_language(result['SubLanguageID']) + language = guessit.Language(result['SubLanguageID']) path = get_subtitle_path(filepath, language, self.config.multi) confidence = 1 - float(self.confidence_order.index(result['MatchedBy'])) / float(len(self.confidence_order)) subtitle = ResultSubtitle(path, language, service=self.__class__.__name__.lower(), link=result['SubDownloadLink'], @@ -108,9 +147,7 @@ class OpenSubtitles(ServiceBase): subtitles.append(subtitle) return subtitles - def list(self, video, languages): - if not self.check_validity(video, languages): - return [] + def list_checked(self, video, languages): results = [] if video.exists: results = self.query(video.path or video.release, languages, moviehash=video.hashes['OpenSubtitles'], size=str(video.size)) diff --git a/libs/subliminal/services/podnapisi.py b/libs/subliminal/services/podnapisi.py new file mode 100755 index 00000000..0e12fa00 --- /dev/null +++ b/libs/subliminal/services/podnapisi.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# Copyright 2011-2012 Antoine Bertin +# +# This file is part of subliminal. +# +# subliminal is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# subliminal 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with subliminal. If not, see . +from . import ServiceBase +from ..exceptions import ServiceError, DownloadFailedError +from ..subtitles import get_subtitle_path, ResultSubtitle +from ..utils import to_unicode +from ..videos import Episode, Movie +from guessit.language import lang_set +from hashlib import md5, sha256 +import guessit +import logging +import xmlrpclib + + +logger = logging.getLogger(__name__) + + +class Podnapisi(ServiceBase): + server_url = 'http://ssp.podnapisi.net:8000' + api_based = True + languages = lang_set(['sl', 'en', 'nn', 'ko', 'de', 'is', 'cs', 'fr', 'it', 'bs', 'jp', 'ar', 'ro', + 'hu', 'gr', 'zh', 'lt', 'et', 'lv', 'he', 'nl', 'da', 'sv', 'pl', 'ru', 'es', + 'sq', 'tr', 'fi', 'pt', 'bg', 'mk', 'sr', 'sk', 'hr', 'hi', 'th', 'ca', 'uk', + 'pb', 'ga', 'be', 'vi', 'fa', 'ca', 'id', 'ms']) + #FIXME: ag and cyr not recognized by guessit + videos = [Episode, Movie] + require_video = True + + def __init__(self, config=None): + super(Podnapisi, self).__init__(config) + self.server = xmlrpclib.ServerProxy(self.server_url) + self.token = None + + def init(self): + super(Podnapisi, self).init() + result = self.server.initiate(self.user_agent) + if result['status'] != 200: + raise ServiceError('Initiate failed') + username = 'python_subliminal' + password = sha256(md5('XWFXQ6gE5Oe12rv4qxXX').hexdigest() + result['nonce']).hexdigest() + self.token = result['session'] + result = self.server.authenticate(self.token, username, password) + if result['status'] != 200: + raise ServiceError('Authenticate failed') + + def terminate(self): + super(Podnapisi, self).terminate() + + def query(self, filepath, languages, moviehash): + results = self.server.search(self.token, [moviehash]) + if results['status'] != 200: + logger.error('Search failed with error code %d' % results['status']) + return [] + if not results['results'] or not results['results'][moviehash]['subtitles']: + logger.debug(u'Could not find subtitles for %r with token %s' % (moviehash, self.token)) + return [] + subtitles = [] + for result in results['results'][moviehash]['subtitles']: + language = guessit.Language(result['lang']) + if language == guessit.language.UNDETERMINED or language not in languages: + continue + path = get_subtitle_path(filepath, language, self.config.multi) + subtitle = ResultSubtitle(path, language, service=self.__class__.__name__.lower(), link=result['id'], + release=to_unicode(result['release']), confidence=result['weight']) + subtitles.append(subtitle) + if not subtitles: + return [] + # Convert weight to confidence + max_weight = float(max([s.confidence for s in subtitles])) + min_weight = float(min([s.confidence for s in subtitles])) + for subtitle in subtitles: + if max_weight == 0 and min_weight == 0: + subtitle.confidence = 1.0 + else: + subtitle.confidence = (subtitle.confidence - min_weight) / (max_weight - min_weight) + return subtitles + + def list_checked(self, video, languages): + results = self.query(video.path, languages, video.hashes['OpenSubtitles']) + return results + + def download(self, subtitle): + results = self.server.download(self.token, [subtitle.link]) + if results['status'] != 200: + raise DownloadFailedError() + subtitle.link = 'http://www.podnapisi.net/static/podnapisi/' + results['names'][0]['filename'] + self.download_file(subtitle.link, subtitle.path) + return subtitle + + +Service = Podnapisi diff --git a/libs/subliminal/services/subswiki.py b/libs/subliminal/services/subswiki.py index c5670c1c..6671a8de 100755 --- a/libs/subliminal/services/subswiki.py +++ b/libs/subliminal/services/subswiki.py @@ -19,8 +19,10 @@ from . import ServiceBase from ..exceptions import ServiceError from ..subtitles import get_subtitle_path, ResultSubtitle from ..videos import Episode, Movie +from bs4 import BeautifulSoup +from guessit.language import lang_set from subliminal.utils import get_keywords, split_keyword -import BeautifulSoup +import guessit import logging import re import urllib @@ -32,17 +34,15 @@ logger = logging.getLogger(__name__) class SubsWiki(ServiceBase): server_url = 'http://www.subswiki.com' api_based = False - languages = {u'English (US)': 'en', u'English (UK)': 'en', u'English': 'en', u'French': 'fr', u'Brazilian': 'po', - u'Portuguese': 'pt', u'Español (Latinoamérica)': 'es', u'Español (España)': 'es', u'Español': 'es', - u'Italian': 'it', u'Català': 'ca'} - reverted_languages = True + languages = lang_set([u'English (US)', u'English (UK)', u'English', u'French', u'Brazilian', + u'Portuguese', u'Español (Latinoamérica)', u'Español (España)', + u'Español', u'Italian', u'Català'], strict=True) videos = [Episode, Movie] require_video = False release_pattern = re.compile('\nVersion (.+), ([0-9]+).([0-9])+ MBs') + required_features = ['permissive'] - def list(self, video, languages): - if not self.check_validity(video, languages): - return [] + def list_checked(self, video, languages): results = [] if isinstance(video, Episode): results = self.query(video.path or video.release, languages, get_keywords(video.guess), series=video.series, season=video.season, episode=video.episode) @@ -74,7 +74,7 @@ class SubsWiki(ServiceBase): if r.status_code != 200: logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) return [] - soup = BeautifulSoup.BeautifulSoup(r.content) + soup = BeautifulSoup(r.content, self.required_features) subtitles = [] for sub in soup('td', {'class': 'NewsTitle'}): sub_keywords = split_keyword(self.release_pattern.search(sub.contents[1]).group(1).lower()) @@ -82,8 +82,8 @@ class SubsWiki(ServiceBase): logger.debug(u'None of subtitle keywords %r in %r' % (sub_keywords, keywords)) continue for html_language in sub.parent.parent.findAll('td', {'class': 'language'}): - language = self.get_revert_language(html_language.string.strip()) - if not language in languages: + language = guessit.Language(html_language.string.strip()) + if language not in languages: logger.debug(u'Language %r not in wanted languages %r' % (language, languages)) continue html_status = html_language.findNextSibling('td') @@ -96,4 +96,5 @@ class SubsWiki(ServiceBase): subtitles.append(subtitle) return subtitles + Service = SubsWiki diff --git a/libs/subliminal/services/subtitulos.py b/libs/subliminal/services/subtitulos.py index 44888e70..8154aaaf 100755 --- a/libs/subliminal/services/subtitulos.py +++ b/libs/subliminal/services/subtitulos.py @@ -18,8 +18,10 @@ from . import ServiceBase from ..subtitles import get_subtitle_path, ResultSubtitle from ..videos import Episode +from bs4 import BeautifulSoup +from guessit.language import lang_set from subliminal.utils import get_keywords, split_keyword -import BeautifulSoup +import guessit import logging import re import unicodedata @@ -32,19 +34,19 @@ logger = logging.getLogger(__name__) class Subtitulos(ServiceBase): server_url = 'http://www.subtitulos.es' api_based = False - languages = {u'English (US)': 'en', u'English (UK)': 'en', u'English': 'en', u'French': 'fr', u'Brazilian': 'po', - u'Portuguese': 'pt', u'Español (Latinoamérica)': 'es', u'Español (España)': 'es', u'Español': 'es', - u'Italian': 'it', u'Català': 'ca'} - reverted_languages = True + languages = lang_set([u'English (US)', u'English (UK)', u'English', u'French', u'Brazilian', + u'Portuguese', u'Español (Latinoamérica)', u'Español (España)', u'Español', + u'Italian', u'Català'], strict=True) videos = [Episode] require_video = False - release_pattern = re.compile('Versión (.+) ([0-9]+).([0-9])+ megabytes') + required_features = ['permissive'] + # the '.+' in the pattern for Version allows us to match both 'ó' + # and the 'ó' char directly. This is because now BS4 converts the html + # code chars into their equivalent unicode char + release_pattern = re.compile('Versi.+n (.+) ([0-9]+).([0-9])+ megabytes') - def list(self, video, languages): - if not self.check_validity(video, languages): - return [] - results = self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) - return results + def list_checked(self, video, languages): + return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) def query(self, filepath, languages, keywords, series, season, episode): request_series = series.lower().replace(' ', '_') @@ -58,7 +60,7 @@ class Subtitulos(ServiceBase): if r.status_code != 200: logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) return [] - soup = BeautifulSoup.BeautifulSoup(r.content) + soup = BeautifulSoup(r.content, self.required_features) subtitles = [] for sub in soup('div', {'id': 'version'}): sub_keywords = split_keyword(self.release_pattern.search(sub.find('p', {'class': 'title-sub'}).contents[1]).group(1).lower()) @@ -66,8 +68,8 @@ class Subtitulos(ServiceBase): logger.debug(u'None of subtitle keywords %r in %r' % (sub_keywords, keywords)) continue for html_language in sub.findAllNext('ul', {'class': 'sslist'}): - language = self.get_revert_language(html_language.findNext('li', {'class': 'li-idioma'}).find('strong').contents[0].string.strip()) - if not language in languages: + language = guessit.Language(html_language.findNext('li', {'class': 'li-idioma'}).find('strong').contents[0].string.strip()) + if language not in languages: logger.debug(u'Language %r not in wanted languages %r' % (language, languages)) continue html_status = html_language.findNext('li', {'class': 'li-estado green'}) @@ -80,4 +82,5 @@ class Subtitulos(ServiceBase): subtitles.append(subtitle) return subtitles + Service = Subtitulos diff --git a/libs/subliminal/services/thesubdb.py b/libs/subliminal/services/thesubdb.py index cccddd40..1800dcf7 100755 --- a/libs/subliminal/services/thesubdb.py +++ b/libs/subliminal/services/thesubdb.py @@ -18,6 +18,8 @@ from . import ServiceBase from ..subtitles import get_subtitle_path, ResultSubtitle from ..videos import Episode, Movie, UnknownVideo +from guessit.language import lang_set +import guessit import logging @@ -26,21 +28,17 @@ logger = logging.getLogger(__name__) class TheSubDB(ServiceBase): server_url = 'http://api.thesubdb.com/' # for testing purpose, use http://sandbox.thesubdb.com/ instead - user_agent = 'SubDB/1.0 (subliminal/0.5; https://github.com/Diaoul/subliminal)' # defined by the API + user_agent = 'SubDB/1.0 (subliminal/0.6; https://github.com/Diaoul/subliminal)' # defined by the API api_based = True - languages = {'af': 'af', 'cs': 'cs', 'da': 'da', 'de': 'de', 'en': 'en', 'es': 'es', 'fi': 'fi', - 'fr': 'fr', 'hu': 'hu', 'id': 'id', 'it': 'it', 'la': 'la', 'nl': 'nl', 'no': 'no', - 'oc': 'oc', 'pl': 'pl', 'pt': 'pt', 'ro': 'ro', 'ru': 'ru', 'sl': 'sl', 'sr': 'sr', - 'sv': 'sv', 'tr': 'tr'} # list available with the API at http://sandbox.thesubdb.com/?action=languages - reverted_languages = False + languages = lang_set(['af', 'cs', 'da', 'de', 'en', 'es', 'fi', + 'fr', 'hu', 'id', 'it', 'la', 'nl', 'no', + 'oc', 'pl', 'pt', 'ro', 'ru', 'sl', 'sr', + 'sv', 'tr'], strict=True) # list available with the API at http://sandbox.thesubdb.com/?action=languages videos = [Movie, Episode, UnknownVideo] require_video = True - def list(self, video, languages): - if not self.check_validity(video, languages): - return [] - results = self.query(video.path, video.hashes['TheSubDB'], languages) - return results + def list_checked(self, video, languages): + return self.query(video.path, video.hashes['TheSubDB'], languages) def query(self, filepath, moviehash, languages): r = self.session.get(self.server_url, params={'action': 'search', 'hash': moviehash}) @@ -50,7 +48,7 @@ class TheSubDB(ServiceBase): if r.status_code != 200: logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) return [] - available_languages = set([self.get_revert_language(l) for l in r.content.split(',')]) + available_languages = set(guessit.Language(l) for l in r.content.split(',')) languages &= available_languages if not languages: logger.debug(u'Could not find subtitles for hash %s with languages %r (only %r available)' % (moviehash, languages, available_languages)) @@ -58,8 +56,9 @@ class TheSubDB(ServiceBase): subtitles = [] for language in languages: path = get_subtitle_path(filepath, language, self.config.multi) - subtitle = ResultSubtitle(path, language, service=self.__class__.__name__.lower(), link='%s?action=download&hash=%s&language=%s' % (self.server_url, moviehash, self.get_language(language))) + subtitle = ResultSubtitle(path, language, self.__class__.__name__.lower(), '%s?action=download&hash=%s&language=%s' % (self.server_url, moviehash, language.alpha2)) subtitles.append(subtitle) return subtitles + Service = TheSubDB diff --git a/libs/subliminal/services/tvsubtitles.py b/libs/subliminal/services/tvsubtitles.py new file mode 100755 index 00000000..5c74cc1e --- /dev/null +++ b/libs/subliminal/services/tvsubtitles.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Copyright 2012 Nicolas Wack +# +# This file is part of subliminal. +# +# subliminal is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# subliminal 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with subliminal. If not, see . +from . import ServiceBase +from ..cache import cachedmethod +from ..subtitles import get_subtitle_path, ResultSubtitle +from ..videos import Episode +from bs4 import BeautifulSoup +from guessit.language import lang_set +from subliminal.utils import get_keywords +import guessit +import logging +import re + + +logger = logging.getLogger(__name__) + + +def match(pattern, string): + try: + return re.search(pattern, string).group(1) + except AttributeError: + logger.debug(u'Could not match %r on %r' % (pattern, string)) + return None + + +class TvSubtitles(ServiceBase): + server_url = 'http://www.tvsubtitles.net' + api_based = False + languages = lang_set([u'English', u'Español', u'French', u'German', + u'Brazilian', u'Russian', u'Ukrainian', u'Italian', + u'Greek', u'Arabic', u'Hungarian', u'Polish', + u'Turkish', u'Dutch', u'Portuguese', u'Swedish', + u'Danish', u'Finnish', u'Korean', u'Chinese', + u'Japanese', u'Bulgarian', u'Czech', u'Romanian'], strict=True) + videos = [Episode] + require_video = False + required_features = ['permissive'] + + @cachedmethod + def get_likely_series_id(self, name): + r = self.session.post('%s/search.php' % self.server_url, data={'q': name}) + soup = BeautifulSoup(r.content, self.required_features) + maindiv = soup.find('div', 'left') + results = [] + for elem in maindiv.find_all('li'): + sid = int(match('tvshow-([0-9]+)\.html', elem.a['href'])) + show_name = match('(.*) \(', elem.a.text) + results.append((show_name, sid)) + #TODO: pick up the best one in a smart way + result = results[0] + return result[1] + + @cachedmethod + def get_episode_id(self, series_id, season, number): + """Get the TvSubtitles id for the given episode. Raises KeyError if none + could be found.""" + # download the page of the season, contains ids for all episodes + episode_id = None + r = self.session.get('%s/tvshow-%d-%d.html' % (self.server_url, series_id, season)) + soup = BeautifulSoup(r.content, self.required_features) + table = soup.find('table', id='table5') + for row in table.find_all('tr'): + cells = row.find_all('td') + if not cells: + continue + + episode_number = match('x([0-9]+)', cells[0].text) + if not episode_number: + continue + + episode_number = int(episode_number) + episode_id = int(match('episode-([0-9]+)', cells[1].a['href'])) + # we could just return the id of the queried episode, but as we + # already downloaded the whole page we might as well fill in the + # information for all the episodes of the season + self.cache_for(self.get_episode_id, args=(series_id, season, episode_number), result=episode_id) + # raises KeyError if not found + return self.cached_value(self.get_episode_id, args=(series_id, season, number)) + + # Do not cache this method in order to always check for the most recent + # subtitles + def get_sub_ids(self, episode_id): + subids = [] + r = self.session.get('%s/episode-%d.html' % (self.server_url, episode_id)) + epsoup = BeautifulSoup(r.content, self.required_features) + for subdiv in epsoup.find_all('a'): + if 'href' not in subdiv.attrs or not subdiv['href'].startswith('/subtitle'): + continue + subid = int(match('([0-9]+)', subdiv['href'])) + lang = guessit.Language(match('flags/(.*).gif', subdiv.img['src'])) + result = {'subid': subid, 'language': lang} + for p in subdiv.find_all('p'): + if 'alt' in p.attrs and p['alt'] == 'rip': + result['rip'] = p.text.strip() + if 'alt' in p.attrs and p['alt'] == 'release': + result['release'] = p.text.strip() + + subids.append(result) + return subids + + def list_checked(self, video, languages): + return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) + + def query(self, filepath, languages, keywords, series, season, episode): + logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) + self.init_cache() + sid = self.get_likely_series_id(series.lower()) + try: + ep_id = self.get_episode_id(sid, season, episode) + except KeyError: + logger.debug(u'Could not find episode id for %s season %d episode %d' % (series, season, episode)) + return [] + subids = self.get_sub_ids(ep_id) + # filter the subtitles with our queried languages + subtitles = [] + for subid in subids: + language = subid['language'] + if language not in languages: + continue + path = get_subtitle_path(filepath, language, self.config.multi) + subtitle = ResultSubtitle(path, language, self.__class__.__name__.lower(), + '%s/download-%d.html' % (self.server_url, subid['subid']), + keywords=[subid['rip'], subid['release']]) + subtitles.append(subtitle) + return subtitles + + def download(self, subtitle): + self.download_zip_file(subtitle.link, subtitle.path) + + +Service = TvSubtitles diff --git a/libs/subliminal/subtitles.py b/libs/subliminal/subtitles.py index 355046dc..a33c35c3 100755 --- a/libs/subliminal/subtitles.py +++ b/libs/subliminal/subtitles.py @@ -15,13 +15,13 @@ # # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . -from .languages import list_languages, convert_language import os.path +import guessit +from guessit.language import is_language __all__ = ['Subtitle', 'EmbeddedSubtitle', 'ExternalSubtitle', 'ResultSubtitle', 'get_subtitle_path'] - #: Subtitles extensions EXTENSIONS = ['.srt', '.sub', '.txt'] @@ -30,7 +30,8 @@ class Subtitle(object): """Base class for subtitles :param string path: path to the subtitle - :param string language: language of the subtitle (second element of :class:`~subliminal.languages.LANGUAGES`) + :param language: language of the subtitle + :type language: :class:`guessit.Language` """ def __init__(self, path, language): @@ -49,7 +50,8 @@ class EmbeddedSubtitle(Subtitle): """Subtitle embedded in a container :param string path: path to the subtitle - :param string language: language of the subtitle (second element of :class:`~subliminal.languages.LANGUAGES`) + :param language: language of the subtitle + :type language: :class:`guessit.Language` :param int track_id: id of the subtitle track in the container """ @@ -59,7 +61,7 @@ class EmbeddedSubtitle(Subtitle): @classmethod def from_enzyme(cls, path, subtitle): - language = convert_language(subtitle.language, 1, 2) + language = guessit.Language(subtitle.language) or None return cls(path, language, subtitle.trackno) @@ -76,8 +78,8 @@ class ExternalSubtitle(Subtitle): if not extension: raise ValueError('Not a supported subtitle extension') language = os.path.splitext(path[:len(path) - len(extension)])[1][1:] - if not language in list_languages(1): - language = None + language = guessit.Language(language) or None + return cls(path, language) @@ -85,7 +87,8 @@ class ResultSubtitle(ExternalSubtitle): """Subtitle found using :mod:`~subliminal.services` :param string path: path to the subtitle - :param string language: language of the subtitle (second element of :class:`~subliminal.languages.LANGUAGES`) + :param language: language of the subtitle + :type language: :class:`guessit.Language` :param string service: name of the service :param string link: download link for the subtitle :param string release: release name of the video @@ -111,20 +114,27 @@ class ResultSubtitle(ExternalSubtitle): """ extension = os.path.splitext(self.path)[0] language = os.path.splitext(self.path[:len(self.path) - len(extension)])[1][1:] - if not language in list_languages(1): - return True - return False + return not is_language(language) def __repr__(self): return 'ResultSubtitle(%s, %s, %.2f, %s)' % (self.language, self.service, self.confidence, self.release) def get_subtitle_path(video_path, language, multi): - """Create the subtitle path from the given video path using language if multi""" + """Create the subtitle path from the given video path using language if multi + + :param string video_path: path to the video + :param language: language of the subtitle + :type language: :class:`guessit.Language` + :param bool multi: whether to use multi language naming or not + :return: path of the subtitle + :rtype: string + + """ if not os.path.exists(video_path): path = os.path.splitext(os.path.basename(video_path))[0] else: path = os.path.splitext(video_path)[0] if multi and language: - return path + '.%s%s' % (language, EXTENSIONS[0]) + return path + '.%s%s' % (language.alpha2, EXTENSIONS[0]) return path + '%s' % EXTENSIONS[0] diff --git a/libs/subliminal/videos.py b/libs/subliminal/videos.py index 79276b14..adfcafe7 100755 --- a/libs/subliminal/videos.py +++ b/libs/subliminal/videos.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . from . import subtitles -from .languages import list_languages import enzyme import guessit import hashlib @@ -130,14 +129,23 @@ class Video(object): logger.debug(u'Failed parsing %s with enzyme' % self.path) if isinstance(video_infos, enzyme.core.AVContainer): results.extend([subtitles.EmbeddedSubtitle.from_enzyme(self.path, s) for s in video_infos.subtitles]) - for l in list_languages(1): - for e in subtitles.EXTENSIONS: - single_path = basepath + '%s' % e - if os.path.exists(single_path): - results.append(subtitles.ExternalSubtitle(single_path, None)) - multi_path = basepath + '.%s%s' % (l, e) - if os.path.exists(multi_path): - results.append(subtitles.ExternalSubtitle(multi_path, l)) + + # cannot use glob here because it chokes if there are any square + # brackets inside the filename, so we have to use basic string + # startswith/endswith comparisons + folder, basename = os.path.split(basepath) + existing = [f for f in os.listdir(folder) if f.startswith(basename)] + for path in existing: + for ext in subtitles.EXTENSIONS: + if path.endswith(ext): + possible_lang = path[len(basename) + 1:-len(ext)] + if possible_lang == '': + results.append(subtitles.ExternalSubtitle(path, None)) + else: + lang = guessit.Language(possible_lang) + if lang: + results.append(subtitles.ExternalSubtitle(path, lang)) + return results def __repr__(self): @@ -189,11 +197,12 @@ class UnknownVideo(Video): pass -def scan(entry, max_depth=3, depth=0): +def scan(entry, max_depth=3, scan_filter=None, depth=0): """Scan a path for videos and subtitles :param string entry: path :param int max_depth: maximum folder depth + :param function scan_filter: filter function that takes a path as argument and returns a boolean indicating whether it has to be filtered out (``True``) or not (``False``) :param int depth: starting depth :return: found videos and subtitles :rtype: list of (:class:`Video`, [:class:`~subliminal.subtitles.Subtitle`]) @@ -207,13 +216,15 @@ def scan(entry, max_depth=3, depth=0): logger.debug(u'Scanning directory %s with depth %d/%d' % (entry, depth, max_depth)) result = [] for e in os.listdir(entry): - result.extend(scan(os.path.join(entry, e), max_depth, depth + 1)) + result.extend(scan(os.path.join(entry, e), max_depth, scan_filter, depth + 1)) return result if os.path.isfile(entry) or depth == 0: logger.debug(u'Scanning file %s with depth %d/%d' % (entry, depth, max_depth)) if depth != 0: # trust the user: only check for valid format if recursing if mimetypes.guess_type(entry)[0] not in MIMETYPES and os.path.splitext(entry)[1] not in EXTENSIONS: return [] + if scan_filter is not None and scan_filter(entry): + return [] video = Video.from_path(entry) return [(video, video.scan())] logger.warning(u'Scanning entry %s failed with depth %d/%d' % (entry, depth, max_depth))