Merge branch 'release/0.10.0'
10
.gitignore
vendored
|
@ -1,5 +1,5 @@
|
|||
# SB User Related #
|
||||
######################
|
||||
# SB User Related #
|
||||
cache/*
|
||||
cache.db*
|
||||
config.ini*
|
||||
|
@ -11,18 +11,18 @@ server.crt
|
|||
server.key
|
||||
restore/
|
||||
|
||||
# SB Test Related #
|
||||
######################
|
||||
# SB Test Related #
|
||||
tests/Logs/*
|
||||
tests/sickbeard.*
|
||||
tests/cache.db
|
||||
|
||||
# Compiled source #
|
||||
######################
|
||||
# Compiled source #
|
||||
*.py[co]
|
||||
|
||||
# IDE specific #
|
||||
######################
|
||||
# IDE specific #
|
||||
*.bak
|
||||
*.tmp
|
||||
*.wpr
|
||||
|
@ -35,8 +35,8 @@ tests/cache.db
|
|||
Session.vim
|
||||
.ropeproject/*
|
||||
|
||||
# OS generated files #
|
||||
######################
|
||||
# OS generated files #
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
.DS_Store
|
||||
|
|
|
@ -6,6 +6,8 @@ python:
|
|||
|
||||
install:
|
||||
- pip install cheetah
|
||||
- pip install coveralls
|
||||
|
||||
before_script: cd ./tests
|
||||
script: python all_tests.py
|
||||
script: coverage run --source=.. --omit=../lib/*,../tornado/* all_tests.py
|
||||
after_success: coveralls
|
114
CHANGES.md
|
@ -1,3 +1,115 @@
|
|||
### 0.10.0 (2015-08-06 11:05:00 UTC)
|
||||
|
||||
* Remove EZRSS provider
|
||||
* Update Tornado webserver to 4.2 (fdfaf3d)
|
||||
* Update change to suppress reporting of Tornado exception error 1 to updated package (ref:hacks.txt)
|
||||
* Update fix for API response header for JSON content type and the return of JSONP data to updated package (ref:hacks.txt)
|
||||
* Update Requests library 2.6.2 to 2.7.0 (8b5e457)
|
||||
* Update change to suppress HTTPS verification InsecureRequestWarning to updated package (ref:hacks.txt)
|
||||
* Change to consolidate cache database migration code
|
||||
* Change to only rebuild namecache on show update instead of on every search
|
||||
* Change to allow file moving across partition
|
||||
* Add removal of old entries from namecache on show deletion
|
||||
* Add Hallmark and specific ITV logos, remove logo of non-english Comedy Central Family
|
||||
* Fix provider TD failing to find episodes of air by date shows
|
||||
* Fix provider SCC failing to find episodes of air by date shows
|
||||
* Fix provider SCC searching propers
|
||||
* Fix provider SCC stop snatching releases for episodes already completed
|
||||
* Fix provider SCC handle null server responses
|
||||
* Change provider SCC remove 1 of 3 requests per search to save 30% time
|
||||
* Change provider SCC login process to use General Config/Advanced/Proxy host setting
|
||||
* Change provider SCD PEP8 and code convention cleanup
|
||||
* Change provider HDB code simplify and PEP8
|
||||
* Change provider IPT only decode unicode search strings
|
||||
* Change provider IPT login process to use General Config/Advanced/Proxy host setting
|
||||
* Change provider TB logo icon used on Config/Search Providers
|
||||
* Change provider TB PEP8 and code convention cleanup
|
||||
* Change provider TB login process to use General Config/Advanced/Proxy host setting
|
||||
* Remove useless webproxies from provider TPB as they fail for one reason or another
|
||||
* Change provider TPB to use mediaExtensions from common instead of hard-coded private list
|
||||
* Add new tld variants to provider TPB
|
||||
* Add test for authenticity to provider TPB to notify of 3rd party block
|
||||
* Change provider TD logo icon used on Config/Search Providers
|
||||
* Change provider TD login process to use General Config/Advanced/Proxy host setting
|
||||
* Change provider BTN code simplify and PEP8
|
||||
* Change provider BTS login process to use General Config/Advanced/Proxy host setting
|
||||
* Change provider FSH login process to use General Config/Advanced/Proxy host setting
|
||||
* Change provider RSS torrent code to use General Config/Advanced/Proxy host setting, simplify and PEP8
|
||||
* Change provider Wombles's PEP8 and code convention cleanup
|
||||
* Change provider Womble's use SSL
|
||||
* Change provider KAT remove dead url
|
||||
* Change provider KAT to use mediaExtensions from common instead of private list
|
||||
* Change provider KAT provider PEP8 and code convention cleanup
|
||||
* Change refactor and code simplification for torrent and newznab providers
|
||||
* Change refactor SCC to use torrent provider simplification and PEP8
|
||||
* Change refactor SCD to use torrent provider simplification
|
||||
* Change refactor TB to use torrent provider simplification and PEP8
|
||||
* Change refactor TBP to use torrent provider simplification and PEP8
|
||||
* Change refactor TD to use torrent provider simplification and PEP8
|
||||
* Change refactor TL to use torrent provider simplification and PEP8
|
||||
* Change refactor BTS to use torrent provider simplification and PEP8
|
||||
* Change refactor FSH to use torrent provider simplification and PEP8
|
||||
* Change refactor IPT to use torrent provider simplification and PEP8
|
||||
* Change refactor KAT to use torrent provider simplification and PEP8
|
||||
* Change refactor TOTV to use torrent provider simplification and PEP8
|
||||
* Remove HDTorrents torrent provider
|
||||
* Remove NextGen torrent provider
|
||||
* Add Rarbg torrent provider
|
||||
* Add MoreThan torrent provider
|
||||
* Add AlphaRatio torrent provider
|
||||
* Add PiSexy torrent provider
|
||||
* Add Strike torrent provider
|
||||
* Add TorrentShack torrent provider
|
||||
* Add BeyondHD torrent provider
|
||||
* Add GFTracker torrent provider
|
||||
* Add TtN torrent provider
|
||||
* Add GTI torrent provider
|
||||
* Fix getManualSearchStatus: object has no attribute 'segment'
|
||||
* Change handling of general HTTP error response codes to prevent issues
|
||||
* Add handling for CloudFlare custom HTTP response codes
|
||||
* Fix to correctly load local libraries instead of system installed libraries
|
||||
* Update PyNMA to hybrid v1.0
|
||||
* Change first run after install to set up the main db to the current schema instead of upgrading
|
||||
* Change don't create a backup from an initial zero byte main database file, PEP8 and code tidy up
|
||||
* Fix show list view when no shows exist and "Group show lists shows into" is set to anything other than "One Show List"
|
||||
* Fix fault matching air by date shows by using correct episode/season strings in find search results
|
||||
* Change add 'hevc', 'x265' and some langs to Config Search/Episode Search/Ignore result with any word
|
||||
* Change NotifyMyAndroid to its new web location
|
||||
* Update feedparser library 5.1.3 to 5.2.0 (8c62940)
|
||||
* Remove feedcache implementation and library
|
||||
* Add coverage testing and coveralls support
|
||||
* Add py2/3 regression testing for exception clauses
|
||||
* Change py2 exception clauses to py2/3 compatible clauses
|
||||
* Change py2 print statements to py2/3 compatible functions
|
||||
* Change py2 octal literals into the new py2/3 syntax
|
||||
* Change py2 iteritems to py2/3 compatible statements using six library
|
||||
* Change py2 queue, httplib, cookielib and xmlrpclib to py2/3 compatible calls using six
|
||||
* Change py2 file and reload functions to py2/3 compatible open and reload_module functions
|
||||
* Change Kodi notifier to use requests as opposed to urllib
|
||||
* Change to consolidate scene exceptions and name cache code
|
||||
* Change check_url function to use requests instead of httplib library
|
||||
* Update Six compatibility library 1.5.2 to 1.9.0 (8a545f4)
|
||||
* Update SimpleJSON library 2.0.9 to 3.7.3 (0bcdf20)
|
||||
* Update xmltodict library 0.9.0 to 0.9.2 (579a005)
|
||||
* Update dateutil library 2.2 to 2.4.2 (a6b8925)
|
||||
* Update ConfigObj library 4.6.0 to 5.1.0 (a68530a)
|
||||
* Update Beautiful Soup to 4.3.2 (r353)
|
||||
* Update jsonrpclib library r20 to (b59217c)
|
||||
* Change cachecontrol library to ensure cache file exists before attempting delete
|
||||
* Fix saving root dirs
|
||||
* Change pushbullet from urllib2 to requests
|
||||
* Change to make pushbullet error messages clearer
|
||||
* Change pyNMA use of urllib to requests (ref:hacks.txt)
|
||||
* Change Trakt url to fix baseline uses (e.g. add from trending)
|
||||
* Fix edit on show page for shows that have anime enabled in mass edit
|
||||
* Fix issue parsing items in ToktoToshokan provider
|
||||
* Change to only show option "End upgrade on first match" on edit show page if quality custom is selected
|
||||
* Change label "Show is grouped in" in edit show page to "Show is in group" and move the section higher
|
||||
* Fix post processing of anime with version tags
|
||||
* Change accept SD titles that contain audio quality
|
||||
* Change readme.md
|
||||
|
||||
|
||||
### 0.9.1 (2015-05-25 03:03:00 UTC)
|
||||
|
||||
* Fix erroneous multiple downloads of torrent files which causes snatches to fail under certain conditions
|
||||
|
@ -64,7 +176,7 @@
|
|||
* Change disable the Force buttons on the Manage Searches page while a search is running
|
||||
* Change staggered periods of testing and updating of all shows "ended" status up to 460 days
|
||||
* Change "Archive" to "Upgrade to" in Edit show and other places and improve related texts for clarity
|
||||
* Fix history consolidation to only update an episode status if the history disagrees with the status.
|
||||
* Fix history consolidation to only update an episode status if the history disagrees with the status
|
||||
|
||||
|
||||
### 0.8.3 (2015-04-25 08:48:00 UTC)
|
||||
|
|
|
@ -3,3 +3,5 @@ Libs with customisations...
|
|||
/tornado
|
||||
/lib/requests/packages/urllib3/connectionpool.py
|
||||
/lib/requests/packages/urllib3/util/ssl_.py
|
||||
/lib/cachecontrol/caches/file_cache.py
|
||||
/lib/pynma/pynma.py
|
52
SickBeard.py
|
@ -18,6 +18,7 @@
|
|||
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Check needed software dependencies to nudge users to fix their setup
|
||||
from __future__ import print_function
|
||||
from __future__ import with_statement
|
||||
|
||||
import time
|
||||
|
@ -32,7 +33,7 @@ import threading
|
|||
import getopt
|
||||
|
||||
if sys.version_info < (2, 6):
|
||||
print 'Sorry, requires Python 2.6 or 2.7.'
|
||||
print('Sorry, requires Python 2.6 or 2.7.')
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
|
@ -41,13 +42,14 @@ try:
|
|||
if Cheetah.Version[0] != '2':
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
print 'Sorry, requires Python module Cheetah 2.1.0 or newer.'
|
||||
print('Sorry, requires Python module Cheetah 2.1.0 or newer.')
|
||||
sys.exit(1)
|
||||
except:
|
||||
print 'The Python module Cheetah is required'
|
||||
print('The Python module Cheetah is required')
|
||||
sys.exit(1)
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'lib')))
|
||||
sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), 'lib')))
|
||||
from lib.six import moves
|
||||
|
||||
# We only need this for compiling an EXE and I will just always do that on 2.6+
|
||||
if sys.hexversion >= 0x020600F0:
|
||||
|
@ -139,15 +141,15 @@ class SickGear(object):
|
|||
sickbeard.SYS_ENCODING = 'UTF-8'
|
||||
|
||||
if not hasattr(sys, 'setdefaultencoding'):
|
||||
reload(sys)
|
||||
moves.reload_module(sys)
|
||||
|
||||
try:
|
||||
# pylint: disable=E1101
|
||||
# On non-unicode builds this will raise an AttributeError, if encoding type is not valid it throws a LookupError
|
||||
sys.setdefaultencoding(sickbeard.SYS_ENCODING)
|
||||
except:
|
||||
print 'Sorry, you MUST add the SickGear folder to the PYTHONPATH environment variable'
|
||||
print 'or find another way to force Python to use %s for string encoding.' % sickbeard.SYS_ENCODING
|
||||
print('Sorry, you MUST add the SickGear folder to the PYTHONPATH environment variable')
|
||||
print('or find another way to force Python to use %s for string encoding.' % sickbeard.SYS_ENCODING)
|
||||
sys.exit(1)
|
||||
|
||||
# Need console logging for SickBeard.py and SickBeard-console.exe
|
||||
|
@ -231,7 +233,7 @@ class SickGear(object):
|
|||
|
||||
else:
|
||||
if self.consoleLogging:
|
||||
print u'Not running in daemon mode. PID file creation disabled'
|
||||
print(u'Not running in daemon mode. PID file creation disabled')
|
||||
|
||||
self.CREATEPID = False
|
||||
|
||||
|
@ -242,7 +244,7 @@ class SickGear(object):
|
|||
# Make sure that we can create the data dir
|
||||
if not os.access(sickbeard.DATA_DIR, os.F_OK):
|
||||
try:
|
||||
os.makedirs(sickbeard.DATA_DIR, 0744)
|
||||
os.makedirs(sickbeard.DATA_DIR, 0o744)
|
||||
except os.error:
|
||||
sys.exit(u'Unable to create data directory: %s Exiting.' % sickbeard.DATA_DIR)
|
||||
|
||||
|
@ -260,11 +262,11 @@ class SickGear(object):
|
|||
os.chdir(sickbeard.DATA_DIR)
|
||||
|
||||
if self.consoleLogging:
|
||||
print u'Starting up SickGear from %s' % sickbeard.CONFIG_FILE
|
||||
print(u'Starting up SickGear from %s' % sickbeard.CONFIG_FILE)
|
||||
|
||||
# Load the config and publish it to the sickbeard package
|
||||
if not os.path.isfile(sickbeard.CONFIG_FILE):
|
||||
print u'Unable to find "%s", all settings will be default!' % sickbeard.CONFIG_FILE
|
||||
print(u'Unable to find "%s", all settings will be default!' % sickbeard.CONFIG_FILE)
|
||||
|
||||
sickbeard.CFG = ConfigObj(sickbeard.CONFIG_FILE)
|
||||
|
||||
|
@ -272,12 +274,12 @@ class SickGear(object):
|
|||
|
||||
if CUR_DB_VERSION > 0:
|
||||
if CUR_DB_VERSION < MIN_DB_VERSION:
|
||||
print u'Your database version (%s) is too old to migrate from with this version of SickGear' \
|
||||
% CUR_DB_VERSION
|
||||
print(u'Your database version (%s) is too old to migrate from with this version of SickGear' \
|
||||
% CUR_DB_VERSION)
|
||||
sys.exit(u'Upgrade using a previous version of SG first, or start with no database file to begin fresh')
|
||||
if CUR_DB_VERSION > MAX_DB_VERSION:
|
||||
print u'Your database version (%s) has been incremented past what this version of SickGear supports' \
|
||||
% CUR_DB_VERSION
|
||||
print(u'Your database version (%s) has been incremented past what this version of SickGear supports' \
|
||||
% CUR_DB_VERSION)
|
||||
sys.exit(
|
||||
u'If you have used other forks of SG, your database may be unusable due to their modifications')
|
||||
|
||||
|
@ -361,6 +363,10 @@ class SickGear(object):
|
|||
# refresh network timezones
|
||||
network_timezones.update_network_dict()
|
||||
|
||||
# load all ids from xem
|
||||
startup_background_tasks = threading.Thread(name='FETCH-XEMDATA', target=sickbeard.scene_exceptions.get_xem_ids)
|
||||
startup_background_tasks.start()
|
||||
|
||||
# sure, why not?
|
||||
if sickbeard.USE_FAILED_DOWNLOADS:
|
||||
failed_history.trimHistory()
|
||||
|
@ -387,7 +393,7 @@ class SickGear(object):
|
|||
pid = os.fork() # @UndefinedVariable - only available in UNIX
|
||||
if pid != 0:
|
||||
os._exit(0)
|
||||
except OSError, e:
|
||||
except OSError as e:
|
||||
sys.stderr.write('fork #1 failed: %d (%s)\n' % (e.errno, e.strerror))
|
||||
sys.exit(1)
|
||||
|
||||
|
@ -402,7 +408,7 @@ class SickGear(object):
|
|||
pid = os.fork() # @UndefinedVariable - only available in UNIX
|
||||
if pid != 0:
|
||||
os._exit(0)
|
||||
except OSError, e:
|
||||
except OSError as e:
|
||||
sys.stderr.write('fork #2 failed: %d (%s)\n' % (e.errno, e.strerror))
|
||||
sys.exit(1)
|
||||
|
||||
|
@ -411,8 +417,8 @@ class SickGear(object):
|
|||
pid = str(os.getpid())
|
||||
logger.log(u'Writing PID: %s to %s' % (pid, self.PIDFILE))
|
||||
try:
|
||||
file(self.PIDFILE, 'w').write('%s\n' % pid)
|
||||
except IOError, e:
|
||||
open(self.PIDFILE, 'w').write('%s\n' % pid)
|
||||
except IOError as e:
|
||||
logger.log_error_and_exit(
|
||||
u'Unable to write PID file: %s Error: %s [%s]' % (self.PIDFILE, e.strerror, e.errno))
|
||||
|
||||
|
@ -421,9 +427,9 @@ class SickGear(object):
|
|||
sys.stderr.flush()
|
||||
|
||||
devnull = getattr(os, 'devnull', '/dev/null')
|
||||
stdin = file(devnull, 'r')
|
||||
stdout = file(devnull, 'a+')
|
||||
stderr = file(devnull, 'a+')
|
||||
stdin = open(devnull, 'r')
|
||||
stdout = open(devnull, 'a+')
|
||||
stderr = open(devnull, 'a+')
|
||||
os.dup2(stdin.fileno(), sys.stdin.fileno())
|
||||
os.dup2(stdout.fileno(), sys.stdout.fileno())
|
||||
os.dup2(stderr.fileno(), sys.stderr.fileno())
|
||||
|
@ -456,7 +462,7 @@ class SickGear(object):
|
|||
curShow = TVShow(int(sqlShow['indexer']), int(sqlShow['indexer_id']))
|
||||
curShow.nextEpisode()
|
||||
sickbeard.showList.append(curShow)
|
||||
except Exception, e:
|
||||
except Exception as e:
|
||||
logger.log(
|
||||
u'There was an error creating the show in %s: %s' % (sqlShow['location'], str(e).decode('utf-8',
|
||||
'replace')),
|
||||
|
|
|
@ -18,14 +18,15 @@
|
|||
# You should have received a copy of the GNU General Public License
|
||||
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import print_function
|
||||
from __future__ import with_statement
|
||||
|
||||
import os.path
|
||||
import sys
|
||||
|
||||
sickbeardPath = os.path.split(os.path.split(sys.argv[0])[0])[0]
|
||||
sys.path.append(os.path.join(sickbeardPath, 'lib'))
|
||||
sys.path.append(sickbeardPath)
|
||||
sys.path.insert(1, os.path.join(sickbeardPath, 'lib'))
|
||||
sys.path.insert(1, sickbeardPath)
|
||||
|
||||
try:
|
||||
import requests
|
||||
|
@ -132,7 +133,7 @@ def processEpisode(dir_to_process, org_NZB_name=None, status=None):
|
|||
sess.post(login_url, data={'username': username, 'password': password}, stream=True, verify=False)
|
||||
result = sess.get(url, params=params, stream=True, verify=False)
|
||||
if result.status_code == 401:
|
||||
print 'Verify and use correct username and password in autoProcessTV.cfg'
|
||||
print('Verify and use correct username and password in autoProcessTV.cfg')
|
||||
else:
|
||||
for line in result.iter_lines():
|
||||
if line:
|
||||
|
|
|
@ -19,12 +19,13 @@
|
|||
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
|
||||
import autoProcessTV
|
||||
|
||||
if len(sys.argv) < 4:
|
||||
print 'No folder supplied - is this being called from HellaVCR?'
|
||||
print('No folder supplied - is this being called from HellaVCR?')
|
||||
sys.exit()
|
||||
else:
|
||||
autoProcessTV.processEpisode(sys.argv[3], sys.argv[2])
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env python2
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
@ -6,8 +7,8 @@ import ConfigParser
|
|||
import logging
|
||||
|
||||
sickbeardPath = os.path.split(os.path.split(sys.argv[0])[0])[0]
|
||||
sys.path.append(os.path.join(sickbeardPath, 'lib'))
|
||||
sys.path.append(sickbeardPath)
|
||||
sys.path.insert(1, os.path.join(sickbeardPath, 'lib'))
|
||||
sys.path.insert(1, sickbeardPath)
|
||||
configFilename = os.path.join(sickbeardPath, 'config.ini')
|
||||
|
||||
try:
|
||||
|
@ -22,9 +23,11 @@ try:
|
|||
fp = open(configFilename, 'r')
|
||||
config.readfp(fp)
|
||||
fp.close()
|
||||
except IOError, e:
|
||||
print 'Could not find/read Sickbeard config.ini: ' + str(e)
|
||||
print 'Possibly wrong mediaToSickbeard.py location. Ensure the file is in the autoProcessTV subdir of your Sickbeard installation'
|
||||
except IOError as e:
|
||||
print('Could not find/read Sickbeard config.ini: ' + str(e))
|
||||
print(
|
||||
'Possibly wrong mediaToSickbeard.py location. Ensure the file is in the autoProcessTV subdir of your Sickbeard '
|
||||
'installation')
|
||||
time.sleep(3)
|
||||
sys.exit(1)
|
||||
|
||||
|
@ -41,7 +44,7 @@ logfile = os.path.join(logdir, 'sickbeard.log')
|
|||
try:
|
||||
handler = logging.FileHandler(logfile)
|
||||
except:
|
||||
print 'Unable to open/create the log file at ' + logfile
|
||||
print('Unable to open/create the log file at ' + logfile)
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
@ -53,7 +56,7 @@ def utorrent():
|
|||
# print 'Calling utorrent'
|
||||
if len(sys.argv) < 2:
|
||||
scriptlogger.error('No folder supplied - is this being called from uTorrent?')
|
||||
print 'No folder supplied - is this being called from uTorrent?'
|
||||
print('No folder supplied - is this being called from uTorrent?')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
@ -73,7 +76,7 @@ def deluge():
|
|||
|
||||
if len(sys.argv) < 4:
|
||||
scriptlogger.error('No folder supplied - is this being called from Deluge?')
|
||||
print 'No folder supplied - is this being called from Deluge?'
|
||||
print('No folder supplied - is this being called from Deluge?')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
@ -86,7 +89,7 @@ def blackhole():
|
|||
|
||||
if None != os.getenv('TR_TORRENT_DIR'):
|
||||
scriptlogger.debug('Processing script triggered by Transmission')
|
||||
print 'Processing script triggered by Transmission'
|
||||
print('Processing script triggered by Transmission')
|
||||
scriptlogger.debug(u'TR_TORRENT_DIR: ' + os.getenv('TR_TORRENT_DIR'))
|
||||
scriptlogger.debug(u'TR_TORRENT_NAME: ' + os.getenv('TR_TORRENT_NAME'))
|
||||
dirName = os.getenv('TR_TORRENT_DIR')
|
||||
|
@ -94,7 +97,7 @@ def blackhole():
|
|||
else:
|
||||
if len(sys.argv) < 2:
|
||||
scriptlogger.error('No folder supplied - Your client should invoke the script with a Dir and a Relese Name')
|
||||
print 'No folder supplied - Your client should invoke the script with a Dir and a Release Name'
|
||||
print('No folder supplied - Your client should invoke the script with a Dir and a Release Name')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
@ -127,13 +130,13 @@ def main():
|
|||
|
||||
if not use_torrents:
|
||||
scriptlogger.error(u'Enable Use Torrent on Sickbeard to use this Script. Aborting!')
|
||||
print u'Enable Use Torrent on Sickbeard to use this Script. Aborting!'
|
||||
print(u'Enable Use Torrent on Sickbeard to use this Script. Aborting!')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
if not torrent_method in ['utorrent', 'transmission', 'deluge', 'blackhole']:
|
||||
scriptlogger.error(u'Unknown Torrent Method. Aborting!')
|
||||
print u'Unknown Torrent Method. Aborting!'
|
||||
print(u'Unknown Torrent Method. Aborting!')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
@ -141,13 +144,13 @@ def main():
|
|||
|
||||
if dirName is None:
|
||||
scriptlogger.error(u'MediaToSickbeard script need a dir to be run. Aborting!')
|
||||
print u'MediaToSickbeard script need a dir to be run. Aborting!'
|
||||
print(u'MediaToSickbeard script need a dir to be run. Aborting!')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
if not os.path.isdir(dirName):
|
||||
scriptlogger.error(u'Folder ' + dirName + ' does not exist. Aborting AutoPostProcess.')
|
||||
print u'Folder ' + dirName + ' does not exist. Aborting AutoPostProcess.'
|
||||
print(u'Folder ' + dirName + ' does not exist. Aborting AutoPostProcess.')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
@ -174,26 +177,26 @@ def main():
|
|||
login_url = protocol + host + ':' + port + web_root + '/login'
|
||||
|
||||
scriptlogger.debug('Opening URL: ' + url + ' with params=' + str(params))
|
||||
print 'Opening URL: ' + url + ' with params=' + str(params)
|
||||
print('Opening URL: ' + url + ' with params=' + str(params))
|
||||
|
||||
try:
|
||||
sess = requests.Session()
|
||||
sess.post(login_url, data={'username': username, 'password': password}, stream=True, verify=False)
|
||||
response = sess.get(url, auth=(username, password), params=params, verify=False, allow_redirects=False)
|
||||
except Exception, e:
|
||||
except Exception as e:
|
||||
scriptlogger.error(u': Unknown exception raised when opening url: ' + str(e))
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
if response.status_code == 401:
|
||||
scriptlogger.error(u'Verify and use correct username and password in autoProcessTV.cfg')
|
||||
print 'Verify and use correct username and password in autoProcessTV.cfg'
|
||||
print('Verify and use correct username and password in autoProcessTV.cfg')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
if response.status_code == 200:
|
||||
scriptlogger.info(u'Script ' + __file__ + ' Succesfull')
|
||||
print 'Script ' + __file__ + ' Succesfull'
|
||||
print('Script ' + __file__ + ' Succesfull')
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
|
||||
|
|
|
@ -19,11 +19,12 @@
|
|||
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from __future__ import print_function
|
||||
import sys
|
||||
import autoProcessTV
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print 'No folder supplied - is this being called from SABnzbd?'
|
||||
print('No folder supplied - is this being called from SABnzbd?')
|
||||
sys.exit()
|
||||
elif len(sys.argv) >= 8:
|
||||
autoProcessTV.processEpisode(sys.argv[1], sys.argv[2], sys.argv[7])
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
from distutils.core import setup
|
||||
import py2exe, sys, shutil
|
||||
import sys
|
||||
import shutil
|
||||
try:
|
||||
import py2exe
|
||||
except:
|
||||
pass
|
||||
|
||||
sys.argv.append('py2exe')
|
||||
|
||||
setup(
|
||||
options = {'py2exe': {'bundle_files': 1}},
|
||||
setup(options={'py2exe': {'bundle_files': 1}},
|
||||
# windows = [{'console': "sabToSickbeard.py"}],
|
||||
zipfile=None,
|
||||
console = ['sabToSickbeard.py'],
|
||||
console=['sabToSickbeard.py']
|
||||
)
|
||||
|
||||
shutil.copy('dist/sabToSickbeard.exe', '.')
|
||||
|
|
125
contributing.md
|
@ -1,125 +0,0 @@
|
|||
### Questions about SickGear?
|
||||
|
||||
To get your questions answered, please ask in the [SickGear Forum], on IRC \#SickGear pn freenode.net, or webchat.
|
||||
|
||||
# Contributing to SickGear
|
||||
|
||||
1. [Getting Involved](#getting-involved)
|
||||
2. [How To Report Bugs](#how-to-report-bugs)
|
||||
3. [Tips For Submitting Code](#tips-for-submitting-code)
|
||||
|
||||
|
||||
## Getting Involved
|
||||
|
||||
There are a number of ways to get involved with the development of SickGear. Even if you've never contributed code to an Open Source project before, we're always looking for help identifying bugs, cleaning up code, writing documentation and testing.
|
||||
|
||||
The goal of this guide is to provide the best way to contribute to the official SickGear repository. Please read through the full guide detailing [How to Report Bugs](#how-to-report-bugs).
|
||||
|
||||
## Discussion
|
||||
|
||||
### Issues and IRC
|
||||
|
||||
If you think you've found a bug please [file it in the bug tracker](#how-to-report-bugs).
|
||||
|
||||
Additionally most of the SickGear development team can be found in the [#SickGear](http://webchat.freenode.net/?channels=SickGear) IRC channel on irc.freenode.net.
|
||||
|
||||
|
||||
## How to Report Bugs
|
||||
|
||||
### Make sure it is a SickGear bug
|
||||
|
||||
Many bugs reported are actually issues with the user mis-understanding of how something works (there are a bit of moving parts to an ideal setup) and most of the time can be fixed by just changing some settings to fit the users needs.
|
||||
|
||||
If you are new to SickGear, it is usually a much better idea to ask for help first in the [SickGear IRC channel](http://webchat.freenode.net/?channels=SickGear). You will get much quicker support, and you will help avoid tying up the SickGear team with invalid bug reports.
|
||||
|
||||
### Try the latest version of SickGear
|
||||
|
||||
Bugs in old versions of SickGear may have already been fixed. In order to avoid reporting known issues, make sure you are always testing against the latest build/source. Also, we put new code in the `dev` branch first before pushing down to the `master` branch (which is what the binary builds are built off of).
|
||||
|
||||
|
||||
## Tips For Submitting Code
|
||||
|
||||
|
||||
### Code
|
||||
|
||||
**NEVER write your patches to the master branch** - it gets messy (I say this from experience!)
|
||||
|
||||
**ALWAYS USE A "TOPIC" BRANCH!** Personally I like the `branch-feature_name` format that way its easy to identify the branch and feature at a glance. Also please make note of any forum post / issue number in the pull commit so we know what you are solving (it helps with cleaning up the related items later).
|
||||
|
||||
|
||||
Please follow these guidelines before reporting a bug:
|
||||
|
||||
1. **Update to the latest version** — Check if you can reproduce the issue with the latest version from the `dev` branch.
|
||||
|
||||
2. **Use the SickGear Forums search** — check if the issue has already been reported. If it has been, please comment on the existing issue.
|
||||
|
||||
3. **Provide a means to reproduce the problem** — Please provide as much details as possible, e.g. SickGear log files (obfuscate apikey/passwords), browser and operating system versions, how you started SickGear, and of course the steps to reproduce the problem. Bugs are always reported in the forums.
|
||||
|
||||
|
||||
### Feature requests
|
||||
|
||||
Please follow the bug guidelines above for feature requests, i.e. update to the latest version and search for existing issues before posting a new request. You can submit Feature Requests in the [SickGear Forum] as well.
|
||||
|
||||
### Pull requests
|
||||
|
||||
[Pull requests](https://help.github.com/articles/using-pull-requests) are welcome and the preferred way of accepting code contributions.
|
||||
|
||||
Please follow these guidelines before sending a pull request:
|
||||
|
||||
1. Update your fork to the latest upstream version.
|
||||
|
||||
2. Use the `dev` branch to base your code off of. Create a topic-branch for your work. We will not merge your 'dev' branch, or your 'master' branch, only topic branches, coming from dev are merged.
|
||||
|
||||
3. Follow the coding conventions of the original repository. Do not change line endings of the existing file, as this will rewrite the file and loses history.
|
||||
|
||||
4. Keep your commits as autonomous as possible, i.e. create a new commit for every single bug fix or feature added.
|
||||
|
||||
5. Always add meaningful commit messages. We should not have to guess at what your code is supposed to do.
|
||||
|
||||
6. One pull request per feature. If you want multiple features, send multiple PR's
|
||||
|
||||
Please follow this process; it's the best way to get your work included in the project:
|
||||
|
||||
- [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork,
|
||||
and configure the remotes:
|
||||
|
||||
```bash
|
||||
# clone your fork of the repo into the current directory in terminal
|
||||
git clone git@github.com:<your username>/SickGear.git
|
||||
# navigate to the newly cloned directory
|
||||
cd SickGear
|
||||
# assign the original repo to a remote called "upstream"
|
||||
git remote add upstream https://github.com/SickGear/SickGear.git
|
||||
```
|
||||
|
||||
- If you cloned a while ago, get the latest changes from upstream:
|
||||
|
||||
```bash
|
||||
# fetch upstream changes
|
||||
git fetch upstream
|
||||
# make sure you are on your 'master' branch
|
||||
git checkout master
|
||||
# merge upstream changes
|
||||
git merge upstream/master
|
||||
```
|
||||
|
||||
- Create a new topic branch to contain your feature, change, or fix:
|
||||
|
||||
```bash
|
||||
git checkout -b <topic-branch-name> dev
|
||||
```
|
||||
|
||||
- Commit your changes in logical chunks. or your pull request is unlikely
|
||||
be merged into the main project. Use git's
|
||||
[interactive rebase](https://help.github.com/articles/interactive-rebase)
|
||||
feature to tidy up your commits before making them public.
|
||||
|
||||
- Push your topic branch up to your fork:
|
||||
|
||||
```bash
|
||||
git push origin <topic-branch-name>
|
||||
```
|
||||
|
||||
- [Open a Pull Request](https://help.github.com/articles/using-pull-requests) with a
|
||||
clear title and description.
|
||||
|
|
@ -507,6 +507,7 @@ inc_bottom.tmpl
|
|||
width:100%;
|
||||
padding:20px 0;
|
||||
text-align:center;
|
||||
clear:both;
|
||||
font-size:12px
|
||||
}
|
||||
|
||||
|
@ -1009,6 +1010,10 @@ div.formpaginate{
|
|||
font-weight:900
|
||||
}
|
||||
|
||||
#addShowForm #blackwhitelist{
|
||||
padding:0 0 0 15px
|
||||
}
|
||||
|
||||
#addShowForm #blackwhitelist,
|
||||
#addShowForm #blackwhitelist h4,
|
||||
#addShowForm #blackwhitelist p{
|
||||
|
|
Before Width: | Height: | Size: 4.9 KiB |
BIN
gui/slick/images/network/hallmark channel.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
gui/slick/images/providers/alpharatio.png
Normal file
After Width: | Height: | Size: 664 B |
BIN
gui/slick/images/providers/beyondhd.png
Normal file
After Width: | Height: | Size: 562 B |
BIN
gui/slick/images/providers/gftracker.png
Normal file
After Width: | Height: | Size: 886 B |
BIN
gui/slick/images/providers/grabtheinfo.png
Normal file
After Width: | Height: | Size: 1,011 B |
Before Width: | Height: | Size: 3.2 KiB |
BIN
gui/slick/images/providers/morethan.png
Normal file
After Width: | Height: | Size: 398 B |
Before Width: | Height: | Size: 243 B |
BIN
gui/slick/images/providers/pisexy.png
Normal file
After Width: | Height: | Size: 517 B |
BIN
gui/slick/images/providers/rarbg.png
Normal file
After Width: | Height: | Size: 635 B |
BIN
gui/slick/images/providers/strike.png
Normal file
After Width: | Height: | Size: 417 B |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.5 KiB |
BIN
gui/slick/images/providers/torrent.png
Normal file
After Width: | Height: | Size: 916 B |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 450 B |
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 441 B |
BIN
gui/slick/images/providers/torrentshack.png
Normal file
After Width: | Height: | Size: 861 B |
BIN
gui/slick/images/providers/transmithe_net.png
Normal file
After Width: | Height: | Size: 595 B |
|
@ -41,7 +41,7 @@
|
|||
<label class="cleafix" for="use_xbmc">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_xbmc" id="use_xbmc" #if $sickbeard.USE_XBMC then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_xbmc" id="use_xbmc" #if $sickbeard.USE_XBMC then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send XBMC commands ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -52,7 +52,7 @@
|
|||
<label for="xbmc_always_on">
|
||||
<span class="component-title">Always on</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_always_on" id="xbmc_always_on" #if $sickbeard.XBMC_ALWAYS_ON then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_always_on" id="xbmc_always_on" #if $sickbeard.XBMC_ALWAYS_ON then 'checked="checked"' else ''# />
|
||||
<p>log errors when unreachable ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -61,7 +61,7 @@
|
|||
<label for="xbmc_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_notify_onsnatch" id="xbmc_notify_onsnatch" #if $sickbeard.XBMC_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_notify_onsnatch" id="xbmc_notify_onsnatch" #if $sickbeard.XBMC_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -70,7 +70,7 @@
|
|||
<label for="xbmc_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_notify_ondownload" id="xbmc_notify_ondownload" #if $sickbeard.XBMC_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_notify_ondownload" id="xbmc_notify_ondownload" #if $sickbeard.XBMC_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -79,7 +79,7 @@
|
|||
<label for="xbmc_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_notify_onsubtitledownload" id="xbmc_notify_onsubtitledownload" #if $sickbeard.XBMC_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_notify_onsubtitledownload" id="xbmc_notify_onsubtitledownload" #if $sickbeard.XBMC_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -88,7 +88,7 @@
|
|||
<label for="xbmc_update_library">
|
||||
<span class="component-title">Update library</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_update_library" id="xbmc_update_library" #if $sickbeard.XBMC_UPDATE_LIBRARY then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_update_library" id="xbmc_update_library" #if $sickbeard.XBMC_UPDATE_LIBRARY then 'checked="checked"' else ''# />
|
||||
<p>update XBMC library when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -97,7 +97,7 @@
|
|||
<label for="xbmc_update_full">
|
||||
<span class="component-title">Full library update</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_update_full" id="xbmc_update_full" #if $sickbeard.XBMC_UPDATE_FULL then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_update_full" id="xbmc_update_full" #if $sickbeard.XBMC_UPDATE_FULL then 'checked="checked"' else ''# />
|
||||
<p>perform a full library update if update per-show fails ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -106,7 +106,7 @@
|
|||
<label for="xbmc_update_onlyfirst">
|
||||
<span class="component-title">Only update first host</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="xbmc_update_onlyfirst" id="xbmc_update_onlyfirst" #if $sickbeard.XBMC_UPDATE_ONLYFIRST then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="xbmc_update_onlyfirst" id="xbmc_update_onlyfirst" #if $sickbeard.XBMC_UPDATE_ONLYFIRST then 'checked="checked"' else ''# />
|
||||
<p>only send library updates to the first active host ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -165,7 +165,7 @@
|
|||
<label class="cleafix" for="use_kodi">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_kodi" id="use_kodi" #if $sickbeard.USE_KODI then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_kodi" id="use_kodi" #if $sickbeard.USE_KODI then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Kodi commands ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -175,7 +175,7 @@
|
|||
<label for="kodi_always_on">
|
||||
<span class="component-title">Always on</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_always_on" id="kodi_always_on" #if $sickbeard.KODI_ALWAYS_ON then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_always_on" id="kodi_always_on" #if $sickbeard.KODI_ALWAYS_ON then 'checked="checked"' else ''# />
|
||||
<p>log errors when unreachable ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -184,7 +184,7 @@
|
|||
<label for="kodi_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_notify_onsnatch" id="kodi_notify_onsnatch" #if $sickbeard.KODI_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_notify_onsnatch" id="kodi_notify_onsnatch" #if $sickbeard.KODI_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -193,7 +193,7 @@
|
|||
<label for="kodi_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_notify_ondownload" id="kodi_notify_ondownload" #if $sickbeard.KODI_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_notify_ondownload" id="kodi_notify_ondownload" #if $sickbeard.KODI_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -202,7 +202,7 @@
|
|||
<label for="kodi_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_notify_onsubtitledownload" id="kodi_notify_onsubtitledownload" #if $sickbeard.KODI_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_notify_onsubtitledownload" id="kodi_notify_onsubtitledownload" #if $sickbeard.KODI_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -211,7 +211,7 @@
|
|||
<label for="kodi_update_library">
|
||||
<span class="component-title">Update library</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_update_library" id="kodi_update_library" #if $sickbeard.KODI_UPDATE_LIBRARY then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_update_library" id="kodi_update_library" #if $sickbeard.KODI_UPDATE_LIBRARY then 'checked="checked"' else ''# />
|
||||
<p>update Kodi library when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -220,7 +220,7 @@
|
|||
<label for="kodi_update_full">
|
||||
<span class="component-title">Full library update</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_update_full" id="kodi_update_full" #if $sickbeard.KODI_UPDATE_FULL then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_update_full" id="kodi_update_full" #if $sickbeard.KODI_UPDATE_FULL then 'checked="checked"' else ''# />
|
||||
<p>perform a full library update if update per-show fails ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -229,7 +229,7 @@
|
|||
<label for="kodi_update_onlyfirst">
|
||||
<span class="component-title">Only update first host</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="kodi_update_onlyfirst" id="kodi_update_onlyfirst" #if $sickbeard.KODI_UPDATE_ONLYFIRST then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="kodi_update_onlyfirst" id="kodi_update_onlyfirst" #if $sickbeard.KODI_UPDATE_ONLYFIRST then 'checked="checked"' else ''# />
|
||||
<p>only send library updates to the first active host ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -352,7 +352,7 @@
|
|||
<label for="plex_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="plex_notify_onsnatch" id="plex_notify_onsnatch" #if $sickbeard.PLEX_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="plex_notify_onsnatch" id="plex_notify_onsnatch" #if $sickbeard.PLEX_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>download start notification</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -361,7 +361,7 @@
|
|||
<label for="plex_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="plex_notify_ondownload" id="plex_notify_ondownload" #if $sickbeard.PLEX_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="plex_notify_ondownload" id="plex_notify_ondownload" #if $sickbeard.PLEX_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>download finish notification</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -370,7 +370,7 @@
|
|||
<label for="plex_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="plex_notify_onsubtitledownload" id="plex_notify_onsubtitledownload" #if $sickbeard.PLEX_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="plex_notify_onsubtitledownload" id="plex_notify_onsubtitledownload" #if $sickbeard.PLEX_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>subtitle downloaded notification</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -411,7 +411,7 @@
|
|||
<label for="use_nmj">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_nmj" id="use_nmj" #if $sickbeard.USE_NMJ then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_nmj" id="use_nmj" #if $sickbeard.USE_NMJ then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send update commands to NMJ ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -441,7 +441,7 @@
|
|||
<div class="field-pair">
|
||||
<label for="nmj_database">
|
||||
<span class="component-title">NMJ database</span>
|
||||
<input type="text" name="nmj_database" id="nmj_database" value="$sickbeard.NMJ_DATABASE" class="form-control input-sm input250" #if $sickbeard.NMJ_DATABASE then "readonly=\"readonly\"" else ""# />
|
||||
<input type="text" name="nmj_database" id="nmj_database" value="$sickbeard.NMJ_DATABASE" class="form-control input-sm input250" #if $sickbeard.NMJ_DATABASE then "readonly=\"readonly\"" else ''# />
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"> </span>
|
||||
|
@ -451,7 +451,7 @@
|
|||
<div class="field-pair">
|
||||
<label for="nmj_mount">
|
||||
<span class="component-title">NMJ mount url</span>
|
||||
<input type="text" name="nmj_mount" id="nmj_mount" value="$sickbeard.NMJ_MOUNT" class="form-control input-sm input250" #if $sickbeard.NMJ_MOUNT then "readonly=\"readonly\"" else ""# />
|
||||
<input type="text" name="nmj_mount" id="nmj_mount" value="$sickbeard.NMJ_MOUNT" class="form-control input-sm input250" #if $sickbeard.NMJ_MOUNT then "readonly=\"readonly\"" else ''# />
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"> </span>
|
||||
|
@ -477,7 +477,7 @@
|
|||
<label for="use_nmjv2">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_nmjv2" id="use_nmjv2" #if $sickbeard.USE_NMJv2 then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_nmjv2" id="use_nmjv2" #if $sickbeard.USE_NMJv2 then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send update commands to NMJv2 ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -498,10 +498,10 @@
|
|||
<span class="component-title">Database location</span>
|
||||
<span class="component-desc">
|
||||
<label for="NMJV2_DBLOC_A" class="space-right">
|
||||
<input type="radio" NAME="nmjv2_dbloc" VALUE="local" id="NMJV2_DBLOC_A" #if $sickbeard.NMJv2_DBLOC=="local" then "checked=\"checked\"" else ""# />PCH Local Media
|
||||
<input type="radio" NAME="nmjv2_dbloc" VALUE="local" id="NMJV2_DBLOC_A" #if $sickbeard.NMJv2_DBLOC=='local' then 'checked="checked"' else ''# />PCH Local Media
|
||||
</label>
|
||||
<label for="NMJV2_DBLOC_B">
|
||||
<input type="radio" NAME="nmjv2_dbloc" VALUE="network" id="NMJV2_DBLOC_B" #if $sickbeard.NMJv2_DBLOC=="network" then "checked=\"checked\"" else ""#/>PCH Network Media
|
||||
<input type="radio" NAME="nmjv2_dbloc" VALUE="network" id="NMJV2_DBLOC_B" #if $sickbeard.NMJv2_DBLOC=='network' then 'checked="checked"' else ''#/>PCH Network Media
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
|
@ -538,7 +538,7 @@
|
|||
<div class="field-pair">
|
||||
<label for="nmjv2_database">
|
||||
<span class="component-title">NMJv2 database</span>
|
||||
<input type="text" name="nmjv2_database" id="nmjv2_database" value="$sickbeard.NMJv2_DATABASE" class="form-control input-sm input250" #if $sickbeard.NMJv2_DATABASE then "readonly=\"readonly\"" else ""# />
|
||||
<input type="text" name="nmjv2_database" id="nmjv2_database" value="$sickbeard.NMJv2_DATABASE" class="form-control input-sm input250" #if $sickbeard.NMJv2_DATABASE then "readonly=\"readonly\"" else ''# />
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"> </span>
|
||||
|
@ -567,7 +567,7 @@
|
|||
<label for="use_synoindex">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_synoindex" id="use_synoindex" #if $sickbeard.USE_SYNOINDEX then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_synoindex" id="use_synoindex" #if $sickbeard.USE_SYNOINDEX then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Synology notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -597,7 +597,7 @@
|
|||
<label for="use_synologynotifier">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_synologynotifier" id="use_synologynotifier" #if $sickbeard.USE_SYNOLOGYNOTIFIER then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_synologynotifier" id="use_synologynotifier" #if $sickbeard.USE_SYNOLOGYNOTIFIER then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send notifications to the Synology Notifier ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -611,7 +611,7 @@
|
|||
<label for="synologynotifier_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="synologynotifier_notify_onsnatch" id="synologynotifier_notify_onsnatch" #if $sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="synologynotifier_notify_onsnatch" id="synologynotifier_notify_onsnatch" #if $sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -620,7 +620,7 @@
|
|||
<label for="synologynotifier_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="synologynotifier_notify_ondownload" id="synologynotifier_notify_ondownload" #if $sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="synologynotifier_notify_ondownload" id="synologynotifier_notify_ondownload" #if $sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -629,7 +629,7 @@
|
|||
<label for="synologynotifier_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="synologynotifier_notify_onsubtitledownload" id="synologynotifier_notify_onsubtitledownload" #if $sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="synologynotifier_notify_onsubtitledownload" id="synologynotifier_notify_onsubtitledownload" #if $sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -651,7 +651,7 @@
|
|||
<label for="use_pytivo">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_pytivo" id="use_pytivo" #if $sickbeard.USE_PYTIVO then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_pytivo" id="use_pytivo" #if $sickbeard.USE_PYTIVO then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send notifications to pyTivo ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -713,7 +713,7 @@
|
|||
<label for="use_growl">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_growl" id="use_growl" #if $sickbeard.USE_GROWL then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_growl" id="use_growl" #if $sickbeard.USE_GROWL then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Growl notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -724,7 +724,7 @@
|
|||
<label for="growl_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="growl_notify_onsnatch" id="growl_notify_onsnatch" #if $sickbeard.GROWL_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="growl_notify_onsnatch" id="growl_notify_onsnatch" #if $sickbeard.GROWL_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -733,7 +733,7 @@
|
|||
<label for="growl_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="growl_notify_ondownload" id="growl_notify_ondownload" #if $sickbeard.GROWL_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="growl_notify_ondownload" id="growl_notify_ondownload" #if $sickbeard.GROWL_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -742,7 +742,7 @@
|
|||
<label for="growl_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="growl_notify_onsubtitledownload" id="growl_notify_onsubtitledownload" #if $sickbeard.GROWL_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="growl_notify_onsubtitledownload" id="growl_notify_onsubtitledownload" #if $sickbeard.GROWL_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -791,7 +791,7 @@
|
|||
<label for="use_prowl">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_prowl" id="use_prowl" #if $sickbeard.USE_PROWL then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_prowl" id="use_prowl" #if $sickbeard.USE_PROWL then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Prowl notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -802,7 +802,7 @@
|
|||
<label for="prowl_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="prowl_notify_onsnatch" id="prowl_notify_onsnatch" #if $sickbeard.PROWL_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="prowl_notify_onsnatch" id="prowl_notify_onsnatch" #if $sickbeard.PROWL_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -811,7 +811,7 @@
|
|||
<label for="prowl_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="prowl_notify_ondownload" id="prowl_notify_ondownload" #if $sickbeard.PROWL_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="prowl_notify_ondownload" id="prowl_notify_ondownload" #if $sickbeard.PROWL_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -820,7 +820,7 @@
|
|||
<label for="prowl_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="prowl_notify_onsubtitledownload" id="prowl_notify_onsubtitledownload" #if $sickbeard.PROWL_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="prowl_notify_onsubtitledownload" id="prowl_notify_onsubtitledownload" #if $sickbeard.PROWL_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -839,11 +839,11 @@
|
|||
<label for="prowl_priority">
|
||||
<span class="component-title">Prowl priority:</span>
|
||||
<select id="prowl_priority" name="prowl_priority" class="form-control input-sm">
|
||||
<option value="-2" #if $sickbeard.PROWL_PRIORITY == "-2" then 'selected="selected"' else ""#>Very Low</option>
|
||||
<option value="-1" #if $sickbeard.PROWL_PRIORITY == "-1" then 'selected="selected"' else ""#>Moderate</option>
|
||||
<option value="0" #if $sickbeard.PROWL_PRIORITY == "0" then 'selected="selected"' else ""#>Normal</option>
|
||||
<option value="1" #if $sickbeard.PROWL_PRIORITY == "1" then 'selected="selected"' else ""#>High</option>
|
||||
<option value="2" #if $sickbeard.PROWL_PRIORITY == "2" then 'selected="selected"' else ""#>Emergency</option>
|
||||
<option value="-2" #if $sickbeard.PROWL_PRIORITY == '-2' then 'selected="selected"' else ''#>Very Low</option>
|
||||
<option value="-1" #if $sickbeard.PROWL_PRIORITY == '-1' then 'selected="selected"' else ''#>Moderate</option>
|
||||
<option value="0" #if $sickbeard.PROWL_PRIORITY == '0' then 'selected="selected"' else ''#>Normal</option>
|
||||
<option value="1" #if $sickbeard.PROWL_PRIORITY == '1' then 'selected="selected"' else ''#>High</option>
|
||||
<option value="2" #if $sickbeard.PROWL_PRIORITY == '2' then 'selected="selected"' else ''#>Emergency</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -871,7 +871,7 @@
|
|||
<label for="use_libnotify">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_libnotify" id="use_libnotify" #if $sickbeard.USE_LIBNOTIFY then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_libnotify" id="use_libnotify" #if $sickbeard.USE_LIBNOTIFY then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Libnotify notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -882,7 +882,7 @@
|
|||
<label for="libnotify_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="libnotify_notify_onsnatch" id="libnotify_notify_onsnatch" #if $sickbeard.LIBNOTIFY_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="libnotify_notify_onsnatch" id="libnotify_notify_onsnatch" #if $sickbeard.LIBNOTIFY_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -891,7 +891,7 @@
|
|||
<label for="libnotify_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="libnotify_notify_ondownload" id="libnotify_notify_ondownload" #if $sickbeard.LIBNOTIFY_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="libnotify_notify_ondownload" id="libnotify_notify_ondownload" #if $sickbeard.LIBNOTIFY_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -900,7 +900,7 @@
|
|||
<label for="libnotify_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="libnotify_notify_onsubtitledownload" id="libnotify_notify_onsubtitledownload" #if $sickbeard.LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="libnotify_notify_onsubtitledownload" id="libnotify_notify_onsubtitledownload" #if $sickbeard.LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -925,7 +925,7 @@
|
|||
<label for="use_pushover">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_pushover" id="use_pushover" #if $sickbeard.USE_PUSHOVER then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_pushover" id="use_pushover" #if $sickbeard.USE_PUSHOVER then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Pushover notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -936,7 +936,7 @@
|
|||
<label for="pushover_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushover_notify_onsnatch" id="pushover_notify_onsnatch" #if $sickbeard.PUSHOVER_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushover_notify_onsnatch" id="pushover_notify_onsnatch" #if $sickbeard.PUSHOVER_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -945,7 +945,7 @@
|
|||
<label for="pushover_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushover_notify_ondownload" id="pushover_notify_ondownload" #if $sickbeard.PUSHOVER_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushover_notify_ondownload" id="pushover_notify_ondownload" #if $sickbeard.PUSHOVER_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -954,7 +954,7 @@
|
|||
<label for="pushover_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushover_notify_onsubtitledownload" id="pushover_notify_onsubtitledownload" #if $sickbeard.PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushover_notify_onsubtitledownload" id="pushover_notify_onsubtitledownload" #if $sickbeard.PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -983,10 +983,10 @@
|
|||
<label for="pushover_priority">
|
||||
<span class="component-title">Pushover priority:</span>
|
||||
<select id="pushover_priority" name="pushover_priority" class="form-control input-sm">
|
||||
<option value="-2" #if $sickbeard.PUSHOVER_PRIORITY == -2 then 'selected="selected"' else ""#>Lowest</option>
|
||||
<option value="-1" #if $sickbeard.PUSHOVER_PRIORITY == -1 then 'selected="selected"' else ""#>Low</option>
|
||||
<option value="0" #if $sickbeard.PUSHOVER_PRIORITY == 0 then 'selected="selected"' else ""#>Normal</option>
|
||||
<option value="1" #if $sickbeard.PUSHOVER_PRIORITY == 1 then 'selected="selected"' else ""#>High</option>
|
||||
<option value="-2" #if $sickbeard.PUSHOVER_PRIORITY == '-2' then 'selected="selected"' else ''#>Lowest</option>
|
||||
<option value="-1" #if $sickbeard.PUSHOVER_PRIORITY == '-1' then 'selected="selected"' else ''#>Low</option>
|
||||
<option value="0" #if $sickbeard.PUSHOVER_PRIORITY == '0' then 'selected="selected"' else ''#>Normal</option>
|
||||
<option value="1" #if $sickbeard.PUSHOVER_PRIORITY == '1' then 'selected="selected"' else ''#>High</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -1015,28 +1015,28 @@
|
|||
<label for="pushover_sound">
|
||||
<span class="component-title">Pushover sound</span>
|
||||
<select id="pushover_sound" name="pushover_sound" class="form-control input-sm">
|
||||
<option value="pushover" #if $sickbeard.PUSHOVER_SOUND == "pushover" then 'selected="selected"' else ""#>Pushover (default)</option>
|
||||
<option value="bike" #if $sickbeard.PUSHOVER_SOUND == "bike" then 'selected="selected"' else ""#>Bike</option>
|
||||
<option value="bugle" #if $sickbeard.PUSHOVER_SOUND == "bugle" then 'selected="selected"' else ""#>Bugle</option>
|
||||
<option value="cashregister" #if $sickbeard.PUSHOVER_SOUND == "cashregister" then 'selected="selected"' else ""#>Cash Register</option>
|
||||
<option value="classical" #if $sickbeard.PUSHOVER_SOUND == "classical" then 'selected="selected"' else ""#>Classical</option>
|
||||
<option value="cosmic" #if $sickbeard.PUSHOVER_SOUND == "cosmic" then 'selected="selected"' else ""#>Cosmic</option>
|
||||
<option value="falling" #if $sickbeard.PUSHOVER_SOUND == "falling" then 'selected="selected"' else ""#>Falling</option>
|
||||
<option value="gamelan" #if $sickbeard.PUSHOVER_SOUND == "gamelan" then 'selected="selected"' else ""#>Gamelan</option>
|
||||
<option value="incoming" #if $sickbeard.PUSHOVER_SOUND == "incoming" then 'selected="selected"' else ""#>Incoming</option>
|
||||
<option value="intermission" #if $sickbeard.PUSHOVER_SOUND == "intermission" then 'selected="selected"' else ""#>Intermission</option>
|
||||
<option value="magic" #if $sickbeard.PUSHOVER_SOUND == "magic" then 'selected="selected"' else ""#>Magic</option>
|
||||
<option value="mechanical" #if $sickbeard.PUSHOVER_SOUND == "mechanical" then 'selected="selected"' else ""#>Mechanical</option>
|
||||
<option value="pianobar" #if $sickbeard.PUSHOVER_SOUND == "pianobar" then 'selected="selected"' else ""#>Piano Bar</option>
|
||||
<option value="siren" #if $sickbeard.PUSHOVER_SOUND == "siren" then 'selected="selected"' else ""#>Siren</option>
|
||||
<option value="spacealarm" #if $sickbeard.PUSHOVER_SOUND == "spacealarm" then 'selected="selected"' else ""#>Space Alarm</option>
|
||||
<option value="tugboat" #if $sickbeard.PUSHOVER_SOUND == "tugboat" then 'selected="selected"' else ""#>Tug Boat</option>
|
||||
<option value="alien" #if $sickbeard.PUSHOVER_SOUND == "alien" then 'selected="selected"' else ""#>Alien Alarm (long)</option>
|
||||
<option value="climb" #if $sickbeard.PUSHOVER_SOUND == "climb" then 'selected="selected"' else ""#>Climb (long)</option>
|
||||
<option value="persistent" #if $sickbeard.PUSHOVER_SOUND == "persistent" then 'selected="selected"' else ""#>Persistent (long)</option>
|
||||
<option value="echo" #if $sickbeard.PUSHOVER_SOUND == "echo" then 'selected="selected"' else ""#>Pushover Echo (long)</option>
|
||||
<option value="updown" #if $sickbeard.PUSHOVER_SOUND == "updown" then 'selected="selected"' else ""#>Up Down (long)</option>
|
||||
<option value="none" #if $sickbeard.PUSHOVER_SOUND == "none" then 'selected="selected"' else ""#>None (silent)</option>
|
||||
<option value="pushover" #if $sickbeard.PUSHOVER_SOUND == 'pushover' then 'selected="selected"' else ''#>Pushover (default)</option>
|
||||
<option value="bike" #if $sickbeard.PUSHOVER_SOUND == 'bike' then 'selected="selected"' else ''#>Bike</option>
|
||||
<option value="bugle" #if $sickbeard.PUSHOVER_SOUND == 'bugle' then 'selected="selected"' else ''#>Bugle</option>
|
||||
<option value="cashregister" #if $sickbeard.PUSHOVER_SOUND == 'cashregister' then 'selected="selected"' else ''#>Cash Register</option>
|
||||
<option value="classical" #if $sickbeard.PUSHOVER_SOUND == 'classical' then 'selected="selected"' else ''#>Classical</option>
|
||||
<option value="cosmic" #if $sickbeard.PUSHOVER_SOUND == 'cosmic' then 'selected="selected"' else ''#>Cosmic</option>
|
||||
<option value="falling" #if $sickbeard.PUSHOVER_SOUND == 'falling' then 'selected="selected"' else ''#>Falling</option>
|
||||
<option value="gamelan" #if $sickbeard.PUSHOVER_SOUND == 'gamelan' then 'selected="selected"' else ''#>Gamelan</option>
|
||||
<option value="incoming" #if $sickbeard.PUSHOVER_SOUND == 'incoming' then 'selected="selected"' else ''#>Incoming</option>
|
||||
<option value="intermission" #if $sickbeard.PUSHOVER_SOUND == 'intermission' then 'selected="selected"' else ''#>Intermission</option>
|
||||
<option value="magic" #if $sickbeard.PUSHOVER_SOUND == 'magic' then 'selected="selected"' else ''#>Magic</option>
|
||||
<option value="mechanical" #if $sickbeard.PUSHOVER_SOUND == 'mechanical' then 'selected="selected"' else ''#>Mechanical</option>
|
||||
<option value="pianobar" #if $sickbeard.PUSHOVER_SOUND == 'pianobar' then 'selected="selected"' else ''#>Piano Bar</option>
|
||||
<option value="siren" #if $sickbeard.PUSHOVER_SOUND == 'siren' then 'selected="selected"' else ''#>Siren</option>
|
||||
<option value="spacealarm" #if $sickbeard.PUSHOVER_SOUND == 'spacealarm' then 'selected="selected"' else ''#>Space Alarm</option>
|
||||
<option value="tugboat" #if $sickbeard.PUSHOVER_SOUND == 'tugboat' then 'selected="selected"' else ''#>Tug Boat</option>
|
||||
<option value="alien" #if $sickbeard.PUSHOVER_SOUND == 'alien' then 'selected="selected"' else ''#>Alien Alarm (long)</option>
|
||||
<option value="climb" #if $sickbeard.PUSHOVER_SOUND == 'climb' then 'selected="selected"' else ''#>Climb (long)</option>
|
||||
<option value="persistent" #if $sickbeard.PUSHOVER_SOUND == 'persistent' then 'selected="selected"' else ''#>Persistent (long)</option>
|
||||
<option value="echo" #if $sickbeard.PUSHOVER_SOUND == 'echo' then 'selected="selected"' else ''#>Pushover Echo (long)</option>
|
||||
<option value="updown" #if $sickbeard.PUSHOVER_SOUND == 'updown' then 'selected="selected"' else ''#>Up Down (long)</option>
|
||||
<option value="none" #if $sickbeard.PUSHOVER_SOUND == 'none' then 'selected="selected"' else ''#>None (silent)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -1063,7 +1063,7 @@
|
|||
<label for="use_boxcar2">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_boxcar2" id="use_boxcar2" #if $sickbeard.USE_BOXCAR2 then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_boxcar2" id="use_boxcar2" #if $sickbeard.USE_BOXCAR2 then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Boxcar2 notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1074,7 +1074,7 @@
|
|||
<label for="boxcar2_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="boxcar2_notify_onsnatch" id="boxcar2_notify_onsnatch" #if $sickbeard.BOXCAR2_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="boxcar2_notify_onsnatch" id="boxcar2_notify_onsnatch" #if $sickbeard.BOXCAR2_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1083,7 +1083,7 @@
|
|||
<label for="boxcar2_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="boxcar2_notify_ondownload" id="boxcar2_notify_ondownload" #if $sickbeard.BOXCAR2_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="boxcar2_notify_ondownload" id="boxcar2_notify_ondownload" #if $sickbeard.BOXCAR2_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1092,7 +1092,7 @@
|
|||
<label for="boxcar2_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="boxcar2_notify_onsubtitledownload" id="boxcar2_notify_onsubtitledownload" #if $sickbeard.BOXCAR2_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="boxcar2_notify_onsubtitledownload" id="boxcar2_notify_onsubtitledownload" #if $sickbeard.BOXCAR2_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1111,36 +1111,36 @@
|
|||
<label for="boxcar2_sound">
|
||||
<span class="component-title">Custom sound</span>
|
||||
<select id="boxcar2_sound" name="boxcar2_sound" class="form-control input-sm">
|
||||
<option value="default" #if $sickbeard.BOXCAR2_SOUND == "default" then 'selected="selected"' else ""#>Default (General)</option>
|
||||
<option value="no-sound" #if $sickbeard.BOXCAR2_SOUND == "no-sound" then 'selected="selected"' else ""#>Silent</option>
|
||||
<option value="beep-crisp" #if $sickbeard.BOXCAR2_SOUND == "beep-crisp" then 'selected="selected"' else ""#>Beep Crisp</option>
|
||||
<option value="beep-soft" #if $sickbeard.BOXCAR2_SOUND == "beep-soft" then 'selected="selected"' else ""#>Beep Soft</option>
|
||||
<option value="bell-modern" #if $sickbeard.BOXCAR2_SOUND == "bell-modern" then 'selected="selected"' else ""#>Bell Modern</option>
|
||||
<option value="bell-one-tone" #if $sickbeard.BOXCAR2_SOUND == "bell-one-tone" then 'selected="selected"' else ""#>Bell One Tone</option>
|
||||
<option value="bell-simple" #if $sickbeard.BOXCAR2_SOUND == "bell-simple" then 'selected="selected"' else ""#>Bell Simple</option>
|
||||
<option value="bell-triple" #if $sickbeard.BOXCAR2_SOUND == "bell-triple" then 'selected="selected"' else ""#>Bell Triple</option>
|
||||
<option value="bird-1" #if $sickbeard.BOXCAR2_SOUND == "bird-1" then 'selected="selected"' else ""#>Bird 1</option>
|
||||
<option value="bird-2" #if $sickbeard.BOXCAR2_SOUND == "bird-2" then 'selected="selected"' else ""#>Bird 2</option>
|
||||
<option value="boing" #if $sickbeard.BOXCAR2_SOUND == "boing" then 'selected="selected"' else ""#>Boing</option>
|
||||
<option value="cash" #if $sickbeard.BOXCAR2_SOUND == "cash" then 'selected="selected"' else ""#>Cash</option>
|
||||
<option value="clanging" #if $sickbeard.BOXCAR2_SOUND == "clanging" then 'selected="selected"' else ""#>Clanging</option>
|
||||
<option value="detonator-charge" #if $sickbeard.BOXCAR2_SOUND == "detonator-charge" then 'selected="selected"' else ""#>Detonator Charge</option>
|
||||
<option value="digital-alarm" #if $sickbeard.BOXCAR2_SOUND == "digital-alarm" then 'selected="selected"' else ""#>Digital Alarm</option>
|
||||
<option value="done" #if $sickbeard.BOXCAR2_SOUND == "done" then 'selected="selected"' else ""#>Done</option>
|
||||
<option value="echo" #if $sickbeard.BOXCAR2_SOUND == "echo" then 'selected="selected"' else ""#>Echo</option>
|
||||
<option value="flourish" #if $sickbeard.BOXCAR2_SOUND == "flourish" then 'selected="selected"' else ""#>Flourish</option>
|
||||
<option value="harp" #if $sickbeard.BOXCAR2_SOUND == "harp" then 'selected="selected"' else ""#>Harp</option>
|
||||
<option value="light" #if $sickbeard.BOXCAR2_SOUND == "light" then 'selected="selected"' else ""#>Light</option>
|
||||
<option value="magic-chime" #if $sickbeard.BOXCAR2_SOUND == "magic-chime" then 'selected="selected"' else ""#>Magic Chime</option>
|
||||
<option value="magic-coin" #if $sickbeard.BOXCAR2_SOUND == "magic-coin" then 'selected="selected"' else ""#>Magic Coin 1</option>
|
||||
<option value="notifier-1" #if $sickbeard.BOXCAR2_SOUND == "notifier-1" then 'selected="selected"' else ""#>Notifier 1</option>
|
||||
<option value="notifier-2" #if $sickbeard.BOXCAR2_SOUND == "notifier-2" then 'selected="selected"' else ""#>Notifier 2</option>
|
||||
<option value="notifier-3" #if $sickbeard.BOXCAR2_SOUND == "notifier-3" then 'selected="selected"' else ""#>Notifier 3</option>
|
||||
<option value="orchestral-long" #if $sickbeard.BOXCAR2_SOUND == "orchestral-long" then 'selected="selected"' else ""#>Orchestral Long</option>
|
||||
<option value="orchestral-short" #if $sickbeard.BOXCAR2_SOUND == "orchestral-short" then 'selected="selected"' else ""#>Orchestral Short</option>
|
||||
<option value="score" #if $sickbeard.BOXCAR2_SOUND == "score" then 'selected="selected"' else ""#>Score</option>
|
||||
<option value="success" #if $sickbeard.BOXCAR2_SOUND == "success" then 'selected="selected"' else ""#>Success</option>
|
||||
<option value="up" #if $sickbeard.BOXCAR2_SOUND == "up" then 'selected="selected"' else ""#>Up</option>
|
||||
<option value="default" #if $sickbeard.BOXCAR2_SOUND == 'default' then 'selected="selected"' else ''#>Default (General)</option>
|
||||
<option value="no-sound" #if $sickbeard.BOXCAR2_SOUND == 'no-sound' then 'selected="selected"' else ''#>Silent</option>
|
||||
<option value="beep-crisp" #if $sickbeard.BOXCAR2_SOUND == 'beep-crisp' then 'selected="selected"' else ''#>Beep Crisp</option>
|
||||
<option value="beep-soft" #if $sickbeard.BOXCAR2_SOUND == 'beep-soft' then 'selected="selected"' else ''#>Beep Soft</option>
|
||||
<option value="bell-modern" #if $sickbeard.BOXCAR2_SOUND == 'bell-modern' then 'selected="selected"' else ''#>Bell Modern</option>
|
||||
<option value="bell-one-tone" #if $sickbeard.BOXCAR2_SOUND == 'bell-one-tone' then 'selected="selected"' else ''#>Bell One Tone</option>
|
||||
<option value="bell-simple" #if $sickbeard.BOXCAR2_SOUND == 'bell-simple' then 'selected="selected"' else ''#>Bell Simple</option>
|
||||
<option value="bell-triple" #if $sickbeard.BOXCAR2_SOUND == 'bell-triple' then 'selected="selected"' else ''#>Bell Triple</option>
|
||||
<option value="bird-1" #if $sickbeard.BOXCAR2_SOUND == 'bird-1' then 'selected="selected"' else ''#>Bird 1</option>
|
||||
<option value="bird-2" #if $sickbeard.BOXCAR2_SOUND == 'bird-2' then 'selected="selected"' else ''#>Bird 2</option>
|
||||
<option value="boing" #if $sickbeard.BOXCAR2_SOUND == 'boing' then 'selected="selected"' else ''#>Boing</option>
|
||||
<option value="cash" #if $sickbeard.BOXCAR2_SOUND == 'cash' then 'selected="selected"' else ''#>Cash</option>
|
||||
<option value="clanging" #if $sickbeard.BOXCAR2_SOUND == 'clanging' then 'selected="selected"' else ''#>Clanging</option>
|
||||
<option value="detonator-charge" #if $sickbeard.BOXCAR2_SOUND == 'detonator-charge' then 'selected="selected"' else ''#>Detonator Charge</option>
|
||||
<option value="digital-alarm" #if $sickbeard.BOXCAR2_SOUND == 'digital-alarm' then 'selected="selected"' else ''#>Digital Alarm</option>
|
||||
<option value="done" #if $sickbeard.BOXCAR2_SOUND == 'done' then 'selected="selected"' else ''#>Done</option>
|
||||
<option value="echo" #if $sickbeard.BOXCAR2_SOUND == 'echo' then 'selected="selected"' else ''#>Echo</option>
|
||||
<option value="flourish" #if $sickbeard.BOXCAR2_SOUND == 'flourish' then 'selected="selected"' else ''#>Flourish</option>
|
||||
<option value="harp" #if $sickbeard.BOXCAR2_SOUND == 'harp' then 'selected="selected"' else ''#>Harp</option>
|
||||
<option value="light" #if $sickbeard.BOXCAR2_SOUND == 'light' then 'selected="selected"' else ''#>Light</option>
|
||||
<option value="magic-chime" #if $sickbeard.BOXCAR2_SOUND == 'magic-chime' then 'selected="selected"' else ''#>Magic Chime</option>
|
||||
<option value="magic-coin" #if $sickbeard.BOXCAR2_SOUND == 'magic-coin' then 'selected="selected"' else ''#>Magic Coin 1</option>
|
||||
<option value="notifier-1" #if $sickbeard.BOXCAR2_SOUND == 'notifier-1' then 'selected="selected"' else ''#>Notifier 1</option>
|
||||
<option value="notifier-2" #if $sickbeard.BOXCAR2_SOUND == 'notifier-2' then 'selected="selected"' else ''#>Notifier 2</option>
|
||||
<option value="notifier-3" #if $sickbeard.BOXCAR2_SOUND == 'notifier-3' then 'selected="selected"' else ''#>Notifier 3</option>
|
||||
<option value="orchestral-long" #if $sickbeard.BOXCAR2_SOUND == 'orchestral-long' then 'selected="selected"' else ''#>Orchestral Long</option>
|
||||
<option value="orchestral-short" #if $sickbeard.BOXCAR2_SOUND == 'orchestral-short' then 'selected="selected"' else ''#>Orchestral Short</option>
|
||||
<option value="score" #if $sickbeard.BOXCAR2_SOUND == 'score' then 'selected="selected"' else ''#>Score</option>
|
||||
<option value="success" #if $sickbeard.BOXCAR2_SOUND == 'success' then 'selected="selected"' else ''#>Success</option>
|
||||
<option value="up" #if $sickbeard.BOXCAR2_SOUND == 'up' then 'selected="selected"' else ''#>Up</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -1159,7 +1159,7 @@
|
|||
<div class="component-group">
|
||||
<div class="component-group-desc">
|
||||
<img class="notifier-icon" src="$sbRoot/images/notifiers/nma.png" alt="" title="NMA"/>
|
||||
<h3><a href="<%= anon_url('http://nma.usk.bz') %>" rel="noreferrer" onclick="window.open(this.href, '_blank'); return false;">Notify My Android</a></h3>
|
||||
<h3><a href="<%= anon_url('https://www.notifymyandroid.com') %>" rel="noreferrer" onclick="window.open(this.href, '_blank'); return false;">Notify My Android</a></h3>
|
||||
<p>Notify My Android is a Prowl-like Android App and API that offers an easy way to send notifications from your application directly to your Android device.</p>
|
||||
</div>
|
||||
<fieldset class="component-group-list">
|
||||
|
@ -1167,7 +1167,7 @@
|
|||
<label for="use_nma">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_nma" id="use_nma" #if $sickbeard.USE_NMA then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_nma" id="use_nma" #if $sickbeard.USE_NMA then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send NMA notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1178,7 +1178,7 @@
|
|||
<label for="nma_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="nma_notify_onsnatch" id="nma_notify_onsnatch" #if $sickbeard.NMA_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="nma_notify_onsnatch" id="nma_notify_onsnatch" #if $sickbeard.NMA_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1187,7 +1187,7 @@
|
|||
<label for="nma_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="nma_notify_ondownload" id="nma_notify_ondownload" #if $sickbeard.NMA_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="nma_notify_ondownload" id="nma_notify_ondownload" #if $sickbeard.NMA_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1196,7 +1196,7 @@
|
|||
<label for="nma_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="nma_notify_onsubtitledownload" id="nma_notify_onsubtitledownload" #if $sickbeard.NMA_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="nma_notify_onsubtitledownload" id="nma_notify_onsubtitledownload" #if $sickbeard.NMA_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1215,11 +1215,11 @@
|
|||
<label for="nma_priority">
|
||||
<span class="component-title">NMA priority:</span>
|
||||
<select id="nma_priority" name="nma_priority" class="form-control input-sm">
|
||||
<option value="-2" #if $sickbeard.NMA_PRIORITY == "-2" then 'selected="selected"' else ""#>Very Low</option>
|
||||
<option value="-1" #if $sickbeard.NMA_PRIORITY == "-1" then 'selected="selected"' else ""#>Moderate</option>
|
||||
<option value="0" #if $sickbeard.NMA_PRIORITY == "0" then 'selected="selected"' else ""#>Normal</option>
|
||||
<option value="1" #if $sickbeard.NMA_PRIORITY == "1" then 'selected="selected"' else ""#>High</option>
|
||||
<option value="2" #if $sickbeard.NMA_PRIORITY == "2" then 'selected="selected"' else ""#>Emergency</option>
|
||||
<option value="-2" #if $sickbeard.NMA_PRIORITY == '-2' then 'selected="selected"' else ''#>Very Low</option>
|
||||
<option value="-1" #if $sickbeard.NMA_PRIORITY == '-1' then 'selected="selected"' else ''#>Moderate</option>
|
||||
<option value="0" #if $sickbeard.NMA_PRIORITY == '0' then 'selected="selected"' else ''#>Normal</option>
|
||||
<option value="1" #if $sickbeard.NMA_PRIORITY == '1' then 'selected="selected"' else ''#>High</option>
|
||||
<option value="2" #if $sickbeard.NMA_PRIORITY == '2' then 'selected="selected"' else ''#>Emergency</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -1246,7 +1246,7 @@
|
|||
<label for="use_pushalot">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_pushalot" id="use_pushalot" #if $sickbeard.USE_PUSHALOT then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_pushalot" id="use_pushalot" #if $sickbeard.USE_PUSHALOT then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Pushalot notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1257,7 +1257,7 @@
|
|||
<label for="pushalot_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushalot_notify_onsnatch" id="pushalot_notify_onsnatch" #if $sickbeard.PUSHALOT_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushalot_notify_onsnatch" id="pushalot_notify_onsnatch" #if $sickbeard.PUSHALOT_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1266,7 +1266,7 @@
|
|||
<label for="pushalot_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushalot_notify_ondownload" id="pushalot_notify_ondownload" #if $sickbeard.PUSHALOT_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushalot_notify_ondownload" id="pushalot_notify_ondownload" #if $sickbeard.PUSHALOT_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1275,7 +1275,7 @@
|
|||
<label for="pushalot_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushalot_notify_onsubtitledownload" id="pushalot_notify_onsubtitledownload" #if $sickbeard.PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushalot_notify_onsubtitledownload" id="pushalot_notify_onsubtitledownload" #if $sickbeard.PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1309,7 +1309,7 @@
|
|||
<label for="use_pushbullet">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_pushbullet" id="use_pushbullet" #if $sickbeard.USE_PUSHBULLET then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_pushbullet" id="use_pushbullet" #if $sickbeard.USE_PUSHBULLET then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Pushbullet notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1320,7 +1320,7 @@
|
|||
<label for="pushbullet_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushbullet_notify_onsnatch" id="pushbullet_notify_onsnatch" #if $sickbeard.PUSHBULLET_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushbullet_notify_onsnatch" id="pushbullet_notify_onsnatch" #if $sickbeard.PUSHBULLET_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1329,7 +1329,7 @@
|
|||
<label for="pushbullet_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushbullet_notify_ondownload" id="pushbullet_notify_ondownload" #if $sickbeard.PUSHBULLET_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushbullet_notify_ondownload" id="pushbullet_notify_ondownload" #if $sickbeard.PUSHBULLET_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1338,7 +1338,7 @@
|
|||
<label for="pushbullet_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="pushbullet_notify_onsubtitledownload" id="pushbullet_notify_onsubtitledownload" #if $sickbeard.PUSHBULLET_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="pushbullet_notify_onsubtitledownload" id="pushbullet_notify_onsubtitledownload" #if $sickbeard.PUSHBULLET_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1388,7 +1388,7 @@
|
|||
<label for="use_twitter">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_twitter" id="use_twitter" #if $sickbeard.USE_TWITTER then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_twitter" id="use_twitter" #if $sickbeard.USE_TWITTER then 'checked="checked"' else ''# />
|
||||
<p>should SickGear post tweets on Twitter ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1403,7 +1403,7 @@
|
|||
<label for="twitter_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="twitter_notify_onsnatch" id="twitter_notify_onsnatch" #if $sickbeard.TWITTER_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="twitter_notify_onsnatch" id="twitter_notify_onsnatch" #if $sickbeard.TWITTER_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1412,7 +1412,7 @@
|
|||
<label for="twitter_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="twitter_notify_ondownload" id="twitter_notify_ondownload" #if $sickbeard.TWITTER_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="twitter_notify_ondownload" id="twitter_notify_ondownload" #if $sickbeard.TWITTER_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1421,7 +1421,7 @@
|
|||
<label for="twitter_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="twitter_notify_onsubtitledownload" id="twitter_notify_onsubtitledownload" #if $sickbeard.TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="twitter_notify_onsubtitledownload" id="twitter_notify_onsubtitledownload" #if $sickbeard.TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1472,7 +1472,7 @@
|
|||
<label for="use_trakt">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_trakt" id="use_trakt" #if $sickbeard.USE_TRAKT then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_trakt" id="use_trakt" #if $sickbeard.USE_TRAKT then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send Trakt.tv notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1515,7 +1515,7 @@
|
|||
<span class="component-desc">
|
||||
<select id="trakt_default_indexer" name="trakt_default_indexer" class="form-control input-sm">
|
||||
#for $indexer in $sickbeard.indexerApi().indexers
|
||||
<option value="$indexer" #if $indexer == $sickbeard.TRAKT_DEFAULT_INDEXER then "selected=\"selected\"" else ""#>$sickbeard.indexerApi().indexers[$indexer]</option>
|
||||
<option value="$indexer" #if $indexer == $sickbeard.TRAKT_DEFAULT_INDEXER then 'selected="selected"' else ''#>$sickbeard.indexerApi().indexers[$indexer]</option>
|
||||
#end for
|
||||
</select>
|
||||
</span>
|
||||
|
@ -1525,7 +1525,7 @@
|
|||
<label for="trakt_sync">
|
||||
<span class="component-title">Sync libraries:</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="trakt_sync" id="trakt_sync" #if $sickbeard.TRAKT_SYNC then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="trakt_sync" id="trakt_sync" #if $sickbeard.TRAKT_SYNC then 'checked="checked"' else ''# />
|
||||
<p>sync your SickGear show library with your trakt show library.</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1534,7 +1534,7 @@
|
|||
<label for="trakt_use_watchlist">
|
||||
<span class="component-title">Use watchlist:</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="trakt_use_watchlist" id="trakt_use_watchlist" #if $sickbeard.TRAKT_USE_WATCHLIST then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="trakt_use_watchlist" id="trakt_use_watchlist" #if $sickbeard.TRAKT_USE_WATCHLIST then 'checked="checked"' else ''# />
|
||||
<p>get new shows from your trakt watchlist.</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1544,9 +1544,9 @@
|
|||
<label for="trakt_method_add">
|
||||
<span class="component-title">Watchlist add method:</span>
|
||||
<select id="trakt_method_add" name="trakt_method_add" class="form-control input-sm">
|
||||
<option value="0" #if $sickbeard.TRAKT_METHOD_ADD == 0 then "selected=\"selected\"" else ""#>Skip All</option>
|
||||
<option value="1" #if $sickbeard.TRAKT_METHOD_ADD == 1 then "selected=\"selected\"" else ""#>Download Pilot Only</option>
|
||||
<option value="2" #if $sickbeard.TRAKT_METHOD_ADD == 2 then "selected=\"selected\"" else ""#>Get whole show</option>
|
||||
<option value="0" #if $sickbeard.TRAKT_METHOD_ADD == 0 then 'selected="selected"' else ''#>Skip All</option>
|
||||
<option value="1" #if $sickbeard.TRAKT_METHOD_ADD == 1 then 'selected="selected"' else ''#>Download Pilot Only</option>
|
||||
<option value="2" #if $sickbeard.TRAKT_METHOD_ADD == 2 then 'selected="selected"' else ''#>Get whole show</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
|
@ -1558,7 +1558,7 @@
|
|||
<label for="trakt_remove_watchlist">
|
||||
<span class="component-title">Remove episode:</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="trakt_remove_watchlist" id="trakt_remove_watchlist" #if $sickbeard.TRAKT_REMOVE_WATCHLIST then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="trakt_remove_watchlist" id="trakt_remove_watchlist" #if $sickbeard.TRAKT_REMOVE_WATCHLIST then 'checked="checked"' else ''# />
|
||||
<p>remove an episode from your watchlist after it is downloaded.</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1567,7 +1567,7 @@
|
|||
<label for="trakt_remove_serieslist">
|
||||
<span class="component-title">Remove series:</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="trakt_remove_serieslist" id="trakt_remove_serieslist" #if $sickbeard.TRAKT_REMOVE_SERIESLIST then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="trakt_remove_serieslist" id="trakt_remove_serieslist" #if $sickbeard.TRAKT_REMOVE_SERIESLIST then 'checked="checked"' else ''# />
|
||||
<p>remove the whole series from your watchlist after any download.</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1576,7 +1576,7 @@
|
|||
<label for="trakt_start_paused">
|
||||
<span class="component-title">Start paused:</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="trakt_start_paused" id="trakt_start_paused" #if $sickbeard.TRAKT_START_PAUSED then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="trakt_start_paused" id="trakt_start_paused" #if $sickbeard.TRAKT_START_PAUSED then 'checked="checked"' else ''# />
|
||||
<p>show's grabbed from your trakt watchlist start paused.</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1600,7 +1600,7 @@
|
|||
<label for="use_email">
|
||||
<span class="component-title">Enable</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="use_email" id="use_email" #if $sickbeard.USE_EMAIL then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" class="enabler" name="use_email" id="use_email" #if $sickbeard.USE_EMAIL then 'checked="checked"' else ''# />
|
||||
<p>should SickGear send email notifications ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1611,7 +1611,7 @@
|
|||
<label for="email_notify_onsnatch">
|
||||
<span class="component-title">Notify on snatch</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="email_notify_onsnatch" id="email_notify_onsnatch" #if $sickbeard.EMAIL_NOTIFY_ONSNATCH then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="email_notify_onsnatch" id="email_notify_onsnatch" #if $sickbeard.EMAIL_NOTIFY_ONSNATCH then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download starts ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1620,7 +1620,7 @@
|
|||
<label for="email_notify_ondownload">
|
||||
<span class="component-title">Notify on download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="email_notify_ondownload" id="email_notify_ondownload" #if $sickbeard.EMAIL_NOTIFY_ONDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="email_notify_ondownload" id="email_notify_ondownload" #if $sickbeard.EMAIL_NOTIFY_ONDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when a download finishes ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1629,7 +1629,7 @@
|
|||
<label for="email_notify_onsubtitledownload">
|
||||
<span class="component-title">Notify on subtitle download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="email_notify_onsubtitledownload" id="email_notify_onsubtitledownload" #if $sickbeard.EMAIL_NOTIFY_ONSUBTITLEDOWNLOAD then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="email_notify_onsubtitledownload" id="email_notify_onsubtitledownload" #if $sickbeard.EMAIL_NOTIFY_ONSUBTITLEDOWNLOAD then 'checked="checked"' else ''# />
|
||||
<p>send a notification when subtitles are downloaded ?</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -1668,7 +1668,7 @@
|
|||
<label for="email_tls">
|
||||
<span class="component-title">Use TLS</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="email_tls" id="email_tls" #if $sickbeard.EMAIL_TLS then "checked=\"checked\"" else ""# />
|
||||
<input type="checkbox" name="email_tls" id="email_tls" #if $sickbeard.EMAIL_TLS then 'checked="checked"' else ''# />
|
||||
<p>check to use TLS encryption.</p>
|
||||
</span>
|
||||
</label>
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
#from sickbeard.providers import thepiratebay
|
||||
#from sickbeard.helpers import anon_url, starify
|
||||
##
|
||||
#set global $title="Config - Providers"
|
||||
#set global $header="Search Providers"
|
||||
#set global $title = 'Config - Providers'
|
||||
#set global $header = 'Search Providers'
|
||||
#set global $sbPath = '../..'
|
||||
#set global $topmenu = 'config'
|
||||
##
|
||||
|
@ -39,7 +39,7 @@
|
|||
|
||||
#for $curNewznabProvider in $sickbeard.newznabProviderList:
|
||||
|
||||
\$(this).addProvider('$curNewznabProvider.getID()', '$curNewznabProvider.name', '$curNewznabProvider.url', '<%= starify(curNewznabProvider.key) %>', '$curNewznabProvider.catIDs', $int($curNewznabProvider.default), show_nzb_providers);
|
||||
\$(this).addProvider('$curNewznabProvider.get_id()', '$curNewznabProvider.name', '$curNewznabProvider.url', '<%= starify(curNewznabProvider.key) %>', '$curNewznabProvider.cat_ids', $int($curNewznabProvider.default), show_nzb_providers);
|
||||
|
||||
#end for
|
||||
|
||||
|
@ -49,7 +49,7 @@
|
|||
|
||||
#for $curTorrentRssProvider in $sickbeard.torrentRssProviderList:
|
||||
|
||||
\$(this).addTorrentRssProvider('$curTorrentRssProvider.getID()', '$curTorrentRssProvider.name', '$curTorrentRssProvider.url', '<%= starify(curTorrentRssProvider.cookies) %>');
|
||||
\$(this).addTorrentRssProvider('$curTorrentRssProvider.get_id()', '$curTorrentRssProvider.name', '$curTorrentRssProvider.url', '<%= starify(curTorrentRssProvider.cookies) %>');
|
||||
|
||||
#end for
|
||||
|
||||
|
@ -104,13 +104,12 @@
|
|||
#elif $curProvider.providerType == $GenericProvider.TORRENT and not $sickbeard.USE_TORRENTS
|
||||
#continue
|
||||
#end if
|
||||
#set $curName = $curProvider.getID()
|
||||
#set $curName = $curProvider.get_id()
|
||||
<li class="ui-state-default" id="$curName">
|
||||
<input type="checkbox" id="enable_$curName" class="provider_enabler" <%= html_checked if curProvider.isEnabled() else '' %>/>
|
||||
<a href="<%= anon_url(curProvider.url) %>" class="imgLink" rel="noreferrer" onclick="window.open(this.href, '_blank'); return false;"><img src="$sbRoot/images/providers/$curProvider.imageName()" alt="$curProvider.name" title="$curProvider.name" width="16" height="16" style="vertical-align:middle;"/></a>
|
||||
<span style="vertical-align:middle;">$curProvider.name</span>
|
||||
<input type="checkbox" id="enable_$curName" class="provider_enabler" <%= html_checked if curProvider.is_enabled() else '' %>/>
|
||||
<a href="<%= anon_url(curProvider.url) %>" class="imgLink" rel="noreferrer" onclick="window.open(this.href, '_blank'); return false;"><img src="$sbRoot/images/providers/$curProvider.image_name()" alt="$curProvider.name" title="$curProvider.name" width="16" height="16" style="vertical-align:middle;"/></a>
|
||||
<span style="vertical-align:middle">$curProvider.name</span>
|
||||
<%= '*' if not curProvider.supportsBacklog else '' %>
|
||||
<%= '**' if 'EZRSS' == curProvider.name else '' %>
|
||||
<span class="ui-icon ui-icon-arrowthick-2-n-s pull-right" style="margin-top:3px"></span>
|
||||
</li>
|
||||
#end for
|
||||
|
@ -125,7 +124,7 @@
|
|||
##<h4 class="note">!</h4><p class="note">Provider is <b>NOT WORKING</b></p>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="provider_order" id="provider_order" value="<%=" ".join([x.getID()+':'+str(int(x.isEnabled())) for x in sickbeard.providers.sortedProviderList()])%>"/>
|
||||
<input type="hidden" name="provider_order" id="provider_order" value="<%=' '.join([x.get_id()+':'+str(int(x.is_enabled())) for x in sickbeard.providers.sortedProviderList()])%>"/>
|
||||
<div style="width: 300px; float: right">
|
||||
<div style="margin: 0px auto; width: 101px">
|
||||
<input type="submit" class="btn config_submitter" value="Save Changes" />
|
||||
|
@ -157,7 +156,7 @@
|
|||
#elif $curProvider.providerType == $GenericProvider.TORRENT and not $sickbeard.USE_TORRENTS
|
||||
#continue
|
||||
#end if
|
||||
#if $curProvider.isEnabled()
|
||||
#if $curProvider.is_enabled()
|
||||
$provider_config_list_enabled.append($curProvider)
|
||||
#else
|
||||
$provider_config_list.append($curProvider)
|
||||
|
@ -169,14 +168,14 @@
|
|||
#if $provider_config_list_enabled
|
||||
<optgroup label="Enabled...">
|
||||
#for $cur_provider in $provider_config_list_enabled:
|
||||
<option value="$cur_provider.getID()">$cur_provider.name</option>
|
||||
<option value="$cur_provider.get_id()">$cur_provider.name</option>
|
||||
#end for
|
||||
</optgroup>
|
||||
#end if
|
||||
#if $provider_config_list
|
||||
<optgroup label="Not Enabled...">
|
||||
#for $cur_provider in $provider_config_list
|
||||
<option value="$cur_provider.getID()">$cur_provider.name</option>
|
||||
<option value="$cur_provider.get_id()">$cur_provider.name</option>
|
||||
#end for
|
||||
</optgroup>
|
||||
#end if
|
||||
|
@ -188,76 +187,71 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- start div for editing providers //-->
|
||||
#for $curNewznabProvider in [$curProvider for $curProvider in $sickbeard.newznabProviderList]
|
||||
<div class="providerDiv" id="${curNewznabProvider.getID()}Div">
|
||||
<div class="providerDiv" id="${curNewznabProvider.get_id()}Div">
|
||||
#if $curNewznabProvider.default and $curNewznabProvider.needs_auth
|
||||
<div class="field-pair">
|
||||
<label for="${curNewznabProvider.getID()}_url">
|
||||
<label for="${curNewznabProvider.get_id()}_url">
|
||||
<span class="component-title">URL</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" id="${curNewznabProvider.getID()}_url" value="$curNewznabProvider.url" class="form-control input-sm input350" disabled/>
|
||||
<input type="text" id="${curNewznabProvider.get_id()}_url" value="$curNewznabProvider.url" class="form-control input-sm input350" disabled/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-pair">
|
||||
<label for="${curNewznabProvider.getID()}_hash">
|
||||
<label for="${curNewznabProvider.get_id()}_hash">
|
||||
<span class="component-title">API key</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" id="${curNewznabProvider.getID()}_hash" value="<%= starify(curNewznabProvider.key) %>" newznab_name="${curNewznabProvider.getID()}_hash" class="newznab_key form-control input-sm input350" />
|
||||
<input type="text" id="${curNewznabProvider.get_id()}_hash" value="<%= starify(curNewznabProvider.key) %>" newznab_name="${curNewznabProvider.get_id()}_hash" class="newznab_key form-control input-sm input350" />
|
||||
<div class="clear-left"><p>get API key from provider website</p></div>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNewznabProvider, 'enable_recentsearch'):
|
||||
#if $hasattr($curNewznabProvider, 'enable_recentsearch') and $curNewznabProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNewznabProvider.getID()}_enable_recentsearch">
|
||||
<label for="${curNewznabProvider.get_id()}_enable_recentsearch">
|
||||
<span class="component-title">Enable recent searches</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curNewznabProvider.getID()}_enable_recentsearch" id="${curNewznabProvider.getID()}_enable_recentsearch" <%= html_checked if curNewznabProvider.enable_recentsearch else '' %>/>
|
||||
<input type="checkbox" name="${curNewznabProvider.get_id()}_enable_recentsearch" id="${curNewznabProvider.get_id()}_enable_recentsearch" <%= html_checked if curNewznabProvider.enable_recentsearch else '' %>/>
|
||||
<p>perform recent searches at provider</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNewznabProvider, 'enable_backlog'):
|
||||
#if $hasattr($curNewznabProvider, 'enable_backlog') and $curNewznabProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNewznabProvider.getID()}_enable_backlog">
|
||||
<label for="${curNewznabProvider.get_id()}_enable_backlog">
|
||||
<span class="component-title">Enable backlog searches</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curNewznabProvider.getID()}_enable_backlog" id="${curNewznabProvider.getID()}_enable_backlog" <%= html_checked if curNewznabProvider.enable_backlog else '' %>/>
|
||||
<input type="checkbox" name="${curNewznabProvider.get_id()}_enable_backlog" id="${curNewznabProvider.get_id()}_enable_backlog" <%= html_checked if curNewznabProvider.enable_backlog else '' %>/>
|
||||
<p>perform backlog searches at provider</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNewznabProvider, 'search_mode'):
|
||||
#if $hasattr($curNewznabProvider, 'search_mode') and $curNewznabProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<span class="component-title">Season search mode</span>
|
||||
<span class="component-desc">
|
||||
<label class="space-right">
|
||||
<input type="radio" name="${curNewznabProvider.getID()}_search_mode" id="${curNewznabProvider.getID()}_search_mode_sponly" value="sponly" <%= html_checked if 'sponly' == curNewznabProvider.search_mode else '' %>/>season packs only
|
||||
<input type="radio" name="${curNewznabProvider.get_id()}_search_mode" id="${curNewznabProvider.get_id()}_search_mode_sponly" value="sponly" <%= html_checked if 'sponly' == curNewznabProvider.search_mode else '' %>/>season packs only
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="${curNewznabProvider.getID()}_search_mode" id="${curNewznabProvider.getID()}_search_mode_eponly" value="eponly" <%= html_checked if 'eponly' == curNewznabProvider.search_mode else '' %>/>episodes only
|
||||
<input type="radio" name="${curNewznabProvider.get_id()}_search_mode" id="${curNewznabProvider.get_id()}_search_mode_eponly" value="eponly" <%= html_checked if 'eponly' == curNewznabProvider.search_mode else '' %>/>episodes only
|
||||
</label>
|
||||
<p>when searching for complete seasons, search for packs or collect single episodes</p>
|
||||
</span>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNewznabProvider, 'search_fallback'):
|
||||
#if $hasattr($curNewznabProvider, 'search_fallback') and $curNewznabProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNewznabProvider.getID()}_search_fallback">
|
||||
<label for="${curNewznabProvider.get_id()}_search_fallback">
|
||||
<span class="component-title">Season search fallback</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curNewznabProvider.getID()}_search_fallback" id="${curNewznabProvider.getID()}_search_fallback" <%= html_checked if curNewznabProvider.search_fallback else '' %>/>
|
||||
<input type="checkbox" name="${curNewznabProvider.get_id()}_search_fallback" id="${curNewznabProvider.get_id()}_search_fallback" <%= html_checked if curNewznabProvider.search_fallback else '' %>/>
|
||||
<p>run the alternate season search mode when a complete season is not found</p>
|
||||
</span>
|
||||
</label>
|
||||
|
@ -265,218 +259,205 @@
|
|||
#end if
|
||||
</div>
|
||||
#end for
|
||||
##
|
||||
|
||||
##
|
||||
#for $curNzbProvider in [$curProvider for $curProvider in $sickbeard.providers.sortedProviderList() if $curProvider.providerType == $GenericProvider.NZB and $curProvider not in $sickbeard.newznabProviderList]:
|
||||
<div class="providerDiv" id="${curNzbProvider.getID()}Div">
|
||||
<div class="providerDiv" id="${curNzbProvider.get_id()}Div">
|
||||
#if $hasattr($curNzbProvider, 'username'):
|
||||
<div class="field-pair">
|
||||
<label for="${curNzbProvider.getID()}_username">
|
||||
<label for="${curNzbProvider.get_id()}_username">
|
||||
<span class="component-title">Username</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curNzbProvider.getID()}_username" value="$curNzbProvider.username" class="form-control input-sm input350" />
|
||||
<input type="text" name="${curNzbProvider.get_id()}_username" value="$curNzbProvider.username" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNzbProvider, 'api_key'):
|
||||
<div class="field-pair">
|
||||
<label for="${curNzbProvider.getID()}_api_key">
|
||||
<label for="${curNzbProvider.get_id()}_api_key">
|
||||
<span class="component-title">API key</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curNzbProvider.getID()}_api_key" value="<%= starify(curNzbProvider.api_key) %>" class="form-control input-sm input350" />
|
||||
#set $field_name = curNzbProvider.get_id() + '_api_key'
|
||||
<input type="text" name="$field_name" value="<%= starify(curNzbProvider.api_key) %>" class="form-control input-sm input350" />
|
||||
#if callable(getattr(curNzbProvider, 'ui_string'))
|
||||
<div class="clear-left"><p>${curNzbProvider.ui_string($field_name)}</p></div>
|
||||
#end if
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
|
||||
#if $hasattr($curNzbProvider, 'enable_recentsearch'):
|
||||
#if $hasattr($curNzbProvider, 'enable_recentsearch') and $curNzbProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNzbProvider.getID()}_enable_recentsearch">
|
||||
<label for="${curNzbProvider.get_id()}_enable_recentsearch">
|
||||
<span class="component-title">Enable recent searches</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curNzbProvider.getID()}_enable_recentsearch" id="${curNzbProvider.getID()}_enable_recentsearch" <%= html_checked if curNzbProvider.enable_recentsearch else '' %>/>
|
||||
<input type="checkbox" name="${curNzbProvider.get_id()}_enable_recentsearch" id="${curNzbProvider.get_id()}_enable_recentsearch" <%= html_checked if curNzbProvider.enable_recentsearch else '' %>/>
|
||||
<p>enable provider to perform recent searches.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNzbProvider, 'enable_backlog'):
|
||||
#if $hasattr($curNzbProvider, 'enable_backlog') and $curNzbProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNzbProvider.getID()}_enable_backlog">
|
||||
<label for="${curNzbProvider.get_id()}_enable_backlog">
|
||||
<span class="component-title">Enable backlog searches</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curNzbProvider.getID()}_enable_backlog" id="${curNzbProvider.getID()}_enable_backlog" <%= html_checked if curNzbProvider.enable_backlog else '' %>/>
|
||||
<input type="checkbox" name="${curNzbProvider.get_id()}_enable_backlog" id="${curNzbProvider.get_id()}_enable_backlog" <%= html_checked if curNzbProvider.enable_backlog else '' %>/>
|
||||
<p>enable provider to perform backlog searches.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNzbProvider, 'search_fallback'):
|
||||
#if $hasattr($curNzbProvider, 'search_mode') and $curNzbProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNzbProvider.getID()}_search_fallback">
|
||||
<span class="component-title">Season search fallback</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curNzbProvider.getID()}_search_fallback" id="${curNzbProvider.getID()}_search_fallback" <%= html_checked if curNzbProvider.search_fallback else '' %>/>
|
||||
<p>when searching for a complete season depending on search mode you may return no results, this helps by restarting the search using the opposite search mode.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curNzbProvider, 'search_mode'):
|
||||
<div class="field-pair">
|
||||
<label>
|
||||
<span class="component-title">Season search mode</span>
|
||||
<span class="component-desc">
|
||||
<p>when searching for complete seasons you can choose to have it look for season packs only, or choose to have it build a complete season from just single episodes.</p>
|
||||
</span>
|
||||
<label class="space-right">
|
||||
<input type="radio" name="${curNzbProvider.get_id()}_search_mode" id="${curNzbProvider.get_id()}_search_mode_sponly" value="sponly" <%= html_checked if 'sponly' == curNzbProvider.search_mode else '' %>/>season packs only
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"></span>
|
||||
<span class="component-desc">
|
||||
<input type="radio" name="${curNzbProvider.getID()}_search_mode" id="${curNzbProvider.getID()}_search_mode_sponly" value="sponly" <%= html_checked if 'sponly' == curNzbProvider.search_mode else '' %>/>season packs only.
|
||||
</span>
|
||||
<input type="radio" name="${curNzbProvider.get_id()}_search_mode" id="${curNzbProvider.get_id()}_search_mode_eponly" value="eponly" <%= html_checked if 'eponly' == curNzbProvider.search_mode else '' %>/>episodes only
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"></span>
|
||||
<p>when searching for complete seasons, search for packs or collect single episodes</p>
|
||||
</span>
|
||||
</div>
|
||||
#end if
|
||||
#if $hasattr($curNzbProvider, 'search_fallback') and $curNzbProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curNzbProvider.get_id()}_search_fallback">
|
||||
<span class="component-title">Season search fallback</span>
|
||||
<span class="component-desc">
|
||||
<input type="radio" name="${curNzbProvider.getID()}_search_mode" id="${curNzbProvider.getID()}_search_mode_eponly" value="eponly" <%= html_checked if 'eponly' == curNzbProvider.search_mode else '' %>/>episodes only.
|
||||
<input type="checkbox" name="${curNzbProvider.get_id()}_search_fallback" id="${curNzbProvider.get_id()}_search_fallback" <%= html_checked if curNzbProvider.search_fallback else '' %>/>
|
||||
<p>run the alternate season search mode when a complete season is not found</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if not $curNzbProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<span class="component-desc">The latest releases are the focus of this provider, no backlog searching</span>
|
||||
</div>
|
||||
#end if
|
||||
</div>
|
||||
#end for
|
||||
##
|
||||
|
||||
##
|
||||
#for $curTorrentProvider in [$curProvider for $curProvider in $sickbeard.providers.sortedProviderList() if $curProvider.providerType == $GenericProvider.TORRENT]:
|
||||
<div class="providerDiv" id="${curTorrentProvider.getID()}Div">
|
||||
<div class="providerDiv" id="${curTorrentProvider.get_id()}Div">
|
||||
#if $hasattr($curTorrentProvider, 'api_key'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_api_key">
|
||||
<label for="${curTorrentProvider.get_id()}_api_key">
|
||||
<span class="component-title">Api key:</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curTorrentProvider.getID()}_api_key" id="${curTorrentProvider.getID()}_api_key" value="<%= starify(curTorrentProvider.api_key) %>" class="form-control input-sm input350" />
|
||||
<input type="text" name="${curTorrentProvider.get_id()}_api_key" id="${curTorrentProvider.get_id()}_api_key" value="<%= starify(curTorrentProvider.api_key) %>" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'digest'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_digest">
|
||||
<label for="${curTorrentProvider.get_id()}_digest">
|
||||
<span class="component-title">Digest:</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curTorrentProvider.getID()}_digest" id="${curTorrentProvider.getID()}_digest" value="$curTorrentProvider.digest" class="form-control input-sm input350" />
|
||||
<input type="text" name="${curTorrentProvider.get_id()}_digest" id="${curTorrentProvider.get_id()}_digest" value="$curTorrentProvider.digest" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'hash'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_hash">
|
||||
<label for="${curTorrentProvider.get_id()}_hash">
|
||||
<span class="component-title">Hash:</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curTorrentProvider.getID()}_hash" id="${curTorrentProvider.getID()}_hash" value="$curTorrentProvider.hash" class="form-control input-sm input350" />
|
||||
<input type="text" name="${curTorrentProvider.get_id()}_hash" id="${curTorrentProvider.get_id()}_hash" value="$curTorrentProvider.hash" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'username'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_username">
|
||||
<label for="${curTorrentProvider.get_id()}_username">
|
||||
<span class="component-title">Username:</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curTorrentProvider.getID()}_username" id="${curTorrentProvider.getID()}_username" value="$curTorrentProvider.username" class="form-control input-sm input350" />
|
||||
<input type="text" name="${curTorrentProvider.get_id()}_username" id="${curTorrentProvider.get_id()}_username" value="$curTorrentProvider.username" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'password'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_password">
|
||||
<label for="${curTorrentProvider.get_id()}_password">
|
||||
<span class="component-title">Password:</span>
|
||||
<span class="component-desc">
|
||||
<input type="password" name="${curTorrentProvider.getID()}_password" id="${curTorrentProvider.getID()}_password" value="#echo '*' * len($curTorrentProvider.password)#" class="form-control input-sm input350" />
|
||||
<input type="password" name="${curTorrentProvider.get_id()}_password" id="${curTorrentProvider.get_id()}_password" value="#echo '*' * len($curTorrentProvider.password)#" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'passkey'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_passkey">
|
||||
<label for="${curTorrentProvider.get_id()}_passkey">
|
||||
<span class="component-title">Passkey:</span>
|
||||
<span class="component-desc">
|
||||
<input type="text" name="${curTorrentProvider.getID()}_passkey" id="${curTorrentProvider.getID()}_passkey" value="<%= starify(curTorrentProvider.passkey) %>" class="form-control input-sm input350" />
|
||||
<input type="text" name="${curTorrentProvider.get_id()}_passkey" id="${curTorrentProvider.get_id()}_passkey" value="<%= starify(curTorrentProvider.passkey) %>" class="form-control input-sm input350" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'ratio'):
|
||||
#if $hasattr($curTorrentProvider, '_seed_ratio') and 'blackhole' != $sickbeard.TORRENT_METHOD:
|
||||
#set $torrent_method_text = {'blackhole': 'Black hole', 'utorrent': 'uTorrent', 'transmission': 'Transmission', 'deluge': 'Deluge', 'download_station': 'Synology DS', 'rtorrent': 'rTorrent'}
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_ratio">
|
||||
<span class="component-title" id="${curTorrentProvider.getID()}_ratio_desc">Seed ratio:</span>
|
||||
<label for="${curTorrentProvider.get_id()}_ratio">
|
||||
<span class="component-title" id="${curTorrentProvider.get_id()}_ratio_desc">Seed until ratio (the goal)</span>
|
||||
<span class="component-desc">
|
||||
<input type="number" step="0.1" name="${curTorrentProvider.getID()}_ratio" id="${curTorrentProvider.getID()}_ratio" value="$curTorrentProvider.ratio" class="form-control input-sm input75" />
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"> </span>
|
||||
<span class="component-desc">
|
||||
<p>stop transfer when ratio is reached<br>(-1 SickGear default to seed forever, or leave blank for downloader default)</p>
|
||||
<input type="number" step="0.1" name="${curTorrentProvider.get_id()}_ratio" id="${curTorrentProvider.get_id()}_ratio" value="$curTorrentProvider._seed_ratio" class="form-control input-sm input75" />
|
||||
<p>this ratio is requested of each download sent to $torrent_method_text[$sickbeard.TORRENT_METHOD]</p>
|
||||
<div class="clear-left"><p>(set -1 to seed forever, or leave blank for the $torrent_method_text[$sickbeard.TORRENT_METHOD] default)</p></div>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'minseed'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_minseed">
|
||||
<span class="component-title" id="${curTorrentProvider.getID()}_minseed_desc">Minimum seeders:</span>
|
||||
<label for="${curTorrentProvider.get_id()}_minseed">
|
||||
<span class="component-title" id="${curTorrentProvider.get_id()}_minseed_desc">Minimum seeders</span>
|
||||
<span class="component-desc">
|
||||
<input type="number" name="${curTorrentProvider.getID()}_minseed" id="${curTorrentProvider.getID()}_minseed" value="$curTorrentProvider.minseed" class="form-control input-sm input75" />
|
||||
<input type="number" name="${curTorrentProvider.get_id()}_minseed" id="${curTorrentProvider.get_id()}_minseed" value="$curTorrentProvider.minseed" class="form-control input-sm input75" />
|
||||
<p>a release must have to be snatch worthy</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'minleech'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_minleech">
|
||||
<span class="component-title" id="${curTorrentProvider.getID()}_minleech_desc">Minimum leechers:</span>
|
||||
<label for="${curTorrentProvider.get_id()}_minleech">
|
||||
<span class="component-title" id="${curTorrentProvider.get_id()}_minleech_desc">Minimum leechers</span>
|
||||
<span class="component-desc">
|
||||
<input type="number" name="${curTorrentProvider.getID()}_minleech" id="${curTorrentProvider.getID()}_minleech" value="$curTorrentProvider.minleech" class="form-control input-sm input75" />
|
||||
<input type="number" name="${curTorrentProvider.get_id()}_minleech" id="${curTorrentProvider.get_id()}_minleech" value="$curTorrentProvider.minleech" class="form-control input-sm input75" />
|
||||
<p>a release must have to be snatch worthy</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'proxy'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_proxy">
|
||||
<label for="${curTorrentProvider.get_id()}_proxy">
|
||||
<span class="component-title">Access provider via proxy</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" class="enabler" name="${curTorrentProvider.getID()}_proxy" id="${curTorrentProvider.getID()}_proxy" <%= html_checked if curTorrentProvider.proxy.enabled else '' %>/>
|
||||
<input type="checkbox" class="enabler" name="${curTorrentProvider.get_id()}_proxy" id="${curTorrentProvider.get_id()}_proxy" <%= html_checked if curTorrentProvider.proxy.enabled else '' %>/>
|
||||
<p>to bypass country blocking mechanisms</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
#if $hasattr($curTorrentProvider.proxy, 'url'):
|
||||
<div class="field-pair content_${curTorrentProvider.getID()}_proxy" id="content_${curTorrentProvider.getID()}_proxy">
|
||||
<label for="${curTorrentProvider.getID()}_proxy_url">
|
||||
<div class="field-pair content_${curTorrentProvider.get_id()}_proxy" id="content_${curTorrentProvider.get_id()}_proxy">
|
||||
<label for="${curTorrentProvider.get_id()}_proxy_url">
|
||||
<span class="component-title">Proxy URL:</span>
|
||||
<span class="component-desc">
|
||||
<select name="${curTorrentProvider.getID()}_proxy_url" id="${curTorrentProvider.getID()}_proxy_url" class="form-control input-sm">
|
||||
<select name="${curTorrentProvider.get_id()}_proxy_url" id="${curTorrentProvider.get_id()}_proxy_url" class="form-control input-sm">
|
||||
#for $i in $curTorrentProvider.proxy.urls.keys():
|
||||
<option value="$curTorrentProvider.proxy.urls[$i]" <%= html_selected if curTorrentProvider.proxy.url == curTorrentProvider.proxy.urls[i] else '' %>>$i</option>
|
||||
#end for
|
||||
|
@ -486,85 +467,71 @@
|
|||
</div>
|
||||
#end if
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'confirmed'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_confirmed">
|
||||
<label for="${curTorrentProvider.get_id()}_confirmed">
|
||||
<span class="component-title">Confirmed download</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curTorrentProvider.getID()}_confirmed" id="${curTorrentProvider.getID()}_confirmed" <%= html_checked if curTorrentProvider.confirmed else '' %>/>
|
||||
<input type="checkbox" name="${curTorrentProvider.get_id()}_confirmed" id="${curTorrentProvider.get_id()}_confirmed" <%= html_checked if curTorrentProvider.confirmed else '' %>/>
|
||||
<p>only download torrents from trusted or verified uploaders ?</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'freeleech'):
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_freeleech">
|
||||
<label for="${curTorrentProvider.get_id()}_freeleech">
|
||||
<span class="component-title">Freeleech</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curTorrentProvider.getID()}_freeleech" id="${curTorrentProvider.getID()}_freeleech" <%= html_checked if curTorrentProvider.freeleech else '' %>/>
|
||||
<input type="checkbox" name="${curTorrentProvider.get_id()}_freeleech" id="${curTorrentProvider.get_id()}_freeleech" <%= html_checked if curTorrentProvider.freeleech else '' %>/>
|
||||
<p>only download <b>[FreeLeech]</b> torrents.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'enable_recentsearch'):
|
||||
#if $hasattr($curTorrentProvider, 'enable_recentsearch') and $curTorrentProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_enable_recentsearch">
|
||||
<label for="${curTorrentProvider.get_id()}_enable_recentsearch">
|
||||
<span class="component-title">Enable recent searches</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curTorrentProvider.getID()}_enable_recentsearch" id="${curTorrentProvider.getID()}_enable_recentsearch" <%= html_checked if curTorrentProvider.enable_recentsearch else '' %>/>
|
||||
<input type="checkbox" name="${curTorrentProvider.get_id()}_enable_recentsearch" id="${curTorrentProvider.get_id()}_enable_recentsearch" <%= html_checked if curTorrentProvider.enable_recentsearch else '' %>/>
|
||||
<p>enable provider to perform recent searches.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'enable_backlog'):
|
||||
#if $hasattr($curTorrentProvider, 'enable_backlog') and $curTorrentProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_enable_backlog">
|
||||
<label for="${curTorrentProvider.get_id()}_enable_backlog">
|
||||
<span class="component-title">Enable backlog searches</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curTorrentProvider.getID()}_enable_backlog" id="${curTorrentProvider.getID()}_enable_backlog" <%= html_checked if curTorrentProvider.enable_backlog else '' %>/>
|
||||
<input type="checkbox" name="${curTorrentProvider.get_id()}_enable_backlog" id="${curTorrentProvider.get_id()}_enable_backlog" <%= html_checked if curTorrentProvider.enable_backlog else '' %>/>
|
||||
<p>enable provider to perform backlog searches.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'search_fallback'):
|
||||
#if $hasattr($curTorrentProvider, 'search_mode') and $curTorrentProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.getID()}_search_fallback">
|
||||
<span class="component-title">Season search fallback</span>
|
||||
<span class="component-desc">
|
||||
<input type="checkbox" name="${curTorrentProvider.getID()}_search_fallback" id="${curTorrentProvider.getID()}_search_fallback" <%= html_checked if curTorrentProvider.search_fallback else '' %>/>
|
||||
<p>when searching for a complete season depending on search mode you may return no results, this helps by restarting the search using the opposite search mode.</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
#end if
|
||||
|
||||
#if $hasattr($curTorrentProvider, 'search_mode'):
|
||||
<div class="field-pair">
|
||||
<label>
|
||||
<span class="component-title">Season search mode</span>
|
||||
<span class="component-desc">
|
||||
<p>when searching for complete seasons you can choose to have it look for season packs only, or choose to have it build a complete season from just single episodes.</p>
|
||||
</span>
|
||||
<label class="space-right">
|
||||
<input type="radio" name="${curTorrentProvider.get_id()}_search_mode" id="${curTorrentProvider.get_id()}_search_mode_sponly" value="sponly" <%= html_checked if 'sponly' == curTorrentProvider.search_mode else '' %>/>season packs only
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"></span>
|
||||
<span class="component-desc">
|
||||
<input type="radio" name="${curTorrentProvider.getID()}_search_mode" id="${curTorrentProvider.getID()}_search_mode_sponly" value="sponly" <%= html_checked if 'sponly' == curTorrentProvider.search_mode else '' %>/>season packs only.
|
||||
</span>
|
||||
<input type="radio" name="${curTorrentProvider.get_id()}_search_mode" id="${curTorrentProvider.get_id()}_search_mode_eponly" value="eponly" <%= html_checked if 'eponly' == curTorrentProvider.search_mode else '' %>/>episodes only
|
||||
</label>
|
||||
<label>
|
||||
<span class="component-title"></span>
|
||||
<p>when searching for complete seasons, search for packs or collect single episodes</p>
|
||||
</span>
|
||||
</div>
|
||||
#end if
|
||||
#if $hasattr($curTorrentProvider, 'search_fallback') and $curTorrentProvider.supportsBacklog:
|
||||
<div class="field-pair">
|
||||
<label for="${curTorrentProvider.get_id()}_search_fallback">
|
||||
<span class="component-title">Season search fallback</span>
|
||||
<span class="component-desc">
|
||||
<input type="radio" name="${curTorrentProvider.getID()}_search_mode" id="${curTorrentProvider.getID()}_search_mode_eponly" value="eponly" <%= html_checked if 'eponly' == curTorrentProvider.search_mode else '' %>/>episodes only.
|
||||
<input type="checkbox" name="${curTorrentProvider.get_id()}_search_fallback" id="${curTorrentProvider.get_id()}_search_fallback" <%= html_checked if curTorrentProvider.search_fallback else '' %>/>
|
||||
<p>run the alternate season search mode when a complete season is not found</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -82,7 +82,7 @@
|
|||
#include $os.path.join($sickbeard.PROG_DIR, 'gui/slick/interfaces/default/inc_qualityChooser.tmpl')
|
||||
|
||||
#if $anyQualities + $bestQualities
|
||||
<div class="field-pair">
|
||||
<div class="field-pair show-if-quality-custom">
|
||||
<label for="archive_firstmatch">
|
||||
<span class="component-title">End upgrade on first match</span>
|
||||
<span class="component-desc">
|
||||
|
@ -211,6 +211,20 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-pair#if $sickbeard.SHOWLIST_TAGVIEW != 'custom' then ' hidden' else ''#" style="margin-bottom:10px">
|
||||
<label for="tag">
|
||||
<span class="component-title">Show is in group</span>
|
||||
<span class="component-desc">
|
||||
<select name="tag" id="tag" class="form-control form-control-inline input-sm">
|
||||
#for $tag in $sickbeard.SHOW_TAGS:
|
||||
<option value="$tag" #if $tag == $show.tag then 'selected="selected"' else ''#>$tag#echo ('', ' (default)')['Show List' == $tag]#</option>
|
||||
#end for
|
||||
</select>
|
||||
<span>and is displayed on the show list page under this section</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-pair">
|
||||
<label for="sports">
|
||||
<span class="component-title">Show is sports</span>
|
||||
|
@ -231,20 +245,6 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-pair#if $sickbeard.SHOWLIST_TAGVIEW != 'custom' then ' hidden' else ''#" style="margin-bottom:10px">
|
||||
<label for="tag">
|
||||
<span class="component-title">Show is grouped in</span>
|
||||
<span class="component-desc">
|
||||
<select name="tag" id="tag" class="form-control form-control-inline input-sm">
|
||||
#for $tag in $sickbeard.SHOW_TAGS:
|
||||
<option value="$tag" #if $tag == $show.tag then 'selected="selected"' else ''#>$tag#echo ('', ' (default)')['Show List' == $tag]#</option>
|
||||
#end for
|
||||
</select>
|
||||
<span>and displays on the show list page under this section</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
#if $show.is_anime:
|
||||
#import sickbeard.blackandwhitelist
|
||||
#include $os.path.join($sickbeard.PROG_DIR, 'gui/slick/interfaces/default/inc_blackwhitelist.tmpl')
|
||||
|
|
|
@ -139,9 +139,9 @@
|
|||
#else
|
||||
#if 0 < $hItem['provider']
|
||||
#if $curStatus in [SNATCHED, FAILED]
|
||||
#set $provider = $providers.getProviderClass($generic.GenericProvider.makeID($hItem['provider']))
|
||||
#set $provider = $providers.getProviderClass($generic.GenericProvider.make_id($hItem['provider']))
|
||||
#if None is not $provider
|
||||
<img src="$sbRoot/images/providers/<%= provider.imageName() %>" width="16" height="16" /><span>$provider.name</span>
|
||||
<img src="$sbRoot/images/providers/<%= provider.image_name() %>" width="16" height="16" /><span>$provider.name</span>
|
||||
#else
|
||||
<img src="$sbRoot/images/providers/missing.png" width="16" height="16" title="missing provider" /><span>Missing Provider</span>
|
||||
#end if
|
||||
|
@ -186,10 +186,10 @@
|
|||
#set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($action['action']))
|
||||
#set $basename = $os.path.basename($action['resource'])
|
||||
#if $curStatus in [SNATCHED, FAILED]
|
||||
#set $provider = $providers.getProviderClass($generic.GenericProvider.makeID($action['provider']))
|
||||
#set $provider = $providers.getProviderClass($generic.GenericProvider.make_id($action['provider']))
|
||||
#if None is not $provider
|
||||
#set $prov_list += ['<span%s><img class="help" src="%s/images/providers/%s" width="16" height="16" alt="%s" title="%s.. %s: %s" /></span>'\
|
||||
% (('', ' class="fail"')[FAILED == $curStatus], $sbRoot, $provider.imageName(), $provider.name,
|
||||
% (('', ' class="fail"')[FAILED == $curStatus], $sbRoot, $provider.image_name(), $provider.name,
|
||||
('%s%s' % ($order, 'th' if $order in [11, 12, 13] or str($order)[-1] not in $ordinal_indicators else $ordinal_indicators[str($order)[-1]]), 'Snatch failed')[FAILED == $curStatus],
|
||||
$provider.name, $basename)]
|
||||
#set $order += (0, 1)[SNATCHED == $curStatus]
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
|
||||
<div id="customQualityWrapper">
|
||||
<div id="customQuality">
|
||||
<div id="customQuality" class="show-if-quality-custom">
|
||||
<div class="component-group-desc">
|
||||
<p>An <em>Initial</em> quality episode must be found before an <em>Upgrade to</em> selection is considered.</p>
|
||||
</div>
|
||||
|
|
|
@ -72,9 +72,9 @@
|
|||
<tr>
|
||||
<td class="text-nowrap text-left">#echo re.sub('"', '', $hItem['release'])#</td>
|
||||
<td>#echo ($hItem['size'], '?')[-1 == $hItem['size']]#</td>
|
||||
#set $provider = $providers.getProviderClass($generic.GenericProvider.makeID($hItem['provider']))
|
||||
#set $provider = $providers.getProviderClass($generic.GenericProvider.make_id($hItem['provider']))
|
||||
#if None is not $provider:
|
||||
<td><img src="$sbRoot/images/providers/<%= provider.imageName() %>" width="16" height="16" alt="$provider.name" title="$provider.name" /></td>
|
||||
<td><img src="$sbRoot/images/providers/<%= provider.image_name() %>" width="16" height="16" alt="$provider.name" title="$provider.name" /></td>
|
||||
#else
|
||||
<td><img src="$sbRoot/images/providers/missing.png" width="16" height="16" alt="missing provider" title="missing provider" /></td>
|
||||
#end if
|
||||
|
|
|
@ -489,10 +489,11 @@ $(document).ready(function(){
|
|||
$.get(sbRoot + '/home/getPushbulletDevices', {'accessToken': pushbullet_access_token})
|
||||
.done(function (data) {
|
||||
var devices = jQuery.parseJSON(data || '{}').devices;
|
||||
var error = jQuery.parseJSON(data || '{}').error;
|
||||
$('#pushbullet_device_list').html('');
|
||||
if (devices) {
|
||||
// add default option to send to all devices
|
||||
$('#pushbullet_device_list').append('<option value="" selected="selected">-- All Devices --</option>');
|
||||
if (devices) {
|
||||
for (var i = 0; i < devices.length; i++) {
|
||||
// only list active device targets
|
||||
if (devices[i].active == true) {
|
||||
|
@ -507,8 +508,12 @@ $(document).ready(function(){
|
|||
}
|
||||
$('#getPushbulletDevices').prop('disabled', false);
|
||||
if (msg) {
|
||||
if (error.message) {
|
||||
$('#testPushbullet-result').html(error.message);
|
||||
} else {
|
||||
$('#testPushbullet-result').html(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$('#pushbullet_device_list').change(function () {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
function setFromPresets (preset) {
|
||||
var elCustomQuality = $('#customQuality'),
|
||||
var elCustomQuality = $('.show-if-quality-custom'),
|
||||
selected = 'selected';
|
||||
if (0 == preset) {
|
||||
elCustomQuality.show();
|
||||
|
|
|
@ -40,8 +40,8 @@ $(document).ready(function() {
|
|||
if (is_default)
|
||||
setDefault($('#rootDirs option').attr('id'));
|
||||
|
||||
$.get(sbRoot+'/config/general/saveRootDirs', { rootDirString: $('#rootDirText').val() });
|
||||
refreshRootDirs();
|
||||
$.get(sbRoot+'/config/general/saveRootDirs', { rootDirString: $('#rootDirText').val() });
|
||||
}
|
||||
|
||||
function editRootDir(path) {
|
||||
|
|
|
@ -45,7 +45,7 @@ from .element import (
|
|||
|
||||
# The very first thing we do is give a useful error if someone is
|
||||
# running this code under Python 3 without converting it.
|
||||
syntax_error = u'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work. You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).'
|
||||
'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work.'<>'You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).'
|
||||
|
||||
class BeautifulSoup(Tag):
|
||||
"""
|
||||
|
@ -77,6 +77,8 @@ class BeautifulSoup(Tag):
|
|||
|
||||
ASCII_SPACES = '\x20\x0a\x09\x0c\x0d'
|
||||
|
||||
NO_PARSER_SPECIFIED_WARNING = "No parser was explicitly specified, so I'm using the best available parser for this system (\"%(parser)s\"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.\n\nTo get rid of this warning, change this:\n\n BeautifulSoup([your markup])\n\nto this:\n\n BeautifulSoup([your markup], \"%(parser)s\")\n"
|
||||
|
||||
def __init__(self, markup="", features=None, builder=None,
|
||||
parse_only=None, from_encoding=None, **kwargs):
|
||||
"""The Soup object is initialized as the 'root tag', and the
|
||||
|
@ -114,9 +116,9 @@ class BeautifulSoup(Tag):
|
|||
del kwargs['isHTML']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the isHTML argument to the "
|
||||
"BeautifulSoup constructor. You can pass in features='html' "
|
||||
"or features='xml' to get a builder capable of handling "
|
||||
"one or the other.")
|
||||
"BeautifulSoup constructor. Suggest you use "
|
||||
"features='lxml' for HTML and features='lxml-xml' for "
|
||||
"XML.")
|
||||
|
||||
def deprecated_argument(old_name, new_name):
|
||||
if old_name in kwargs:
|
||||
|
@ -140,6 +142,7 @@ class BeautifulSoup(Tag):
|
|||
"__init__() got an unexpected keyword argument '%s'" % arg)
|
||||
|
||||
if builder is None:
|
||||
original_features = features
|
||||
if isinstance(features, basestring):
|
||||
features = [features]
|
||||
if features is None or len(features) == 0:
|
||||
|
@ -151,6 +154,11 @@ class BeautifulSoup(Tag):
|
|||
"requested: %s. Do you need to install a parser library?"
|
||||
% ",".join(features))
|
||||
builder = builder_class()
|
||||
if not (original_features == builder.NAME or
|
||||
original_features in builder.ALTERNATE_NAMES):
|
||||
warnings.warn(self.NO_PARSER_SPECIFIED_WARNING % dict(
|
||||
parser=builder.NAME))
|
||||
|
||||
self.builder = builder
|
||||
self.is_xml = builder.is_xml
|
||||
self.builder.soup = self
|
||||
|
@ -178,6 +186,8 @@ class BeautifulSoup(Tag):
|
|||
# system. Just let it go.
|
||||
pass
|
||||
if is_file:
|
||||
if isinstance(markup, unicode):
|
||||
markup = markup.encode("utf8")
|
||||
warnings.warn(
|
||||
'"%s" looks like a filename, not markup. You should probably open this file and pass the filehandle into Beautiful Soup.' % markup)
|
||||
if markup[:5] == "http:" or markup[:6] == "https:":
|
||||
|
@ -185,6 +195,8 @@ class BeautifulSoup(Tag):
|
|||
# Python 3 otherwise.
|
||||
if ((isinstance(markup, bytes) and not b' ' in markup)
|
||||
or (isinstance(markup, unicode) and not u' ' in markup)):
|
||||
if isinstance(markup, unicode):
|
||||
markup = markup.encode("utf8")
|
||||
warnings.warn(
|
||||
'"%s" looks like a URL. Beautiful Soup is not an HTTP client. You should probably use an HTTP client to get the document behind the URL, and feed that document to Beautiful Soup.' % markup)
|
||||
|
||||
|
|
|
@ -80,6 +80,8 @@ builder_registry = TreeBuilderRegistry()
|
|||
class TreeBuilder(object):
|
||||
"""Turn a document into a Beautiful Soup object tree."""
|
||||
|
||||
NAME = "[Unknown tree builder]"
|
||||
ALTERNATE_NAMES = []
|
||||
features = []
|
||||
|
||||
is_xml = False
|
||||
|
|
|
@ -22,7 +22,9 @@ from bs4.element import (
|
|||
class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
"""Use html5lib to build a tree."""
|
||||
|
||||
features = ['html5lib', PERMISSIVE, HTML_5, HTML]
|
||||
NAME = "html5lib"
|
||||
|
||||
features = [NAME, PERMISSIVE, HTML_5, HTML]
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding):
|
||||
# Store the user-specified encoding for use later on.
|
||||
|
@ -161,6 +163,12 @@ class Element(html5lib.treebuilders._base.Node):
|
|||
# immediately after the parent, if it has no children.)
|
||||
if self.element.contents:
|
||||
most_recent_element = self.element._last_descendant(False)
|
||||
elif self.element.next_element is not None:
|
||||
# Something from further ahead in the parse tree is
|
||||
# being inserted into this earlier element. This is
|
||||
# very annoying because it means an expensive search
|
||||
# for the last element in the tree.
|
||||
most_recent_element = self.soup._last_descendant()
|
||||
else:
|
||||
most_recent_element = self.element
|
||||
|
||||
|
|
|
@ -19,10 +19,8 @@ import warnings
|
|||
# At the end of this file, we monkeypatch HTMLParser so that
|
||||
# strict=True works well on Python 3.2.2.
|
||||
major, minor, release = sys.version_info[:3]
|
||||
CONSTRUCTOR_TAKES_STRICT = (
|
||||
major > 3
|
||||
or (major == 3 and minor > 2)
|
||||
or (major == 3 and minor == 2 and release >= 3))
|
||||
CONSTRUCTOR_TAKES_STRICT = major == 3 and minor == 2 and release >= 3
|
||||
CONSTRUCTOR_TAKES_CONVERT_CHARREFS = major == 3 and minor >= 4
|
||||
|
||||
from bs4.element import (
|
||||
CData,
|
||||
|
@ -63,7 +61,8 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
|||
|
||||
def handle_charref(self, name):
|
||||
# XXX workaround for a bug in HTMLParser. Remove this once
|
||||
# it's fixed.
|
||||
# it's fixed in all supported versions.
|
||||
# http://bugs.python.org/issue13633
|
||||
if name.startswith('x'):
|
||||
real_name = int(name.lstrip('x'), 16)
|
||||
elif name.startswith('X'):
|
||||
|
@ -113,14 +112,6 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
|||
|
||||
def handle_pi(self, data):
|
||||
self.soup.endData()
|
||||
if data.endswith("?") and data.lower().startswith("xml"):
|
||||
# "An XHTML processing instruction using the trailing '?'
|
||||
# will cause the '?' to be included in data." - HTMLParser
|
||||
# docs.
|
||||
#
|
||||
# Strip the question mark so we don't end up with two
|
||||
# question marks.
|
||||
data = data[:-1]
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(ProcessingInstruction)
|
||||
|
||||
|
@ -128,11 +119,14 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
|||
class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
|
||||
is_xml = False
|
||||
features = [HTML, STRICT, HTMLPARSER]
|
||||
NAME = HTMLPARSER
|
||||
features = [NAME, HTML, STRICT]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if CONSTRUCTOR_TAKES_STRICT:
|
||||
kwargs['strict'] = False
|
||||
if CONSTRUCTOR_TAKES_CONVERT_CHARREFS:
|
||||
kwargs['convert_charrefs'] = False
|
||||
self.parser_args = (args, kwargs)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
|
|
|
@ -7,7 +7,12 @@ from io import BytesIO
|
|||
from StringIO import StringIO
|
||||
import collections
|
||||
from lxml import etree
|
||||
from bs4.element import Comment, Doctype, NamespacedAttribute
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
Doctype,
|
||||
NamespacedAttribute,
|
||||
ProcessingInstruction,
|
||||
)
|
||||
from bs4.builder import (
|
||||
FAST,
|
||||
HTML,
|
||||
|
@ -25,8 +30,10 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
|||
|
||||
is_xml = True
|
||||
|
||||
NAME = "lxml-xml"
|
||||
|
||||
# Well, it's permissive by XML parser standards.
|
||||
features = [LXML, XML, FAST, PERMISSIVE]
|
||||
features = [NAME, LXML, XML, FAST, PERMISSIVE]
|
||||
|
||||
CHUNK_SIZE = 512
|
||||
|
||||
|
@ -189,7 +196,9 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
|||
self.nsmaps.pop()
|
||||
|
||||
def pi(self, target, data):
|
||||
pass
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(target + ' ' + data)
|
||||
self.soup.endData(ProcessingInstruction)
|
||||
|
||||
def data(self, content):
|
||||
self.soup.handle_data(content)
|
||||
|
@ -212,7 +221,10 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
|||
|
||||
class LXMLTreeBuilder(HTMLTreeBuilder, LXMLTreeBuilderForXML):
|
||||
|
||||
features = [LXML, HTML, FAST, PERMISSIVE]
|
||||
NAME = LXML
|
||||
ALTERNATE_NAMES = ["lxml-html"]
|
||||
|
||||
features = ALTERNATE_NAMES + [NAME, HTML, FAST, PERMISSIVE]
|
||||
is_xml = False
|
||||
|
||||
def default_parser(self, encoding):
|
||||
|
|
|
@ -548,17 +548,17 @@ class PageElement(object):
|
|||
|
||||
# Methods for supporting CSS selectors.
|
||||
|
||||
tag_name_re = re.compile('^[a-z0-9]+$')
|
||||
tag_name_re = re.compile('^[a-zA-Z0-9][-.a-zA-Z0-9:_]*$')
|
||||
|
||||
# /^(\w+)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/
|
||||
# \---/ \---/\-------------/ \-------/
|
||||
# /^([a-zA-Z0-9][-.a-zA-Z0-9:_]*)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/
|
||||
# \---------------------------/ \---/\-------------/ \-------/
|
||||
# | | | |
|
||||
# | | | The value
|
||||
# | | ~,|,^,$,* or =
|
||||
# | Attribute
|
||||
# Tag
|
||||
attribselect_re = re.compile(
|
||||
r'^(?P<tag>\w+)?\[(?P<attribute>\w+)(?P<operator>[=~\|\^\$\*]?)' +
|
||||
r'^(?P<tag>[a-zA-Z0-9][-.a-zA-Z0-9:_]*)?\[(?P<attribute>\w+)(?P<operator>[=~\|\^\$\*]?)' +
|
||||
r'=?"?(?P<value>[^\]"]*)"?\]$'
|
||||
)
|
||||
|
||||
|
@ -707,7 +707,7 @@ class CData(PreformattedString):
|
|||
class ProcessingInstruction(PreformattedString):
|
||||
|
||||
PREFIX = u'<?'
|
||||
SUFFIX = u'?>'
|
||||
SUFFIX = u'>'
|
||||
|
||||
class Comment(PreformattedString):
|
||||
|
||||
|
@ -1203,24 +1203,40 @@ class Tag(PageElement):
|
|||
_select_debug = False
|
||||
def select(self, selector, _candidate_generator=None):
|
||||
"""Perform a CSS selection operation on the current element."""
|
||||
tokens = selector.split()
|
||||
|
||||
# Remove whitespace directly after the grouping operator ','
|
||||
# then split into tokens.
|
||||
tokens = re.sub(',[\s]*',',', selector).split()
|
||||
current_context = [self]
|
||||
|
||||
if tokens[-1] in self._selector_combinators:
|
||||
raise ValueError(
|
||||
'Final combinator "%s" is missing an argument.' % tokens[-1])
|
||||
|
||||
if self._select_debug:
|
||||
print 'Running CSS selector "%s"' % selector
|
||||
for index, token in enumerate(tokens):
|
||||
if self._select_debug:
|
||||
print ' Considering token "%s"' % token
|
||||
recursive_candidate_generator = None
|
||||
tag_name = None
|
||||
|
||||
for index, token_group in enumerate(tokens):
|
||||
new_context = []
|
||||
new_context_ids = set([])
|
||||
|
||||
# Grouping selectors, ie: p,a
|
||||
grouped_tokens = token_group.split(',')
|
||||
if '' in grouped_tokens:
|
||||
raise ValueError('Invalid group selection syntax: %s' % token_group)
|
||||
|
||||
if tokens[index-1] in self._selector_combinators:
|
||||
# This token was consumed by the previous combinator. Skip it.
|
||||
if self._select_debug:
|
||||
print ' Token was consumed by the previous combinator.'
|
||||
continue
|
||||
|
||||
for token in grouped_tokens:
|
||||
if self._select_debug:
|
||||
print ' Considering token "%s"' % token
|
||||
recursive_candidate_generator = None
|
||||
tag_name = None
|
||||
|
||||
# Each operation corresponds to a checker function, a rule
|
||||
# for determining whether a candidate matches the
|
||||
# selector. Candidates are generated by the active
|
||||
|
@ -1311,7 +1327,6 @@ class Tag(PageElement):
|
|||
else:
|
||||
raise ValueError(
|
||||
'Unsupported or invalid CSS selector: "%s"' % token)
|
||||
|
||||
if recursive_candidate_generator:
|
||||
# This happens when the selector looks like "> foo".
|
||||
#
|
||||
|
@ -1361,8 +1376,6 @@ class Tag(PageElement):
|
|||
else:
|
||||
_use_candidate_generator = _candidate_generator
|
||||
|
||||
new_context = []
|
||||
new_context_ids = set([])
|
||||
for tag in current_context:
|
||||
if self._select_debug:
|
||||
print " Running candidate generator on %s %s" % (
|
||||
|
@ -1390,6 +1403,7 @@ class Tag(PageElement):
|
|||
elif self._select_debug:
|
||||
print " FAILURE %s %s" % (candidate.name, repr(candidate.attrs))
|
||||
|
||||
|
||||
current_context = new_context
|
||||
|
||||
if self._select_debug:
|
||||
|
|
|
@ -1,592 +0,0 @@
|
|||
"""Helper classes for tests."""
|
||||
|
||||
import copy
|
||||
import functools
|
||||
import unittest
|
||||
from unittest import TestCase
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import (
|
||||
CharsetMetaAttributeValue,
|
||||
Comment,
|
||||
ContentMetaAttributeValue,
|
||||
Doctype,
|
||||
SoupStrainer,
|
||||
)
|
||||
|
||||
from bs4.builder import HTMLParserTreeBuilder
|
||||
default_builder = HTMLParserTreeBuilder
|
||||
|
||||
|
||||
class SoupTest(unittest.TestCase):
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return default_builder()
|
||||
|
||||
def soup(self, markup, **kwargs):
|
||||
"""Build a Beautiful Soup object from markup."""
|
||||
builder = kwargs.pop('builder', self.default_builder)
|
||||
return BeautifulSoup(markup, builder=builder, **kwargs)
|
||||
|
||||
def document_for(self, markup):
|
||||
"""Turn an HTML fragment into a document.
|
||||
|
||||
The details depend on the builder.
|
||||
"""
|
||||
return self.default_builder.test_fragment_to_document(markup)
|
||||
|
||||
def assertSoupEquals(self, to_parse, compare_parsed_to=None):
|
||||
builder = self.default_builder
|
||||
obj = BeautifulSoup(to_parse, builder=builder)
|
||||
if compare_parsed_to is None:
|
||||
compare_parsed_to = to_parse
|
||||
|
||||
self.assertEqual(obj.decode(), self.document_for(compare_parsed_to))
|
||||
|
||||
|
||||
class HTMLTreeBuilderSmokeTest(object):
|
||||
|
||||
"""A basic test of a treebuilder's competence.
|
||||
|
||||
Any HTML treebuilder, present or future, should be able to pass
|
||||
these tests. With invalid markup, there's room for interpretation,
|
||||
and different parsers can handle it differently. But with the
|
||||
markup in these tests, there's not much room for interpretation.
|
||||
"""
|
||||
|
||||
def assertDoctypeHandled(self, doctype_fragment):
|
||||
"""Assert that a given doctype string is handled correctly."""
|
||||
doctype_str, soup = self._document_with_doctype(doctype_fragment)
|
||||
|
||||
# Make sure a Doctype object was created.
|
||||
doctype = soup.contents[0]
|
||||
self.assertEqual(doctype.__class__, Doctype)
|
||||
self.assertEqual(doctype, doctype_fragment)
|
||||
self.assertEqual(str(soup)[:len(doctype_str)], doctype_str)
|
||||
|
||||
# Make sure that the doctype was correctly associated with the
|
||||
# parse tree and that the rest of the document parsed.
|
||||
self.assertEqual(soup.p.contents[0], 'foo')
|
||||
|
||||
def _document_with_doctype(self, doctype_fragment):
|
||||
"""Generate and parse a document with the given doctype."""
|
||||
doctype = '<!DOCTYPE %s>' % doctype_fragment
|
||||
markup = doctype + '\n<p>foo</p>'
|
||||
soup = self.soup(markup)
|
||||
return doctype, soup
|
||||
|
||||
def test_normal_doctypes(self):
|
||||
"""Make sure normal, everyday HTML doctypes are handled correctly."""
|
||||
self.assertDoctypeHandled("html")
|
||||
self.assertDoctypeHandled(
|
||||
'html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"')
|
||||
|
||||
def test_empty_doctype(self):
|
||||
soup = self.soup("<!DOCTYPE>")
|
||||
doctype = soup.contents[0]
|
||||
self.assertEqual("", doctype.strip())
|
||||
|
||||
def test_public_doctype_with_url(self):
|
||||
doctype = 'html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"'
|
||||
self.assertDoctypeHandled(doctype)
|
||||
|
||||
def test_system_doctype(self):
|
||||
self.assertDoctypeHandled('foo SYSTEM "http://www.example.com/"')
|
||||
|
||||
def test_namespaced_system_doctype(self):
|
||||
# We can handle a namespaced doctype with a system ID.
|
||||
self.assertDoctypeHandled('xsl:stylesheet SYSTEM "htmlent.dtd"')
|
||||
|
||||
def test_namespaced_public_doctype(self):
|
||||
# Test a namespaced doctype with a public id.
|
||||
self.assertDoctypeHandled('xsl:stylesheet PUBLIC "htmlent.dtd"')
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""A real XHTML document should come out more or less the same as it went in."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Hello.</title></head>
|
||||
<body>Goodbye.</body>
|
||||
</html>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(
|
||||
soup.encode("utf-8").replace(b"\n", b""),
|
||||
markup.replace(b"\n", b""))
|
||||
|
||||
def test_deepcopy(self):
|
||||
"""Make sure you can copy the tree builder.
|
||||
|
||||
This is important because the builder is part of a
|
||||
BeautifulSoup object, and we want to be able to copy that.
|
||||
"""
|
||||
copy.deepcopy(self.default_builder)
|
||||
|
||||
def test_p_tag_is_never_empty_element(self):
|
||||
"""A <p> tag is never designated as an empty-element tag.
|
||||
|
||||
Even if the markup shows it as an empty-element tag, it
|
||||
shouldn't be presented that way.
|
||||
"""
|
||||
soup = self.soup("<p/>")
|
||||
self.assertFalse(soup.p.is_empty_element)
|
||||
self.assertEqual(str(soup.p), "<p></p>")
|
||||
|
||||
def test_unclosed_tags_get_closed(self):
|
||||
"""A tag that's not closed by the end of the document should be closed.
|
||||
|
||||
This applies to all tags except empty-element tags.
|
||||
"""
|
||||
self.assertSoupEquals("<p>", "<p></p>")
|
||||
self.assertSoupEquals("<b>", "<b></b>")
|
||||
|
||||
self.assertSoupEquals("<br>", "<br/>")
|
||||
|
||||
def test_br_is_always_empty_element_tag(self):
|
||||
"""A <br> tag is designated as an empty-element tag.
|
||||
|
||||
Some parsers treat <br></br> as one <br/> tag, some parsers as
|
||||
two tags, but it should always be an empty-element tag.
|
||||
"""
|
||||
soup = self.soup("<br></br>")
|
||||
self.assertTrue(soup.br.is_empty_element)
|
||||
self.assertEqual(str(soup.br), "<br/>")
|
||||
|
||||
def test_nested_formatting_elements(self):
|
||||
self.assertSoupEquals("<em><em></em></em>")
|
||||
|
||||
def test_comment(self):
|
||||
# Comments are represented as Comment objects.
|
||||
markup = "<p>foo<!--foobar-->baz</p>"
|
||||
self.assertSoupEquals(markup)
|
||||
|
||||
soup = self.soup(markup)
|
||||
comment = soup.find(text="foobar")
|
||||
self.assertEqual(comment.__class__, Comment)
|
||||
|
||||
# The comment is properly integrated into the tree.
|
||||
foo = soup.find(text="foo")
|
||||
self.assertEqual(comment, foo.next_element)
|
||||
baz = soup.find(text="baz")
|
||||
self.assertEqual(comment, baz.previous_element)
|
||||
|
||||
def test_preserved_whitespace_in_pre_and_textarea(self):
|
||||
"""Whitespace must be preserved in <pre> and <textarea> tags."""
|
||||
self.assertSoupEquals("<pre> </pre>")
|
||||
self.assertSoupEquals("<textarea> woo </textarea>")
|
||||
|
||||
def test_nested_inline_elements(self):
|
||||
"""Inline elements can be nested indefinitely."""
|
||||
b_tag = "<b>Inside a B tag</b>"
|
||||
self.assertSoupEquals(b_tag)
|
||||
|
||||
nested_b_tag = "<p>A <i>nested <b>tag</b></i></p>"
|
||||
self.assertSoupEquals(nested_b_tag)
|
||||
|
||||
double_nested_b_tag = "<p>A <a>doubly <i>nested <b>tag</b></i></a></p>"
|
||||
self.assertSoupEquals(nested_b_tag)
|
||||
|
||||
def test_nested_block_level_elements(self):
|
||||
"""Block elements can be nested."""
|
||||
soup = self.soup('<blockquote><p><b>Foo</b></p></blockquote>')
|
||||
blockquote = soup.blockquote
|
||||
self.assertEqual(blockquote.p.b.string, 'Foo')
|
||||
self.assertEqual(blockquote.b.string, 'Foo')
|
||||
|
||||
def test_correctly_nested_tables(self):
|
||||
"""One table can go inside another one."""
|
||||
markup = ('<table id="1">'
|
||||
'<tr>'
|
||||
"<td>Here's another table:"
|
||||
'<table id="2">'
|
||||
'<tr><td>foo</td></tr>'
|
||||
'</table></td>')
|
||||
|
||||
self.assertSoupEquals(
|
||||
markup,
|
||||
'<table id="1"><tr><td>Here\'s another table:'
|
||||
'<table id="2"><tr><td>foo</td></tr></table>'
|
||||
'</td></tr></table>')
|
||||
|
||||
self.assertSoupEquals(
|
||||
"<table><thead><tr><td>Foo</td></tr></thead>"
|
||||
"<tbody><tr><td>Bar</td></tr></tbody>"
|
||||
"<tfoot><tr><td>Baz</td></tr></tfoot></table>")
|
||||
|
||||
def test_deeply_nested_multivalued_attribute(self):
|
||||
# html5lib can set the attributes of the same tag many times
|
||||
# as it rearranges the tree. This has caused problems with
|
||||
# multivalued attributes.
|
||||
markup = '<table><div><div class="css"></div></div></table>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(["css"], soup.div.div['class'])
|
||||
|
||||
def test_angle_brackets_in_attribute_values_are_escaped(self):
|
||||
self.assertSoupEquals('<a b="<a>"></a>', '<a b="<a>"></a>')
|
||||
|
||||
def test_entities_in_attributes_converted_to_unicode(self):
|
||||
expect = u'<p id="pi\N{LATIN SMALL LETTER N WITH TILDE}ata"></p>'
|
||||
self.assertSoupEquals('<p id="piñata"></p>', expect)
|
||||
self.assertSoupEquals('<p id="piñata"></p>', expect)
|
||||
self.assertSoupEquals('<p id="piñata"></p>', expect)
|
||||
self.assertSoupEquals('<p id="piñata"></p>', expect)
|
||||
|
||||
def test_entities_in_text_converted_to_unicode(self):
|
||||
expect = u'<p>pi\N{LATIN SMALL LETTER N WITH TILDE}ata</p>'
|
||||
self.assertSoupEquals("<p>piñata</p>", expect)
|
||||
self.assertSoupEquals("<p>piñata</p>", expect)
|
||||
self.assertSoupEquals("<p>piñata</p>", expect)
|
||||
self.assertSoupEquals("<p>piñata</p>", expect)
|
||||
|
||||
def test_quot_entity_converted_to_quotation_mark(self):
|
||||
self.assertSoupEquals("<p>I said "good day!"</p>",
|
||||
'<p>I said "good day!"</p>')
|
||||
|
||||
def test_out_of_range_entity(self):
|
||||
expect = u"\N{REPLACEMENT CHARACTER}"
|
||||
self.assertSoupEquals("�", expect)
|
||||
self.assertSoupEquals("�", expect)
|
||||
self.assertSoupEquals("�", expect)
|
||||
|
||||
def test_multipart_strings(self):
|
||||
"Mostly to prevent a recurrence of a bug in the html5lib treebuilder."
|
||||
soup = self.soup("<html><h2>\nfoo</h2><p></p></html>")
|
||||
self.assertEqual("p", soup.h2.string.next_element.name)
|
||||
self.assertEqual("p", soup.p.name)
|
||||
|
||||
def test_basic_namespaces(self):
|
||||
"""Parsers don't need to *understand* namespaces, but at the
|
||||
very least they should not choke on namespaces or lose
|
||||
data."""
|
||||
|
||||
markup = b'<html xmlns="http://www.w3.org/1999/xhtml" xmlns:mathml="http://www.w3.org/1998/Math/MathML" xmlns:svg="http://www.w3.org/2000/svg"><head></head><body><mathml:msqrt>4</mathml:msqrt><b svg:fill="red"></b></body></html>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.encode())
|
||||
html = soup.html
|
||||
self.assertEqual('http://www.w3.org/1999/xhtml', soup.html['xmlns'])
|
||||
self.assertEqual(
|
||||
'http://www.w3.org/1998/Math/MathML', soup.html['xmlns:mathml'])
|
||||
self.assertEqual(
|
||||
'http://www.w3.org/2000/svg', soup.html['xmlns:svg'])
|
||||
|
||||
def test_multivalued_attribute_value_becomes_list(self):
|
||||
markup = b'<a class="foo bar">'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(['foo', 'bar'], soup.a['class'])
|
||||
|
||||
#
|
||||
# Generally speaking, tests below this point are more tests of
|
||||
# Beautiful Soup than tests of the tree builders. But parsers are
|
||||
# weird, so we run these tests separately for every tree builder
|
||||
# to detect any differences between them.
|
||||
#
|
||||
|
||||
def test_can_parse_unicode_document(self):
|
||||
# A seemingly innocuous document... but it's in Unicode! And
|
||||
# it contains characters that can't be represented in the
|
||||
# encoding found in the declaration! The horror!
|
||||
markup = u'<html><head><meta encoding="euc-jp"></head><body>Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!</body>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u'Sacr\xe9 bleu!', soup.body.string)
|
||||
|
||||
def test_soupstrainer(self):
|
||||
"""Parsers should be able to work with SoupStrainers."""
|
||||
strainer = SoupStrainer("b")
|
||||
soup = self.soup("A <b>bold</b> <meta/> <i>statement</i>",
|
||||
parse_only=strainer)
|
||||
self.assertEqual(soup.decode(), "<b>bold</b>")
|
||||
|
||||
def test_single_quote_attribute_values_become_double_quotes(self):
|
||||
self.assertSoupEquals("<foo attr='bar'></foo>",
|
||||
'<foo attr="bar"></foo>')
|
||||
|
||||
def test_attribute_values_with_nested_quotes_are_left_alone(self):
|
||||
text = """<foo attr='bar "brawls" happen'>a</foo>"""
|
||||
self.assertSoupEquals(text)
|
||||
|
||||
def test_attribute_values_with_double_nested_quotes_get_quoted(self):
|
||||
text = """<foo attr='bar "brawls" happen'>a</foo>"""
|
||||
soup = self.soup(text)
|
||||
soup.foo['attr'] = 'Brawls happen at "Bob\'s Bar"'
|
||||
self.assertSoupEquals(
|
||||
soup.foo.decode(),
|
||||
"""<foo attr="Brawls happen at "Bob\'s Bar"">a</foo>""")
|
||||
|
||||
def test_ampersand_in_attribute_value_gets_escaped(self):
|
||||
self.assertSoupEquals('<this is="really messed up & stuff"></this>',
|
||||
'<this is="really messed up & stuff"></this>')
|
||||
|
||||
self.assertSoupEquals(
|
||||
'<a href="http://example.org?a=1&b=2;3">foo</a>',
|
||||
'<a href="http://example.org?a=1&b=2;3">foo</a>')
|
||||
|
||||
def test_escaped_ampersand_in_attribute_value_is_left_alone(self):
|
||||
self.assertSoupEquals('<a href="http://example.org?a=1&b=2;3"></a>')
|
||||
|
||||
def test_entities_in_strings_converted_during_parsing(self):
|
||||
# Both XML and HTML entities are converted to Unicode characters
|
||||
# during parsing.
|
||||
text = "<p><<sacré bleu!>></p>"
|
||||
expected = u"<p><<sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></p>"
|
||||
self.assertSoupEquals(text, expected)
|
||||
|
||||
def test_smart_quotes_converted_on_the_way_in(self):
|
||||
# Microsoft smart quotes are converted to Unicode characters during
|
||||
# parsing.
|
||||
quote = b"<p>\x91Foo\x92</p>"
|
||||
soup = self.soup(quote)
|
||||
self.assertEqual(
|
||||
soup.p.string,
|
||||
u"\N{LEFT SINGLE QUOTATION MARK}Foo\N{RIGHT SINGLE QUOTATION MARK}")
|
||||
|
||||
def test_non_breaking_spaces_converted_on_the_way_in(self):
|
||||
soup = self.soup("<a> </a>")
|
||||
self.assertEqual(soup.a.string, u"\N{NO-BREAK SPACE}" * 2)
|
||||
|
||||
def test_entities_converted_on_the_way_out(self):
|
||||
text = "<p><<sacré bleu!>></p>"
|
||||
expected = u"<p><<sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!>></p>".encode("utf-8")
|
||||
soup = self.soup(text)
|
||||
self.assertEqual(soup.p.encode("utf-8"), expected)
|
||||
|
||||
def test_real_iso_latin_document(self):
|
||||
# Smoke test of interrelated functionality, using an
|
||||
# easy-to-understand document.
|
||||
|
||||
# Here it is in Unicode. Note that it claims to be in ISO-Latin-1.
|
||||
unicode_html = u'<html><head><meta content="text/html; charset=ISO-Latin-1" http-equiv="Content-type"/></head><body><p>Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!</p></body></html>'
|
||||
|
||||
# That's because we're going to encode it into ISO-Latin-1, and use
|
||||
# that to test.
|
||||
iso_latin_html = unicode_html.encode("iso-8859-1")
|
||||
|
||||
# Parse the ISO-Latin-1 HTML.
|
||||
soup = self.soup(iso_latin_html)
|
||||
# Encode it to UTF-8.
|
||||
result = soup.encode("utf-8")
|
||||
|
||||
# What do we expect the result to look like? Well, it would
|
||||
# look like unicode_html, except that the META tag would say
|
||||
# UTF-8 instead of ISO-Latin-1.
|
||||
expected = unicode_html.replace("ISO-Latin-1", "utf-8")
|
||||
|
||||
# And, of course, it would be in UTF-8, not Unicode.
|
||||
expected = expected.encode("utf-8")
|
||||
|
||||
# Ta-da!
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_real_shift_jis_document(self):
|
||||
# Smoke test to make sure the parser can handle a document in
|
||||
# Shift-JIS encoding, without choking.
|
||||
shift_jis_html = (
|
||||
b'<html><head></head><body><pre>'
|
||||
b'\x82\xb1\x82\xea\x82\xcdShift-JIS\x82\xc5\x83R\x81[\x83f'
|
||||
b'\x83B\x83\x93\x83O\x82\xb3\x82\xea\x82\xbd\x93\xfa\x96{\x8c'
|
||||
b'\xea\x82\xcc\x83t\x83@\x83C\x83\x8b\x82\xc5\x82\xb7\x81B'
|
||||
b'</pre></body></html>')
|
||||
unicode_html = shift_jis_html.decode("shift-jis")
|
||||
soup = self.soup(unicode_html)
|
||||
|
||||
# Make sure the parse tree is correctly encoded to various
|
||||
# encodings.
|
||||
self.assertEqual(soup.encode("utf-8"), unicode_html.encode("utf-8"))
|
||||
self.assertEqual(soup.encode("euc_jp"), unicode_html.encode("euc_jp"))
|
||||
|
||||
def test_real_hebrew_document(self):
|
||||
# A real-world test to make sure we can convert ISO-8859-9 (a
|
||||
# Hebrew encoding) to UTF-8.
|
||||
hebrew_document = b'<html><head><title>Hebrew (ISO 8859-8) in Visual Directionality</title></head><body><h1>Hebrew (ISO 8859-8) in Visual Directionality</h1>\xed\xe5\xec\xf9</body></html>'
|
||||
soup = self.soup(
|
||||
hebrew_document, from_encoding="iso8859-8")
|
||||
self.assertEqual(soup.original_encoding, 'iso8859-8')
|
||||
self.assertEqual(
|
||||
soup.encode('utf-8'),
|
||||
hebrew_document.decode("iso8859-8").encode("utf-8"))
|
||||
|
||||
def test_meta_tag_reflects_current_encoding(self):
|
||||
# Here's the <meta> tag saying that a document is
|
||||
# encoded in Shift-JIS.
|
||||
meta_tag = ('<meta content="text/html; charset=x-sjis" '
|
||||
'http-equiv="Content-type"/>')
|
||||
|
||||
# Here's a document incorporating that meta tag.
|
||||
shift_jis_html = (
|
||||
'<html><head>\n%s\n'
|
||||
'<meta http-equiv="Content-language" content="ja"/>'
|
||||
'</head><body>Shift-JIS markup goes here.') % meta_tag
|
||||
soup = self.soup(shift_jis_html)
|
||||
|
||||
# Parse the document, and the charset is seemingly unaffected.
|
||||
parsed_meta = soup.find('meta', {'http-equiv': 'Content-type'})
|
||||
content = parsed_meta['content']
|
||||
self.assertEqual('text/html; charset=x-sjis', content)
|
||||
|
||||
# But that value is actually a ContentMetaAttributeValue object.
|
||||
self.assertTrue(isinstance(content, ContentMetaAttributeValue))
|
||||
|
||||
# And it will take on a value that reflects its current
|
||||
# encoding.
|
||||
self.assertEqual('text/html; charset=utf8', content.encode("utf8"))
|
||||
|
||||
# For the rest of the story, see TestSubstitutions in
|
||||
# test_tree.py.
|
||||
|
||||
def test_html5_style_meta_tag_reflects_current_encoding(self):
|
||||
# Here's the <meta> tag saying that a document is
|
||||
# encoded in Shift-JIS.
|
||||
meta_tag = ('<meta id="encoding" charset="x-sjis" />')
|
||||
|
||||
# Here's a document incorporating that meta tag.
|
||||
shift_jis_html = (
|
||||
'<html><head>\n%s\n'
|
||||
'<meta http-equiv="Content-language" content="ja"/>'
|
||||
'</head><body>Shift-JIS markup goes here.') % meta_tag
|
||||
soup = self.soup(shift_jis_html)
|
||||
|
||||
# Parse the document, and the charset is seemingly unaffected.
|
||||
parsed_meta = soup.find('meta', id="encoding")
|
||||
charset = parsed_meta['charset']
|
||||
self.assertEqual('x-sjis', charset)
|
||||
|
||||
# But that value is actually a CharsetMetaAttributeValue object.
|
||||
self.assertTrue(isinstance(charset, CharsetMetaAttributeValue))
|
||||
|
||||
# And it will take on a value that reflects its current
|
||||
# encoding.
|
||||
self.assertEqual('utf8', charset.encode("utf8"))
|
||||
|
||||
def test_tag_with_no_attributes_can_have_attributes_added(self):
|
||||
data = self.soup("<a>text</a>")
|
||||
data.a['foo'] = 'bar'
|
||||
self.assertEqual('<a foo="bar">text</a>', data.a.decode())
|
||||
|
||||
class XMLTreeBuilderSmokeTest(object):
|
||||
|
||||
def test_docstring_generated(self):
|
||||
soup = self.soup("<root/>")
|
||||
self.assertEqual(
|
||||
soup.encode(), b'<?xml version="1.0" encoding="utf-8"?>\n<root/>')
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""A real XHTML document should come out *exactly* the same as it went in."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Hello.</title></head>
|
||||
<body>Goodbye.</body>
|
||||
</html>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(
|
||||
soup.encode("utf-8"), markup)
|
||||
|
||||
def test_formatter_processes_script_tag_for_xml_documents(self):
|
||||
doc = """
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
"""
|
||||
soup = BeautifulSoup(doc, "xml")
|
||||
# lxml would have stripped this while parsing, but we can add
|
||||
# it later.
|
||||
soup.script.string = 'console.log("< < hey > > ");'
|
||||
encoded = soup.encode()
|
||||
self.assertTrue(b"< < hey > >" in encoded)
|
||||
|
||||
def test_can_parse_unicode_document(self):
|
||||
markup = u'<?xml version="1.0" encoding="euc-jp"><root>Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!</root>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u'Sacr\xe9 bleu!', soup.root.string)
|
||||
|
||||
def test_popping_namespaced_tag(self):
|
||||
markup = '<rss xmlns:dc="foo"><dc:creator>b</dc:creator><dc:date>2012-07-02T20:33:42Z</dc:date><dc:rights>c</dc:rights><image>d</image></rss>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(
|
||||
unicode(soup.rss), markup)
|
||||
|
||||
def test_docstring_includes_correct_encoding(self):
|
||||
soup = self.soup("<root/>")
|
||||
self.assertEqual(
|
||||
soup.encode("latin1"),
|
||||
b'<?xml version="1.0" encoding="latin1"?>\n<root/>')
|
||||
|
||||
def test_large_xml_document(self):
|
||||
"""A large XML document should come out the same as it went in."""
|
||||
markup = (b'<?xml version="1.0" encoding="utf-8"?>\n<root>'
|
||||
+ b'0' * (2**12)
|
||||
+ b'</root>')
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(soup.encode("utf-8"), markup)
|
||||
|
||||
|
||||
def test_tags_are_empty_element_if_and_only_if_they_are_empty(self):
|
||||
self.assertSoupEquals("<p>", "<p/>")
|
||||
self.assertSoupEquals("<p>foo</p>")
|
||||
|
||||
def test_namespaces_are_preserved(self):
|
||||
markup = '<root xmlns:a="http://example.com/" xmlns:b="http://example.net/"><a:foo>This tag is in the a namespace</a:foo><b:foo>This tag is in the b namespace</b:foo></root>'
|
||||
soup = self.soup(markup)
|
||||
root = soup.root
|
||||
self.assertEqual("http://example.com/", root['xmlns:a'])
|
||||
self.assertEqual("http://example.net/", root['xmlns:b'])
|
||||
|
||||
def test_closing_namespaced_tag(self):
|
||||
markup = '<p xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:date>20010504</dc:date></p>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(unicode(soup.p), markup)
|
||||
|
||||
def test_namespaced_attributes(self):
|
||||
markup = '<foo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><bar xsi:schemaLocation="http://www.example.com"/></foo>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(unicode(soup.foo), markup)
|
||||
|
||||
def test_namespaced_attributes_xml_namespace(self):
|
||||
markup = '<foo xml:lang="fr">bar</foo>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(unicode(soup.foo), markup)
|
||||
|
||||
class HTML5TreeBuilderSmokeTest(HTMLTreeBuilderSmokeTest):
|
||||
"""Smoke test for a tree builder that supports HTML5."""
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
# Since XHTML is not HTML5, HTML5 parsers are not tested to handle
|
||||
# XHTML documents in any particular way.
|
||||
pass
|
||||
|
||||
def test_html_tags_have_namespace(self):
|
||||
markup = "<a>"
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual("http://www.w3.org/1999/xhtml", soup.a.namespace)
|
||||
|
||||
def test_svg_tags_have_namespace(self):
|
||||
markup = '<svg><circle/></svg>'
|
||||
soup = self.soup(markup)
|
||||
namespace = "http://www.w3.org/2000/svg"
|
||||
self.assertEqual(namespace, soup.svg.namespace)
|
||||
self.assertEqual(namespace, soup.circle.namespace)
|
||||
|
||||
|
||||
def test_mathml_tags_have_namespace(self):
|
||||
markup = '<math><msqrt>5</msqrt></math>'
|
||||
soup = self.soup(markup)
|
||||
namespace = 'http://www.w3.org/1998/Math/MathML'
|
||||
self.assertEqual(namespace, soup.math.namespace)
|
||||
self.assertEqual(namespace, soup.msqrt.namespace)
|
||||
|
||||
def test_xml_declaration_becomes_comment(self):
|
||||
markup = '<?xml version="1.0" encoding="utf-8"?><html></html>'
|
||||
soup = self.soup(markup)
|
||||
self.assertTrue(isinstance(soup.contents[0], Comment))
|
||||
self.assertEqual(soup.contents[0], '?xml version="1.0" encoding="utf-8"?')
|
||||
self.assertEqual("html", soup.contents[0].next_element.name)
|
||||
|
||||
def skipIf(condition, reason):
|
||||
def nothing(test, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def decorator(test_item):
|
||||
if condition:
|
||||
return nothing
|
||||
else:
|
||||
return test_item
|
||||
|
||||
return decorator
|
|
@ -1 +0,0 @@
|
|||
"The beautifulsoup tests."
|
|
@ -1,141 +0,0 @@
|
|||
"""Tests of the builder registry."""
|
||||
|
||||
import unittest
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.builder import (
|
||||
builder_registry as registry,
|
||||
HTMLParserTreeBuilder,
|
||||
TreeBuilderRegistry,
|
||||
)
|
||||
|
||||
try:
|
||||
from bs4.builder import HTML5TreeBuilder
|
||||
HTML5LIB_PRESENT = True
|
||||
except ImportError:
|
||||
HTML5LIB_PRESENT = False
|
||||
|
||||
try:
|
||||
from bs4.builder import (
|
||||
LXMLTreeBuilderForXML,
|
||||
LXMLTreeBuilder,
|
||||
)
|
||||
LXML_PRESENT = True
|
||||
except ImportError:
|
||||
LXML_PRESENT = False
|
||||
|
||||
|
||||
class BuiltInRegistryTest(unittest.TestCase):
|
||||
"""Test the built-in registry with the default builders registered."""
|
||||
|
||||
def test_combination(self):
|
||||
if LXML_PRESENT:
|
||||
self.assertEqual(registry.lookup('fast', 'html'),
|
||||
LXMLTreeBuilder)
|
||||
|
||||
if LXML_PRESENT:
|
||||
self.assertEqual(registry.lookup('permissive', 'xml'),
|
||||
LXMLTreeBuilderForXML)
|
||||
self.assertEqual(registry.lookup('strict', 'html'),
|
||||
HTMLParserTreeBuilder)
|
||||
if HTML5LIB_PRESENT:
|
||||
self.assertEqual(registry.lookup('html5lib', 'html'),
|
||||
HTML5TreeBuilder)
|
||||
|
||||
def test_lookup_by_markup_type(self):
|
||||
if LXML_PRESENT:
|
||||
self.assertEqual(registry.lookup('html'), LXMLTreeBuilder)
|
||||
self.assertEqual(registry.lookup('xml'), LXMLTreeBuilderForXML)
|
||||
else:
|
||||
self.assertEqual(registry.lookup('xml'), None)
|
||||
if HTML5LIB_PRESENT:
|
||||
self.assertEqual(registry.lookup('html'), HTML5TreeBuilder)
|
||||
else:
|
||||
self.assertEqual(registry.lookup('html'), HTMLParserTreeBuilder)
|
||||
|
||||
def test_named_library(self):
|
||||
if LXML_PRESENT:
|
||||
self.assertEqual(registry.lookup('lxml', 'xml'),
|
||||
LXMLTreeBuilderForXML)
|
||||
self.assertEqual(registry.lookup('lxml', 'html'),
|
||||
LXMLTreeBuilder)
|
||||
if HTML5LIB_PRESENT:
|
||||
self.assertEqual(registry.lookup('html5lib'),
|
||||
HTML5TreeBuilder)
|
||||
|
||||
self.assertEqual(registry.lookup('html.parser'),
|
||||
HTMLParserTreeBuilder)
|
||||
|
||||
def test_beautifulsoup_constructor_does_lookup(self):
|
||||
# You can pass in a string.
|
||||
BeautifulSoup("", features="html")
|
||||
# Or a list of strings.
|
||||
BeautifulSoup("", features=["html", "fast"])
|
||||
|
||||
# You'll get an exception if BS can't find an appropriate
|
||||
# builder.
|
||||
self.assertRaises(ValueError, BeautifulSoup,
|
||||
"", features="no-such-feature")
|
||||
|
||||
class RegistryTest(unittest.TestCase):
|
||||
"""Test the TreeBuilderRegistry class in general."""
|
||||
|
||||
def setUp(self):
|
||||
self.registry = TreeBuilderRegistry()
|
||||
|
||||
def builder_for_features(self, *feature_list):
|
||||
cls = type('Builder_' + '_'.join(feature_list),
|
||||
(object,), {'features' : feature_list})
|
||||
|
||||
self.registry.register(cls)
|
||||
return cls
|
||||
|
||||
def test_register_with_no_features(self):
|
||||
builder = self.builder_for_features()
|
||||
|
||||
# Since the builder advertises no features, you can't find it
|
||||
# by looking up features.
|
||||
self.assertEqual(self.registry.lookup('foo'), None)
|
||||
|
||||
# But you can find it by doing a lookup with no features, if
|
||||
# this happens to be the only registered builder.
|
||||
self.assertEqual(self.registry.lookup(), builder)
|
||||
|
||||
def test_register_with_features_makes_lookup_succeed(self):
|
||||
builder = self.builder_for_features('foo', 'bar')
|
||||
self.assertEqual(self.registry.lookup('foo'), builder)
|
||||
self.assertEqual(self.registry.lookup('bar'), builder)
|
||||
|
||||
def test_lookup_fails_when_no_builder_implements_feature(self):
|
||||
builder = self.builder_for_features('foo', 'bar')
|
||||
self.assertEqual(self.registry.lookup('baz'), None)
|
||||
|
||||
def test_lookup_gets_most_recent_registration_when_no_feature_specified(self):
|
||||
builder1 = self.builder_for_features('foo')
|
||||
builder2 = self.builder_for_features('bar')
|
||||
self.assertEqual(self.registry.lookup(), builder2)
|
||||
|
||||
def test_lookup_fails_when_no_tree_builders_registered(self):
|
||||
self.assertEqual(self.registry.lookup(), None)
|
||||
|
||||
def test_lookup_gets_most_recent_builder_supporting_all_features(self):
|
||||
has_one = self.builder_for_features('foo')
|
||||
has_the_other = self.builder_for_features('bar')
|
||||
has_both_early = self.builder_for_features('foo', 'bar', 'baz')
|
||||
has_both_late = self.builder_for_features('foo', 'bar', 'quux')
|
||||
lacks_one = self.builder_for_features('bar')
|
||||
has_the_other = self.builder_for_features('foo')
|
||||
|
||||
# There are two builders featuring 'foo' and 'bar', but
|
||||
# the one that also features 'quux' was registered later.
|
||||
self.assertEqual(self.registry.lookup('foo', 'bar'),
|
||||
has_both_late)
|
||||
|
||||
# There is only one builder featuring 'foo', 'bar', and 'baz'.
|
||||
self.assertEqual(self.registry.lookup('foo', 'bar', 'baz'),
|
||||
has_both_early)
|
||||
|
||||
def test_lookup_fails_when_cannot_reconcile_requested_features(self):
|
||||
builder1 = self.builder_for_features('foo', 'bar')
|
||||
builder2 = self.builder_for_features('foo', 'baz')
|
||||
self.assertEqual(self.registry.lookup('bar', 'baz'), None)
|
|
@ -1,36 +0,0 @@
|
|||
"Test harness for doctests."
|
||||
|
||||
# pylint: disable-msg=E0611,W0142
|
||||
|
||||
__metaclass__ = type
|
||||
__all__ = [
|
||||
'additional_tests',
|
||||
]
|
||||
|
||||
import atexit
|
||||
import doctest
|
||||
import os
|
||||
#from pkg_resources import (
|
||||
# resource_filename, resource_exists, resource_listdir, cleanup_resources)
|
||||
import unittest
|
||||
|
||||
DOCTEST_FLAGS = (
|
||||
doctest.ELLIPSIS |
|
||||
doctest.NORMALIZE_WHITESPACE |
|
||||
doctest.REPORT_NDIFF)
|
||||
|
||||
|
||||
# def additional_tests():
|
||||
# "Run the doc tests (README.txt and docs/*, if any exist)"
|
||||
# doctest_files = [
|
||||
# os.path.abspath(resource_filename('bs4', 'README.txt'))]
|
||||
# if resource_exists('bs4', 'docs'):
|
||||
# for name in resource_listdir('bs4', 'docs'):
|
||||
# if name.endswith('.txt'):
|
||||
# doctest_files.append(
|
||||
# os.path.abspath(
|
||||
# resource_filename('bs4', 'docs/%s' % name)))
|
||||
# kwargs = dict(module_relative=False, optionflags=DOCTEST_FLAGS)
|
||||
# atexit.register(cleanup_resources)
|
||||
# return unittest.TestSuite((
|
||||
# doctest.DocFileSuite(*doctest_files, **kwargs)))
|
|
@ -1,85 +0,0 @@
|
|||
"""Tests to ensure that the html5lib tree builder generates good trees."""
|
||||
|
||||
import warnings
|
||||
|
||||
try:
|
||||
from bs4.builder import HTML5TreeBuilder
|
||||
HTML5LIB_PRESENT = True
|
||||
except ImportError, e:
|
||||
HTML5LIB_PRESENT = False
|
||||
from bs4.element import SoupStrainer
|
||||
from bs4.testing import (
|
||||
HTML5TreeBuilderSmokeTest,
|
||||
SoupTest,
|
||||
skipIf,
|
||||
)
|
||||
|
||||
@skipIf(
|
||||
not HTML5LIB_PRESENT,
|
||||
"html5lib seems not to be present, not testing its tree builder.")
|
||||
class HTML5LibBuilderSmokeTest(SoupTest, HTML5TreeBuilderSmokeTest):
|
||||
"""See ``HTML5TreeBuilderSmokeTest``."""
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return HTML5TreeBuilder()
|
||||
|
||||
def test_soupstrainer(self):
|
||||
# The html5lib tree builder does not support SoupStrainers.
|
||||
strainer = SoupStrainer("b")
|
||||
markup = "<p>A <b>bold</b> statement.</p>"
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup(markup, parse_only=strainer)
|
||||
self.assertEqual(
|
||||
soup.decode(), self.document_for(markup))
|
||||
|
||||
self.assertTrue(
|
||||
"the html5lib tree builder doesn't support parse_only" in
|
||||
str(w[0].message))
|
||||
|
||||
def test_correctly_nested_tables(self):
|
||||
"""html5lib inserts <tbody> tags where other parsers don't."""
|
||||
markup = ('<table id="1">'
|
||||
'<tr>'
|
||||
"<td>Here's another table:"
|
||||
'<table id="2">'
|
||||
'<tr><td>foo</td></tr>'
|
||||
'</table></td>')
|
||||
|
||||
self.assertSoupEquals(
|
||||
markup,
|
||||
'<table id="1"><tbody><tr><td>Here\'s another table:'
|
||||
'<table id="2"><tbody><tr><td>foo</td></tr></tbody></table>'
|
||||
'</td></tr></tbody></table>')
|
||||
|
||||
self.assertSoupEquals(
|
||||
"<table><thead><tr><td>Foo</td></tr></thead>"
|
||||
"<tbody><tr><td>Bar</td></tr></tbody>"
|
||||
"<tfoot><tr><td>Baz</td></tr></tfoot></table>")
|
||||
|
||||
def test_xml_declaration_followed_by_doctype(self):
|
||||
markup = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<p>foo</p>
|
||||
</body>
|
||||
</html>'''
|
||||
soup = self.soup(markup)
|
||||
# Verify that we can reach the <p> tag; this means the tree is connected.
|
||||
self.assertEqual(b"<p>foo</p>", soup.p.encode())
|
||||
|
||||
def test_reparented_markup(self):
|
||||
markup = '<p><em>foo</p>\n<p>bar<a></a></em></p>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u"<body><p><em>foo</em></p><em>\n</em><p><em>bar<a></a></em></p></body>", soup.body.decode())
|
||||
self.assertEqual(2, len(soup.find_all('p')))
|
||||
|
||||
|
||||
def test_reparented_markup_ends_with_whitespace(self):
|
||||
markup = '<p><em>foo</p>\n<p>bar<a></a></em></p>\n'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u"<body><p><em>foo</em></p><em>\n</em><p><em>bar<a></a></em></p>\n</body>", soup.body.decode())
|
||||
self.assertEqual(2, len(soup.find_all('p')))
|
|
@ -1,19 +0,0 @@
|
|||
"""Tests to ensure that the html.parser tree builder generates good
|
||||
trees."""
|
||||
|
||||
from bs4.testing import SoupTest, HTMLTreeBuilderSmokeTest
|
||||
from bs4.builder import HTMLParserTreeBuilder
|
||||
|
||||
class HTMLParserTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return HTMLParserTreeBuilder()
|
||||
|
||||
def test_namespaced_system_doctype(self):
|
||||
# html.parser can't handle namespaced doctypes, so skip this one.
|
||||
pass
|
||||
|
||||
def test_namespaced_public_doctype(self):
|
||||
# html.parser can't handle namespaced doctypes, so skip this one.
|
||||
pass
|
|
@ -1,91 +0,0 @@
|
|||
"""Tests to ensure that the lxml tree builder generates good trees."""
|
||||
|
||||
import re
|
||||
import warnings
|
||||
|
||||
try:
|
||||
import lxml.etree
|
||||
LXML_PRESENT = True
|
||||
LXML_VERSION = lxml.etree.LXML_VERSION
|
||||
except ImportError, e:
|
||||
LXML_PRESENT = False
|
||||
LXML_VERSION = (0,)
|
||||
|
||||
if LXML_PRESENT:
|
||||
from bs4.builder import LXMLTreeBuilder, LXMLTreeBuilderForXML
|
||||
|
||||
from bs4 import (
|
||||
BeautifulSoup,
|
||||
BeautifulStoneSoup,
|
||||
)
|
||||
from bs4.element import Comment, Doctype, SoupStrainer
|
||||
from bs4.testing import skipIf
|
||||
from bs4.tests import test_htmlparser
|
||||
from bs4.testing import (
|
||||
HTMLTreeBuilderSmokeTest,
|
||||
XMLTreeBuilderSmokeTest,
|
||||
SoupTest,
|
||||
skipIf,
|
||||
)
|
||||
|
||||
@skipIf(
|
||||
not LXML_PRESENT,
|
||||
"lxml seems not to be present, not testing its tree builder.")
|
||||
class LXMLTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
"""See ``HTMLTreeBuilderSmokeTest``."""
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return LXMLTreeBuilder()
|
||||
|
||||
def test_out_of_range_entity(self):
|
||||
self.assertSoupEquals(
|
||||
"<p>foo�bar</p>", "<p>foobar</p>")
|
||||
self.assertSoupEquals(
|
||||
"<p>foo�bar</p>", "<p>foobar</p>")
|
||||
self.assertSoupEquals(
|
||||
"<p>foo�bar</p>", "<p>foobar</p>")
|
||||
|
||||
# In lxml < 2.3.5, an empty doctype causes a segfault. Skip this
|
||||
# test if an old version of lxml is installed.
|
||||
|
||||
@skipIf(
|
||||
not LXML_PRESENT or LXML_VERSION < (2,3,5,0),
|
||||
"Skipping doctype test for old version of lxml to avoid segfault.")
|
||||
def test_empty_doctype(self):
|
||||
soup = self.soup("<!DOCTYPE>")
|
||||
doctype = soup.contents[0]
|
||||
self.assertEqual("", doctype.strip())
|
||||
|
||||
def test_beautifulstonesoup_is_xml_parser(self):
|
||||
# Make sure that the deprecated BSS class uses an xml builder
|
||||
# if one is installed.
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = BeautifulStoneSoup("<b />")
|
||||
self.assertEqual(u"<b/>", unicode(soup.b))
|
||||
self.assertTrue("BeautifulStoneSoup class is deprecated" in str(w[0].message))
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""lxml strips the XML definition from an XHTML doc, which is fine."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Hello.</title></head>
|
||||
<body>Goodbye.</body>
|
||||
</html>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(
|
||||
soup.encode("utf-8").replace(b"\n", b''),
|
||||
markup.replace(b'\n', b'').replace(
|
||||
b'<?xml version="1.0" encoding="utf-8"?>', b''))
|
||||
|
||||
|
||||
@skipIf(
|
||||
not LXML_PRESENT,
|
||||
"lxml seems not to be present, not testing its XML tree builder.")
|
||||
class LXMLXMLTreeBuilderSmokeTest(SoupTest, XMLTreeBuilderSmokeTest):
|
||||
"""See ``HTMLTreeBuilderSmokeTest``."""
|
||||
|
||||
@property
|
||||
def default_builder(self):
|
||||
return LXMLTreeBuilderForXML()
|
|
@ -1,434 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Tests of Beautiful Soup as a whole."""
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from bs4 import (
|
||||
BeautifulSoup,
|
||||
BeautifulStoneSoup,
|
||||
)
|
||||
from bs4.element import (
|
||||
CharsetMetaAttributeValue,
|
||||
ContentMetaAttributeValue,
|
||||
SoupStrainer,
|
||||
NamespacedAttribute,
|
||||
)
|
||||
import bs4.dammit
|
||||
from bs4.dammit import (
|
||||
EntitySubstitution,
|
||||
UnicodeDammit,
|
||||
)
|
||||
from bs4.testing import (
|
||||
SoupTest,
|
||||
skipIf,
|
||||
)
|
||||
import warnings
|
||||
|
||||
try:
|
||||
from bs4.builder import LXMLTreeBuilder, LXMLTreeBuilderForXML
|
||||
LXML_PRESENT = True
|
||||
except ImportError, e:
|
||||
LXML_PRESENT = False
|
||||
|
||||
PYTHON_2_PRE_2_7 = (sys.version_info < (2,7))
|
||||
PYTHON_3_PRE_3_2 = (sys.version_info[0] == 3 and sys.version_info < (3,2))
|
||||
|
||||
class TestConstructor(SoupTest):
|
||||
|
||||
def test_short_unicode_input(self):
|
||||
data = u"<h1>éé</h1>"
|
||||
soup = self.soup(data)
|
||||
self.assertEqual(u"éé", soup.h1.string)
|
||||
|
||||
def test_embedded_null(self):
|
||||
data = u"<h1>foo\0bar</h1>"
|
||||
soup = self.soup(data)
|
||||
self.assertEqual(u"foo\0bar", soup.h1.string)
|
||||
|
||||
|
||||
class TestDeprecatedConstructorArguments(SoupTest):
|
||||
|
||||
def test_parseOnlyThese_renamed_to_parse_only(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>", parseOnlyThese=SoupStrainer("b"))
|
||||
msg = str(w[0].message)
|
||||
self.assertTrue("parseOnlyThese" in msg)
|
||||
self.assertTrue("parse_only" in msg)
|
||||
self.assertEqual(b"<b></b>", soup.encode())
|
||||
|
||||
def test_fromEncoding_renamed_to_from_encoding(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
utf8 = b"\xc3\xa9"
|
||||
soup = self.soup(utf8, fromEncoding="utf8")
|
||||
msg = str(w[0].message)
|
||||
self.assertTrue("fromEncoding" in msg)
|
||||
self.assertTrue("from_encoding" in msg)
|
||||
self.assertEqual("utf8", soup.original_encoding)
|
||||
|
||||
def test_unrecognized_keyword_argument(self):
|
||||
self.assertRaises(
|
||||
TypeError, self.soup, "<a>", no_such_argument=True)
|
||||
|
||||
class TestWarnings(SoupTest):
|
||||
|
||||
def test_disk_file_warning(self):
|
||||
filehandle = tempfile.NamedTemporaryFile()
|
||||
filename = filehandle.name
|
||||
try:
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup(filename)
|
||||
msg = str(w[0].message)
|
||||
self.assertTrue("looks like a filename" in msg)
|
||||
finally:
|
||||
filehandle.close()
|
||||
|
||||
# The file no longer exists, so Beautiful Soup will no longer issue the warning.
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup(filename)
|
||||
self.assertEqual(0, len(w))
|
||||
|
||||
def test_url_warning(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("http://www.crummy.com/")
|
||||
msg = str(w[0].message)
|
||||
self.assertTrue("looks like a URL" in msg)
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("http://www.crummy.com/ is great")
|
||||
self.assertEqual(0, len(w))
|
||||
|
||||
class TestSelectiveParsing(SoupTest):
|
||||
|
||||
def test_parse_with_soupstrainer(self):
|
||||
markup = "No<b>Yes</b><a>No<b>Yes <c>Yes</c></b>"
|
||||
strainer = SoupStrainer("b")
|
||||
soup = self.soup(markup, parse_only=strainer)
|
||||
self.assertEqual(soup.encode(), b"<b>Yes</b><b>Yes <c>Yes</c></b>")
|
||||
|
||||
|
||||
class TestEntitySubstitution(unittest.TestCase):
|
||||
"""Standalone tests of the EntitySubstitution class."""
|
||||
def setUp(self):
|
||||
self.sub = EntitySubstitution
|
||||
|
||||
def test_simple_html_substitution(self):
|
||||
# Unicode characters corresponding to named HTML entites
|
||||
# are substituted, and no others.
|
||||
s = u"foo\u2200\N{SNOWMAN}\u00f5bar"
|
||||
self.assertEqual(self.sub.substitute_html(s),
|
||||
u"foo∀\N{SNOWMAN}õbar")
|
||||
|
||||
def test_smart_quote_substitution(self):
|
||||
# MS smart quotes are a common source of frustration, so we
|
||||
# give them a special test.
|
||||
quotes = b"\x91\x92foo\x93\x94"
|
||||
dammit = UnicodeDammit(quotes)
|
||||
self.assertEqual(self.sub.substitute_html(dammit.markup),
|
||||
"‘’foo“”")
|
||||
|
||||
def test_xml_converstion_includes_no_quotes_if_make_quoted_attribute_is_false(self):
|
||||
s = 'Welcome to "my bar"'
|
||||
self.assertEqual(self.sub.substitute_xml(s, False), s)
|
||||
|
||||
def test_xml_attribute_quoting_normally_uses_double_quotes(self):
|
||||
self.assertEqual(self.sub.substitute_xml("Welcome", True),
|
||||
'"Welcome"')
|
||||
self.assertEqual(self.sub.substitute_xml("Bob's Bar", True),
|
||||
'"Bob\'s Bar"')
|
||||
|
||||
def test_xml_attribute_quoting_uses_single_quotes_when_value_contains_double_quotes(self):
|
||||
s = 'Welcome to "my bar"'
|
||||
self.assertEqual(self.sub.substitute_xml(s, True),
|
||||
"'Welcome to \"my bar\"'")
|
||||
|
||||
def test_xml_attribute_quoting_escapes_single_quotes_when_value_contains_both_single_and_double_quotes(self):
|
||||
s = 'Welcome to "Bob\'s Bar"'
|
||||
self.assertEqual(
|
||||
self.sub.substitute_xml(s, True),
|
||||
'"Welcome to "Bob\'s Bar""')
|
||||
|
||||
def test_xml_quotes_arent_escaped_when_value_is_not_being_quoted(self):
|
||||
quoted = 'Welcome to "Bob\'s Bar"'
|
||||
self.assertEqual(self.sub.substitute_xml(quoted), quoted)
|
||||
|
||||
def test_xml_quoting_handles_angle_brackets(self):
|
||||
self.assertEqual(
|
||||
self.sub.substitute_xml("foo<bar>"),
|
||||
"foo<bar>")
|
||||
|
||||
def test_xml_quoting_handles_ampersands(self):
|
||||
self.assertEqual(self.sub.substitute_xml("AT&T"), "AT&T")
|
||||
|
||||
def test_xml_quoting_including_ampersands_when_they_are_part_of_an_entity(self):
|
||||
self.assertEqual(
|
||||
self.sub.substitute_xml("ÁT&T"),
|
||||
"&Aacute;T&T")
|
||||
|
||||
def test_xml_quoting_ignoring_ampersands_when_they_are_part_of_an_entity(self):
|
||||
self.assertEqual(
|
||||
self.sub.substitute_xml_containing_entities("ÁT&T"),
|
||||
"ÁT&T")
|
||||
|
||||
def test_quotes_not_html_substituted(self):
|
||||
"""There's no need to do this except inside attribute values."""
|
||||
text = 'Bob\'s "bar"'
|
||||
self.assertEqual(self.sub.substitute_html(text), text)
|
||||
|
||||
|
||||
class TestEncodingConversion(SoupTest):
|
||||
# Test Beautiful Soup's ability to decode and encode from various
|
||||
# encodings.
|
||||
|
||||
def setUp(self):
|
||||
super(TestEncodingConversion, self).setUp()
|
||||
self.unicode_data = u'<html><head><meta charset="utf-8"/></head><body><foo>Sacr\N{LATIN SMALL LETTER E WITH ACUTE} bleu!</foo></body></html>'
|
||||
self.utf8_data = self.unicode_data.encode("utf-8")
|
||||
# Just so you know what it looks like.
|
||||
self.assertEqual(
|
||||
self.utf8_data,
|
||||
b'<html><head><meta charset="utf-8"/></head><body><foo>Sacr\xc3\xa9 bleu!</foo></body></html>')
|
||||
|
||||
def test_ascii_in_unicode_out(self):
|
||||
# ASCII input is converted to Unicode. The original_encoding
|
||||
# attribute is set to 'utf-8', a superset of ASCII.
|
||||
chardet = bs4.dammit.chardet_dammit
|
||||
logging.disable(logging.WARNING)
|
||||
try:
|
||||
def noop(str):
|
||||
return None
|
||||
# Disable chardet, which will realize that the ASCII is ASCII.
|
||||
bs4.dammit.chardet_dammit = noop
|
||||
ascii = b"<foo>a</foo>"
|
||||
soup_from_ascii = self.soup(ascii)
|
||||
unicode_output = soup_from_ascii.decode()
|
||||
self.assertTrue(isinstance(unicode_output, unicode))
|
||||
self.assertEqual(unicode_output, self.document_for(ascii.decode()))
|
||||
self.assertEqual(soup_from_ascii.original_encoding.lower(), "utf-8")
|
||||
finally:
|
||||
logging.disable(logging.NOTSET)
|
||||
bs4.dammit.chardet_dammit = chardet
|
||||
|
||||
def test_unicode_in_unicode_out(self):
|
||||
# Unicode input is left alone. The original_encoding attribute
|
||||
# is not set.
|
||||
soup_from_unicode = self.soup(self.unicode_data)
|
||||
self.assertEqual(soup_from_unicode.decode(), self.unicode_data)
|
||||
self.assertEqual(soup_from_unicode.foo.string, u'Sacr\xe9 bleu!')
|
||||
self.assertEqual(soup_from_unicode.original_encoding, None)
|
||||
|
||||
def test_utf8_in_unicode_out(self):
|
||||
# UTF-8 input is converted to Unicode. The original_encoding
|
||||
# attribute is set.
|
||||
soup_from_utf8 = self.soup(self.utf8_data)
|
||||
self.assertEqual(soup_from_utf8.decode(), self.unicode_data)
|
||||
self.assertEqual(soup_from_utf8.foo.string, u'Sacr\xe9 bleu!')
|
||||
|
||||
def test_utf8_out(self):
|
||||
# The internal data structures can be encoded as UTF-8.
|
||||
soup_from_unicode = self.soup(self.unicode_data)
|
||||
self.assertEqual(soup_from_unicode.encode('utf-8'), self.utf8_data)
|
||||
|
||||
@skipIf(
|
||||
PYTHON_2_PRE_2_7 or PYTHON_3_PRE_3_2,
|
||||
"Bad HTMLParser detected; skipping test of non-ASCII characters in attribute name.")
|
||||
def test_attribute_name_containing_unicode_characters(self):
|
||||
markup = u'<div><a \N{SNOWMAN}="snowman"></a></div>'
|
||||
self.assertEqual(self.soup(markup).div.encode("utf8"), markup.encode("utf8"))
|
||||
|
||||
class TestUnicodeDammit(unittest.TestCase):
|
||||
"""Standalone tests of UnicodeDammit."""
|
||||
|
||||
def test_unicode_input(self):
|
||||
markup = u"I'm already Unicode! \N{SNOWMAN}"
|
||||
dammit = UnicodeDammit(markup)
|
||||
self.assertEqual(dammit.unicode_markup, markup)
|
||||
|
||||
def test_smart_quotes_to_unicode(self):
|
||||
markup = b"<foo>\x91\x92\x93\x94</foo>"
|
||||
dammit = UnicodeDammit(markup)
|
||||
self.assertEqual(
|
||||
dammit.unicode_markup, u"<foo>\u2018\u2019\u201c\u201d</foo>")
|
||||
|
||||
def test_smart_quotes_to_xml_entities(self):
|
||||
markup = b"<foo>\x91\x92\x93\x94</foo>"
|
||||
dammit = UnicodeDammit(markup, smart_quotes_to="xml")
|
||||
self.assertEqual(
|
||||
dammit.unicode_markup, "<foo>‘’“”</foo>")
|
||||
|
||||
def test_smart_quotes_to_html_entities(self):
|
||||
markup = b"<foo>\x91\x92\x93\x94</foo>"
|
||||
dammit = UnicodeDammit(markup, smart_quotes_to="html")
|
||||
self.assertEqual(
|
||||
dammit.unicode_markup, "<foo>‘’“”</foo>")
|
||||
|
||||
def test_smart_quotes_to_ascii(self):
|
||||
markup = b"<foo>\x91\x92\x93\x94</foo>"
|
||||
dammit = UnicodeDammit(markup, smart_quotes_to="ascii")
|
||||
self.assertEqual(
|
||||
dammit.unicode_markup, """<foo>''""</foo>""")
|
||||
|
||||
def test_detect_utf8(self):
|
||||
utf8 = b"\xc3\xa9"
|
||||
dammit = UnicodeDammit(utf8)
|
||||
self.assertEqual(dammit.unicode_markup, u'\xe9')
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
|
||||
def test_convert_hebrew(self):
|
||||
hebrew = b"\xed\xe5\xec\xf9"
|
||||
dammit = UnicodeDammit(hebrew, ["iso-8859-8"])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'iso-8859-8')
|
||||
self.assertEqual(dammit.unicode_markup, u'\u05dd\u05d5\u05dc\u05e9')
|
||||
|
||||
def test_dont_see_smart_quotes_where_there_are_none(self):
|
||||
utf_8 = b"\343\202\261\343\203\274\343\202\277\343\202\244 Watch"
|
||||
dammit = UnicodeDammit(utf_8)
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
self.assertEqual(dammit.unicode_markup.encode("utf-8"), utf_8)
|
||||
|
||||
def test_ignore_inappropriate_codecs(self):
|
||||
utf8_data = u"Räksmörgås".encode("utf-8")
|
||||
dammit = UnicodeDammit(utf8_data, ["iso-8859-8"])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
|
||||
def test_ignore_invalid_codecs(self):
|
||||
utf8_data = u"Räksmörgås".encode("utf-8")
|
||||
for bad_encoding in ['.utf8', '...', 'utF---16.!']:
|
||||
dammit = UnicodeDammit(utf8_data, [bad_encoding])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
|
||||
def test_detect_html5_style_meta_tag(self):
|
||||
|
||||
for data in (
|
||||
b'<html><meta charset="euc-jp" /></html>',
|
||||
b"<html><meta charset='euc-jp' /></html>",
|
||||
b"<html><meta charset=euc-jp /></html>",
|
||||
b"<html><meta charset=euc-jp/></html>"):
|
||||
dammit = UnicodeDammit(data, is_html=True)
|
||||
self.assertEqual(
|
||||
"euc-jp", dammit.original_encoding)
|
||||
|
||||
def test_last_ditch_entity_replacement(self):
|
||||
# This is a UTF-8 document that contains bytestrings
|
||||
# completely incompatible with UTF-8 (ie. encoded with some other
|
||||
# encoding).
|
||||
#
|
||||
# Since there is no consistent encoding for the document,
|
||||
# Unicode, Dammit will eventually encode the document as UTF-8
|
||||
# and encode the incompatible characters as REPLACEMENT
|
||||
# CHARACTER.
|
||||
#
|
||||
# If chardet is installed, it will detect that the document
|
||||
# can be converted into ISO-8859-1 without errors. This happens
|
||||
# to be the wrong encoding, but it is a consistent encoding, so the
|
||||
# code we're testing here won't run.
|
||||
#
|
||||
# So we temporarily disable chardet if it's present.
|
||||
doc = b"""\357\273\277<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html><b>\330\250\330\252\330\261</b>
|
||||
<i>\310\322\321\220\312\321\355\344</i></html>"""
|
||||
chardet = bs4.dammit.chardet_dammit
|
||||
logging.disable(logging.WARNING)
|
||||
try:
|
||||
def noop(str):
|
||||
return None
|
||||
bs4.dammit.chardet_dammit = noop
|
||||
dammit = UnicodeDammit(doc)
|
||||
self.assertEqual(True, dammit.contains_replacement_characters)
|
||||
self.assertTrue(u"\ufffd" in dammit.unicode_markup)
|
||||
|
||||
soup = BeautifulSoup(doc, "html.parser")
|
||||
self.assertTrue(soup.contains_replacement_characters)
|
||||
finally:
|
||||
logging.disable(logging.NOTSET)
|
||||
bs4.dammit.chardet_dammit = chardet
|
||||
|
||||
def test_byte_order_mark_removed(self):
|
||||
# A document written in UTF-16LE will have its byte order marker stripped.
|
||||
data = b'\xff\xfe<\x00a\x00>\x00\xe1\x00\xe9\x00<\x00/\x00a\x00>\x00'
|
||||
dammit = UnicodeDammit(data)
|
||||
self.assertEqual(u"<a>áé</a>", dammit.unicode_markup)
|
||||
self.assertEqual("utf-16le", dammit.original_encoding)
|
||||
|
||||
def test_detwingle(self):
|
||||
# Here's a UTF8 document.
|
||||
utf8 = (u"\N{SNOWMAN}" * 3).encode("utf8")
|
||||
|
||||
# Here's a Windows-1252 document.
|
||||
windows_1252 = (
|
||||
u"\N{LEFT DOUBLE QUOTATION MARK}Hi, I like Windows!"
|
||||
u"\N{RIGHT DOUBLE QUOTATION MARK}").encode("windows_1252")
|
||||
|
||||
# Through some unholy alchemy, they've been stuck together.
|
||||
doc = utf8 + windows_1252 + utf8
|
||||
|
||||
# The document can't be turned into UTF-8:
|
||||
self.assertRaises(UnicodeDecodeError, doc.decode, "utf8")
|
||||
|
||||
# Unicode, Dammit thinks the whole document is Windows-1252,
|
||||
# and decodes it into "☃☃☃“Hi, I like Windows!”☃☃☃"
|
||||
|
||||
# But if we run it through fix_embedded_windows_1252, it's fixed:
|
||||
|
||||
fixed = UnicodeDammit.detwingle(doc)
|
||||
self.assertEqual(
|
||||
u"☃☃☃“Hi, I like Windows!”☃☃☃", fixed.decode("utf8"))
|
||||
|
||||
def test_detwingle_ignores_multibyte_characters(self):
|
||||
# Each of these characters has a UTF-8 representation ending
|
||||
# in \x93. \x93 is a smart quote if interpreted as
|
||||
# Windows-1252. But our code knows to skip over multibyte
|
||||
# UTF-8 characters, so they'll survive the process unscathed.
|
||||
for tricky_unicode_char in (
|
||||
u"\N{LATIN SMALL LIGATURE OE}", # 2-byte char '\xc5\x93'
|
||||
u"\N{LATIN SUBSCRIPT SMALL LETTER X}", # 3-byte char '\xe2\x82\x93'
|
||||
u"\xf0\x90\x90\x93", # This is a CJK character, not sure which one.
|
||||
):
|
||||
input = tricky_unicode_char.encode("utf8")
|
||||
self.assertTrue(input.endswith(b'\x93'))
|
||||
output = UnicodeDammit.detwingle(input)
|
||||
self.assertEqual(output, input)
|
||||
|
||||
class TestNamedspacedAttribute(SoupTest):
|
||||
|
||||
def test_name_may_be_none(self):
|
||||
a = NamespacedAttribute("xmlns", None)
|
||||
self.assertEqual(a, "xmlns")
|
||||
|
||||
def test_attribute_is_equivalent_to_colon_separated_string(self):
|
||||
a = NamespacedAttribute("a", "b")
|
||||
self.assertEqual("a:b", a)
|
||||
|
||||
def test_attributes_are_equivalent_if_prefix_and_name_identical(self):
|
||||
a = NamespacedAttribute("a", "b", "c")
|
||||
b = NamespacedAttribute("a", "b", "c")
|
||||
self.assertEqual(a, b)
|
||||
|
||||
# The actual namespace is not considered.
|
||||
c = NamespacedAttribute("a", "b", None)
|
||||
self.assertEqual(a, c)
|
||||
|
||||
# But name and prefix are important.
|
||||
d = NamespacedAttribute("a", "z", "c")
|
||||
self.assertNotEqual(a, d)
|
||||
|
||||
e = NamespacedAttribute("z", "b", "c")
|
||||
self.assertNotEqual(a, e)
|
||||
|
||||
|
||||
class TestAttributeValueWithCharsetSubstitution(unittest.TestCase):
|
||||
|
||||
def test_content_meta_attribute_value(self):
|
||||
value = CharsetMetaAttributeValue("euc-jp")
|
||||
self.assertEqual("euc-jp", value)
|
||||
self.assertEqual("euc-jp", value.original_value)
|
||||
self.assertEqual("utf8", value.encode("utf8"))
|
||||
|
||||
|
||||
def test_content_meta_attribute_value(self):
|
||||
value = ContentMetaAttributeValue("text/html; charset=euc-jp")
|
||||
self.assertEqual("text/html; charset=euc-jp", value)
|
||||
self.assertEqual("text/html; charset=euc-jp", value.original_value)
|
||||
self.assertEqual("text/html; charset=utf8", value.encode("utf8"))
|
|
@ -90,7 +90,7 @@ class FileCache(BaseCache):
|
|||
|
||||
def delete(self, key):
|
||||
name = self._fn(key)
|
||||
if not self.forever:
|
||||
if not self.forever and os.path.exists(name):
|
||||
os.remove(name)
|
||||
|
||||
|
||||
|
|
1
lib/configobj/_version.py
Normal file
|
@ -0,0 +1 @@
|
|||
__version__ = '5.1.0'
|
1471
lib/configobj/validate.py
Normal file
|
@ -1,10 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2003-2010 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
"""
|
||||
__author__ = "Tomi Pieviläinen <tomi.pievilainen@iki.fi>"
|
||||
__license__ = "Simplified BSD"
|
||||
__version__ = "2.2"
|
||||
__version__ = "2.4.2"
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2003-2007 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
This module offers a generic easter computing method for any given year, using
|
||||
Western, Orthodox or Julian algorithms.
|
||||
"""
|
||||
__license__ = "Simplified BSD"
|
||||
|
||||
import datetime
|
||||
|
||||
|
@ -14,6 +12,7 @@ EASTER_JULIAN = 1
|
|||
EASTER_ORTHODOX = 2
|
||||
EASTER_WESTERN = 3
|
||||
|
||||
|
||||
def easter(year, method=EASTER_WESTERN):
|
||||
"""
|
||||
This method was ported from the work done by GM Arts,
|
||||
|
@ -88,4 +87,3 @@ def easter(year, method=EASTER_WESTERN):
|
|||
d = 1 + (p + 27 + (p + 6)//40) % 31
|
||||
m = 3 + (p + 26)//30
|
||||
return datetime.date(int(y), int(m), int(d))
|
||||
|
||||
|
|
|
@ -1,50 +1,70 @@
|
|||
# -*- coding:iso-8859-1 -*-
|
||||
"""
|
||||
Copyright (c) 2003-2007 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
This module offers a generic date/time string parser which is able to parse
|
||||
most known formats to represent a date and/or time.
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
This module attempts to be forgiving with regards to unlikely input formats,
|
||||
returning a datetime object even for dates which are ambiguous. If an element of
|
||||
a date/time stamp is omitted, the following rules are applied:
|
||||
- If AM or PM is left unspecified, a 24-hour clock is assumed, however, an hour
|
||||
on a 12-hour clock (`0 <= hour <= 12`) *must* be specified if AM or PM is
|
||||
specified.
|
||||
- If a time zone is omitted, it is assumed to be UTC.
|
||||
|
||||
If any other elements are missing, they are taken from the `datetime.datetime`
|
||||
object passed to the parameter `default`. If this results in a day number
|
||||
exceeding the valid number of days per month, one can fall back to the last
|
||||
day of the month by setting `fallback_on_invalid_day` parameter to `True`.
|
||||
|
||||
Also provided is the `smart_defaults` option, which attempts to fill in the
|
||||
missing elements from context. If specified, the logic is:
|
||||
- If the omitted element is smaller than the largest specified element, select
|
||||
the *earliest* time matching the specified conditions; so `"June 2010"` is
|
||||
interpreted as `June 1, 2010 0:00:00`) and the (somewhat strange)
|
||||
`"Feb 1997 3:15 PM"` is interpreted as `February 1, 1997 15:15:00`.
|
||||
- If the element is larger than the largest specified element, select the
|
||||
*most recent* time matching the specified conditions (e.g parsing `"May"`
|
||||
in June 2015 returns the date May 1st, 2015, whereas parsing it in April 2015
|
||||
returns May 1st 2014). If using the `date_in_future` flag, this logic is
|
||||
inverted, and instead the *next* time matching the specified conditions is
|
||||
returned.
|
||||
|
||||
Additional resources about date/time string formats can be found below:
|
||||
|
||||
- `A summary of the international standard date and time notation
|
||||
<http://www.cl.cam.ac.uk/~mgk25/iso-time.html>`_
|
||||
- `W3C Date and Time Formats <http://www.w3.org/TR/NOTE-datetime>`_
|
||||
- `Time Formats (Planetary Rings Node) <http://pds-rings.seti.org/tools/time_formats.html>`_
|
||||
- `CPAN ParseDate module
|
||||
<http://search.cpan.org/~muir/Time-modules-2013.0912/lib/Time/ParseDate.pm>`_
|
||||
- `Java SimpleDateFormat Class
|
||||
<https://docs.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html>`_
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
__license__ = "Simplified BSD"
|
||||
|
||||
|
||||
import datetime
|
||||
import string
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
import collections
|
||||
|
||||
try:
|
||||
from io import StringIO
|
||||
except ImportError:
|
||||
from io import StringIO
|
||||
from calendar import monthrange, isleap
|
||||
|
||||
from six import text_type, binary_type, integer_types
|
||||
|
||||
from . import relativedelta
|
||||
from . import tz
|
||||
|
||||
|
||||
__all__ = ["parse", "parserinfo"]
|
||||
|
||||
|
||||
# Some pointers:
|
||||
#
|
||||
# http://www.cl.cam.ac.uk/~mgk25/iso-time.html
|
||||
# http://www.iso.ch/iso/en/prods-services/popstds/datesandtime.html
|
||||
# http://www.w3.org/TR/NOTE-datetime
|
||||
# http://ringmaster.arc.nasa.gov/tools/time_formats.html
|
||||
# http://search.cpan.org/author/MUIR/Time-modules-2003.0211/lib/Time/ParseDate.pm
|
||||
# http://stein.cshl.org/jade/distrib/docs/java.text.SimpleDateFormat.html
|
||||
|
||||
|
||||
class _timelex(object):
|
||||
|
||||
def __init__(self, instream):
|
||||
if isinstance(instream, binary_type):
|
||||
instream = instream.decode()
|
||||
|
||||
if isinstance(instream, text_type):
|
||||
instream = StringIO(instream)
|
||||
|
||||
self.instream = instream
|
||||
self.wordchars = ('abcdfeghijklmnopqrstuvwxyz'
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ_'
|
||||
|
@ -57,25 +77,47 @@ class _timelex(object):
|
|||
self.eof = False
|
||||
|
||||
def get_token(self):
|
||||
"""
|
||||
This function breaks the time string into lexical units (tokens), which
|
||||
can be parsed by the parser. Lexical units are demarcated by changes in
|
||||
the character set, so any continuous string of letters is considered one
|
||||
unit, any continuous string of numbers is considered one unit.
|
||||
|
||||
The main complication arises from the fact that dots ('.') can be used
|
||||
both as separators (e.g. "Sep.20.2009") or decimal points (e.g.
|
||||
"4:30:21.447"). As such, it is necessary to read the full context of
|
||||
any dot-separated strings before breaking it into tokens; as such, this
|
||||
function maintains a "token stack", for when the ambiguous context
|
||||
demands that multiple tokens be parsed at once.
|
||||
"""
|
||||
if self.tokenstack:
|
||||
return self.tokenstack.pop(0)
|
||||
|
||||
seenletters = False
|
||||
token = None
|
||||
state = None
|
||||
wordchars = self.wordchars
|
||||
numchars = self.numchars
|
||||
whitespace = self.whitespace
|
||||
|
||||
while not self.eof:
|
||||
# We only realize that we've reached the end of a token when we find
|
||||
# a character that's not part of the current token - since that
|
||||
# character may be part of the next token, it's stored in the
|
||||
# charstack.
|
||||
if self.charstack:
|
||||
nextchar = self.charstack.pop(0)
|
||||
else:
|
||||
nextchar = self.instream.read(1)
|
||||
while nextchar == '\x00':
|
||||
nextchar = self.instream.read(1)
|
||||
|
||||
if not nextchar:
|
||||
self.eof = True
|
||||
break
|
||||
elif not state:
|
||||
# First character of the token - determines if we're starting
|
||||
# to parse a word, a number or something else.
|
||||
token = nextchar
|
||||
if nextchar in wordchars:
|
||||
state = 'a'
|
||||
|
@ -87,6 +129,8 @@ class _timelex(object):
|
|||
else:
|
||||
break # emit token
|
||||
elif state == 'a':
|
||||
# If we've already started reading a word, we keep reading
|
||||
# letters until we find something that's not part of a word.
|
||||
seenletters = True
|
||||
if nextchar in wordchars:
|
||||
token += nextchar
|
||||
|
@ -97,6 +141,8 @@ class _timelex(object):
|
|||
self.charstack.append(nextchar)
|
||||
break # emit token
|
||||
elif state == '0':
|
||||
# If we've already started reading a number, we keep reading
|
||||
# numbers until we find something that doesn't fit.
|
||||
if nextchar in numchars:
|
||||
token += nextchar
|
||||
elif nextchar == '.':
|
||||
|
@ -106,6 +152,8 @@ class _timelex(object):
|
|||
self.charstack.append(nextchar)
|
||||
break # emit token
|
||||
elif state == 'a.':
|
||||
# If we've seen some letters and a dot separator, continue
|
||||
# parsing, and the tokens will be broken up later.
|
||||
seenletters = True
|
||||
if nextchar == '.' or nextchar in wordchars:
|
||||
token += nextchar
|
||||
|
@ -116,6 +164,8 @@ class _timelex(object):
|
|||
self.charstack.append(nextchar)
|
||||
break # emit token
|
||||
elif state == '0.':
|
||||
# If we've seen at least one dot separator, keep going, we'll
|
||||
# break up the tokens later.
|
||||
if nextchar == '.' or nextchar in numchars:
|
||||
token += nextchar
|
||||
elif nextchar in wordchars and token[-1] == '.':
|
||||
|
@ -124,14 +174,16 @@ class _timelex(object):
|
|||
else:
|
||||
self.charstack.append(nextchar)
|
||||
break # emit token
|
||||
if (state in ('a.', '0.') and
|
||||
(seenletters or token.count('.') > 1 or token[-1] == '.')):
|
||||
|
||||
if (state in ('a.', '0.') and (seenletters or token.count('.') > 1 or
|
||||
token[-1] == '.')):
|
||||
l = token.split('.')
|
||||
token = l[0]
|
||||
for tok in l[1:]:
|
||||
self.tokenstack.append('.')
|
||||
if tok:
|
||||
self.tokenstack.append(tok)
|
||||
|
||||
return token
|
||||
|
||||
def __iter__(self):
|
||||
|
@ -141,6 +193,7 @@ class _timelex(object):
|
|||
token = self.get_token()
|
||||
if token is None:
|
||||
raise StopIteration
|
||||
|
||||
return token
|
||||
|
||||
def next(self):
|
||||
|
@ -170,6 +223,22 @@ class _resultbase(object):
|
|||
|
||||
|
||||
class parserinfo(object):
|
||||
"""
|
||||
Class which handles what inputs are accepted. Subclass this to customize the
|
||||
language and acceptable values for each parameter.
|
||||
|
||||
:param dayfirst:
|
||||
Whether to interpret the first value in an ambiguous 3-integer date
|
||||
(e.g. 01/05/09) as the day (`True`) or month (`False`). If
|
||||
`yearfirst` is set to `True`, this distinguishes between YDM and
|
||||
YMD. Default is `False`.
|
||||
|
||||
:param yearfirst:
|
||||
Whether to interpret the first value in an ambiguous 3-integer date
|
||||
(e.g. 01/05/09) as the year. If `True`, the first number is taken to
|
||||
be the year, otherwise the last number is taken to be the year.
|
||||
Default is `False`.
|
||||
"""
|
||||
|
||||
# m from a.m/p.m, t from ISO T separator
|
||||
JUMP = [" ", ".", ",", ";", "-", "/", "'",
|
||||
|
@ -204,7 +273,7 @@ class parserinfo(object):
|
|||
PERTAIN = ["of"]
|
||||
TZOFFSET = {}
|
||||
|
||||
def __init__(self, dayfirst=False, yearfirst=False):
|
||||
def __init__(self, dayfirst=False, yearfirst=False, smart_defaults=False):
|
||||
self._jump = self._convert(self.JUMP)
|
||||
self._weekdays = self._convert(self.WEEKDAYS)
|
||||
self._months = self._convert(self.MONTHS)
|
||||
|
@ -215,14 +284,14 @@ class parserinfo(object):
|
|||
|
||||
self.dayfirst = dayfirst
|
||||
self.yearfirst = yearfirst
|
||||
self.smart_defaults = smart_defaults
|
||||
|
||||
self._year = time.localtime().tm_year
|
||||
self._century = self._year // 100*100
|
||||
|
||||
def _convert(self, lst):
|
||||
dct = {}
|
||||
for i in range(len(lst)):
|
||||
v = lst[i]
|
||||
for i, v in enumerate(lst):
|
||||
if isinstance(v, tuple):
|
||||
for v in v:
|
||||
dct[v.lower()] = i
|
||||
|
@ -270,6 +339,7 @@ class parserinfo(object):
|
|||
def tzoffset(self, name):
|
||||
if name in self._utczone:
|
||||
return 0
|
||||
|
||||
return self.TZOFFSET.get(name)
|
||||
|
||||
def convertyear(self, year):
|
||||
|
@ -286,6 +356,7 @@ class parserinfo(object):
|
|||
# move to info
|
||||
if res.year is not None:
|
||||
res.year = self.convertyear(res.year)
|
||||
|
||||
if res.tzoffset == 0 and not res.tzname or res.tzname == 'Z':
|
||||
res.tzname = "UTC"
|
||||
res.tzoffset = 0
|
||||
|
@ -295,37 +366,192 @@ class parserinfo(object):
|
|||
|
||||
|
||||
class parser(object):
|
||||
|
||||
def __init__(self, info=None):
|
||||
self.info = info or parserinfo()
|
||||
|
||||
def parse(self, timestr, default=None,
|
||||
ignoretz=False, tzinfos=None,
|
||||
**kwargs):
|
||||
if not default:
|
||||
def parse(self, timestr, default=None, ignoretz=False, tzinfos=None,
|
||||
smart_defaults=None, date_in_future=False,
|
||||
fallback_on_invalid_day=None, **kwargs):
|
||||
"""
|
||||
Parse the date/time string into a datetime object.
|
||||
|
||||
:param timestr:
|
||||
Any date/time string using the supported formats.
|
||||
|
||||
:param default:
|
||||
The default datetime object, if this is a datetime object and not
|
||||
`None`, elements specified in `timestr` replace elements in the
|
||||
default object, unless `smart_defaults` is set to `True`, in which
|
||||
case to the extent necessary, timestamps are calculated relative to
|
||||
this date.
|
||||
|
||||
:param smart_defaults:
|
||||
If using smart defaults, the `default` parameter is treated as the
|
||||
effective parsing date/time, and the context of the datetime string
|
||||
is determined relative to `default`. If `None`, this parameter is
|
||||
inherited from the :class:`parserinfo` object.
|
||||
|
||||
:param date_in_future:
|
||||
If `smart_defaults` is `True`, the parser assumes by default that
|
||||
the timestamp refers to a date in the past, and will return the
|
||||
beginning of the most recent timespan which matches the time string
|
||||
(e.g. if `default` is March 3rd, 2013, "Feb" parses to
|
||||
"Feb 1, 2013" and "May 3" parses to May 3rd, 2012). Setting this
|
||||
parameter to `True` inverts this assumption, and returns the
|
||||
beginning of the *next* matching timespan.
|
||||
|
||||
:param fallback_on_invalid_day:
|
||||
If specified `True`, an otherwise invalid date such as "Feb 30" or
|
||||
"June 32" falls back to the last day of the month. If specified as
|
||||
"False", the parser is strict about parsing otherwise valid dates
|
||||
that would turn up as invalid because of the fallback rules (e.g.
|
||||
"Feb 2010" run with a default of January 30, 2010 and `smartparser`
|
||||
set to `False` would would throw an error, rather than falling
|
||||
back to the end of February). If `None` or unspecified, the date
|
||||
falls back to the most recent valid date only if the invalid date
|
||||
is created as a result of an unspecified day in the time string.
|
||||
|
||||
:param ignoretz:
|
||||
Whether or not to ignore the time zone.
|
||||
|
||||
:param tzinfos:
|
||||
A time zone, to be applied to the date, if `ignoretz` is `True`.
|
||||
This can be either a subclass of `tzinfo`, a time zone string or an
|
||||
integer offset.
|
||||
|
||||
:param **kwargs:
|
||||
Keyword arguments as passed to `_parse()`.
|
||||
|
||||
:return:
|
||||
Returns a `datetime.datetime` object or, if the `fuzzy_with_tokens`
|
||||
option is `True`, returns a tuple, the first element being a
|
||||
`datetime.datetime` object, the second a tuple containing the
|
||||
fuzzy tokens.
|
||||
|
||||
:raises ValueError:
|
||||
Raised for invalid or unknown string format, if the provided
|
||||
`tzinfo` is not in a valid format, or if an invalid date would
|
||||
be created.
|
||||
|
||||
:raises OverFlowError:
|
||||
Raised if the parsed date exceeds the largest valid C integer on
|
||||
your system.
|
||||
"""
|
||||
|
||||
if smart_defaults is None:
|
||||
smart_defaults = self.info.smart_defaults
|
||||
|
||||
if default is None:
|
||||
effective_dt = datetime.datetime.now()
|
||||
default = datetime.datetime.now().replace(hour=0, minute=0,
|
||||
second=0, microsecond=0)
|
||||
else:
|
||||
effective_dt = default
|
||||
|
||||
|
||||
if kwargs.get('fuzzy_with_tokens', False):
|
||||
res, skipped_tokens = self._parse(timestr, **kwargs)
|
||||
else:
|
||||
res = self._parse(timestr, **kwargs)
|
||||
|
||||
if res is None:
|
||||
raise ValueError("unknown string format")
|
||||
raise ValueError("Unknown string format")
|
||||
|
||||
repl = {}
|
||||
for attr in ["year", "month", "day", "hour",
|
||||
"minute", "second", "microsecond"]:
|
||||
for attr in ("year", "month", "day", "hour",
|
||||
"minute", "second", "microsecond"):
|
||||
value = getattr(res, attr)
|
||||
if value is not None:
|
||||
repl[attr] = value
|
||||
|
||||
# Choose the correct fallback position if requested by the
|
||||
# `smart_defaults` parameter.
|
||||
if smart_defaults:
|
||||
# Determine if it refers to this year, last year or next year
|
||||
if res.year is None:
|
||||
if res.month is not None:
|
||||
# Explicitly deal with leap year problems
|
||||
if res.month == 2 and (res.day is not None and
|
||||
res.day == 29):
|
||||
|
||||
ly_offset = 4 if date_in_future else -4
|
||||
next_year = 4 * (default.year // 4)
|
||||
|
||||
if date_in_future:
|
||||
next_year += ly_offset
|
||||
|
||||
if not isleap(next_year):
|
||||
next_year += ly_offset
|
||||
|
||||
if not isleap(default.year):
|
||||
default = default.replace(year=next_year)
|
||||
elif date_in_future:
|
||||
next_year = default.year + 1
|
||||
else:
|
||||
next_year = default.year - 1
|
||||
|
||||
if ((res.month == default.month and res.day is not None and
|
||||
((res.day < default.day and date_in_future) or
|
||||
(res.day > default.day and not date_in_future))) or
|
||||
((res.month < default.month and date_in_future) or
|
||||
(res.month > default.month and not date_in_future))):
|
||||
|
||||
default = default.replace(year=next_year)
|
||||
|
||||
# Select a proper month
|
||||
if res.month is None:
|
||||
if res.year is not None:
|
||||
default = default.replace(month=1)
|
||||
|
||||
# I'm not sure if this is even possible.
|
||||
if res.day is not None:
|
||||
if res.day < default.day and date_in_future:
|
||||
default += datetime.timedelta(months=1)
|
||||
elif res.day > default.day and not date_in_future:
|
||||
default -= datetime.timedelta(months=1)
|
||||
|
||||
if res.day is None:
|
||||
# Determine if it's today, tomorrow or yesterday.
|
||||
if res.year is None and res.month is None:
|
||||
t_repl = {}
|
||||
for key, val in repl.iteritems():
|
||||
if key in ('hour', 'minute', 'second', 'microsecond'):
|
||||
t_repl[key] = val
|
||||
|
||||
stime = effective_dt.replace(**t_repl)
|
||||
|
||||
if stime < effective_dt and date_in_future:
|
||||
default += datetime.timedelta(days=1)
|
||||
elif stime > effective_dt and not date_in_future:
|
||||
default -= datetime.timedelta(days=1)
|
||||
else:
|
||||
# Otherwise it's the beginning of the month
|
||||
default = default.replace(day=1)
|
||||
|
||||
if fallback_on_invalid_day or (fallback_on_invalid_day is None and
|
||||
'day' not in repl):
|
||||
# If the default day exceeds the last day of the month, fall back to
|
||||
# the end of the month.
|
||||
cyear = default.year if res.year is None else res.year
|
||||
cmonth = default.month if res.month is None else res.month
|
||||
cday = default.day if res.day is None else res.day
|
||||
|
||||
if cday > monthrange(cyear, cmonth)[1]:
|
||||
repl['day'] = monthrange(cyear, cmonth)[1]
|
||||
|
||||
ret = default.replace(**repl)
|
||||
|
||||
if res.weekday is not None and not res.day:
|
||||
ret = ret+relativedelta.relativedelta(weekday=res.weekday)
|
||||
|
||||
if not ignoretz:
|
||||
if isinstance(tzinfos, collections.Callable) or tzinfos and res.tzname in tzinfos:
|
||||
if (isinstance(tzinfos, collections.Callable) or
|
||||
tzinfos and res.tzname in tzinfos):
|
||||
|
||||
if isinstance(tzinfos, collections.Callable):
|
||||
tzdata = tzinfos(res.tzname, res.tzoffset)
|
||||
else:
|
||||
tzdata = tzinfos.get(res.tzname)
|
||||
|
||||
if isinstance(tzdata, datetime.tzinfo):
|
||||
tzinfo = tzdata
|
||||
elif isinstance(tzdata, text_type):
|
||||
|
@ -333,8 +559,8 @@ class parser(object):
|
|||
elif isinstance(tzdata, integer_types):
|
||||
tzinfo = tz.tzoffset(res.tzname, tzdata)
|
||||
else:
|
||||
raise ValueError("offset must be tzinfo subclass, " \
|
||||
"tz string, or int offset")
|
||||
raise ValueError("Offset must be tzinfo subclass, "
|
||||
"tz string, or int offset.")
|
||||
ret = ret.replace(tzinfo=tzinfo)
|
||||
elif res.tzname and res.tzname in time.tzname:
|
||||
ret = ret.replace(tzinfo=tz.tzlocal())
|
||||
|
@ -343,28 +569,64 @@ class parser(object):
|
|||
elif res.tzoffset:
|
||||
ret = ret.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset))
|
||||
|
||||
if skipped_tokens:
|
||||
if kwargs.get('fuzzy_with_tokens', False):
|
||||
return ret, skipped_tokens
|
||||
|
||||
else:
|
||||
return ret
|
||||
|
||||
class _result(_resultbase):
|
||||
__slots__ = ["year", "month", "day", "weekday",
|
||||
"hour", "minute", "second", "microsecond",
|
||||
"tzname", "tzoffset"]
|
||||
"tzname", "tzoffset", "ampm"]
|
||||
|
||||
def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False, fuzzy_with_tokens=False):
|
||||
def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False,
|
||||
fuzzy_with_tokens=False):
|
||||
"""
|
||||
Private method which performs the heavy lifting of parsing, called from
|
||||
`parse()`, which passes on its `kwargs` to this function.
|
||||
|
||||
:param timestr:
|
||||
The string to parse.
|
||||
|
||||
:param dayfirst:
|
||||
Whether to interpret the first value in an ambiguous 3-integer date
|
||||
(e.g. 01/05/09) as the day (`True`) or month (`False`). If
|
||||
`yearfirst` is set to `True`, this distinguishes between YDM and
|
||||
YMD. If set to `None`, this value is retrieved from the current
|
||||
`parserinfo` object (which itself defaults to `False`).
|
||||
|
||||
:param yearfirst:
|
||||
Whether to interpret the first value in an ambiguous 3-integer date
|
||||
(e.g. 01/05/09) as the year. If `True`, the first number is taken to
|
||||
be the year, otherwise the last number is taken to be the year. If
|
||||
this is set to `None`, the value is retrieved from the current
|
||||
`parserinfo` object (which itself defaults to `False`).
|
||||
|
||||
:param fuzzy:
|
||||
Whether to allow fuzzy parsing, allowing for string like "Today is
|
||||
January 1, 2047 at 8:21:00AM".
|
||||
|
||||
:param fuzzy_with_tokens:
|
||||
If `True`, `fuzzy` is automatically set to True, and the parser will
|
||||
return a tuple where the first element is the parsed
|
||||
`datetime.datetime` datetimestamp and the second element is a tuple
|
||||
containing the portions of the string which were ignored, e.g.
|
||||
"Today is January 1, 2047 at 8:21:00AM" should return
|
||||
`(datetime.datetime(2011, 1, 1, 8, 21), (u'Today is ', u' ', u'at '))`
|
||||
"""
|
||||
if fuzzy_with_tokens:
|
||||
fuzzy = True
|
||||
|
||||
info = self.info
|
||||
|
||||
if dayfirst is None:
|
||||
dayfirst = info.dayfirst
|
||||
|
||||
if yearfirst is None:
|
||||
yearfirst = info.yearfirst
|
||||
res = self._result()
|
||||
l = _timelex.split(timestr)
|
||||
|
||||
res = self._result()
|
||||
l = _timelex.split(timestr) # Splits the timestr into tokens
|
||||
|
||||
# keep up with the last token skipped so we can recombine
|
||||
# consecutively skipped tokens (-2 for when i begins at 0).
|
||||
|
@ -372,7 +634,6 @@ class parser(object):
|
|||
skipped_tokens = list()
|
||||
|
||||
try:
|
||||
|
||||
# year/month/day list
|
||||
ymd = []
|
||||
|
||||
|
@ -394,17 +655,21 @@ class parser(object):
|
|||
# Token is a number
|
||||
len_li = len(l[i])
|
||||
i += 1
|
||||
|
||||
if (len(ymd) == 3 and len_li in (2, 4)
|
||||
and (i >= len_l or (l[i] != ':' and
|
||||
and res.hour is None and (i >= len_l or (l[i] != ':' and
|
||||
info.hms(l[i]) is None))):
|
||||
# 19990101T23[59]
|
||||
s = l[i-1]
|
||||
res.hour = int(s[:2])
|
||||
|
||||
if len_li == 4:
|
||||
res.minute = int(s[2:])
|
||||
|
||||
elif len_li == 6 or (len_li > 6 and l[i-1].find('.') == 6):
|
||||
# YYMMDD or HHMMSS[.ss]
|
||||
s = l[i-1]
|
||||
|
||||
if not ymd and l[i-1].find('.') == -1:
|
||||
ymd.append(info.convertyear(int(s[:2])))
|
||||
ymd.append(int(s[2:4]))
|
||||
|
@ -414,12 +679,14 @@ class parser(object):
|
|||
res.hour = int(s[:2])
|
||||
res.minute = int(s[2:4])
|
||||
res.second, res.microsecond = _parsems(s[4:])
|
||||
|
||||
elif len_li == 8:
|
||||
# YYYYMMDD
|
||||
s = l[i-1]
|
||||
ymd.append(int(s[:4]))
|
||||
ymd.append(int(s[4:6]))
|
||||
ymd.append(int(s[6:]))
|
||||
|
||||
elif len_li in (12, 14):
|
||||
# YYYYMMDDhhmm[ss]
|
||||
s = l[i-1]
|
||||
|
@ -428,30 +695,42 @@ class parser(object):
|
|||
ymd.append(int(s[6:8]))
|
||||
res.hour = int(s[8:10])
|
||||
res.minute = int(s[10:12])
|
||||
|
||||
if len_li == 14:
|
||||
res.second = int(s[12:])
|
||||
|
||||
elif ((i < len_l and info.hms(l[i]) is not None) or
|
||||
(i+1 < len_l and l[i] == ' ' and
|
||||
info.hms(l[i+1]) is not None)):
|
||||
|
||||
# HH[ ]h or MM[ ]m or SS[.ss][ ]s
|
||||
if l[i] == ' ':
|
||||
i += 1
|
||||
|
||||
idx = info.hms(l[i])
|
||||
|
||||
while True:
|
||||
if idx == 0:
|
||||
res.hour = int(value)
|
||||
|
||||
if value % 1:
|
||||
res.minute = int(60*(value % 1))
|
||||
|
||||
elif idx == 1:
|
||||
res.minute = int(value)
|
||||
|
||||
if value % 1:
|
||||
res.second = int(60*(value % 1))
|
||||
|
||||
elif idx == 2:
|
||||
res.second, res.microsecond = \
|
||||
_parsems(value_repr)
|
||||
|
||||
i += 1
|
||||
|
||||
if i >= len_l or idx == 2:
|
||||
break
|
||||
|
||||
# 12h00
|
||||
try:
|
||||
value_repr = l[i]
|
||||
|
@ -461,37 +740,49 @@ class parser(object):
|
|||
else:
|
||||
i += 1
|
||||
idx += 1
|
||||
|
||||
if i < len_l:
|
||||
newidx = info.hms(l[i])
|
||||
|
||||
if newidx is not None:
|
||||
idx = newidx
|
||||
elif i == len_l and l[i-2] == ' ' and info.hms(l[i-3]) is not None:
|
||||
|
||||
elif (i == len_l and l[i-2] == ' ' and
|
||||
info.hms(l[i-3]) is not None):
|
||||
# X h MM or X m SS
|
||||
idx = info.hms(l[i-3]) + 1
|
||||
|
||||
if idx == 1:
|
||||
res.minute = int(value)
|
||||
|
||||
if value % 1:
|
||||
res.second = int(60*(value % 1))
|
||||
elif idx == 2:
|
||||
res.second, res.microsecond = \
|
||||
_parsems(value_repr)
|
||||
i += 1
|
||||
|
||||
elif i+1 < len_l and l[i] == ':':
|
||||
# HH:MM[:SS[.ss]]
|
||||
res.hour = int(value)
|
||||
i += 1
|
||||
value = float(l[i])
|
||||
res.minute = int(value)
|
||||
|
||||
if value % 1:
|
||||
res.second = int(60*(value % 1))
|
||||
|
||||
i += 1
|
||||
|
||||
if i < len_l and l[i] == ':':
|
||||
res.second, res.microsecond = _parsems(l[i+1])
|
||||
i += 2
|
||||
|
||||
elif i < len_l and l[i] in ('-', '/', '.'):
|
||||
sep = l[i]
|
||||
ymd.append(int(value))
|
||||
i += 1
|
||||
|
||||
if i < len_l and not info.jump(l[i]):
|
||||
try:
|
||||
# 01-01[-01]
|
||||
|
@ -499,45 +790,55 @@ class parser(object):
|
|||
except ValueError:
|
||||
# 01-Jan[-01]
|
||||
value = info.month(l[i])
|
||||
|
||||
if value is not None:
|
||||
ymd.append(value)
|
||||
assert mstridx == -1
|
||||
mstridx = len(ymd)-1
|
||||
else:
|
||||
return None
|
||||
|
||||
i += 1
|
||||
|
||||
if i < len_l and l[i] == sep:
|
||||
# We have three members
|
||||
i += 1
|
||||
value = info.month(l[i])
|
||||
|
||||
if value is not None:
|
||||
ymd.append(value)
|
||||
mstridx = len(ymd)-1
|
||||
assert mstridx == -1
|
||||
else:
|
||||
ymd.append(int(l[i]))
|
||||
|
||||
i += 1
|
||||
elif i >= len_l or info.jump(l[i]):
|
||||
if i+1 < len_l and info.ampm(l[i+1]) is not None:
|
||||
# 12 am
|
||||
res.hour = int(value)
|
||||
|
||||
if res.hour < 12 and info.ampm(l[i+1]) == 1:
|
||||
res.hour += 12
|
||||
elif res.hour == 12 and info.ampm(l[i+1]) == 0:
|
||||
res.hour = 0
|
||||
|
||||
i += 1
|
||||
else:
|
||||
# Year, month or day
|
||||
ymd.append(int(value))
|
||||
i += 1
|
||||
elif info.ampm(l[i]) is not None:
|
||||
|
||||
# 12am
|
||||
res.hour = int(value)
|
||||
|
||||
if res.hour < 12 and info.ampm(l[i]) == 1:
|
||||
res.hour += 12
|
||||
elif res.hour == 12 and info.ampm(l[i]) == 0:
|
||||
res.hour = 0
|
||||
i += 1
|
||||
|
||||
elif not fuzzy:
|
||||
return None
|
||||
else:
|
||||
|
@ -557,6 +858,7 @@ class parser(object):
|
|||
ymd.append(value)
|
||||
assert mstridx == -1
|
||||
mstridx = len(ymd)-1
|
||||
|
||||
i += 1
|
||||
if i < len_l:
|
||||
if l[i] in ('-', '/'):
|
||||
|
@ -565,11 +867,13 @@ class parser(object):
|
|||
i += 1
|
||||
ymd.append(int(l[i]))
|
||||
i += 1
|
||||
|
||||
if i < len_l and l[i] == sep:
|
||||
# Jan-01-99
|
||||
i += 1
|
||||
ymd.append(int(l[i]))
|
||||
i += 1
|
||||
|
||||
elif (i+3 < len_l and l[i] == l[i+2] == ' '
|
||||
and info.pertain(l[i+1])):
|
||||
# Jan of 01
|
||||
|
@ -588,17 +892,47 @@ class parser(object):
|
|||
# Check am/pm
|
||||
value = info.ampm(l[i])
|
||||
if value is not None:
|
||||
# For fuzzy parsing, 'a' or 'am' (both valid English words)
|
||||
# may erroneously trigger the AM/PM flag. Deal with that
|
||||
# here.
|
||||
val_is_ampm = True
|
||||
|
||||
# If there's already an AM/PM flag, this one isn't one.
|
||||
if fuzzy and res.ampm is not None:
|
||||
val_is_ampm = False
|
||||
|
||||
# If AM/PM is found and hour is not, raise a ValueError
|
||||
if res.hour is None:
|
||||
if fuzzy:
|
||||
val_is_ampm = False
|
||||
else:
|
||||
raise ValueError('No hour specified with ' +
|
||||
'AM or PM flag.')
|
||||
elif not 0 <= res.hour <= 12:
|
||||
# If AM/PM is found, it's a 12 hour clock, so raise
|
||||
# an error for invalid range
|
||||
if fuzzy:
|
||||
val_is_ampm = False
|
||||
else:
|
||||
raise ValueError('Invalid hour specified for ' +
|
||||
'12-hour clock.')
|
||||
|
||||
if val_is_ampm:
|
||||
if value == 1 and res.hour < 12:
|
||||
res.hour += 12
|
||||
elif value == 0 and res.hour == 12:
|
||||
res.hour = 0
|
||||
|
||||
res.ampm = value
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for a timezone name
|
||||
if (res.hour is not None and len(l[i]) <= 5 and
|
||||
res.tzname is None and res.tzoffset is None and
|
||||
not [x for x in l[i] if x not in string.ascii_uppercase]):
|
||||
not [x for x in l[i] if x not in
|
||||
string.ascii_uppercase]):
|
||||
res.tzname = l[i]
|
||||
res.tzoffset = info.tzoffset(res.tzname)
|
||||
i += 1
|
||||
|
@ -623,6 +957,7 @@ class parser(object):
|
|||
signal = (-1, 1)[l[i] == '+']
|
||||
i += 1
|
||||
len_li = len(l[i])
|
||||
|
||||
if len_li == 4:
|
||||
# -0300
|
||||
res.tzoffset = int(l[i][:2])*3600+int(l[i][2:])*60
|
||||
|
@ -636,6 +971,7 @@ class parser(object):
|
|||
else:
|
||||
return None
|
||||
i += 1
|
||||
|
||||
res.tzoffset *= signal
|
||||
|
||||
# Look for a timezone name between parenthesis
|
||||
|
@ -672,11 +1008,13 @@ class parser(object):
|
|||
if mstridx != -1:
|
||||
res.month = ymd[mstridx]
|
||||
del ymd[mstridx]
|
||||
|
||||
if len_ymd > 1 or mstridx == -1:
|
||||
if ymd[0] > 31:
|
||||
res.year = ymd[0]
|
||||
else:
|
||||
res.day = ymd[0]
|
||||
|
||||
elif len_ymd == 2:
|
||||
# Two members with numbers
|
||||
if ymd[0] > 31:
|
||||
|
@ -691,7 +1029,8 @@ class parser(object):
|
|||
else:
|
||||
# 01-13
|
||||
res.month, res.day = ymd
|
||||
if len_ymd == 3:
|
||||
|
||||
elif len_ymd == 3:
|
||||
# Three members
|
||||
if mstridx == 0:
|
||||
res.month, res.day, res.year = ymd
|
||||
|
@ -704,6 +1043,7 @@ class parser(object):
|
|||
# Give precendence to day-first, since
|
||||
# two-digit years is usually hand-written.
|
||||
res.day, res.month, res.year = ymd
|
||||
|
||||
elif mstridx == 2:
|
||||
# WTF!?
|
||||
if ymd[1] > 31:
|
||||
|
@ -712,6 +1052,7 @@ class parser(object):
|
|||
else:
|
||||
# 99-01-Jan
|
||||
res.year, res.day, res.month = ymd
|
||||
|
||||
else:
|
||||
if ymd[0] > 31 or \
|
||||
(yearfirst and ymd[1] <= 12 and ymd[2] <= 31):
|
||||
|
@ -732,16 +1073,66 @@ class parser(object):
|
|||
|
||||
if fuzzy_with_tokens:
|
||||
return res, tuple(skipped_tokens)
|
||||
|
||||
return res, None
|
||||
else:
|
||||
return res
|
||||
|
||||
DEFAULTPARSER = parser()
|
||||
|
||||
|
||||
def parse(timestr, parserinfo=None, **kwargs):
|
||||
# Python 2.x support: datetimes return their string presentation as
|
||||
# bytes in 2.x and unicode in 3.x, so it's reasonable to expect that
|
||||
# the parser will get both kinds. Internally we use unicode only.
|
||||
if isinstance(timestr, binary_type):
|
||||
timestr = timestr.decode()
|
||||
"""
|
||||
Parse a string in one of the supported formats, using the `parserinfo`
|
||||
parameters.
|
||||
|
||||
:param timestr:
|
||||
A string containing a date/time stamp.
|
||||
|
||||
:param parserinfo:
|
||||
A :class:`parserinfo` object containing parameters for the parser.
|
||||
If `None`, the default arguments to the `parserinfo` constructor are
|
||||
used.
|
||||
|
||||
The `**kwargs` parameter takes the following keyword arguments:
|
||||
|
||||
:param default:
|
||||
The default datetime object, if this is a datetime object and not
|
||||
`None`, elements specified in `timestr` replace elements in the
|
||||
default object.
|
||||
|
||||
:param ignoretz:
|
||||
Whether or not to ignore the time zone (boolean).
|
||||
|
||||
:param tzinfos:
|
||||
A time zone, to be applied to the date, if `ignoretz` is `True`.
|
||||
This can be either a subclass of `tzinfo`, a time zone string or an
|
||||
integer offset.
|
||||
|
||||
:param dayfirst:
|
||||
Whether to interpret the first value in an ambiguous 3-integer date
|
||||
(e.g. 01/05/09) as the day (`True`) or month (`False`). If
|
||||
`yearfirst` is set to `True`, this distinguishes between YDM and
|
||||
YMD. If set to `None`, this value is retrieved from the current
|
||||
:class:`parserinfo` object (which itself defaults to `False`).
|
||||
|
||||
:param yearfirst:
|
||||
Whether to interpret the first value in an ambiguous 3-integer date
|
||||
(e.g. 01/05/09) as the year. If `True`, the first number is taken to
|
||||
be the year, otherwise the last number is taken to be the year. If
|
||||
this is set to `None`, the value is retrieved from the current
|
||||
:class:`parserinfo` object (which itself defaults to `False`).
|
||||
|
||||
:param fuzzy:
|
||||
Whether to allow fuzzy parsing, allowing for string like "Today is
|
||||
January 1, 2047 at 8:21:00AM".
|
||||
|
||||
:param fuzzy_with_tokens:
|
||||
If `True`, `fuzzy` is automatically set to True, and the parser will
|
||||
return a tuple where the first element is the parsed
|
||||
`datetime.datetime` datetimestamp and the second element is a tuple
|
||||
containing the portions of the string which were ignored, e.g.
|
||||
"Today is January 1, 2047 at 8:21:00AM" should return
|
||||
`(datetime.datetime(2011, 1, 1, 8, 21), (u'Today is ', u' ', u'at '))`
|
||||
"""
|
||||
if parserinfo:
|
||||
return parser(parserinfo).parse(timestr, **kwargs)
|
||||
else:
|
||||
|
@ -789,8 +1180,8 @@ class _tzparser(object):
|
|||
offattr = "dstoffset"
|
||||
res.dstabbr = "".join(l[i:j])
|
||||
i = j
|
||||
if (i < len_l and
|
||||
(l[i] in ('+', '-') or l[i][0] in "0123456789")):
|
||||
if (i < len_l and (l[i] in ('+', '-') or l[i][0] in
|
||||
"0123456789")):
|
||||
if l[i] in ('+', '-'):
|
||||
# Yes, that's right. See the TZ variable
|
||||
# documentation.
|
||||
|
@ -801,8 +1192,8 @@ class _tzparser(object):
|
|||
len_li = len(l[i])
|
||||
if len_li == 4:
|
||||
# -0300
|
||||
setattr(res, offattr,
|
||||
(int(l[i][:2])*3600+int(l[i][2:])*60)*signal)
|
||||
setattr(res, offattr, (int(l[i][:2])*3600 +
|
||||
int(l[i][2:])*60)*signal)
|
||||
elif i+1 < len_l and l[i+1] == ':':
|
||||
# -03:00
|
||||
setattr(res, offattr,
|
||||
|
@ -822,7 +1213,8 @@ class _tzparser(object):
|
|||
|
||||
if i < len_l:
|
||||
for j in range(i, len_l):
|
||||
if l[j] == ';': l[j] = ','
|
||||
if l[j] == ';':
|
||||
l[j] = ','
|
||||
|
||||
assert l[i] == ','
|
||||
|
||||
|
@ -921,6 +1313,8 @@ class _tzparser(object):
|
|||
|
||||
|
||||
DEFAULTTZPARSER = _tzparser()
|
||||
|
||||
|
||||
def _parsetz(tzstr):
|
||||
return DEFAULTTZPARSER.parse(tzstr)
|
||||
|
||||
|
|
|
@ -1,11 +1,4 @@
|
|||
"""
|
||||
Copyright (c) 2003-2010 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
"""
|
||||
__license__ = "Simplified BSD"
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
import calendar
|
||||
|
||||
|
@ -13,6 +6,7 @@ from six import integer_types
|
|||
|
||||
__all__ = ["relativedelta", "MO", "TU", "WE", "TH", "FR", "SA", "SU"]
|
||||
|
||||
|
||||
class weekday(object):
|
||||
__slots__ = ["weekday", "n"]
|
||||
|
||||
|
@ -43,25 +37,35 @@ class weekday(object):
|
|||
|
||||
MO, TU, WE, TH, FR, SA, SU = weekdays = tuple([weekday(x) for x in range(7)])
|
||||
|
||||
|
||||
class relativedelta(object):
|
||||
"""
|
||||
The relativedelta type is based on the specification of the excelent
|
||||
work done by M.-A. Lemburg in his mx.DateTime extension. However,
|
||||
notice that this type does *NOT* implement the same algorithm as
|
||||
The relativedelta type is based on the specification of the excellent
|
||||
work done by M.-A. Lemburg in his
|
||||
`mx.DateTime <http://www.egenix.com/files/python/mxDateTime.html>`_ extension.
|
||||
However, notice that this type does *NOT* implement the same algorithm as
|
||||
his work. Do *NOT* expect it to behave like mx.DateTime's counterpart.
|
||||
|
||||
There's two different ways to build a relativedelta instance. The
|
||||
first one is passing it two date/datetime classes:
|
||||
There are two different ways to build a relativedelta instance. The
|
||||
first one is passing it two date/datetime classes::
|
||||
|
||||
relativedelta(datetime1, datetime2)
|
||||
|
||||
And the other way is to use the following keyword arguments:
|
||||
The second one is passing it any number of the following keyword arguments::
|
||||
|
||||
relativedelta(arg1=x,arg2=y,arg3=z...)
|
||||
|
||||
year, month, day, hour, minute, second, microsecond:
|
||||
Absolute information.
|
||||
Absolute information (argument is singular); adding or subtracting a
|
||||
relativedelta with absolute information does not perform an aritmetic
|
||||
operation, but rather REPLACES the corresponding value in the
|
||||
original datetime with the value(s) in relativedelta.
|
||||
|
||||
years, months, weeks, days, hours, minutes, seconds, microseconds:
|
||||
Relative information, may be negative.
|
||||
Relative information, may be negative (argument is plural); adding
|
||||
or subtracting a relativedelta with relative information performs
|
||||
the corresponding aritmetic operation on the original datetime value
|
||||
with the information in the relativedelta.
|
||||
|
||||
weekday:
|
||||
One of the weekday instances (MO, TU, etc). These instances may
|
||||
|
@ -80,26 +84,26 @@ And the other way is to use the following keyword arguments:
|
|||
|
||||
Here is the behavior of operations with relativedelta:
|
||||
|
||||
1) Calculate the absolute year, using the 'year' argument, or the
|
||||
1. Calculate the absolute year, using the 'year' argument, or the
|
||||
original datetime year, if the argument is not present.
|
||||
|
||||
2) Add the relative 'years' argument to the absolute year.
|
||||
2. Add the relative 'years' argument to the absolute year.
|
||||
|
||||
3) Do steps 1 and 2 for month/months.
|
||||
3. Do steps 1 and 2 for month/months.
|
||||
|
||||
4) Calculate the absolute day, using the 'day' argument, or the
|
||||
4. Calculate the absolute day, using the 'day' argument, or the
|
||||
original datetime day, if the argument is not present. Then,
|
||||
subtract from the day until it fits in the year and month
|
||||
found after their operations.
|
||||
|
||||
5) Add the relative 'days' argument to the absolute day. Notice
|
||||
5. Add the relative 'days' argument to the absolute day. Notice
|
||||
that the 'weeks' argument is multiplied by 7 and added to
|
||||
'days'.
|
||||
|
||||
6) Do steps 1 and 2 for hour/hours, minute/minutes, second/seconds,
|
||||
6. Do steps 1 and 2 for hour/hours, minute/minutes, second/seconds,
|
||||
microsecond/microseconds.
|
||||
|
||||
7) If the 'weekday' argument is present, calculate the weekday,
|
||||
7. If the 'weekday' argument is present, calculate the weekday,
|
||||
with the given (wday, nth) tuple. wday is the index of the
|
||||
weekday (0-6, 0=Mon), and nth is the number of weeks to add
|
||||
forward or backward, depending on its signal. Notice that if
|
||||
|
@ -114,9 +118,14 @@ Here is the behavior of operations with relativedelta:
|
|||
yearday=None, nlyearday=None,
|
||||
hour=None, minute=None, second=None, microsecond=None):
|
||||
if dt1 and dt2:
|
||||
if (not isinstance(dt1, datetime.date)) or (not isinstance(dt2, datetime.date)):
|
||||
# datetime is a subclass of date. So both must be date
|
||||
if not (isinstance(dt1, datetime.date) and
|
||||
isinstance(dt2, datetime.date)):
|
||||
raise TypeError("relativedelta only diffs datetime/date")
|
||||
if not type(dt1) == type(dt2): #isinstance(dt1, type(dt2)):
|
||||
# We allow two dates, or two datetimes, so we coerce them to be
|
||||
# of the same type
|
||||
if (isinstance(dt1, datetime.datetime) !=
|
||||
isinstance(dt2, datetime.datetime)):
|
||||
if not isinstance(dt1, datetime.datetime):
|
||||
dt1 = datetime.datetime.fromordinal(dt1.toordinal())
|
||||
elif not isinstance(dt2, datetime.datetime):
|
||||
|
@ -185,7 +194,8 @@ Here is the behavior of operations with relativedelta:
|
|||
if yearday > 59:
|
||||
self.leapdays = -1
|
||||
if yday:
|
||||
ydayidx = [31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 366]
|
||||
ydayidx = [31, 59, 90, 120, 151, 181, 212,
|
||||
243, 273, 304, 334, 366]
|
||||
for idx, ydays in enumerate(ydayidx):
|
||||
if yday <= ydays:
|
||||
self.month = idx+1
|
||||
|
@ -225,13 +235,20 @@ Here is the behavior of operations with relativedelta:
|
|||
div, mod = divmod(self.months*s, 12)
|
||||
self.months = mod*s
|
||||
self.years += div*s
|
||||
if (self.hours or self.minutes or self.seconds or self.microseconds or
|
||||
self.hour is not None or self.minute is not None or
|
||||
if (self.hours or self.minutes or self.seconds or self.microseconds
|
||||
or self.hour is not None or self.minute is not None or
|
||||
self.second is not None or self.microsecond is not None):
|
||||
self._has_time = 1
|
||||
else:
|
||||
self._has_time = 0
|
||||
|
||||
@property
|
||||
def weeks(self):
|
||||
return self.days // 7
|
||||
@weeks.setter
|
||||
def weeks(self, value):
|
||||
self.days = self.days - (self.weeks * 7) + value*7
|
||||
|
||||
def _set_months(self, months):
|
||||
self.months = months
|
||||
if abs(self.months) > 11:
|
||||
|
@ -244,13 +261,14 @@ Here is the behavior of operations with relativedelta:
|
|||
|
||||
def __add__(self, other):
|
||||
if isinstance(other, relativedelta):
|
||||
return relativedelta(years=other.years+self.years,
|
||||
return self.__class__(years=other.years+self.years,
|
||||
months=other.months+self.months,
|
||||
days=other.days+self.days,
|
||||
hours=other.hours+self.hours,
|
||||
minutes=other.minutes+self.minutes,
|
||||
seconds=other.seconds+self.seconds,
|
||||
microseconds=other.microseconds+self.microseconds,
|
||||
microseconds=(other.microseconds +
|
||||
self.microseconds),
|
||||
leapdays=other.leapdays or self.leapdays,
|
||||
year=other.year or self.year,
|
||||
month=other.month or self.month,
|
||||
|
@ -259,7 +277,8 @@ Here is the behavior of operations with relativedelta:
|
|||
hour=other.hour or self.hour,
|
||||
minute=other.minute or self.minute,
|
||||
second=other.second or self.second,
|
||||
microsecond=other.microsecond or self.microsecond)
|
||||
microsecond=(other.microsecond or
|
||||
self.microsecond))
|
||||
if not isinstance(other, datetime.date):
|
||||
raise TypeError("unsupported type for add operation")
|
||||
elif self._has_time and not isinstance(other, datetime.datetime):
|
||||
|
@ -311,7 +330,7 @@ Here is the behavior of operations with relativedelta:
|
|||
def __sub__(self, other):
|
||||
if not isinstance(other, relativedelta):
|
||||
raise TypeError("unsupported type for sub operation")
|
||||
return relativedelta(years=self.years-other.years,
|
||||
return self.__class__(years=self.years-other.years,
|
||||
months=self.months-other.months,
|
||||
days=self.days-other.days,
|
||||
hours=self.hours-other.hours,
|
||||
|
@ -329,7 +348,7 @@ Here is the behavior of operations with relativedelta:
|
|||
microsecond=self.microsecond or other.microsecond)
|
||||
|
||||
def __neg__(self):
|
||||
return relativedelta(years=-self.years,
|
||||
return self.__class__(years=-self.years,
|
||||
months=-self.months,
|
||||
days=-self.days,
|
||||
hours=-self.hours,
|
||||
|
@ -363,10 +382,12 @@ Here is the behavior of operations with relativedelta:
|
|||
self.minute is None and
|
||||
self.second is None and
|
||||
self.microsecond is None)
|
||||
# Compatibility with Python 2.x
|
||||
__nonzero__ = __bool__
|
||||
|
||||
def __mul__(self, other):
|
||||
f = float(other)
|
||||
return relativedelta(years=int(self.years*f),
|
||||
return self.__class__(years=int(self.years*f),
|
||||
months=int(self.months*f),
|
||||
days=int(self.days*f),
|
||||
hours=int(self.hours*f),
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2003-2010 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
The rrule module offers a small, complete, and very fast, implementation of
|
||||
the recurrence rules documented in the
|
||||
`iCalendar RFC <http://www.ietf.org/rfc/rfc2445.txt>`_,
|
||||
including support for caching of results.
|
||||
"""
|
||||
__license__ = "Simplified BSD"
|
||||
|
||||
import itertools
|
||||
import datetime
|
||||
import calendar
|
||||
try:
|
||||
import _thread
|
||||
except ImportError:
|
||||
import thread as _thread
|
||||
import sys
|
||||
|
||||
from fractions import gcd
|
||||
|
||||
from six import advance_iterator, integer_types
|
||||
from six.moves import _thread
|
||||
|
||||
__all__ = ["rrule", "rruleset", "rrulestr",
|
||||
"YEARLY", "MONTHLY", "WEEKLY", "DAILY",
|
||||
|
@ -39,6 +37,8 @@ del M29, M30, M31, M365MASK[59], MDAY365MASK[59], NMDAY365MASK[31]
|
|||
MDAY365MASK = tuple(MDAY365MASK)
|
||||
M365MASK = tuple(M365MASK)
|
||||
|
||||
FREQNAMES = ['YEARLY','MONTHLY','WEEKLY','DAILY','HOURLY','MINUTELY','SECONDLY']
|
||||
|
||||
(YEARLY,
|
||||
MONTHLY,
|
||||
WEEKLY,
|
||||
|
@ -51,6 +51,7 @@ M365MASK = tuple(M365MASK)
|
|||
easter = None
|
||||
parser = None
|
||||
|
||||
|
||||
class weekday(object):
|
||||
__slots__ = ["weekday", "n"]
|
||||
|
||||
|
@ -83,6 +84,7 @@ class weekday(object):
|
|||
|
||||
MO, TU, WE, TH, FR, SA, SU = weekdays = tuple([weekday(x) for x in range(7)])
|
||||
|
||||
|
||||
class rrulebase(object):
|
||||
def __init__(self, cache=False):
|
||||
if cache:
|
||||
|
@ -163,11 +165,17 @@ class rrulebase(object):
|
|||
|
||||
# __len__() introduces a large performance penality.
|
||||
def count(self):
|
||||
""" Returns the number of recurrences in this set. It will have go
|
||||
trough the whole recurrence, if this hasn't been done before. """
|
||||
if self._len is None:
|
||||
for x in self: pass
|
||||
for x in self:
|
||||
pass
|
||||
return self._len
|
||||
|
||||
def before(self, dt, inc=False):
|
||||
""" Returns the last recurrence before the given datetime instance. The
|
||||
inc keyword defines what happens if dt is an occurrence. With
|
||||
inc=True, if dt itself is an occurrence, it will be returned. """
|
||||
if self._cache_complete:
|
||||
gen = self._cache
|
||||
else:
|
||||
|
@ -186,6 +194,9 @@ class rrulebase(object):
|
|||
return last
|
||||
|
||||
def after(self, dt, inc=False):
|
||||
""" Returns the first recurrence after the given datetime instance. The
|
||||
inc keyword defines what happens if dt is an occurrence. With
|
||||
inc=True, if dt itself is an occurrence, it will be returned. """
|
||||
if self._cache_complete:
|
||||
gen = self._cache
|
||||
else:
|
||||
|
@ -200,7 +211,52 @@ class rrulebase(object):
|
|||
return i
|
||||
return None
|
||||
|
||||
def between(self, after, before, inc=False):
|
||||
def xafter(self, dt, count=None, inc=False):
|
||||
"""
|
||||
Generator which yields up to `count` recurrences after the given
|
||||
datetime instance, equivalent to `after`.
|
||||
|
||||
:param dt:
|
||||
The datetime at which to start generating recurrences.
|
||||
|
||||
:param count:
|
||||
The maximum number of recurrences to generate. If `None` (default),
|
||||
dates are generated until the recurrence rule is exhausted.
|
||||
|
||||
:param inc:
|
||||
If `dt` is an instance of the rule and `inc` is `True`, it is
|
||||
included in the output.
|
||||
|
||||
:yields: Yields a sequence of `datetime` objects.
|
||||
"""
|
||||
|
||||
if self._cache_complete:
|
||||
gen = self._cache
|
||||
else:
|
||||
gen = self
|
||||
|
||||
# Select the comparison function
|
||||
if inc:
|
||||
comp = lambda dc, dtc: dc >= dtc
|
||||
else:
|
||||
comp = lambda dc, dtc: dc > dtc
|
||||
|
||||
# Generate dates
|
||||
n = 0
|
||||
for d in gen:
|
||||
if comp(d, dt):
|
||||
yield d
|
||||
|
||||
if count is not None:
|
||||
n += 1
|
||||
if n >= count:
|
||||
break
|
||||
|
||||
def between(self, after, before, inc=False, count=1):
|
||||
""" Returns all the occurrences of the rrule between after and before.
|
||||
The inc keyword defines what happens if after and/or before are
|
||||
themselves occurrences. With inc=True, they will be included in the
|
||||
list, if they are found in the recurrence set. """
|
||||
if self._cache_complete:
|
||||
gen = self._cache
|
||||
else:
|
||||
|
@ -229,7 +285,93 @@ class rrulebase(object):
|
|||
l.append(i)
|
||||
return l
|
||||
|
||||
|
||||
class rrule(rrulebase):
|
||||
"""
|
||||
That's the base of the rrule operation. It accepts all the keywords
|
||||
defined in the RFC as its constructor parameters (except byday,
|
||||
which was renamed to byweekday) and more. The constructor prototype is::
|
||||
|
||||
rrule(freq)
|
||||
|
||||
Where freq must be one of YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY,
|
||||
or SECONDLY.
|
||||
|
||||
Additionally, it supports the following keyword arguments:
|
||||
|
||||
:param cache:
|
||||
If given, it must be a boolean value specifying to enable or disable
|
||||
caching of results. If you will use the same rrule instance multiple
|
||||
times, enabling caching will improve the performance considerably.
|
||||
:param dtstart:
|
||||
The recurrence start. Besides being the base for the recurrence,
|
||||
missing parameters in the final recurrence instances will also be
|
||||
extracted from this date. If not given, datetime.now() will be used
|
||||
instead.
|
||||
:param interval:
|
||||
The interval between each freq iteration. For example, when using
|
||||
YEARLY, an interval of 2 means once every two years, but with HOURLY,
|
||||
it means once every two hours. The default interval is 1.
|
||||
:param wkst:
|
||||
The week start day. Must be one of the MO, TU, WE constants, or an
|
||||
integer, specifying the first day of the week. This will affect
|
||||
recurrences based on weekly periods. The default week start is got
|
||||
from calendar.firstweekday(), and may be modified by
|
||||
calendar.setfirstweekday().
|
||||
:param count:
|
||||
How many occurrences will be generated.
|
||||
:param until:
|
||||
If given, this must be a datetime instance, that will specify the
|
||||
limit of the recurrence. If a recurrence instance happens to be the
|
||||
same as the datetime instance given in the until keyword, this will
|
||||
be the last occurrence.
|
||||
:param bysetpos:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
positive or negative. Each given integer will specify an occurrence
|
||||
number, corresponding to the nth occurrence of the rule inside the
|
||||
frequency period. For example, a bysetpos of -1 if combined with a
|
||||
MONTHLY frequency, and a byweekday of (MO, TU, WE, TH, FR), will
|
||||
result in the last work day of every month.
|
||||
:param bymonth:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the months to apply the recurrence to.
|
||||
:param bymonthday:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the month days to apply the recurrence to.
|
||||
:param byyearday:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the year days to apply the recurrence to.
|
||||
:param byweekno:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the week numbers to apply the recurrence to. Week numbers
|
||||
have the meaning described in ISO8601, that is, the first week of
|
||||
the year is that containing at least four days of the new year.
|
||||
:param byweekday:
|
||||
If given, it must be either an integer (0 == MO), a sequence of
|
||||
integers, one of the weekday constants (MO, TU, etc), or a sequence
|
||||
of these constants. When given, these variables will define the
|
||||
weekdays where the recurrence will be applied. It's also possible to
|
||||
use an argument n for the weekday instances, which will mean the nth
|
||||
occurrence of this weekday in the period. For example, with MONTHLY,
|
||||
or with YEARLY and BYMONTH, using FR(+1) in byweekday will specify the
|
||||
first friday of the month where the recurrence happens. Notice that in
|
||||
the RFC documentation, this is specified as BYDAY, but was renamed to
|
||||
avoid the ambiguity of that keyword.
|
||||
:param byhour:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the hours to apply the recurrence to.
|
||||
:param byminute:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the minutes to apply the recurrence to.
|
||||
:param bysecond:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
meaning the seconds to apply the recurrence to.
|
||||
:param byeaster:
|
||||
If given, it must be either an integer, or a sequence of integers,
|
||||
positive or negative. Each integer will define an offset from the
|
||||
Easter Sunday. Passing the offset 0 to byeaster will yield the Easter
|
||||
Sunday itself. This is an extension to the RFC specification.
|
||||
"""
|
||||
def __init__(self, freq, dtstart=None,
|
||||
interval=1, wkst=None, count=None, until=None, bysetpos=None,
|
||||
bymonth=None, bymonthday=None, byyearday=None, byeaster=None,
|
||||
|
@ -249,15 +391,24 @@ class rrule(rrulebase):
|
|||
self._freq = freq
|
||||
self._interval = interval
|
||||
self._count = count
|
||||
|
||||
# Cache the original byxxx rules, if they are provided, as the _byxxx
|
||||
# attributes do not necessarily map to the inputs, and this can be
|
||||
# a problem in generating the strings. Only store things if they've
|
||||
# been supplied (the string retrieval will just use .get())
|
||||
self._original_rule = {}
|
||||
|
||||
if until and not isinstance(until, datetime.datetime):
|
||||
until = datetime.datetime.fromordinal(until.toordinal())
|
||||
self._until = until
|
||||
|
||||
if wkst is None:
|
||||
self._wkst = calendar.firstweekday()
|
||||
elif isinstance(wkst, integer_types):
|
||||
self._wkst = wkst
|
||||
else:
|
||||
self._wkst = wkst.weekday
|
||||
|
||||
if bysetpos is None:
|
||||
self._bysetpos = None
|
||||
elif isinstance(bysetpos, integer_types):
|
||||
|
@ -271,30 +422,47 @@ class rrule(rrulebase):
|
|||
if pos == 0 or not (-366 <= pos <= 366):
|
||||
raise ValueError("bysetpos must be between 1 and 366, "
|
||||
"or between -366 and -1")
|
||||
if not (byweekno or byyearday or bymonthday or
|
||||
byweekday is not None or byeaster is not None):
|
||||
|
||||
if self._bysetpos:
|
||||
self._original_rule['bysetpos'] = self._bysetpos
|
||||
|
||||
if (byweekno is None and byyearday is None and bymonthday is None and
|
||||
byweekday is None and byeaster is None):
|
||||
if freq == YEARLY:
|
||||
if not bymonth:
|
||||
if bymonth is None:
|
||||
bymonth = dtstart.month
|
||||
self._original_rule['bymonth'] = None
|
||||
bymonthday = dtstart.day
|
||||
self._original_rule['bymonthday'] = None
|
||||
elif freq == MONTHLY:
|
||||
bymonthday = dtstart.day
|
||||
self._original_rule['bymonthday'] = None
|
||||
elif freq == WEEKLY:
|
||||
byweekday = dtstart.weekday()
|
||||
self._original_rule['byweekday'] = None
|
||||
|
||||
# bymonth
|
||||
if not bymonth:
|
||||
if bymonth is None:
|
||||
self._bymonth = None
|
||||
elif isinstance(bymonth, integer_types):
|
||||
self._bymonth = (bymonth,)
|
||||
else:
|
||||
self._bymonth = tuple(bymonth)
|
||||
if isinstance(bymonth, integer_types):
|
||||
bymonth = (bymonth,)
|
||||
|
||||
self._bymonth = tuple(sorted(set(bymonth)))
|
||||
|
||||
if 'bymonth' not in self._original_rule:
|
||||
self._original_rule['bymonth'] = self._bymonth
|
||||
|
||||
# byyearday
|
||||
if not byyearday:
|
||||
if byyearday is None:
|
||||
self._byyearday = None
|
||||
elif isinstance(byyearday, integer_types):
|
||||
self._byyearday = (byyearday,)
|
||||
else:
|
||||
self._byyearday = tuple(byyearday)
|
||||
if isinstance(byyearday, integer_types):
|
||||
byyearday = (byyearday,)
|
||||
|
||||
self._byyearday = tuple(sorted(set(byyearday)))
|
||||
self._original_rule['byyearday'] = self._byyearday
|
||||
|
||||
# byeaster
|
||||
if byeaster is not None:
|
||||
if not easter:
|
||||
|
@ -302,90 +470,144 @@ class rrule(rrulebase):
|
|||
if isinstance(byeaster, integer_types):
|
||||
self._byeaster = (byeaster,)
|
||||
else:
|
||||
self._byeaster = tuple(byeaster)
|
||||
self._byeaster = tuple(sorted(byeaster))
|
||||
|
||||
self._original_rule['byeaster'] = self._byeaster
|
||||
else:
|
||||
self._byeaster = None
|
||||
# bymonthay
|
||||
if not bymonthday:
|
||||
|
||||
# bymonthday
|
||||
if bymonthday is None:
|
||||
self._bymonthday = ()
|
||||
self._bynmonthday = ()
|
||||
elif isinstance(bymonthday, integer_types):
|
||||
if bymonthday < 0:
|
||||
self._bynmonthday = (bymonthday,)
|
||||
self._bymonthday = ()
|
||||
else:
|
||||
self._bymonthday = (bymonthday,)
|
||||
self._bynmonthday = ()
|
||||
else:
|
||||
self._bymonthday = tuple([x for x in bymonthday if x > 0])
|
||||
self._bynmonthday = tuple([x for x in bymonthday if x < 0])
|
||||
if isinstance(bymonthday, integer_types):
|
||||
bymonthday = (bymonthday,)
|
||||
|
||||
bymonthday = set(bymonthday) # Ensure it's unique
|
||||
|
||||
self._bymonthday = tuple(sorted([x for x in bymonthday if x > 0]))
|
||||
self._bynmonthday = tuple(sorted([x for x in bymonthday if x < 0]))
|
||||
|
||||
# Storing positive numbers first, then negative numbers
|
||||
if 'bymonthday' not in self._original_rule:
|
||||
self._original_rule['bymonthday'] = tuple(
|
||||
itertools.chain(self._bymonthday, self._bynmonthday))
|
||||
|
||||
# byweekno
|
||||
if byweekno is None:
|
||||
self._byweekno = None
|
||||
elif isinstance(byweekno, integer_types):
|
||||
self._byweekno = (byweekno,)
|
||||
else:
|
||||
self._byweekno = tuple(byweekno)
|
||||
if isinstance(byweekno, integer_types):
|
||||
byweekno = (byweekno,)
|
||||
|
||||
self._byweekno = tuple(sorted(set(byweekno)))
|
||||
|
||||
self._original_rule['byweekno'] = self._byweekno
|
||||
|
||||
# byweekday / bynweekday
|
||||
if byweekday is None:
|
||||
self._byweekday = None
|
||||
self._bynweekday = None
|
||||
elif isinstance(byweekday, integer_types):
|
||||
self._byweekday = (byweekday,)
|
||||
self._bynweekday = None
|
||||
elif hasattr(byweekday, "n"):
|
||||
if not byweekday.n or freq > MONTHLY:
|
||||
self._byweekday = (byweekday.weekday,)
|
||||
self._bynweekday = None
|
||||
else:
|
||||
self._bynweekday = ((byweekday.weekday, byweekday.n),)
|
||||
self._byweekday = None
|
||||
else:
|
||||
self._byweekday = []
|
||||
self._bynweekday = []
|
||||
# If it's one of the valid non-sequence types, convert to a
|
||||
# single-element sequence before the iterator that builds the
|
||||
# byweekday set.
|
||||
if isinstance(byweekday, integer_types) or hasattr(byweekday, "n"):
|
||||
byweekday = (byweekday,)
|
||||
|
||||
self._byweekday = set()
|
||||
self._bynweekday = set()
|
||||
for wday in byweekday:
|
||||
if isinstance(wday, integer_types):
|
||||
self._byweekday.append(wday)
|
||||
self._byweekday.add(wday)
|
||||
elif not wday.n or freq > MONTHLY:
|
||||
self._byweekday.append(wday.weekday)
|
||||
self._byweekday.add(wday.weekday)
|
||||
else:
|
||||
self._bynweekday.append((wday.weekday, wday.n))
|
||||
self._byweekday = tuple(self._byweekday)
|
||||
self._bynweekday = tuple(self._bynweekday)
|
||||
self._bynweekday.add((wday.weekday, wday.n))
|
||||
|
||||
if not self._byweekday:
|
||||
self._byweekday = None
|
||||
elif not self._bynweekday:
|
||||
self._bynweekday = None
|
||||
|
||||
if self._byweekday is not None:
|
||||
self._byweekday = tuple(sorted(self._byweekday))
|
||||
orig_byweekday = [weekday(x) for x in self._byweekday]
|
||||
else:
|
||||
orig_byweekday = tuple()
|
||||
|
||||
if self._bynweekday is not None:
|
||||
self._bynweekday = tuple(sorted(self._bynweekday))
|
||||
orig_bynweekday = [weekday(*x) for x in self._bynweekday]
|
||||
else:
|
||||
orig_bynweekday = tuple()
|
||||
|
||||
if 'byweekday' not in self._original_rule:
|
||||
self._original_rule['byweekday'] = tuple(itertools.chain(
|
||||
orig_byweekday, orig_bynweekday))
|
||||
|
||||
# byhour
|
||||
if byhour is None:
|
||||
if freq < HOURLY:
|
||||
self._byhour = (dtstart.hour,)
|
||||
self._byhour = set((dtstart.hour,))
|
||||
else:
|
||||
self._byhour = None
|
||||
elif isinstance(byhour, integer_types):
|
||||
self._byhour = (byhour,)
|
||||
else:
|
||||
self._byhour = tuple(byhour)
|
||||
if isinstance(byhour, integer_types):
|
||||
byhour = (byhour,)
|
||||
|
||||
if freq == HOURLY:
|
||||
self._byhour = self.__construct_byset(start=dtstart.hour,
|
||||
byxxx=byhour,
|
||||
base=24)
|
||||
else:
|
||||
self._byhour = set(byhour)
|
||||
|
||||
self._byhour = tuple(sorted(self._byhour))
|
||||
self._original_rule['byhour'] = self._byhour
|
||||
|
||||
# byminute
|
||||
if byminute is None:
|
||||
if freq < MINUTELY:
|
||||
self._byminute = (dtstart.minute,)
|
||||
self._byminute = set((dtstart.minute,))
|
||||
else:
|
||||
self._byminute = None
|
||||
elif isinstance(byminute, integer_types):
|
||||
self._byminute = (byminute,)
|
||||
else:
|
||||
self._byminute = tuple(byminute)
|
||||
if isinstance(byminute, integer_types):
|
||||
byminute = (byminute,)
|
||||
|
||||
if freq == MINUTELY:
|
||||
self._byminute = self.__construct_byset(start=dtstart.minute,
|
||||
byxxx=byminute,
|
||||
base=60)
|
||||
else:
|
||||
self._byminute = set(byminute)
|
||||
|
||||
self._byminute = tuple(sorted(self._byminute))
|
||||
self._original_rule['byminute'] = self._byminute
|
||||
|
||||
# bysecond
|
||||
if bysecond is None:
|
||||
if freq < SECONDLY:
|
||||
self._bysecond = (dtstart.second,)
|
||||
self._bysecond = ((dtstart.second,))
|
||||
else:
|
||||
self._bysecond = None
|
||||
elif isinstance(bysecond, integer_types):
|
||||
self._bysecond = (bysecond,)
|
||||
else:
|
||||
self._bysecond = tuple(bysecond)
|
||||
if isinstance(bysecond, integer_types):
|
||||
bysecond = (bysecond,)
|
||||
|
||||
self._bysecond = set(bysecond)
|
||||
|
||||
if freq == SECONDLY:
|
||||
self._bysecond = self.__construct_byset(start=dtstart.second,
|
||||
byxxx=bysecond,
|
||||
base=60)
|
||||
else:
|
||||
self._bysecond = set(bysecond)
|
||||
|
||||
self._bysecond = tuple(sorted(self._bysecond))
|
||||
self._original_rule['bysecond'] = self._bysecond
|
||||
|
||||
if self._freq >= HOURLY:
|
||||
self._timeset = None
|
||||
|
@ -400,6 +622,65 @@ class rrule(rrulebase):
|
|||
self._timeset.sort()
|
||||
self._timeset = tuple(self._timeset)
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
Output a string that would generate this RRULE if passed to rrulestr.
|
||||
This is mostly compatible with RFC2445, except for the
|
||||
dateutil-specific extension BYEASTER.
|
||||
"""
|
||||
|
||||
output = []
|
||||
h, m, s = [None] * 3
|
||||
if self._dtstart:
|
||||
output.append(self._dtstart.strftime('DTSTART:%Y%m%dT%H%M%S'))
|
||||
h, m, s = self._dtstart.timetuple()[3:6]
|
||||
|
||||
parts = ['FREQ=' + FREQNAMES[self._freq]]
|
||||
if self._interval != 1:
|
||||
parts.append('INTERVAL=' + str(self._interval))
|
||||
|
||||
if self._wkst:
|
||||
parts.append('WKST=' + str(self._wkst))
|
||||
|
||||
if self._count:
|
||||
parts.append('COUNT=' + str(self._count))
|
||||
|
||||
if self._original_rule.get('byweekday') is not None:
|
||||
# The str() method on weekday objects doesn't generate
|
||||
# RFC2445-compliant strings, so we should modify that.
|
||||
original_rule = dict(self._original_rule)
|
||||
wday_strings = []
|
||||
for wday in original_rule['byweekday']:
|
||||
if wday.n:
|
||||
wday_strings.append('{n:+d}{wday}'.format(
|
||||
n=wday.n,
|
||||
wday=repr(wday)[0:2]))
|
||||
else:
|
||||
wday_strings.append(repr(wday))
|
||||
|
||||
original_rule['byweekday'] = wday_strings
|
||||
else:
|
||||
original_rule = self._original_rule
|
||||
|
||||
partfmt = '{name}={vals}'
|
||||
for name, key in [('BYSETPOS', 'bysetpos'),
|
||||
('BYMONTH', 'bymonth'),
|
||||
('BYMONTHDAY', 'bymonthday'),
|
||||
('BYYEARDAY', 'byyearday'),
|
||||
('BYWEEKNO', 'byweekno'),
|
||||
('BYDAY', 'byweekday'),
|
||||
('BYHOUR', 'byhour'),
|
||||
('BYMINUTE', 'byminute'),
|
||||
('BYSECOND', 'bysecond'),
|
||||
('BYEASTER', 'byeaster')]:
|
||||
value = original_rule.get(key)
|
||||
if value:
|
||||
parts.append(partfmt.format(name=name, vals=(','.join(str(v)
|
||||
for v in value))))
|
||||
|
||||
output.append(';'.join(parts))
|
||||
return '\n'.join(output)
|
||||
|
||||
def _iter(self):
|
||||
year, month, day, hour, minute, second, weekday, yearday, _ = \
|
||||
self._dtstart.timetuple()
|
||||
|
@ -466,11 +747,10 @@ class rrule(rrulebase):
|
|||
ii.mdaymask[i] not in bymonthday and
|
||||
ii.nmdaymask[i] not in bynmonthday) or
|
||||
(byyearday and
|
||||
((i < ii.yearlen and i+1 not in byyearday
|
||||
and -ii.yearlen+i not in byyearday) or
|
||||
(i >= ii.yearlen and i+1-ii.yearlen not in byyearday
|
||||
and -ii.nextyearlen+i-ii.yearlen
|
||||
not in byyearday)))):
|
||||
((i < ii.yearlen and i+1 not in byyearday and
|
||||
-ii.yearlen+i not in byyearday) or
|
||||
(i >= ii.yearlen and i+1-ii.yearlen not in byyearday and
|
||||
-ii.nextyearlen+i-ii.yearlen not in byyearday)))):
|
||||
dayset[i] = None
|
||||
filtered = True
|
||||
|
||||
|
@ -559,60 +839,86 @@ class rrule(rrulebase):
|
|||
if filtered:
|
||||
# Jump to one iteration before next day
|
||||
hour += ((23-hour)//interval)*interval
|
||||
while True:
|
||||
hour += interval
|
||||
div, mod = divmod(hour, 24)
|
||||
if div:
|
||||
hour = mod
|
||||
day += div
|
||||
|
||||
if byhour:
|
||||
ndays, hour = self.__mod_distance(value=hour,
|
||||
byxxx=self._byhour,
|
||||
base=24)
|
||||
else:
|
||||
ndays, hour = divmod(hour+interval, 24)
|
||||
|
||||
if ndays:
|
||||
day += ndays
|
||||
fixday = True
|
||||
if not byhour or hour in byhour:
|
||||
break
|
||||
|
||||
timeset = gettimeset(hour, minute, second)
|
||||
elif freq == MINUTELY:
|
||||
if filtered:
|
||||
# Jump to one iteration before next day
|
||||
minute += ((1439-(hour*60+minute))//interval)*interval
|
||||
while True:
|
||||
minute += interval
|
||||
div, mod = divmod(minute, 60)
|
||||
|
||||
valid = False
|
||||
rep_rate = (24*60)
|
||||
for j in range(rep_rate // gcd(interval, rep_rate)):
|
||||
if byminute:
|
||||
nhours, minute = \
|
||||
self.__mod_distance(value=minute,
|
||||
byxxx=self._byminute,
|
||||
base=60)
|
||||
else:
|
||||
nhours, minute = divmod(minute+interval, 60)
|
||||
|
||||
div, hour = divmod(hour+nhours, 24)
|
||||
if div:
|
||||
minute = mod
|
||||
hour += div
|
||||
div, mod = divmod(hour, 24)
|
||||
if div:
|
||||
hour = mod
|
||||
day += div
|
||||
fixday = True
|
||||
filtered = False
|
||||
if ((not byhour or hour in byhour) and
|
||||
(not byminute or minute in byminute)):
|
||||
|
||||
if not byhour or hour in byhour:
|
||||
valid = True
|
||||
break
|
||||
|
||||
if not valid:
|
||||
raise ValueError('Invalid combination of interval and ' +
|
||||
'byhour resulting in empty rule.')
|
||||
|
||||
timeset = gettimeset(hour, minute, second)
|
||||
elif freq == SECONDLY:
|
||||
if filtered:
|
||||
# Jump to one iteration before next day
|
||||
second += (((86399 - (hour * 3600 + minute * 60 + second))
|
||||
// interval) * interval)
|
||||
while True:
|
||||
second += self._interval
|
||||
div, mod = divmod(second, 60)
|
||||
|
||||
rep_rate = (24 * 3600)
|
||||
valid = False
|
||||
for j in range(0, rep_rate // gcd(interval, rep_rate)):
|
||||
if bysecond:
|
||||
nminutes, second = \
|
||||
self.__mod_distance(value=second,
|
||||
byxxx=self._bysecond,
|
||||
base=60)
|
||||
else:
|
||||
nminutes, second = divmod(second+interval, 60)
|
||||
|
||||
div, minute = divmod(minute+nminutes, 60)
|
||||
if div:
|
||||
second = mod
|
||||
minute += div
|
||||
div, mod = divmod(minute, 60)
|
||||
if div:
|
||||
minute = mod
|
||||
hour += div
|
||||
div, mod = divmod(hour, 24)
|
||||
div, hour = divmod(hour, 24)
|
||||
if div:
|
||||
hour = mod
|
||||
day += div
|
||||
fixday = True
|
||||
|
||||
if ((not byhour or hour in byhour) and
|
||||
(not byminute or minute in byminute) and
|
||||
(not bysecond or second in bysecond)):
|
||||
valid = True
|
||||
break
|
||||
|
||||
if not valid:
|
||||
raise ValueError('Invalid combination of interval, ' +
|
||||
'byhour and byminute resulting in empty' +
|
||||
' rule.')
|
||||
|
||||
timeset = gettimeset(hour, minute, second)
|
||||
|
||||
if fixday and day > 28:
|
||||
|
@ -630,6 +936,86 @@ class rrule(rrulebase):
|
|||
daysinmonth = calendar.monthrange(year, month)[1]
|
||||
ii.rebuild(year, month)
|
||||
|
||||
def __construct_byset(self, start, byxxx, base):
|
||||
"""
|
||||
If a `BYXXX` sequence is passed to the constructor at the same level as
|
||||
`FREQ` (e.g. `FREQ=HOURLY,BYHOUR={2,4,7},INTERVAL=3`), there are some
|
||||
specifications which cannot be reached given some starting conditions.
|
||||
|
||||
This occurs whenever the interval is not coprime with the base of a
|
||||
given unit and the difference between the starting position and the
|
||||
ending position is not coprime with the greatest common denominator
|
||||
between the interval and the base. For example, with a FREQ of hourly
|
||||
starting at 17:00 and an interval of 4, the only valid values for
|
||||
BYHOUR would be {21, 1, 5, 9, 13, 17}, because 4 and 24 are not
|
||||
coprime.
|
||||
|
||||
:param start:
|
||||
Specifies the starting position.
|
||||
:param byxxx:
|
||||
An iterable containing the list of allowed values.
|
||||
:param base:
|
||||
The largest allowable value for the specified frequency (e.g.
|
||||
24 hours, 60 minutes).
|
||||
|
||||
This does not preserve the type of the iterable, returning a set, since
|
||||
the values should be unique and the order is irrelevant, this will
|
||||
speed up later lookups.
|
||||
|
||||
In the event of an empty set, raises a :exception:`ValueError`, as this
|
||||
results in an empty rrule.
|
||||
"""
|
||||
|
||||
cset = set()
|
||||
|
||||
# Support a single byxxx value.
|
||||
if isinstance(byxxx, integer_types):
|
||||
byxxx = (byxxx, )
|
||||
|
||||
for num in byxxx:
|
||||
i_gcd = gcd(self._interval, base)
|
||||
# Use divmod rather than % because we need to wrap negative nums.
|
||||
if i_gcd == 1 or divmod(num - start, i_gcd)[1] == 0:
|
||||
cset.add(num)
|
||||
|
||||
if len(cset) == 0:
|
||||
raise ValueError("Invalid rrule byxxx generates an empty set.")
|
||||
|
||||
return cset
|
||||
|
||||
def __mod_distance(self, value, byxxx, base):
|
||||
"""
|
||||
Calculates the next value in a sequence where the `FREQ` parameter is
|
||||
specified along with a `BYXXX` parameter at the same "level"
|
||||
(e.g. `HOURLY` specified with `BYHOUR`).
|
||||
|
||||
:param value:
|
||||
The old value of the component.
|
||||
:param byxxx:
|
||||
The `BYXXX` set, which should have been generated by
|
||||
`rrule._construct_byset`, or something else which checks that a
|
||||
valid rule is present.
|
||||
:param base:
|
||||
The largest allowable value for the specified frequency (e.g.
|
||||
24 hours, 60 minutes).
|
||||
|
||||
If a valid value is not found after `base` iterations (the maximum
|
||||
number before the sequence would start to repeat), this raises a
|
||||
:exception:`ValueError`, as no valid values were found.
|
||||
|
||||
This returns a tuple of `divmod(n*interval, base)`, where `n` is the
|
||||
smallest number of `interval` repetitions until the next specified
|
||||
value in `byxxx` is found.
|
||||
"""
|
||||
accumulator = 0
|
||||
for ii in range(1, base + 1):
|
||||
# Using divmod() over % to account for negative intervals
|
||||
div, value = divmod(value + self._interval, base)
|
||||
accumulator += div
|
||||
if value in byxxx:
|
||||
return (accumulator, value)
|
||||
|
||||
|
||||
class _iterinfo(object):
|
||||
__slots__ = ["rrule", "lastyear", "lastmonth",
|
||||
"yearlen", "nextyearlen", "yearordinal", "yearweekday",
|
||||
|
@ -735,8 +1121,8 @@ class _iterinfo(object):
|
|||
for i in range(no1wkst):
|
||||
self.wnomask[i] = 1
|
||||
|
||||
if (rr._bynweekday and
|
||||
(month != self.lastmonth or year != self.lastyear)):
|
||||
if (rr._bynweekday and (month != self.lastmonth or
|
||||
year != self.lastyear)):
|
||||
ranges = []
|
||||
if rr._freq == YEARLY:
|
||||
if rr._bymonth:
|
||||
|
@ -775,50 +1161,50 @@ class _iterinfo(object):
|
|||
return list(range(self.yearlen)), 0, self.yearlen
|
||||
|
||||
def mdayset(self, year, month, day):
|
||||
set = [None]*self.yearlen
|
||||
dset = [None]*self.yearlen
|
||||
start, end = self.mrange[month-1:month+1]
|
||||
for i in range(start, end):
|
||||
set[i] = i
|
||||
return set, start, end
|
||||
dset[i] = i
|
||||
return dset, start, end
|
||||
|
||||
def wdayset(self, year, month, day):
|
||||
# We need to handle cross-year weeks here.
|
||||
set = [None]*(self.yearlen+7)
|
||||
dset = [None]*(self.yearlen+7)
|
||||
i = datetime.date(year, month, day).toordinal()-self.yearordinal
|
||||
start = i
|
||||
for j in range(7):
|
||||
set[i] = i
|
||||
dset[i] = i
|
||||
i += 1
|
||||
# if (not (0 <= i < self.yearlen) or
|
||||
# self.wdaymask[i] == self.rrule._wkst):
|
||||
# This will cross the year boundary, if necessary.
|
||||
if self.wdaymask[i] == self.rrule._wkst:
|
||||
break
|
||||
return set, start, i
|
||||
return dset, start, i
|
||||
|
||||
def ddayset(self, year, month, day):
|
||||
set = [None]*self.yearlen
|
||||
dset = [None] * self.yearlen
|
||||
i = datetime.date(year, month, day).toordinal() - self.yearordinal
|
||||
set[i] = i
|
||||
return set, i, i+1
|
||||
dset[i] = i
|
||||
return dset, i, i + 1
|
||||
|
||||
def htimeset(self, hour, minute, second):
|
||||
set = []
|
||||
tset = []
|
||||
rr = self.rrule
|
||||
for minute in rr._byminute:
|
||||
for second in rr._bysecond:
|
||||
set.append(datetime.time(hour, minute, second,
|
||||
tset.append(datetime.time(hour, minute, second,
|
||||
tzinfo=rr._tzinfo))
|
||||
set.sort()
|
||||
return set
|
||||
tset.sort()
|
||||
return tset
|
||||
|
||||
def mtimeset(self, hour, minute, second):
|
||||
set = []
|
||||
tset = []
|
||||
rr = self.rrule
|
||||
for second in rr._bysecond:
|
||||
set.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo))
|
||||
set.sort()
|
||||
return set
|
||||
tset.append(datetime.time(hour, minute, second, tzinfo=rr._tzinfo))
|
||||
tset.sort()
|
||||
return tset
|
||||
|
||||
def stimeset(self, hour, minute, second):
|
||||
return (datetime.time(hour, minute, second,
|
||||
|
@ -826,6 +1212,12 @@ class _iterinfo(object):
|
|||
|
||||
|
||||
class rruleset(rrulebase):
|
||||
""" The rruleset type allows more complex recurrence setups, mixing
|
||||
multiple rules, dates, exclusion rules, and exclusion dates. The type
|
||||
constructor takes the following keyword arguments:
|
||||
|
||||
:param cache: If True, caching of results will be enabled, improving
|
||||
performance of multiple queries considerably. """
|
||||
|
||||
class _genitem(object):
|
||||
def __init__(self, genlist, gen):
|
||||
|
@ -865,15 +1257,26 @@ class rruleset(rrulebase):
|
|||
self._exdate = []
|
||||
|
||||
def rrule(self, rrule):
|
||||
""" Include the given :py:class:`rrule` instance in the recurrence set
|
||||
generation. """
|
||||
self._rrule.append(rrule)
|
||||
|
||||
def rdate(self, rdate):
|
||||
""" Include the given :py:class:`datetime` instance in the recurrence
|
||||
set generation. """
|
||||
self._rdate.append(rdate)
|
||||
|
||||
def exrule(self, exrule):
|
||||
""" Include the given rrule instance in the recurrence set exclusion
|
||||
list. Dates which are part of the given recurrence rules will not
|
||||
be generated, even if some inclusive rrule or rdate matches them.
|
||||
"""
|
||||
self._exrule.append(exrule)
|
||||
|
||||
def exdate(self, exdate):
|
||||
""" Include the given datetime instance in the recurrence set
|
||||
exclusion list. Dates included that way will not be generated,
|
||||
even if some inclusive rrule or rdate matches them. """
|
||||
self._exdate.append(exdate)
|
||||
|
||||
def _iter(self):
|
||||
|
@ -905,6 +1308,7 @@ class rruleset(rrulebase):
|
|||
rlist.sort()
|
||||
self._len = total
|
||||
|
||||
|
||||
class _rrulestr(object):
|
||||
|
||||
_freq_map = {"YEARLY": YEARLY,
|
||||
|
@ -915,7 +1319,8 @@ class _rrulestr(object):
|
|||
"MINUTELY": MINUTELY,
|
||||
"SECONDLY": SECONDLY}
|
||||
|
||||
_weekday_map = {"MO":0,"TU":1,"WE":2,"TH":3,"FR":4,"SA":5,"SU":6}
|
||||
_weekday_map = {"MO": 0, "TU": 1, "WE": 2, "TH": 3,
|
||||
"FR": 4, "SA": 5, "SU": 6}
|
||||
|
||||
def _handle_int(self, rrkwargs, name, value, **kwargs):
|
||||
rrkwargs[name.lower()] = int(value)
|
||||
|
@ -952,15 +1357,26 @@ class _rrulestr(object):
|
|||
def _handle_WKST(self, rrkwargs, name, value, **kwargs):
|
||||
rrkwargs["wkst"] = self._weekday_map[value]
|
||||
|
||||
def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwarsg):
|
||||
def _handle_BYWEEKDAY(self, rrkwargs, name, value, **kwargs):
|
||||
"""
|
||||
Two ways to specify this: +1MO or MO(+1)
|
||||
"""
|
||||
l = []
|
||||
for wday in value.split(','):
|
||||
if '(' in wday:
|
||||
# If it's of the form TH(+1), etc.
|
||||
splt = wday.split('(')
|
||||
w = splt[0]
|
||||
n = int(splt[1][:-1])
|
||||
else:
|
||||
# If it's of the form +1MO
|
||||
for i in range(len(wday)):
|
||||
if wday[i] not in '+-0123456789':
|
||||
break
|
||||
n = wday[:i] or None
|
||||
w = wday[i:]
|
||||
if n: n = int(n)
|
||||
if n:
|
||||
n = int(n)
|
||||
l.append(weekdays[self._weekday_map[w]](n))
|
||||
rrkwargs["byweekday"] = l
|
||||
|
||||
|
@ -1021,8 +1437,8 @@ class _rrulestr(object):
|
|||
i += 1
|
||||
else:
|
||||
lines = s.split()
|
||||
if (not forceset and len(lines) == 1 and
|
||||
(s.find(':') == -1 or s.startswith('RRULE:'))):
|
||||
if (not forceset and len(lines) == 1 and (s.find(':') == -1 or
|
||||
s.startswith('RRULE:'))):
|
||||
return self._parse_rfc_rrule(lines[0], cache=cache,
|
||||
dtstart=dtstart, ignoretz=ignoretz,
|
||||
tzinfos=tzinfos)
|
||||
|
@ -1071,32 +1487,32 @@ class _rrulestr(object):
|
|||
tzinfos=tzinfos)
|
||||
else:
|
||||
raise ValueError("unsupported property: "+name)
|
||||
if (forceset or len(rrulevals) > 1 or
|
||||
rdatevals or exrulevals or exdatevals):
|
||||
if (forceset or len(rrulevals) > 1 or rdatevals
|
||||
or exrulevals or exdatevals):
|
||||
if not parser and (rdatevals or exdatevals):
|
||||
from dateutil import parser
|
||||
set = rruleset(cache=cache)
|
||||
rset = rruleset(cache=cache)
|
||||
for value in rrulevals:
|
||||
set.rrule(self._parse_rfc_rrule(value, dtstart=dtstart,
|
||||
rset.rrule(self._parse_rfc_rrule(value, dtstart=dtstart,
|
||||
ignoretz=ignoretz,
|
||||
tzinfos=tzinfos))
|
||||
for value in rdatevals:
|
||||
for datestr in value.split(','):
|
||||
set.rdate(parser.parse(datestr,
|
||||
rset.rdate(parser.parse(datestr,
|
||||
ignoretz=ignoretz,
|
||||
tzinfos=tzinfos))
|
||||
for value in exrulevals:
|
||||
set.exrule(self._parse_rfc_rrule(value, dtstart=dtstart,
|
||||
rset.exrule(self._parse_rfc_rrule(value, dtstart=dtstart,
|
||||
ignoretz=ignoretz,
|
||||
tzinfos=tzinfos))
|
||||
for value in exdatevals:
|
||||
for datestr in value.split(','):
|
||||
set.exdate(parser.parse(datestr,
|
||||
rset.exdate(parser.parse(datestr,
|
||||
ignoretz=ignoretz,
|
||||
tzinfos=tzinfos))
|
||||
if compatible and dtstart:
|
||||
set.rdate(dtstart)
|
||||
return set
|
||||
rset.rdate(dtstart)
|
||||
return rset
|
||||
else:
|
||||
return self._parse_rfc_rrule(rrulevals[0],
|
||||
dtstart=dtstart,
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2003-2007 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
This module offers timezone implementations subclassing the abstract
|
||||
:py:`datetime.tzinfo` type. There are classes to handle tzfile format files
|
||||
(usually are in :file:`/etc/localtime`, :file:`/usr/share/zoneinfo`, etc), TZ
|
||||
environment string (in all known formats), given ranges (with help from
|
||||
relative deltas), local machine timezone, fixed offset timezone, and UTC
|
||||
timezone.
|
||||
"""
|
||||
__license__ = "Simplified BSD"
|
||||
|
||||
from six import string_types, PY3
|
||||
|
||||
import datetime
|
||||
import struct
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
||||
from six import string_types, PY3
|
||||
|
||||
try:
|
||||
from dateutil.tzwin import tzwin, tzwinlocal
|
||||
except ImportError:
|
||||
tzwin = tzwinlocal = None
|
||||
|
||||
relativedelta = None
|
||||
parser = None
|
||||
rrule = None
|
||||
|
@ -21,27 +27,26 @@ rrule = None
|
|||
__all__ = ["tzutc", "tzoffset", "tzlocal", "tzfile", "tzrange",
|
||||
"tzstr", "tzical", "tzwin", "tzwinlocal", "gettz"]
|
||||
|
||||
try:
|
||||
from dateutil.tzwin import tzwin, tzwinlocal
|
||||
except (ImportError, OSError):
|
||||
tzwin, tzwinlocal = None, None
|
||||
|
||||
def tzname_in_python2(myfunc):
|
||||
def tzname_in_python2(namefunc):
|
||||
"""Change unicode output into bytestrings in Python 2
|
||||
|
||||
tzname() API changed in Python 3. It used to return bytes, but was changed
|
||||
to unicode strings
|
||||
"""
|
||||
def inner_func(*args, **kwargs):
|
||||
if PY3:
|
||||
return myfunc(*args, **kwargs)
|
||||
else:
|
||||
return myfunc(*args, **kwargs).encode()
|
||||
return inner_func
|
||||
def adjust_encoding(*args, **kwargs):
|
||||
name = namefunc(*args, **kwargs)
|
||||
if name is not None and not PY3:
|
||||
name = name.encode()
|
||||
|
||||
return name
|
||||
|
||||
return adjust_encoding
|
||||
|
||||
ZERO = datetime.timedelta(0)
|
||||
EPOCHORDINAL = datetime.datetime.utcfromtimestamp(0).toordinal()
|
||||
|
||||
|
||||
class tzutc(datetime.tzinfo):
|
||||
|
||||
def utcoffset(self, dt):
|
||||
|
@ -66,6 +71,7 @@ class tzutc(datetime.tzinfo):
|
|||
|
||||
__reduce__ = object.__reduce__
|
||||
|
||||
|
||||
class tzoffset(datetime.tzinfo):
|
||||
|
||||
def __init__(self, name, offset):
|
||||
|
@ -96,6 +102,7 @@ class tzoffset(datetime.tzinfo):
|
|||
|
||||
__reduce__ = object.__reduce__
|
||||
|
||||
|
||||
class tzlocal(datetime.tzinfo):
|
||||
|
||||
_std_offset = datetime.timedelta(seconds=-time.timezone)
|
||||
|
@ -166,6 +173,7 @@ class tzlocal(datetime.tzinfo):
|
|||
|
||||
__reduce__ = object.__reduce__
|
||||
|
||||
|
||||
class _ttinfo(object):
|
||||
__slots__ = ["offset", "delta", "isdst", "abbr", "isstd", "isgmt"]
|
||||
|
||||
|
@ -205,15 +213,20 @@ class _ttinfo(object):
|
|||
if name in state:
|
||||
setattr(self, name, state[name])
|
||||
|
||||
|
||||
class tzfile(datetime.tzinfo):
|
||||
|
||||
# http://www.twinsun.com/tz/tz-link.htm
|
||||
# ftp://ftp.iana.org/tz/tz*.tar.gz
|
||||
|
||||
def __init__(self, fileobj):
|
||||
def __init__(self, fileobj, filename=None):
|
||||
file_opened_here = False
|
||||
if isinstance(fileobj, string_types):
|
||||
self._filename = fileobj
|
||||
fileobj = open(fileobj, 'rb')
|
||||
file_opened_here = True
|
||||
elif filename is not None:
|
||||
self._filename = filename
|
||||
elif hasattr(fileobj, "name"):
|
||||
self._filename = fileobj.name
|
||||
else:
|
||||
|
@ -228,7 +241,7 @@ class tzfile(datetime.tzinfo):
|
|||
# six four-byte values of type long, written in a
|
||||
# ``standard'' byte order (the high-order byte
|
||||
# of the value is written first).
|
||||
|
||||
try:
|
||||
if fileobj.read(4).decode() != "TZif":
|
||||
raise ValueError("magic not found")
|
||||
|
||||
|
@ -313,9 +326,9 @@ class tzfile(datetime.tzinfo):
|
|||
# by time.
|
||||
|
||||
# Not used, for now
|
||||
if leapcnt:
|
||||
leap = struct.unpack(">%dl" % (leapcnt*2),
|
||||
fileobj.read(leapcnt*8))
|
||||
# if leapcnt:
|
||||
# leap = struct.unpack(">%dl" % (leapcnt*2),
|
||||
# fileobj.read(leapcnt*8))
|
||||
|
||||
# Then there are tzh_ttisstdcnt standard/wall
|
||||
# indicators, each stored as a one-byte value;
|
||||
|
@ -342,6 +355,9 @@ class tzfile(datetime.tzinfo):
|
|||
fileobj.read(ttisgmtcnt))
|
||||
|
||||
# ** Everything has been read **
|
||||
finally:
|
||||
if file_opened_here:
|
||||
fileobj.close()
|
||||
|
||||
# Build ttinfo list
|
||||
self._ttinfo_list = []
|
||||
|
@ -481,7 +497,6 @@ class tzfile(datetime.tzinfo):
|
|||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
return "%s(%s)" % (self.__class__.__name__, repr(self._filename))
|
||||
|
||||
|
@ -490,8 +505,8 @@ class tzfile(datetime.tzinfo):
|
|||
raise ValueError("Unpickable %s class" % self.__class__.__name__)
|
||||
return (self.__class__, (self._filename,))
|
||||
|
||||
class tzrange(datetime.tzinfo):
|
||||
|
||||
class tzrange(datetime.tzinfo):
|
||||
def __init__(self, stdabbr, stdoffset=None,
|
||||
dstabbr=None, dstoffset=None,
|
||||
start=None, end=None):
|
||||
|
@ -570,6 +585,7 @@ class tzrange(datetime.tzinfo):
|
|||
|
||||
__reduce__ = object.__reduce__
|
||||
|
||||
|
||||
class tzstr(tzrange):
|
||||
|
||||
def __init__(self, s):
|
||||
|
@ -645,6 +661,7 @@ class tzstr(tzrange):
|
|||
def __repr__(self):
|
||||
return "%s(%s)" % (self.__class__.__name__, repr(self._s))
|
||||
|
||||
|
||||
class _tzicalvtzcomp(object):
|
||||
def __init__(self, tzoffsetfrom, tzoffsetto, isdst,
|
||||
tzname=None, rrule=None):
|
||||
|
@ -655,6 +672,7 @@ class _tzicalvtzcomp(object):
|
|||
self.tzname = tzname
|
||||
self.rrule = rrule
|
||||
|
||||
|
||||
class _tzicalvtz(datetime.tzinfo):
|
||||
def __init__(self, tzid, comps=[]):
|
||||
self._tzid = tzid
|
||||
|
@ -718,6 +736,7 @@ class _tzicalvtz(datetime.tzinfo):
|
|||
|
||||
__reduce__ = object.__reduce__
|
||||
|
||||
|
||||
class tzical(object):
|
||||
def __init__(self, fileobj):
|
||||
global rrule
|
||||
|
@ -726,7 +745,8 @@ class tzical(object):
|
|||
|
||||
if isinstance(fileobj, string_types):
|
||||
self._s = fileobj
|
||||
fileobj = open(fileobj, 'r') # ical should be encoded in UTF-8 with CRLF
|
||||
# ical should be encoded in UTF-8 with CRLF
|
||||
fileobj = open(fileobj, 'r')
|
||||
elif hasattr(fileobj, "name"):
|
||||
self._s = fileobj.name
|
||||
else:
|
||||
|
@ -815,7 +835,8 @@ class tzical(object):
|
|||
if not tzid:
|
||||
raise ValueError("mandatory TZID not found")
|
||||
if not comps:
|
||||
raise ValueError("at least one component is needed")
|
||||
raise ValueError(
|
||||
"at least one component is needed")
|
||||
# Process vtimezone
|
||||
self._vtz[tzid] = _tzicalvtz(tzid, comps)
|
||||
invtz = False
|
||||
|
@ -823,9 +844,11 @@ class tzical(object):
|
|||
if not founddtstart:
|
||||
raise ValueError("mandatory DTSTART not found")
|
||||
if tzoffsetfrom is None:
|
||||
raise ValueError("mandatory TZOFFSETFROM not found")
|
||||
raise ValueError(
|
||||
"mandatory TZOFFSETFROM not found")
|
||||
if tzoffsetto is None:
|
||||
raise ValueError("mandatory TZOFFSETFROM not found")
|
||||
raise ValueError(
|
||||
"mandatory TZOFFSETFROM not found")
|
||||
# Process component
|
||||
rr = None
|
||||
if rrulelines:
|
||||
|
@ -848,15 +871,18 @@ class tzical(object):
|
|||
rrulelines.append(line)
|
||||
elif name == "TZOFFSETFROM":
|
||||
if parms:
|
||||
raise ValueError("unsupported %s parm: %s "%(name, parms[0]))
|
||||
raise ValueError(
|
||||
"unsupported %s parm: %s " % (name, parms[0]))
|
||||
tzoffsetfrom = self._parse_offset(value)
|
||||
elif name == "TZOFFSETTO":
|
||||
if parms:
|
||||
raise ValueError("unsupported TZOFFSETTO parm: "+parms[0])
|
||||
raise ValueError(
|
||||
"unsupported TZOFFSETTO parm: "+parms[0])
|
||||
tzoffsetto = self._parse_offset(value)
|
||||
elif name == "TZNAME":
|
||||
if parms:
|
||||
raise ValueError("unsupported TZNAME parm: "+parms[0])
|
||||
raise ValueError(
|
||||
"unsupported TZNAME parm: "+parms[0])
|
||||
tzname = value
|
||||
elif name == "COMMENT":
|
||||
pass
|
||||
|
@ -865,7 +891,8 @@ class tzical(object):
|
|||
else:
|
||||
if name == "TZID":
|
||||
if parms:
|
||||
raise ValueError("unsupported TZID parm: "+parms[0])
|
||||
raise ValueError(
|
||||
"unsupported TZID parm: "+parms[0])
|
||||
tzid = value
|
||||
elif name in ("TZURL", "LAST-MODIFIED", "COMMENT"):
|
||||
pass
|
||||
|
@ -886,6 +913,7 @@ else:
|
|||
TZFILES = []
|
||||
TZPATHS = []
|
||||
|
||||
|
||||
def gettz(name=None):
|
||||
tz = None
|
||||
if not name:
|
||||
|
@ -933,11 +961,11 @@ def gettz(name=None):
|
|||
pass
|
||||
else:
|
||||
tz = None
|
||||
if tzwin:
|
||||
if tzwin is not None:
|
||||
try:
|
||||
tz = tzwin(name)
|
||||
except OSError:
|
||||
pass
|
||||
except WindowsError:
|
||||
tz = None
|
||||
if not tz:
|
||||
from dateutil.zoneinfo import gettz
|
||||
tz = gettz(name)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
# This code was originally contributed by Jeffrey Harris.
|
||||
import datetime
|
||||
import struct
|
||||
import winreg
|
||||
|
||||
from six.moves import winreg
|
||||
|
||||
__all__ = ["tzwin", "tzwinlocal"]
|
||||
|
||||
|
@ -12,8 +12,8 @@ TZKEYNAMENT = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zones"
|
|||
TZKEYNAME9X = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Time Zones"
|
||||
TZLOCALKEYNAME = r"SYSTEM\CurrentControlSet\Control\TimeZoneInformation"
|
||||
|
||||
|
||||
def _settzkeyname():
|
||||
global TZKEYNAME
|
||||
handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
|
||||
try:
|
||||
winreg.OpenKey(handle, TZKEYNAMENT).Close()
|
||||
|
@ -21,8 +21,10 @@ def _settzkeyname():
|
|||
except WindowsError:
|
||||
TZKEYNAME = TZKEYNAME9X
|
||||
handle.Close()
|
||||
return TZKEYNAME
|
||||
|
||||
TZKEYNAME = _settzkeyname()
|
||||
|
||||
_settzkeyname()
|
||||
|
||||
class tzwinbase(datetime.tzinfo):
|
||||
"""tzinfo class based on win32's timezones available in the registry."""
|
||||
|
@ -61,6 +63,9 @@ class tzwinbase(datetime.tzinfo):
|
|||
return self._display
|
||||
|
||||
def _isdst(self, dt):
|
||||
if not self._dstmonth:
|
||||
# dstmonth == 0 signals the zone has no daylight saving time
|
||||
return False
|
||||
dston = picknthweekday(dt.year, self._dstmonth, self._dstdayofweek,
|
||||
self._dsthour, self._dstminute,
|
||||
self._dstweeknumber)
|
||||
|
@ -78,11 +83,11 @@ class tzwin(tzwinbase):
|
|||
def __init__(self, name):
|
||||
self._name = name
|
||||
|
||||
handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
|
||||
tzkey = winreg.OpenKey(handle, "%s\%s" % (TZKEYNAME, name))
|
||||
# multiple contexts only possible in 2.7 and 3.1, we still support 2.6
|
||||
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle:
|
||||
with winreg.OpenKey(handle,
|
||||
"%s\%s" % (TZKEYNAME, name)) as tzkey:
|
||||
keydict = valuestodict(tzkey)
|
||||
tzkey.Close()
|
||||
handle.Close()
|
||||
|
||||
self._stdname = keydict["Std"].encode("iso-8859-1")
|
||||
self._dstname = keydict["Dlt"].encode("iso-8859-1")
|
||||
|
@ -94,6 +99,8 @@ class tzwin(tzwinbase):
|
|||
self._stdoffset = -tup[0]-tup[1] # Bias + StandardBias * -1
|
||||
self._dstoffset = self._stdoffset-tup[2] # + DaylightBias * -1
|
||||
|
||||
# for the meaning see the win32 TIME_ZONE_INFORMATION structure docs
|
||||
# http://msdn.microsoft.com/en-us/library/windows/desktop/ms725481(v=vs.85).aspx
|
||||
(self._stdmonth,
|
||||
self._stddayofweek, # Sunday = 0
|
||||
self._stdweeknumber, # Last = 5
|
||||
|
@ -117,29 +124,25 @@ class tzwinlocal(tzwinbase):
|
|||
|
||||
def __init__(self):
|
||||
|
||||
handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
|
||||
with winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) as handle:
|
||||
|
||||
tzlocalkey = winreg.OpenKey(handle, TZLOCALKEYNAME)
|
||||
with winreg.OpenKey(handle, TZLOCALKEYNAME) as tzlocalkey:
|
||||
keydict = valuestodict(tzlocalkey)
|
||||
tzlocalkey.Close()
|
||||
|
||||
self._stdname = keydict["StandardName"].encode("iso-8859-1")
|
||||
self._dstname = keydict["DaylightName"].encode("iso-8859-1")
|
||||
|
||||
try:
|
||||
tzkey = winreg.OpenKey(handle, "%s\%s"%(TZKEYNAME, self._stdname))
|
||||
with winreg.OpenKey(
|
||||
handle, "%s\%s" % (TZKEYNAME, self._stdname)) as tzkey:
|
||||
_keydict = valuestodict(tzkey)
|
||||
self._display = _keydict["Display"]
|
||||
tzkey.Close()
|
||||
except OSError:
|
||||
self._display = None
|
||||
|
||||
handle.Close()
|
||||
|
||||
self._stdoffset = -keydict["Bias"]-keydict["StandardBias"]
|
||||
self._dstoffset = self._stdoffset-keydict["DaylightBias"]
|
||||
|
||||
|
||||
# See http://ww_winreg.jsiinc.com/SUBA/tip0300/rh0398.htm
|
||||
tup = struct.unpack("=8h", keydict["StandardStart"])
|
||||
|
||||
|
@ -160,6 +163,7 @@ class tzwinlocal(tzwinbase):
|
|||
def __reduce__(self):
|
||||
return (self.__class__, ())
|
||||
|
||||
|
||||
def picknthweekday(year, month, dayofweek, hour, minute, whichweek):
|
||||
"""dayofweek == 0 means Sunday, whichweek 5 means last instance"""
|
||||
first = datetime.datetime(year, month, 1, hour, minute)
|
||||
|
@ -169,6 +173,7 @@ def picknthweekday(year, month, dayofweek, hour, minute, whichweek):
|
|||
if dt.month == month:
|
||||
return dt
|
||||
|
||||
|
||||
def valuestodict(key):
|
||||
"""Convert a registry key's values to a dictionary."""
|
||||
dict = {}
|
||||
|
|
|
@ -1,93 +1,121 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2003-2005 Gustavo Niemeyer <gustavo@niemeyer.net>
|
||||
|
||||
This module offers extensions to the standard Python
|
||||
datetime module.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from subprocess import call
|
||||
import warnings
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
|
||||
from subprocess import check_call
|
||||
from tarfile import TarFile
|
||||
from pkgutil import get_data
|
||||
from io import BytesIO
|
||||
from contextlib import closing
|
||||
|
||||
from dateutil.tz import tzfile
|
||||
|
||||
__author__ = "Tomi Pieviläinen <tomi.pievilainen@iki.fi>"
|
||||
__license__ = "Simplified BSD"
|
||||
__all__ = ["gettz", "gettz_db_metadata", "rebuild"]
|
||||
|
||||
__all__ = ["setcachesize", "gettz", "rebuild"]
|
||||
_ZONEFILENAME = "dateutil-zoneinfo.tar.gz"
|
||||
_METADATA_FN = 'METADATA'
|
||||
|
||||
# python2.6 compatability. Note that TarFile.__exit__ != TarFile.close, but
|
||||
# it's close enough for python2.6
|
||||
_tar_open = TarFile.open
|
||||
if not hasattr(TarFile, '__exit__'):
|
||||
def _tar_open(*args, **kwargs):
|
||||
return closing(TarFile.open(*args, **kwargs))
|
||||
|
||||
CACHE = []
|
||||
CACHESIZE = 10
|
||||
|
||||
class tzfile(tzfile):
|
||||
def __reduce__(self):
|
||||
return (gettz, (self._filename,))
|
||||
|
||||
def getzoneinfofile():
|
||||
filenames = sorted(os.listdir(os.path.join(os.path.dirname(__file__))))
|
||||
filenames.reverse()
|
||||
for entry in filenames:
|
||||
if entry.startswith("zoneinfo") and ".tar." in entry:
|
||||
return os.path.join(os.path.dirname(__file__), entry)
|
||||
|
||||
def getzoneinfofile_stream():
|
||||
try:
|
||||
return BytesIO(get_data(__name__, _ZONEFILENAME))
|
||||
except IOError as e: # TODO switch to FileNotFoundError?
|
||||
warnings.warn("I/O error({0}): {1}".format(e.errno, e.strerror))
|
||||
return None
|
||||
|
||||
ZONEINFOFILE = getzoneinfofile()
|
||||
|
||||
del getzoneinfofile
|
||||
class ZoneInfoFile(object):
|
||||
def __init__(self, zonefile_stream=None):
|
||||
if zonefile_stream is not None:
|
||||
with _tar_open(fileobj=zonefile_stream, mode='r') as tf:
|
||||
# dict comprehension does not work on python2.6
|
||||
# TODO: get back to the nicer syntax when we ditch python2.6
|
||||
# self.zones = {zf.name: tzfile(tf.extractfile(zf),
|
||||
# filename = zf.name)
|
||||
# for zf in tf.getmembers() if zf.isfile()}
|
||||
self.zones = dict((zf.name, tzfile(tf.extractfile(zf),
|
||||
filename=zf.name))
|
||||
for zf in tf.getmembers()
|
||||
if zf.isfile() and zf.name != _METADATA_FN)
|
||||
# deal with links: They'll point to their parent object. Less
|
||||
# waste of memory
|
||||
# links = {zl.name: self.zones[zl.linkname]
|
||||
# for zl in tf.getmembers() if zl.islnk() or zl.issym()}
|
||||
links = dict((zl.name, self.zones[zl.linkname])
|
||||
for zl in tf.getmembers() if
|
||||
zl.islnk() or zl.issym())
|
||||
self.zones.update(links)
|
||||
try:
|
||||
metadata_json = tf.extractfile(tf.getmember(_METADATA_FN))
|
||||
metadata_str = metadata_json.read().decode('UTF-8')
|
||||
self.metadata = json.loads(metadata_str)
|
||||
except KeyError:
|
||||
# no metadata in tar file
|
||||
self.metadata = None
|
||||
else:
|
||||
self.zones = dict()
|
||||
self.metadata = None
|
||||
|
||||
|
||||
# The current API has gettz as a module function, although in fact it taps into
|
||||
# a stateful class. So as a workaround for now, without changing the API, we
|
||||
# will create a new "global" class instance the first time a user requests a
|
||||
# timezone. Ugly, but adheres to the api.
|
||||
#
|
||||
# TODO: deprecate this.
|
||||
_CLASS_ZONE_INSTANCE = list()
|
||||
|
||||
def setcachesize(size):
|
||||
global CACHESIZE, CACHE
|
||||
CACHESIZE = size
|
||||
del CACHE[size:]
|
||||
|
||||
def gettz(name):
|
||||
tzinfo = None
|
||||
if ZONEINFOFILE:
|
||||
for cachedname, tzinfo in CACHE:
|
||||
if cachedname == name:
|
||||
break
|
||||
else:
|
||||
tf = TarFile.open(ZONEINFOFILE)
|
||||
try:
|
||||
zonefile = tf.extractfile(name)
|
||||
except KeyError:
|
||||
tzinfo = None
|
||||
else:
|
||||
tzinfo = tzfile(zonefile)
|
||||
tf.close()
|
||||
CACHE.insert(0, (name, tzinfo))
|
||||
del CACHE[CACHESIZE:]
|
||||
return tzinfo
|
||||
if len(_CLASS_ZONE_INSTANCE) == 0:
|
||||
_CLASS_ZONE_INSTANCE.append(ZoneInfoFile(getzoneinfofile_stream()))
|
||||
return _CLASS_ZONE_INSTANCE[0].zones.get(name)
|
||||
|
||||
def rebuild(filename, tag=None, format="gz"):
|
||||
|
||||
def gettz_db_metadata():
|
||||
""" Get the zonefile metadata
|
||||
|
||||
See `zonefile_metadata`_
|
||||
|
||||
:returns: A dictionary with the database metadata
|
||||
"""
|
||||
if len(_CLASS_ZONE_INSTANCE) == 0:
|
||||
_CLASS_ZONE_INSTANCE.append(ZoneInfoFile(getzoneinfofile_stream()))
|
||||
return _CLASS_ZONE_INSTANCE[0].metadata
|
||||
|
||||
|
||||
def rebuild(filename, tag=None, format="gz", zonegroups=[], metadata=None):
|
||||
"""Rebuild the internal timezone info in dateutil/zoneinfo/zoneinfo*tar*
|
||||
|
||||
filename is the timezone tarball from ftp.iana.org/tz.
|
||||
|
||||
"""
|
||||
import tempfile, shutil
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
zonedir = os.path.join(tmpdir, "zoneinfo")
|
||||
moduledir = os.path.dirname(__file__)
|
||||
if tag: tag = "-"+tag
|
||||
targetname = "zoneinfo%s.tar.%s" % (tag, format)
|
||||
try:
|
||||
tf = TarFile.open(filename)
|
||||
# The "backwards" zone file contains links to other files, so must be
|
||||
# processed as last
|
||||
for name in sorted(tf.getnames(),
|
||||
key=lambda k: k != "backward" and k or "z"):
|
||||
if not (name.endswith(".sh") or
|
||||
name.endswith(".tab") or
|
||||
name == "leapseconds"):
|
||||
with _tar_open(filename) as tf:
|
||||
for name in zonegroups:
|
||||
tf.extract(name, tmpdir)
|
||||
filepath = os.path.join(tmpdir, name)
|
||||
filepaths = [os.path.join(tmpdir, n) for n in zonegroups]
|
||||
try:
|
||||
# zic will return errors for nontz files in the package
|
||||
# such as the Makefile or README, so check_call cannot
|
||||
# be used (or at least extra checks would be needed)
|
||||
call(["zic", "-d", zonedir, filepath])
|
||||
check_call(["zic", "-d", zonedir] + filepaths)
|
||||
except OSError as e:
|
||||
if e.errno == 2:
|
||||
logging.error(
|
||||
|
@ -95,15 +123,13 @@ def rebuild(filename, tag=None, format="gz"):
|
|||
"libc-bin or some other package that provides it, "
|
||||
"or it's not in your PATH?")
|
||||
raise
|
||||
tf.close()
|
||||
target = os.path.join(moduledir, targetname)
|
||||
for entry in os.listdir(moduledir):
|
||||
if entry.startswith("zoneinfo") and ".tar." in entry:
|
||||
os.unlink(os.path.join(moduledir, entry))
|
||||
tf = TarFile.open(target, "w:%s" % format)
|
||||
# write metadata file
|
||||
with open(os.path.join(zonedir, _METADATA_FN), 'w') as f:
|
||||
json.dump(metadata, f, indent=4, sort_keys=True)
|
||||
target = os.path.join(moduledir, _ZONEFILENAME)
|
||||
with _tar_open(target, "w:%s" % format) as tf:
|
||||
for entry in os.listdir(zonedir):
|
||||
entrypath = os.path.join(zonedir, entry)
|
||||
tf.add(entrypath, entry)
|
||||
tf.close()
|
||||
finally:
|
||||
shutil.rmtree(tmpdir)
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
from cache import Cache
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
|
@ -1,204 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
from feedparser import feedparser
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
logger = logging.getLogger('feedcache.cache')
|
||||
|
||||
|
||||
class Cache:
|
||||
"""A class to wrap Mark Pilgrim's Universal Feed Parser module
|
||||
(http://www.feedparser.org) so that parameters can be used to
|
||||
cache the feed results locally instead of fetching the feed every
|
||||
time it is requested. Uses both etag and modified times for
|
||||
caching.
|
||||
"""
|
||||
|
||||
def __init__(self, storage, timeToLiveSeconds=300, userAgent='feedcache'):
|
||||
"""
|
||||
Arguments:
|
||||
|
||||
storage -- Backing store for the cache. It should follow
|
||||
the dictionary API, with URLs used as keys. It should
|
||||
persist data.
|
||||
|
||||
timeToLiveSeconds=300 -- The length of time content should
|
||||
live in the cache before an update is attempted.
|
||||
|
||||
userAgent='feedcache' -- User agent string to be used when
|
||||
fetching feed contents.
|
||||
|
||||
"""
|
||||
self.storage = storage
|
||||
self.time_to_live = timeToLiveSeconds
|
||||
self.user_agent = userAgent
|
||||
return
|
||||
|
||||
def purge(self, olderThanSeconds):
|
||||
"""Remove cached data from the storage if the data is older than the
|
||||
date given. If olderThanSeconds is None, the entire cache is purged.
|
||||
"""
|
||||
if olderThanSeconds is None:
|
||||
logger.debug('purging the entire cache')
|
||||
for key in self.storage.keys():
|
||||
del self.storage[key]
|
||||
else:
|
||||
now = time.time()
|
||||
# Iterate over the keys and load each item one at a time
|
||||
# to avoid having the entire cache loaded into memory
|
||||
# at one time.
|
||||
for url in self.storage.keys():
|
||||
(cached_time, cached_data) = self.storage[url]
|
||||
age = now - cached_time
|
||||
if age >= olderThanSeconds:
|
||||
logger.debug('removing %s with age %d', url, age)
|
||||
del self.storage[url]
|
||||
return
|
||||
|
||||
def fetch(self, url, force_update=False, offline=False, request_headers=None):
|
||||
"""Return the feed at url.
|
||||
|
||||
url - The URL of the feed.
|
||||
|
||||
force_update=False - When True, update the cache whether the
|
||||
current contents have
|
||||
exceeded their time-to-live
|
||||
or not.
|
||||
|
||||
offline=False - When True, only return data from the local
|
||||
cache and never access the remote
|
||||
URL.
|
||||
|
||||
If there is data for that feed in the cache already, check
|
||||
the expiration date before accessing the server. If the
|
||||
cached data has not expired, return it without accessing the
|
||||
server.
|
||||
|
||||
In cases where the server is accessed, check for updates
|
||||
before deciding what to return. If the server reports a
|
||||
status of 304, the previously cached content is returned.
|
||||
|
||||
The cache is only updated if the server returns a status of
|
||||
200, to avoid holding redirected data in the cache.
|
||||
"""
|
||||
logger.debug('url="%s"' % url)
|
||||
|
||||
# Convert the URL to a value we can use
|
||||
# as a key for the storage backend.
|
||||
key = url
|
||||
if isinstance(key, unicode):
|
||||
key = key.encode('utf-8')
|
||||
|
||||
modified = None
|
||||
etag = None
|
||||
now = time.time()
|
||||
|
||||
cached_time, cached_content = self.storage.get(key, (None, None))
|
||||
|
||||
# Offline mode support (no networked requests)
|
||||
# so return whatever we found in the storage.
|
||||
# If there is nothing in the storage, we'll be returning None.
|
||||
if offline:
|
||||
logger.debug('offline mode')
|
||||
return cached_content
|
||||
|
||||
# Does the storage contain a version of the data
|
||||
# which is older than the time-to-live?
|
||||
logger.debug('cache modified time: %s' % str(cached_time))
|
||||
if cached_time is not None and not force_update:
|
||||
if self.time_to_live:
|
||||
age = now - cached_time
|
||||
if age <= self.time_to_live:
|
||||
logger.debug('cache contents still valid')
|
||||
return cached_content
|
||||
else:
|
||||
logger.debug('cache contents older than TTL')
|
||||
else:
|
||||
logger.debug('no TTL value')
|
||||
|
||||
# The cache is out of date, but we have
|
||||
# something. Try to use the etag and modified_time
|
||||
# values from the cached content.
|
||||
etag = cached_content.get('etag')
|
||||
modified = cached_content.get('modified')
|
||||
logger.debug('cached etag=%s' % etag)
|
||||
logger.debug('cached modified=%s' % str(modified))
|
||||
else:
|
||||
logger.debug('nothing in the cache, or forcing update')
|
||||
|
||||
# We know we need to fetch, so go ahead and do it.
|
||||
logger.debug('fetching...')
|
||||
parsed_result = feedparser.parse(url,
|
||||
agent=self.user_agent,
|
||||
modified=modified,
|
||||
etag=etag,
|
||||
request_headers=request_headers)
|
||||
|
||||
status = parsed_result.get('status', None)
|
||||
logger.debug('HTTP status=%s' % status)
|
||||
if status == 304:
|
||||
# No new data, based on the etag or modified values.
|
||||
# We need to update the modified time in the
|
||||
# storage, though, so we know that what we have
|
||||
# stored is up to date.
|
||||
self.storage[key] = (now, cached_content)
|
||||
|
||||
# Return the data from the cache, since
|
||||
# the parsed data will be empty.
|
||||
parsed_result = cached_content
|
||||
elif status == 200:
|
||||
# There is new content, so store it unless there was an error.
|
||||
error = parsed_result.get('bozo_exception')
|
||||
if not error:
|
||||
logger.debug('Updating stored data for %s' % url)
|
||||
self.storage[key] = (now, parsed_result)
|
||||
else:
|
||||
logger.warning('Not storing data with exception: %s',
|
||||
error)
|
||||
else:
|
||||
logger.warning('Not updating cache with HTTP status %s', status)
|
||||
|
||||
return parsed_result
|
|
@ -1,69 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
from __future__ import with_statement
|
||||
|
||||
"""Lock wrapper for cache storage which do not permit multi-threaded access.
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import threading
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
class CacheStorageLock:
|
||||
"""Lock wrapper for cache storage which do not permit multi-threaded access.
|
||||
"""
|
||||
|
||||
def __init__(self, shelf):
|
||||
self.lock = threading.Lock()
|
||||
self.shelf = shelf
|
||||
return
|
||||
|
||||
def __getitem__(self, key):
|
||||
with self.lock:
|
||||
return self.shelf[key]
|
||||
|
||||
def get(self, key, default=None):
|
||||
with self.lock:
|
||||
try:
|
||||
return self.shelf[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
with self.lock:
|
||||
self.shelf[key] = value
|
|
@ -1,63 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""Example use of feedcache.Cache.
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import sys
|
||||
import shelve
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
import cache
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
def main(urls=[]):
|
||||
print 'Saving feed data to ./.feedcache'
|
||||
storage = shelve.open('.feedcache')
|
||||
try:
|
||||
fc = cache.Cache(storage)
|
||||
for url in urls:
|
||||
parsed_data = fc.fetch(url)
|
||||
print parsed_data.feed.title
|
||||
for entry in parsed_data.entries:
|
||||
print '\t', entry.title
|
||||
finally:
|
||||
storage.close()
|
||||
return
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1:])
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""Example use of feedcache.Cache combined with threads.
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import Queue
|
||||
import sys
|
||||
import shove
|
||||
import threading
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
import cache
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
MAX_THREADS=5
|
||||
OUTPUT_DIR='/tmp/feedcache_example'
|
||||
|
||||
|
||||
def main(urls=[]):
|
||||
|
||||
if not urls:
|
||||
print 'Specify the URLs to a few RSS or Atom feeds on the command line.'
|
||||
return
|
||||
|
||||
# Decide how many threads to start
|
||||
num_threads = min(len(urls), MAX_THREADS)
|
||||
|
||||
# Add the URLs to a queue
|
||||
url_queue = Queue.Queue()
|
||||
for url in urls:
|
||||
url_queue.put(url)
|
||||
|
||||
# Add poison pills to the url queue to cause
|
||||
# the worker threads to break out of their loops
|
||||
for i in range(num_threads):
|
||||
url_queue.put(None)
|
||||
|
||||
# Track the entries in the feeds being fetched
|
||||
entry_queue = Queue.Queue()
|
||||
|
||||
print 'Saving feed data to', OUTPUT_DIR
|
||||
storage = shove.Shove('file://' + OUTPUT_DIR)
|
||||
try:
|
||||
|
||||
# Start a few worker threads
|
||||
worker_threads = []
|
||||
for i in range(num_threads):
|
||||
t = threading.Thread(target=fetch_urls,
|
||||
args=(storage, url_queue, entry_queue,))
|
||||
worker_threads.append(t)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
# Start a thread to print the results
|
||||
printer_thread = threading.Thread(target=print_entries, args=(entry_queue,))
|
||||
printer_thread.setDaemon(True)
|
||||
printer_thread.start()
|
||||
|
||||
# Wait for all of the URLs to be processed
|
||||
url_queue.join()
|
||||
|
||||
# Wait for the worker threads to finish
|
||||
for t in worker_threads:
|
||||
t.join()
|
||||
|
||||
# Poison the print thread and wait for it to exit
|
||||
entry_queue.put((None,None))
|
||||
entry_queue.join()
|
||||
printer_thread.join()
|
||||
|
||||
finally:
|
||||
storage.close()
|
||||
return
|
||||
|
||||
|
||||
def fetch_urls(storage, input_queue, output_queue):
|
||||
"""Thread target for fetching feed data.
|
||||
"""
|
||||
c = cache.Cache(storage)
|
||||
|
||||
while True:
|
||||
next_url = input_queue.get()
|
||||
if next_url is None: # None causes thread to exit
|
||||
input_queue.task_done()
|
||||
break
|
||||
|
||||
feed_data = c.fetch(next_url)
|
||||
for entry in feed_data.entries:
|
||||
output_queue.put( (feed_data.feed, entry) )
|
||||
input_queue.task_done()
|
||||
return
|
||||
|
||||
|
||||
def print_entries(input_queue):
|
||||
"""Thread target for printing the contents of the feeds.
|
||||
"""
|
||||
while True:
|
||||
feed, entry = input_queue.get()
|
||||
if feed is None: # None causes thread to exist
|
||||
input_queue.task_done()
|
||||
break
|
||||
|
||||
print '%s: %s' % (feed.title, entry.title)
|
||||
input_queue.task_done()
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1:])
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""Unittests for feedcache.cache
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG,
|
||||
format='%(asctime)s %(levelname)-8s %(name)s %(message)s',
|
||||
)
|
||||
logger = logging.getLogger('feedcache.test_cache')
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import copy
|
||||
import time
|
||||
import unittest
|
||||
import UserDict
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
import cache
|
||||
from test_server import HTTPTestBase, TestHTTPServer
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
|
||||
class CacheTestBase(HTTPTestBase):
|
||||
|
||||
CACHE_TTL = 30
|
||||
|
||||
def setUp(self):
|
||||
HTTPTestBase.setUp(self)
|
||||
|
||||
self.storage = self.getStorage()
|
||||
self.cache = cache.Cache(self.storage,
|
||||
timeToLiveSeconds=self.CACHE_TTL,
|
||||
userAgent='feedcache.test',
|
||||
)
|
||||
return
|
||||
|
||||
def getStorage(self):
|
||||
"Return a cache storage for the test."
|
||||
return {}
|
||||
|
||||
|
||||
class CacheTest(CacheTestBase):
|
||||
|
||||
CACHE_TTL = 30
|
||||
|
||||
def getServer(self):
|
||||
"These tests do not want to use the ETag or If-Modified-Since headers"
|
||||
return TestHTTPServer(applyModifiedHeaders=False)
|
||||
|
||||
def testRetrieveNotInCache(self):
|
||||
# Retrieve data not already in the cache.
|
||||
feed_data = self.cache.fetch(self.TEST_URL)
|
||||
self.failUnless(feed_data)
|
||||
self.failUnlessEqual(feed_data.feed.title, 'CacheTest test data')
|
||||
return
|
||||
|
||||
def testRetrieveIsInCache(self):
|
||||
# Retrieve data which is alread in the cache,
|
||||
# and verify that the second copy is identitical
|
||||
# to the first.
|
||||
|
||||
# First fetch
|
||||
feed_data = self.cache.fetch(self.TEST_URL)
|
||||
|
||||
# Second fetch
|
||||
feed_data2 = self.cache.fetch(self.TEST_URL)
|
||||
|
||||
# Since it is the in-memory storage, we should have the
|
||||
# exact same object.
|
||||
self.failUnless(feed_data is feed_data2)
|
||||
return
|
||||
|
||||
def testExpireDataInCache(self):
|
||||
# Retrieve data which is in the cache but which
|
||||
# has expired and verify that the second copy
|
||||
# is different from the first.
|
||||
|
||||
# First fetch
|
||||
feed_data = self.cache.fetch(self.TEST_URL)
|
||||
|
||||
# Change the timeout and sleep to move the clock
|
||||
self.cache.time_to_live = 0
|
||||
time.sleep(1)
|
||||
|
||||
# Second fetch
|
||||
feed_data2 = self.cache.fetch(self.TEST_URL)
|
||||
|
||||
# Since we reparsed, the cache response should be different.
|
||||
self.failIf(feed_data is feed_data2)
|
||||
return
|
||||
|
||||
def testForceUpdate(self):
|
||||
# Force cache to retrieve data which is alread in the cache,
|
||||
# and verify that the new data is different.
|
||||
|
||||
# Pre-populate the storage with bad data
|
||||
self.cache.storage[self.TEST_URL] = (time.time() + 100, self.id())
|
||||
|
||||
# Fetch the data
|
||||
feed_data = self.cache.fetch(self.TEST_URL, force_update=True)
|
||||
|
||||
self.failIfEqual(feed_data, self.id())
|
||||
return
|
||||
|
||||
def testOfflineMode(self):
|
||||
# Retrieve data which is alread in the cache,
|
||||
# whether it is expired or not.
|
||||
|
||||
# Pre-populate the storage with data
|
||||
self.cache.storage[self.TEST_URL] = (0, self.id())
|
||||
|
||||
# Fetch it
|
||||
feed_data = self.cache.fetch(self.TEST_URL, offline=True)
|
||||
|
||||
self.failUnlessEqual(feed_data, self.id())
|
||||
return
|
||||
|
||||
def testUnicodeURL(self):
|
||||
# Pass in a URL which is unicode
|
||||
|
||||
url = unicode(self.TEST_URL)
|
||||
feed_data = self.cache.fetch(url)
|
||||
|
||||
storage = self.cache.storage
|
||||
key = unicode(self.TEST_URL).encode('UTF-8')
|
||||
|
||||
# Verify that the storage has a key
|
||||
self.failUnless(key in storage)
|
||||
|
||||
# Now pull the data from the storage directly
|
||||
storage_timeout, storage_data = self.cache.storage.get(key)
|
||||
self.failUnlessEqual(feed_data, storage_data)
|
||||
return
|
||||
|
||||
|
||||
class SingleWriteMemoryStorage(UserDict.UserDict):
|
||||
"""Cache storage which only allows the cache value
|
||||
for a URL to be updated one time.
|
||||
"""
|
||||
|
||||
def __setitem__(self, url, data):
|
||||
if url in self.keys():
|
||||
modified, existing = self[url]
|
||||
# Allow the modified time to change,
|
||||
# but not the feed content.
|
||||
if data[1] != existing:
|
||||
raise AssertionError('Trying to update cache for %s to %s' \
|
||||
% (url, data))
|
||||
UserDict.UserDict.__setitem__(self, url, data)
|
||||
return
|
||||
|
||||
|
||||
class CacheConditionalGETTest(CacheTestBase):
|
||||
|
||||
CACHE_TTL = 0
|
||||
|
||||
def getStorage(self):
|
||||
return SingleWriteMemoryStorage()
|
||||
|
||||
def testFetchOnceForEtag(self):
|
||||
# Fetch data which has a valid ETag value, and verify
|
||||
# that while we hit the server twice the response
|
||||
# codes cause us to use the same data.
|
||||
|
||||
# First fetch populates the cache
|
||||
response1 = self.cache.fetch(self.TEST_URL)
|
||||
self.failUnlessEqual(response1.feed.title, 'CacheTest test data')
|
||||
|
||||
# Remove the modified setting from the cache so we know
|
||||
# the next time we check the etag will be used
|
||||
# to check for updates. Since we are using an in-memory
|
||||
# cache, modifying response1 updates the cache storage
|
||||
# directly.
|
||||
response1['modified'] = None
|
||||
|
||||
# This should result in a 304 status, and no data from
|
||||
# the server. That means the cache won't try to
|
||||
# update the storage, so our SingleWriteMemoryStorage
|
||||
# should not raise and we should have the same
|
||||
# response object.
|
||||
response2 = self.cache.fetch(self.TEST_URL)
|
||||
self.failUnless(response1 is response2)
|
||||
|
||||
# Should have hit the server twice
|
||||
self.failUnlessEqual(self.server.getNumRequests(), 2)
|
||||
return
|
||||
|
||||
def testFetchOnceForModifiedTime(self):
|
||||
# Fetch data which has a valid Last-Modified value, and verify
|
||||
# that while we hit the server twice the response
|
||||
# codes cause us to use the same data.
|
||||
|
||||
# First fetch populates the cache
|
||||
response1 = self.cache.fetch(self.TEST_URL)
|
||||
self.failUnlessEqual(response1.feed.title, 'CacheTest test data')
|
||||
|
||||
# Remove the etag setting from the cache so we know
|
||||
# the next time we check the modified time will be used
|
||||
# to check for updates. Since we are using an in-memory
|
||||
# cache, modifying response1 updates the cache storage
|
||||
# directly.
|
||||
response1['etag'] = None
|
||||
|
||||
# This should result in a 304 status, and no data from
|
||||
# the server. That means the cache won't try to
|
||||
# update the storage, so our SingleWriteMemoryStorage
|
||||
# should not raise and we should have the same
|
||||
# response object.
|
||||
response2 = self.cache.fetch(self.TEST_URL)
|
||||
self.failUnless(response1 is response2)
|
||||
|
||||
# Should have hit the server twice
|
||||
self.failUnlessEqual(self.server.getNumRequests(), 2)
|
||||
return
|
||||
|
||||
|
||||
class CacheRedirectHandlingTest(CacheTestBase):
|
||||
|
||||
def _test(self, response):
|
||||
# Set up the server to redirect requests,
|
||||
# then verify that the cache is not updated
|
||||
# for the original or new URL and that the
|
||||
# redirect status is fed back to us with
|
||||
# the fetched data.
|
||||
|
||||
self.server.setResponse(response, '/redirected')
|
||||
|
||||
response1 = self.cache.fetch(self.TEST_URL)
|
||||
|
||||
# The response should include the status code we set
|
||||
self.failUnlessEqual(response1.get('status'), response)
|
||||
|
||||
# The response should include the new URL, too
|
||||
self.failUnlessEqual(response1.href, self.TEST_URL + 'redirected')
|
||||
|
||||
# The response should not have been cached under either URL
|
||||
self.failIf(self.TEST_URL in self.storage)
|
||||
self.failIf(self.TEST_URL + 'redirected' in self.storage)
|
||||
return
|
||||
|
||||
def test301(self):
|
||||
self._test(301)
|
||||
|
||||
def test302(self):
|
||||
self._test(302)
|
||||
|
||||
def test303(self):
|
||||
self._test(303)
|
||||
|
||||
def test307(self):
|
||||
self._test(307)
|
||||
|
||||
|
||||
class CachePurgeTest(CacheTestBase):
|
||||
|
||||
def testPurgeAll(self):
|
||||
# Remove everything from the cache
|
||||
|
||||
self.cache.fetch(self.TEST_URL)
|
||||
self.failUnless(self.storage.keys(),
|
||||
'Have no data in the cache storage')
|
||||
|
||||
self.cache.purge(None)
|
||||
|
||||
self.failIf(self.storage.keys(),
|
||||
'Still have data in the cache storage')
|
||||
return
|
||||
|
||||
def testPurgeByAge(self):
|
||||
# Remove old content from the cache
|
||||
|
||||
self.cache.fetch(self.TEST_URL)
|
||||
self.failUnless(self.storage.keys(),
|
||||
'have no data in the cache storage')
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
remains = (time.time(), copy.deepcopy(self.storage[self.TEST_URL][1]))
|
||||
self.storage['http://this.should.remain/'] = remains
|
||||
|
||||
self.cache.purge(1)
|
||||
|
||||
self.failUnlessEqual(self.storage.keys(),
|
||||
['http://this.should.remain/'])
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,90 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""Tests for shelflock.
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import os
|
||||
import shelve
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
from cache import Cache
|
||||
from cachestoragelock import CacheStorageLock
|
||||
from test_server import HTTPTestBase
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
class CacheShelveTest(HTTPTestBase):
|
||||
|
||||
def setUp(self):
|
||||
HTTPTestBase.setUp(self)
|
||||
handle, self.shelve_filename = tempfile.mkstemp('.shelve')
|
||||
os.close(handle) # we just want the file name, so close the open handle
|
||||
os.unlink(self.shelve_filename) # remove the empty file
|
||||
return
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
os.unlink(self.shelve_filename)
|
||||
except AttributeError:
|
||||
pass
|
||||
HTTPTestBase.tearDown(self)
|
||||
return
|
||||
|
||||
def test(self):
|
||||
storage = shelve.open(self.shelve_filename)
|
||||
locking_storage = CacheStorageLock(storage)
|
||||
try:
|
||||
fc = Cache(locking_storage)
|
||||
|
||||
# First fetch the data through the cache
|
||||
parsed_data = fc.fetch(self.TEST_URL)
|
||||
self.failUnlessEqual(parsed_data.feed.title, 'CacheTest test data')
|
||||
|
||||
# Now retrieve the same data directly from the shelf
|
||||
modified, shelved_data = storage[self.TEST_URL]
|
||||
|
||||
# The data should be the same
|
||||
self.failUnlessEqual(parsed_data, shelved_data)
|
||||
finally:
|
||||
storage.close()
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,241 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""Simple HTTP server for testing the feed cache.
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import BaseHTTPServer
|
||||
import logging
|
||||
import md5
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
logger = logging.getLogger('feedcache.test_server')
|
||||
|
||||
|
||||
def make_etag(data):
|
||||
"""Given a string containing data to be returned to the client,
|
||||
compute an ETag value for the data.
|
||||
"""
|
||||
_md5 = md5.new()
|
||||
_md5.update(data)
|
||||
return _md5.hexdigest()
|
||||
|
||||
|
||||
class TestHTTPHandler(BaseHTTPServer.BaseHTTPRequestHandler):
|
||||
"HTTP request handler which serves the same feed data every time."
|
||||
|
||||
FEED_DATA = """<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-us">
|
||||
<title>CacheTest test data</title>
|
||||
<link href="http://localhost/feedcache/" rel="alternate"></link>
|
||||
<link href="http://localhost/feedcache/atom/" rel="self"></link>
|
||||
<id>http://localhost/feedcache/</id>
|
||||
<updated>2006-10-14T11:00:36Z</updated>
|
||||
<entry>
|
||||
<title>single test entry</title>
|
||||
<link href="http://www.example.com/" rel="alternate"></link>
|
||||
<updated>2006-10-14T11:00:36Z</updated>
|
||||
<author>
|
||||
<name>author goes here</name>
|
||||
<email>authoremail@example.com</email>
|
||||
</author>
|
||||
<id>http://www.example.com/</id>
|
||||
<summary type="html">description goes here</summary>
|
||||
<link length="100" href="http://www.example.com/enclosure" type="text/html" rel="enclosure">
|
||||
</link>
|
||||
</entry>
|
||||
</feed>"""
|
||||
|
||||
# The data does not change, so save the ETag and modified times
|
||||
# as class attributes.
|
||||
ETAG = make_etag(FEED_DATA)
|
||||
# Calculated using email.utils.formatdate(usegmt=True)
|
||||
MODIFIED_TIME = 'Sun, 08 Apr 2012 20:16:48 GMT'
|
||||
|
||||
def do_GET(self):
|
||||
"Handle GET requests."
|
||||
logger.debug('GET %s', self.path)
|
||||
|
||||
if self.path == '/shutdown':
|
||||
# Shortcut to handle stopping the server
|
||||
logger.debug('Stopping server')
|
||||
self.server.stop()
|
||||
self.send_response(200)
|
||||
|
||||
else:
|
||||
# Record the request for tests that count them
|
||||
self.server.requests.append(self.path)
|
||||
# Process the request
|
||||
logger.debug('pre-defined response code: %d', self.server.response)
|
||||
handler_method_name = 'do_GET_%d' % self.server.response
|
||||
handler_method = getattr(self, handler_method_name)
|
||||
handler_method()
|
||||
return
|
||||
|
||||
def do_GET_3xx(self):
|
||||
"Handle redirects"
|
||||
if self.path.endswith('/redirected'):
|
||||
logger.debug('already redirected')
|
||||
# We have already redirected, so return the data.
|
||||
return self.do_GET_200()
|
||||
new_path = self.server.new_path
|
||||
logger.debug('redirecting to %s', new_path)
|
||||
self.send_response(self.server.response)
|
||||
self.send_header('Location', new_path)
|
||||
return
|
||||
|
||||
do_GET_301 = do_GET_3xx
|
||||
do_GET_302 = do_GET_3xx
|
||||
do_GET_303 = do_GET_3xx
|
||||
do_GET_307 = do_GET_3xx
|
||||
|
||||
def do_GET_200(self):
|
||||
logger.debug('Etag: %s' % self.ETAG)
|
||||
logger.debug('Last-Modified: %s' % self.MODIFIED_TIME)
|
||||
|
||||
incoming_etag = self.headers.get('If-None-Match', None)
|
||||
logger.debug('Incoming ETag: "%s"' % incoming_etag)
|
||||
|
||||
incoming_modified = self.headers.get('If-Modified-Since', None)
|
||||
logger.debug('Incoming If-Modified-Since: %s' % incoming_modified)
|
||||
|
||||
send_data = True
|
||||
|
||||
# Does the client have the same version of the data we have?
|
||||
if self.server.apply_modified_headers:
|
||||
if incoming_etag == self.ETAG:
|
||||
logger.debug('Response 304, etag')
|
||||
self.send_response(304)
|
||||
send_data = False
|
||||
|
||||
elif incoming_modified == self.MODIFIED_TIME:
|
||||
logger.debug('Response 304, modified time')
|
||||
self.send_response(304)
|
||||
send_data = False
|
||||
|
||||
# Now optionally send the data, if the client needs it
|
||||
if send_data:
|
||||
logger.debug('Response 200')
|
||||
self.send_response(200)
|
||||
|
||||
self.send_header('Content-Type', 'application/atom+xml')
|
||||
|
||||
logger.debug('Outgoing Etag: %s' % self.ETAG)
|
||||
self.send_header('ETag', self.ETAG)
|
||||
|
||||
logger.debug('Outgoing modified time: %s' % self.MODIFIED_TIME)
|
||||
self.send_header('Last-Modified', self.MODIFIED_TIME)
|
||||
|
||||
self.end_headers()
|
||||
|
||||
logger.debug('Sending data')
|
||||
self.wfile.write(self.FEED_DATA)
|
||||
return
|
||||
|
||||
|
||||
class TestHTTPServer(BaseHTTPServer.HTTPServer):
|
||||
"""HTTP Server which counts the number of requests made
|
||||
and can stop based on client instructions.
|
||||
"""
|
||||
|
||||
def __init__(self, applyModifiedHeaders=True, handler=TestHTTPHandler):
|
||||
self.apply_modified_headers = applyModifiedHeaders
|
||||
self.keep_serving = True
|
||||
self.requests = []
|
||||
self.setResponse(200)
|
||||
BaseHTTPServer.HTTPServer.__init__(self, ('', 9999), handler)
|
||||
return
|
||||
|
||||
def setResponse(self, newResponse, newPath=None):
|
||||
"""Sets the response code to use for future requests, and a new
|
||||
path to be used as a redirect target, if necessary.
|
||||
"""
|
||||
self.response = newResponse
|
||||
self.new_path = newPath
|
||||
return
|
||||
|
||||
def getNumRequests(self):
|
||||
"Return the number of requests which have been made on the server."
|
||||
return len(self.requests)
|
||||
|
||||
def stop(self):
|
||||
"Stop serving requests, after the next request."
|
||||
self.keep_serving = False
|
||||
return
|
||||
|
||||
def serve_forever(self):
|
||||
"Main loop for server"
|
||||
while self.keep_serving:
|
||||
self.handle_request()
|
||||
logger.debug('exiting')
|
||||
return
|
||||
|
||||
|
||||
class HTTPTestBase(unittest.TestCase):
|
||||
"Base class for tests that use a TestHTTPServer"
|
||||
|
||||
TEST_URL = 'http://localhost:9999/'
|
||||
|
||||
CACHE_TTL = 0
|
||||
|
||||
def setUp(self):
|
||||
self.server = self.getServer()
|
||||
self.server_thread = threading.Thread(target=self.server.serve_forever)
|
||||
# set daemon flag so the tests don't hang if cleanup fails
|
||||
self.server_thread.setDaemon(True)
|
||||
self.server_thread.start()
|
||||
return
|
||||
|
||||
def getServer(self):
|
||||
"Return a web server for the test."
|
||||
s = TestHTTPServer()
|
||||
s.setResponse(200)
|
||||
return s
|
||||
|
||||
def tearDown(self):
|
||||
# Stop the server thread
|
||||
urllib.urlretrieve(self.TEST_URL + 'shutdown')
|
||||
time.sleep(1)
|
||||
self.server.server_close()
|
||||
self.server_thread.join()
|
||||
return
|
|
@ -1,89 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright 2007 Doug Hellmann.
|
||||
#
|
||||
#
|
||||
# All Rights Reserved
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and
|
||||
# its documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appear in all
|
||||
# copies and that both that copyright notice and this permission
|
||||
# notice appear in supporting documentation, and that the name of Doug
|
||||
# Hellmann not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# DOUG HELLMANN DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE,
|
||||
# INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN
|
||||
# NO EVENT SHALL DOUG HELLMANN BE LIABLE FOR ANY SPECIAL, INDIRECT OR
|
||||
# CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
||||
# CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
#
|
||||
|
||||
"""Tests with shove filesystem storage.
|
||||
|
||||
"""
|
||||
|
||||
__module_id__ = "$Id$"
|
||||
|
||||
#
|
||||
# Import system modules
|
||||
#
|
||||
import os
|
||||
import shove
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
#
|
||||
# Import local modules
|
||||
#
|
||||
from cache import Cache
|
||||
from test_server import HTTPTestBase
|
||||
|
||||
#
|
||||
# Module
|
||||
#
|
||||
|
||||
class CacheShoveTest(HTTPTestBase):
|
||||
|
||||
def setUp(self):
|
||||
HTTPTestBase.setUp(self)
|
||||
self.shove_dirname = tempfile.mkdtemp('shove')
|
||||
return
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
os.system('rm -rf %s' % self.storage_dirname)
|
||||
except AttributeError:
|
||||
pass
|
||||
HTTPTestBase.tearDown(self)
|
||||
return
|
||||
|
||||
def test(self):
|
||||
# First fetch the data through the cache
|
||||
storage = shove.Shove('file://' + self.shove_dirname)
|
||||
try:
|
||||
fc = Cache(storage)
|
||||
parsed_data = fc.fetch(self.TEST_URL)
|
||||
self.failUnlessEqual(parsed_data.feed.title, 'CacheTest test data')
|
||||
finally:
|
||||
storage.close()
|
||||
|
||||
# Now retrieve the same data directly from the shelf
|
||||
storage = shove.Shove('file://' + self.shove_dirname)
|
||||
try:
|
||||
modified, shelved_data = storage[self.TEST_URL]
|
||||
finally:
|
||||
storage.close()
|
||||
|
||||
# The data should be the same
|
||||
self.failUnlessEqual(parsed_data, shelved_data)
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
|
@ -1,30 +0,0 @@
|
|||
Metadata-Version: 1.1
|
||||
Name: feedparser
|
||||
Version: 5.1.3
|
||||
Summary: Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds
|
||||
Home-page: http://code.google.com/p/feedparser/
|
||||
Author: Kurt McKee
|
||||
Author-email: contactme@kurtmckee.org
|
||||
License: UNKNOWN
|
||||
Download-URL: http://code.google.com/p/feedparser/
|
||||
Description: UNKNOWN
|
||||
Keywords: atom,cdf,feed,parser,rdf,rss
|
||||
Platform: POSIX
|
||||
Platform: Windows
|
||||
Classifier: Development Status :: 5 - Production/Stable
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 2
|
||||
Classifier: Programming Language :: Python :: 2.4
|
||||
Classifier: Programming Language :: Python :: 2.5
|
||||
Classifier: Programming Language :: Python :: 2.6
|
||||
Classifier: Programming Language :: Python :: 2.7
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.0
|
||||
Classifier: Programming Language :: Python :: 3.1
|
||||
Classifier: Programming Language :: Python :: 3.2
|
||||
Classifier: Programming Language :: Python :: 3.3
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Classifier: Topic :: Text Processing :: Markup :: XML
|
|
@ -1 +0,0 @@
|
|||
|
|
@ -1 +0,0 @@
|
|||
feedparser
|
|
@ -1,859 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
__author__ = "Mark Pilgrim <http://diveintomark.org/>"
|
||||
__license__ = """
|
||||
Copyright (c) 2010-2012 Kurt McKee <contactme@kurtmckee.org>
|
||||
Copyright (c) 2004-2008 Mark Pilgrim
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE."""
|
||||
|
||||
import codecs
|
||||
import datetime
|
||||
import glob
|
||||
import operator
|
||||
import os
|
||||
import posixpath
|
||||
import pprint
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import urllib
|
||||
import warnings
|
||||
import zlib
|
||||
import BaseHTTPServer
|
||||
import SimpleHTTPServer
|
||||
|
||||
import feedparser
|
||||
|
||||
if not feedparser._XML_AVAILABLE:
|
||||
sys.stderr.write('No XML parsers available, unit testing can not proceed\n')
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# the utf_32 codec was introduced in Python 2.6; it's necessary to
|
||||
# check this as long as feedparser supports Python 2.4 and 2.5
|
||||
codecs.lookup('utf_32')
|
||||
except LookupError:
|
||||
_UTF32_AVAILABLE = False
|
||||
else:
|
||||
_UTF32_AVAILABLE = True
|
||||
|
||||
_s2bytes = feedparser._s2bytes
|
||||
_l2bytes = feedparser._l2bytes
|
||||
|
||||
#---------- custom HTTP server (used to serve test feeds) ----------
|
||||
|
||||
_PORT = 8097 # not really configurable, must match hardcoded port in tests
|
||||
_HOST = '127.0.0.1' # also not really configurable
|
||||
|
||||
class FeedParserTestRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
|
||||
headers_re = re.compile(_s2bytes(r"^Header:\s+([^:]+):(.+)$"), re.MULTILINE)
|
||||
|
||||
def send_head(self):
|
||||
"""Send custom headers defined in test case
|
||||
|
||||
Example:
|
||||
<!--
|
||||
Header: Content-type: application/atom+xml
|
||||
Header: X-Foo: bar
|
||||
-->
|
||||
"""
|
||||
# Short-circuit the HTTP status test `test_redirect_to_304()`
|
||||
if self.path == '/-/return-304.xml':
|
||||
self.send_response(304)
|
||||
self.send_header('Content-type', 'text/xml')
|
||||
self.end_headers()
|
||||
return feedparser._StringIO(u''.encode('utf-8'))
|
||||
path = self.translate_path(self.path)
|
||||
# the compression tests' filenames determine the header sent
|
||||
if self.path.startswith('/tests/compression'):
|
||||
if self.path.endswith('gz'):
|
||||
headers = {'Content-Encoding': 'gzip'}
|
||||
else:
|
||||
headers = {'Content-Encoding': 'deflate'}
|
||||
headers['Content-type'] = 'application/xml'
|
||||
else:
|
||||
headers = dict([(k.decode('utf-8'), v.decode('utf-8').strip()) for k, v in self.headers_re.findall(open(path, 'rb').read())])
|
||||
f = open(path, 'rb')
|
||||
if (self.headers.get('if-modified-since') == headers.get('Last-Modified', 'nom')) \
|
||||
or (self.headers.get('if-none-match') == headers.get('ETag', 'nomatch')):
|
||||
status = 304
|
||||
else:
|
||||
status = 200
|
||||
headers.setdefault('Status', status)
|
||||
self.send_response(int(headers['Status']))
|
||||
headers.setdefault('Content-type', self.guess_type(path))
|
||||
self.send_header("Content-type", headers['Content-type'])
|
||||
self.send_header("Content-Length", str(os.stat(f.name)[6]))
|
||||
for k, v in headers.items():
|
||||
if k not in ('Status', 'Content-type'):
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
return f
|
||||
|
||||
def log_request(self, *args):
|
||||
pass
|
||||
|
||||
class FeedParserTestServer(threading.Thread):
|
||||
"""HTTP Server that runs in a thread and handles a predetermined number of requests"""
|
||||
|
||||
def __init__(self, requests):
|
||||
threading.Thread.__init__(self)
|
||||
self.requests = requests
|
||||
self.ready = threading.Event()
|
||||
|
||||
def run(self):
|
||||
self.httpd = BaseHTTPServer.HTTPServer((_HOST, _PORT), FeedParserTestRequestHandler)
|
||||
self.ready.set()
|
||||
while self.requests:
|
||||
self.httpd.handle_request()
|
||||
self.requests -= 1
|
||||
self.ready.clear()
|
||||
|
||||
#---------- dummy test case class (test methods are added dynamically) ----------
|
||||
unicode1_re = re.compile(_s2bytes(" u'"))
|
||||
unicode2_re = re.compile(_s2bytes(' u"'))
|
||||
|
||||
# _bytes is only used in everythingIsUnicode().
|
||||
# In Python 2 it's str, and in Python 3 it's bytes.
|
||||
_bytes = type(_s2bytes(''))
|
||||
|
||||
def everythingIsUnicode(d):
|
||||
"""Takes a dictionary, recursively verifies that every value is unicode"""
|
||||
for k, v in d.iteritems():
|
||||
if isinstance(v, dict) and k != 'headers':
|
||||
if not everythingIsUnicode(v):
|
||||
return False
|
||||
elif isinstance(v, list):
|
||||
for i in v:
|
||||
if isinstance(i, dict) and not everythingIsUnicode(i):
|
||||
return False
|
||||
elif isinstance(i, _bytes):
|
||||
return False
|
||||
elif isinstance(v, _bytes):
|
||||
return False
|
||||
return True
|
||||
|
||||
def failUnlessEval(self, xmlfile, evalString, msg=None):
|
||||
"""Fail unless eval(evalString, env)"""
|
||||
env = feedparser.parse(xmlfile)
|
||||
try:
|
||||
if not eval(evalString, globals(), env):
|
||||
failure=(msg or 'not eval(%s) \nWITH env(%s)' % (evalString, pprint.pformat(env)))
|
||||
raise self.failureException, failure
|
||||
if not everythingIsUnicode(env):
|
||||
raise self.failureException, "not everything is unicode \nWITH env(%s)" % (pprint.pformat(env), )
|
||||
except SyntaxError:
|
||||
# Python 3 doesn't have the `u""` syntax, so evalString needs to be modified,
|
||||
# which will require the failure message to be updated
|
||||
evalString = re.sub(unicode1_re, _s2bytes(" '"), evalString)
|
||||
evalString = re.sub(unicode2_re, _s2bytes(' "'), evalString)
|
||||
if not eval(evalString, globals(), env):
|
||||
failure=(msg or 'not eval(%s) \nWITH env(%s)' % (evalString, pprint.pformat(env)))
|
||||
raise self.failureException, failure
|
||||
|
||||
class BaseTestCase(unittest.TestCase):
|
||||
failUnlessEval = failUnlessEval
|
||||
|
||||
class TestCase(BaseTestCase):
|
||||
pass
|
||||
|
||||
class TestTemporaryFallbackBehavior(unittest.TestCase):
|
||||
"These tests are temporarily here because of issues 310 and 328"
|
||||
def test_issue_328_fallback_behavior(self):
|
||||
warnings.filterwarnings('error')
|
||||
|
||||
d = feedparser.FeedParserDict()
|
||||
d['published'] = u'pub string'
|
||||
d['published_parsed'] = u'pub tuple'
|
||||
d['updated'] = u'upd string'
|
||||
d['updated_parsed'] = u'upd tuple'
|
||||
# Ensure that `updated` doesn't map to `published` when it exists
|
||||
self.assertTrue('published' in d)
|
||||
self.assertTrue('published_parsed' in d)
|
||||
self.assertTrue('updated' in d)
|
||||
self.assertTrue('updated_parsed' in d)
|
||||
self.assertEqual(d['published'], 'pub string')
|
||||
self.assertEqual(d['published_parsed'], 'pub tuple')
|
||||
self.assertEqual(d['updated'], 'upd string')
|
||||
self.assertEqual(d['updated_parsed'], 'upd tuple')
|
||||
|
||||
d = feedparser.FeedParserDict()
|
||||
d['published'] = u'pub string'
|
||||
d['published_parsed'] = u'pub tuple'
|
||||
# Ensure that `updated` doesn't actually exist
|
||||
self.assertTrue('updated' not in d)
|
||||
self.assertTrue('updated_parsed' not in d)
|
||||
# Ensure that accessing `updated` throws a DeprecationWarning
|
||||
try:
|
||||
d['updated']
|
||||
except DeprecationWarning:
|
||||
# Expected behavior
|
||||
pass
|
||||
else:
|
||||
# Wrong behavior
|
||||
self.assertEqual(True, False)
|
||||
try:
|
||||
d['updated_parsed']
|
||||
except DeprecationWarning:
|
||||
# Expected behavior
|
||||
pass
|
||||
else:
|
||||
# Wrong behavior
|
||||
self.assertEqual(True, False)
|
||||
# Ensure that `updated` maps to `published`
|
||||
warnings.filterwarnings('ignore')
|
||||
self.assertEqual(d['updated'], u'pub string')
|
||||
self.assertEqual(d['updated_parsed'], u'pub tuple')
|
||||
warnings.resetwarnings()
|
||||
|
||||
|
||||
class TestEverythingIsUnicode(unittest.TestCase):
|
||||
"Ensure that `everythingIsUnicode()` is working appropriately"
|
||||
def test_everything_is_unicode(self):
|
||||
self.assertTrue(everythingIsUnicode(
|
||||
{'a': u'a', 'b': [u'b', {'c': u'c'}], 'd': {'e': u'e'}}
|
||||
))
|
||||
def test_not_everything_is_unicode(self):
|
||||
self.assertFalse(everythingIsUnicode({'a': _s2bytes('a')}))
|
||||
self.assertFalse(everythingIsUnicode({'a': [_s2bytes('a')]}))
|
||||
self.assertFalse(everythingIsUnicode({'a': {'b': _s2bytes('b')}}))
|
||||
self.assertFalse(everythingIsUnicode({'a': [{'b': _s2bytes('b')}]}))
|
||||
|
||||
class TestLooseParser(BaseTestCase):
|
||||
"Test the sgmllib-based parser by manipulating feedparser " \
|
||||
"into believing no XML parsers are installed"
|
||||
def __init__(self, arg):
|
||||
unittest.TestCase.__init__(self, arg)
|
||||
self._xml_available = feedparser._XML_AVAILABLE
|
||||
def setUp(self):
|
||||
feedparser._XML_AVAILABLE = 0
|
||||
def tearDown(self):
|
||||
feedparser._XML_AVAILABLE = self._xml_available
|
||||
|
||||
class TestStrictParser(BaseTestCase):
|
||||
pass
|
||||
|
||||
class TestMicroformats(BaseTestCase):
|
||||
pass
|
||||
|
||||
class TestEncodings(BaseTestCase):
|
||||
def test_doctype_replacement(self):
|
||||
"Ensure that non-ASCII-compatible encodings don't hide " \
|
||||
"disallowed ENTITY declarations"
|
||||
doc = """<?xml version="1.0" encoding="utf-16be"?>
|
||||
<!DOCTYPE feed [
|
||||
<!ENTITY exponential1 "bogus ">
|
||||
<!ENTITY exponential2 "&exponential1;&exponential1;">
|
||||
<!ENTITY exponential3 "&exponential2;&exponential2;">
|
||||
]>
|
||||
<feed><title type="html">&exponential3;</title></feed>"""
|
||||
doc = codecs.BOM_UTF16_BE + doc.encode('utf-16be')
|
||||
result = feedparser.parse(doc)
|
||||
self.assertEqual(result['feed']['title'], u'&exponential3')
|
||||
def test_gb2312_converted_to_gb18030_in_xml_encoding(self):
|
||||
# \u55de was chosen because it exists in gb18030 but not gb2312
|
||||
feed = u'''<?xml version="1.0" encoding="gb2312"?>
|
||||
<feed><title>\u55de</title></feed>'''
|
||||
result = feedparser.parse(feed.encode('gb18030'), response_headers={
|
||||
'Content-Type': 'text/xml'
|
||||
})
|
||||
self.assertEqual(result.encoding, 'gb18030')
|
||||
|
||||
class TestFeedParserDict(unittest.TestCase):
|
||||
"Ensure that FeedParserDict returns values as expected and won't crash"
|
||||
def setUp(self):
|
||||
self.d = feedparser.FeedParserDict()
|
||||
def _check_key(self, k):
|
||||
self.assertTrue(k in self.d)
|
||||
self.assertTrue(hasattr(self.d, k))
|
||||
self.assertEqual(self.d[k], 1)
|
||||
self.assertEqual(getattr(self.d, k), 1)
|
||||
def _check_no_key(self, k):
|
||||
self.assertTrue(k not in self.d)
|
||||
self.assertTrue(not hasattr(self.d, k))
|
||||
def test_empty(self):
|
||||
keys = (
|
||||
'a','entries', 'id', 'guid', 'summary', 'subtitle', 'description',
|
||||
'category', 'enclosures', 'license', 'categories',
|
||||
)
|
||||
for k in keys:
|
||||
self._check_no_key(k)
|
||||
self.assertTrue('items' not in self.d)
|
||||
self.assertTrue(hasattr(self.d, 'items')) # dict.items() exists
|
||||
def test_neutral(self):
|
||||
self.d['a'] = 1
|
||||
self._check_key('a')
|
||||
def test_single_mapping_target_1(self):
|
||||
self.d['id'] = 1
|
||||
self._check_key('id')
|
||||
self._check_key('guid')
|
||||
def test_single_mapping_target_2(self):
|
||||
self.d['guid'] = 1
|
||||
self._check_key('id')
|
||||
self._check_key('guid')
|
||||
def test_multiple_mapping_target_1(self):
|
||||
self.d['summary'] = 1
|
||||
self._check_key('summary')
|
||||
self._check_key('description')
|
||||
def test_multiple_mapping_target_2(self):
|
||||
self.d['subtitle'] = 1
|
||||
self._check_key('subtitle')
|
||||
self._check_key('description')
|
||||
def test_multiple_mapping_mapped_key(self):
|
||||
self.d['description'] = 1
|
||||
self._check_key('summary')
|
||||
self._check_key('description')
|
||||
def test_license(self):
|
||||
self.d['links'] = []
|
||||
try:
|
||||
self.d['license']
|
||||
self.assertTrue(False)
|
||||
except KeyError:
|
||||
pass
|
||||
self.d['links'].append({'rel': 'license'})
|
||||
try:
|
||||
self.d['license']
|
||||
self.assertTrue(False)
|
||||
except KeyError:
|
||||
pass
|
||||
self.d['links'].append({'rel': 'license', 'href': 'http://dom.test/'})
|
||||
self.assertEqual(self.d['license'], 'http://dom.test/')
|
||||
def test_category(self):
|
||||
self.d['tags'] = []
|
||||
try:
|
||||
self.d['category']
|
||||
self.assertTrue(False)
|
||||
except KeyError:
|
||||
pass
|
||||
self.d['tags'] = [{}]
|
||||
try:
|
||||
self.d['category']
|
||||
self.assertTrue(False)
|
||||
except KeyError:
|
||||
pass
|
||||
self.d['tags'] = [{'term': 'cat'}]
|
||||
self.assertEqual(self.d['category'], 'cat')
|
||||
self.d['tags'].append({'term': 'dog'})
|
||||
self.assertEqual(self.d['category'], 'cat')
|
||||
|
||||
class TestOpenResource(unittest.TestCase):
|
||||
"Ensure that `_open_resource()` interprets its arguments as URIs, " \
|
||||
"file-like objects, or in-memory feeds as expected"
|
||||
def test_fileobj(self):
|
||||
r = feedparser._open_resource(sys.stdin, '', '', '', '', [], {})
|
||||
self.assertTrue(r is sys.stdin)
|
||||
def test_feed(self):
|
||||
f = feedparser.parse(u'feed://localhost:8097/tests/http/target.xml')
|
||||
self.assertEqual(f.href, u'http://localhost:8097/tests/http/target.xml')
|
||||
def test_feed_http(self):
|
||||
f = feedparser.parse(u'feed:http://localhost:8097/tests/http/target.xml')
|
||||
self.assertEqual(f.href, u'http://localhost:8097/tests/http/target.xml')
|
||||
def test_bytes(self):
|
||||
s = '<feed><item><title>text</title></item></feed>'.encode('utf-8')
|
||||
r = feedparser._open_resource(s, '', '', '', '', [], {})
|
||||
self.assertEqual(s, r.read())
|
||||
def test_string(self):
|
||||
s = '<feed><item><title>text</title></item></feed>'
|
||||
r = feedparser._open_resource(s, '', '', '', '', [], {})
|
||||
self.assertEqual(s.encode('utf-8'), r.read())
|
||||
def test_unicode_1(self):
|
||||
s = u'<feed><item><title>text</title></item></feed>'
|
||||
r = feedparser._open_resource(s, '', '', '', '', [], {})
|
||||
self.assertEqual(s.encode('utf-8'), r.read())
|
||||
def test_unicode_2(self):
|
||||
s = u'<feed><item><title>t\u00e9xt</title></item></feed>'
|
||||
r = feedparser._open_resource(s, '', '', '', '', [], {})
|
||||
self.assertEqual(s.encode('utf-8'), r.read())
|
||||
|
||||
class TestMakeSafeAbsoluteURI(unittest.TestCase):
|
||||
"Exercise the URI joining and sanitization code"
|
||||
base = u'http://d.test/d/f.ext'
|
||||
def _mktest(rel, expect, doc):
|
||||
def fn(self):
|
||||
value = feedparser._makeSafeAbsoluteURI(self.base, rel)
|
||||
self.assertEqual(value, expect)
|
||||
fn.__doc__ = doc
|
||||
return fn
|
||||
|
||||
# make the test cases; the call signature is:
|
||||
# (relative_url, expected_return_value, test_doc_string)
|
||||
test_abs = _mktest(u'https://s.test/', u'https://s.test/', 'absolute uri')
|
||||
test_rel = _mktest(u'/new', u'http://d.test/new', 'relative uri')
|
||||
test_bad = _mktest(u'x://bad.test/', u'', 'unacceptable uri protocol')
|
||||
test_mag = _mktest(u'magnet:?xt=a', u'magnet:?xt=a', 'magnet uri')
|
||||
|
||||
def test_catch_ValueError(self):
|
||||
'catch ValueError in Python 2.7 and up'
|
||||
uri = u'http://bad]test/'
|
||||
value1 = feedparser._makeSafeAbsoluteURI(uri)
|
||||
value2 = feedparser._makeSafeAbsoluteURI(self.base, uri)
|
||||
swap = feedparser.ACCEPTABLE_URI_SCHEMES
|
||||
feedparser.ACCEPTABLE_URI_SCHEMES = ()
|
||||
value3 = feedparser._makeSafeAbsoluteURI(self.base, uri)
|
||||
feedparser.ACCEPTABLE_URI_SCHEMES = swap
|
||||
# Only Python 2.7 and up throw a ValueError, otherwise uri is returned
|
||||
self.assertTrue(value1 in (uri, u''))
|
||||
self.assertTrue(value2 in (uri, u''))
|
||||
self.assertTrue(value3 in (uri, u''))
|
||||
|
||||
class TestConvertToIdn(unittest.TestCase):
|
||||
"Test IDN support (unavailable in Jython as of Jython 2.5.2)"
|
||||
# this is the greek test domain
|
||||
hostname = u'\u03c0\u03b1\u03c1\u03ac\u03b4\u03b5\u03b9\u03b3\u03bc\u03b1'
|
||||
hostname += u'.\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae'
|
||||
def test_control(self):
|
||||
r = feedparser._convert_to_idn(u'http://example.test/')
|
||||
self.assertEqual(r, u'http://example.test/')
|
||||
def test_idn(self):
|
||||
r = feedparser._convert_to_idn(u'http://%s/' % (self.hostname,))
|
||||
self.assertEqual(r, u'http://xn--hxajbheg2az3al.xn--jxalpdlp/')
|
||||
def test_port(self):
|
||||
r = feedparser._convert_to_idn(u'http://%s:8080/' % (self.hostname,))
|
||||
self.assertEqual(r, u'http://xn--hxajbheg2az3al.xn--jxalpdlp:8080/')
|
||||
|
||||
class TestCompression(unittest.TestCase):
|
||||
"Test the gzip and deflate support in the HTTP code"
|
||||
def test_gzip_good(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/compression/gzip.gz')
|
||||
self.assertEqual(f.version, 'atom10')
|
||||
def test_gzip_not_compressed(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/compression/gzip-not-compressed.gz')
|
||||
self.assertEqual(f.bozo, 1)
|
||||
self.assertTrue(isinstance(f.bozo_exception, IOError))
|
||||
self.assertEqual(f['feed']['title'], 'gzip')
|
||||
def test_gzip_struct_error(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/compression/gzip-struct-error.gz')
|
||||
self.assertEqual(f.bozo, 1)
|
||||
self.assertTrue(isinstance(f.bozo_exception, struct.error))
|
||||
def test_zlib_good(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/compression/deflate.z')
|
||||
self.assertEqual(f.version, 'atom10')
|
||||
def test_zlib_no_headers(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/compression/deflate-no-headers.z')
|
||||
self.assertEqual(f.version, 'atom10')
|
||||
def test_zlib_not_compressed(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/compression/deflate-not-compressed.z')
|
||||
self.assertEqual(f.bozo, 1)
|
||||
self.assertTrue(isinstance(f.bozo_exception, zlib.error))
|
||||
self.assertEqual(f['feed']['title'], 'deflate')
|
||||
|
||||
class TestHTTPStatus(unittest.TestCase):
|
||||
"Test HTTP redirection and other status codes"
|
||||
def test_301(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/http/http_status_301.xml')
|
||||
self.assertEqual(f.status, 301)
|
||||
self.assertEqual(f.href, 'http://localhost:8097/tests/http/target.xml')
|
||||
self.assertEqual(f.entries[0].title, 'target')
|
||||
def test_302(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/http/http_status_302.xml')
|
||||
self.assertEqual(f.status, 302)
|
||||
self.assertEqual(f.href, 'http://localhost:8097/tests/http/target.xml')
|
||||
self.assertEqual(f.entries[0].title, 'target')
|
||||
def test_303(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/http/http_status_303.xml')
|
||||
self.assertEqual(f.status, 303)
|
||||
self.assertEqual(f.href, 'http://localhost:8097/tests/http/target.xml')
|
||||
self.assertEqual(f.entries[0].title, 'target')
|
||||
def test_307(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/http/http_status_307.xml')
|
||||
self.assertEqual(f.status, 307)
|
||||
self.assertEqual(f.href, 'http://localhost:8097/tests/http/target.xml')
|
||||
self.assertEqual(f.entries[0].title, 'target')
|
||||
def test_304(self):
|
||||
# first retrieve the url
|
||||
u = 'http://localhost:8097/tests/http/http_status_304.xml'
|
||||
f = feedparser.parse(u)
|
||||
self.assertEqual(f.status, 200)
|
||||
self.assertEqual(f.entries[0].title, 'title 304')
|
||||
# extract the etag and last-modified headers
|
||||
e = [v for k, v in f.headers.items() if k.lower() == 'etag'][0]
|
||||
mh = [v for k, v in f.headers.items() if k.lower() == 'last-modified'][0]
|
||||
ms = f.updated
|
||||
mt = f.updated_parsed
|
||||
md = datetime.datetime(*mt[0:7])
|
||||
self.assertTrue(isinstance(mh, basestring))
|
||||
self.assertTrue(isinstance(ms, basestring))
|
||||
self.assertTrue(isinstance(mt, time.struct_time))
|
||||
self.assertTrue(isinstance(md, datetime.datetime))
|
||||
# test that sending back the etag results in a 304
|
||||
f = feedparser.parse(u, etag=e)
|
||||
self.assertEqual(f.status, 304)
|
||||
# test that sending back last-modified (string) results in a 304
|
||||
f = feedparser.parse(u, modified=ms)
|
||||
self.assertEqual(f.status, 304)
|
||||
# test that sending back last-modified (9-tuple) results in a 304
|
||||
f = feedparser.parse(u, modified=mt)
|
||||
self.assertEqual(f.status, 304)
|
||||
# test that sending back last-modified (datetime) results in a 304
|
||||
f = feedparser.parse(u, modified=md)
|
||||
self.assertEqual(f.status, 304)
|
||||
def test_404(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/http/http_status_404.xml')
|
||||
self.assertEqual(f.status, 404)
|
||||
def test_9001(self):
|
||||
f = feedparser.parse('http://localhost:8097/tests/http/http_status_9001.xml')
|
||||
self.assertEqual(f.bozo, 1)
|
||||
def test_redirect_to_304(self):
|
||||
# ensure that an http redirect to an http 304 doesn't
|
||||
# trigger a bozo_exception
|
||||
u = 'http://localhost:8097/tests/http/http_redirect_to_304.xml'
|
||||
f = feedparser.parse(u)
|
||||
self.assertTrue(f.bozo == 0)
|
||||
self.assertTrue(f.status == 302)
|
||||
|
||||
class TestDateParsers(unittest.TestCase):
|
||||
"Test the various date parsers; most of the test cases are constructed " \
|
||||
"dynamically based on the contents of the `date_tests` dict, below"
|
||||
def test_None(self):
|
||||
self.assertTrue(feedparser._parse_date(None) is None)
|
||||
def _check_date(self, func, dtstring, dttuple):
|
||||
try:
|
||||
tup = func(dtstring)
|
||||
except (OverflowError, ValueError):
|
||||
tup = None
|
||||
self.assertEqual(tup, dttuple)
|
||||
self.assertEqual(tup, feedparser._parse_date(dtstring))
|
||||
def test_year_10000_date(self):
|
||||
# On some systems this date string will trigger an OverflowError.
|
||||
# On Jython and x64 systems, however, it's interpreted just fine.
|
||||
try:
|
||||
date = feedparser._parse_date_rfc822(u'Sun, 31 Dec 9999 23:59:59 -9999')
|
||||
except OverflowError:
|
||||
date = None
|
||||
self.assertTrue(date in (None, (10000, 1, 5, 4, 38, 59, 2, 5, 0)))
|
||||
|
||||
date_tests = {
|
||||
feedparser._parse_date_greek: (
|
||||
(u'', None), # empty string
|
||||
(u'\u039a\u03c5\u03c1, 11 \u0399\u03bf\u03cd\u03bb 2004 12:00:00 EST', (2004, 7, 11, 17, 0, 0, 6, 193, 0)),
|
||||
),
|
||||
feedparser._parse_date_hungarian: (
|
||||
(u'', None), # empty string
|
||||
(u'2004-j\u00falius-13T9:15-05:00', (2004, 7, 13, 14, 15, 0, 1, 195, 0)),
|
||||
),
|
||||
feedparser._parse_date_iso8601: (
|
||||
(u'', None), # empty string
|
||||
(u'-0312', (2003, 12, 1, 0, 0, 0, 0, 335, 0)), # 2-digit year/month only variant
|
||||
(u'031231', (2003, 12, 31, 0, 0, 0, 2, 365, 0)), # 2-digit year/month/day only, no hyphens
|
||||
(u'03-12-31', (2003, 12, 31, 0, 0, 0, 2, 365, 0)), # 2-digit year/month/day only
|
||||
(u'-03-12', (2003, 12, 1, 0, 0, 0, 0, 335, 0)), # 2-digit year/month only
|
||||
(u'03335', (2003, 12, 1, 0, 0, 0, 0, 335, 0)), # 2-digit year/ordinal, no hyphens
|
||||
(u'2003-12-31T10:14:55.1234Z', (2003, 12, 31, 10, 14, 55, 2, 365, 0)), # fractional seconds
|
||||
# Special case for Google's extra zero in the month
|
||||
(u'2003-012-31T10:14:55+00:00', (2003, 12, 31, 10, 14, 55, 2, 365, 0)),
|
||||
),
|
||||
feedparser._parse_date_nate: (
|
||||
(u'', None), # empty string
|
||||
(u'2004-05-25 \uc624\ud6c4 11:23:17', (2004, 5, 25, 14, 23, 17, 1, 146, 0)),
|
||||
),
|
||||
feedparser._parse_date_onblog: (
|
||||
(u'', None), # empty string
|
||||
(u'2004\ub144 05\uc6d4 28\uc77c 01:31:15', (2004, 5, 27, 16, 31, 15, 3, 148, 0)),
|
||||
),
|
||||
feedparser._parse_date_perforce: (
|
||||
(u'', None), # empty string
|
||||
(u'Fri, 2006/09/15 08:19:53 EDT', (2006, 9, 15, 12, 19, 53, 4, 258, 0)),
|
||||
),
|
||||
feedparser._parse_date_rfc822: (
|
||||
(u'', None), # empty string
|
||||
(u'Thu, 01 Jan 0100 00:00:01 +0100', (99, 12, 31, 23, 0, 1, 3, 365, 0)), # ancient date
|
||||
(u'Thu, 01 Jan 04 19:48:21 GMT', (2004, 1, 1, 19, 48, 21, 3, 1, 0)), # 2-digit year
|
||||
(u'Thu, 01 Jan 2004 19:48:21 GMT', (2004, 1, 1, 19, 48, 21, 3, 1, 0)), # 4-digit year
|
||||
(u'Thu, 5 Apr 2012 10:00:00 GMT', (2012, 4, 5, 10, 0, 0, 3, 96, 0)), # 1-digit day
|
||||
(u'Wed, 19 Aug 2009 18:28:00 Etc/GMT', (2009, 8, 19, 18, 28, 0, 2, 231, 0)), # etc/gmt timezone
|
||||
(u'Wed, 19 Feb 2012 22:40:00 GMT-01:01', (2012, 2, 19, 23, 41, 0, 6, 50, 0)), # gmt+hh:mm timezone
|
||||
(u'Mon, 13 Feb, 2012 06:28:00 UTC', (2012, 2, 13, 6, 28, 0, 0, 44, 0)), # extraneous comma
|
||||
(u'Thu, 01 Jan 2004 00:00 GMT', (2004, 1, 1, 0, 0, 0, 3, 1, 0)), # no seconds
|
||||
(u'Thu, 01 Jan 2004', (2004, 1, 1, 0, 0, 0, 3, 1, 0)), # no time
|
||||
# Additional tests to handle Disney's long month names and invalid timezones
|
||||
(u'Mon, 26 January 2004 16:31:00 AT', (2004, 1, 26, 20, 31, 0, 0, 26, 0)),
|
||||
(u'Mon, 26 January 2004 16:31:00 ET', (2004, 1, 26, 21, 31, 0, 0, 26, 0)),
|
||||
(u'Mon, 26 January 2004 16:31:00 CT', (2004, 1, 26, 22, 31, 0, 0, 26, 0)),
|
||||
(u'Mon, 26 January 2004 16:31:00 MT', (2004, 1, 26, 23, 31, 0, 0, 26, 0)),
|
||||
(u'Mon, 26 January 2004 16:31:00 PT', (2004, 1, 27, 0, 31, 0, 1, 27, 0)),
|
||||
),
|
||||
feedparser._parse_date_rfc822_grubby: (
|
||||
(u'Thu Aug 30 2012 17:26:16 +0200', (2012, 8, 30, 15, 26, 16, 3, 243, 0)),
|
||||
),
|
||||
feedparser._parse_date_asctime: (
|
||||
(u'Sun Jan 4 16:29:06 2004', (2004, 1, 4, 16, 29, 6, 6, 4, 0)),
|
||||
),
|
||||
feedparser._parse_date_w3dtf: (
|
||||
(u'', None), # empty string
|
||||
(u'2003-12-31T10:14:55Z', (2003, 12, 31, 10, 14, 55, 2, 365, 0)), # UTC
|
||||
(u'2003-12-31T10:14:55-08:00', (2003, 12, 31, 18, 14, 55, 2, 365, 0)), # San Francisco timezone
|
||||
(u'2003-12-31T18:14:55+08:00', (2003, 12, 31, 10, 14, 55, 2, 365, 0)), # Tokyo timezone
|
||||
(u'2007-04-23T23:25:47.538+10:00', (2007, 4, 23, 13, 25, 47, 0, 113, 0)), # fractional seconds
|
||||
(u'2003-12-31', (2003, 12, 31, 0, 0, 0, 2, 365, 0)), # year/month/day only
|
||||
(u'20031231', (2003, 12, 31, 0, 0, 0, 2, 365, 0)), # year/month/day only, no hyphens
|
||||
(u'2003-12', (2003, 12, 1, 0, 0, 0, 0, 335, 0)), # year/month only
|
||||
(u'2003', (2003, 1, 1, 0, 0, 0, 2, 1, 0)), # year only
|
||||
# MSSQL-style dates
|
||||
(u'2004-07-08 23:56:58 -00:20', (2004, 7, 9, 0, 16, 58, 4, 191, 0)), # with timezone
|
||||
(u'2004-07-08 23:56:58', (2004, 7, 8, 23, 56, 58, 3, 190, 0)), # without timezone
|
||||
(u'2004-07-08 23:56:58.0', (2004, 7, 8, 23, 56, 58, 3, 190, 0)), # with fractional second
|
||||
# Special cases for out-of-range times
|
||||
(u'2003-12-31T25:14:55Z', (2004, 1, 1, 1, 14, 55, 3, 1, 0)), # invalid (25 hours)
|
||||
(u'2003-12-31T10:61:55Z', (2003, 12, 31, 11, 1, 55, 2, 365, 0)), # invalid (61 minutes)
|
||||
(u'2003-12-31T10:14:61Z', (2003, 12, 31, 10, 15, 1, 2, 365, 0)), # invalid (61 seconds)
|
||||
# Special cases for rollovers in leap years
|
||||
(u'2004-02-28T18:14:55-08:00', (2004, 2, 29, 2, 14, 55, 6, 60, 0)), # feb 28 in leap year
|
||||
(u'2003-02-28T18:14:55-08:00', (2003, 3, 1, 2, 14, 55, 5, 60, 0)), # feb 28 in non-leap year
|
||||
(u'2000-02-28T18:14:55-08:00', (2000, 2, 29, 2, 14, 55, 1, 60, 0)), # feb 28 in leap year on century divisible by 400
|
||||
)
|
||||
}
|
||||
|
||||
def make_date_test(f, s, t):
|
||||
return lambda self: self._check_date(f, s, t)
|
||||
|
||||
for func, items in date_tests.iteritems():
|
||||
for i, (dtstring, dttuple) in enumerate(items):
|
||||
uniqfunc = make_date_test(func, dtstring, dttuple)
|
||||
setattr(TestDateParsers, 'test_%s_%02i' % (func.__name__, i), uniqfunc)
|
||||
|
||||
|
||||
class TestHTMLGuessing(unittest.TestCase):
|
||||
"Exercise the HTML sniffing code"
|
||||
def _mktest(text, expect, doc):
|
||||
def fn(self):
|
||||
value = bool(feedparser._FeedParserMixin.lookslikehtml(text))
|
||||
self.assertEqual(value, expect)
|
||||
fn.__doc__ = doc
|
||||
return fn
|
||||
|
||||
test_text_1 = _mktest(u'plain text', False, u'plain text')
|
||||
test_text_2 = _mktest(u'2 < 3', False, u'plain text with angle bracket')
|
||||
test_html_1 = _mktest(u'<a href="">a</a>', True, u'anchor tag')
|
||||
test_html_2 = _mktest(u'<i>i</i>', True, u'italics tag')
|
||||
test_html_3 = _mktest(u'<b>b</b>', True, u'bold tag')
|
||||
test_html_4 = _mktest(u'<code>', False, u'allowed tag, no end tag')
|
||||
test_html_5 = _mktest(u'<rss> .. </rss>', False, u'disallowed tag')
|
||||
test_entity_1 = _mktest(u'AT&T', False, u'corporation name')
|
||||
test_entity_2 = _mktest(u'©', True, u'named entity reference')
|
||||
test_entity_3 = _mktest(u'©', True, u'numeric entity reference')
|
||||
test_entity_4 = _mktest(u'©', True, u'hex numeric entity reference')
|
||||
|
||||
#---------- additional api unit tests, not backed by files
|
||||
|
||||
class TestBuildRequest(unittest.TestCase):
|
||||
"Test that HTTP request objects are created as expected"
|
||||
def test_extra_headers(self):
|
||||
"""You can pass in extra headers and they go into the request object."""
|
||||
|
||||
request = feedparser._build_urllib2_request(
|
||||
'http://example.com/feed',
|
||||
'agent-name',
|
||||
None, None, None, None,
|
||||
{'Cache-Control': 'max-age=0'})
|
||||
# nb, urllib2 folds the case of the headers
|
||||
self.assertEqual(
|
||||
request.get_header('Cache-control'), 'max-age=0')
|
||||
|
||||
|
||||
class TestLxmlBug(unittest.TestCase):
|
||||
def test_lxml_etree_bug(self):
|
||||
try:
|
||||
import lxml.etree
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
doc = u"<feed>&illformed_charref</feed>".encode('utf8')
|
||||
# Importing lxml.etree currently causes libxml2 to
|
||||
# throw SAXException instead of SAXParseException.
|
||||
feedparser.parse(feedparser._StringIO(doc))
|
||||
self.assertTrue(True)
|
||||
|
||||
#---------- parse test files and create test methods ----------
|
||||
def convert_to_utf8(data):
|
||||
"Identify data's encoding using its byte order mark" \
|
||||
"and convert it to its utf-8 equivalent"
|
||||
if data[:4] == _l2bytes([0x4c, 0x6f, 0xa7, 0x94]):
|
||||
return data.decode('cp037').encode('utf-8')
|
||||
elif data[:4] == _l2bytes([0x00, 0x00, 0xfe, 0xff]):
|
||||
if not _UTF32_AVAILABLE:
|
||||
return None
|
||||
return data.decode('utf-32be').encode('utf-8')
|
||||
elif data[:4] == _l2bytes([0xff, 0xfe, 0x00, 0x00]):
|
||||
if not _UTF32_AVAILABLE:
|
||||
return None
|
||||
return data.decode('utf-32le').encode('utf-8')
|
||||
elif data[:4] == _l2bytes([0x00, 0x00, 0x00, 0x3c]):
|
||||
if not _UTF32_AVAILABLE:
|
||||
return None
|
||||
return data.decode('utf-32be').encode('utf-8')
|
||||
elif data[:4] == _l2bytes([0x3c, 0x00, 0x00, 0x00]):
|
||||
if not _UTF32_AVAILABLE:
|
||||
return None
|
||||
return data.decode('utf-32le').encode('utf-8')
|
||||
elif data[:4] == _l2bytes([0x00, 0x3c, 0x00, 0x3f]):
|
||||
return data.decode('utf-16be').encode('utf-8')
|
||||
elif data[:4] == _l2bytes([0x3c, 0x00, 0x3f, 0x00]):
|
||||
return data.decode('utf-16le').encode('utf-8')
|
||||
elif (data[:2] == _l2bytes([0xfe, 0xff])) and (data[2:4] != _l2bytes([0x00, 0x00])):
|
||||
return data[2:].decode('utf-16be').encode('utf-8')
|
||||
elif (data[:2] == _l2bytes([0xff, 0xfe])) and (data[2:4] != _l2bytes([0x00, 0x00])):
|
||||
return data[2:].decode('utf-16le').encode('utf-8')
|
||||
elif data[:3] == _l2bytes([0xef, 0xbb, 0xbf]):
|
||||
return data[3:]
|
||||
# no byte order mark was found
|
||||
return data
|
||||
|
||||
skip_re = re.compile(_s2bytes("SkipUnless:\s*(.*?)\n"))
|
||||
desc_re = re.compile(_s2bytes("Description:\s*(.*?)\s*Expect:\s*(.*)\s*-->"))
|
||||
def getDescription(xmlfile, data):
|
||||
"""Extract test data
|
||||
|
||||
Each test case is an XML file which contains not only a test feed
|
||||
but also the description of the test and the condition that we
|
||||
would expect the parser to create when it parses the feed. Example:
|
||||
<!--
|
||||
Description: feed title
|
||||
Expect: feed['title'] == u'Example feed'
|
||||
-->
|
||||
"""
|
||||
skip_results = skip_re.search(data)
|
||||
if skip_results:
|
||||
skipUnless = skip_results.group(1).strip()
|
||||
else:
|
||||
skipUnless = '1'
|
||||
search_results = desc_re.search(data)
|
||||
if not search_results:
|
||||
raise RuntimeError, "can't parse %s" % xmlfile
|
||||
description, evalString = map(lambda s: s.strip(), list(search_results.groups()))
|
||||
description = xmlfile + ": " + unicode(description, 'utf8')
|
||||
return description, evalString, skipUnless
|
||||
|
||||
def buildTestCase(xmlfile, description, evalString):
|
||||
func = lambda self, xmlfile=xmlfile, evalString=evalString: \
|
||||
self.failUnlessEval(xmlfile, evalString)
|
||||
func.__doc__ = description
|
||||
return func
|
||||
|
||||
def runtests():
|
||||
"Read the files in the tests/ directory, dynamically add tests to the " \
|
||||
"TestCases above, spawn the HTTP server, and run the test suite"
|
||||
if sys.argv[1:]:
|
||||
allfiles = filter(lambda s: s.endswith('.xml'), reduce(operator.add, map(glob.glob, sys.argv[1:]), []))
|
||||
sys.argv = [sys.argv[0]] #+ sys.argv[2:]
|
||||
else:
|
||||
allfiles = glob.glob(os.path.join('.', 'tests', '**', '**', '*.xml'))
|
||||
wellformedfiles = glob.glob(os.path.join('.', 'tests', 'wellformed', '**', '*.xml'))
|
||||
illformedfiles = glob.glob(os.path.join('.', 'tests', 'illformed', '*.xml'))
|
||||
encodingfiles = glob.glob(os.path.join('.', 'tests', 'encoding', '*.xml'))
|
||||
entitiesfiles = glob.glob(os.path.join('.', 'tests', 'entities', '*.xml'))
|
||||
microformatfiles = glob.glob(os.path.join('.', 'tests', 'microformats', '**', '*.xml'))
|
||||
httpd = None
|
||||
# there are several compression test cases that must be accounted for
|
||||
# as well as a number of http status tests that redirect to a target
|
||||
# and a few `_open_resource`-related tests
|
||||
httpcount = 6 + 17 + 2
|
||||
httpcount += len([f for f in allfiles if 'http' in f])
|
||||
httpcount += len([f for f in wellformedfiles if 'http' in f])
|
||||
httpcount += len([f for f in illformedfiles if 'http' in f])
|
||||
httpcount += len([f for f in encodingfiles if 'http' in f])
|
||||
try:
|
||||
for c, xmlfile in enumerate(allfiles + encodingfiles + illformedfiles + entitiesfiles):
|
||||
addTo = TestCase
|
||||
if xmlfile in encodingfiles:
|
||||
addTo = TestEncodings
|
||||
elif xmlfile in entitiesfiles:
|
||||
addTo = (TestStrictParser, TestLooseParser)
|
||||
elif xmlfile in microformatfiles:
|
||||
addTo = TestMicroformats
|
||||
elif xmlfile in wellformedfiles:
|
||||
addTo = (TestStrictParser, TestLooseParser)
|
||||
data = open(xmlfile, 'rb').read()
|
||||
if 'encoding' in xmlfile:
|
||||
data = convert_to_utf8(data)
|
||||
if data is None:
|
||||
# convert_to_utf8 found a byte order mark for utf_32
|
||||
# but it's not supported in this installation of Python
|
||||
if 'http' in xmlfile:
|
||||
httpcount -= 1 + (xmlfile in wellformedfiles)
|
||||
continue
|
||||
description, evalString, skipUnless = getDescription(xmlfile, data)
|
||||
testName = 'test_%06d' % c
|
||||
ishttp = 'http' in xmlfile
|
||||
try:
|
||||
if not eval(skipUnless): raise NotImplementedError
|
||||
except (ImportError, LookupError, NotImplementedError, AttributeError):
|
||||
if ishttp:
|
||||
httpcount -= 1 + (xmlfile in wellformedfiles)
|
||||
continue
|
||||
if ishttp:
|
||||
xmlfile = 'http://%s:%s/%s' % (_HOST, _PORT, posixpath.normpath(xmlfile.replace('\\', '/')))
|
||||
testFunc = buildTestCase(xmlfile, description, evalString)
|
||||
if isinstance(addTo, tuple):
|
||||
setattr(addTo[0], testName, testFunc)
|
||||
setattr(addTo[1], testName, testFunc)
|
||||
else:
|
||||
setattr(addTo, testName, testFunc)
|
||||
if feedparser.TIDY_MARKUP and feedparser._mxtidy:
|
||||
sys.stderr.write('\nWarning: feedparser.TIDY_MARKUP invalidates tests, turning it off temporarily\n\n')
|
||||
feedparser.TIDY_MARKUP = 0
|
||||
if httpcount:
|
||||
httpd = FeedParserTestServer(httpcount)
|
||||
httpd.daemon = True
|
||||
httpd.start()
|
||||
httpd.ready.wait()
|
||||
testsuite = unittest.TestSuite()
|
||||
testloader = unittest.TestLoader()
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestCase))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestStrictParser))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestLooseParser))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestEncodings))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestDateParsers))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestHTMLGuessing))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestHTTPStatus))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestCompression))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestConvertToIdn))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestMicroformats))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestOpenResource))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestFeedParserDict))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestMakeSafeAbsoluteURI))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestEverythingIsUnicode))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestTemporaryFallbackBehavior))
|
||||
testsuite.addTest(testloader.loadTestsFromTestCase(TestLxmlBug))
|
||||
testresults = unittest.TextTestRunner(verbosity=1).run(testsuite)
|
||||
|
||||
# Return 0 if successful, 1 if there was a failure
|
||||
sys.exit(not testresults.wasSuccessful())
|
||||
finally:
|
||||
if httpd:
|
||||
if httpd.requests:
|
||||
# Should never get here unless something went horribly wrong, like the
|
||||
# user hitting Ctrl-C. Tell our HTTP server that it's done, then do
|
||||
# one more request to flush it. This rarely works; the combination of
|
||||
# threading, self-terminating HTTP servers, and unittest is really
|
||||
# quite flaky. Just what you want in a testing framework, no?
|
||||
httpd.requests = 0
|
||||
if httpd.ready:
|
||||
urllib.urlopen('http://127.0.0.1:8097/tests/wellformed/rss/aaa_wellformed.xml').read()
|
||||
httpd.join(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
runtests()
|
|
@ -1 +0,0 @@
|
|||
<feed><title>deflate</title></feed>
|
|
@ -1 +0,0 @@
|
|||
<feed><title>gzip</title></feed>
|
|
@ -1 +0,0 @@
|
|||
<feed xmlns="http://www.w3.org/2005/Atom"></feed>
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="big5"?>
|
||||
<!--
|
||||
SkipUnless: __import__('codecs').lookup('big5')
|
||||
Description: big5
|
||||
Expect: not bozo and encoding == 'big5'
|
||||
-->
|
||||
<rss>
|
||||
</rss>
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="bogus"?>
|
||||
<!--
|
||||
Description: bogus encoding
|
||||
Expect: bozo
|
||||
-->
|
||||
<rss>
|
||||
</rss>
|
|
@ -1,13 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<!--
|
||||
Description: utf-8 interpreted as iso-8859-1 and re-encoded as utf-8
|
||||
Expect: bozo and ord(entries[0]['description']) == 8230
|
||||
-->
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<description>…</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<!--
|
||||
SkipUnless: __import__('sys').version.split()[0] >= '2.2.0'
|
||||
Description: crashes
|
||||
Expect: 1
|
||||
-->
|
||||
<rss>
|
||||
<item>
|
||||
<description><![CDATA[<a href="http://www.example.com/">¤</a><a href="&"></a>]]></description>
|
||||
</item>
|
||||
</rss>
|
|
@ -1,11 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Note: text/xml defaults to us-ascii, in conflict with the XML declaration of utf-8
|
||||
Header: Content-type: text/xml
|
||||
Description: Content-type with no charset (text/xml defaults to us-ascii)
|
||||
Expect: bozo and isinstance(bozo_exception, feedparser.CharacterEncodingOverride)
|
||||
-->
|
||||
|
||||
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
|
||||
<title>Iñtërnâtiônàlizætiøn</title>
|
||||
</feed>
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<!--
|
||||
Header: Content-type: text/plain
|
||||
Description: text/plain + no encoding
|
||||
Expect: bozo
|
||||
-->
|
||||
<rss version="2.0">
|
||||
</rss>
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<!--
|
||||
Header: Content-type: text/plain; charset=utf-8
|
||||
Description: text/plain + charset
|
||||
Expect: bozo and encoding == 'utf-8'
|
||||
-->
|
||||
<rss version="2.0">
|
||||
</rss>
|
|
@ -1,10 +0,0 @@
|
|||
<!--
|
||||
Description: Ensure when there are invalid bytes in encoding specified by BOM, feedparser doesn't crash
|
||||
Expect: bozo
|
||||
-->
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Valid UTF8: ѨInvalid UTF8: España</title>
|
||||
<description><pre class="screen"></pre></description>
|
||||
</channel>
|
||||
</rss
|
|
@ -1,13 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Description: unguessable characters
|
||||
Expect: bozo and entries[0].summary == u'\xe2\u20ac\u2122\xe2\u20ac\x9d\u0160'
|
||||
-->
|
||||
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<description><![CDATA[ ’<>â€<C3A2>© ]]></description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|