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"] = "%s>" % 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("%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 in CDATA"))
+ yield self.encode(token["data"])
+ else:
+ yield self.encode(escape(token["data"]))
+
+ elif type in ("StartTag", "EmptyTag"):
+ name = token["name"]
+ yield self.encodeStrict(u"<%s" % name)
+ if name in rcdataElements and not self.escape_rcdata:
+ in_cdata = True
+ elif in_cdata:
+ self.serializeError(_("Unexpected child element of a CDATA element"))
+ attributes = []
+ for (attr_namespace,attr_name),attr_value in sorted(token["data"].items()):
+ #TODO: Add namespace support here
+ k = attr_name
+ v = attr_value
+ yield self.encodeStrict(u' ')
+
+ yield self.encodeStrict(k)
+ if not self.minimize_boolean_attributes or \
+ (k not in booleanAttributes.get(name, tuple()) \
+ and k not in booleanAttributes.get("", tuple())):
+ yield self.encodeStrict(u"=")
+ if self.quote_attr_values or not v:
+ quote_attr = True
+ else:
+ quote_attr = reduce(lambda x,y: x or (y in v),
+ spaceCharacters + u">\"'=", 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"%s>" % 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.state = self.dataState
+ else:
+ # XXX data can be _'_...
+ self.tokenQueue.append({"type": tokenTypes["ParseError"], "data":
+ "expected-closing-tag-but-got-char",
+ "datavars": {"data": data}})
+ self.stream.unget(data)
+ self.state = self.bogusCommentState
+ return True
+
+ def tagNameState(self):
+ data = self.stream.char()
+ if data in spaceCharacters:
+ self.state = self.beforeAttributeNameState
+ elif 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""})
+ self.stream.unget(data)
+ self.state = self.rcdataState
+ return True
+
+ def rcdataEndTagNameState(self):
+ appropriate = self.currentToken and self.currentToken["name"].lower() == self.temporaryBuffer.lower()
+ data = self.stream.char()
+ if data in spaceCharacters and appropriate:
+ self.currentToken = {"type": tokenTypes["EndTag"],
+ "name": self.temporaryBuffer,
+ "data": [], "selfClosing":False}
+ self.state = self.beforeAttributeNameState
+ elif data == "/" and appropriate:
+ self.currentToken = {"type": tokenTypes["EndTag"],
+ "name": self.temporaryBuffer,
+ "data": [], "selfClosing":False}
+ self.state = self.selfClosingStartTagState
+ elif data == ">" 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.temporaryBuffer})
+ self.stream.unget(data)
+ self.state = self.rcdataState
+ return True
+
+ def rawtextLessThanSignState(self):
+ data = self.stream.char()
+ if data == "/":
+ self.temporaryBuffer = ""
+ self.state = self.rawtextEndTagOpenState
+ else:
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"<"})
+ self.stream.unget(data)
+ self.state = self.rawtextState
+ return True
+
+ def rawtextEndTagOpenState(self):
+ data = self.stream.char()
+ if data in asciiLetters:
+ self.temporaryBuffer += data
+ self.state = self.rawtextEndTagNameState
+ else:
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u""})
+ self.stream.unget(data)
+ self.state = self.rawtextState
+ return True
+
+ def rawtextEndTagNameState(self):
+ appropriate = self.currentToken and self.currentToken["name"].lower() == self.temporaryBuffer.lower()
+ data = self.stream.char()
+ if data in spaceCharacters and appropriate:
+ self.currentToken = {"type": tokenTypes["EndTag"],
+ "name": self.temporaryBuffer,
+ "data": [], "selfClosing":False}
+ self.state = self.beforeAttributeNameState
+ elif data == "/" and appropriate:
+ self.currentToken = {"type": tokenTypes["EndTag"],
+ "name": self.temporaryBuffer,
+ "data": [], "selfClosing":False}
+ self.state = self.selfClosingStartTagState
+ elif data == ">" 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.temporaryBuffer})
+ self.stream.unget(data)
+ self.state = self.rawtextState
+ return True
+
+ def scriptDataLessThanSignState(self):
+ data = self.stream.char()
+ if data == "/":
+ self.temporaryBuffer = ""
+ self.state = self.scriptDataEndTagOpenState
+ elif data == "!":
+ 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.temporaryBuffer})
+ self.stream.unget(data)
+ self.state = self.scriptDataState
+ return True
+
+ def scriptDataEscapeStartState(self):
+ data = self.stream.char()
+ if data == "-":
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"})
+ self.state = self.scriptDataEscapeStartDashState
+ else:
+ self.stream.unget(data)
+ self.state = self.scriptDataState
+ return True
+
+ def scriptDataEscapeStartDashState(self):
+ data = self.stream.char()
+ if data == "-":
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"})
+ self.state = self.scriptDataEscapedDashDashState
+ else:
+ self.stream.unget(data)
+ self.state = self.scriptDataState
+ return True
+
+ def scriptDataEscapedState(self):
+ data = self.stream.char()
+ if data == "-":
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"})
+ self.state = self.scriptDataEscapedDashState
+ elif data == "<":
+ self.state = self.scriptDataEscapedLessThanSignState
+ 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.state = self.dataState
+ else:
+ chars = self.stream.charsUntil((u"<", u"-", u"\u0000"))
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data":
+ data + chars})
+ return True
+
+ def scriptDataEscapedDashState(self):
+ data = self.stream.char()
+ if data == "-":
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"})
+ self.state = self.scriptDataEscapedDashDashState
+ elif data == "<":
+ self.state = self.scriptDataEscapedLessThanSignState
+ 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 scriptDataEscapedDashDashState(self):
+ data = self.stream.char()
+ if data == "-":
+ self.tokenQueue.append({"type": tokenTypes["Characters"], "data": u"-"})
+ elif data == "<":
+ self.state = self.scriptDataEscapedLessThanSignState
+ 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.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""})
+ self.stream.unget(data)
+ self.state = self.scriptDataEscapedState
+ return True
+
+ def scriptDataEscapedEndTagNameState(self):
+ appropriate = self.currentToken and self.currentToken["name"].lower() == self.temporaryBuffer.lower()
+ data = self.stream.char()
+ if data in spaceCharacters and appropriate:
+ self.currentToken = {"type": tokenTypes["EndTag"],
+ "name": self.temporaryBuffer,
+ "data": [], "selfClosing":False}
+ self.state = self.beforeAttributeNameState
+ elif data == "/" and appropriate:
+ self.currentToken = {"type": tokenTypes["EndTag"],
+ "name": self.temporaryBuffer,
+ "data": [], "selfClosing":False}
+ self.state = self.selfClosingStartTagState
+ elif data == ">" 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.temporaryBuffer})
+ self.stream.unget(data)
+ self.state = self.scriptDataEscapedState
+ return True
+
+ def scriptDataDoubleEscapeStartState(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.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("%s>"%(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("%s>"%(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'%s>' % 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 '' % 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))