mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-10 00:32:52 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
742529a92d | ||
|
|
944d066903 | ||
|
|
87819a3c74 | ||
|
|
c9fbe29c90 | ||
|
|
d78cb7d14e | ||
|
|
e8c392824f | ||
|
|
9811df2779 | ||
|
|
1a4865ed38 | ||
|
|
a06fb40f50 | ||
|
|
ad6a4f570e | ||
|
|
3685d32a7d | ||
|
|
152f5daa8c | ||
|
|
39054a04df | ||
|
|
1c4b9c10f0 | ||
|
|
73ca787cf1 | ||
|
|
c7bc852868 | ||
|
|
391b0cc465 | ||
|
|
4aaeaa704f | ||
|
|
4d14b028ff |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.6.1
|
||||||
|
Released 26 November 2023
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Dependency updates to work with > Python 3.11
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.0...v0.6.1).
|
||||||
|
|
||||||
## v0.6.0
|
## v0.6.0
|
||||||
Released 13 November 2022
|
Released 13 November 2022
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,16 @@
|
|||||||
<input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5">
|
<input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5">
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset title="Method for downloading Bandcamp.com files.">
|
||||||
|
<legend>Bandcamp</legend>
|
||||||
|
<div class="row">
|
||||||
|
<label title="Path to folder where Headphones can store raw downloads from Bandcamp.com.">
|
||||||
|
Bandcamp Directory
|
||||||
|
</label>
|
||||||
|
<input type="text" name="bandcamp_dir" value="${config['bandcamp_dir']}" size="50">
|
||||||
|
<small>Full path where raw MP3s will be stored, e.g. /Users/name/Downloads/bandcamp</small>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<fieldset title="Method for downloading torrent files.">
|
<fieldset title="Method for downloading torrent files.">
|
||||||
@@ -579,6 +589,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Other</legend>
|
||||||
|
<fieldset>
|
||||||
|
<div class="row checkbox left">
|
||||||
|
<input id="use_bandcamp" type="checkbox" class="bigcheck" name="use_bandcamp" value="1" ${config['use_bandcamp']} /><label for="use_bandcamp"><span class="option">Bandcamp</span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
@@ -56,6 +56,8 @@
|
|||||||
fileid = 'torrent'
|
fileid = 'torrent'
|
||||||
if item['URL'].find('codeshy') != -1:
|
if item['URL'].find('codeshy') != -1:
|
||||||
fileid = 'nzb'
|
fileid = 'nzb'
|
||||||
|
if item['URL'].find('bandcamp') != -1:
|
||||||
|
fileid = 'bandcamp'
|
||||||
|
|
||||||
folder = 'Folder: ' + item['FolderName']
|
folder = 'Folder: ' + item['FolderName']
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# This file is part of Headphones.
|
||||||
|
#
|
||||||
|
# Headphones is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# Headphones is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>
|
||||||
|
|
||||||
|
import headphones
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from headphones import logger, helpers, metadata, request
|
||||||
|
from headphones.common import USER_AGENT
|
||||||
|
|
||||||
|
from mediafile import MediaFile, UnreadableFileError
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
def search(album, albumlength=None, page=1, resultlist=None):
|
||||||
|
dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ',
|
||||||
|
'"': '', ',': '', '*': '', '.': '', ':': ''}
|
||||||
|
if resultlist is None:
|
||||||
|
resultlist = []
|
||||||
|
|
||||||
|
cleanalbum = helpers.latinToAscii(
|
||||||
|
helpers.replace_all(album['AlbumTitle'], dic)
|
||||||
|
).strip()
|
||||||
|
cleanartist = helpers.latinToAscii(
|
||||||
|
helpers.replace_all(album['ArtistName'], dic)
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
headers = {'User-Agent': USER_AGENT}
|
||||||
|
params = {
|
||||||
|
"page": page,
|
||||||
|
"q": cleanalbum,
|
||||||
|
}
|
||||||
|
logger.info("Looking up https://bandcamp.com/search with {}".format(
|
||||||
|
params))
|
||||||
|
content = request.request_content(
|
||||||
|
url='https://bandcamp.com/search',
|
||||||
|
params=params,
|
||||||
|
headers=headers
|
||||||
|
).decode('utf8')
|
||||||
|
soup = BeautifulSoup(content, "html5lib")
|
||||||
|
|
||||||
|
for item in soup.find_all("li", class_="searchresult"):
|
||||||
|
type = item.find('div', class_='itemtype').text.strip().lower()
|
||||||
|
if type == "album":
|
||||||
|
data = parse_album(item)
|
||||||
|
|
||||||
|
cleanartist_found = helpers.latinToAscii(data['artist'])
|
||||||
|
cleanalbum_found = helpers.latinToAscii(data['album'])
|
||||||
|
|
||||||
|
logger.debug(u"{} - {}".format(data['album'], cleanalbum_found))
|
||||||
|
|
||||||
|
logger.debug("Comparing {} to {}".format(
|
||||||
|
cleanalbum, cleanalbum_found))
|
||||||
|
if (cleanartist.lower() == cleanartist_found.lower() and
|
||||||
|
cleanalbum.lower() == cleanalbum_found.lower()):
|
||||||
|
resultlist.append((
|
||||||
|
data['title'], data['size'], data['url'],
|
||||||
|
'bandcamp', 'bandcamp', True))
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if(soup.find('a', class_='next')):
|
||||||
|
page += 1
|
||||||
|
logger.debug("Calling next page ({})".format(page))
|
||||||
|
search(album, albumlength=albumlength,
|
||||||
|
page=page, resultlist=resultlist)
|
||||||
|
|
||||||
|
return resultlist
|
||||||
|
|
||||||
|
|
||||||
|
def download(album, bestqual):
|
||||||
|
html = request.request_content(url=bestqual[2]).decode('utf-8')
|
||||||
|
trackinfo = []
|
||||||
|
try:
|
||||||
|
trackinfo = json.loads(
|
||||||
|
re.search(r"trackinfo":(\[.*?\]),", html)
|
||||||
|
.group(1)
|
||||||
|
.replace('"', '"'))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warn("Couldn't load json: {}".format(e))
|
||||||
|
|
||||||
|
directory = os.path.join(
|
||||||
|
headphones.CONFIG.BANDCAMP_DIR,
|
||||||
|
u'{} - {}'.format(
|
||||||
|
album['ArtistName'].replace('/', '_'),
|
||||||
|
album['AlbumTitle'].replace('/', '_')))
|
||||||
|
directory = helpers.latinToAscii(directory)
|
||||||
|
|
||||||
|
if not os.path.exists(directory):
|
||||||
|
try:
|
||||||
|
os.makedirs(directory)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warn("Could not create directory ({})".format(e))
|
||||||
|
|
||||||
|
index = 1
|
||||||
|
for track in trackinfo:
|
||||||
|
filename = helpers.replace_illegal_chars(
|
||||||
|
u'{:02d} - {}.mp3'.format(index, track['title']))
|
||||||
|
fullname = os.path.join(directory.encode('utf-8'),
|
||||||
|
filename.encode('utf-8'))
|
||||||
|
logger.debug("Downloading to {}".format(fullname))
|
||||||
|
|
||||||
|
if 'file' in track and track['file'] != None and 'mp3-128' in track['file']:
|
||||||
|
content = request.request_content(track['file']['mp3-128'])
|
||||||
|
open(fullname, 'wb').write(content)
|
||||||
|
try:
|
||||||
|
f = MediaFile(fullname)
|
||||||
|
date, year = metadata._date_year(album)
|
||||||
|
f.update({
|
||||||
|
'artist': album['ArtistName'].encode('utf-8'),
|
||||||
|
'album': album['AlbumTitle'].encode('utf-8'),
|
||||||
|
'title': track['title'].encode('utf-8'),
|
||||||
|
'track': track['track_num'],
|
||||||
|
'tracktotal': len(trackinfo),
|
||||||
|
'year': year,
|
||||||
|
})
|
||||||
|
f.save()
|
||||||
|
except UnreadableFileError as ex:
|
||||||
|
logger.warn("MediaFile couldn't parse: %s (%s)",
|
||||||
|
fullname,
|
||||||
|
str(ex))
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def parse_album(item):
|
||||||
|
album = item.find('div', class_='heading').text.strip()
|
||||||
|
artist = item.find('div', class_='subhead').text.strip().replace("by ", "")
|
||||||
|
released = item.find('div', class_='released').text.strip().replace(
|
||||||
|
"released ", "")
|
||||||
|
year = re.search(r"(\d{4})", released).group(1)
|
||||||
|
|
||||||
|
url = item.find('div', class_='heading').find('a')['href'].split("?")[0]
|
||||||
|
|
||||||
|
length = item.find('div', class_='length').text.strip()
|
||||||
|
tracks, minutes = length.split(",")
|
||||||
|
tracks = tracks.replace(" tracks", "").replace(" track", "").strip()
|
||||||
|
minutes = minutes.replace(" minutes", "").strip()
|
||||||
|
# bandcamp offers mp3 128b with should be 960KB/minute
|
||||||
|
size = int(minutes) * 983040
|
||||||
|
|
||||||
|
data = {"title": u'{} - {} [{}]'.format(artist, album, year),
|
||||||
|
"artist": artist, "album": album,
|
||||||
|
"url": url, "size": size}
|
||||||
|
|
||||||
|
return data
|
||||||
@@ -102,36 +102,6 @@ class Quality:
|
|||||||
|
|
||||||
return (anyQualities, bestQualities)
|
return (anyQualities, bestQualities)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def nameQuality(name):
|
|
||||||
|
|
||||||
def checkName(list, func):
|
|
||||||
return func([re.search(x, name, re.I) for x in list])
|
|
||||||
|
|
||||||
name = os.path.basename(name)
|
|
||||||
|
|
||||||
# if we have our exact text then assume we put it there
|
|
||||||
for x in Quality.qualityStrings:
|
|
||||||
if x == Quality.UNKNOWN:
|
|
||||||
continue
|
|
||||||
|
|
||||||
regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W'
|
|
||||||
regex_match = re.search(regex, name, re.I)
|
|
||||||
if regex_match:
|
|
||||||
return x
|
|
||||||
|
|
||||||
# TODO: fix quality checking here
|
|
||||||
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.B192
|
|
||||||
elif checkName(["mp3", "256"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.B256
|
|
||||||
elif checkName(["mp3", "vbr"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.VBR
|
|
||||||
elif checkName(["mp3", "320"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.B320
|
|
||||||
else:
|
|
||||||
return Quality.UNKNOWN
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def assumeQuality(name):
|
def assumeQuality(name):
|
||||||
if name.lower().endswith(".mp3"):
|
if name.lower().endswith(".mp3"):
|
||||||
@@ -158,13 +128,6 @@ class Quality:
|
|||||||
|
|
||||||
return (Quality.NONE, status)
|
return (Quality.NONE, status)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def statusFromName(name, assume=True):
|
|
||||||
quality = Quality.nameQuality(name)
|
|
||||||
if assume and quality == Quality.UNKNOWN:
|
|
||||||
quality = Quality.assumeQuality(name)
|
|
||||||
return Quality.compositeStatus(DOWNLOADED, quality)
|
|
||||||
|
|
||||||
DOWNLOADED = None
|
DOWNLOADED = None
|
||||||
SNATCHED = None
|
SNATCHED = None
|
||||||
SNATCHED_PROPER = None
|
SNATCHED_PROPER = None
|
||||||
|
|||||||
@@ -317,7 +317,9 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'XBMC_PASSWORD': (str, 'XBMC', ''),
|
'XBMC_PASSWORD': (str, 'XBMC', ''),
|
||||||
'XBMC_UPDATE': (int, 'XBMC', 0),
|
'XBMC_UPDATE': (int, 'XBMC', 0),
|
||||||
'XBMC_USERNAME': (str, 'XBMC', ''),
|
'XBMC_USERNAME': (str, 'XBMC', ''),
|
||||||
'XLDPROFILE': (str, 'General', '')
|
'XLDPROFILE': (str, 'General', ''),
|
||||||
|
'BANDCAMP': (int, 'General', 1),
|
||||||
|
'BANDCAMP_DIR': (path, 'General', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ def _scrubber(text):
|
|||||||
if scrub_logs:
|
if scrub_logs:
|
||||||
try:
|
try:
|
||||||
# URL parameter values
|
# URL parameter values
|
||||||
text = re.sub('=[0-9a-zA-Z]*', '=REMOVED', text)
|
text = re.sub(r'=[0-9a-zA-Z]*', r'=REMOVED', text)
|
||||||
# Local host with port
|
# Local host with port
|
||||||
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
|
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
|
||||||
text = re.sub('\:\/\/.*\:[0-9]*', '://REMOVED:', text)
|
text = re.sub(r'\:\/\/.*\:[0-9]*', r'://REMOVED:', text)
|
||||||
# Session cookie
|
# Session cookie
|
||||||
text = re.sub("_session_id'\: '.*'", "_session_id': 'REMOVED'", text)
|
text = re.sub(r"_session_id'\: '.*'", r"_session_id': 'REMOVED'", text)
|
||||||
# Local Windows user path
|
# Local Windows user path
|
||||||
if text.lower().startswith('c:\\users\\'):
|
if text.lower().startswith('c:\\users\\'):
|
||||||
k = text.split('\\')
|
k = text.split('\\')
|
||||||
@@ -128,9 +128,9 @@ def addTorrent(link, data=None, name=None):
|
|||||||
# Extract torrent name from .torrent
|
# Extract torrent name from .torrent
|
||||||
try:
|
try:
|
||||||
logger.debug('Deluge: Getting torrent name length')
|
logger.debug('Deluge: Getting torrent name length')
|
||||||
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||||
logger.debug('Deluge: Getting torrent name')
|
logger.debug('Deluge: Getting torrent name')
|
||||||
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||||
# get last part of link/path (name only)
|
# get last part of link/path (name only)
|
||||||
@@ -160,9 +160,9 @@ def addTorrent(link, data=None, name=None):
|
|||||||
# Extract torrent name from .torrent
|
# Extract torrent name from .torrent
|
||||||
try:
|
try:
|
||||||
logger.debug('Deluge: Getting torrent name length')
|
logger.debug('Deluge: Getting torrent name length')
|
||||||
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||||
logger.debug('Deluge: Getting torrent name')
|
logger.debug('Deluge: Getting torrent name')
|
||||||
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||||
# get last part of link/path (name only)
|
# get last part of link/path (name only)
|
||||||
|
|||||||
@@ -1050,3 +1050,10 @@ def have_pct_have_total(db_artist):
|
|||||||
have_pct = have_tracks / total_tracks if total_tracks else 0
|
have_pct = have_tracks / total_tracks if total_tracks else 0
|
||||||
return (have_pct, total_tracks)
|
return (have_pct, total_tracks)
|
||||||
|
|
||||||
|
|
||||||
|
def has_token(title, token):
|
||||||
|
return bool(
|
||||||
|
re.search(rf'(?:\W|^)+{token}(?:\W|$)+',
|
||||||
|
title,
|
||||||
|
re.IGNORECASE | re.UNICODE)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from .unittestcompat import TestCase
|
from .unittestcompat import TestCase
|
||||||
from headphones.helpers import clean_name, is_valid_date, age
|
from headphones.helpers import clean_name, is_valid_date, age, has_token
|
||||||
|
|
||||||
|
|
||||||
class HelpersTest(TestCase):
|
class HelpersTest(TestCase):
|
||||||
@@ -56,3 +56,18 @@ class HelpersTest(TestCase):
|
|||||||
]
|
]
|
||||||
for input, expected, desc in test_cases:
|
for input, expected, desc in test_cases:
|
||||||
self.assertEqual(is_valid_date(input), expected, desc)
|
self.assertEqual(is_valid_date(input), expected, desc)
|
||||||
|
|
||||||
|
def test_has_token(self):
|
||||||
|
"""helpers: has_token()"""
|
||||||
|
self.assertEqual(
|
||||||
|
has_token("a cat ran", "cat"),
|
||||||
|
True,
|
||||||
|
"return True if token is in string"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
has_token("acatran", "cat"),
|
||||||
|
False,
|
||||||
|
"return False if token is part of another word"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ def checkFolder():
|
|||||||
single = False
|
single = False
|
||||||
if album['Kind'] == 'nzb':
|
if album['Kind'] == 'nzb':
|
||||||
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||||
|
elif album['Kind'] == 'bandcamp':
|
||||||
|
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
||||||
else:
|
else:
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
||||||
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||||
@@ -289,7 +291,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
|
|||||||
logger.debug('Metadata check failed. Verifying filenames...')
|
logger.debug('Metadata check failed. Verifying filenames...')
|
||||||
for downloaded_track in downloaded_track_list:
|
for downloaded_track in downloaded_track_list:
|
||||||
track_name = os.path.splitext(downloaded_track)[0]
|
track_name = os.path.splitext(downloaded_track)[0]
|
||||||
split_track_name = re.sub('[\.\-\_]', ' ', track_name).lower()
|
split_track_name = re.sub(r'[\.\-\_]', r' ', track_name).lower()
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
|
|
||||||
if not track['TrackTitle']:
|
if not track['TrackTitle']:
|
||||||
@@ -1171,7 +1173,11 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
|||||||
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
||||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
||||||
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
||||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_DIR)
|
download_dirs.append(
|
||||||
|
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
||||||
|
if headphones.CONFIG.BANDCAMP and not dir:
|
||||||
|
download_dirs.append(
|
||||||
|
headphones.CONFIG.BANDCAMP_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
||||||
|
|
||||||
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
|
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
|
||||||
download_dirs = list(set(download_dirs))
|
download_dirs = list(set(download_dirs))
|
||||||
|
|||||||
+14
-5
@@ -42,19 +42,22 @@ class Rutracker(object):
|
|||||||
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||||
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
||||||
}
|
}
|
||||||
|
headers = {
|
||||||
|
'User-Agent' : 'Headphones'
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Attempting to log in to rutracker...")
|
logger.info("Attempting to log in to rutracker...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers)
|
||||||
# try again
|
# try again
|
||||||
if not self.has_bb_session_cookie(r):
|
if not self.has_bb_session_cookie(r):
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
if headphones.CONFIG.RUTRACKER_COOKIE:
|
if headphones.CONFIG.RUTRACKER_COOKIE:
|
||||||
logger.info("Attempting to log in using predefined cookie...")
|
logger.info("Attempting to log in using predefined cookie...")
|
||||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
|
||||||
else:
|
else:
|
||||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers)
|
||||||
if self.has_bb_session_cookie(r):
|
if self.has_bb_session_cookie(r):
|
||||||
self.loggedin = True
|
self.loggedin = True
|
||||||
logger.info("Successfully logged in to rutracker")
|
logger.info("Successfully logged in to rutracker")
|
||||||
@@ -113,7 +116,10 @@ class Rutracker(object):
|
|||||||
Parse the search results and return valid torrent list
|
Parse the search results and return valid torrent list
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
headers = {'Referer': self.search_referer}
|
headers = {
|
||||||
|
'Referer': self.search_referer,
|
||||||
|
'User-Agent' : 'Headphones'
|
||||||
|
}
|
||||||
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
||||||
soup = BeautifulSoup(r.content, 'html.parser')
|
soup = BeautifulSoup(r.content, 'html.parser')
|
||||||
|
|
||||||
@@ -183,7 +189,10 @@ class Rutracker(object):
|
|||||||
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
|
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
|
||||||
cookie = {'bb_dl': torrent_id}
|
cookie = {'bb_dl': torrent_id}
|
||||||
try:
|
try:
|
||||||
headers = {'Referer': url}
|
headers = {
|
||||||
|
'Referer': url,
|
||||||
|
'User-Agent' : 'Headphones'
|
||||||
|
}
|
||||||
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
||||||
timeout=self.timeout)
|
timeout=self.timeout)
|
||||||
return r.content
|
return r.content
|
||||||
|
|||||||
+63
-25
@@ -37,10 +37,27 @@ from unidecode import unidecode
|
|||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
from headphones.common import USER_AGENT
|
from headphones.common import USER_AGENT
|
||||||
|
from headphones.helpers import (
|
||||||
|
bytes_to_mb,
|
||||||
|
has_token,
|
||||||
|
piratesize,
|
||||||
|
replace_all,
|
||||||
|
replace_illegal_chars,
|
||||||
|
sab_replace_dots,
|
||||||
|
sab_replace_spaces,
|
||||||
|
sab_sanitize_foldername,
|
||||||
|
)
|
||||||
from headphones.types import Result
|
from headphones.types import Result
|
||||||
from headphones import logger, db, helpers, classes, sab, nzbget, request
|
from headphones import logger, db, classes, sab, nzbget, request
|
||||||
from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent
|
from headphones import (
|
||||||
|
bandcamp,
|
||||||
|
deluge,
|
||||||
|
notifiers,
|
||||||
|
qbittorrent,
|
||||||
|
rutracker,
|
||||||
|
transmission,
|
||||||
|
utorrent,
|
||||||
|
)
|
||||||
|
|
||||||
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
|
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
|
||||||
TORRENT_TO_MAGNET_SERVICES = [
|
TORRENT_TO_MAGNET_SERVICES = [
|
||||||
@@ -137,7 +154,7 @@ def calculate_torrent_hash(link, data=None):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if link.startswith("magnet:"):
|
if link.startswith("magnet:"):
|
||||||
torrent_hash = re.findall("urn:btih:([\w]{32,40})", link)[0]
|
torrent_hash = re.findall(r"urn:btih:([\w]{32,40})", link)[0]
|
||||||
if len(torrent_hash) == 32:
|
if len(torrent_hash) == 32:
|
||||||
torrent_hash = b16encode(b32decode(torrent_hash)).lower()
|
torrent_hash = b16encode(b32decode(torrent_hash)).lower()
|
||||||
elif data:
|
elif data:
|
||||||
@@ -284,25 +301,29 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
|||||||
[album['AlbumID']])[0][0]
|
[album['AlbumID']])[0][0]
|
||||||
|
|
||||||
if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download:
|
if headphones.CONFIG.PREFER_TORRENTS == 0 and not choose_specific_download:
|
||||||
|
|
||||||
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||||
results = searchNZB(album, new, losslessOnly, albumlength)
|
results = searchNZB(album, new, losslessOnly, albumlength)
|
||||||
|
|
||||||
if not results and TORRENT_PROVIDERS:
|
if not results and TORRENT_PROVIDERS:
|
||||||
results = searchTorrent(album, new, losslessOnly, albumlength)
|
results = searchTorrent(album, new, losslessOnly, albumlength)
|
||||||
|
|
||||||
elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download:
|
if not results and headphones.CONFIG.BANDCAMP:
|
||||||
|
results = searchBandcamp(album, new, albumlength)
|
||||||
|
|
||||||
|
elif headphones.CONFIG.PREFER_TORRENTS == 1 and not choose_specific_download:
|
||||||
if TORRENT_PROVIDERS:
|
if TORRENT_PROVIDERS:
|
||||||
results = searchTorrent(album, new, losslessOnly, albumlength)
|
results = searchTorrent(album, new, losslessOnly, albumlength)
|
||||||
|
|
||||||
if not results and NZB_PROVIDERS and NZB_DOWNLOADERS:
|
if not results and NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||||
results = searchNZB(album, new, losslessOnly, albumlength)
|
results = searchNZB(album, new, losslessOnly, albumlength)
|
||||||
|
|
||||||
|
if not results and headphones.CONFIG.BANDCAMP:
|
||||||
|
results = searchBandcamp(album, new, albumlength)
|
||||||
else:
|
else:
|
||||||
|
|
||||||
nzb_results = None
|
nzb_results = None
|
||||||
torrent_results = None
|
torrent_results = None
|
||||||
|
bandcamp_results = None
|
||||||
|
|
||||||
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
if NZB_PROVIDERS and NZB_DOWNLOADERS:
|
||||||
nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download)
|
nzb_results = searchNZB(album, new, losslessOnly, albumlength, choose_specific_download)
|
||||||
@@ -311,13 +332,16 @@ def do_sorted_search(album, new, losslessOnly, choose_specific_download=False):
|
|||||||
torrent_results = searchTorrent(album, new, losslessOnly, albumlength,
|
torrent_results = searchTorrent(album, new, losslessOnly, albumlength,
|
||||||
choose_specific_download)
|
choose_specific_download)
|
||||||
|
|
||||||
|
if headphones.CONFIG.BANDCAMP:
|
||||||
|
bandcamp_results = searchBandcamp(album, new, albumlength)
|
||||||
|
|
||||||
if not nzb_results:
|
if not nzb_results:
|
||||||
nzb_results = []
|
nzb_results = []
|
||||||
|
|
||||||
if not torrent_results:
|
if not torrent_results:
|
||||||
torrent_results = []
|
torrent_results = []
|
||||||
|
|
||||||
results = nzb_results + torrent_results
|
results = nzb_results + torrent_results + bandcamp_results
|
||||||
|
|
||||||
if choose_specific_download:
|
if choose_specific_download:
|
||||||
return results
|
return results
|
||||||
@@ -502,6 +526,10 @@ def get_year_from_release_date(release_date):
|
|||||||
return year
|
return year
|
||||||
|
|
||||||
|
|
||||||
|
def searchBandcamp(album, new=False, albumlength=None):
|
||||||
|
return bandcamp.search(album)
|
||||||
|
|
||||||
|
|
||||||
def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
||||||
choose_specific_download=False):
|
choose_specific_download=False):
|
||||||
reldate = album['ReleaseDate']
|
reldate = album['ReleaseDate']
|
||||||
@@ -542,8 +570,8 @@ def searchNZB(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
term = cleanartist + ' ' + cleanalbum
|
term = cleanartist + ' ' + cleanalbum
|
||||||
|
|
||||||
# Replace bad characters in the term
|
# Replace bad characters in the term
|
||||||
term = re.sub('[\.\-\/]', ' ', term)
|
term = re.sub(r'[\.\-\/]', r' ', term)
|
||||||
artistterm = re.sub('[\.\-\/]', ' ', cleanartist)
|
artistterm = re.sub(r'[\.\-\/]', r' ', cleanartist)
|
||||||
|
|
||||||
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
|
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
|
||||||
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
|
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
|
||||||
@@ -839,6 +867,11 @@ def send_to_downloader(data, result, album):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Couldn\'t write NZB file: %s', e)
|
logger.error('Couldn\'t write NZB file: %s', e)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
elif kind == 'bandcamp':
|
||||||
|
folder_name = bandcamp.download(album, bestqual)
|
||||||
|
logger.info("Setting folder_name to: {}".format(folder_name))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
folder_name = '%s - %s [%s]' % (
|
folder_name = '%s - %s [%s]' % (
|
||||||
unidecode(album['ArtistName']).replace('/', '_'),
|
unidecode(album['ArtistName']).replace('/', '_'),
|
||||||
@@ -1156,7 +1189,7 @@ def send_to_downloader(data, result, album):
|
|||||||
|
|
||||||
|
|
||||||
def verifyresult(title, artistterm, term, lossless):
|
def verifyresult(title, artistterm, term, lossless):
|
||||||
title = re.sub('[\.\-\/\_]', ' ', title)
|
title = re.sub(r'[\.\-\/\_]', r' ', title)
|
||||||
|
|
||||||
# if artistterm != 'Various Artists':
|
# if artistterm != 'Various Artists':
|
||||||
#
|
#
|
||||||
@@ -1219,23 +1252,23 @@ def verifyresult(title, artistterm, term, lossless):
|
|||||||
title, each_word)
|
title, each_word)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
tokens = re.split('\W', term, re.IGNORECASE | re.UNICODE)
|
tokens = re.split(r'\W', term, re.IGNORECASE | re.UNICODE)
|
||||||
|
|
||||||
for token in tokens:
|
for token in tokens:
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
continue
|
continue
|
||||||
if token == 'Various' or token == 'Artists' or token == 'VA':
|
if token == 'Various' or token == 'Artists' or token == 'VA':
|
||||||
continue
|
continue
|
||||||
if not re.search('(?:\W|^)+' + token + '(?:\W|$)+', title, re.IGNORECASE | re.UNICODE):
|
if not has_token(title, token):
|
||||||
cleantoken = ''.join(c for c in token if c not in string.punctuation)
|
cleantoken = ''.join(c for c in token if c not in string.punctuation)
|
||||||
if not not re.search('(?:\W|^)+' + cleantoken + '(?:\W|$)+', title,
|
if not has_token(title, cleantoken):
|
||||||
re.IGNORECASE | re.UNICODE):
|
|
||||||
dic = {'!': 'i', '$': 's'}
|
dic = {'!': 'i', '$': 's'}
|
||||||
dumbtoken = helpers.replace_all(token, dic)
|
dumbtoken = helpers.replace_all(token, dic)
|
||||||
if not not re.search('(?:\W|^)+' + dumbtoken + '(?:\W|$)+', title,
|
if not has_token(title, dumbtoken):
|
||||||
re.IGNORECASE | re.UNICODE):
|
logger.info(
|
||||||
logger.info("Removed from results: %s (missing tokens: %s and %s)", title,
|
"Removed from results: %s (missing tokens: [%s, %s, %s])",
|
||||||
token, cleantoken)
|
title, token, cleantoken, dumbtoken)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -1293,12 +1326,12 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
else:
|
else:
|
||||||
usersearchterm = ''
|
usersearchterm = ''
|
||||||
|
|
||||||
semi_clean_artist_term = re.sub('[\.\-\/]', ' ', semi_cleanartist)
|
semi_clean_artist_term = re.sub(r'[\.\-\/]', r' ', semi_cleanartist)
|
||||||
semi_clean_album_term = re.sub('[\.\-\/]', ' ', semi_cleanalbum)
|
semi_clean_album_term = re.sub(r'[\.\-\/]', r' ', semi_cleanalbum)
|
||||||
# Replace bad characters in the term
|
# Replace bad characters in the term
|
||||||
term = re.sub('[\.\-\/]', ' ', term)
|
term = re.sub(r'[\.\-\/]', r' ', term)
|
||||||
artistterm = re.sub('[\.\-\/]', ' ', cleanartist)
|
artistterm = re.sub(r'[\.\-\/]', r' ', cleanartist)
|
||||||
albumterm = re.sub('[\.\-\/]', ' ', cleanalbum)
|
albumterm = re.sub(r'[\.\-\/]', r' ', cleanalbum)
|
||||||
|
|
||||||
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
|
# If Preferred Bitrate and High Limit and Allow Lossless then get both lossy and lossless
|
||||||
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
|
if headphones.CONFIG.PREFERRED_QUALITY == 2 and headphones.CONFIG.PREFERRED_BITRATE and headphones.CONFIG.PREFERRED_BITRATE_HIGH_BUFFER and headphones.CONFIG.PREFERRED_BITRATE_ALLOW_LOSSLESS:
|
||||||
@@ -1761,7 +1794,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
# Pirate Bay
|
# Pirate Bay
|
||||||
if headphones.CONFIG.PIRATEBAY:
|
if headphones.CONFIG.PIRATEBAY:
|
||||||
provider = "The Pirate Bay"
|
provider = "The Pirate Bay"
|
||||||
tpb_term = term.replace("!", "").replace("'", " ")
|
tpb_term = term.replace("!", "").replace("'", " ").replace(" ", "%20")
|
||||||
|
|
||||||
# Use proxy if specified
|
# Use proxy if specified
|
||||||
if headphones.CONFIG.PIRATEBAY_PROXY_URL:
|
if headphones.CONFIG.PIRATEBAY_PROXY_URL:
|
||||||
@@ -1793,6 +1826,8 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
# Process content
|
# Process content
|
||||||
if data:
|
if data:
|
||||||
rows = data.select('table tbody tr')
|
rows = data.select('table tbody tr')
|
||||||
|
if not rows:
|
||||||
|
rows = data.select('table tr')
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
logger.info("No results found from The Pirate Bay using term: %s" % tpb_term)
|
logger.info("No results found from The Pirate Bay using term: %s" % tpb_term)
|
||||||
@@ -1906,14 +1941,17 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None,
|
|||||||
|
|
||||||
def preprocess(resultlist):
|
def preprocess(resultlist):
|
||||||
for result in resultlist:
|
for result in resultlist:
|
||||||
|
if result[4] == 'bandcamp':
|
||||||
|
return True, result
|
||||||
|
|
||||||
if result.provider in ["The Pirate Bay", "Old Pirate Bay"]:
|
if result[4] == 'torrent' and result.provider in ["The Pirate Bay", "Old Pirate Bay"]:
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Windows NT 6.3; Win64; x64) \
|
'Mozilla/5.0 (Windows NT 6.3; Win64; x64) \
|
||||||
AppleWebKit/537.36 (KHTML, like Gecko) \
|
AppleWebKit/537.36 (KHTML, like Gecko) \
|
||||||
Chrome/41.0.2243.2 Safari/537.36'
|
Chrome/41.0.2243.2 Safari/537.36'
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
headers = {'User-Agent': USER_AGENT}
|
headers = {'User-Agent': USER_AGENT}
|
||||||
|
|
||||||
|
|||||||
@@ -1413,7 +1413,9 @@ class WebInterface(object):
|
|||||||
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
|
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
|
||||||
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
|
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
|
||||||
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
||||||
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID
|
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID,
|
||||||
|
"use_bandcamp": checked(headphones.CONFIG.BANDCAMP),
|
||||||
|
"bandcamp_dir": headphones.CONFIG.BANDCAMP_DIR
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v in config.items():
|
for k, v in config.items():
|
||||||
@@ -1482,7 +1484,7 @@ class WebInterface(object):
|
|||||||
"songkick_enabled", "songkick_filter_enabled",
|
"songkick_enabled", "songkick_filter_enabled",
|
||||||
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
|
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
|
||||||
"customauth", "idtag", "deluge_paused",
|
"customauth", "idtag", "deluge_paused",
|
||||||
"join_enabled", "join_onsnatch"
|
"join_enabled", "join_onsnatch", "use_bandcamp"
|
||||||
]
|
]
|
||||||
for checked_config in checked_configs:
|
for checked_config in checked_configs:
|
||||||
if checked_config not in kwargs:
|
if checked_config not in kwargs:
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version_info = (3, 0, 1)
|
from pkg_resources import get_distribution, DistributionNotFound
|
||||||
version = '3.0.1'
|
|
||||||
release = '3.0.1'
|
|
||||||
|
|
||||||
__version__ = release # PEP 396
|
try:
|
||||||
|
release = get_distribution('APScheduler').version.split('-')[0]
|
||||||
|
except DistributionNotFound:
|
||||||
|
release = '3.5.0'
|
||||||
|
|
||||||
|
version_info = tuple(int(x) if x.isdigit() else x for x in release.split('.'))
|
||||||
|
version = __version__ = '.'.join(str(x) for x in version_info[:3])
|
||||||
|
del get_distribution, DistributionNotFound
|
||||||
|
|||||||
+42
-21
@@ -1,25 +1,33 @@
|
|||||||
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
|
__all__ = ('EVENT_SCHEDULER_STARTED', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_SCHEDULER_PAUSED',
|
||||||
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED', 'EVENT_JOB_ADDED',
|
'EVENT_SCHEDULER_RESUMED', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
|
||||||
'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED', 'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED',
|
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED',
|
||||||
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
|
'EVENT_JOB_ADDED', 'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED',
|
||||||
|
'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOB_SUBMITTED', 'EVENT_JOB_MAX_INSTANCES',
|
||||||
|
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent', 'JobSubmissionEvent')
|
||||||
|
|
||||||
|
|
||||||
EVENT_SCHEDULER_START = 1
|
EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0
|
||||||
EVENT_SCHEDULER_SHUTDOWN = 2
|
EVENT_SCHEDULER_SHUTDOWN = 2 ** 1
|
||||||
EVENT_EXECUTOR_ADDED = 4
|
EVENT_SCHEDULER_PAUSED = 2 ** 2
|
||||||
EVENT_EXECUTOR_REMOVED = 8
|
EVENT_SCHEDULER_RESUMED = 2 ** 3
|
||||||
EVENT_JOBSTORE_ADDED = 16
|
EVENT_EXECUTOR_ADDED = 2 ** 4
|
||||||
EVENT_JOBSTORE_REMOVED = 32
|
EVENT_EXECUTOR_REMOVED = 2 ** 5
|
||||||
EVENT_ALL_JOBS_REMOVED = 64
|
EVENT_JOBSTORE_ADDED = 2 ** 6
|
||||||
EVENT_JOB_ADDED = 128
|
EVENT_JOBSTORE_REMOVED = 2 ** 7
|
||||||
EVENT_JOB_REMOVED = 256
|
EVENT_ALL_JOBS_REMOVED = 2 ** 8
|
||||||
EVENT_JOB_MODIFIED = 512
|
EVENT_JOB_ADDED = 2 ** 9
|
||||||
EVENT_JOB_EXECUTED = 1024
|
EVENT_JOB_REMOVED = 2 ** 10
|
||||||
EVENT_JOB_ERROR = 2048
|
EVENT_JOB_MODIFIED = 2 ** 11
|
||||||
EVENT_JOB_MISSED = 4096
|
EVENT_JOB_EXECUTED = 2 ** 12
|
||||||
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
|
EVENT_JOB_ERROR = 2 ** 13
|
||||||
|
EVENT_JOB_MISSED = 2 ** 14
|
||||||
|
EVENT_JOB_SUBMITTED = 2 ** 15
|
||||||
|
EVENT_JOB_MAX_INSTANCES = 2 ** 16
|
||||||
|
EVENT_ALL = (EVENT_SCHEDULER_STARTED | EVENT_SCHEDULER_SHUTDOWN | EVENT_SCHEDULER_PAUSED |
|
||||||
|
EVENT_SCHEDULER_RESUMED | EVENT_EXECUTOR_ADDED | EVENT_EXECUTOR_REMOVED |
|
||||||
|
EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED | EVENT_ALL_JOBS_REMOVED |
|
||||||
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
|
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
|
||||||
EVENT_JOB_ERROR | EVENT_JOB_MISSED)
|
EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_SUBMITTED | EVENT_JOB_MAX_INSTANCES)
|
||||||
|
|
||||||
|
|
||||||
class SchedulerEvent(object):
|
class SchedulerEvent(object):
|
||||||
@@ -55,9 +63,21 @@ class JobEvent(SchedulerEvent):
|
|||||||
self.jobstore = jobstore
|
self.jobstore = jobstore
|
||||||
|
|
||||||
|
|
||||||
|
class JobSubmissionEvent(JobEvent):
|
||||||
|
"""
|
||||||
|
An event that concerns the submission of a job to its executor.
|
||||||
|
|
||||||
|
:ivar scheduled_run_times: a list of datetimes when the job was intended to run
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code, job_id, jobstore, scheduled_run_times):
|
||||||
|
super(JobSubmissionEvent, self).__init__(code, job_id, jobstore)
|
||||||
|
self.scheduled_run_times = scheduled_run_times
|
||||||
|
|
||||||
|
|
||||||
class JobExecutionEvent(JobEvent):
|
class JobExecutionEvent(JobEvent):
|
||||||
"""
|
"""
|
||||||
An event that concerns the execution of individual jobs.
|
An event that concerns the running of a job within its executor.
|
||||||
|
|
||||||
:ivar scheduled_run_time: the time when the job was scheduled to be run
|
:ivar scheduled_run_time: the time when the job was scheduled to be run
|
||||||
:ivar retval: the return value of the successfully executed job
|
:ivar retval: the return value of the successfully executed job
|
||||||
@@ -65,7 +85,8 @@ class JobExecutionEvent(JobEvent):
|
|||||||
:ivar traceback: a formatted traceback for the exception
|
:ivar traceback: a formatted traceback for the exception
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, traceback=None):
|
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None,
|
||||||
|
traceback=None):
|
||||||
super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
|
super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
|
||||||
self.scheduled_run_time = scheduled_run_time
|
self.scheduled_run_time = scheduled_run_time
|
||||||
self.retval = retval
|
self.retval = retval
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||||
|
from apscheduler.util import iscoroutinefunction_partial
|
||||||
|
|
||||||
|
|
||||||
class AsyncIOExecutor(BaseExecutor):
|
class AsyncIOExecutor(BaseExecutor):
|
||||||
"""
|
"""
|
||||||
Runs jobs in the default executor of the event loop.
|
Runs jobs in the default executor of the event loop.
|
||||||
|
|
||||||
|
If the job function is a native coroutine function, it is scheduled to be run directly in the
|
||||||
|
event loop as soon as possible. All other functions are run in the event loop's default
|
||||||
|
executor which is usually a thread pool.
|
||||||
|
|
||||||
Plugin alias: ``asyncio``
|
Plugin alias: ``asyncio``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def start(self, scheduler, alias):
|
def start(self, scheduler, alias):
|
||||||
super(AsyncIOExecutor, self).start(scheduler, alias)
|
super(AsyncIOExecutor, self).start(scheduler, alias)
|
||||||
self._eventloop = scheduler._eventloop
|
self._eventloop = scheduler._eventloop
|
||||||
|
self._pending_futures = set()
|
||||||
|
|
||||||
|
def shutdown(self, wait=True):
|
||||||
|
# There is no way to honor wait=True without converting this method into a coroutine method
|
||||||
|
for f in self._pending_futures:
|
||||||
|
if not f.done():
|
||||||
|
f.cancel()
|
||||||
|
|
||||||
|
self._pending_futures.clear()
|
||||||
|
|
||||||
def _do_submit_job(self, job, run_times):
|
def _do_submit_job(self, job, run_times):
|
||||||
def callback(f):
|
def callback(f):
|
||||||
|
self._pending_futures.discard(f)
|
||||||
try:
|
try:
|
||||||
events = f.result()
|
events = f.result()
|
||||||
except:
|
except BaseException:
|
||||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, events)
|
self._run_job_success(job.id, events)
|
||||||
|
|
||||||
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
if iscoroutinefunction_partial(job.func):
|
||||||
|
coro = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
|
f = self._eventloop.create_task(coro)
|
||||||
|
else:
|
||||||
|
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times,
|
||||||
|
self._logger.name)
|
||||||
|
|
||||||
f.add_done_callback(callback)
|
f.add_done_callback(callback)
|
||||||
|
self._pending_futures.add(f)
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import sys
|
|||||||
from pytz import utc
|
from pytz import utc
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.events import JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
from apscheduler.events import (
|
||||||
|
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
|
||||||
|
|
||||||
|
|
||||||
class MaxInstancesReachedError(Exception):
|
class MaxInstancesReachedError(Exception):
|
||||||
def __init__(self, job):
|
def __init__(self, job):
|
||||||
super(MaxInstancesReachedError, self).__init__(
|
super(MaxInstancesReachedError, self).__init__(
|
||||||
'Job "%s" has already reached its maximum number of instances (%d)' % (job.id, job.max_instances))
|
'Job "%s" has already reached its maximum number of instances (%d)' %
|
||||||
|
(job.id, job.max_instances))
|
||||||
|
|
||||||
|
|
||||||
class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||||
@@ -30,13 +32,14 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
|
|
||||||
def start(self, scheduler, alias):
|
def start(self, scheduler, alias):
|
||||||
"""
|
"""
|
||||||
Called by the scheduler when the scheduler is being started or when the executor is being added to an already
|
Called by the scheduler when the scheduler is being started or when the executor is being
|
||||||
running scheduler.
|
added to an already running scheduler.
|
||||||
|
|
||||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this executor
|
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
|
||||||
|
this executor
|
||||||
:param str|unicode alias: alias of this executor as it was assigned to the scheduler
|
:param str|unicode alias: alias of this executor as it was assigned to the scheduler
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler = scheduler
|
self._scheduler = scheduler
|
||||||
self._lock = scheduler._create_lock()
|
self._lock = scheduler._create_lock()
|
||||||
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
||||||
@@ -45,7 +48,8 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
"""
|
"""
|
||||||
Shuts down this executor.
|
Shuts down this executor.
|
||||||
|
|
||||||
:param bool wait: ``True`` to wait until all submitted jobs have been executed
|
:param bool wait: ``True`` to wait until all submitted jobs
|
||||||
|
have been executed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def submit_job(self, job, run_times):
|
def submit_job(self, job, run_times):
|
||||||
@@ -53,10 +57,12 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
Submits job for execution.
|
Submits job for execution.
|
||||||
|
|
||||||
:param Job job: job to execute
|
:param Job job: job to execute
|
||||||
:param list[datetime] run_times: list of datetimes specifying when the job should have been run
|
:param list[datetime] run_times: list of datetimes specifying
|
||||||
:raises MaxInstancesReachedError: if the maximum number of allowed instances for this job has been reached
|
when the job should have been run
|
||||||
"""
|
:raises MaxInstancesReachedError: if the maximum number of
|
||||||
|
allowed instances for this job has been reached
|
||||||
|
|
||||||
|
"""
|
||||||
assert self._lock is not None, 'This executor has not been started yet'
|
assert self._lock is not None, 'This executor has not been started yet'
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._instances[job.id] >= job.max_instances:
|
if self._instances[job.id] >= job.max_instances:
|
||||||
@@ -70,50 +76,71 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
"""Performs the actual task of scheduling `run_job` to be called."""
|
"""Performs the actual task of scheduling `run_job` to be called."""
|
||||||
|
|
||||||
def _run_job_success(self, job_id, events):
|
def _run_job_success(self, job_id, events):
|
||||||
"""Called by the executor with the list of generated events when `run_job` has been successfully called."""
|
"""
|
||||||
|
Called by the executor with the list of generated events when :func:`run_job` has been
|
||||||
|
successfully called.
|
||||||
|
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._instances[job_id] -= 1
|
self._instances[job_id] -= 1
|
||||||
|
if self._instances[job_id] == 0:
|
||||||
|
del self._instances[job_id]
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
self._scheduler._dispatch_event(event)
|
self._scheduler._dispatch_event(event)
|
||||||
|
|
||||||
def _run_job_error(self, job_id, exc, traceback=None):
|
def _run_job_error(self, job_id, exc, traceback=None):
|
||||||
"""Called by the executor with the exception if there is an error calling `run_job`."""
|
"""Called by the executor with the exception if there is an error calling `run_job`."""
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._instances[job_id] -= 1
|
self._instances[job_id] -= 1
|
||||||
|
if self._instances[job_id] == 0:
|
||||||
|
del self._instances[job_id]
|
||||||
|
|
||||||
exc_info = (exc.__class__, exc, traceback)
|
exc_info = (exc.__class__, exc, traceback)
|
||||||
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
||||||
|
|
||||||
|
|
||||||
def run_job(job, jobstore_alias, run_times, logger_name):
|
def run_job(job, jobstore_alias, run_times, logger_name):
|
||||||
"""Called by executors to run the job. Returns a list of scheduler events to be dispatched by the scheduler."""
|
"""
|
||||||
|
Called by executors to run the job. Returns a list of scheduler events to be dispatched by the
|
||||||
|
scheduler.
|
||||||
|
|
||||||
|
"""
|
||||||
events = []
|
events = []
|
||||||
logger = logging.getLogger(logger_name)
|
logger = logging.getLogger(logger_name)
|
||||||
for run_time in run_times:
|
for run_time in run_times:
|
||||||
# See if the job missed its run time window, and handle possible misfires accordingly
|
# See if the job missed its run time window, and handle
|
||||||
|
# possible misfires accordingly
|
||||||
if job.misfire_grace_time is not None:
|
if job.misfire_grace_time is not None:
|
||||||
difference = datetime.now(utc) - run_time
|
difference = datetime.now(utc) - run_time
|
||||||
grace_time = timedelta(seconds=job.misfire_grace_time)
|
grace_time = timedelta(seconds=job.misfire_grace_time)
|
||||||
if difference > grace_time:
|
if difference > grace_time:
|
||||||
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias, run_time))
|
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
|
||||||
|
run_time))
|
||||||
logger.warning('Run time of job "%s" was missed by %s', job, difference)
|
logger.warning('Run time of job "%s" was missed by %s', job, difference)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
||||||
try:
|
try:
|
||||||
retval = job.func(*job.args, **job.kwargs)
|
retval = job.func(*job.args, **job.kwargs)
|
||||||
except:
|
except BaseException:
|
||||||
exc, tb = sys.exc_info()[1:]
|
exc, tb = sys.exc_info()[1:]
|
||||||
formatted_tb = ''.join(format_tb(tb))
|
formatted_tb = ''.join(format_tb(tb))
|
||||||
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time, exception=exc,
|
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
|
||||||
traceback=formatted_tb))
|
exception=exc, traceback=formatted_tb))
|
||||||
logger.exception('Job "%s" raised an exception', job)
|
logger.exception('Job "%s" raised an exception', job)
|
||||||
|
|
||||||
|
# This is to prevent cyclic references that would lead to memory leaks
|
||||||
|
if six.PY2:
|
||||||
|
sys.exc_clear()
|
||||||
|
del tb
|
||||||
|
else:
|
||||||
|
import traceback
|
||||||
|
traceback.clear_frames(tb)
|
||||||
|
del tb
|
||||||
else:
|
else:
|
||||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval))
|
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
|
||||||
|
retval=retval))
|
||||||
logger.info('Job "%s" executed successfully', job)
|
logger.info('Job "%s" executed successfully', job)
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from traceback import format_tb
|
||||||
|
|
||||||
|
from pytz import utc
|
||||||
|
|
||||||
|
from apscheduler.events import (
|
||||||
|
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_coroutine_job(job, jobstore_alias, run_times, logger_name):
|
||||||
|
"""Coroutine version of run_job()."""
|
||||||
|
events = []
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
for run_time in run_times:
|
||||||
|
# See if the job missed its run time window, and handle possible misfires accordingly
|
||||||
|
if job.misfire_grace_time is not None:
|
||||||
|
difference = datetime.now(utc) - run_time
|
||||||
|
grace_time = timedelta(seconds=job.misfire_grace_time)
|
||||||
|
if difference > grace_time:
|
||||||
|
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
|
||||||
|
run_time))
|
||||||
|
logger.warning('Run time of job "%s" was missed by %s', job, difference)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
||||||
|
try:
|
||||||
|
retval = await job.func(*job.args, **job.kwargs)
|
||||||
|
except BaseException:
|
||||||
|
exc, tb = sys.exc_info()[1:]
|
||||||
|
formatted_tb = ''.join(format_tb(tb))
|
||||||
|
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
|
||||||
|
exception=exc, traceback=formatted_tb))
|
||||||
|
logger.exception('Job "%s" raised an exception', job)
|
||||||
|
traceback.clear_frames(tb)
|
||||||
|
else:
|
||||||
|
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
|
||||||
|
retval=retval))
|
||||||
|
logger.info('Job "%s" executed successfully', job)
|
||||||
|
|
||||||
|
return events
|
||||||
@@ -5,7 +5,8 @@ from apscheduler.executors.base import BaseExecutor, run_job
|
|||||||
|
|
||||||
class DebugExecutor(BaseExecutor):
|
class DebugExecutor(BaseExecutor):
|
||||||
"""
|
"""
|
||||||
A special executor that executes the target callable directly instead of deferring it to a thread or process.
|
A special executor that executes the target callable directly instead of deferring it to a
|
||||||
|
thread or process.
|
||||||
|
|
||||||
Plugin alias: ``debug``
|
Plugin alias: ``debug``
|
||||||
"""
|
"""
|
||||||
@@ -13,7 +14,7 @@ class DebugExecutor(BaseExecutor):
|
|||||||
def _do_submit_job(self, job, run_times):
|
def _do_submit_job(self, job, run_times):
|
||||||
try:
|
try:
|
||||||
events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
|
events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
except:
|
except BaseException:
|
||||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, events)
|
self._run_job_success(job.id, events)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
@@ -21,9 +21,10 @@ class GeventExecutor(BaseExecutor):
|
|||||||
def callback(greenlet):
|
def callback(greenlet):
|
||||||
try:
|
try:
|
||||||
events = greenlet.get()
|
events = greenlet.get()
|
||||||
except:
|
except BaseException:
|
||||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, events)
|
self._run_job_success(job.id, events)
|
||||||
|
|
||||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback)
|
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).\
|
||||||
|
link(callback)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import concurrent.futures
|
|||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
|
||||||
|
try:
|
||||||
|
from concurrent.futures.process import BrokenProcessPool
|
||||||
|
except ImportError:
|
||||||
|
BrokenProcessPool = None
|
||||||
|
|
||||||
|
|
||||||
class BasePoolExecutor(BaseExecutor):
|
class BasePoolExecutor(BaseExecutor):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -19,7 +24,13 @@ class BasePoolExecutor(BaseExecutor):
|
|||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, f.result())
|
self._run_job_success(job.id, f.result())
|
||||||
|
|
||||||
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
try:
|
||||||
|
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
|
except BrokenProcessPool:
|
||||||
|
self._logger.warning('Process pool is broken; replacing pool with a fresh instance')
|
||||||
|
self._pool = self._pool.__class__(self._pool._max_workers)
|
||||||
|
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
|
|
||||||
f.add_done_callback(callback)
|
f.add_done_callback(callback)
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
@@ -33,10 +44,13 @@ class ThreadPoolExecutor(BasePoolExecutor):
|
|||||||
Plugin alias: ``threadpool``
|
Plugin alias: ``threadpool``
|
||||||
|
|
||||||
:param max_workers: the maximum number of spawned threads.
|
:param max_workers: the maximum number of spawned threads.
|
||||||
|
:param pool_kwargs: dict of keyword arguments to pass to the underlying
|
||||||
|
ThreadPoolExecutor constructor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, max_workers=10):
|
def __init__(self, max_workers=10, pool_kwargs=None):
|
||||||
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers))
|
pool_kwargs = pool_kwargs or {}
|
||||||
|
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers), **pool_kwargs)
|
||||||
super(ThreadPoolExecutor, self).__init__(pool)
|
super(ThreadPoolExecutor, self).__init__(pool)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +61,11 @@ class ProcessPoolExecutor(BasePoolExecutor):
|
|||||||
Plugin alias: ``processpool``
|
Plugin alias: ``processpool``
|
||||||
|
|
||||||
:param max_workers: the maximum number of spawned processes.
|
:param max_workers: the maximum number of spawned processes.
|
||||||
|
:param pool_kwargs: dict of keyword arguments to pass to the underlying
|
||||||
|
ProcessPoolExecutor constructor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, max_workers=10):
|
def __init__(self, max_workers=10, pool_kwargs=None):
|
||||||
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers))
|
pool_kwargs = pool_kwargs or {}
|
||||||
|
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers), **pool_kwargs)
|
||||||
super(ProcessPoolExecutor, self).__init__(pool)
|
super(ProcessPoolExecutor, self).__init__(pool)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from tornado.gen import convert_yielded
|
||||||
|
|
||||||
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||||
|
from apscheduler.util import iscoroutinefunction_partial
|
||||||
|
except ImportError:
|
||||||
|
def iscoroutinefunction_partial(func):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TornadoExecutor(BaseExecutor):
|
||||||
|
"""
|
||||||
|
Runs jobs either in a thread pool or directly on the I/O loop.
|
||||||
|
|
||||||
|
If the job function is a native coroutine function, it is scheduled to be run directly in the
|
||||||
|
I/O loop as soon as possible. All other functions are run in a thread pool.
|
||||||
|
|
||||||
|
Plugin alias: ``tornado``
|
||||||
|
|
||||||
|
:param int max_workers: maximum number of worker threads in the thread pool
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_workers=10):
|
||||||
|
super(TornadoExecutor, self).__init__()
|
||||||
|
self.executor = ThreadPoolExecutor(max_workers)
|
||||||
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(TornadoExecutor, self).start(scheduler, alias)
|
||||||
|
self._ioloop = scheduler._ioloop
|
||||||
|
|
||||||
|
def _do_submit_job(self, job, run_times):
|
||||||
|
def callback(f):
|
||||||
|
try:
|
||||||
|
events = f.result()
|
||||||
|
except BaseException:
|
||||||
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
|
else:
|
||||||
|
self._run_job_success(job.id, events)
|
||||||
|
|
||||||
|
if iscoroutinefunction_partial(job.func):
|
||||||
|
f = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
|
else:
|
||||||
|
f = self.executor.submit(run_job, job, job._jobstore_alias, run_times,
|
||||||
|
self._logger.name)
|
||||||
|
|
||||||
|
f = convert_yielded(f)
|
||||||
|
f.add_done_callback(callback)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
|
||||||
@@ -21,5 +21,5 @@ class TwistedExecutor(BaseExecutor):
|
|||||||
else:
|
else:
|
||||||
self._run_job_error(job.id, result.value, result.tb)
|
self._run_job_error(job.id, result.value, result.tb)
|
||||||
|
|
||||||
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
|
self._reactor.getThreadPool().callInThreadWithCallback(
|
||||||
self._logger.name)
|
callback, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
|
|||||||
+77
-27
@@ -1,11 +1,17 @@
|
|||||||
from collections.abc import Iterable, Mapping
|
from inspect import ismethod, isclass
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.triggers.base import BaseTrigger
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
from apscheduler.util import ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, \
|
from apscheduler.util import (
|
||||||
convert_to_datetime
|
ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args,
|
||||||
|
convert_to_datetime)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
except ImportError:
|
||||||
|
from collections import Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
class Job(object):
|
class Job(object):
|
||||||
@@ -21,13 +27,20 @@ class Job(object):
|
|||||||
:var bool coalesce: whether to only run the job once when several run times are due
|
:var bool coalesce: whether to only run the job once when several run times are due
|
||||||
:var trigger: the trigger object that controls the schedule of this job
|
:var trigger: the trigger object that controls the schedule of this job
|
||||||
:var str executor: the name of the executor that will run this job
|
:var str executor: the name of the executor that will run this job
|
||||||
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late
|
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to
|
||||||
:var int max_instances: the maximum number of concurrently executing instances allowed for this job
|
be late (``None`` means "allow the job to run no matter how late it is")
|
||||||
|
:var int max_instances: the maximum number of concurrently executing instances allowed for this
|
||||||
|
job
|
||||||
:var datetime.datetime next_run_time: the next scheduled run time of this job
|
:var datetime.datetime next_run_time: the next scheduled run time of this job
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The ``misfire_grace_time`` has some non-obvious effects on job execution. See the
|
||||||
|
:ref:`missed-job-executions` section in the documentation for an in-depth explanation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs',
|
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref',
|
||||||
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
|
'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances',
|
||||||
|
'next_run_time', '__weakref__')
|
||||||
|
|
||||||
def __init__(self, scheduler, id=None, **kwargs):
|
def __init__(self, scheduler, id=None, **kwargs):
|
||||||
super(Job, self).__init__()
|
super(Job, self).__init__()
|
||||||
@@ -38,53 +51,69 @@ class Job(object):
|
|||||||
def modify(self, **changes):
|
def modify(self, **changes):
|
||||||
"""
|
"""
|
||||||
Makes the given changes to this job and saves it in the associated job store.
|
Makes the given changes to this job and saves it in the associated job store.
|
||||||
|
|
||||||
Accepted keyword arguments are the same as the variables on this class.
|
Accepted keyword arguments are the same as the variables on this class.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
||||||
|
return self
|
||||||
|
|
||||||
def reschedule(self, trigger, **trigger_args):
|
def reschedule(self, trigger, **trigger_args):
|
||||||
"""
|
"""
|
||||||
Shortcut for switching the trigger on this job.
|
Shortcut for switching the trigger on this job.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
|
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
|
||||||
|
return self
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
"""
|
"""
|
||||||
Temporarily suspend the execution of this job.
|
Temporarily suspend the execution of this job.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
||||||
|
return self
|
||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
"""
|
"""
|
||||||
Resume the schedule of this job if previously paused.
|
Resume the schedule of this job if previously paused.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
||||||
|
return self
|
||||||
|
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
Unschedules this job and removes it from its associated job store.
|
Unschedules this job and removes it from its associated job store.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending(self):
|
def pending(self):
|
||||||
"""Returns ``True`` if the referenced job is still waiting to be added to its designated job store."""
|
"""
|
||||||
|
Returns ``True`` if the referenced job is still waiting to be added to its designated job
|
||||||
|
store.
|
||||||
|
|
||||||
|
"""
|
||||||
return self._jobstore_alias is None
|
return self._jobstore_alias is None
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -97,8 +126,8 @@ class Job(object):
|
|||||||
|
|
||||||
:type now: datetime.datetime
|
:type now: datetime.datetime
|
||||||
:rtype: list[datetime.datetime]
|
:rtype: list[datetime.datetime]
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
run_times = []
|
run_times = []
|
||||||
next_run_time = self.next_run_time
|
next_run_time = self.next_run_time
|
||||||
while next_run_time and next_run_time <= now:
|
while next_run_time and next_run_time <= now:
|
||||||
@@ -108,8 +137,11 @@ class Job(object):
|
|||||||
return run_times
|
return run_times
|
||||||
|
|
||||||
def _modify(self, **changes):
|
def _modify(self, **changes):
|
||||||
"""Validates the changes to the Job and makes the modifications if and only if all of them validate."""
|
"""
|
||||||
|
Validates the changes to the Job and makes the modifications if and only if all of them
|
||||||
|
validate.
|
||||||
|
|
||||||
|
"""
|
||||||
approved = {}
|
approved = {}
|
||||||
|
|
||||||
if 'id' in changes:
|
if 'id' in changes:
|
||||||
@@ -125,7 +157,7 @@ class Job(object):
|
|||||||
args = changes.pop('args') if 'args' in changes else self.args
|
args = changes.pop('args') if 'args' in changes else self.args
|
||||||
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
|
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
|
||||||
|
|
||||||
if isinstance(func, str):
|
if isinstance(func, six.string_types):
|
||||||
func_ref = func
|
func_ref = func
|
||||||
func = ref_to_obj(func)
|
func = ref_to_obj(func)
|
||||||
elif callable(func):
|
elif callable(func):
|
||||||
@@ -177,7 +209,8 @@ class Job(object):
|
|||||||
if 'trigger' in changes:
|
if 'trigger' in changes:
|
||||||
trigger = changes.pop('trigger')
|
trigger = changes.pop('trigger')
|
||||||
if not isinstance(trigger, BaseTrigger):
|
if not isinstance(trigger, BaseTrigger):
|
||||||
raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__)
|
raise TypeError('Expected a trigger instance, got %s instead' %
|
||||||
|
trigger.__class__.__name__)
|
||||||
|
|
||||||
approved['trigger'] = trigger
|
approved['trigger'] = trigger
|
||||||
|
|
||||||
@@ -189,10 +222,12 @@ class Job(object):
|
|||||||
|
|
||||||
if 'next_run_time' in changes:
|
if 'next_run_time' in changes:
|
||||||
value = changes.pop('next_run_time')
|
value = changes.pop('next_run_time')
|
||||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time')
|
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone,
|
||||||
|
'next_run_time')
|
||||||
|
|
||||||
if changes:
|
if changes:
|
||||||
raise AttributeError('The following are not modifiable attributes of Job: %s' % ', '.join(changes))
|
raise AttributeError('The following are not modifiable attributes of Job: %s' %
|
||||||
|
', '.join(changes))
|
||||||
|
|
||||||
for key, value in six.iteritems(approved):
|
for key, value in six.iteritems(approved):
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
@@ -200,9 +235,18 @@ class Job(object):
|
|||||||
def __getstate__(self):
|
def __getstate__(self):
|
||||||
# Don't allow this Job to be serialized if the function reference could not be determined
|
# Don't allow this Job to be serialized if the function reference could not be determined
|
||||||
if not self.func_ref:
|
if not self.func_ref:
|
||||||
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
|
raise ValueError(
|
||||||
'determined. Consider giving a textual reference (module:function name) instead.' %
|
'This Job cannot be serialized since the reference to its callable (%r) could not '
|
||||||
(self.func,))
|
'be determined. Consider giving a textual reference (module:function name) '
|
||||||
|
'instead.' % (self.func,))
|
||||||
|
|
||||||
|
# Instance methods cannot survive serialization as-is, so store the "self" argument
|
||||||
|
# explicitly
|
||||||
|
func = self.func
|
||||||
|
if ismethod(func) and not isclass(func.__self__) and obj_to_ref(func) == self.func_ref:
|
||||||
|
args = (func.__self__,) + tuple(self.args)
|
||||||
|
else:
|
||||||
|
args = self.args
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
@@ -210,7 +254,7 @@ class Job(object):
|
|||||||
'func': self.func_ref,
|
'func': self.func_ref,
|
||||||
'trigger': self.trigger,
|
'trigger': self.trigger,
|
||||||
'executor': self.executor,
|
'executor': self.executor,
|
||||||
'args': self.args,
|
'args': args,
|
||||||
'kwargs': self.kwargs,
|
'kwargs': self.kwargs,
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'misfire_grace_time': self.misfire_grace_time,
|
'misfire_grace_time': self.misfire_grace_time,
|
||||||
@@ -221,7 +265,8 @@ class Job(object):
|
|||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
if state.get('version', 1) > 1:
|
if state.get('version', 1) > 1:
|
||||||
raise ValueError('Job has version %s, but only version 1 can be handled' % state['version'])
|
raise ValueError('Job has version %s, but only version 1 can be handled' %
|
||||||
|
state['version'])
|
||||||
|
|
||||||
self.id = state['id']
|
self.id = state['id']
|
||||||
self.func_ref = state['func']
|
self.func_ref = state['func']
|
||||||
@@ -245,8 +290,13 @@ class Job(object):
|
|||||||
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
|
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)),
|
return repr_escape(self.__unicode__())
|
||||||
datetime_repr(self.next_run_time))
|
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time))
|
if hasattr(self, 'next_run_time'):
|
||||||
|
status = ('next run at: ' + datetime_repr(self.next_run_time) if
|
||||||
|
self.next_run_time else 'paused')
|
||||||
|
else:
|
||||||
|
status = 'pending'
|
||||||
|
|
||||||
|
return u'%s (trigger: %s, %s)' % (self.name, self.trigger, status)
|
||||||
|
|||||||
@@ -8,23 +8,27 @@ class JobLookupError(KeyError):
|
|||||||
"""Raised when the job store cannot find a job for update or removal."""
|
"""Raised when the job store cannot find a job for update or removal."""
|
||||||
|
|
||||||
def __init__(self, job_id):
|
def __init__(self, job_id):
|
||||||
super(JobLookupError, self).__init__(six.u('No job by the id of %s was found') % job_id)
|
super(JobLookupError, self).__init__(u'No job by the id of %s was found' % job_id)
|
||||||
|
|
||||||
|
|
||||||
class ConflictingIdError(KeyError):
|
class ConflictingIdError(KeyError):
|
||||||
"""Raised when the uniqueness of job IDs is being violated."""
|
"""Raised when the uniqueness of job IDs is being violated."""
|
||||||
|
|
||||||
def __init__(self, job_id):
|
def __init__(self, job_id):
|
||||||
super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id)
|
super(ConflictingIdError, self).__init__(
|
||||||
|
u'Job identifier (%s) conflicts with an existing job' % job_id)
|
||||||
|
|
||||||
|
|
||||||
class TransientJobError(ValueError):
|
class TransientJobError(ValueError):
|
||||||
"""Raised when an attempt to add transient (with no func_ref) job to a persistent job store is detected."""
|
"""
|
||||||
|
Raised when an attempt to add transient (with no func_ref) job to a persistent job store is
|
||||||
|
detected.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, job_id):
|
def __init__(self, job_id):
|
||||||
super(TransientJobError, self).__init__(
|
super(TransientJobError, self).__init__(
|
||||||
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
|
u'Job (%s) cannot be added to this job store because a reference to the callable '
|
||||||
'determined.') % job_id)
|
u'could not be determined.' % job_id)
|
||||||
|
|
||||||
|
|
||||||
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||||
@@ -36,10 +40,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
|
|
||||||
def start(self, scheduler, alias):
|
def start(self, scheduler, alias):
|
||||||
"""
|
"""
|
||||||
Called by the scheduler when the scheduler is being started or when the job store is being added to an already
|
Called by the scheduler when the scheduler is being started or when the job store is being
|
||||||
running scheduler.
|
added to an already running scheduler.
|
||||||
|
|
||||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this job store
|
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
|
||||||
|
this job store
|
||||||
:param str|unicode alias: alias of this job store as it was assigned to the scheduler
|
:param str|unicode alias: alias of this job store as it was assigned to the scheduler
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -50,13 +55,22 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""Frees any resources still bound to this job store."""
|
"""Frees any resources still bound to this job store."""
|
||||||
|
|
||||||
|
def _fix_paused_jobs_sorting(self, jobs):
|
||||||
|
for i, job in enumerate(jobs):
|
||||||
|
if job.next_run_time is not None:
|
||||||
|
if i > 0:
|
||||||
|
paused_jobs = jobs[:i]
|
||||||
|
del jobs[:i]
|
||||||
|
jobs.extend(paused_jobs)
|
||||||
|
break
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
"""
|
"""
|
||||||
Returns a specific job, or ``None`` if it isn't found..
|
Returns a specific job, or ``None`` if it isn't found..
|
||||||
|
|
||||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned job to
|
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||||
point to the scheduler and itself, respectively.
|
the returned job to point to the scheduler and itself, respectively.
|
||||||
|
|
||||||
:param str|unicode job_id: identifier of the job
|
:param str|unicode job_id: identifier of the job
|
||||||
:rtype: Job
|
:rtype: Job
|
||||||
@@ -75,7 +89,8 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_next_run_time(self):
|
def get_next_run_time(self):
|
||||||
"""
|
"""
|
||||||
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if there are no active jobs.
|
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if
|
||||||
|
there are no active jobs.
|
||||||
|
|
||||||
:rtype: datetime.datetime
|
:rtype: datetime.datetime
|
||||||
"""
|
"""
|
||||||
@@ -83,11 +98,12 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
"""
|
"""
|
||||||
Returns a list of all jobs in this job store. The returned jobs should be sorted by next run time (ascending).
|
Returns a list of all jobs in this job store.
|
||||||
Paused jobs (next_run_time is None) should be sorted last.
|
The returned jobs should be sorted by next run time (ascending).
|
||||||
|
Paused jobs (next_run_time == None) should be sorted last.
|
||||||
|
|
||||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned jobs to
|
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||||
point to the scheduler and itself, respectively.
|
the returned jobs to point to the scheduler and itself, respectively.
|
||||||
|
|
||||||
:rtype: list[Job]
|
:rtype: list[Job]
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
from apscheduler.util import datetime_to_utc_timestamp
|
from apscheduler.util import datetime_to_utc_timestamp
|
||||||
@@ -13,7 +13,8 @@ class MemoryJobStore(BaseJobStore):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(MemoryJobStore, self).__init__()
|
super(MemoryJobStore, self).__init__()
|
||||||
self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
# list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||||
|
self._jobs = []
|
||||||
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
@@ -80,13 +81,13 @@ class MemoryJobStore(BaseJobStore):
|
|||||||
|
|
||||||
def _get_job_index(self, timestamp, job_id):
|
def _get_job_index(self, timestamp, job_id):
|
||||||
"""
|
"""
|
||||||
Returns the index of the given job, or if it's not found, the index where the job should be inserted based on
|
Returns the index of the given job, or if it's not found, the index where the job should be
|
||||||
the given timestamp.
|
inserted based on the given timestamp.
|
||||||
|
|
||||||
:type timestamp: int
|
:type timestamp: int
|
||||||
:type job_id: str
|
:type job_id: str
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
lo, hi = 0, len(self._jobs)
|
lo, hi = 0, len(self._jobs)
|
||||||
timestamp = float('inf') if timestamp is None else timestamp
|
timestamp = float('inf') if timestamp is None else timestamp
|
||||||
while lo < hi:
|
while lo < hi:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
import warnings
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
@@ -19,16 +20,18 @@ except ImportError: # pragma: nocover
|
|||||||
|
|
||||||
class MongoDBJobStore(BaseJobStore):
|
class MongoDBJobStore(BaseJobStore):
|
||||||
"""
|
"""
|
||||||
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to pymongo's `MongoClient
|
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to
|
||||||
|
pymongo's `MongoClient
|
||||||
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
|
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
|
||||||
|
|
||||||
Plugin alias: ``mongodb``
|
Plugin alias: ``mongodb``
|
||||||
|
|
||||||
:param str database: database to store jobs in
|
:param str database: database to store jobs in
|
||||||
:param str collection: collection to store jobs in
|
:param str collection: collection to store jobs in
|
||||||
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of providing connection
|
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of
|
||||||
arguments
|
providing connection arguments
|
||||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||||
|
highest available
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, database='apscheduler', collection='jobs', client=None,
|
def __init__(self, database='apscheduler', collection='jobs', client=None,
|
||||||
@@ -42,13 +45,22 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
raise ValueError('The "collection" parameter must not be empty')
|
raise ValueError('The "collection" parameter must not be empty')
|
||||||
|
|
||||||
if client:
|
if client:
|
||||||
self.connection = maybe_ref(client)
|
self.client = maybe_ref(client)
|
||||||
else:
|
else:
|
||||||
connect_args.setdefault('w', 1)
|
connect_args.setdefault('w', 1)
|
||||||
self.connection = MongoClient(**connect_args)
|
self.client = MongoClient(**connect_args)
|
||||||
|
|
||||||
self.collection = self.connection[database][collection]
|
self.collection = self.client[database][collection]
|
||||||
self.collection.ensure_index('next_run_time', sparse=True)
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(MongoDBJobStore, self).start(scheduler, alias)
|
||||||
|
self.collection.create_index('next_run_time', sparse=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection(self):
|
||||||
|
warnings.warn('The "connection" member is deprecated -- use "client" instead',
|
||||||
|
DeprecationWarning)
|
||||||
|
return self.client
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
document = self.collection.find_one(job_id, ['job_state'])
|
document = self.collection.find_one(job_id, ['job_state'])
|
||||||
@@ -59,16 +71,19 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
return self._get_jobs({'next_run_time': {'$lte': timestamp}})
|
return self._get_jobs({'next_run_time': {'$lte': timestamp}})
|
||||||
|
|
||||||
def get_next_run_time(self):
|
def get_next_run_time(self):
|
||||||
document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'],
|
document = self.collection.find_one({'next_run_time': {'$ne': None}},
|
||||||
|
projection=['next_run_time'],
|
||||||
sort=[('next_run_time', ASCENDING)])
|
sort=[('next_run_time', ASCENDING)])
|
||||||
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
||||||
|
|
||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
return self._get_jobs({})
|
jobs = self._get_jobs({})
|
||||||
|
self._fix_paused_jobs_sorting(jobs)
|
||||||
|
return jobs
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
try:
|
try:
|
||||||
self.collection.insert({
|
self.collection.insert_one({
|
||||||
'_id': job.id,
|
'_id': job.id,
|
||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||||
@@ -81,20 +96,20 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||||
}
|
}
|
||||||
result = self.collection.update({'_id': job.id}, {'$set': changes})
|
result = self.collection.update_one({'_id': job.id}, {'$set': changes})
|
||||||
if result and result['n'] == 0:
|
if result and result.matched_count == 0:
|
||||||
raise JobLookupError(id)
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
def remove_job(self, job_id):
|
def remove_job(self, job_id):
|
||||||
result = self.collection.remove(job_id)
|
result = self.collection.delete_one({'_id': job_id})
|
||||||
if result and result['n'] == 0:
|
if result and result.deleted_count == 0:
|
||||||
raise JobLookupError(job_id)
|
raise JobLookupError(job_id)
|
||||||
|
|
||||||
def remove_all_jobs(self):
|
def remove_all_jobs(self):
|
||||||
self.collection.remove()
|
self.collection.delete_many({})
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.connection.disconnect()
|
self.client.close()
|
||||||
|
|
||||||
def _reconstitute_job(self, job_state):
|
def _reconstitute_job(self, job_state):
|
||||||
job_state = pickle.loads(job_state)
|
job_state = pickle.loads(job_state)
|
||||||
@@ -107,18 +122,20 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
def _get_jobs(self, conditions):
|
def _get_jobs(self, conditions):
|
||||||
jobs = []
|
jobs = []
|
||||||
failed_job_ids = []
|
failed_job_ids = []
|
||||||
for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]):
|
for document in self.collection.find(conditions, ['_id', 'job_state'],
|
||||||
|
sort=[('next_run_time', ASCENDING)]):
|
||||||
try:
|
try:
|
||||||
jobs.append(self._reconstitute_job(document['job_state']))
|
jobs.append(self._reconstitute_job(document['job_state']))
|
||||||
except:
|
except BaseException:
|
||||||
self._logger.exception('Unable to restore job "%s" -- removing it', document['_id'])
|
self._logger.exception('Unable to restore job "%s" -- removing it',
|
||||||
|
document['_id'])
|
||||||
failed_job_ids.append(document['_id'])
|
failed_job_ids.append(document['_id'])
|
||||||
|
|
||||||
# Remove all the jobs we failed to restore
|
# Remove all the jobs we failed to restore
|
||||||
if failed_job_ids:
|
if failed_job_ids:
|
||||||
self.collection.remove({'_id': {'$in': failed_job_ids}})
|
self.collection.delete_many({'_id': {'$in': failed_job_ids}})
|
||||||
|
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.connection)
|
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pytz import utc
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
@@ -7,26 +9,28 @@ from apscheduler.util import datetime_to_utc_timestamp, utc_timestamp_to_datetim
|
|||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from redis import StrictRedis
|
from redis import Redis
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
raise ImportError('RedisJobStore requires redis installed')
|
raise ImportError('RedisJobStore requires redis installed')
|
||||||
|
|
||||||
|
|
||||||
class RedisJobStore(BaseJobStore):
|
class RedisJobStore(BaseJobStore):
|
||||||
"""
|
"""
|
||||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis.
|
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's
|
||||||
|
:class:`~redis.StrictRedis`.
|
||||||
|
|
||||||
Plugin alias: ``redis``
|
Plugin alias: ``redis``
|
||||||
|
|
||||||
:param int db: the database number to store jobs in
|
:param int db: the database number to store jobs in
|
||||||
:param str jobs_key: key to store jobs in
|
:param str jobs_key: key to store jobs in
|
||||||
:param str run_times_key: key to store the jobs' run times in
|
:param str run_times_key: key to store the jobs' run times in
|
||||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||||
|
highest available
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times',
|
def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times',
|
||||||
@@ -43,7 +47,7 @@ class RedisJobStore(BaseJobStore):
|
|||||||
self.pickle_protocol = pickle_protocol
|
self.pickle_protocol = pickle_protocol
|
||||||
self.jobs_key = jobs_key
|
self.jobs_key = jobs_key
|
||||||
self.run_times_key = run_times_key
|
self.run_times_key = run_times_key
|
||||||
self.redis = StrictRedis(db=int(db), **connect_args)
|
self.redis = Redis(db=int(db), **connect_args)
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
job_state = self.redis.hget(self.jobs_key, job_id)
|
job_state = self.redis.hget(self.jobs_key, job_id)
|
||||||
@@ -65,7 +69,8 @@ class RedisJobStore(BaseJobStore):
|
|||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
job_states = self.redis.hgetall(self.jobs_key)
|
job_states = self.redis.hgetall(self.jobs_key)
|
||||||
jobs = self._reconstitute_jobs(six.iteritems(job_states))
|
jobs = self._reconstitute_jobs(six.iteritems(job_states))
|
||||||
return sorted(jobs, key=lambda job: job.next_run_time)
|
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
|
||||||
|
return sorted(jobs, key=lambda job: job.next_run_time or paused_sort_key)
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
if self.redis.hexists(self.jobs_key, job.id):
|
if self.redis.hexists(self.jobs_key, job.id):
|
||||||
@@ -73,8 +78,12 @@ class RedisJobStore(BaseJobStore):
|
|||||||
|
|
||||||
with self.redis.pipeline() as pipe:
|
with self.redis.pipeline() as pipe:
|
||||||
pipe.multi()
|
pipe.multi()
|
||||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
self.pickle_protocol))
|
||||||
|
if job.next_run_time:
|
||||||
|
pipe.zadd(self.run_times_key,
|
||||||
|
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
|
||||||
|
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
def update_job(self, job):
|
def update_job(self, job):
|
||||||
@@ -82,11 +91,14 @@ class RedisJobStore(BaseJobStore):
|
|||||||
raise JobLookupError(job.id)
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
with self.redis.pipeline() as pipe:
|
with self.redis.pipeline() as pipe:
|
||||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||||
|
self.pickle_protocol))
|
||||||
if job.next_run_time:
|
if job.next_run_time:
|
||||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
pipe.zadd(self.run_times_key,
|
||||||
|
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
|
||||||
else:
|
else:
|
||||||
pipe.zrem(self.run_times_key, job.id)
|
pipe.zrem(self.run_times_key, job.id)
|
||||||
|
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
def remove_job(self, job_id):
|
def remove_job(self, job_id):
|
||||||
@@ -121,7 +133,7 @@ class RedisJobStore(BaseJobStore):
|
|||||||
for job_id, job_state in job_states:
|
for job_id, job_state in job_states:
|
||||||
try:
|
try:
|
||||||
jobs.append(self._reconstitute_job(job_state))
|
jobs.append(self._reconstitute_job(job_state))
|
||||||
except:
|
except BaseException:
|
||||||
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
||||||
failed_job_ids.append(job_id)
|
failed_job_ids.append(job_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
|
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||||
|
from apscheduler.job import Job
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cPickle as pickle
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
try:
|
||||||
|
from rethinkdb import RethinkDB
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
raise ImportError('RethinkDBJobStore requires rethinkdb installed')
|
||||||
|
|
||||||
|
|
||||||
|
class RethinkDBJobStore(BaseJobStore):
|
||||||
|
"""
|
||||||
|
Stores jobs in a RethinkDB database. Any leftover keyword arguments are directly passed to
|
||||||
|
rethinkdb's `RethinkdbClient <http://www.rethinkdb.com/api/#connect>`_.
|
||||||
|
|
||||||
|
Plugin alias: ``rethinkdb``
|
||||||
|
|
||||||
|
:param str database: database to store jobs in
|
||||||
|
:param str collection: collection to store jobs in
|
||||||
|
:param client: a :class:`rethinkdb.net.Connection` instance to use instead of providing
|
||||||
|
connection arguments
|
||||||
|
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||||
|
highest available
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, database='apscheduler', table='jobs', client=None,
|
||||||
|
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
|
||||||
|
super(RethinkDBJobStore, self).__init__()
|
||||||
|
|
||||||
|
if not database:
|
||||||
|
raise ValueError('The "database" parameter must not be empty')
|
||||||
|
if not table:
|
||||||
|
raise ValueError('The "table" parameter must not be empty')
|
||||||
|
|
||||||
|
self.database = database
|
||||||
|
self.table_name = table
|
||||||
|
self.table = None
|
||||||
|
self.client = client
|
||||||
|
self.pickle_protocol = pickle_protocol
|
||||||
|
self.connect_args = connect_args
|
||||||
|
self.r = RethinkDB()
|
||||||
|
self.conn = None
|
||||||
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(RethinkDBJobStore, self).start(scheduler, alias)
|
||||||
|
|
||||||
|
if self.client:
|
||||||
|
self.conn = maybe_ref(self.client)
|
||||||
|
else:
|
||||||
|
self.conn = self.r.connect(db=self.database, **self.connect_args)
|
||||||
|
|
||||||
|
if self.database not in self.r.db_list().run(self.conn):
|
||||||
|
self.r.db_create(self.database).run(self.conn)
|
||||||
|
|
||||||
|
if self.table_name not in self.r.table_list().run(self.conn):
|
||||||
|
self.r.table_create(self.table_name).run(self.conn)
|
||||||
|
|
||||||
|
if 'next_run_time' not in self.r.table(self.table_name).index_list().run(self.conn):
|
||||||
|
self.r.table(self.table_name).index_create('next_run_time').run(self.conn)
|
||||||
|
|
||||||
|
self.table = self.r.db(self.database).table(self.table_name)
|
||||||
|
|
||||||
|
def lookup_job(self, job_id):
|
||||||
|
results = list(self.table.get_all(job_id).pluck('job_state').run(self.conn))
|
||||||
|
return self._reconstitute_job(results[0]['job_state']) if results else None
|
||||||
|
|
||||||
|
def get_due_jobs(self, now):
|
||||||
|
return self._get_jobs(self.r.row['next_run_time'] <= datetime_to_utc_timestamp(now))
|
||||||
|
|
||||||
|
def get_next_run_time(self):
|
||||||
|
results = list(
|
||||||
|
self.table
|
||||||
|
.filter(self.r.row['next_run_time'] != None) # noqa
|
||||||
|
.order_by(self.r.asc('next_run_time'))
|
||||||
|
.map(lambda x: x['next_run_time'])
|
||||||
|
.limit(1)
|
||||||
|
.run(self.conn)
|
||||||
|
)
|
||||||
|
return utc_timestamp_to_datetime(results[0]) if results else None
|
||||||
|
|
||||||
|
def get_all_jobs(self):
|
||||||
|
jobs = self._get_jobs()
|
||||||
|
self._fix_paused_jobs_sorting(jobs)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def add_job(self, job):
|
||||||
|
job_dict = {
|
||||||
|
'id': job.id,
|
||||||
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
|
'job_state': self.r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||||
|
}
|
||||||
|
results = self.table.insert(job_dict).run(self.conn)
|
||||||
|
if results['errors'] > 0:
|
||||||
|
raise ConflictingIdError(job.id)
|
||||||
|
|
||||||
|
def update_job(self, job):
|
||||||
|
changes = {
|
||||||
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
|
'job_state': self.r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||||
|
}
|
||||||
|
results = self.table.get_all(job.id).update(changes).run(self.conn)
|
||||||
|
skipped = False in map(lambda x: results[x] == 0, results.keys())
|
||||||
|
if results['skipped'] > 0 or results['errors'] > 0 or not skipped:
|
||||||
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
|
def remove_job(self, job_id):
|
||||||
|
results = self.table.get_all(job_id).delete().run(self.conn)
|
||||||
|
if results['deleted'] + results['skipped'] != 1:
|
||||||
|
raise JobLookupError(job_id)
|
||||||
|
|
||||||
|
def remove_all_jobs(self):
|
||||||
|
self.table.delete().run(self.conn)
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def _reconstitute_job(self, job_state):
|
||||||
|
job_state = pickle.loads(job_state)
|
||||||
|
job = Job.__new__(Job)
|
||||||
|
job.__setstate__(job_state)
|
||||||
|
job._scheduler = self._scheduler
|
||||||
|
job._jobstore_alias = self._alias
|
||||||
|
return job
|
||||||
|
|
||||||
|
def _get_jobs(self, predicate=None):
|
||||||
|
jobs = []
|
||||||
|
failed_job_ids = []
|
||||||
|
query = (self.table.filter(self.r.row['next_run_time'] != None).filter(predicate) # noqa
|
||||||
|
if predicate else self.table)
|
||||||
|
query = query.order_by('next_run_time', 'id').pluck('id', 'job_state')
|
||||||
|
|
||||||
|
for document in query.run(self.conn):
|
||||||
|
try:
|
||||||
|
jobs.append(self._reconstitute_job(document['job_state']))
|
||||||
|
except Exception:
|
||||||
|
self._logger.exception('Unable to restore job "%s" -- removing it', document['id'])
|
||||||
|
failed_job_ids.append(document['id'])
|
||||||
|
|
||||||
|
# Remove all the jobs we failed to restore
|
||||||
|
if failed_job_ids:
|
||||||
|
self.r.expr(failed_job_ids).for_each(
|
||||||
|
lambda job_id: self.table.get_all(job_id).delete()).run(self.conn)
|
||||||
|
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
connection = self.conn
|
||||||
|
return '<%s (connection=%s)>' % (self.__class__.__name__, connection)
|
||||||
@@ -1,38 +1,47 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select
|
from sqlalchemy import (
|
||||||
|
create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select, and_)
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.sql.expression import null
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
||||||
|
|
||||||
|
|
||||||
class SQLAlchemyJobStore(BaseJobStore):
|
class SQLAlchemyJobStore(BaseJobStore):
|
||||||
"""
|
"""
|
||||||
Stores jobs in a database table using SQLAlchemy. The table will be created if it doesn't exist in the database.
|
Stores jobs in a database table using SQLAlchemy.
|
||||||
|
The table will be created if it doesn't exist in the database.
|
||||||
|
|
||||||
Plugin alias: ``sqlalchemy``
|
Plugin alias: ``sqlalchemy``
|
||||||
|
|
||||||
:param str url: connection string (see `SQLAlchemy documentation
|
:param str url: connection string (see
|
||||||
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
|
:ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this)
|
||||||
on this)
|
:param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a
|
||||||
:param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url``
|
new one based on ``url``
|
||||||
:param str tablename: name of the table to store jobs in
|
:param str tablename: name of the table to store jobs in
|
||||||
:param metadata: a :class:`~sqlalchemy.MetaData` instance to use instead of creating a new one
|
:param metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a
|
||||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
new one
|
||||||
|
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||||
|
highest available
|
||||||
|
:param str tableschema: name of the (existing) schema in the target database where the table
|
||||||
|
should be
|
||||||
|
:param dict engine_options: keyword arguments to :func:`~sqlalchemy.create_engine`
|
||||||
|
(ignored if ``engine`` is given)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
|
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
|
||||||
pickle_protocol=pickle.HIGHEST_PROTOCOL):
|
pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None):
|
||||||
super(SQLAlchemyJobStore, self).__init__()
|
super(SQLAlchemyJobStore, self).__init__()
|
||||||
self.pickle_protocol = pickle_protocol
|
self.pickle_protocol = pickle_protocol
|
||||||
metadata = maybe_ref(metadata) or MetaData()
|
metadata = maybe_ref(metadata) or MetaData()
|
||||||
@@ -40,37 +49,46 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||||||
if engine:
|
if engine:
|
||||||
self.engine = maybe_ref(engine)
|
self.engine = maybe_ref(engine)
|
||||||
elif url:
|
elif url:
|
||||||
self.engine = create_engine(url)
|
self.engine = create_engine(url, **(engine_options or {}))
|
||||||
else:
|
else:
|
||||||
raise ValueError('Need either "engine" or "url" defined')
|
raise ValueError('Need either "engine" or "url" defined')
|
||||||
|
|
||||||
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables, 25 = precision that translates to an 8-byte float
|
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables,
|
||||||
|
# 25 = precision that translates to an 8-byte float
|
||||||
self.jobs_t = Table(
|
self.jobs_t = Table(
|
||||||
tablename, metadata,
|
tablename, metadata,
|
||||||
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
|
Column('id', Unicode(191), primary_key=True),
|
||||||
Column('next_run_time', Float(25), index=True),
|
Column('next_run_time', Float(25), index=True),
|
||||||
Column('job_state', LargeBinary, nullable=False)
|
Column('job_state', LargeBinary, nullable=False),
|
||||||
|
schema=tableschema
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(SQLAlchemyJobStore, self).start(scheduler, alias)
|
||||||
self.jobs_t.create(self.engine, True)
|
self.jobs_t.create(self.engine, True)
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
selectable = select([self.jobs_t.c.job_state]).where(self.jobs_t.c.id == job_id)
|
selectable = select(self.jobs_t.c.job_state).where(self.jobs_t.c.id == job_id)
|
||||||
job_state = self.engine.execute(selectable).scalar()
|
with self.engine.begin() as connection:
|
||||||
return self._reconstitute_job(job_state) if job_state else None
|
job_state = connection.execute(selectable).scalar()
|
||||||
|
return self._reconstitute_job(job_state) if job_state else None
|
||||||
|
|
||||||
def get_due_jobs(self, now):
|
def get_due_jobs(self, now):
|
||||||
timestamp = datetime_to_utc_timestamp(now)
|
timestamp = datetime_to_utc_timestamp(now)
|
||||||
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
||||||
|
|
||||||
def get_next_run_time(self):
|
def get_next_run_time(self):
|
||||||
selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\
|
selectable = select(self.jobs_t.c.next_run_time).\
|
||||||
|
where(self.jobs_t.c.next_run_time != null()).\
|
||||||
order_by(self.jobs_t.c.next_run_time).limit(1)
|
order_by(self.jobs_t.c.next_run_time).limit(1)
|
||||||
next_run_time = self.engine.execute(selectable).scalar()
|
with self.engine.begin() as connection:
|
||||||
return utc_timestamp_to_datetime(next_run_time)
|
next_run_time = connection.execute(selectable).scalar()
|
||||||
|
return utc_timestamp_to_datetime(next_run_time)
|
||||||
|
|
||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
return self._get_jobs()
|
jobs = self._get_jobs()
|
||||||
|
self._fix_paused_jobs_sorting(jobs)
|
||||||
|
return jobs
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
insert = self.jobs_t.insert().values(**{
|
insert = self.jobs_t.insert().values(**{
|
||||||
@@ -78,29 +96,33 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||||
})
|
})
|
||||||
try:
|
with self.engine.begin() as connection:
|
||||||
self.engine.execute(insert)
|
try:
|
||||||
except IntegrityError:
|
connection.execute(insert)
|
||||||
raise ConflictingIdError(job.id)
|
except IntegrityError:
|
||||||
|
raise ConflictingIdError(job.id)
|
||||||
|
|
||||||
def update_job(self, job):
|
def update_job(self, job):
|
||||||
update = self.jobs_t.update().values(**{
|
update = self.jobs_t.update().values(**{
|
||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||||
}).where(self.jobs_t.c.id == job.id)
|
}).where(self.jobs_t.c.id == job.id)
|
||||||
result = self.engine.execute(update)
|
with self.engine.begin() as connection:
|
||||||
if result.rowcount == 0:
|
result = connection.execute(update)
|
||||||
raise JobLookupError(id)
|
if result.rowcount == 0:
|
||||||
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
def remove_job(self, job_id):
|
def remove_job(self, job_id):
|
||||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
||||||
result = self.engine.execute(delete)
|
with self.engine.begin() as connection:
|
||||||
if result.rowcount == 0:
|
result = connection.execute(delete)
|
||||||
raise JobLookupError(job_id)
|
if result.rowcount == 0:
|
||||||
|
raise JobLookupError(job_id)
|
||||||
|
|
||||||
def remove_all_jobs(self):
|
def remove_all_jobs(self):
|
||||||
delete = self.jobs_t.delete()
|
delete = self.jobs_t.delete()
|
||||||
self.engine.execute(delete)
|
with self.engine.begin() as connection:
|
||||||
|
connection.execute(delete)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
@@ -116,20 +138,22 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||||||
|
|
||||||
def _get_jobs(self, *conditions):
|
def _get_jobs(self, *conditions):
|
||||||
jobs = []
|
jobs = []
|
||||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time)
|
selectable = select(self.jobs_t.c.id, self.jobs_t.c.job_state).\
|
||||||
selectable = selectable.where(*conditions) if conditions else selectable
|
order_by(self.jobs_t.c.next_run_time)
|
||||||
|
selectable = selectable.where(and_(*conditions)) if conditions else selectable
|
||||||
failed_job_ids = set()
|
failed_job_ids = set()
|
||||||
for row in self.engine.execute(selectable):
|
with self.engine.begin() as connection:
|
||||||
try:
|
for row in connection.execute(selectable):
|
||||||
jobs.append(self._reconstitute_job(row.job_state))
|
try:
|
||||||
except:
|
jobs.append(self._reconstitute_job(row.job_state))
|
||||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
except BaseException:
|
||||||
failed_job_ids.add(row.id)
|
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
||||||
|
failed_job_ids.add(row.id)
|
||||||
|
|
||||||
# Remove all the jobs we failed to restore
|
# Remove all the jobs we failed to restore
|
||||||
if failed_job_ids:
|
if failed_job_ids:
|
||||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||||
self.engine.execute(delete)
|
connection.execute(delete)
|
||||||
|
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pytz import utc
|
||||||
|
from kazoo.exceptions import NoNodeError, NodeExistsError
|
||||||
|
|
||||||
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
|
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||||
|
from apscheduler.job import Job
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cPickle as pickle
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
try:
|
||||||
|
from kazoo.client import KazooClient
|
||||||
|
except ImportError: # pragma: nocover
|
||||||
|
raise ImportError('ZooKeeperJobStore requires Kazoo installed')
|
||||||
|
|
||||||
|
|
||||||
|
class ZooKeeperJobStore(BaseJobStore):
|
||||||
|
"""
|
||||||
|
Stores jobs in a ZooKeeper tree. Any leftover keyword arguments are directly passed to
|
||||||
|
kazoo's `KazooClient
|
||||||
|
<http://kazoo.readthedocs.io/en/latest/api/client.html>`_.
|
||||||
|
|
||||||
|
Plugin alias: ``zookeeper``
|
||||||
|
|
||||||
|
:param str path: path to store jobs in
|
||||||
|
:param client: a :class:`~kazoo.client.KazooClient` instance to use instead of
|
||||||
|
providing connection arguments
|
||||||
|
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||||
|
highest available
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path='/apscheduler', client=None, close_connection_on_exit=False,
|
||||||
|
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
|
||||||
|
super(ZooKeeperJobStore, self).__init__()
|
||||||
|
self.pickle_protocol = pickle_protocol
|
||||||
|
self.close_connection_on_exit = close_connection_on_exit
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
raise ValueError('The "path" parameter must not be empty')
|
||||||
|
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
if client:
|
||||||
|
self.client = maybe_ref(client)
|
||||||
|
else:
|
||||||
|
self.client = KazooClient(**connect_args)
|
||||||
|
self._ensured_path = False
|
||||||
|
|
||||||
|
def _ensure_paths(self):
|
||||||
|
if not self._ensured_path:
|
||||||
|
self.client.ensure_path(self.path)
|
||||||
|
self._ensured_path = True
|
||||||
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(ZooKeeperJobStore, self).start(scheduler, alias)
|
||||||
|
if not self.client.connected:
|
||||||
|
self.client.start()
|
||||||
|
|
||||||
|
def lookup_job(self, job_id):
|
||||||
|
self._ensure_paths()
|
||||||
|
node_path = self.path + "/" + str(job_id)
|
||||||
|
try:
|
||||||
|
content, _ = self.client.get(node_path)
|
||||||
|
doc = pickle.loads(content)
|
||||||
|
job = self._reconstitute_job(doc['job_state'])
|
||||||
|
return job
|
||||||
|
except BaseException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_due_jobs(self, now):
|
||||||
|
timestamp = datetime_to_utc_timestamp(now)
|
||||||
|
jobs = [job_def['job'] for job_def in self._get_jobs()
|
||||||
|
if job_def['next_run_time'] is not None and job_def['next_run_time'] <= timestamp]
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def get_next_run_time(self):
|
||||||
|
next_runs = [job_def['next_run_time'] for job_def in self._get_jobs()
|
||||||
|
if job_def['next_run_time'] is not None]
|
||||||
|
return utc_timestamp_to_datetime(min(next_runs)) if len(next_runs) > 0 else None
|
||||||
|
|
||||||
|
def get_all_jobs(self):
|
||||||
|
jobs = [job_def['job'] for job_def in self._get_jobs()]
|
||||||
|
self._fix_paused_jobs_sorting(jobs)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
def add_job(self, job):
|
||||||
|
self._ensure_paths()
|
||||||
|
node_path = self.path + "/" + str(job.id)
|
||||||
|
value = {
|
||||||
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
|
'job_state': job.__getstate__()
|
||||||
|
}
|
||||||
|
data = pickle.dumps(value, self.pickle_protocol)
|
||||||
|
try:
|
||||||
|
self.client.create(node_path, value=data)
|
||||||
|
except NodeExistsError:
|
||||||
|
raise ConflictingIdError(job.id)
|
||||||
|
|
||||||
|
def update_job(self, job):
|
||||||
|
self._ensure_paths()
|
||||||
|
node_path = self.path + "/" + str(job.id)
|
||||||
|
changes = {
|
||||||
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
|
'job_state': job.__getstate__()
|
||||||
|
}
|
||||||
|
data = pickle.dumps(changes, self.pickle_protocol)
|
||||||
|
try:
|
||||||
|
self.client.set(node_path, value=data)
|
||||||
|
except NoNodeError:
|
||||||
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
|
def remove_job(self, job_id):
|
||||||
|
self._ensure_paths()
|
||||||
|
node_path = self.path + "/" + str(job_id)
|
||||||
|
try:
|
||||||
|
self.client.delete(node_path)
|
||||||
|
except NoNodeError:
|
||||||
|
raise JobLookupError(job_id)
|
||||||
|
|
||||||
|
def remove_all_jobs(self):
|
||||||
|
try:
|
||||||
|
self.client.delete(self.path, recursive=True)
|
||||||
|
except NoNodeError:
|
||||||
|
pass
|
||||||
|
self._ensured_path = False
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
if self.close_connection_on_exit:
|
||||||
|
self.client.stop()
|
||||||
|
self.client.close()
|
||||||
|
|
||||||
|
def _reconstitute_job(self, job_state):
|
||||||
|
job_state = job_state
|
||||||
|
job = Job.__new__(Job)
|
||||||
|
job.__setstate__(job_state)
|
||||||
|
job._scheduler = self._scheduler
|
||||||
|
job._jobstore_alias = self._alias
|
||||||
|
return job
|
||||||
|
|
||||||
|
def _get_jobs(self):
|
||||||
|
self._ensure_paths()
|
||||||
|
jobs = []
|
||||||
|
failed_job_ids = []
|
||||||
|
all_ids = self.client.get_children(self.path)
|
||||||
|
for node_name in all_ids:
|
||||||
|
try:
|
||||||
|
node_path = self.path + "/" + node_name
|
||||||
|
content, _ = self.client.get(node_path)
|
||||||
|
doc = pickle.loads(content)
|
||||||
|
job_def = {
|
||||||
|
'job_id': node_name,
|
||||||
|
'next_run_time': doc['next_run_time'] if doc['next_run_time'] else None,
|
||||||
|
'job_state': doc['job_state'],
|
||||||
|
'job': self._reconstitute_job(doc['job_state']),
|
||||||
|
'creation_time': _.ctime
|
||||||
|
}
|
||||||
|
jobs.append(job_def)
|
||||||
|
except BaseException:
|
||||||
|
self._logger.exception('Unable to restore job "%s" -- removing it' % node_name)
|
||||||
|
failed_job_ids.append(node_name)
|
||||||
|
|
||||||
|
# Remove all the jobs we failed to restore
|
||||||
|
if failed_job_ids:
|
||||||
|
for failed_id in failed_job_ids:
|
||||||
|
self.remove_job(failed_id)
|
||||||
|
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
|
||||||
|
return sorted(jobs, key=lambda job_def: (job_def['job'].next_run_time or paused_sort_key,
|
||||||
|
job_def['creation_time']))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
self._logger.exception('<%s (client=%s)>' % (self.__class__.__name__, self.client))
|
||||||
|
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
from functools import wraps
|
import asyncio
|
||||||
|
from functools import wraps, partial
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
from apscheduler.util import maybe_ref
|
from apscheduler.util import maybe_ref
|
||||||
|
|
||||||
try:
|
|
||||||
import asyncio
|
|
||||||
except ImportError: # pragma: nocover
|
|
||||||
try:
|
|
||||||
import trollius as asyncio
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError('AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
|
|
||||||
|
|
||||||
|
|
||||||
def run_in_event_loop(func):
|
def run_in_event_loop(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(self, *args, **kwargs):
|
def wrapper(self, *args, **kwargs):
|
||||||
self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs)
|
wrapped = partial(func, self, *args, **kwargs)
|
||||||
|
self._eventloop.call_soon_threadsafe(wrapped)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +18,8 @@ class AsyncIOScheduler(BaseScheduler):
|
|||||||
"""
|
"""
|
||||||
A scheduler that runs on an asyncio (:pep:`3156`) event loop.
|
A scheduler that runs on an asyncio (:pep:`3156`) event loop.
|
||||||
|
|
||||||
|
The default executor can run jobs based on native coroutines (``async def``).
|
||||||
|
|
||||||
Extra options:
|
Extra options:
|
||||||
|
|
||||||
============== =============================================================
|
============== =============================================================
|
||||||
@@ -34,9 +30,11 @@ class AsyncIOScheduler(BaseScheduler):
|
|||||||
_eventloop = None
|
_eventloop = None
|
||||||
_timeout = None
|
_timeout = None
|
||||||
|
|
||||||
def start(self):
|
def start(self, paused=False):
|
||||||
super(AsyncIOScheduler, self).start()
|
if not self._eventloop:
|
||||||
self.wakeup()
|
self._eventloop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
super(AsyncIOScheduler, self).start(paused)
|
||||||
|
|
||||||
@run_in_event_loop
|
@run_in_event_loop
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
@@ -44,7 +42,7 @@ class AsyncIOScheduler(BaseScheduler):
|
|||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
|
|
||||||
def _configure(self, config):
|
def _configure(self, config):
|
||||||
self._eventloop = maybe_ref(config.pop('event_loop', None)) or asyncio.get_event_loop()
|
self._eventloop = maybe_ref(config.pop('event_loop', None))
|
||||||
super(AsyncIOScheduler, self)._configure(config)
|
super(AsyncIOScheduler, self)._configure(config)
|
||||||
|
|
||||||
def _start_timer(self, wait_seconds):
|
def _start_timer(self, wait_seconds):
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from threading import Thread, Event
|
from threading import Thread, Event
|
||||||
|
|
||||||
@@ -13,11 +14,12 @@ class BackgroundScheduler(BlockingScheduler):
|
|||||||
|
|
||||||
Extra options:
|
Extra options:
|
||||||
|
|
||||||
========== ============================================================================================
|
========== =============================================================================
|
||||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
|
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see
|
||||||
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
`the documentation
|
||||||
|
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||||
for further details)
|
for further details)
|
||||||
========== ============================================================================================
|
========== =============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_thread = None
|
_thread = None
|
||||||
@@ -26,14 +28,16 @@ class BackgroundScheduler(BlockingScheduler):
|
|||||||
self._daemon = asbool(config.pop('daemon', True))
|
self._daemon = asbool(config.pop('daemon', True))
|
||||||
super(BackgroundScheduler, self)._configure(config)
|
super(BackgroundScheduler, self)._configure(config)
|
||||||
|
|
||||||
def start(self):
|
def start(self, *args, **kwargs):
|
||||||
BaseScheduler.start(self)
|
if self._event is None or self._event.is_set():
|
||||||
self._event = Event()
|
self._event = Event()
|
||||||
|
|
||||||
|
BaseScheduler.start(self, *args, **kwargs)
|
||||||
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
||||||
self._thread.daemon = self._daemon
|
self._thread.daemon = self._daemon
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, *args, **kwargs):
|
||||||
super(BackgroundScheduler, self).shutdown(wait)
|
super(BackgroundScheduler, self).shutdown(*args, **kwargs)
|
||||||
self._thread.join()
|
self._thread.join()
|
||||||
del self._thread
|
del self._thread
|
||||||
|
|||||||
+395
-214
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,23 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED
|
||||||
|
from apscheduler.util import TIMEOUT_MAX
|
||||||
|
|
||||||
|
|
||||||
class BlockingScheduler(BaseScheduler):
|
class BlockingScheduler(BaseScheduler):
|
||||||
"""
|
"""
|
||||||
A scheduler that runs in the foreground (:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
|
A scheduler that runs in the foreground
|
||||||
|
(:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
MAX_WAIT_TIME = 4294967 # Maximum value accepted by Event.wait() on Windows
|
|
||||||
|
|
||||||
_event = None
|
_event = None
|
||||||
|
|
||||||
def start(self):
|
def start(self, *args, **kwargs):
|
||||||
super(BlockingScheduler, self).start()
|
if self._event is None or self._event.is_set():
|
||||||
self._event = Event()
|
self._event = Event()
|
||||||
|
|
||||||
|
super(BlockingScheduler, self).start(*args, **kwargs)
|
||||||
self._main_loop()
|
self._main_loop()
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
@@ -23,10 +25,11 @@ class BlockingScheduler(BaseScheduler):
|
|||||||
self._event.set()
|
self._event.set()
|
||||||
|
|
||||||
def _main_loop(self):
|
def _main_loop(self):
|
||||||
while self.running:
|
wait_seconds = TIMEOUT_MAX
|
||||||
wait_seconds = self._process_jobs()
|
while self.state != STATE_STOPPED:
|
||||||
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME)
|
self._event.wait(wait_seconds)
|
||||||
self._event.clear()
|
self._event.clear()
|
||||||
|
wait_seconds = self._process_jobs()
|
||||||
|
|
||||||
def wakeup(self):
|
def wakeup(self):
|
||||||
self._event.set()
|
self._event.set()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
@@ -16,14 +16,14 @@ class GeventScheduler(BlockingScheduler):
|
|||||||
|
|
||||||
_greenlet = None
|
_greenlet = None
|
||||||
|
|
||||||
def start(self):
|
def start(self, *args, **kwargs):
|
||||||
BaseScheduler.start(self)
|
|
||||||
self._event = Event()
|
self._event = Event()
|
||||||
|
BaseScheduler.start(self, *args, **kwargs)
|
||||||
self._greenlet = gevent.spawn(self._main_loop)
|
self._greenlet = gevent.spawn(self._main_loop)
|
||||||
return self._greenlet
|
return self._greenlet
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, *args, **kwargs):
|
||||||
super(GeventScheduler, self).shutdown(wait)
|
super(GeventScheduler, self).shutdown(*args, **kwargs)
|
||||||
self._greenlet.join()
|
self._greenlet.join()
|
||||||
del self._greenlet
|
del self._greenlet
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtCore import QObject, QTimer
|
from PyQt5.QtCore import QObject, QTimer
|
||||||
except ImportError: # pragma: nocover
|
except (ImportError, RuntimeError): # pragma: nocover
|
||||||
try:
|
try:
|
||||||
from PyQt4.QtCore import QObject, QTimer
|
from PyQt4.QtCore import QObject, QTimer
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from PySide.QtCore import QObject, QTimer # flake8: noqa
|
from PySide6.QtCore import QObject, QTimer # noqa
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError('QtScheduler requires either PyQt5, PyQt4 or PySide installed')
|
try:
|
||||||
|
from PySide2.QtCore import QObject, QTimer # noqa
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from PySide.QtCore import QObject, QTimer # noqa
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError('QtScheduler requires either PyQt5, PyQt4, PySide6, PySide2 '
|
||||||
|
'or PySide installed')
|
||||||
|
|
||||||
|
|
||||||
class QtScheduler(BaseScheduler):
|
class QtScheduler(BaseScheduler):
|
||||||
@@ -19,18 +26,15 @@ class QtScheduler(BaseScheduler):
|
|||||||
|
|
||||||
_timer = None
|
_timer = None
|
||||||
|
|
||||||
def start(self):
|
def shutdown(self, *args, **kwargs):
|
||||||
super(QtScheduler, self).start()
|
super(QtScheduler, self).shutdown(*args, **kwargs)
|
||||||
self.wakeup()
|
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
|
||||||
super(QtScheduler, self).shutdown(wait)
|
|
||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
|
|
||||||
def _start_timer(self, wait_seconds):
|
def _start_timer(self, wait_seconds):
|
||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
if wait_seconds is not None:
|
if wait_seconds is not None:
|
||||||
self._timer = QTimer.singleShot(wait_seconds * 1000, self._process_jobs)
|
wait_time = min(int(wait_seconds * 1000), 2147483647)
|
||||||
|
self._timer = QTimer.singleShot(wait_time, self._process_jobs)
|
||||||
|
|
||||||
def _stop_timer(self):
|
def _stop_timer(self):
|
||||||
if self._timer:
|
if self._timer:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -22,6 +23,8 @@ class TornadoScheduler(BaseScheduler):
|
|||||||
"""
|
"""
|
||||||
A scheduler that runs on a Tornado IOLoop.
|
A scheduler that runs on a Tornado IOLoop.
|
||||||
|
|
||||||
|
The default executor can run jobs based on native coroutines (``async def``).
|
||||||
|
|
||||||
=========== ===============================================================
|
=========== ===============================================================
|
||||||
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
|
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
|
||||||
=========== ===============================================================
|
=========== ===============================================================
|
||||||
@@ -30,10 +33,6 @@ class TornadoScheduler(BaseScheduler):
|
|||||||
_ioloop = None
|
_ioloop = None
|
||||||
_timeout = None
|
_timeout = None
|
||||||
|
|
||||||
def start(self):
|
|
||||||
super(TornadoScheduler, self).start()
|
|
||||||
self.wakeup()
|
|
||||||
|
|
||||||
@run_in_ioloop
|
@run_in_ioloop
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
super(TornadoScheduler, self).shutdown(wait)
|
super(TornadoScheduler, self).shutdown(wait)
|
||||||
@@ -53,6 +52,10 @@ class TornadoScheduler(BaseScheduler):
|
|||||||
self._ioloop.remove_timeout(self._timeout)
|
self._ioloop.remove_timeout(self._timeout)
|
||||||
del self._timeout
|
del self._timeout
|
||||||
|
|
||||||
|
def _create_default_executor(self):
|
||||||
|
from apscheduler.executors.tornado import TornadoExecutor
|
||||||
|
return TornadoExecutor()
|
||||||
|
|
||||||
@run_in_ioloop
|
@run_in_ioloop
|
||||||
def wakeup(self):
|
def wakeup(self):
|
||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
@@ -35,10 +36,6 @@ class TwistedScheduler(BaseScheduler):
|
|||||||
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
||||||
super(TwistedScheduler, self)._configure(config)
|
super(TwistedScheduler, self)._configure(config)
|
||||||
|
|
||||||
def start(self):
|
|
||||||
super(TwistedScheduler, self).start()
|
|
||||||
self.wakeup()
|
|
||||||
|
|
||||||
@run_in_reactor
|
@run_in_reactor
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
super(TwistedScheduler, self).shutdown(wait)
|
super(TwistedScheduler, self).shutdown(wait)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from datetime import timedelta
|
||||||
|
import random
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
@@ -6,11 +8,30 @@ import six
|
|||||||
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
||||||
"""Abstract base class that defines the interface that every trigger must implement."""
|
"""Abstract base class that defines the interface that every trigger must implement."""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
"""
|
"""
|
||||||
Returns the next datetime to fire on, If no such datetime can be calculated, returns ``None``.
|
Returns the next datetime to fire on, If no such datetime can be calculated, returns
|
||||||
|
``None``.
|
||||||
|
|
||||||
:param datetime.datetime previous_fire_time: the previous time the trigger was fired
|
:param datetime.datetime previous_fire_time: the previous time the trigger was fired
|
||||||
:param datetime.datetime now: current datetime
|
:param datetime.datetime now: current datetime
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _apply_jitter(self, next_fire_time, jitter, now):
|
||||||
|
"""
|
||||||
|
Randomize ``next_fire_time`` by adding a random value (the jitter).
|
||||||
|
|
||||||
|
:param datetime.datetime|None next_fire_time: next fire time without jitter applied. If
|
||||||
|
``None``, returns ``None``.
|
||||||
|
:param int|None jitter: maximum number of seconds to add to ``next_fire_time``
|
||||||
|
(if ``None`` or ``0``, returns ``next_fire_time``)
|
||||||
|
:param datetime.datetime now: current datetime
|
||||||
|
:return datetime.datetime|None: next fire time with a jitter.
|
||||||
|
"""
|
||||||
|
if next_fire_time is None or not jitter:
|
||||||
|
return next_fire_time
|
||||||
|
|
||||||
|
return next_fire_time + timedelta(seconds=random.uniform(0, jitter))
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
|
from apscheduler.util import obj_to_ref, ref_to_obj
|
||||||
|
|
||||||
|
|
||||||
|
class BaseCombiningTrigger(BaseTrigger):
|
||||||
|
__slots__ = ('triggers', 'jitter')
|
||||||
|
|
||||||
|
def __init__(self, triggers, jitter=None):
|
||||||
|
self.triggers = triggers
|
||||||
|
self.jitter = jitter
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
return {
|
||||||
|
'version': 1,
|
||||||
|
'triggers': [(obj_to_ref(trigger.__class__), trigger.__getstate__())
|
||||||
|
for trigger in self.triggers],
|
||||||
|
'jitter': self.jitter
|
||||||
|
}
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
if state.get('version', 1) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
'Got serialized data for version %s of %s, but only versions up to 1 can be '
|
||||||
|
'handled' % (state['version'], self.__class__.__name__))
|
||||||
|
|
||||||
|
self.jitter = state['jitter']
|
||||||
|
self.triggers = []
|
||||||
|
for clsref, state in state['triggers']:
|
||||||
|
cls = ref_to_obj(clsref)
|
||||||
|
trigger = cls.__new__(cls)
|
||||||
|
trigger.__setstate__(state)
|
||||||
|
self.triggers.append(trigger)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{}({}{})>'.format(self.__class__.__name__, self.triggers,
|
||||||
|
', jitter={}'.format(self.jitter) if self.jitter else '')
|
||||||
|
|
||||||
|
|
||||||
|
class AndTrigger(BaseCombiningTrigger):
|
||||||
|
"""
|
||||||
|
Always returns the earliest next fire time that all the given triggers can agree on.
|
||||||
|
The trigger is considered to be finished when any of the given triggers has finished its
|
||||||
|
schedule.
|
||||||
|
|
||||||
|
Trigger alias: ``and``
|
||||||
|
|
||||||
|
:param list triggers: triggers to combine
|
||||||
|
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
|
while True:
|
||||||
|
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
|
||||||
|
for trigger in self.triggers]
|
||||||
|
if None in fire_times:
|
||||||
|
return None
|
||||||
|
elif min(fire_times) == max(fire_times):
|
||||||
|
return self._apply_jitter(fire_times[0], self.jitter, now)
|
||||||
|
else:
|
||||||
|
now = max(fire_times)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'and[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
|
||||||
|
|
||||||
|
|
||||||
|
class OrTrigger(BaseCombiningTrigger):
|
||||||
|
"""
|
||||||
|
Always returns the earliest next fire time produced by any of the given triggers.
|
||||||
|
The trigger is considered finished when all the given triggers have finished their schedules.
|
||||||
|
|
||||||
|
Trigger alias: ``or``
|
||||||
|
|
||||||
|
:param list triggers: triggers to combine
|
||||||
|
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||||
|
|
||||||
|
.. note:: Triggers that depends on the previous fire time, such as the interval trigger, may
|
||||||
|
seem to behave strangely since they are always passed the previous fire time produced by
|
||||||
|
any of the given triggers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
|
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
|
||||||
|
for trigger in self.triggers]
|
||||||
|
fire_times = [fire_time for fire_time in fire_times if fire_time is not None]
|
||||||
|
if fire_times:
|
||||||
|
return self._apply_jitter(min(fire_times), self.jitter, now)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'or[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
|
||||||
@@ -4,17 +4,20 @@ from tzlocal import get_localzone
|
|||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.triggers.base import BaseTrigger
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES
|
from apscheduler.triggers.cron.fields import (
|
||||||
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
|
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES)
|
||||||
|
from apscheduler.util import (
|
||||||
|
datetime_ceil, convert_to_datetime, datetime_repr, astimezone, localize, normalize)
|
||||||
|
|
||||||
|
|
||||||
class CronTrigger(BaseTrigger):
|
class CronTrigger(BaseTrigger):
|
||||||
"""
|
"""
|
||||||
Triggers when current time matches all specified time constraints, similarly to how the UNIX cron scheduler works.
|
Triggers when current time matches all specified time constraints,
|
||||||
|
similarly to how the UNIX cron scheduler works.
|
||||||
|
|
||||||
:param int|str year: 4-digit year
|
:param int|str year: 4-digit year
|
||||||
:param int|str month: month (1-12)
|
:param int|str month: month (1-12)
|
||||||
:param int|str day: day of the (1-31)
|
:param int|str day: day of month (1-31)
|
||||||
:param int|str week: ISO week (1-53)
|
:param int|str week: ISO week (1-53)
|
||||||
:param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun)
|
:param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun)
|
||||||
:param int|str hour: hour (0-23)
|
:param int|str hour: hour (0-23)
|
||||||
@@ -22,8 +25,9 @@ class CronTrigger(BaseTrigger):
|
|||||||
:param int|str second: second (0-59)
|
:param int|str second: second (0-59)
|
||||||
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
|
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
|
||||||
:param datetime|str end_date: latest possible date/time to trigger on (inclusive)
|
:param datetime|str end_date: latest possible date/time to trigger on (inclusive)
|
||||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults
|
||||||
(defaults to scheduler timezone)
|
to scheduler timezone)
|
||||||
|
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||||
|
|
||||||
.. note:: The first weekday is always **monday**.
|
.. note:: The first weekday is always **monday**.
|
||||||
"""
|
"""
|
||||||
@@ -31,7 +35,7 @@ class CronTrigger(BaseTrigger):
|
|||||||
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
|
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
|
||||||
FIELDS_MAP = {
|
FIELDS_MAP = {
|
||||||
'year': BaseField,
|
'year': BaseField,
|
||||||
'month': BaseField,
|
'month': MonthField,
|
||||||
'week': WeekField,
|
'week': WeekField,
|
||||||
'day': DayOfMonthField,
|
'day': DayOfMonthField,
|
||||||
'day_of_week': DayOfWeekField,
|
'day_of_week': DayOfWeekField,
|
||||||
@@ -40,15 +44,16 @@ class CronTrigger(BaseTrigger):
|
|||||||
'second': BaseField
|
'second': BaseField
|
||||||
}
|
}
|
||||||
|
|
||||||
__slots__ = 'timezone', 'start_date', 'end_date', 'fields'
|
__slots__ = 'timezone', 'start_date', 'end_date', 'fields', 'jitter'
|
||||||
|
|
||||||
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None,
|
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None,
|
||||||
second=None, start_date=None, end_date=None, timezone=None):
|
minute=None, second=None, start_date=None, end_date=None, timezone=None,
|
||||||
|
jitter=None):
|
||||||
if timezone:
|
if timezone:
|
||||||
self.timezone = astimezone(timezone)
|
self.timezone = astimezone(timezone)
|
||||||
elif start_date and start_date.tzinfo:
|
elif isinstance(start_date, datetime) and start_date.tzinfo:
|
||||||
self.timezone = start_date.tzinfo
|
self.timezone = start_date.tzinfo
|
||||||
elif end_date and end_date.tzinfo:
|
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||||
self.timezone = end_date.tzinfo
|
self.timezone = end_date.tzinfo
|
||||||
else:
|
else:
|
||||||
self.timezone = get_localzone()
|
self.timezone = get_localzone()
|
||||||
@@ -56,6 +61,8 @@ class CronTrigger(BaseTrigger):
|
|||||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||||
|
|
||||||
|
self.jitter = jitter
|
||||||
|
|
||||||
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
||||||
if key in self.FIELD_NAMES and value is not None)
|
if key in self.FIELD_NAMES and value is not None)
|
||||||
self.fields = []
|
self.fields = []
|
||||||
@@ -76,13 +83,35 @@ class CronTrigger(BaseTrigger):
|
|||||||
field = field_class(field_name, exprs, is_default)
|
field = field_class(field_name, exprs, is_default)
|
||||||
self.fields.append(field)
|
self.fields.append(field)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_crontab(cls, expr, timezone=None):
|
||||||
|
"""
|
||||||
|
Create a :class:`~CronTrigger` from a standard crontab expression.
|
||||||
|
|
||||||
|
See https://en.wikipedia.org/wiki/Cron for more information on the format accepted here.
|
||||||
|
|
||||||
|
:param expr: minute, hour, day of month, month, day of week
|
||||||
|
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (
|
||||||
|
defaults to scheduler timezone)
|
||||||
|
:return: a :class:`~CronTrigger` instance
|
||||||
|
|
||||||
|
"""
|
||||||
|
values = expr.split()
|
||||||
|
if len(values) != 5:
|
||||||
|
raise ValueError('Wrong number of fields; got {}, expected 5'.format(len(values)))
|
||||||
|
|
||||||
|
return cls(minute=values[0], hour=values[1], day=values[2], month=values[3],
|
||||||
|
day_of_week=values[4], timezone=timezone)
|
||||||
|
|
||||||
def _increment_field_value(self, dateval, fieldnum):
|
def _increment_field_value(self, dateval, fieldnum):
|
||||||
"""
|
"""
|
||||||
Increments the designated field and resets all less significant fields to their minimum values.
|
Increments the designated field and resets all less significant fields to their minimum
|
||||||
|
values.
|
||||||
|
|
||||||
:type dateval: datetime
|
:type dateval: datetime
|
||||||
:type fieldnum: int
|
:type fieldnum: int
|
||||||
:return: a tuple containing the new date, and the number of the field that was actually incremented
|
:return: a tuple containing the new date, and the number of the field that was actually
|
||||||
|
incremented
|
||||||
:rtype: tuple
|
:rtype: tuple
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -115,7 +144,7 @@ class CronTrigger(BaseTrigger):
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
||||||
return self.timezone.normalize(dateval + difference), fieldnum
|
return normalize(dateval + difference), fieldnum
|
||||||
|
|
||||||
def _set_field_value(self, dateval, fieldnum, new_value):
|
def _set_field_value(self, dateval, fieldnum, new_value):
|
||||||
values = {}
|
values = {}
|
||||||
@@ -128,12 +157,13 @@ class CronTrigger(BaseTrigger):
|
|||||||
else:
|
else:
|
||||||
values[field.name] = new_value
|
values[field.name] = new_value
|
||||||
|
|
||||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
return localize(datetime(**values), self.timezone)
|
||||||
return self.timezone.normalize(dateval + difference)
|
|
||||||
|
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
if previous_fire_time:
|
if previous_fire_time:
|
||||||
start_date = max(now, previous_fire_time + timedelta(microseconds=1))
|
start_date = min(now, previous_fire_time + timedelta(microseconds=1))
|
||||||
|
if start_date == previous_fire_time:
|
||||||
|
start_date += timedelta(microseconds=1)
|
||||||
else:
|
else:
|
||||||
start_date = max(now, self.start_date) if self.start_date else now
|
start_date = max(now, self.start_date) if self.start_date else now
|
||||||
|
|
||||||
@@ -163,7 +193,34 @@ class CronTrigger(BaseTrigger):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if fieldnum >= 0:
|
if fieldnum >= 0:
|
||||||
return next_date
|
next_date = self._apply_jitter(next_date, self.jitter, now)
|
||||||
|
return min(next_date, self.end_date) if self.end_date else next_date
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
return {
|
||||||
|
'version': 2,
|
||||||
|
'timezone': self.timezone,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date,
|
||||||
|
'fields': self.fields,
|
||||||
|
'jitter': self.jitter,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
# This is for compatibility with APScheduler 3.0.x
|
||||||
|
if isinstance(state, tuple):
|
||||||
|
state = state[1]
|
||||||
|
|
||||||
|
if state.get('version', 1) > 2:
|
||||||
|
raise ValueError(
|
||||||
|
'Got serialized data for version %s of %s, but only versions up to 2 can be '
|
||||||
|
'handled' % (state['version'], self.__class__.__name__))
|
||||||
|
|
||||||
|
self.timezone = state['timezone']
|
||||||
|
self.start_date = state['start_date']
|
||||||
|
self.end_date = state['end_date']
|
||||||
|
self.fields = state['fields']
|
||||||
|
self.jitter = state.get('jitter')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||||
@@ -172,5 +229,11 @@ class CronTrigger(BaseTrigger):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||||
if self.start_date:
|
if self.start_date:
|
||||||
options.append("start_date='%s'" % datetime_repr(self.start_date))
|
options.append("start_date=%r" % datetime_repr(self.start_date))
|
||||||
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
|
if self.end_date:
|
||||||
|
options.append("end_date=%r" % datetime_repr(self.end_date))
|
||||||
|
if self.jitter:
|
||||||
|
options.append('jitter=%s' % self.jitter)
|
||||||
|
|
||||||
|
return "<%s (%s, timezone='%s')>" % (
|
||||||
|
self.__class__.__name__, ', '.join(options), self.timezone)
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
"""
|
"""This module contains the expressions applicable for CronTrigger's fields."""
|
||||||
This module contains the expressions applicable for CronTrigger's fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from apscheduler.util import asint
|
from apscheduler.util import asint
|
||||||
|
|
||||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
|
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
|
||||||
'LastDayOfMonthExpression')
|
'WeekdayPositionExpression', 'LastDayOfMonthExpression')
|
||||||
|
|
||||||
|
|
||||||
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||||
|
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
||||||
|
|
||||||
|
|
||||||
class AllExpression(object):
|
class AllExpression(object):
|
||||||
@@ -22,6 +21,14 @@ class AllExpression(object):
|
|||||||
if self.step == 0:
|
if self.step == 0:
|
||||||
raise ValueError('Increment must be higher than 0')
|
raise ValueError('Increment must be higher than 0')
|
||||||
|
|
||||||
|
def validate_range(self, field_name):
|
||||||
|
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
|
||||||
|
|
||||||
|
value_range = MAX_VALUES[field_name] - MIN_VALUES[field_name]
|
||||||
|
if self.step and self.step > value_range:
|
||||||
|
raise ValueError('the step value ({}) is higher than the total range of the '
|
||||||
|
'expression ({})'.format(self.step, value_range))
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
start = field.get_value(date)
|
start = field.get_value(date)
|
||||||
minval = field.get_min(date)
|
minval = field.get_min(date)
|
||||||
@@ -37,6 +44,9 @@ class AllExpression(object):
|
|||||||
if next <= maxval:
|
if next <= maxval:
|
||||||
return next
|
return next
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return isinstance(other, self.__class__) and self.step == other.step
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.step:
|
if self.step:
|
||||||
return '*/%d' % self.step
|
return '*/%d' % self.step
|
||||||
@@ -51,7 +61,7 @@ class RangeExpression(AllExpression):
|
|||||||
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
|
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
|
||||||
|
|
||||||
def __init__(self, first, last=None, step=None):
|
def __init__(self, first, last=None, step=None):
|
||||||
AllExpression.__init__(self, step)
|
super(RangeExpression, self).__init__(step)
|
||||||
first = asint(first)
|
first = asint(first)
|
||||||
last = asint(last)
|
last = asint(last)
|
||||||
if last is None and step is None:
|
if last is None and step is None:
|
||||||
@@ -61,25 +71,41 @@ class RangeExpression(AllExpression):
|
|||||||
self.first = first
|
self.first = first
|
||||||
self.last = last
|
self.last = last
|
||||||
|
|
||||||
|
def validate_range(self, field_name):
|
||||||
|
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
|
||||||
|
|
||||||
|
super(RangeExpression, self).validate_range(field_name)
|
||||||
|
if self.first < MIN_VALUES[field_name]:
|
||||||
|
raise ValueError('the first value ({}) is lower than the minimum value ({})'
|
||||||
|
.format(self.first, MIN_VALUES[field_name]))
|
||||||
|
if self.last is not None and self.last > MAX_VALUES[field_name]:
|
||||||
|
raise ValueError('the last value ({}) is higher than the maximum value ({})'
|
||||||
|
.format(self.last, MAX_VALUES[field_name]))
|
||||||
|
value_range = (self.last or MAX_VALUES[field_name]) - self.first
|
||||||
|
if self.step and self.step > value_range:
|
||||||
|
raise ValueError('the step value ({}) is higher than the total range of the '
|
||||||
|
'expression ({})'.format(self.step, value_range))
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
start = field.get_value(date)
|
startval = field.get_value(date)
|
||||||
minval = field.get_min(date)
|
minval = field.get_min(date)
|
||||||
maxval = field.get_max(date)
|
maxval = field.get_max(date)
|
||||||
|
|
||||||
# Apply range limits
|
# Apply range limits
|
||||||
minval = max(minval, self.first)
|
minval = max(minval, self.first)
|
||||||
if self.last is not None:
|
maxval = min(maxval, self.last) if self.last is not None else maxval
|
||||||
maxval = min(maxval, self.last)
|
nextval = max(minval, startval)
|
||||||
start = max(start, minval)
|
|
||||||
|
|
||||||
if not self.step:
|
# Apply the step if defined
|
||||||
next = start
|
if self.step:
|
||||||
else:
|
distance_to_next = (self.step - (nextval - minval)) % self.step
|
||||||
distance_to_next = (self.step - (start - minval)) % self.step
|
nextval += distance_to_next
|
||||||
next = start + distance_to_next
|
|
||||||
|
|
||||||
if next <= maxval:
|
return nextval if nextval <= maxval else None
|
||||||
return next
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (isinstance(other, self.__class__) and self.first == other.first and
|
||||||
|
self.last == other.last)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.last != self.first and self.last is not None:
|
if self.last != self.first and self.last is not None:
|
||||||
@@ -100,6 +126,37 @@ class RangeExpression(AllExpression):
|
|||||||
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
||||||
|
|
||||||
|
|
||||||
|
class MonthRangeExpression(RangeExpression):
|
||||||
|
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||||
|
|
||||||
|
def __init__(self, first, last=None):
|
||||||
|
try:
|
||||||
|
first_num = MONTHS.index(first.lower()) + 1
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('Invalid month name "%s"' % first)
|
||||||
|
|
||||||
|
if last:
|
||||||
|
try:
|
||||||
|
last_num = MONTHS.index(last.lower()) + 1
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('Invalid month name "%s"' % last)
|
||||||
|
else:
|
||||||
|
last_num = None
|
||||||
|
|
||||||
|
super(MonthRangeExpression, self).__init__(first_num, last_num)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.last != self.first and self.last is not None:
|
||||||
|
return '%s-%s' % (MONTHS[self.first - 1], MONTHS[self.last - 1])
|
||||||
|
return MONTHS[self.first - 1]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
args = ["'%s'" % MONTHS[self.first]]
|
||||||
|
if self.last != self.first and self.last is not None:
|
||||||
|
args.append("'%s'" % MONTHS[self.last - 1])
|
||||||
|
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
||||||
|
|
||||||
|
|
||||||
class WeekdayRangeExpression(RangeExpression):
|
class WeekdayRangeExpression(RangeExpression):
|
||||||
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||||
|
|
||||||
@@ -117,7 +174,7 @@ class WeekdayRangeExpression(RangeExpression):
|
|||||||
else:
|
else:
|
||||||
last_num = None
|
last_num = None
|
||||||
|
|
||||||
RangeExpression.__init__(self, first_num, last_num)
|
super(WeekdayRangeExpression, self).__init__(first_num, last_num)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.last != self.first and self.last is not None:
|
if self.last != self.first and self.last is not None:
|
||||||
@@ -133,9 +190,11 @@ class WeekdayRangeExpression(RangeExpression):
|
|||||||
|
|
||||||
class WeekdayPositionExpression(AllExpression):
|
class WeekdayPositionExpression(AllExpression):
|
||||||
options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
|
options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
|
||||||
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % '|'.join(options), re.IGNORECASE)
|
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' %
|
||||||
|
'|'.join(options), re.IGNORECASE)
|
||||||
|
|
||||||
def __init__(self, option_name, weekday_name):
|
def __init__(self, option_name, weekday_name):
|
||||||
|
super(WeekdayPositionExpression, self).__init__(None)
|
||||||
try:
|
try:
|
||||||
self.option_num = self.options.index(option_name.lower())
|
self.option_num = self.options.index(option_name.lower())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -147,8 +206,7 @@ class WeekdayPositionExpression(AllExpression):
|
|||||||
raise ValueError('Invalid weekday name "%s"' % weekday_name)
|
raise ValueError('Invalid weekday name "%s"' % weekday_name)
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
# Figure out the weekday of the month's first day and the number
|
# Figure out the weekday of the month's first day and the number of days in that month
|
||||||
# of days in that month
|
|
||||||
first_day_wday, last_day = monthrange(date.year, date.month)
|
first_day_wday, last_day = monthrange(date.year, date.month)
|
||||||
|
|
||||||
# Calculate which day of the month is the first of the target weekdays
|
# Calculate which day of the month is the first of the target weekdays
|
||||||
@@ -160,23 +218,28 @@ class WeekdayPositionExpression(AllExpression):
|
|||||||
if self.option_num < 5:
|
if self.option_num < 5:
|
||||||
target_day = first_hit_day + self.option_num * 7
|
target_day = first_hit_day + self.option_num * 7
|
||||||
else:
|
else:
|
||||||
target_day = first_hit_day + ((last_day - first_hit_day) / 7) * 7
|
target_day = first_hit_day + ((last_day - first_hit_day) // 7) * 7
|
||||||
|
|
||||||
if target_day <= last_day and target_day >= date.day:
|
if target_day <= last_day and target_day >= date.day:
|
||||||
return target_day
|
return target_day
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (super(WeekdayPositionExpression, self).__eq__(other) and
|
||||||
|
self.option_num == other.option_num and self.weekday == other.weekday)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
|
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], WEEKDAYS[self.weekday])
|
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num],
|
||||||
|
WEEKDAYS[self.weekday])
|
||||||
|
|
||||||
|
|
||||||
class LastDayOfMonthExpression(AllExpression):
|
class LastDayOfMonthExpression(AllExpression):
|
||||||
value_re = re.compile(r'last', re.IGNORECASE)
|
value_re = re.compile(r'last', re.IGNORECASE)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
super(LastDayOfMonthExpression, self).__init__(None)
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
return monthrange(date.year, date.month)[1]
|
return monthrange(date.year, date.month)[1]
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
"""
|
"""Fields represent CronTrigger options which map to :class:`~datetime.datetime` fields."""
|
||||||
Fields represent CronTrigger options which map to :class:`~datetime.datetime`
|
|
||||||
fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
|
import re
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from apscheduler.triggers.cron.expressions import (
|
from apscheduler.triggers.cron.expressions import (
|
||||||
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, WeekdayRangeExpression)
|
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression,
|
||||||
|
WeekdayRangeExpression, MonthRangeExpression)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField', 'DayOfMonthField', 'DayOfWeekField')
|
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField',
|
||||||
|
'DayOfMonthField', 'DayOfWeekField')
|
||||||
|
|
||||||
|
|
||||||
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0, 'minute': 0, 'second': 0}
|
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0,
|
||||||
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59,
|
'minute': 0, 'second': 0}
|
||||||
'second': 59}
|
MAX_VALUES = {'year': 9999, 'month': 12, 'day': 31, 'week': 53, 'day_of_week': 6, 'hour': 23,
|
||||||
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0,
|
'minute': 59, 'second': 59}
|
||||||
'second': 0}
|
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0,
|
||||||
|
'minute': 0, 'second': 0}
|
||||||
|
SEPARATOR = re.compile(' *, *')
|
||||||
|
|
||||||
|
|
||||||
class BaseField(object):
|
class BaseField(object):
|
||||||
@@ -50,23 +54,29 @@ class BaseField(object):
|
|||||||
self.expressions = []
|
self.expressions = []
|
||||||
|
|
||||||
# Split a comma-separated expression list, if any
|
# Split a comma-separated expression list, if any
|
||||||
exprs = str(exprs).strip()
|
for expr in SEPARATOR.split(str(exprs).strip()):
|
||||||
if ',' in exprs:
|
self.compile_expression(expr)
|
||||||
for expr in exprs.split(','):
|
|
||||||
self.compile_expression(expr)
|
|
||||||
else:
|
|
||||||
self.compile_expression(exprs)
|
|
||||||
|
|
||||||
def compile_expression(self, expr):
|
def compile_expression(self, expr):
|
||||||
for compiler in self.COMPILERS:
|
for compiler in self.COMPILERS:
|
||||||
match = compiler.value_re.match(expr)
|
match = compiler.value_re.match(expr)
|
||||||
if match:
|
if match:
|
||||||
compiled_expr = compiler(**match.groupdict())
|
compiled_expr = compiler(**match.groupdict())
|
||||||
|
|
||||||
|
try:
|
||||||
|
compiled_expr.validate_range(self.name)
|
||||||
|
except ValueError as e:
|
||||||
|
exc = ValueError('Error validating expression {!r}: {}'.format(expr, e))
|
||||||
|
six.raise_from(exc, None)
|
||||||
|
|
||||||
self.expressions.append(compiled_expr)
|
self.expressions.append(compiled_expr)
|
||||||
return
|
return
|
||||||
|
|
||||||
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
|
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return isinstance(self, self.__class__) and self.expressions == other.expressions
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
expr_strings = (str(e) for e in self.expressions)
|
expr_strings = (str(e) for e in self.expressions)
|
||||||
return ','.join(expr_strings)
|
return ','.join(expr_strings)
|
||||||
@@ -95,3 +105,7 @@ class DayOfWeekField(BaseField):
|
|||||||
|
|
||||||
def get_value(self, dateval):
|
def get_value(self, dateval):
|
||||||
return dateval.weekday()
|
return dateval.weekday()
|
||||||
|
|
||||||
|
|
||||||
|
class MonthField(BaseField):
|
||||||
|
COMPILERS = BaseField.COMPILERS + [MonthRangeExpression]
|
||||||
|
|||||||
@@ -14,15 +14,36 @@ class DateTrigger(BaseTrigger):
|
|||||||
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
|
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = 'timezone', 'run_date'
|
__slots__ = 'run_date'
|
||||||
|
|
||||||
def __init__(self, run_date=None, timezone=None):
|
def __init__(self, run_date=None, timezone=None):
|
||||||
timezone = astimezone(timezone) or get_localzone()
|
timezone = astimezone(timezone) or get_localzone()
|
||||||
self.run_date = convert_to_datetime(run_date or datetime.now(), timezone, 'run_date')
|
if run_date is not None:
|
||||||
|
self.run_date = convert_to_datetime(run_date, timezone, 'run_date')
|
||||||
|
else:
|
||||||
|
self.run_date = datetime.now(timezone)
|
||||||
|
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
return self.run_date if previous_fire_time is None else None
|
return self.run_date if previous_fire_time is None else None
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
return {
|
||||||
|
'version': 1,
|
||||||
|
'run_date': self.run_date
|
||||||
|
}
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
# This is for compatibility with APScheduler 3.0.x
|
||||||
|
if isinstance(state, tuple):
|
||||||
|
state = state[1]
|
||||||
|
|
||||||
|
if state.get('version', 1) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
'Got serialized data for version %s of %s, but only version 1 can be handled' %
|
||||||
|
(state['version'], self.__class__.__name__))
|
||||||
|
|
||||||
|
self.run_date = state['run_date']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'date[%s]' % datetime_repr(self.run_date)
|
return 'date[%s]' % datetime_repr(self.run_date)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ from math import ceil
|
|||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone
|
||||||
|
|
||||||
from apscheduler.triggers.base import BaseTrigger
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_repr, astimezone
|
from apscheduler.util import (
|
||||||
|
convert_to_datetime, normalize, timedelta_seconds, datetime_repr,
|
||||||
|
astimezone)
|
||||||
|
|
||||||
|
|
||||||
class IntervalTrigger(BaseTrigger):
|
class IntervalTrigger(BaseTrigger):
|
||||||
"""
|
"""
|
||||||
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval
|
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` +
|
||||||
otherwise.
|
interval otherwise.
|
||||||
|
|
||||||
:param int weeks: number of weeks to wait
|
:param int weeks: number of weeks to wait
|
||||||
:param int days: number of days to wait
|
:param int days: number of days to wait
|
||||||
@@ -20,12 +22,15 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
:param datetime|str start_date: starting point for the interval calculation
|
:param datetime|str start_date: starting point for the interval calculation
|
||||||
:param datetime|str end_date: latest possible date/time to trigger on
|
:param datetime|str end_date: latest possible date/time to trigger on
|
||||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
||||||
|
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = 'timezone', 'start_date', 'end_date', 'interval'
|
__slots__ = 'timezone', 'start_date', 'end_date', 'interval', 'interval_length', 'jitter'
|
||||||
|
|
||||||
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, end_date=None, timezone=None):
|
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None,
|
||||||
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds)
|
end_date=None, timezone=None, jitter=None):
|
||||||
|
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes,
|
||||||
|
seconds=seconds)
|
||||||
self.interval_length = timedelta_seconds(self.interval)
|
self.interval_length = timedelta_seconds(self.interval)
|
||||||
if self.interval_length == 0:
|
if self.interval_length == 0:
|
||||||
self.interval = timedelta(seconds=1)
|
self.interval = timedelta(seconds=1)
|
||||||
@@ -33,9 +38,9 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
|
|
||||||
if timezone:
|
if timezone:
|
||||||
self.timezone = astimezone(timezone)
|
self.timezone = astimezone(timezone)
|
||||||
elif start_date and start_date.tzinfo:
|
elif isinstance(start_date, datetime) and start_date.tzinfo:
|
||||||
self.timezone = start_date.tzinfo
|
self.timezone = start_date.tzinfo
|
||||||
elif end_date and end_date.tzinfo:
|
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||||
self.timezone = end_date.tzinfo
|
self.timezone = end_date.tzinfo
|
||||||
else:
|
else:
|
||||||
self.timezone = get_localzone()
|
self.timezone = get_localzone()
|
||||||
@@ -44,6 +49,8 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||||
|
|
||||||
|
self.jitter = jitter
|
||||||
|
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
if previous_fire_time:
|
if previous_fire_time:
|
||||||
next_fire_time = previous_fire_time + self.interval
|
next_fire_time = previous_fire_time + self.interval
|
||||||
@@ -54,12 +61,48 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
|
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
|
||||||
next_fire_time = self.start_date + self.interval * next_interval_num
|
next_fire_time = self.start_date + self.interval * next_interval_num
|
||||||
|
|
||||||
|
if self.jitter is not None:
|
||||||
|
next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now)
|
||||||
|
|
||||||
if not self.end_date or next_fire_time <= self.end_date:
|
if not self.end_date or next_fire_time <= self.end_date:
|
||||||
return self.timezone.normalize(next_fire_time)
|
return normalize(next_fire_time)
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
return {
|
||||||
|
'version': 2,
|
||||||
|
'timezone': self.timezone,
|
||||||
|
'start_date': self.start_date,
|
||||||
|
'end_date': self.end_date,
|
||||||
|
'interval': self.interval,
|
||||||
|
'jitter': self.jitter,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
# This is for compatibility with APScheduler 3.0.x
|
||||||
|
if isinstance(state, tuple):
|
||||||
|
state = state[1]
|
||||||
|
|
||||||
|
if state.get('version', 1) > 2:
|
||||||
|
raise ValueError(
|
||||||
|
'Got serialized data for version %s of %s, but only versions up to 2 can be '
|
||||||
|
'handled' % (state['version'], self.__class__.__name__))
|
||||||
|
|
||||||
|
self.timezone = state['timezone']
|
||||||
|
self.start_date = state['start_date']
|
||||||
|
self.end_date = state['end_date']
|
||||||
|
self.interval = state['interval']
|
||||||
|
self.interval_length = timedelta_seconds(self.interval)
|
||||||
|
self.jitter = state.get('jitter')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'interval[%s]' % str(self.interval)
|
return 'interval[%s]' % str(self.interval)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval,
|
options = ['interval=%r' % self.interval, 'start_date=%r' % datetime_repr(self.start_date)]
|
||||||
datetime_repr(self.start_date))
|
if self.end_date:
|
||||||
|
options.append("end_date=%r" % datetime_repr(self.end_date))
|
||||||
|
if self.jitter:
|
||||||
|
options.append('jitter=%s' % self.jitter)
|
||||||
|
|
||||||
|
return "<%s (%s, timezone='%s')>" % (
|
||||||
|
self.__class__.__name__, ', '.join(options), self.timezone)
|
||||||
|
|||||||
+156
-111
@@ -1,29 +1,36 @@
|
|||||||
"""This module contains several handy functions primarily meant for internal use."""
|
"""This module contains several handy functions primarily meant for internal use."""
|
||||||
|
|
||||||
|
from __future__ import division
|
||||||
|
|
||||||
|
from asyncio import iscoroutinefunction
|
||||||
from datetime import date, datetime, time, timedelta, tzinfo
|
from datetime import date, datetime, time, timedelta, tzinfo
|
||||||
from inspect import isfunction, ismethod, getargspec
|
|
||||||
from calendar import timegm
|
from calendar import timegm
|
||||||
|
from functools import partial
|
||||||
|
from inspect import isclass, ismethod
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
from pytz import timezone, utc
|
from pytz import timezone, utc, FixedOffset
|
||||||
import six
|
import six
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from inspect import signature
|
from inspect import signature
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
try:
|
from funcsigs import signature
|
||||||
from funcsigs import signature
|
|
||||||
except ImportError:
|
try:
|
||||||
signature = None
|
from threading import TIMEOUT_MAX
|
||||||
|
except ImportError:
|
||||||
|
TIMEOUT_MAX = 4294967 # Maximum value accepted by Event.wait() on Windows
|
||||||
|
|
||||||
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
|
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
|
||||||
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'obj_to_ref',
|
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name',
|
||||||
'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
|
'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args',
|
||||||
|
'normalize', 'localize', 'TIMEOUT_MAX')
|
||||||
|
|
||||||
|
|
||||||
class _Undefined(object):
|
class _Undefined(object):
|
||||||
def __bool__(self):
|
def __nonzero__(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
@@ -32,17 +39,18 @@ class _Undefined(object):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<undefined>'
|
return '<undefined>'
|
||||||
|
|
||||||
|
|
||||||
undefined = _Undefined() #: a unique object that only signifies that no value is defined
|
undefined = _Undefined() #: a unique object that only signifies that no value is defined
|
||||||
|
|
||||||
|
|
||||||
def asint(text):
|
def asint(text):
|
||||||
"""
|
"""
|
||||||
Safely converts a string to an integer, returning None if the string is None.
|
Safely converts a string to an integer, returning ``None`` if the string is ``None``.
|
||||||
|
|
||||||
:type text: str
|
:type text: str
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if text is not None:
|
if text is not None:
|
||||||
return int(text)
|
return int(text)
|
||||||
|
|
||||||
@@ -52,8 +60,8 @@ def asbool(obj):
|
|||||||
Interprets an object as a boolean value.
|
Interprets an object as a boolean value.
|
||||||
|
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
obj = obj.strip().lower()
|
obj = obj.strip().lower()
|
||||||
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
|
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
|
||||||
@@ -69,15 +77,17 @@ def astimezone(obj):
|
|||||||
Interprets an object as a timezone.
|
Interprets an object as a timezone.
|
||||||
|
|
||||||
:rtype: tzinfo
|
:rtype: tzinfo
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if isinstance(obj, six.string_types):
|
if isinstance(obj, six.string_types):
|
||||||
return timezone(obj)
|
return timezone(obj)
|
||||||
if isinstance(obj, tzinfo):
|
if isinstance(obj, tzinfo):
|
||||||
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
|
if obj.tzname(None) == 'local':
|
||||||
raise TypeError('Only timezones from the pytz library are supported')
|
raise ValueError(
|
||||||
if obj.zone == 'local':
|
'Unable to determine the name of the local timezone -- you must explicitly '
|
||||||
raise ValueError('Unable to determine the name of the local timezone -- use an explicit timezone instead')
|
'specify the name of the local timezone. Please refrain from using timezones like '
|
||||||
|
'EST to prevent problems with daylight saving time. Instead, use a locale based '
|
||||||
|
'timezone name (such as Europe/Helsinki).')
|
||||||
return obj
|
return obj
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
|
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
|
||||||
@@ -85,27 +95,30 @@ def astimezone(obj):
|
|||||||
|
|
||||||
_DATE_REGEX = re.compile(
|
_DATE_REGEX = re.compile(
|
||||||
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
|
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
|
||||||
r'(?: (?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\d{1,2})'
|
r'(?:[ T](?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\d{1,2})'
|
||||||
r'(?:\.(?P<microsecond>\d{1,6}))?)?')
|
r'(?:\.(?P<microsecond>\d{1,6}))?'
|
||||||
|
r'(?P<timezone>Z|[+-]\d\d:\d\d)?)?$')
|
||||||
|
|
||||||
|
|
||||||
def convert_to_datetime(input, tz, arg_name):
|
def convert_to_datetime(input, tz, arg_name):
|
||||||
"""
|
"""
|
||||||
Converts the given object to a timezone aware datetime object.
|
Converts the given object to a timezone aware datetime object.
|
||||||
|
|
||||||
If a timezone aware datetime object is passed, it is returned unmodified.
|
If a timezone aware datetime object is passed, it is returned unmodified.
|
||||||
If a native datetime object is passed, it is given the specified timezone.
|
If a native datetime object is passed, it is given the specified timezone.
|
||||||
If the input is a string, it is parsed as a datetime with the given timezone.
|
If the input is a string, it is parsed as a datetime with the given timezone.
|
||||||
|
|
||||||
Date strings are accepted in three different forms: date only (Y-m-d),
|
Date strings are accepted in three different forms: date only (Y-m-d), date with time
|
||||||
date with time (Y-m-d H:M:S) or with date+time with microseconds
|
(Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro). Additionally you can
|
||||||
(Y-m-d H:M:S.micro).
|
override the time zone by giving a specific offset in the format specified by ISO 8601:
|
||||||
|
Z (UTC), +HH:MM or -HH:MM.
|
||||||
|
|
||||||
:param str|datetime input: the datetime or string to convert to a timezone aware datetime
|
:param str|datetime input: the datetime or string to convert to a timezone aware datetime
|
||||||
:param datetime.tzinfo tz: timezone to interpret ``input`` in
|
:param datetime.tzinfo tz: timezone to interpret ``input`` in
|
||||||
:param str arg_name: the name of the argument (used in an error message)
|
:param str arg_name: the name of the argument (used in an error message)
|
||||||
:rtype: datetime
|
:rtype: datetime
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if input is None:
|
if input is None:
|
||||||
return
|
return
|
||||||
elif isinstance(input, datetime):
|
elif isinstance(input, datetime):
|
||||||
@@ -116,8 +129,17 @@ def convert_to_datetime(input, tz, arg_name):
|
|||||||
m = _DATE_REGEX.match(input)
|
m = _DATE_REGEX.match(input)
|
||||||
if not m:
|
if not m:
|
||||||
raise ValueError('Invalid date string')
|
raise ValueError('Invalid date string')
|
||||||
values = [(k, int(v or 0)) for k, v in list(m.groupdict().items())]
|
|
||||||
values = dict(values)
|
values = m.groupdict()
|
||||||
|
tzname = values.pop('timezone')
|
||||||
|
if tzname == 'Z':
|
||||||
|
tz = utc
|
||||||
|
elif tzname:
|
||||||
|
hours, minutes = (int(x) for x in tzname[1:].split(':'))
|
||||||
|
sign = 1 if tzname[0] == '+' else -1
|
||||||
|
tz = FixedOffset(sign * (hours * 60 + minutes))
|
||||||
|
|
||||||
|
values = {k: int(v or 0) for k, v in values.items()}
|
||||||
datetime_ = datetime(**values)
|
datetime_ = datetime(**values)
|
||||||
else:
|
else:
|
||||||
raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__))
|
raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__))
|
||||||
@@ -125,14 +147,12 @@ def convert_to_datetime(input, tz, arg_name):
|
|||||||
if datetime_.tzinfo is not None:
|
if datetime_.tzinfo is not None:
|
||||||
return datetime_
|
return datetime_
|
||||||
if tz is None:
|
if tz is None:
|
||||||
raise ValueError('The "tz" argument must be specified if %s has no timezone information' % arg_name)
|
raise ValueError(
|
||||||
|
'The "tz" argument must be specified if %s has no timezone information' % arg_name)
|
||||||
if isinstance(tz, six.string_types):
|
if isinstance(tz, six.string_types):
|
||||||
tz = timezone(tz)
|
tz = timezone(tz)
|
||||||
|
|
||||||
try:
|
return localize(datetime_, tz)
|
||||||
return tz.localize(datetime_, is_dst=None)
|
|
||||||
except AttributeError:
|
|
||||||
raise TypeError('Only pytz timezones are supported (need the localize() and normalize() methods)')
|
|
||||||
|
|
||||||
|
|
||||||
def datetime_to_utc_timestamp(timeval):
|
def datetime_to_utc_timestamp(timeval):
|
||||||
@@ -141,8 +161,8 @@ def datetime_to_utc_timestamp(timeval):
|
|||||||
|
|
||||||
:type timeval: datetime
|
:type timeval: datetime
|
||||||
:rtype: float
|
:rtype: float
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if timeval is not None:
|
if timeval is not None:
|
||||||
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
|
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
|
||||||
|
|
||||||
@@ -153,8 +173,8 @@ def utc_timestamp_to_datetime(timestamp):
|
|||||||
|
|
||||||
:type timestamp: float
|
:type timestamp: float
|
||||||
:rtype: datetime
|
:rtype: datetime
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if timestamp is not None:
|
if timestamp is not None:
|
||||||
return datetime.fromtimestamp(timestamp, utc)
|
return datetime.fromtimestamp(timestamp, utc)
|
||||||
|
|
||||||
@@ -165,8 +185,8 @@ def timedelta_seconds(delta):
|
|||||||
|
|
||||||
:type delta: timedelta
|
:type delta: timedelta
|
||||||
:rtype: float
|
:rtype: float
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
return delta.days * 24 * 60 * 60 + delta.seconds + \
|
return delta.days * 24 * 60 * 60 + delta.seconds + \
|
||||||
delta.microseconds / 1000000.0
|
delta.microseconds / 1000000.0
|
||||||
|
|
||||||
@@ -176,8 +196,8 @@ def datetime_ceil(dateval):
|
|||||||
Rounds the given datetime object upwards.
|
Rounds the given datetime object upwards.
|
||||||
|
|
||||||
:type dateval: datetime
|
:type dateval: datetime
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if dateval.microsecond > 0:
|
if dateval.microsecond > 0:
|
||||||
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
|
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
|
||||||
return dateval
|
return dateval
|
||||||
@@ -192,8 +212,8 @@ def get_callable_name(func):
|
|||||||
Returns the best available display name for the given function/callable.
|
Returns the best available display name for the given function/callable.
|
||||||
|
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
# the easy case (on Python 3.3+)
|
# the easy case (on Python 3.3+)
|
||||||
if hasattr(func, '__qualname__'):
|
if hasattr(func, '__qualname__'):
|
||||||
return func.__qualname__
|
return func.__qualname__
|
||||||
@@ -201,7 +221,7 @@ def get_callable_name(func):
|
|||||||
# class methods, bound and unbound methods
|
# class methods, bound and unbound methods
|
||||||
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
|
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
|
||||||
if f_self and hasattr(func, '__name__'):
|
if f_self and hasattr(func, '__name__'):
|
||||||
f_class = f_self if isinstance(f_self, type) else f_self.__class__
|
f_class = f_self if isclass(f_self) else f_self.__class__
|
||||||
else:
|
else:
|
||||||
f_class = getattr(func, 'im_class', None)
|
f_class = getattr(func, 'im_class', None)
|
||||||
|
|
||||||
@@ -222,20 +242,35 @@ def get_callable_name(func):
|
|||||||
|
|
||||||
def obj_to_ref(obj):
|
def obj_to_ref(obj):
|
||||||
"""
|
"""
|
||||||
Returns the path to the given object.
|
Returns the path to the given callable.
|
||||||
|
|
||||||
:rtype: str
|
:rtype: str
|
||||||
|
:raises TypeError: if the given object is not callable
|
||||||
|
:raises ValueError: if the given object is a :class:`~functools.partial`, lambda or a nested
|
||||||
|
function
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
if isinstance(obj, partial):
|
||||||
|
raise ValueError('Cannot create a reference to a partial()')
|
||||||
|
|
||||||
try:
|
name = get_callable_name(obj)
|
||||||
ref = '%s:%s' % (obj.__module__, get_callable_name(obj))
|
if '<lambda>' in name:
|
||||||
obj2 = ref_to_obj(ref)
|
raise ValueError('Cannot create a reference to a lambda')
|
||||||
if obj != obj2:
|
if '<locals>' in name:
|
||||||
raise ValueError
|
raise ValueError('Cannot create a reference to a nested function')
|
||||||
except Exception:
|
|
||||||
raise ValueError('Cannot determine the reference to %r' % obj)
|
|
||||||
|
|
||||||
return ref
|
if ismethod(obj):
|
||||||
|
if hasattr(obj, 'im_self') and obj.im_self:
|
||||||
|
# bound method
|
||||||
|
module = obj.im_self.__module__
|
||||||
|
elif hasattr(obj, 'im_class') and obj.im_class:
|
||||||
|
# unbound method
|
||||||
|
module = obj.im_class.__module__
|
||||||
|
else:
|
||||||
|
module = obj.__module__
|
||||||
|
else:
|
||||||
|
module = obj.__module__
|
||||||
|
return '%s:%s' % (module, name)
|
||||||
|
|
||||||
|
|
||||||
def ref_to_obj(ref):
|
def ref_to_obj(ref):
|
||||||
@@ -243,8 +278,8 @@ def ref_to_obj(ref):
|
|||||||
Returns the object pointed to by ``ref``.
|
Returns the object pointed to by ``ref``.
|
||||||
|
|
||||||
:type ref: str
|
:type ref: str
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if not isinstance(ref, six.string_types):
|
if not isinstance(ref, six.string_types):
|
||||||
raise TypeError('References must be strings')
|
raise TypeError('References must be strings')
|
||||||
if ':' not in ref:
|
if ':' not in ref:
|
||||||
@@ -252,12 +287,12 @@ def ref_to_obj(ref):
|
|||||||
|
|
||||||
modulename, rest = ref.split(':', 1)
|
modulename, rest = ref.split(':', 1)
|
||||||
try:
|
try:
|
||||||
obj = __import__(modulename)
|
obj = __import__(modulename, fromlist=[rest])
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise LookupError('Error resolving reference %s: could not import module' % ref)
|
raise LookupError('Error resolving reference %s: could not import module' % ref)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for name in modulename.split('.')[1:] + rest.split('.'):
|
for name in rest.split('.'):
|
||||||
obj = getattr(obj, name)
|
obj = getattr(obj, name)
|
||||||
return obj
|
return obj
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -268,8 +303,8 @@ def maybe_ref(ref):
|
|||||||
"""
|
"""
|
||||||
Returns the object that the given reference points to, if it is indeed a reference.
|
Returns the object that the given reference points to, if it is indeed a reference.
|
||||||
If it is not a reference, the object is returned as-is.
|
If it is not a reference, the object is returned as-is.
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if not isinstance(ref, str):
|
if not isinstance(ref, str):
|
||||||
return ref
|
return ref
|
||||||
return ref_to_obj(ref)
|
return ref_to_obj(ref)
|
||||||
@@ -281,7 +316,8 @@ if six.PY2:
|
|||||||
return string.encode('ascii', 'backslashreplace')
|
return string.encode('ascii', 'backslashreplace')
|
||||||
return string
|
return string
|
||||||
else:
|
else:
|
||||||
repr_escape = lambda string: string
|
def repr_escape(string):
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
def check_callable_args(func, args, kwargs):
|
def check_callable_args(func, args, kwargs):
|
||||||
@@ -290,70 +326,54 @@ def check_callable_args(func, args, kwargs):
|
|||||||
|
|
||||||
:type args: tuple
|
:type args: tuple
|
||||||
:type kwargs: dict
|
:type kwargs: dict
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
|
pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
|
||||||
positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
|
positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
|
||||||
unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
|
unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
|
||||||
unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
|
unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
|
||||||
unmatched_args = list(args) # args that didn't match any of the parameters in the signature
|
unmatched_args = list(args) # args that didn't match any of the parameters in the signature
|
||||||
unmatched_kwargs = list(kwargs) # kwargs that didn't match any of the parameters in the signature
|
# kwargs that didn't match any of the parameters in the signature
|
||||||
has_varargs = has_var_kwargs = False # indicates if the signature defines *args and **kwargs respectively
|
unmatched_kwargs = list(kwargs)
|
||||||
|
# indicates if the signature defines *args and **kwargs respectively
|
||||||
|
has_varargs = has_var_kwargs = False
|
||||||
|
|
||||||
if signature:
|
try:
|
||||||
try:
|
if sys.version_info >= (3, 5):
|
||||||
|
sig = signature(func, follow_wrapped=False)
|
||||||
|
else:
|
||||||
sig = signature(func)
|
sig = signature(func)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return # signature() doesn't work against every kind of callable
|
# signature() doesn't work against every kind of callable
|
||||||
|
return
|
||||||
|
|
||||||
for param in six.itervalues(sig.parameters):
|
for param in six.itervalues(sig.parameters):
|
||||||
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
||||||
if param.name in unmatched_kwargs and unmatched_args:
|
if param.name in unmatched_kwargs and unmatched_args:
|
||||||
pos_kwargs_conflicts.append(param.name)
|
pos_kwargs_conflicts.append(param.name)
|
||||||
elif unmatched_args:
|
|
||||||
del unmatched_args[0]
|
|
||||||
elif param.name in unmatched_kwargs:
|
|
||||||
unmatched_kwargs.remove(param.name)
|
|
||||||
elif param.default is param.empty:
|
|
||||||
unsatisfied_args.append(param.name)
|
|
||||||
elif param.kind == param.POSITIONAL_ONLY:
|
|
||||||
if unmatched_args:
|
|
||||||
del unmatched_args[0]
|
|
||||||
elif param.name in unmatched_kwargs:
|
|
||||||
unmatched_kwargs.remove(param.name)
|
|
||||||
positional_only_kwargs.append(param.name)
|
|
||||||
elif param.default is param.empty:
|
|
||||||
unsatisfied_args.append(param.name)
|
|
||||||
elif param.kind == param.KEYWORD_ONLY:
|
|
||||||
if param.name in unmatched_kwargs:
|
|
||||||
unmatched_kwargs.remove(param.name)
|
|
||||||
elif param.default is param.empty:
|
|
||||||
unsatisfied_kwargs.append(param.name)
|
|
||||||
elif param.kind == param.VAR_POSITIONAL:
|
|
||||||
has_varargs = True
|
|
||||||
elif param.kind == param.VAR_KEYWORD:
|
|
||||||
has_var_kwargs = True
|
|
||||||
else:
|
|
||||||
if not isfunction(func) and not ismethod(func) and hasattr(func, '__call__'):
|
|
||||||
func = func.__call__
|
|
||||||
|
|
||||||
try:
|
|
||||||
argspec = getargspec(func)
|
|
||||||
except TypeError:
|
|
||||||
return # getargspec() doesn't work certain callables
|
|
||||||
|
|
||||||
argspec_args = argspec.args if not ismethod(func) else argspec.args[1:]
|
|
||||||
has_varargs = bool(argspec.varargs)
|
|
||||||
has_var_kwargs = bool(argspec.keywords)
|
|
||||||
for arg, default in six.moves.zip_longest(argspec_args, argspec.defaults or (), fillvalue=undefined):
|
|
||||||
if arg in unmatched_kwargs and unmatched_args:
|
|
||||||
pos_kwargs_conflicts.append(arg)
|
|
||||||
elif unmatched_args:
|
elif unmatched_args:
|
||||||
del unmatched_args[0]
|
del unmatched_args[0]
|
||||||
elif arg in unmatched_kwargs:
|
elif param.name in unmatched_kwargs:
|
||||||
unmatched_kwargs.remove(arg)
|
unmatched_kwargs.remove(param.name)
|
||||||
elif default is undefined:
|
elif param.default is param.empty:
|
||||||
unsatisfied_args.append(arg)
|
unsatisfied_args.append(param.name)
|
||||||
|
elif param.kind == param.POSITIONAL_ONLY:
|
||||||
|
if unmatched_args:
|
||||||
|
del unmatched_args[0]
|
||||||
|
elif param.name in unmatched_kwargs:
|
||||||
|
unmatched_kwargs.remove(param.name)
|
||||||
|
positional_only_kwargs.append(param.name)
|
||||||
|
elif param.default is param.empty:
|
||||||
|
unsatisfied_args.append(param.name)
|
||||||
|
elif param.kind == param.KEYWORD_ONLY:
|
||||||
|
if param.name in unmatched_kwargs:
|
||||||
|
unmatched_kwargs.remove(param.name)
|
||||||
|
elif param.default is param.empty:
|
||||||
|
unsatisfied_kwargs.append(param.name)
|
||||||
|
elif param.kind == param.VAR_POSITIONAL:
|
||||||
|
has_varargs = True
|
||||||
|
elif param.kind == param.VAR_KEYWORD:
|
||||||
|
has_var_kwargs = True
|
||||||
|
|
||||||
# Make sure there are no conflicts between args and kwargs
|
# Make sure there are no conflicts between args and kwargs
|
||||||
if pos_kwargs_conflicts:
|
if pos_kwargs_conflicts:
|
||||||
@@ -365,21 +385,46 @@ def check_callable_args(func, args, kwargs):
|
|||||||
raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
|
raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
|
||||||
', '.join(positional_only_kwargs))
|
', '.join(positional_only_kwargs))
|
||||||
|
|
||||||
# Check that the number of positional arguments minus the number of matched kwargs matches the argspec
|
# Check that the number of positional arguments minus the number of matched kwargs matches the
|
||||||
|
# argspec
|
||||||
if unsatisfied_args:
|
if unsatisfied_args:
|
||||||
raise ValueError('The following arguments have not been supplied: %s' % ', '.join(unsatisfied_args))
|
raise ValueError('The following arguments have not been supplied: %s' %
|
||||||
|
', '.join(unsatisfied_args))
|
||||||
|
|
||||||
# Check that all keyword-only arguments have been supplied
|
# Check that all keyword-only arguments have been supplied
|
||||||
if unsatisfied_kwargs:
|
if unsatisfied_kwargs:
|
||||||
raise ValueError('The following keyword-only arguments have not been supplied in kwargs: %s' %
|
raise ValueError(
|
||||||
', '.join(unsatisfied_kwargs))
|
'The following keyword-only arguments have not been supplied in kwargs: %s' %
|
||||||
|
', '.join(unsatisfied_kwargs))
|
||||||
|
|
||||||
# Check that the callable can accept the given number of positional arguments
|
# Check that the callable can accept the given number of positional arguments
|
||||||
if not has_varargs and unmatched_args:
|
if not has_varargs and unmatched_args:
|
||||||
raise ValueError('The list of positional arguments is longer than the target callable can handle '
|
raise ValueError(
|
||||||
'(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args)))
|
'The list of positional arguments is longer than the target callable can handle '
|
||||||
|
'(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args)))
|
||||||
|
|
||||||
# Check that the callable can accept the given keyword arguments
|
# Check that the callable can accept the given keyword arguments
|
||||||
if not has_var_kwargs and unmatched_kwargs:
|
if not has_var_kwargs and unmatched_kwargs:
|
||||||
raise ValueError('The target callable does not accept the following keyword arguments: %s' %
|
raise ValueError(
|
||||||
', '.join(unmatched_kwargs))
|
'The target callable does not accept the following keyword arguments: %s' %
|
||||||
|
', '.join(unmatched_kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def iscoroutinefunction_partial(f):
|
||||||
|
while isinstance(f, partial):
|
||||||
|
f = f.func
|
||||||
|
|
||||||
|
# The asyncio version of iscoroutinefunction includes testing for @coroutine
|
||||||
|
# decorations vs. the inspect version which does not.
|
||||||
|
return iscoroutinefunction(f)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(dt):
|
||||||
|
return datetime.fromtimestamp(dt.timestamp(), dt.tzinfo)
|
||||||
|
|
||||||
|
|
||||||
|
def localize(dt, tzinfo):
|
||||||
|
if hasattr(tzinfo, 'localize'):
|
||||||
|
return tzinfo.localize(dt)
|
||||||
|
|
||||||
|
return normalize(dt.replace(tzinfo=tzinfo))
|
||||||
|
|||||||
+23
-17
@@ -57,9 +57,11 @@ These API's are described in the `CherryPy specification
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import importlib.metadata as importlib_metadata
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
# fall back for python <= 3.7
|
||||||
|
# This try/except can be removed with py <= 3.7 support
|
||||||
|
import importlib_metadata
|
||||||
|
|
||||||
from threading import local as _local
|
from threading import local as _local
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ tree = _cptree.Tree()
|
|||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = pkg_resources.require('cherrypy')[0].version
|
__version__ = importlib_metadata.version('cherrypy')
|
||||||
except Exception:
|
except Exception:
|
||||||
__version__ = 'unknown'
|
__version__ = 'unknown'
|
||||||
|
|
||||||
@@ -181,24 +183,28 @@ def quickstart(root=None, script_name='', config=None):
|
|||||||
class _Serving(_local):
|
class _Serving(_local):
|
||||||
"""An interface for registering request and response objects.
|
"""An interface for registering request and response objects.
|
||||||
|
|
||||||
Rather than have a separate "thread local" object for the request and
|
Rather than have a separate "thread local" object for the request
|
||||||
the response, this class works as a single threadlocal container for
|
and the response, this class works as a single threadlocal container
|
||||||
both objects (and any others which developers wish to define). In this
|
for both objects (and any others which developers wish to define).
|
||||||
way, we can easily dump those objects when we stop/start a new HTTP
|
In this way, we can easily dump those objects when we stop/start a
|
||||||
conversation, yet still refer to them as module-level globals in a
|
new HTTP conversation, yet still refer to them as module-level
|
||||||
thread-safe way.
|
globals in a thread-safe way.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
request = _cprequest.Request(_httputil.Host('127.0.0.1', 80),
|
request = _cprequest.Request(_httputil.Host('127.0.0.1', 80),
|
||||||
_httputil.Host('127.0.0.1', 1111))
|
_httputil.Host('127.0.0.1', 1111))
|
||||||
|
"""The request object for the current thread.
|
||||||
|
|
||||||
|
In the main thread, and any threads which are not receiving HTTP
|
||||||
|
requests, this is None.
|
||||||
"""
|
"""
|
||||||
The request object for the current thread. In the main thread,
|
|
||||||
and any threads which are not receiving HTTP requests, this is None."""
|
|
||||||
|
|
||||||
response = _cprequest.Response()
|
response = _cprequest.Response()
|
||||||
|
"""The response object for the current thread.
|
||||||
|
|
||||||
|
In the main thread, and any threads which are not receiving HTTP
|
||||||
|
requests, this is None.
|
||||||
"""
|
"""
|
||||||
The response object for the current thread. In the main thread,
|
|
||||||
and any threads which are not receiving HTTP requests, this is None."""
|
|
||||||
|
|
||||||
def load(self, request, response):
|
def load(self, request, response):
|
||||||
self.request = request
|
self.request = request
|
||||||
@@ -316,8 +322,8 @@ class _GlobalLogManager(_cplogging.LogManager):
|
|||||||
def __call__(self, *args, **kwargs):
|
def __call__(self, *args, **kwargs):
|
||||||
"""Log the given message to the app.log or global log.
|
"""Log the given message to the app.log or global log.
|
||||||
|
|
||||||
Log the given message to the app.log or global
|
Log the given message to the app.log or global log as
|
||||||
log as appropriate.
|
appropriate.
|
||||||
"""
|
"""
|
||||||
# Do NOT use try/except here. See
|
# Do NOT use try/except here. See
|
||||||
# https://github.com/cherrypy/cherrypy/issues/945
|
# https://github.com/cherrypy/cherrypy/issues/945
|
||||||
@@ -330,8 +336,8 @@ class _GlobalLogManager(_cplogging.LogManager):
|
|||||||
def access(self):
|
def access(self):
|
||||||
"""Log an access message to the app.log or global log.
|
"""Log an access message to the app.log or global log.
|
||||||
|
|
||||||
Log the given message to the app.log or global
|
Log the given message to the app.log or global log as
|
||||||
log as appropriate.
|
appropriate.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return request.app.log.access()
|
return request.app.log.access()
|
||||||
|
|||||||
@@ -313,7 +313,10 @@ class Checker(object):
|
|||||||
|
|
||||||
# -------------------- Specific config warnings -------------------- #
|
# -------------------- Specific config warnings -------------------- #
|
||||||
def check_localhost(self):
|
def check_localhost(self):
|
||||||
"""Warn if any socket_host is 'localhost'. See #711."""
|
"""Warn if any socket_host is 'localhost'.
|
||||||
|
|
||||||
|
See #711.
|
||||||
|
"""
|
||||||
for k, v in cherrypy.config.items():
|
for k, v in cherrypy.config.items():
|
||||||
if k == 'server.socket_host' and v == 'localhost':
|
if k == 'server.socket_host' and v == 'localhost':
|
||||||
warnings.warn("The use of 'localhost' as a socket host can "
|
warnings.warn("The use of 'localhost' as a socket host can "
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""
|
"""Configuration system for CherryPy.
|
||||||
Configuration system for CherryPy.
|
|
||||||
|
|
||||||
Configuration in CherryPy is implemented via dictionaries. Keys are strings
|
Configuration in CherryPy is implemented via dictionaries. Keys are strings
|
||||||
which name the mapped value, which may be of any type.
|
which name the mapped value, which may be of any type.
|
||||||
@@ -132,8 +131,8 @@ def _if_filename_register_autoreload(ob):
|
|||||||
def merge(base, other):
|
def merge(base, other):
|
||||||
"""Merge one app config (from a dict, file, or filename) into another.
|
"""Merge one app config (from a dict, file, or filename) into another.
|
||||||
|
|
||||||
If the given config is a filename, it will be appended to
|
If the given config is a filename, it will be appended to the list
|
||||||
the list of files to monitor for "autoreload" changes.
|
of files to monitor for "autoreload" changes.
|
||||||
"""
|
"""
|
||||||
_if_filename_register_autoreload(other)
|
_if_filename_register_autoreload(other)
|
||||||
|
|
||||||
|
|||||||
+26
-36
@@ -1,9 +1,10 @@
|
|||||||
"""CherryPy dispatchers.
|
"""CherryPy dispatchers.
|
||||||
|
|
||||||
A 'dispatcher' is the object which looks up the 'page handler' callable
|
A 'dispatcher' is the object which looks up the 'page handler' callable
|
||||||
and collects config for the current request based on the path_info, other
|
and collects config for the current request based on the path_info,
|
||||||
request attributes, and the application architecture. The core calls the
|
other request attributes, and the application architecture. The core
|
||||||
dispatcher as early as possible, passing it a 'path_info' argument.
|
calls the dispatcher as early as possible, passing it a 'path_info'
|
||||||
|
argument.
|
||||||
|
|
||||||
The default dispatcher discovers the page handler by matching path_info
|
The default dispatcher discovers the page handler by matching path_info
|
||||||
to a hierarchical arrangement of objects, starting at request.app.root.
|
to a hierarchical arrangement of objects, starting at request.app.root.
|
||||||
@@ -21,7 +22,6 @@ import cherrypy
|
|||||||
|
|
||||||
|
|
||||||
class PageHandler(object):
|
class PageHandler(object):
|
||||||
|
|
||||||
"""Callable which sets response.body."""
|
"""Callable which sets response.body."""
|
||||||
|
|
||||||
def __init__(self, callable, *args, **kwargs):
|
def __init__(self, callable, *args, **kwargs):
|
||||||
@@ -64,8 +64,7 @@ class PageHandler(object):
|
|||||||
|
|
||||||
|
|
||||||
def test_callable_spec(callable, callable_args, callable_kwargs):
|
def test_callable_spec(callable, callable_args, callable_kwargs):
|
||||||
"""
|
"""Inspect callable and test to see if the given args are suitable for it.
|
||||||
Inspect callable and test to see if the given args are suitable for it.
|
|
||||||
|
|
||||||
When an error occurs during the handler's invoking stage there are 2
|
When an error occurs during the handler's invoking stage there are 2
|
||||||
erroneous cases:
|
erroneous cases:
|
||||||
@@ -206,12 +205,8 @@ except ImportError:
|
|||||||
def test_callable_spec(callable, args, kwargs): # noqa: F811
|
def test_callable_spec(callable, args, kwargs): # noqa: F811
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
getargspec = inspect.getargspec
|
def getargspec(callable):
|
||||||
# Python 3 requires using getfullargspec if
|
return inspect.getfullargspec(callable)[:4]
|
||||||
# keyword-only arguments are present
|
|
||||||
if hasattr(inspect, 'getfullargspec'):
|
|
||||||
def getargspec(callable):
|
|
||||||
return inspect.getfullargspec(callable)[:4]
|
|
||||||
|
|
||||||
|
|
||||||
class LateParamPageHandler(PageHandler):
|
class LateParamPageHandler(PageHandler):
|
||||||
@@ -256,16 +251,16 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
class Dispatcher(object):
|
class Dispatcher(object):
|
||||||
|
|
||||||
"""CherryPy Dispatcher which walks a tree of objects to find a handler.
|
"""CherryPy Dispatcher which walks a tree of objects to find a handler.
|
||||||
|
|
||||||
The tree is rooted at cherrypy.request.app.root, and each hierarchical
|
The tree is rooted at cherrypy.request.app.root, and each
|
||||||
component in the path_info argument is matched to a corresponding nested
|
hierarchical component in the path_info argument is matched to a
|
||||||
attribute of the root object. Matching handlers must have an 'exposed'
|
corresponding nested attribute of the root object. Matching handlers
|
||||||
attribute which evaluates to True. The special method name "index"
|
must have an 'exposed' attribute which evaluates to True. The
|
||||||
matches a URI which ends in a slash ("/"). The special method name
|
special method name "index" matches a URI which ends in a slash
|
||||||
"default" may match a portion of the path_info (but only when no longer
|
("/"). The special method name "default" may match a portion of the
|
||||||
substring of the path_info matches some other object).
|
path_info (but only when no longer substring of the path_info
|
||||||
|
matches some other object).
|
||||||
|
|
||||||
This is the default, built-in dispatcher for CherryPy.
|
This is the default, built-in dispatcher for CherryPy.
|
||||||
"""
|
"""
|
||||||
@@ -310,9 +305,9 @@ class Dispatcher(object):
|
|||||||
|
|
||||||
The second object returned will be a list of names which are
|
The second object returned will be a list of names which are
|
||||||
'virtual path' components: parts of the URL which are dynamic,
|
'virtual path' components: parts of the URL which are dynamic,
|
||||||
and were not used when looking up the handler.
|
and were not used when looking up the handler. These virtual
|
||||||
These virtual path components are passed to the handler as
|
path components are passed to the handler as positional
|
||||||
positional arguments.
|
arguments.
|
||||||
"""
|
"""
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
app = request.app
|
app = request.app
|
||||||
@@ -452,13 +447,11 @@ class Dispatcher(object):
|
|||||||
|
|
||||||
|
|
||||||
class MethodDispatcher(Dispatcher):
|
class MethodDispatcher(Dispatcher):
|
||||||
|
|
||||||
"""Additional dispatch based on cherrypy.request.method.upper().
|
"""Additional dispatch based on cherrypy.request.method.upper().
|
||||||
|
|
||||||
Methods named GET, POST, etc will be called on an exposed class.
|
Methods named GET, POST, etc will be called on an exposed class. The
|
||||||
The method names must be all caps; the appropriate Allow header
|
method names must be all caps; the appropriate Allow header will be
|
||||||
will be output showing all capitalized method names as allowable
|
output showing all capitalized method names as allowable HTTP verbs.
|
||||||
HTTP verbs.
|
|
||||||
|
|
||||||
Note that the containing class must be exposed, not the methods.
|
Note that the containing class must be exposed, not the methods.
|
||||||
"""
|
"""
|
||||||
@@ -496,16 +489,14 @@ class MethodDispatcher(Dispatcher):
|
|||||||
|
|
||||||
|
|
||||||
class RoutesDispatcher(object):
|
class RoutesDispatcher(object):
|
||||||
|
|
||||||
"""A Routes based dispatcher for CherryPy."""
|
"""A Routes based dispatcher for CherryPy."""
|
||||||
|
|
||||||
def __init__(self, full_result=False, **mapper_options):
|
def __init__(self, full_result=False, **mapper_options):
|
||||||
"""
|
"""Routes dispatcher.
|
||||||
Routes dispatcher
|
|
||||||
|
|
||||||
Set full_result to True if you wish the controller
|
Set full_result to True if you wish the controller and the
|
||||||
and the action to be passed on to the page handler
|
action to be passed on to the page handler parameters. By
|
||||||
parameters. By default they won't be.
|
default they won't be.
|
||||||
"""
|
"""
|
||||||
import routes
|
import routes
|
||||||
self.full_result = full_result
|
self.full_result = full_result
|
||||||
@@ -621,8 +612,7 @@ def XMLRPCDispatcher(next_dispatcher=Dispatcher()):
|
|||||||
|
|
||||||
def VirtualHost(next_dispatcher=Dispatcher(), use_x_forwarded_host=True,
|
def VirtualHost(next_dispatcher=Dispatcher(), use_x_forwarded_host=True,
|
||||||
**domains):
|
**domains):
|
||||||
"""
|
"""Select a different handler based on the Host header.
|
||||||
Select a different handler based on the Host header.
|
|
||||||
|
|
||||||
This can be useful when running multiple sites within one CP server.
|
This can be useful when running multiple sites within one CP server.
|
||||||
It allows several domains to point to different parts of a single
|
It allows several domains to point to different parts of a single
|
||||||
|
|||||||
+24
-25
@@ -136,19 +136,17 @@ from cherrypy.lib import httputil as _httputil
|
|||||||
|
|
||||||
|
|
||||||
class CherryPyException(Exception):
|
class CherryPyException(Exception):
|
||||||
|
|
||||||
"""A base class for CherryPy exceptions."""
|
"""A base class for CherryPy exceptions."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InternalRedirect(CherryPyException):
|
class InternalRedirect(CherryPyException):
|
||||||
|
|
||||||
"""Exception raised to switch to the handler for a different URL.
|
"""Exception raised to switch to the handler for a different URL.
|
||||||
|
|
||||||
This exception will redirect processing to another path within the site
|
This exception will redirect processing to another path within the
|
||||||
(without informing the client). Provide the new path as an argument when
|
site (without informing the client). Provide the new path as an
|
||||||
raising the exception. Provide any params in the querystring for the new
|
argument when raising the exception. Provide any params in the
|
||||||
URL.
|
querystring for the new URL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path, query_string=''):
|
def __init__(self, path, query_string=''):
|
||||||
@@ -173,7 +171,6 @@ class InternalRedirect(CherryPyException):
|
|||||||
|
|
||||||
|
|
||||||
class HTTPRedirect(CherryPyException):
|
class HTTPRedirect(CherryPyException):
|
||||||
|
|
||||||
"""Exception raised when the request should be redirected.
|
"""Exception raised when the request should be redirected.
|
||||||
|
|
||||||
This exception will force a HTTP redirect to the URL or URL's you give it.
|
This exception will force a HTTP redirect to the URL or URL's you give it.
|
||||||
@@ -202,7 +199,7 @@ class HTTPRedirect(CherryPyException):
|
|||||||
"""The list of URL's to emit."""
|
"""The list of URL's to emit."""
|
||||||
|
|
||||||
encoding = 'utf-8'
|
encoding = 'utf-8'
|
||||||
"""The encoding when passed urls are not native strings"""
|
"""The encoding when passed urls are not native strings."""
|
||||||
|
|
||||||
def __init__(self, urls, status=None, encoding=None):
|
def __init__(self, urls, status=None, encoding=None):
|
||||||
self.urls = abs_urls = [
|
self.urls = abs_urls = [
|
||||||
@@ -230,8 +227,7 @@ class HTTPRedirect(CherryPyException):
|
|||||||
|
|
||||||
@classproperty
|
@classproperty
|
||||||
def default_status(cls):
|
def default_status(cls):
|
||||||
"""
|
"""The default redirect status for the request.
|
||||||
The default redirect status for the request.
|
|
||||||
|
|
||||||
RFC 2616 indicates a 301 response code fits our goal; however,
|
RFC 2616 indicates a 301 response code fits our goal; however,
|
||||||
browser support for 301 is quite messy. Use 302/303 instead. See
|
browser support for 301 is quite messy. Use 302/303 instead. See
|
||||||
@@ -249,8 +245,9 @@ class HTTPRedirect(CherryPyException):
|
|||||||
"""Modify cherrypy.response status, headers, and body to represent
|
"""Modify cherrypy.response status, headers, and body to represent
|
||||||
self.
|
self.
|
||||||
|
|
||||||
CherryPy uses this internally, but you can also use it to create an
|
CherryPy uses this internally, but you can also use it to create
|
||||||
HTTPRedirect object and set its output without *raising* the exception.
|
an HTTPRedirect object and set its output without *raising* the
|
||||||
|
exception.
|
||||||
"""
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
response.status = status = self.status
|
response.status = status = self.status
|
||||||
@@ -339,7 +336,6 @@ def clean_headers(status):
|
|||||||
|
|
||||||
|
|
||||||
class HTTPError(CherryPyException):
|
class HTTPError(CherryPyException):
|
||||||
|
|
||||||
"""Exception used to return an HTTP error code (4xx-5xx) to the client.
|
"""Exception used to return an HTTP error code (4xx-5xx) to the client.
|
||||||
|
|
||||||
This exception can be used to automatically send a response using a
|
This exception can be used to automatically send a response using a
|
||||||
@@ -358,7 +354,9 @@ class HTTPError(CherryPyException):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
status = None
|
status = None
|
||||||
"""The HTTP status code. May be of type int or str (with a Reason-Phrase).
|
"""The HTTP status code.
|
||||||
|
|
||||||
|
May be of type int or str (with a Reason-Phrase).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
code = None
|
code = None
|
||||||
@@ -386,8 +384,9 @@ class HTTPError(CherryPyException):
|
|||||||
"""Modify cherrypy.response status, headers, and body to represent
|
"""Modify cherrypy.response status, headers, and body to represent
|
||||||
self.
|
self.
|
||||||
|
|
||||||
CherryPy uses this internally, but you can also use it to create an
|
CherryPy uses this internally, but you can also use it to create
|
||||||
HTTPError object and set its output without *raising* the exception.
|
an HTTPError object and set its output without *raising* the
|
||||||
|
exception.
|
||||||
"""
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
|
|
||||||
@@ -426,11 +425,10 @@ class HTTPError(CherryPyException):
|
|||||||
|
|
||||||
|
|
||||||
class NotFound(HTTPError):
|
class NotFound(HTTPError):
|
||||||
|
|
||||||
"""Exception raised when a URL could not be mapped to any handler (404).
|
"""Exception raised when a URL could not be mapped to any handler (404).
|
||||||
|
|
||||||
This is equivalent to raising
|
This is equivalent to raising :class:`HTTPError("404 Not Found")
|
||||||
:class:`HTTPError("404 Not Found") <cherrypy._cperror.HTTPError>`.
|
<cherrypy._cperror.HTTPError>`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path=None):
|
def __init__(self, path=None):
|
||||||
@@ -466,7 +464,7 @@ _HTTPErrorTemplate = '''<!DOCTYPE html PUBLIC
|
|||||||
<pre id="traceback">%(traceback)s</pre>
|
<pre id="traceback">%(traceback)s</pre>
|
||||||
<div id="powered_by">
|
<div id="powered_by">
|
||||||
<span>
|
<span>
|
||||||
Powered by <a href="http://www.cherrypy.org">CherryPy %(version)s</a>
|
Powered by <a href="http://www.cherrypy.dev">CherryPy %(version)s</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -477,8 +475,8 @@ _HTTPErrorTemplate = '''<!DOCTYPE html PUBLIC
|
|||||||
def get_error_page(status, **kwargs):
|
def get_error_page(status, **kwargs):
|
||||||
"""Return an HTML page, containing a pretty error response.
|
"""Return an HTML page, containing a pretty error response.
|
||||||
|
|
||||||
status should be an int or a str.
|
status should be an int or a str. kwargs will be interpolated into
|
||||||
kwargs will be interpolated into the page template.
|
the page template.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
code, reason, message = _httputil.valid_status(status)
|
code, reason, message = _httputil.valid_status(status)
|
||||||
@@ -532,7 +530,8 @@ def get_error_page(status, **kwargs):
|
|||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
# Load the template from this path.
|
# Load the template from this path.
|
||||||
template = io.open(error_page, newline='').read()
|
with io.open(error_page, newline='') as f:
|
||||||
|
template = f.read()
|
||||||
except Exception:
|
except Exception:
|
||||||
e = _format_exception(*_exc_info())[-1]
|
e = _format_exception(*_exc_info())[-1]
|
||||||
m = kwargs['message']
|
m = kwargs['message']
|
||||||
@@ -594,8 +593,8 @@ def bare_error(extrabody=None):
|
|||||||
"""Produce status, headers, body for a critical error.
|
"""Produce status, headers, body for a critical error.
|
||||||
|
|
||||||
Returns a triple without calling any other questionable functions,
|
Returns a triple without calling any other questionable functions,
|
||||||
so it should be as error-free as possible. Call it from an HTTP server
|
so it should be as error-free as possible. Call it from an HTTP
|
||||||
if you get errors outside of the request.
|
server if you get errors outside of the request.
|
||||||
|
|
||||||
If extrabody is None, a friendly but rather unhelpful error message
|
If extrabody is None, a friendly but rather unhelpful error message
|
||||||
is set in the body. If extrabody is a string, it will be appended
|
is set in the body. If extrabody is a string, it will be appended
|
||||||
|
|||||||
+11
-10
@@ -123,7 +123,6 @@ logfmt = logging.Formatter('%(message)s')
|
|||||||
|
|
||||||
|
|
||||||
class NullHandler(logging.Handler):
|
class NullHandler(logging.Handler):
|
||||||
|
|
||||||
"""A no-op logging handler to silence the logging.lastResort handler."""
|
"""A no-op logging handler to silence the logging.lastResort handler."""
|
||||||
|
|
||||||
def handle(self, record):
|
def handle(self, record):
|
||||||
@@ -137,15 +136,16 @@ class NullHandler(logging.Handler):
|
|||||||
|
|
||||||
|
|
||||||
class LogManager(object):
|
class LogManager(object):
|
||||||
|
|
||||||
"""An object to assist both simple and advanced logging.
|
"""An object to assist both simple and advanced logging.
|
||||||
|
|
||||||
``cherrypy.log`` is an instance of this class.
|
``cherrypy.log`` is an instance of this class.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
appid = None
|
appid = None
|
||||||
"""The id() of the Application object which owns this log manager. If this
|
"""The id() of the Application object which owns this log manager.
|
||||||
is a global log manager, appid is None."""
|
|
||||||
|
If this is a global log manager, appid is None.
|
||||||
|
"""
|
||||||
|
|
||||||
error_log = None
|
error_log = None
|
||||||
"""The actual :class:`logging.Logger` instance for error messages."""
|
"""The actual :class:`logging.Logger` instance for error messages."""
|
||||||
@@ -317,8 +317,8 @@ class LogManager(object):
|
|||||||
def screen(self):
|
def screen(self):
|
||||||
"""Turn stderr/stdout logging on or off.
|
"""Turn stderr/stdout logging on or off.
|
||||||
|
|
||||||
If you set this to True, it'll add the appropriate StreamHandler for
|
If you set this to True, it'll add the appropriate StreamHandler
|
||||||
you. If you set it to False, it will remove the handler.
|
for you. If you set it to False, it will remove the handler.
|
||||||
"""
|
"""
|
||||||
h = self._get_builtin_handler
|
h = self._get_builtin_handler
|
||||||
has_h = h(self.error_log, 'screen') or h(self.access_log, 'screen')
|
has_h = h(self.error_log, 'screen') or h(self.access_log, 'screen')
|
||||||
@@ -414,7 +414,6 @@ class LogManager(object):
|
|||||||
|
|
||||||
|
|
||||||
class WSGIErrorHandler(logging.Handler):
|
class WSGIErrorHandler(logging.Handler):
|
||||||
|
|
||||||
"A handler class which writes logging records to environ['wsgi.errors']."
|
"A handler class which writes logging records to environ['wsgi.errors']."
|
||||||
|
|
||||||
def flush(self):
|
def flush(self):
|
||||||
@@ -452,6 +451,8 @@ class WSGIErrorHandler(logging.Handler):
|
|||||||
|
|
||||||
class LazyRfc3339UtcTime(object):
|
class LazyRfc3339UtcTime(object):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return now() in RFC3339 UTC Format."""
|
"""Return datetime in RFC3339 UTC Format."""
|
||||||
now = datetime.datetime.now()
|
iso_formatted_now = datetime.datetime.now(
|
||||||
return now.isoformat('T') + 'Z'
|
datetime.timezone.utc,
|
||||||
|
).isoformat('T')
|
||||||
|
return f'{iso_formatted_now!s}Z'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Native adapter for serving CherryPy via mod_python
|
"""Native adapter for serving CherryPy via mod_python.
|
||||||
|
|
||||||
Basic usage:
|
Basic usage:
|
||||||
|
|
||||||
@@ -339,11 +339,8 @@ LoadModule python_module modules/mod_python.so
|
|||||||
}
|
}
|
||||||
|
|
||||||
mpconf = os.path.join(os.path.dirname(__file__), 'cpmodpy.conf')
|
mpconf = os.path.join(os.path.dirname(__file__), 'cpmodpy.conf')
|
||||||
f = open(mpconf, 'wb')
|
with open(mpconf, 'wb') as f:
|
||||||
try:
|
|
||||||
f.write(conf_data)
|
f.write(conf_data)
|
||||||
finally:
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
response = read_process(self.apache_path, '-k start -f %s' % mpconf)
|
response = read_process(self.apache_path, '-k start -f %s' % mpconf)
|
||||||
self.ready = True
|
self.ready = True
|
||||||
|
|||||||
@@ -120,10 +120,10 @@ class NativeGateway(cheroot.server.Gateway):
|
|||||||
class CPHTTPServer(cheroot.server.HTTPServer):
|
class CPHTTPServer(cheroot.server.HTTPServer):
|
||||||
"""Wrapper for cheroot.server.HTTPServer.
|
"""Wrapper for cheroot.server.HTTPServer.
|
||||||
|
|
||||||
cheroot has been designed to not reference CherryPy in any way,
|
cheroot has been designed to not reference CherryPy in any way, so
|
||||||
so that it can be used in other frameworks and applications.
|
that it can be used in other frameworks and applications. Therefore,
|
||||||
Therefore, we wrap it here, so we can apply some attributes
|
we wrap it here, so we can apply some attributes from config ->
|
||||||
from config -> cherrypy.server -> HTTPServer.
|
cherrypy.server -> HTTPServer.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, server_adapter=cherrypy.server):
|
def __init__(self, server_adapter=cherrypy.server):
|
||||||
|
|||||||
+24
-21
@@ -248,7 +248,10 @@ def process_multipart_form_data(entity):
|
|||||||
|
|
||||||
|
|
||||||
def _old_process_multipart(entity):
|
def _old_process_multipart(entity):
|
||||||
"""The behavior of 3.2 and lower. Deprecated and will be changed in 3.3."""
|
"""The behavior of 3.2 and lower.
|
||||||
|
|
||||||
|
Deprecated and will be changed in 3.3.
|
||||||
|
"""
|
||||||
process_multipart(entity)
|
process_multipart(entity)
|
||||||
|
|
||||||
params = entity.params
|
params = entity.params
|
||||||
@@ -277,7 +280,6 @@ def _old_process_multipart(entity):
|
|||||||
|
|
||||||
# -------------------------------- Entities --------------------------------- #
|
# -------------------------------- Entities --------------------------------- #
|
||||||
class Entity(object):
|
class Entity(object):
|
||||||
|
|
||||||
"""An HTTP request body, or MIME multipart body.
|
"""An HTTP request body, or MIME multipart body.
|
||||||
|
|
||||||
This class collects information about the HTTP request entity. When a
|
This class collects information about the HTTP request entity. When a
|
||||||
@@ -346,13 +348,15 @@ class Entity(object):
|
|||||||
content_type = None
|
content_type = None
|
||||||
"""The value of the Content-Type request header.
|
"""The value of the Content-Type request header.
|
||||||
|
|
||||||
If the Entity is part of a multipart payload, this will be the Content-Type
|
If the Entity is part of a multipart payload, this will be the
|
||||||
given in the MIME headers for this part.
|
Content-Type given in the MIME headers for this part.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
default_content_type = 'application/x-www-form-urlencoded'
|
default_content_type = 'application/x-www-form-urlencoded'
|
||||||
"""This defines a default ``Content-Type`` to use if no Content-Type header
|
"""This defines a default ``Content-Type`` to use if no Content-Type header
|
||||||
is given. The empty string is used for RequestBody, which results in the
|
is given.
|
||||||
|
|
||||||
|
The empty string is used for RequestBody, which results in the
|
||||||
request body not being read or parsed at all. This is by design; a missing
|
request body not being read or parsed at all. This is by design; a missing
|
||||||
``Content-Type`` header in the HTTP request entity is an error at best,
|
``Content-Type`` header in the HTTP request entity is an error at best,
|
||||||
and a security hole at worst. For multipart parts, however, the MIME spec
|
and a security hole at worst. For multipart parts, however, the MIME spec
|
||||||
@@ -402,8 +406,8 @@ class Entity(object):
|
|||||||
part_class = None
|
part_class = None
|
||||||
"""The class used for multipart parts.
|
"""The class used for multipart parts.
|
||||||
|
|
||||||
You can replace this with custom subclasses to alter the processing of
|
You can replace this with custom subclasses to alter the processing
|
||||||
multipart parts.
|
of multipart parts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, fp, headers, params=None, parts=None):
|
def __init__(self, fp, headers, params=None, parts=None):
|
||||||
@@ -509,7 +513,8 @@ class Entity(object):
|
|||||||
"""Return a file-like object into which the request body will be read.
|
"""Return a file-like object into which the request body will be read.
|
||||||
|
|
||||||
By default, this will return a TemporaryFile. Override as needed.
|
By default, this will return a TemporaryFile. Override as needed.
|
||||||
See also :attr:`cherrypy._cpreqbody.Part.maxrambytes`."""
|
See also :attr:`cherrypy._cpreqbody.Part.maxrambytes`.
|
||||||
|
"""
|
||||||
return tempfile.TemporaryFile()
|
return tempfile.TemporaryFile()
|
||||||
|
|
||||||
def fullvalue(self):
|
def fullvalue(self):
|
||||||
@@ -525,7 +530,7 @@ class Entity(object):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def decode_entity(self, value):
|
def decode_entity(self, value):
|
||||||
"""Return a given byte encoded value as a string"""
|
"""Return a given byte encoded value as a string."""
|
||||||
for charset in self.attempt_charsets:
|
for charset in self.attempt_charsets:
|
||||||
try:
|
try:
|
||||||
value = value.decode(charset)
|
value = value.decode(charset)
|
||||||
@@ -569,7 +574,6 @@ class Entity(object):
|
|||||||
|
|
||||||
|
|
||||||
class Part(Entity):
|
class Part(Entity):
|
||||||
|
|
||||||
"""A MIME part entity, part of a multipart entity."""
|
"""A MIME part entity, part of a multipart entity."""
|
||||||
|
|
||||||
# "The default character set, which must be assumed in the absence of a
|
# "The default character set, which must be assumed in the absence of a
|
||||||
@@ -653,8 +657,8 @@ class Part(Entity):
|
|||||||
def read_lines_to_boundary(self, fp_out=None):
|
def read_lines_to_boundary(self, fp_out=None):
|
||||||
"""Read bytes from self.fp and return or write them to a file.
|
"""Read bytes from self.fp and return or write them to a file.
|
||||||
|
|
||||||
If the 'fp_out' argument is None (the default), all bytes read are
|
If the 'fp_out' argument is None (the default), all bytes read
|
||||||
returned in a single byte string.
|
are returned in a single byte string.
|
||||||
|
|
||||||
If the 'fp_out' argument is not None, it must be a file-like
|
If the 'fp_out' argument is not None, it must be a file-like
|
||||||
object that supports the 'write' method; all bytes read will be
|
object that supports the 'write' method; all bytes read will be
|
||||||
@@ -755,15 +759,15 @@ class SizedReader:
|
|||||||
def read(self, size=None, fp_out=None):
|
def read(self, size=None, fp_out=None):
|
||||||
"""Read bytes from the request body and return or write them to a file.
|
"""Read bytes from the request body and return or write them to a file.
|
||||||
|
|
||||||
A number of bytes less than or equal to the 'size' argument are read
|
A number of bytes less than or equal to the 'size' argument are
|
||||||
off the socket. The actual number of bytes read are tracked in
|
read off the socket. The actual number of bytes read are tracked
|
||||||
self.bytes_read. The number may be smaller than 'size' when 1) the
|
in self.bytes_read. The number may be smaller than 'size' when
|
||||||
client sends fewer bytes, 2) the 'Content-Length' request header
|
1) the client sends fewer bytes, 2) the 'Content-Length' request
|
||||||
specifies fewer bytes than requested, or 3) the number of bytes read
|
header specifies fewer bytes than requested, or 3) the number of
|
||||||
exceeds self.maxbytes (in which case, 413 is raised).
|
bytes read exceeds self.maxbytes (in which case, 413 is raised).
|
||||||
|
|
||||||
If the 'fp_out' argument is None (the default), all bytes read are
|
If the 'fp_out' argument is None (the default), all bytes read
|
||||||
returned in a single byte string.
|
are returned in a single byte string.
|
||||||
|
|
||||||
If the 'fp_out' argument is not None, it must be a file-like
|
If the 'fp_out' argument is not None, it must be a file-like
|
||||||
object that supports the 'write' method; all bytes read will be
|
object that supports the 'write' method; all bytes read will be
|
||||||
@@ -918,7 +922,6 @@ class SizedReader:
|
|||||||
|
|
||||||
|
|
||||||
class RequestBody(Entity):
|
class RequestBody(Entity):
|
||||||
|
|
||||||
"""The entity of the HTTP request."""
|
"""The entity of the HTTP request."""
|
||||||
|
|
||||||
bufsize = 8 * 1024
|
bufsize = 8 * 1024
|
||||||
|
|||||||
+123
-85
@@ -16,7 +16,6 @@ from cherrypy.lib import httputil, reprconf, encoding
|
|||||||
|
|
||||||
|
|
||||||
class Hook(object):
|
class Hook(object):
|
||||||
|
|
||||||
"""A callback and its metadata: failsafe, priority, and kwargs."""
|
"""A callback and its metadata: failsafe, priority, and kwargs."""
|
||||||
|
|
||||||
callback = None
|
callback = None
|
||||||
@@ -30,10 +29,12 @@ class Hook(object):
|
|||||||
from the same call point raise exceptions."""
|
from the same call point raise exceptions."""
|
||||||
|
|
||||||
priority = 50
|
priority = 50
|
||||||
|
"""Defines the order of execution for a list of Hooks.
|
||||||
|
|
||||||
|
Priority numbers should be limited to the closed interval [0, 100],
|
||||||
|
but values outside this range are acceptable, as are fractional
|
||||||
|
values.
|
||||||
"""
|
"""
|
||||||
Defines the order of execution for a list of Hooks. Priority numbers
|
|
||||||
should be limited to the closed interval [0, 100], but values outside
|
|
||||||
this range are acceptable, as are fractional values."""
|
|
||||||
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
"""
|
"""
|
||||||
@@ -74,7 +75,6 @@ class Hook(object):
|
|||||||
|
|
||||||
|
|
||||||
class HookMap(dict):
|
class HookMap(dict):
|
||||||
|
|
||||||
"""A map of call points to lists of callbacks (Hook objects)."""
|
"""A map of call points to lists of callbacks (Hook objects)."""
|
||||||
|
|
||||||
def __new__(cls, points=None):
|
def __new__(cls, points=None):
|
||||||
@@ -169,7 +169,7 @@ def request_namespace(k, v):
|
|||||||
def response_namespace(k, v):
|
def response_namespace(k, v):
|
||||||
"""Attach response attributes declared in config."""
|
"""Attach response attributes declared in config."""
|
||||||
# Provides config entries to set default response headers
|
# Provides config entries to set default response headers
|
||||||
# http://cherrypy.org/ticket/889
|
# http://cherrypy.dev/ticket/889
|
||||||
if k[:8] == 'headers.':
|
if k[:8] == 'headers.':
|
||||||
cherrypy.serving.response.headers[k.split('.', 1)[1]] = v
|
cherrypy.serving.response.headers[k.split('.', 1)[1]] = v
|
||||||
else:
|
else:
|
||||||
@@ -190,23 +190,23 @@ hookpoints = ['on_start_resource', 'before_request_body',
|
|||||||
|
|
||||||
|
|
||||||
class Request(object):
|
class Request(object):
|
||||||
|
|
||||||
"""An HTTP request.
|
"""An HTTP request.
|
||||||
|
|
||||||
This object represents the metadata of an HTTP request message;
|
This object represents the metadata of an HTTP request message; that
|
||||||
that is, it contains attributes which describe the environment
|
is, it contains attributes which describe the environment in which
|
||||||
in which the request URL, headers, and body were sent (if you
|
the request URL, headers, and body were sent (if you want tools to
|
||||||
want tools to interpret the headers and body, those are elsewhere,
|
interpret the headers and body, those are elsewhere, mostly in
|
||||||
mostly in Tools). This 'metadata' consists of socket data,
|
Tools). This 'metadata' consists of socket data, transport
|
||||||
transport characteristics, and the Request-Line. This object
|
characteristics, and the Request-Line. This object also contains
|
||||||
also contains data regarding the configuration in effect for
|
data regarding the configuration in effect for the given URL, and
|
||||||
the given URL, and the execution plan for generating a response.
|
the execution plan for generating a response.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
prev = None
|
prev = None
|
||||||
|
"""The previous Request object (if any).
|
||||||
|
|
||||||
|
This should be None unless we are processing an InternalRedirect.
|
||||||
"""
|
"""
|
||||||
The previous Request object (if any). This should be None
|
|
||||||
unless we are processing an InternalRedirect."""
|
|
||||||
|
|
||||||
# Conversation/connection attributes
|
# Conversation/connection attributes
|
||||||
local = httputil.Host('127.0.0.1', 80)
|
local = httputil.Host('127.0.0.1', 80)
|
||||||
@@ -216,9 +216,10 @@ class Request(object):
|
|||||||
'An httputil.Host(ip, port, hostname) object for the client socket.'
|
'An httputil.Host(ip, port, hostname) object for the client socket.'
|
||||||
|
|
||||||
scheme = 'http'
|
scheme = 'http'
|
||||||
|
"""The protocol used between client and server.
|
||||||
|
|
||||||
|
In most cases, this will be either 'http' or 'https'.
|
||||||
"""
|
"""
|
||||||
The protocol used between client and server. In most cases,
|
|
||||||
this will be either 'http' or 'https'."""
|
|
||||||
|
|
||||||
server_protocol = 'HTTP/1.1'
|
server_protocol = 'HTTP/1.1'
|
||||||
"""
|
"""
|
||||||
@@ -227,32 +228,37 @@ class Request(object):
|
|||||||
|
|
||||||
base = ''
|
base = ''
|
||||||
"""The (scheme://host) portion of the requested URL.
|
"""The (scheme://host) portion of the requested URL.
|
||||||
|
|
||||||
In some cases (e.g. when proxying via mod_rewrite), this may contain
|
In some cases (e.g. when proxying via mod_rewrite), this may contain
|
||||||
path segments which cherrypy.url uses when constructing url's, but
|
path segments which cherrypy.url uses when constructing url's, but
|
||||||
which otherwise are ignored by CherryPy. Regardless, this value
|
which otherwise are ignored by CherryPy. Regardless, this value MUST
|
||||||
MUST NOT end in a slash."""
|
NOT end in a slash.
|
||||||
|
"""
|
||||||
|
|
||||||
# Request-Line attributes
|
# Request-Line attributes
|
||||||
request_line = ''
|
request_line = ''
|
||||||
|
"""The complete Request-Line received from the client.
|
||||||
|
|
||||||
|
This is a single string consisting of the request method, URI, and
|
||||||
|
protocol version (joined by spaces). Any final CRLF is removed.
|
||||||
"""
|
"""
|
||||||
The complete Request-Line received from the client. This is a
|
|
||||||
single string consisting of the request method, URI, and protocol
|
|
||||||
version (joined by spaces). Any final CRLF is removed."""
|
|
||||||
|
|
||||||
method = 'GET'
|
method = 'GET'
|
||||||
|
"""Indicates the HTTP method to be performed on the resource identified by
|
||||||
|
the Request-URI.
|
||||||
|
|
||||||
|
Common methods include GET, HEAD, POST, PUT, and DELETE. CherryPy
|
||||||
|
allows any extension method; however, various HTTP servers and
|
||||||
|
gateways may restrict the set of allowable methods. CherryPy
|
||||||
|
applications SHOULD restrict the set (on a per-URI basis).
|
||||||
"""
|
"""
|
||||||
Indicates the HTTP method to be performed on the resource identified
|
|
||||||
by the Request-URI. Common methods include GET, HEAD, POST, PUT, and
|
|
||||||
DELETE. CherryPy allows any extension method; however, various HTTP
|
|
||||||
servers and gateways may restrict the set of allowable methods.
|
|
||||||
CherryPy applications SHOULD restrict the set (on a per-URI basis)."""
|
|
||||||
|
|
||||||
query_string = ''
|
query_string = ''
|
||||||
"""
|
"""
|
||||||
The query component of the Request-URI, a string of information to be
|
The query component of the Request-URI, a string of information to be
|
||||||
interpreted by the resource. The query portion of a URI follows the
|
interpreted by the resource. The query portion of a URI follows the
|
||||||
path component, and is separated by a '?'. For example, the URI
|
path component, and is separated by a '?'. For example, the URI
|
||||||
'http://www.cherrypy.org/wiki?a=3&b=4' has the query component,
|
'http://www.cherrypy.dev/wiki?a=3&b=4' has the query component,
|
||||||
'a=3&b=4'."""
|
'a=3&b=4'."""
|
||||||
|
|
||||||
query_string_encoding = 'utf8'
|
query_string_encoding = 'utf8'
|
||||||
@@ -277,22 +283,26 @@ class Request(object):
|
|||||||
A dict which combines query string (GET) and request entity (POST)
|
A dict which combines query string (GET) and request entity (POST)
|
||||||
variables. This is populated in two stages: GET params are added
|
variables. This is populated in two stages: GET params are added
|
||||||
before the 'on_start_resource' hook, and POST params are added
|
before the 'on_start_resource' hook, and POST params are added
|
||||||
between the 'before_request_body' and 'before_handler' hooks."""
|
between the 'before_request_body' and 'before_handler' hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
# Message attributes
|
# Message attributes
|
||||||
header_list = []
|
header_list = []
|
||||||
|
"""A list of the HTTP request headers as (name, value) tuples.
|
||||||
|
|
||||||
|
In general, you should use request.headers (a dict) instead.
|
||||||
"""
|
"""
|
||||||
A list of the HTTP request headers as (name, value) tuples.
|
|
||||||
In general, you should use request.headers (a dict) instead."""
|
|
||||||
|
|
||||||
headers = httputil.HeaderMap()
|
headers = httputil.HeaderMap()
|
||||||
"""
|
"""A dict-like object containing the request headers.
|
||||||
A dict-like object containing the request headers. Keys are header
|
|
||||||
|
Keys are header
|
||||||
names (in Title-Case format); however, you may get and set them in
|
names (in Title-Case format); however, you may get and set them in
|
||||||
a case-insensitive manner. That is, headers['Content-Type'] and
|
a case-insensitive manner. That is, headers['Content-Type'] and
|
||||||
headers['content-type'] refer to the same value. Values are header
|
headers['content-type'] refer to the same value. Values are header
|
||||||
values (decoded according to :rfc:`2047` if necessary). See also:
|
values (decoded according to :rfc:`2047` if necessary). See also:
|
||||||
httputil.HeaderMap, httputil.HeaderElement."""
|
httputil.HeaderMap, httputil.HeaderElement.
|
||||||
|
"""
|
||||||
|
|
||||||
cookie = SimpleCookie()
|
cookie = SimpleCookie()
|
||||||
"""See help(Cookie)."""
|
"""See help(Cookie)."""
|
||||||
@@ -336,7 +346,8 @@ class Request(object):
|
|||||||
or multipart, this will be None. Otherwise, this will be an instance
|
or multipart, this will be None. Otherwise, this will be an instance
|
||||||
of :class:`RequestBody<cherrypy._cpreqbody.RequestBody>` (which you
|
of :class:`RequestBody<cherrypy._cpreqbody.RequestBody>` (which you
|
||||||
can .read()); this value is set between the 'before_request_body' and
|
can .read()); this value is set between the 'before_request_body' and
|
||||||
'before_handler' hooks (assuming that process_request_body is True)."""
|
'before_handler' hooks (assuming that process_request_body is True).
|
||||||
|
"""
|
||||||
|
|
||||||
# Dispatch attributes
|
# Dispatch attributes
|
||||||
dispatch = cherrypy.dispatch.Dispatcher()
|
dispatch = cherrypy.dispatch.Dispatcher()
|
||||||
@@ -347,23 +358,24 @@ class Request(object):
|
|||||||
calls the dispatcher as early as possible, passing it a 'path_info'
|
calls the dispatcher as early as possible, passing it a 'path_info'
|
||||||
argument.
|
argument.
|
||||||
|
|
||||||
The default dispatcher discovers the page handler by matching path_info
|
The default dispatcher discovers the page handler by matching
|
||||||
to a hierarchical arrangement of objects, starting at request.app.root.
|
path_info to a hierarchical arrangement of objects, starting at
|
||||||
See help(cherrypy.dispatch) for more information."""
|
request.app.root. See help(cherrypy.dispatch) for more information.
|
||||||
|
"""
|
||||||
|
|
||||||
script_name = ''
|
script_name = ''
|
||||||
"""
|
"""The 'mount point' of the application which is handling this request.
|
||||||
The 'mount point' of the application which is handling this request.
|
|
||||||
|
|
||||||
This attribute MUST NOT end in a slash. If the script_name refers to
|
This attribute MUST NOT end in a slash. If the script_name refers to
|
||||||
the root of the URI, it MUST be an empty string (not "/").
|
the root of the URI, it MUST be an empty string (not "/").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
path_info = '/'
|
path_info = '/'
|
||||||
|
"""The 'relative path' portion of the Request-URI.
|
||||||
|
|
||||||
|
This is relative to the script_name ('mount point') of the
|
||||||
|
application which is handling this request.
|
||||||
"""
|
"""
|
||||||
The 'relative path' portion of the Request-URI. This is relative
|
|
||||||
to the script_name ('mount point') of the application which is
|
|
||||||
handling this request."""
|
|
||||||
|
|
||||||
login = None
|
login = None
|
||||||
"""
|
"""
|
||||||
@@ -391,14 +403,16 @@ class Request(object):
|
|||||||
of the form: {Toolbox.namespace: {Tool.name: config dict}}."""
|
of the form: {Toolbox.namespace: {Tool.name: config dict}}."""
|
||||||
|
|
||||||
config = None
|
config = None
|
||||||
|
"""A flat dict of all configuration entries which apply to the current
|
||||||
|
request.
|
||||||
|
|
||||||
|
These entries are collected from global config, application config
|
||||||
|
(based on request.path_info), and from handler config (exactly how
|
||||||
|
is governed by the request.dispatch object in effect for this
|
||||||
|
request; by default, handler config can be attached anywhere in the
|
||||||
|
tree between request.app.root and the final handler, and inherits
|
||||||
|
downward).
|
||||||
"""
|
"""
|
||||||
A flat dict of all configuration entries which apply to the
|
|
||||||
current request. These entries are collected from global config,
|
|
||||||
application config (based on request.path_info), and from handler
|
|
||||||
config (exactly how is governed by the request.dispatch object in
|
|
||||||
effect for this request; by default, handler config can be attached
|
|
||||||
anywhere in the tree between request.app.root and the final handler,
|
|
||||||
and inherits downward)."""
|
|
||||||
|
|
||||||
is_index = None
|
is_index = None
|
||||||
"""
|
"""
|
||||||
@@ -409,13 +423,14 @@ class Request(object):
|
|||||||
the trailing slash. See cherrypy.tools.trailing_slash."""
|
the trailing slash. See cherrypy.tools.trailing_slash."""
|
||||||
|
|
||||||
hooks = HookMap(hookpoints)
|
hooks = HookMap(hookpoints)
|
||||||
"""
|
"""A HookMap (dict-like object) of the form: {hookpoint: [hook, ...]}.
|
||||||
A HookMap (dict-like object) of the form: {hookpoint: [hook, ...]}.
|
|
||||||
Each key is a str naming the hook point, and each value is a list
|
Each key is a str naming the hook point, and each value is a list
|
||||||
of hooks which will be called at that hook point during this request.
|
of hooks which will be called at that hook point during this request.
|
||||||
The list of hooks is generally populated as early as possible (mostly
|
The list of hooks is generally populated as early as possible (mostly
|
||||||
from Tools specified in config), but may be extended at any time.
|
from Tools specified in config), but may be extended at any time.
|
||||||
See also: _cprequest.Hook, _cprequest.HookMap, and cherrypy.tools."""
|
See also: _cprequest.Hook, _cprequest.HookMap, and cherrypy.tools.
|
||||||
|
"""
|
||||||
|
|
||||||
error_response = cherrypy.HTTPError(500).set_response
|
error_response = cherrypy.HTTPError(500).set_response
|
||||||
"""
|
"""
|
||||||
@@ -428,12 +443,11 @@ class Request(object):
|
|||||||
error response to the user-agent."""
|
error response to the user-agent."""
|
||||||
|
|
||||||
error_page = {}
|
error_page = {}
|
||||||
"""
|
"""A dict of {error code: response filename or callable} pairs.
|
||||||
A dict of {error code: response filename or callable} pairs.
|
|
||||||
|
|
||||||
The error code must be an int representing a given HTTP error code,
|
The error code must be an int representing a given HTTP error code,
|
||||||
or the string 'default', which will be used if no matching entry
|
or the string 'default', which will be used if no matching entry is
|
||||||
is found for a given numeric code.
|
found for a given numeric code.
|
||||||
|
|
||||||
If a filename is provided, the file should contain a Python string-
|
If a filename is provided, the file should contain a Python string-
|
||||||
formatting template, and can expect by default to receive format
|
formatting template, and can expect by default to receive format
|
||||||
@@ -447,8 +461,8 @@ class Request(object):
|
|||||||
iterable of strings which will be set to response.body. It may also
|
iterable of strings which will be set to response.body. It may also
|
||||||
override headers or perform any other processing.
|
override headers or perform any other processing.
|
||||||
|
|
||||||
If no entry is given for an error code, and no 'default' entry exists,
|
If no entry is given for an error code, and no 'default' entry
|
||||||
a default template will be used.
|
exists, a default template will be used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
show_tracebacks = True
|
show_tracebacks = True
|
||||||
@@ -473,9 +487,10 @@ class Request(object):
|
|||||||
"""True once the close method has been called, False otherwise."""
|
"""True once the close method has been called, False otherwise."""
|
||||||
|
|
||||||
stage = None
|
stage = None
|
||||||
|
"""A string containing the stage reached in the request-handling process.
|
||||||
|
|
||||||
|
This is useful when debugging a live server with hung requests.
|
||||||
"""
|
"""
|
||||||
A string containing the stage reached in the request-handling process.
|
|
||||||
This is useful when debugging a live server with hung requests."""
|
|
||||||
|
|
||||||
unique_id = None
|
unique_id = None
|
||||||
"""A lazy object generating and memorizing UUID4 on ``str()`` render."""
|
"""A lazy object generating and memorizing UUID4 on ``str()`` render."""
|
||||||
@@ -492,9 +507,10 @@ class Request(object):
|
|||||||
server_protocol='HTTP/1.1'):
|
server_protocol='HTTP/1.1'):
|
||||||
"""Populate a new Request object.
|
"""Populate a new Request object.
|
||||||
|
|
||||||
local_host should be an httputil.Host object with the server info.
|
local_host should be an httputil.Host object with the server
|
||||||
remote_host should be an httputil.Host object with the client info.
|
info. remote_host should be an httputil.Host object with the
|
||||||
scheme should be a string, either "http" or "https".
|
client info. scheme should be a string, either "http" or
|
||||||
|
"https".
|
||||||
"""
|
"""
|
||||||
self.local = local_host
|
self.local = local_host
|
||||||
self.remote = remote_host
|
self.remote = remote_host
|
||||||
@@ -514,7 +530,10 @@ class Request(object):
|
|||||||
self.unique_id = LazyUUID4()
|
self.unique_id = LazyUUID4()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Run cleanup code. (Core)"""
|
"""Run cleanup code.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
if not self.closed:
|
if not self.closed:
|
||||||
self.closed = True
|
self.closed = True
|
||||||
self.stage = 'on_end_request'
|
self.stage = 'on_end_request'
|
||||||
@@ -551,7 +570,6 @@ class Request(object):
|
|||||||
|
|
||||||
Consumer code (HTTP servers) should then access these response
|
Consumer code (HTTP servers) should then access these response
|
||||||
attributes to build the outbound stream.
|
attributes to build the outbound stream.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
self.stage = 'run'
|
self.stage = 'run'
|
||||||
@@ -631,7 +649,10 @@ class Request(object):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def respond(self, path_info):
|
def respond(self, path_info):
|
||||||
"""Generate a response for the resource at self.path_info. (Core)"""
|
"""Generate a response for the resource at self.path_info.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
@@ -702,7 +723,10 @@ class Request(object):
|
|||||||
response.finalize()
|
response.finalize()
|
||||||
|
|
||||||
def process_query_string(self):
|
def process_query_string(self):
|
||||||
"""Parse the query string into Python structures. (Core)"""
|
"""Parse the query string into Python structures.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
p = httputil.parse_query_string(
|
p = httputil.parse_query_string(
|
||||||
self.query_string, encoding=self.query_string_encoding)
|
self.query_string, encoding=self.query_string_encoding)
|
||||||
@@ -715,7 +739,10 @@ class Request(object):
|
|||||||
self.params.update(p)
|
self.params.update(p)
|
||||||
|
|
||||||
def process_headers(self):
|
def process_headers(self):
|
||||||
"""Parse HTTP header data into Python structures. (Core)"""
|
"""Parse HTTP header data into Python structures.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
# Process the headers into self.headers
|
# Process the headers into self.headers
|
||||||
headers = self.headers
|
headers = self.headers
|
||||||
for name, value in self.header_list:
|
for name, value in self.header_list:
|
||||||
@@ -742,13 +769,19 @@ class Request(object):
|
|||||||
if self.protocol >= (1, 1):
|
if self.protocol >= (1, 1):
|
||||||
msg = "HTTP/1.1 requires a 'Host' request header."
|
msg = "HTTP/1.1 requires a 'Host' request header."
|
||||||
raise cherrypy.HTTPError(400, msg)
|
raise cherrypy.HTTPError(400, msg)
|
||||||
|
else:
|
||||||
|
headers['Host'] = httputil.SanitizedHost(dict.get(headers, 'Host'))
|
||||||
|
|
||||||
host = dict.get(headers, 'Host')
|
host = dict.get(headers, 'Host')
|
||||||
if not host:
|
if not host:
|
||||||
host = self.local.name or self.local.ip
|
host = self.local.name or self.local.ip
|
||||||
self.base = '%s://%s' % (self.scheme, host)
|
self.base = '%s://%s' % (self.scheme, host)
|
||||||
|
|
||||||
def get_resource(self, path):
|
def get_resource(self, path):
|
||||||
"""Call a dispatcher (which sets self.handler and .config). (Core)"""
|
"""Call a dispatcher (which sets self.handler and .config).
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
# First, see if there is a custom dispatch at this URI. Custom
|
# First, see if there is a custom dispatch at this URI. Custom
|
||||||
# dispatchers can only be specified in app.config, not in _cp_config
|
# dispatchers can only be specified in app.config, not in _cp_config
|
||||||
# (since custom dispatchers may not even have an app.root).
|
# (since custom dispatchers may not even have an app.root).
|
||||||
@@ -759,7 +792,10 @@ class Request(object):
|
|||||||
dispatch(path)
|
dispatch(path)
|
||||||
|
|
||||||
def handle_error(self):
|
def handle_error(self):
|
||||||
"""Handle the last unanticipated exception. (Core)"""
|
"""Handle the last unanticipated exception.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
self.hooks.run('before_error_response')
|
self.hooks.run('before_error_response')
|
||||||
if self.error_response:
|
if self.error_response:
|
||||||
@@ -773,7 +809,6 @@ class Request(object):
|
|||||||
|
|
||||||
|
|
||||||
class ResponseBody(object):
|
class ResponseBody(object):
|
||||||
|
|
||||||
"""The body of the HTTP response (the response entity)."""
|
"""The body of the HTTP response (the response entity)."""
|
||||||
|
|
||||||
unicode_err = ('Page handlers MUST return bytes. Use tools.encode '
|
unicode_err = ('Page handlers MUST return bytes. Use tools.encode '
|
||||||
@@ -799,18 +834,18 @@ class ResponseBody(object):
|
|||||||
|
|
||||||
|
|
||||||
class Response(object):
|
class Response(object):
|
||||||
|
|
||||||
"""An HTTP Response, including status, headers, and body."""
|
"""An HTTP Response, including status, headers, and body."""
|
||||||
|
|
||||||
status = ''
|
status = ''
|
||||||
"""The HTTP Status-Code and Reason-Phrase."""
|
"""The HTTP Status-Code and Reason-Phrase."""
|
||||||
|
|
||||||
header_list = []
|
header_list = []
|
||||||
"""
|
"""A list of the HTTP response headers as (name, value) tuples.
|
||||||
A list of the HTTP response headers as (name, value) tuples.
|
|
||||||
In general, you should use response.headers (a dict) instead. This
|
In general, you should use response.headers (a dict) instead. This
|
||||||
attribute is generated from response.headers and is not valid until
|
attribute is generated from response.headers and is not valid until
|
||||||
after the finalize phase."""
|
after the finalize phase.
|
||||||
|
"""
|
||||||
|
|
||||||
headers = httputil.HeaderMap()
|
headers = httputil.HeaderMap()
|
||||||
"""
|
"""
|
||||||
@@ -830,7 +865,10 @@ class Response(object):
|
|||||||
"""The body (entity) of the HTTP response."""
|
"""The body (entity) of the HTTP response."""
|
||||||
|
|
||||||
time = None
|
time = None
|
||||||
"""The value of time.time() when created. Use in HTTP dates."""
|
"""The value of time.time() when created.
|
||||||
|
|
||||||
|
Use in HTTP dates.
|
||||||
|
"""
|
||||||
|
|
||||||
stream = False
|
stream = False
|
||||||
"""If False, buffer the response body."""
|
"""If False, buffer the response body."""
|
||||||
@@ -858,15 +896,15 @@ class Response(object):
|
|||||||
return new_body
|
return new_body
|
||||||
|
|
||||||
def _flush_body(self):
|
def _flush_body(self):
|
||||||
"""
|
"""Discard self.body but consume any generator such that any
|
||||||
Discard self.body but consume any generator such that
|
finalization can occur, such as is required by caching.tee_output()."""
|
||||||
any finalization can occur, such as is required by
|
|
||||||
caching.tee_output().
|
|
||||||
"""
|
|
||||||
consume(iter(self.body))
|
consume(iter(self.body))
|
||||||
|
|
||||||
def finalize(self):
|
def finalize(self):
|
||||||
"""Transform headers (and cookies) into self.header_list. (Core)"""
|
"""Transform headers (and cookies) into self.header_list.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
code, reason, _ = httputil.valid_status(self.status)
|
code, reason, _ = httputil.valid_status(self.status)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|||||||
+24
-12
@@ -50,7 +50,8 @@ class Server(ServerAdapter):
|
|||||||
"""If given, the name of the UNIX socket to use instead of TCP/IP.
|
"""If given, the name of the UNIX socket to use instead of TCP/IP.
|
||||||
|
|
||||||
When this option is not None, the `socket_host` and `socket_port` options
|
When this option is not None, the `socket_host` and `socket_port` options
|
||||||
are ignored."""
|
are ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
socket_queue_size = 5
|
socket_queue_size = 5
|
||||||
"""The 'backlog' argument to socket.listen(); specifies the maximum number
|
"""The 'backlog' argument to socket.listen(); specifies the maximum number
|
||||||
@@ -79,17 +80,24 @@ class Server(ServerAdapter):
|
|||||||
"""The number of worker threads to start up in the pool."""
|
"""The number of worker threads to start up in the pool."""
|
||||||
|
|
||||||
thread_pool_max = -1
|
thread_pool_max = -1
|
||||||
"""The maximum size of the worker-thread pool. Use -1 to indicate no limit.
|
"""The maximum size of the worker-thread pool.
|
||||||
|
|
||||||
|
Use -1 to indicate no limit.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
max_request_header_size = 500 * 1024
|
max_request_header_size = 500 * 1024
|
||||||
"""The maximum number of bytes allowable in the request headers.
|
"""The maximum number of bytes allowable in the request headers.
|
||||||
If exceeded, the HTTP server should return "413 Request Entity Too Large".
|
|
||||||
|
If exceeded, the HTTP server should return "413 Request Entity Too
|
||||||
|
Large".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
max_request_body_size = 100 * 1024 * 1024
|
max_request_body_size = 100 * 1024 * 1024
|
||||||
"""The maximum number of bytes allowable in the request body. If exceeded,
|
"""The maximum number of bytes allowable in the request body.
|
||||||
the HTTP server should return "413 Request Entity Too Large"."""
|
|
||||||
|
If exceeded, the HTTP server should return "413 Request Entity Too
|
||||||
|
Large".
|
||||||
|
"""
|
||||||
|
|
||||||
instance = None
|
instance = None
|
||||||
"""If not None, this should be an HTTP server instance (such as
|
"""If not None, this should be an HTTP server instance (such as
|
||||||
@@ -119,7 +127,8 @@ class Server(ServerAdapter):
|
|||||||
the builtin WSGI server. Builtin options are: 'builtin' (to
|
the builtin WSGI server. Builtin options are: 'builtin' (to
|
||||||
use the SSL library built into recent versions of Python).
|
use the SSL library built into recent versions of Python).
|
||||||
You may also register your own classes in the
|
You may also register your own classes in the
|
||||||
cheroot.server.ssl_adapters dict."""
|
cheroot.server.ssl_adapters dict.
|
||||||
|
"""
|
||||||
|
|
||||||
statistics = False
|
statistics = False
|
||||||
"""Turns statistics-gathering on or off for aware HTTP servers."""
|
"""Turns statistics-gathering on or off for aware HTTP servers."""
|
||||||
@@ -129,11 +138,13 @@ class Server(ServerAdapter):
|
|||||||
|
|
||||||
wsgi_version = (1, 0)
|
wsgi_version = (1, 0)
|
||||||
"""The WSGI version tuple to use with the builtin WSGI server.
|
"""The WSGI version tuple to use with the builtin WSGI server.
|
||||||
The provided options are (1, 0) [which includes support for PEP 3333,
|
|
||||||
which declares it covers WSGI version 1.0.1 but still mandates the
|
The provided options are (1, 0) [which includes support for PEP
|
||||||
wsgi.version (1, 0)] and ('u', 0), an experimental unicode version.
|
3333, which declares it covers WSGI version 1.0.1 but still mandates
|
||||||
You may create and register your own experimental versions of the WSGI
|
the wsgi.version (1, 0)] and ('u', 0), an experimental unicode
|
||||||
protocol by adding custom classes to the cheroot.server.wsgi_gateways dict.
|
version. You may create and register your own experimental versions
|
||||||
|
of the WSGI protocol by adding custom classes to the
|
||||||
|
cheroot.server.wsgi_gateways dict.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
peercreds = False
|
peercreds = False
|
||||||
@@ -184,7 +195,8 @@ class Server(ServerAdapter):
|
|||||||
def bind_addr(self):
|
def bind_addr(self):
|
||||||
"""Return bind address.
|
"""Return bind address.
|
||||||
|
|
||||||
A (host, port) tuple for TCP sockets or a str for Unix domain sockts.
|
A (host, port) tuple for TCP sockets or a str for Unix domain
|
||||||
|
sockets.
|
||||||
"""
|
"""
|
||||||
if self.socket_file:
|
if self.socket_file:
|
||||||
return self.socket_file
|
return self.socket_file
|
||||||
|
|||||||
+21
-26
@@ -1,7 +1,7 @@
|
|||||||
"""CherryPy tools. A "tool" is any helper, adapted to CP.
|
"""CherryPy tools. A "tool" is any helper, adapted to CP.
|
||||||
|
|
||||||
Tools are usually designed to be used in a variety of ways (although some
|
Tools are usually designed to be used in a variety of ways (although
|
||||||
may only offer one if they choose):
|
some may only offer one if they choose):
|
||||||
|
|
||||||
Library calls
|
Library calls
|
||||||
All tools are callables that can be used wherever needed.
|
All tools are callables that can be used wherever needed.
|
||||||
@@ -48,10 +48,10 @@ _attr_error = (
|
|||||||
|
|
||||||
|
|
||||||
class Tool(object):
|
class Tool(object):
|
||||||
|
|
||||||
"""A registered function for use with CherryPy request-processing hooks.
|
"""A registered function for use with CherryPy request-processing hooks.
|
||||||
|
|
||||||
help(tool.callable) should give you more information about this Tool.
|
help(tool.callable) should give you more information about this
|
||||||
|
Tool.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
namespace = 'tools'
|
namespace = 'tools'
|
||||||
@@ -135,8 +135,8 @@ class Tool(object):
|
|||||||
def _setup(self):
|
def _setup(self):
|
||||||
"""Hook this tool into cherrypy.request.
|
"""Hook this tool into cherrypy.request.
|
||||||
|
|
||||||
The standard CherryPy request object will automatically call this
|
The standard CherryPy request object will automatically call
|
||||||
method when the tool is "turned on" in config.
|
this method when the tool is "turned on" in config.
|
||||||
"""
|
"""
|
||||||
conf = self._merged_args()
|
conf = self._merged_args()
|
||||||
p = conf.pop('priority', None)
|
p = conf.pop('priority', None)
|
||||||
@@ -147,15 +147,15 @@ class Tool(object):
|
|||||||
|
|
||||||
|
|
||||||
class HandlerTool(Tool):
|
class HandlerTool(Tool):
|
||||||
|
|
||||||
"""Tool which is called 'before main', that may skip normal handlers.
|
"""Tool which is called 'before main', that may skip normal handlers.
|
||||||
|
|
||||||
If the tool successfully handles the request (by setting response.body),
|
If the tool successfully handles the request (by setting
|
||||||
if should return True. This will cause CherryPy to skip any 'normal' page
|
response.body), if should return True. This will cause CherryPy to
|
||||||
handler. If the tool did not handle the request, it should return False
|
skip any 'normal' page handler. If the tool did not handle the
|
||||||
to tell CherryPy to continue on and call the normal page handler. If the
|
request, it should return False to tell CherryPy to continue on and
|
||||||
tool is declared AS a page handler (see the 'handler' method), returning
|
call the normal page handler. If the tool is declared AS a page
|
||||||
False will raise NotFound.
|
handler (see the 'handler' method), returning False will raise
|
||||||
|
NotFound.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, callable, name=None):
|
def __init__(self, callable, name=None):
|
||||||
@@ -185,8 +185,8 @@ class HandlerTool(Tool):
|
|||||||
def _setup(self):
|
def _setup(self):
|
||||||
"""Hook this tool into cherrypy.request.
|
"""Hook this tool into cherrypy.request.
|
||||||
|
|
||||||
The standard CherryPy request object will automatically call this
|
The standard CherryPy request object will automatically call
|
||||||
method when the tool is "turned on" in config.
|
this method when the tool is "turned on" in config.
|
||||||
"""
|
"""
|
||||||
conf = self._merged_args()
|
conf = self._merged_args()
|
||||||
p = conf.pop('priority', None)
|
p = conf.pop('priority', None)
|
||||||
@@ -197,7 +197,6 @@ class HandlerTool(Tool):
|
|||||||
|
|
||||||
|
|
||||||
class HandlerWrapperTool(Tool):
|
class HandlerWrapperTool(Tool):
|
||||||
|
|
||||||
"""Tool which wraps request.handler in a provided wrapper function.
|
"""Tool which wraps request.handler in a provided wrapper function.
|
||||||
|
|
||||||
The 'newhandler' arg must be a handler wrapper function that takes a
|
The 'newhandler' arg must be a handler wrapper function that takes a
|
||||||
@@ -232,7 +231,6 @@ class HandlerWrapperTool(Tool):
|
|||||||
|
|
||||||
|
|
||||||
class ErrorTool(Tool):
|
class ErrorTool(Tool):
|
||||||
|
|
||||||
"""Tool which is used to replace the default request.error_response."""
|
"""Tool which is used to replace the default request.error_response."""
|
||||||
|
|
||||||
def __init__(self, callable, name=None):
|
def __init__(self, callable, name=None):
|
||||||
@@ -244,8 +242,8 @@ class ErrorTool(Tool):
|
|||||||
def _setup(self):
|
def _setup(self):
|
||||||
"""Hook this tool into cherrypy.request.
|
"""Hook this tool into cherrypy.request.
|
||||||
|
|
||||||
The standard CherryPy request object will automatically call this
|
The standard CherryPy request object will automatically call
|
||||||
method when the tool is "turned on" in config.
|
this method when the tool is "turned on" in config.
|
||||||
"""
|
"""
|
||||||
cherrypy.serving.request.error_response = self._wrapper
|
cherrypy.serving.request.error_response = self._wrapper
|
||||||
|
|
||||||
@@ -254,7 +252,6 @@ class ErrorTool(Tool):
|
|||||||
|
|
||||||
|
|
||||||
class SessionTool(Tool):
|
class SessionTool(Tool):
|
||||||
|
|
||||||
"""Session Tool for CherryPy.
|
"""Session Tool for CherryPy.
|
||||||
|
|
||||||
sessions.locking
|
sessions.locking
|
||||||
@@ -282,8 +279,8 @@ class SessionTool(Tool):
|
|||||||
def _setup(self):
|
def _setup(self):
|
||||||
"""Hook this tool into cherrypy.request.
|
"""Hook this tool into cherrypy.request.
|
||||||
|
|
||||||
The standard CherryPy request object will automatically call this
|
The standard CherryPy request object will automatically call
|
||||||
method when the tool is "turned on" in config.
|
this method when the tool is "turned on" in config.
|
||||||
"""
|
"""
|
||||||
hooks = cherrypy.serving.request.hooks
|
hooks = cherrypy.serving.request.hooks
|
||||||
|
|
||||||
@@ -325,7 +322,6 @@ class SessionTool(Tool):
|
|||||||
|
|
||||||
|
|
||||||
class XMLRPCController(object):
|
class XMLRPCController(object):
|
||||||
|
|
||||||
"""A Controller (page handler collection) for XML-RPC.
|
"""A Controller (page handler collection) for XML-RPC.
|
||||||
|
|
||||||
To use it, have your controllers subclass this base class (it will
|
To use it, have your controllers subclass this base class (it will
|
||||||
@@ -392,7 +388,6 @@ class SessionAuthTool(HandlerTool):
|
|||||||
|
|
||||||
|
|
||||||
class CachingTool(Tool):
|
class CachingTool(Tool):
|
||||||
|
|
||||||
"""Caching Tool for CherryPy."""
|
"""Caching Tool for CherryPy."""
|
||||||
|
|
||||||
def _wrapper(self, **kwargs):
|
def _wrapper(self, **kwargs):
|
||||||
@@ -416,11 +411,11 @@ class CachingTool(Tool):
|
|||||||
|
|
||||||
|
|
||||||
class Toolbox(object):
|
class Toolbox(object):
|
||||||
|
|
||||||
"""A collection of Tools.
|
"""A collection of Tools.
|
||||||
|
|
||||||
This object also functions as a config namespace handler for itself.
|
This object also functions as a config namespace handler for itself.
|
||||||
Custom toolboxes should be added to each Application's toolboxes dict.
|
Custom toolboxes should be added to each Application's toolboxes
|
||||||
|
dict.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, namespace):
|
def __init__(self, namespace):
|
||||||
|
|||||||
+31
-19
@@ -10,19 +10,22 @@ from cherrypy.lib import httputil, reprconf
|
|||||||
class Application(object):
|
class Application(object):
|
||||||
"""A CherryPy Application.
|
"""A CherryPy Application.
|
||||||
|
|
||||||
Servers and gateways should not instantiate Request objects directly.
|
Servers and gateways should not instantiate Request objects
|
||||||
Instead, they should ask an Application object for a request object.
|
directly. Instead, they should ask an Application object for a
|
||||||
|
request object.
|
||||||
|
|
||||||
An instance of this class may also be used as a WSGI callable
|
An instance of this class may also be used as a WSGI callable (WSGI
|
||||||
(WSGI application object) for itself.
|
application object) for itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
root = None
|
root = None
|
||||||
"""The top-most container of page handlers for this app. Handlers should
|
"""The top-most container of page handlers for this app.
|
||||||
be arranged in a hierarchy of attributes, matching the expected URI
|
|
||||||
hierarchy; the default dispatcher then searches this hierarchy for a
|
Handlers should be arranged in a hierarchy of attributes, matching
|
||||||
matching handler. When using a dispatcher other than the default,
|
the expected URI hierarchy; the default dispatcher then searches
|
||||||
this value may be None."""
|
this hierarchy for a matching handler. When using a dispatcher other
|
||||||
|
than the default, this value may be None.
|
||||||
|
"""
|
||||||
|
|
||||||
config = {}
|
config = {}
|
||||||
"""A dict of {path: pathconf} pairs, where 'pathconf' is itself a dict
|
"""A dict of {path: pathconf} pairs, where 'pathconf' is itself a dict
|
||||||
@@ -32,10 +35,16 @@ class Application(object):
|
|||||||
toolboxes = {'tools': cherrypy.tools}
|
toolboxes = {'tools': cherrypy.tools}
|
||||||
|
|
||||||
log = None
|
log = None
|
||||||
"""A LogManager instance. See _cplogging."""
|
"""A LogManager instance.
|
||||||
|
|
||||||
|
See _cplogging.
|
||||||
|
"""
|
||||||
|
|
||||||
wsgiapp = None
|
wsgiapp = None
|
||||||
"""A CPWSGIApp instance. See _cpwsgi."""
|
"""A CPWSGIApp instance.
|
||||||
|
|
||||||
|
See _cpwsgi.
|
||||||
|
"""
|
||||||
|
|
||||||
request_class = _cprequest.Request
|
request_class = _cprequest.Request
|
||||||
response_class = _cprequest.Response
|
response_class = _cprequest.Response
|
||||||
@@ -82,12 +91,15 @@ class Application(object):
|
|||||||
def script_name(self): # noqa: D401; irrelevant for properties
|
def script_name(self): # noqa: D401; irrelevant for properties
|
||||||
"""The URI "mount point" for this app.
|
"""The URI "mount point" for this app.
|
||||||
|
|
||||||
A mount point is that portion of the URI which is constant for all URIs
|
A mount point is that portion of the URI which is constant for
|
||||||
that are serviced by this application; it does not include scheme,
|
all URIs that are serviced by this application; it does not
|
||||||
host, or proxy ("virtual host") portions of the URI.
|
include scheme, host, or proxy ("virtual host") portions of the
|
||||||
|
URI.
|
||||||
|
|
||||||
For example, if script_name is "/my/cool/app", then the URL
|
For example, if script_name is "/my/cool/app", then the URL "
|
||||||
"http://www.example.com/my/cool/app/page1" might be handled by a
|
|
||||||
|
http://www.example.com/my/cool/app/page1"
|
||||||
|
might be handled by a
|
||||||
"page1" method on the root object.
|
"page1" method on the root object.
|
||||||
|
|
||||||
The value of script_name MUST NOT end in a slash. If the script_name
|
The value of script_name MUST NOT end in a slash. If the script_name
|
||||||
@@ -171,9 +183,9 @@ class Application(object):
|
|||||||
class Tree(object):
|
class Tree(object):
|
||||||
"""A registry of CherryPy applications, mounted at diverse points.
|
"""A registry of CherryPy applications, mounted at diverse points.
|
||||||
|
|
||||||
An instance of this class may also be used as a WSGI callable
|
An instance of this class may also be used as a WSGI callable (WSGI
|
||||||
(WSGI application object), in which case it dispatches to all
|
application object), in which case it dispatches to all mounted
|
||||||
mounted apps.
|
apps.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
apps = {}
|
apps = {}
|
||||||
|
|||||||
+34
-26
@@ -1,10 +1,10 @@
|
|||||||
"""WSGI interface (see PEP 333 and 3333).
|
"""WSGI interface (see PEP 333 and 3333).
|
||||||
|
|
||||||
Note that WSGI environ keys and values are 'native strings'; that is,
|
Note that WSGI environ keys and values are 'native strings'; that is,
|
||||||
whatever the type of "" is. For Python 2, that's a byte string; for Python 3,
|
whatever the type of "" is. For Python 2, that's a byte string; for
|
||||||
it's a unicode string. But PEP 3333 says: "even if Python's str type is
|
Python 3, it's a unicode string. But PEP 3333 says: "even if Python's
|
||||||
actually Unicode "under the hood", the content of native strings must
|
str type is actually Unicode "under the hood", the content of native
|
||||||
still be translatable to bytes via the Latin-1 encoding!"
|
strings must still be translatable to bytes via the Latin-1 encoding!"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys as _sys
|
import sys as _sys
|
||||||
@@ -34,7 +34,6 @@ def downgrade_wsgi_ux_to_1x(environ):
|
|||||||
|
|
||||||
|
|
||||||
class VirtualHost(object):
|
class VirtualHost(object):
|
||||||
|
|
||||||
"""Select a different WSGI application based on the Host header.
|
"""Select a different WSGI application based on the Host header.
|
||||||
|
|
||||||
This can be useful when running multiple sites within one CP server.
|
This can be useful when running multiple sites within one CP server.
|
||||||
@@ -56,7 +55,10 @@ class VirtualHost(object):
|
|||||||
cherrypy.tree.graft(vhost)
|
cherrypy.tree.graft(vhost)
|
||||||
"""
|
"""
|
||||||
default = None
|
default = None
|
||||||
"""Required. The default WSGI application."""
|
"""Required.
|
||||||
|
|
||||||
|
The default WSGI application.
|
||||||
|
"""
|
||||||
|
|
||||||
use_x_forwarded_host = True
|
use_x_forwarded_host = True
|
||||||
"""If True (the default), any "X-Forwarded-Host"
|
"""If True (the default), any "X-Forwarded-Host"
|
||||||
@@ -65,11 +67,12 @@ class VirtualHost(object):
|
|||||||
|
|
||||||
domains = {}
|
domains = {}
|
||||||
"""A dict of {host header value: application} pairs.
|
"""A dict of {host header value: application} pairs.
|
||||||
The incoming "Host" request header is looked up in this dict,
|
|
||||||
and, if a match is found, the corresponding WSGI application
|
The incoming "Host" request header is looked up in this dict, and,
|
||||||
will be called instead of the default. Note that you often need
|
if a match is found, the corresponding WSGI application will be
|
||||||
separate entries for "example.com" and "www.example.com".
|
called instead of the default. Note that you often need separate
|
||||||
In addition, "Host" headers may contain the port number.
|
entries for "example.com" and "www.example.com". In addition, "Host"
|
||||||
|
headers may contain the port number.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, default, domains=None, use_x_forwarded_host=True):
|
def __init__(self, default, domains=None, use_x_forwarded_host=True):
|
||||||
@@ -89,7 +92,6 @@ class VirtualHost(object):
|
|||||||
|
|
||||||
|
|
||||||
class InternalRedirector(object):
|
class InternalRedirector(object):
|
||||||
|
|
||||||
"""WSGI middleware that handles raised cherrypy.InternalRedirect."""
|
"""WSGI middleware that handles raised cherrypy.InternalRedirect."""
|
||||||
|
|
||||||
def __init__(self, nextapp, recursive=False):
|
def __init__(self, nextapp, recursive=False):
|
||||||
@@ -137,7 +139,6 @@ class InternalRedirector(object):
|
|||||||
|
|
||||||
|
|
||||||
class ExceptionTrapper(object):
|
class ExceptionTrapper(object):
|
||||||
|
|
||||||
"""WSGI middleware that traps exceptions."""
|
"""WSGI middleware that traps exceptions."""
|
||||||
|
|
||||||
def __init__(self, nextapp, throws=(KeyboardInterrupt, SystemExit)):
|
def __init__(self, nextapp, throws=(KeyboardInterrupt, SystemExit)):
|
||||||
@@ -226,7 +227,6 @@ class _TrappedResponse(object):
|
|||||||
|
|
||||||
|
|
||||||
class AppResponse(object):
|
class AppResponse(object):
|
||||||
|
|
||||||
"""WSGI response iterable for CherryPy applications."""
|
"""WSGI response iterable for CherryPy applications."""
|
||||||
|
|
||||||
def __init__(self, environ, start_response, cpapp):
|
def __init__(self, environ, start_response, cpapp):
|
||||||
@@ -277,7 +277,10 @@ class AppResponse(object):
|
|||||||
return next(self.iter_response)
|
return next(self.iter_response)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close and de-reference the current request and response. (Core)"""
|
"""Close and de-reference the current request and response.
|
||||||
|
|
||||||
|
(Core)
|
||||||
|
"""
|
||||||
streaming = _cherrypy.serving.response.stream
|
streaming = _cherrypy.serving.response.stream
|
||||||
self.cpapp.release_serving()
|
self.cpapp.release_serving()
|
||||||
|
|
||||||
@@ -380,18 +383,20 @@ class AppResponse(object):
|
|||||||
|
|
||||||
|
|
||||||
class CPWSGIApp(object):
|
class CPWSGIApp(object):
|
||||||
|
|
||||||
"""A WSGI application object for a CherryPy Application."""
|
"""A WSGI application object for a CherryPy Application."""
|
||||||
|
|
||||||
pipeline = [
|
pipeline = [
|
||||||
('ExceptionTrapper', ExceptionTrapper),
|
('ExceptionTrapper', ExceptionTrapper),
|
||||||
('InternalRedirector', InternalRedirector),
|
('InternalRedirector', InternalRedirector),
|
||||||
]
|
]
|
||||||
"""A list of (name, wsgiapp) pairs. Each 'wsgiapp' MUST be a
|
"""A list of (name, wsgiapp) pairs.
|
||||||
constructor that takes an initial, positional 'nextapp' argument,
|
|
||||||
plus optional keyword arguments, and returns a WSGI application
|
Each 'wsgiapp' MUST be a constructor that takes an initial,
|
||||||
(that takes environ and start_response arguments). The 'name' can
|
positional 'nextapp' argument, plus optional keyword arguments, and
|
||||||
be any you choose, and will correspond to keys in self.config."""
|
returns a WSGI application (that takes environ and start_response
|
||||||
|
arguments). The 'name' can be any you choose, and will correspond to
|
||||||
|
keys in self.config.
|
||||||
|
"""
|
||||||
|
|
||||||
head = None
|
head = None
|
||||||
"""Rather than nest all apps in the pipeline on each call, it's only
|
"""Rather than nest all apps in the pipeline on each call, it's only
|
||||||
@@ -399,9 +404,12 @@ class CPWSGIApp(object):
|
|||||||
this to None again if you change self.pipeline after calling self."""
|
this to None again if you change self.pipeline after calling self."""
|
||||||
|
|
||||||
config = {}
|
config = {}
|
||||||
"""A dict whose keys match names listed in the pipeline. Each
|
"""A dict whose keys match names listed in the pipeline.
|
||||||
value is a further dict which will be passed to the corresponding
|
|
||||||
named WSGI callable (from the pipeline) as keyword arguments."""
|
Each value is a further dict which will be passed to the
|
||||||
|
corresponding named WSGI callable (from the pipeline) as keyword
|
||||||
|
arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
response_class = AppResponse
|
response_class = AppResponse
|
||||||
"""The class to instantiate and return as the next app in the WSGI chain.
|
"""The class to instantiate and return as the next app in the WSGI chain.
|
||||||
@@ -417,8 +425,8 @@ class CPWSGIApp(object):
|
|||||||
def tail(self, environ, start_response):
|
def tail(self, environ, start_response):
|
||||||
"""WSGI application callable for the actual CherryPy application.
|
"""WSGI application callable for the actual CherryPy application.
|
||||||
|
|
||||||
You probably shouldn't call this; call self.__call__ instead,
|
You probably shouldn't call this; call self.__call__ instead, so
|
||||||
so that any WSGI middleware in self.pipeline can run first.
|
that any WSGI middleware in self.pipeline can run first.
|
||||||
"""
|
"""
|
||||||
return self.response_class(environ, start_response, self.cpapp)
|
return self.response_class(environ, start_response, self.cpapp)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""WSGI server interface (see PEP 333).
|
||||||
WSGI server interface (see PEP 333).
|
|
||||||
|
|
||||||
This adds some CP-specific bits to the framework-agnostic cheroot package.
|
This adds some CP-specific bits to the framework-agnostic cheroot
|
||||||
|
package.
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -35,10 +35,11 @@ class CPWSGIHTTPRequest(cheroot.server.HTTPRequest):
|
|||||||
class CPWSGIServer(cheroot.wsgi.Server):
|
class CPWSGIServer(cheroot.wsgi.Server):
|
||||||
"""Wrapper for cheroot.wsgi.Server.
|
"""Wrapper for cheroot.wsgi.Server.
|
||||||
|
|
||||||
cheroot has been designed to not reference CherryPy in any way,
|
cheroot has been designed to not reference CherryPy in any way, so
|
||||||
so that it can be used in other frameworks and applications. Therefore,
|
that it can be used in other frameworks and applications. Therefore,
|
||||||
we wrap it here, so we can set our own mount points from cherrypy.tree
|
we wrap it here, so we can set our own mount points from
|
||||||
and apply some attributes from config -> cherrypy.server -> wsgi.Server.
|
cherrypy.tree and apply some attributes from config ->
|
||||||
|
cherrypy.server -> wsgi.Server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
fmt = 'CherryPy/{cherrypy.__version__} {cheroot.wsgi.Server.version}'
|
fmt = 'CherryPy/{cherrypy.__version__} {cheroot.wsgi.Server.version}'
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ def popargs(*args, **kwargs):
|
|||||||
class Root:
|
class Root:
|
||||||
def index(self):
|
def index(self):
|
||||||
#...
|
#...
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# Since keyword arg comes after *args, we have to process it ourselves
|
# Since keyword arg comes after *args, we have to process it ourselves
|
||||||
# for lower versions of python.
|
# for lower versions of python.
|
||||||
@@ -201,16 +200,17 @@ def url(path='', qs='', script_name=None, base=None, relative=None):
|
|||||||
If it does not start with a slash, this returns
|
If it does not start with a slash, this returns
|
||||||
(base + script_name [+ request.path_info] + path + qs).
|
(base + script_name [+ request.path_info] + path + qs).
|
||||||
|
|
||||||
If script_name is None, cherrypy.request will be used
|
If script_name is None, cherrypy.request will be used to find a
|
||||||
to find a script_name, if available.
|
script_name, if available.
|
||||||
|
|
||||||
If base is None, cherrypy.request.base will be used (if available).
|
If base is None, cherrypy.request.base will be used (if available).
|
||||||
Note that you can use cherrypy.tools.proxy to change this.
|
Note that you can use cherrypy.tools.proxy to change this.
|
||||||
|
|
||||||
Finally, note that this function can be used to obtain an absolute URL
|
Finally, note that this function can be used to obtain an absolute
|
||||||
for the current request path (minus the querystring) by passing no args.
|
URL for the current request path (minus the querystring) by passing
|
||||||
If you call url(qs=cherrypy.request.query_string), you should get the
|
no args. If you call url(qs=cherrypy.request.query_string), you
|
||||||
original browser URL (assuming no internal redirections).
|
should get the original browser URL (assuming no internal
|
||||||
|
redirections).
|
||||||
|
|
||||||
If relative is None or not provided, request.app.relative_urls will
|
If relative is None or not provided, request.app.relative_urls will
|
||||||
be used (if available, else False). If False, the output will be an
|
be used (if available, else False). If False, the output will be an
|
||||||
@@ -320,8 +320,8 @@ def normalize_path(path):
|
|||||||
class _ClassPropertyDescriptor(object):
|
class _ClassPropertyDescriptor(object):
|
||||||
"""Descript for read-only class-based property.
|
"""Descript for read-only class-based property.
|
||||||
|
|
||||||
Turns a classmethod-decorated func into a read-only property of that class
|
Turns a classmethod-decorated func into a read-only property of that
|
||||||
type (means the value cannot be set).
|
class type (means the value cannot be set).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, fget, fset=None):
|
def __init__(self, fget, fset=None):
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""
|
"""JSON support.
|
||||||
JSON support.
|
|
||||||
|
|
||||||
Expose preferred json module as json and provide encode/decode
|
Expose preferred json module as json and provide encode/decode
|
||||||
convenience functions.
|
convenience functions.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ def is_iterator(obj):
|
|||||||
|
|
||||||
(i.e. like a generator).
|
(i.e. like a generator).
|
||||||
|
|
||||||
This will return False for objects which are iterable,
|
This will return False for objects which are iterable, but not
|
||||||
but not iterators themselves.
|
iterators themselves.
|
||||||
"""
|
"""
|
||||||
from types import GeneratorType
|
from types import GeneratorType
|
||||||
if isinstance(obj, GeneratorType):
|
if isinstance(obj, GeneratorType):
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ as the credentials store::
|
|||||||
'tools.auth_basic.accept_charset': 'UTF-8',
|
'tools.auth_basic.accept_charset': 'UTF-8',
|
||||||
}
|
}
|
||||||
app_config = { '/' : basic_auth }
|
app_config = { '/' : basic_auth }
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def TRACE(msg):
|
|||||||
|
|
||||||
|
|
||||||
def get_ha1_dict_plain(user_password_dict):
|
def get_ha1_dict_plain(user_password_dict):
|
||||||
"""Returns a get_ha1 function which obtains a plaintext password from a
|
"""Return a get_ha1 function which obtains a plaintext password from a
|
||||||
dictionary of the form: {username : password}.
|
dictionary of the form: {username : password}.
|
||||||
|
|
||||||
If you want a simple dictionary-based authentication scheme, with plaintext
|
If you want a simple dictionary-based authentication scheme, with plaintext
|
||||||
@@ -72,7 +72,7 @@ def get_ha1_dict_plain(user_password_dict):
|
|||||||
|
|
||||||
|
|
||||||
def get_ha1_dict(user_ha1_dict):
|
def get_ha1_dict(user_ha1_dict):
|
||||||
"""Returns a get_ha1 function which obtains a HA1 password hash from a
|
"""Return a get_ha1 function which obtains a HA1 password hash from a
|
||||||
dictionary of the form: {username : HA1}.
|
dictionary of the form: {username : HA1}.
|
||||||
|
|
||||||
If you want a dictionary-based authentication scheme, but with
|
If you want a dictionary-based authentication scheme, but with
|
||||||
@@ -87,7 +87,7 @@ def get_ha1_dict(user_ha1_dict):
|
|||||||
|
|
||||||
|
|
||||||
def get_ha1_file_htdigest(filename):
|
def get_ha1_file_htdigest(filename):
|
||||||
"""Returns a get_ha1 function which obtains a HA1 password hash from a
|
"""Return a get_ha1 function which obtains a HA1 password hash from a
|
||||||
flat file with lines of the same format as that produced by the Apache
|
flat file with lines of the same format as that produced by the Apache
|
||||||
htdigest utility. For example, for realm 'wonderland', username 'alice',
|
htdigest utility. For example, for realm 'wonderland', username 'alice',
|
||||||
and password '4x5istwelve', the htdigest line would be::
|
and password '4x5istwelve', the htdigest line would be::
|
||||||
@@ -101,13 +101,12 @@ def get_ha1_file_htdigest(filename):
|
|||||||
"""
|
"""
|
||||||
def get_ha1(realm, username):
|
def get_ha1(realm, username):
|
||||||
result = None
|
result = None
|
||||||
f = open(filename, 'r')
|
with open(filename, 'r') as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
u, r, ha1 = line.rstrip().split(':')
|
u, r, ha1 = line.rstrip().split(':')
|
||||||
if u == username and r == realm:
|
if u == username and r == realm:
|
||||||
result = ha1
|
result = ha1
|
||||||
break
|
break
|
||||||
f.close()
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
return get_ha1
|
return get_ha1
|
||||||
@@ -136,7 +135,7 @@ def synthesize_nonce(s, key, timestamp=None):
|
|||||||
|
|
||||||
|
|
||||||
def H(s):
|
def H(s):
|
||||||
"""The hash function H"""
|
"""The hash function H."""
|
||||||
return md5_hex(s)
|
return md5_hex(s)
|
||||||
|
|
||||||
|
|
||||||
@@ -260,10 +259,11 @@ class HttpDigestAuthorization(object):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def is_nonce_stale(self, max_age_seconds=600):
|
def is_nonce_stale(self, max_age_seconds=600):
|
||||||
"""Returns True if a validated nonce is stale. The nonce contains a
|
"""Return True if a validated nonce is stale.
|
||||||
timestamp in plaintext and also a secure hash of the timestamp.
|
|
||||||
You should first validate the nonce to ensure the plaintext
|
The nonce contains a timestamp in plaintext and also a secure
|
||||||
timestamp is not spoofed.
|
hash of the timestamp. You should first validate the nonce to
|
||||||
|
ensure the plaintext timestamp is not spoofed.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
timestamp, hashpart = self.nonce.split(':', 1)
|
timestamp, hashpart = self.nonce.split(':', 1)
|
||||||
@@ -276,7 +276,10 @@ class HttpDigestAuthorization(object):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def HA2(self, entity_body=''):
|
def HA2(self, entity_body=''):
|
||||||
"""Returns the H(A2) string. See :rfc:`2617` section 3.2.2.3."""
|
"""Return the H(A2) string.
|
||||||
|
|
||||||
|
See :rfc:`2617` section 3.2.2.3.
|
||||||
|
"""
|
||||||
# RFC 2617 3.2.2.3
|
# RFC 2617 3.2.2.3
|
||||||
# If the "qop" directive's value is "auth" or is unspecified,
|
# If the "qop" directive's value is "auth" or is unspecified,
|
||||||
# then A2 is:
|
# then A2 is:
|
||||||
@@ -307,7 +310,6 @@ class HttpDigestAuthorization(object):
|
|||||||
4.3. This refers to the entity the user agent sent in the
|
4.3. This refers to the entity the user agent sent in the
|
||||||
request which has the Authorization header. Typically GET
|
request which has the Authorization header. Typically GET
|
||||||
requests don't have an entity, and POST requests do.
|
requests don't have an entity, and POST requests do.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ha2 = self.HA2(entity_body)
|
ha2 = self.HA2(entity_body)
|
||||||
# Request-Digest -- RFC 2617 3.2.2.1
|
# Request-Digest -- RFC 2617 3.2.2.1
|
||||||
@@ -396,7 +398,6 @@ def digest_auth(realm, get_ha1, key, debug=False, accept_charset='utf-8'):
|
|||||||
key
|
key
|
||||||
A secret string known only to the server, used in the synthesis
|
A secret string known only to the server, used in the synthesis
|
||||||
of nonces.
|
of nonces.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
|
|
||||||
@@ -448,9 +449,7 @@ def digest_auth(realm, get_ha1, key, debug=False, accept_charset='utf-8'):
|
|||||||
|
|
||||||
|
|
||||||
def _respond_401(realm, key, accept_charset, debug, **kwargs):
|
def _respond_401(realm, key, accept_charset, debug, **kwargs):
|
||||||
"""
|
"""Respond with 401 status and a WWW-Authenticate header."""
|
||||||
Respond with 401 status and a WWW-Authenticate header
|
|
||||||
"""
|
|
||||||
header = www_authenticate(
|
header = www_authenticate(
|
||||||
realm, key,
|
realm, key,
|
||||||
accept_charset=accept_charset,
|
accept_charset=accept_charset,
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ from cherrypy.lib import cptools, httputil
|
|||||||
|
|
||||||
|
|
||||||
class Cache(object):
|
class Cache(object):
|
||||||
|
|
||||||
"""Base class for Cache implementations."""
|
"""Base class for Cache implementations."""
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
@@ -64,17 +63,16 @@ class Cache(object):
|
|||||||
|
|
||||||
# ------------------------------ Memory Cache ------------------------------- #
|
# ------------------------------ Memory Cache ------------------------------- #
|
||||||
class AntiStampedeCache(dict):
|
class AntiStampedeCache(dict):
|
||||||
|
|
||||||
"""A storage system for cached items which reduces stampede collisions."""
|
"""A storage system for cached items which reduces stampede collisions."""
|
||||||
|
|
||||||
def wait(self, key, timeout=5, debug=False):
|
def wait(self, key, timeout=5, debug=False):
|
||||||
"""Return the cached value for the given key, or None.
|
"""Return the cached value for the given key, or None.
|
||||||
|
|
||||||
If timeout is not None, and the value is already
|
If timeout is not None, and the value is already being
|
||||||
being calculated by another thread, wait until the given timeout has
|
calculated by another thread, wait until the given timeout has
|
||||||
elapsed. If the value is available before the timeout expires, it is
|
elapsed. If the value is available before the timeout expires,
|
||||||
returned. If not, None is returned, and a sentinel placed in the cache
|
it is returned. If not, None is returned, and a sentinel placed
|
||||||
to signal other threads to wait.
|
in the cache to signal other threads to wait.
|
||||||
|
|
||||||
If timeout is None, no waiting is performed nor sentinels used.
|
If timeout is None, no waiting is performed nor sentinels used.
|
||||||
"""
|
"""
|
||||||
@@ -127,7 +125,6 @@ class AntiStampedeCache(dict):
|
|||||||
|
|
||||||
|
|
||||||
class MemoryCache(Cache):
|
class MemoryCache(Cache):
|
||||||
|
|
||||||
"""An in-memory cache for varying response content.
|
"""An in-memory cache for varying response content.
|
||||||
|
|
||||||
Each key in self.store is a URI, and each value is an AntiStampedeCache.
|
Each key in self.store is a URI, and each value is an AntiStampedeCache.
|
||||||
@@ -381,7 +378,10 @@ def get(invalid_methods=('POST', 'PUT', 'DELETE'), debug=False, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
def tee_output():
|
def tee_output():
|
||||||
"""Tee response output to cache storage. Internal."""
|
"""Tee response output to cache storage.
|
||||||
|
|
||||||
|
Internal.
|
||||||
|
"""
|
||||||
# Used by CachingTool by attaching to request.hooks
|
# Used by CachingTool by attaching to request.hooks
|
||||||
|
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
@@ -441,7 +441,6 @@ def expires(secs=0, force=False, debug=False):
|
|||||||
* Expires
|
* Expires
|
||||||
|
|
||||||
If any are already present, none of the above response headers are set.
|
If any are already present, none of the above response headers are set.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
|
|||||||
@@ -334,9 +334,10 @@ class CoverStats(object):
|
|||||||
yield '</body></html>'
|
yield '</body></html>'
|
||||||
|
|
||||||
def annotated_file(self, filename, statements, excluded, missing):
|
def annotated_file(self, filename, statements, excluded, missing):
|
||||||
source = open(filename, 'r')
|
with open(filename, 'r') as source:
|
||||||
|
lines = source.readlines()
|
||||||
buffer = []
|
buffer = []
|
||||||
for lineno, line in enumerate(source.readlines()):
|
for lineno, line in enumerate(lines):
|
||||||
lineno += 1
|
lineno += 1
|
||||||
line = line.strip('\n\r')
|
line = line.strip('\n\r')
|
||||||
empty_the_buffer = True
|
empty_the_buffer = True
|
||||||
|
|||||||
@@ -184,7 +184,6 @@ To report statistics::
|
|||||||
To format statistics reports::
|
To format statistics reports::
|
||||||
|
|
||||||
See 'Reporting', above.
|
See 'Reporting', above.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -254,7 +253,6 @@ def proc_time(s):
|
|||||||
|
|
||||||
|
|
||||||
class ByteCountWrapper(object):
|
class ByteCountWrapper(object):
|
||||||
|
|
||||||
"""Wraps a file-like object, counting the number of bytes read."""
|
"""Wraps a file-like object, counting the number of bytes read."""
|
||||||
|
|
||||||
def __init__(self, rfile):
|
def __init__(self, rfile):
|
||||||
@@ -307,7 +305,6 @@ def _get_threading_ident():
|
|||||||
|
|
||||||
|
|
||||||
class StatsTool(cherrypy.Tool):
|
class StatsTool(cherrypy.Tool):
|
||||||
|
|
||||||
"""Record various information about the current request."""
|
"""Record various information about the current request."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -316,8 +313,8 @@ class StatsTool(cherrypy.Tool):
|
|||||||
def _setup(self):
|
def _setup(self):
|
||||||
"""Hook this tool into cherrypy.request.
|
"""Hook this tool into cherrypy.request.
|
||||||
|
|
||||||
The standard CherryPy request object will automatically call this
|
The standard CherryPy request object will automatically call
|
||||||
method when the tool is "turned on" in config.
|
this method when the tool is "turned on" in config.
|
||||||
"""
|
"""
|
||||||
if appstats.get('Enabled', False):
|
if appstats.get('Enabled', False):
|
||||||
cherrypy.Tool._setup(self)
|
cherrypy.Tool._setup(self)
|
||||||
|
|||||||
+41
-32
@@ -94,8 +94,8 @@ def validate_etags(autotags=False, debug=False):
|
|||||||
def validate_since():
|
def validate_since():
|
||||||
"""Validate the current Last-Modified against If-Modified-Since headers.
|
"""Validate the current Last-Modified against If-Modified-Since headers.
|
||||||
|
|
||||||
If no code has set the Last-Modified response header, then no validation
|
If no code has set the Last-Modified response header, then no
|
||||||
will be performed.
|
validation will be performed.
|
||||||
"""
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
lastmod = response.headers.get('Last-Modified')
|
lastmod = response.headers.get('Last-Modified')
|
||||||
@@ -123,9 +123,9 @@ def validate_since():
|
|||||||
def allow(methods=None, debug=False):
|
def allow(methods=None, debug=False):
|
||||||
"""Raise 405 if request.method not in methods (default ['GET', 'HEAD']).
|
"""Raise 405 if request.method not in methods (default ['GET', 'HEAD']).
|
||||||
|
|
||||||
The given methods are case-insensitive, and may be in any order.
|
The given methods are case-insensitive, and may be in any order. If
|
||||||
If only one method is allowed, you may supply a single string;
|
only one method is allowed, you may supply a single string; if more
|
||||||
if more than one, supply a list of strings.
|
than one, supply a list of strings.
|
||||||
|
|
||||||
Regardless of whether the current method is allowed or not, this
|
Regardless of whether the current method is allowed or not, this
|
||||||
also emits an 'Allow' response header, containing the given methods.
|
also emits an 'Allow' response header, containing the given methods.
|
||||||
@@ -154,22 +154,23 @@ def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For',
|
|||||||
scheme='X-Forwarded-Proto', debug=False):
|
scheme='X-Forwarded-Proto', debug=False):
|
||||||
"""Change the base URL (scheme://host[:port][/path]).
|
"""Change the base URL (scheme://host[:port][/path]).
|
||||||
|
|
||||||
For running a CP server behind Apache, lighttpd, or other HTTP server.
|
For running a CP server behind Apache, lighttpd, or other HTTP
|
||||||
|
server.
|
||||||
|
|
||||||
For Apache and lighttpd, you should leave the 'local' argument at the
|
For Apache and lighttpd, you should leave the 'local' argument at
|
||||||
default value of 'X-Forwarded-Host'. For Squid, you probably want to set
|
the default value of 'X-Forwarded-Host'. For Squid, you probably
|
||||||
tools.proxy.local = 'Origin'.
|
want to set tools.proxy.local = 'Origin'.
|
||||||
|
|
||||||
If you want the new request.base to include path info (not just the host),
|
If you want the new request.base to include path info (not just the
|
||||||
you must explicitly set base to the full base path, and ALSO set 'local'
|
host), you must explicitly set base to the full base path, and ALSO
|
||||||
to '', so that the X-Forwarded-Host request header (which never includes
|
set 'local' to '', so that the X-Forwarded-Host request header
|
||||||
path info) does not override it. Regardless, the value for 'base' MUST
|
(which never includes path info) does not override it. Regardless,
|
||||||
NOT end in a slash.
|
the value for 'base' MUST NOT end in a slash.
|
||||||
|
|
||||||
cherrypy.request.remote.ip (the IP address of the client) will be
|
cherrypy.request.remote.ip (the IP address of the client) will be
|
||||||
rewritten if the header specified by the 'remote' arg is valid.
|
rewritten if the header specified by the 'remote' arg is valid. By
|
||||||
By default, 'remote' is set to 'X-Forwarded-For'. If you do not
|
default, 'remote' is set to 'X-Forwarded-For'. If you do not want to
|
||||||
want to rewrite remote.ip, set the 'remote' arg to an empty string.
|
rewrite remote.ip, set the 'remote' arg to an empty string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
@@ -217,8 +218,8 @@ def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For',
|
|||||||
def ignore_headers(headers=('Range',), debug=False):
|
def ignore_headers(headers=('Range',), debug=False):
|
||||||
"""Delete request headers whose field names are included in 'headers'.
|
"""Delete request headers whose field names are included in 'headers'.
|
||||||
|
|
||||||
This is a useful tool for working behind certain HTTP servers;
|
This is a useful tool for working behind certain HTTP servers; for
|
||||||
for example, Apache duplicates the work that CP does for 'Range'
|
example, Apache duplicates the work that CP does for 'Range'
|
||||||
headers, and will doubly-truncate the response.
|
headers, and will doubly-truncate the response.
|
||||||
"""
|
"""
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
@@ -281,7 +282,6 @@ def referer(pattern, accept=True, accept_missing=False, error=403,
|
|||||||
|
|
||||||
|
|
||||||
class SessionAuth(object):
|
class SessionAuth(object):
|
||||||
|
|
||||||
"""Assert that the user is logged in."""
|
"""Assert that the user is logged in."""
|
||||||
|
|
||||||
session_key = 'username'
|
session_key = 'username'
|
||||||
@@ -319,7 +319,10 @@ Message: %(error_msg)s
|
|||||||
</body></html>""") % vars()).encode('utf-8')
|
</body></html>""") % vars()).encode('utf-8')
|
||||||
|
|
||||||
def do_login(self, username, password, from_page='..', **kwargs):
|
def do_login(self, username, password, from_page='..', **kwargs):
|
||||||
"""Login. May raise redirect, or return True if request handled."""
|
"""Login.
|
||||||
|
|
||||||
|
May raise redirect, or return True if request handled.
|
||||||
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
error_msg = self.check_username_and_password(username, password)
|
error_msg = self.check_username_and_password(username, password)
|
||||||
if error_msg:
|
if error_msg:
|
||||||
@@ -336,7 +339,10 @@ Message: %(error_msg)s
|
|||||||
raise cherrypy.HTTPRedirect(from_page or '/')
|
raise cherrypy.HTTPRedirect(from_page or '/')
|
||||||
|
|
||||||
def do_logout(self, from_page='..', **kwargs):
|
def do_logout(self, from_page='..', **kwargs):
|
||||||
"""Logout. May raise redirect, or return True if request handled."""
|
"""Logout.
|
||||||
|
|
||||||
|
May raise redirect, or return True if request handled.
|
||||||
|
"""
|
||||||
sess = cherrypy.session
|
sess = cherrypy.session
|
||||||
username = sess.get(self.session_key)
|
username = sess.get(self.session_key)
|
||||||
sess[self.session_key] = None
|
sess[self.session_key] = None
|
||||||
@@ -346,7 +352,9 @@ Message: %(error_msg)s
|
|||||||
raise cherrypy.HTTPRedirect(from_page)
|
raise cherrypy.HTTPRedirect(from_page)
|
||||||
|
|
||||||
def do_check(self):
|
def do_check(self):
|
||||||
"""Assert username. Raise redirect, or return True if request handled.
|
"""Assert username.
|
||||||
|
|
||||||
|
Raise redirect, or return True if request handled.
|
||||||
"""
|
"""
|
||||||
sess = cherrypy.session
|
sess = cherrypy.session
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
@@ -408,8 +416,7 @@ def session_auth(**kwargs):
|
|||||||
|
|
||||||
Any attribute of the SessionAuth class may be overridden
|
Any attribute of the SessionAuth class may be overridden
|
||||||
via a keyword arg to this function:
|
via a keyword arg to this function:
|
||||||
|
""" + '\n' + '\n '.join(
|
||||||
""" + '\n '.join(
|
|
||||||
'{!s}: {!s}'.format(k, type(getattr(SessionAuth, k)).__name__)
|
'{!s}: {!s}'.format(k, type(getattr(SessionAuth, k)).__name__)
|
||||||
for k in dir(SessionAuth)
|
for k in dir(SessionAuth)
|
||||||
if not k.startswith('__')
|
if not k.startswith('__')
|
||||||
@@ -490,8 +497,8 @@ def trailing_slash(missing=True, extra=False, status=None, debug=False):
|
|||||||
def flatten(debug=False):
|
def flatten(debug=False):
|
||||||
"""Wrap response.body in a generator that recursively iterates over body.
|
"""Wrap response.body in a generator that recursively iterates over body.
|
||||||
|
|
||||||
This allows cherrypy.response.body to consist of 'nested generators';
|
This allows cherrypy.response.body to consist of 'nested
|
||||||
that is, a set of generators that yield generators.
|
generators'; that is, a set of generators that yield generators.
|
||||||
"""
|
"""
|
||||||
def flattener(input):
|
def flattener(input):
|
||||||
numchunks = 0
|
numchunks = 0
|
||||||
@@ -622,13 +629,15 @@ def autovary(ignore=None, debug=False):
|
|||||||
|
|
||||||
|
|
||||||
def convert_params(exception=ValueError, error=400):
|
def convert_params(exception=ValueError, error=400):
|
||||||
"""Convert request params based on function annotations, with error handling.
|
"""Convert request params based on function annotations.
|
||||||
|
|
||||||
exception
|
This function also processes errors that are subclasses of ``exception``.
|
||||||
Exception class to catch.
|
|
||||||
|
|
||||||
status
|
:param BaseException exception: Exception class to catch.
|
||||||
The HTTP error code to return to the client on failure.
|
:type exception: BaseException
|
||||||
|
|
||||||
|
:param error: The HTTP status code to return to the client on failure.
|
||||||
|
:type error: int
|
||||||
"""
|
"""
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
types = request.handler.callable.__annotations__
|
types = request.handler.callable.__annotations__
|
||||||
|
|||||||
@@ -261,9 +261,7 @@ class ResponseEncoder:
|
|||||||
|
|
||||||
|
|
||||||
def prepare_iter(value):
|
def prepare_iter(value):
|
||||||
"""
|
"""Ensure response body is iterable and resolves to False when empty."""
|
||||||
Ensure response body is iterable and resolves to False when empty.
|
|
||||||
"""
|
|
||||||
if isinstance(value, text_or_bytes):
|
if isinstance(value, text_or_bytes):
|
||||||
# strings get wrapped in a list because iterating over a single
|
# strings get wrapped in a list because iterating over a single
|
||||||
# item list is much faster than iterating over every character
|
# item list is much faster than iterating over every character
|
||||||
@@ -360,7 +358,6 @@ def gzip(compress_level=5, mime_types=['text/html', 'text/plain'],
|
|||||||
* No 'gzip' or 'x-gzip' is present in the Accept-Encoding header
|
* No 'gzip' or 'x-gzip' is present in the Accept-Encoding header
|
||||||
* No 'gzip' or 'x-gzip' with a qvalue > 0 is present
|
* No 'gzip' or 'x-gzip' with a qvalue > 0 is present
|
||||||
* The 'identity' value is given with a qvalue > 0.
|
* The 'identity' value is given with a qvalue > 0.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
request = cherrypy.serving.request
|
request = cherrypy.serving.request
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from cherrypy.process.plugins import SimplePlugin
|
|||||||
|
|
||||||
|
|
||||||
class ReferrerTree(object):
|
class ReferrerTree(object):
|
||||||
|
|
||||||
"""An object which gathers all referrers of an object to a given depth."""
|
"""An object which gathers all referrers of an object to a given depth."""
|
||||||
|
|
||||||
peek_length = 40
|
peek_length = 40
|
||||||
@@ -132,7 +131,6 @@ def get_context(obj):
|
|||||||
|
|
||||||
|
|
||||||
class GCRoot(object):
|
class GCRoot(object):
|
||||||
|
|
||||||
"""A CherryPy page handler for testing reference leaks."""
|
"""A CherryPy page handler for testing reference leaks."""
|
||||||
|
|
||||||
classes = [
|
classes = [
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ def protocol_from_http(protocol_str):
|
|||||||
def get_ranges(headervalue, content_length):
|
def get_ranges(headervalue, content_length):
|
||||||
"""Return a list of (start, stop) indices from a Range header, or None.
|
"""Return a list of (start, stop) indices from a Range header, or None.
|
||||||
|
|
||||||
Each (start, stop) tuple will be composed of two ints, which are suitable
|
Each (start, stop) tuple will be composed of two ints, which are
|
||||||
for use in a slicing operation. That is, the header "Range: bytes=3-6",
|
suitable for use in a slicing operation. That is, the header "Range:
|
||||||
if applied against a Python string, is requesting resource[3:7]. This
|
bytes=3-6", if applied against a Python string, is requesting
|
||||||
function will return the list [(3, 7)].
|
resource[3:7]. This function will return the list [(3, 7)].
|
||||||
|
|
||||||
If this function returns an empty list, you should return HTTP 416.
|
If this function returns an empty list, you should return HTTP 416.
|
||||||
"""
|
"""
|
||||||
@@ -127,7 +127,6 @@ def get_ranges(headervalue, content_length):
|
|||||||
|
|
||||||
|
|
||||||
class HeaderElement(object):
|
class HeaderElement(object):
|
||||||
|
|
||||||
"""An element (with parameters) from an HTTP header's element list."""
|
"""An element (with parameters) from an HTTP header's element list."""
|
||||||
|
|
||||||
def __init__(self, value, params=None):
|
def __init__(self, value, params=None):
|
||||||
@@ -169,14 +168,14 @@ q_separator = re.compile(r'; *q *=')
|
|||||||
|
|
||||||
|
|
||||||
class AcceptElement(HeaderElement):
|
class AcceptElement(HeaderElement):
|
||||||
|
|
||||||
"""An element (with parameters) from an Accept* header's element list.
|
"""An element (with parameters) from an Accept* header's element list.
|
||||||
|
|
||||||
AcceptElement objects are comparable; the more-preferred object will be
|
AcceptElement objects are comparable; the more-preferred object will
|
||||||
"less than" the less-preferred object. They are also therefore sortable;
|
be "less than" the less-preferred object. They are also therefore
|
||||||
if you sort a list of AcceptElement objects, they will be listed in
|
sortable; if you sort a list of AcceptElement objects, they will be
|
||||||
priority order; the most preferred value will be first. Yes, it should
|
listed in priority order; the most preferred value will be first.
|
||||||
have been the other way around, but it's too late to fix now.
|
Yes, it should have been the other way around, but it's too late to
|
||||||
|
fix now.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -249,8 +248,7 @@ def header_elements(fieldname, fieldvalue):
|
|||||||
|
|
||||||
|
|
||||||
def decode_TEXT(value):
|
def decode_TEXT(value):
|
||||||
r"""
|
r"""Decode :rfc:`2047` TEXT.
|
||||||
Decode :rfc:`2047` TEXT
|
|
||||||
|
|
||||||
>>> decode_TEXT("=?utf-8?q?f=C3=BCr?=") == b'f\xfcr'.decode('latin-1')
|
>>> decode_TEXT("=?utf-8?q?f=C3=BCr?=") == b'f\xfcr'.decode('latin-1')
|
||||||
True
|
True
|
||||||
@@ -265,9 +263,7 @@ def decode_TEXT(value):
|
|||||||
|
|
||||||
|
|
||||||
def decode_TEXT_maybe(value):
|
def decode_TEXT_maybe(value):
|
||||||
"""
|
"""Decode the text but only if '=?' appears in it."""
|
||||||
Decode the text but only if '=?' appears in it.
|
|
||||||
"""
|
|
||||||
return decode_TEXT(value) if '=?' in value else value
|
return decode_TEXT(value) if '=?' in value else value
|
||||||
|
|
||||||
|
|
||||||
@@ -388,7 +384,6 @@ def parse_query_string(query_string, keep_blank_values=True, encoding='utf-8'):
|
|||||||
|
|
||||||
|
|
||||||
class CaseInsensitiveDict(jaraco.collections.KeyTransformingDict):
|
class CaseInsensitiveDict(jaraco.collections.KeyTransformingDict):
|
||||||
|
|
||||||
"""A case-insensitive dict subclass.
|
"""A case-insensitive dict subclass.
|
||||||
|
|
||||||
Each key is changed on entry to title case.
|
Each key is changed on entry to title case.
|
||||||
@@ -417,7 +412,6 @@ else:
|
|||||||
|
|
||||||
|
|
||||||
class HeaderMap(CaseInsensitiveDict):
|
class HeaderMap(CaseInsensitiveDict):
|
||||||
|
|
||||||
"""A dict subclass for HTTP request and response headers.
|
"""A dict subclass for HTTP request and response headers.
|
||||||
|
|
||||||
Each key is changed on entry to str(key).title(). This allows headers
|
Each key is changed on entry to str(key).title(). This allows headers
|
||||||
@@ -494,7 +488,6 @@ class HeaderMap(CaseInsensitiveDict):
|
|||||||
|
|
||||||
|
|
||||||
class Host(object):
|
class Host(object):
|
||||||
|
|
||||||
"""An internet address.
|
"""An internet address.
|
||||||
|
|
||||||
name
|
name
|
||||||
@@ -516,3 +509,33 @@ class Host(object):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return 'httputil.Host(%r, %r, %r)' % (self.ip, self.port, self.name)
|
return 'httputil.Host(%r, %r, %r)' % (self.ip, self.port, self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizedHost(str):
|
||||||
|
r"""
|
||||||
|
Wraps a raw host header received from the network in
|
||||||
|
a sanitized version that elides dangerous characters.
|
||||||
|
|
||||||
|
>>> SanitizedHost('foo\nbar')
|
||||||
|
'foobar'
|
||||||
|
>>> SanitizedHost('foo\nbar').raw
|
||||||
|
'foo\nbar'
|
||||||
|
|
||||||
|
A SanitizedInstance is only returned if sanitization was performed.
|
||||||
|
|
||||||
|
>>> isinstance(SanitizedHost('foobar'), SanitizedHost)
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
dangerous = re.compile(r'[\n\r]')
|
||||||
|
|
||||||
|
def __new__(cls, raw):
|
||||||
|
sanitized = cls._sanitize(raw)
|
||||||
|
if sanitized == raw:
|
||||||
|
return raw
|
||||||
|
instance = super().__new__(cls, sanitized)
|
||||||
|
instance.raw = raw
|
||||||
|
return instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _sanitize(cls, raw):
|
||||||
|
return cls.dangerous.sub('', raw)
|
||||||
|
|||||||
@@ -7,22 +7,22 @@ class NeverExpires(object):
|
|||||||
|
|
||||||
|
|
||||||
class Timer(object):
|
class Timer(object):
|
||||||
"""
|
"""A simple timer that will indicate when an expiration time has passed."""
|
||||||
A simple timer that will indicate when an expiration time has passed.
|
|
||||||
"""
|
|
||||||
def __init__(self, expiration):
|
def __init__(self, expiration):
|
||||||
'Create a timer that expires at `expiration` (UTC datetime)'
|
'Create a timer that expires at `expiration` (UTC datetime)'
|
||||||
self.expiration = expiration
|
self.expiration = expiration
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def after(cls, elapsed):
|
def after(cls, elapsed):
|
||||||
"""
|
"""Return a timer that will expire after `elapsed` passes."""
|
||||||
Return a timer that will expire after `elapsed` passes.
|
return cls(
|
||||||
"""
|
datetime.datetime.now(datetime.timezone.utc) + elapsed,
|
||||||
return cls(datetime.datetime.utcnow() + elapsed)
|
)
|
||||||
|
|
||||||
def expired(self):
|
def expired(self):
|
||||||
return datetime.datetime.utcnow() >= self.expiration
|
return datetime.datetime.now(
|
||||||
|
datetime.timezone.utc,
|
||||||
|
) >= self.expiration
|
||||||
|
|
||||||
|
|
||||||
class LockTimeout(Exception):
|
class LockTimeout(Exception):
|
||||||
@@ -30,9 +30,7 @@ class LockTimeout(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class LockChecker(object):
|
class LockChecker(object):
|
||||||
"""
|
"""Keep track of the time and detect if a timeout has expired."""
|
||||||
Keep track of the time and detect if a timeout has expired
|
|
||||||
"""
|
|
||||||
def __init__(self, session_id, timeout):
|
def __init__(self, session_id, timeout):
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
if timeout:
|
if timeout:
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ to get a quick sanity-check on overall CP performance. Use the
|
|||||||
``--profile`` flag when running the test suite. Then, use the ``serve()``
|
``--profile`` flag when running the test suite. Then, use the ``serve()``
|
||||||
function to browse the results in a web browser. If you run this
|
function to browse the results in a web browser. If you run this
|
||||||
module from the command line, it will call ``serve()`` for you.
|
module from the command line, it will call ``serve()`` for you.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -47,7 +46,9 @@ try:
|
|||||||
import pstats
|
import pstats
|
||||||
|
|
||||||
def new_func_strip_path(func_name):
|
def new_func_strip_path(func_name):
|
||||||
"""Make profiler output more readable by adding `__init__` modules' parents
|
"""Add ``__init__`` modules' parents.
|
||||||
|
|
||||||
|
This makes the profiler output more readable.
|
||||||
"""
|
"""
|
||||||
filename, line, name = func_name
|
filename, line, name = func_name
|
||||||
if filename.endswith('__init__.py'):
|
if filename.endswith('__init__.py'):
|
||||||
|
|||||||
@@ -27,18 +27,17 @@ from cherrypy._cpcompat import text_or_bytes
|
|||||||
|
|
||||||
|
|
||||||
class NamespaceSet(dict):
|
class NamespaceSet(dict):
|
||||||
|
|
||||||
"""A dict of config namespace names and handlers.
|
"""A dict of config namespace names and handlers.
|
||||||
|
|
||||||
Each config entry should begin with a namespace name; the corresponding
|
Each config entry should begin with a namespace name; the
|
||||||
namespace handler will be called once for each config entry in that
|
corresponding namespace handler will be called once for each config
|
||||||
namespace, and will be passed two arguments: the config key (with the
|
entry in that namespace, and will be passed two arguments: the
|
||||||
namespace removed) and the config value.
|
config key (with the namespace removed) and the config value.
|
||||||
|
|
||||||
Namespace handlers may be any Python callable; they may also be
|
Namespace handlers may be any Python callable; they may also be
|
||||||
context managers, in which case their __enter__
|
context managers, in which case their __enter__ method should return
|
||||||
method should return a callable to be used as the handler.
|
a callable to be used as the handler. See cherrypy.tools (the
|
||||||
See cherrypy.tools (the Toolbox class) for an example.
|
Toolbox class) for an example.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __call__(self, config):
|
def __call__(self, config):
|
||||||
@@ -48,9 +47,10 @@ class NamespaceSet(dict):
|
|||||||
A flat dict, where keys use dots to separate
|
A flat dict, where keys use dots to separate
|
||||||
namespaces, and values are arbitrary.
|
namespaces, and values are arbitrary.
|
||||||
|
|
||||||
The first name in each config key is used to look up the corresponding
|
The first name in each config key is used to look up the
|
||||||
namespace handler. For example, a config entry of {'tools.gzip.on': v}
|
corresponding namespace handler. For example, a config entry of
|
||||||
will call the 'tools' namespace handler with the args: ('gzip.on', v)
|
{'tools.gzip.on': v} will call the 'tools' namespace handler
|
||||||
|
with the args: ('gzip.on', v)
|
||||||
"""
|
"""
|
||||||
# Separate the given config into namespaces
|
# Separate the given config into namespaces
|
||||||
ns_confs = {}
|
ns_confs = {}
|
||||||
@@ -103,7 +103,6 @@ class NamespaceSet(dict):
|
|||||||
|
|
||||||
|
|
||||||
class Config(dict):
|
class Config(dict):
|
||||||
|
|
||||||
"""A dict-like set of configuration data, with defaults and namespaces.
|
"""A dict-like set of configuration data, with defaults and namespaces.
|
||||||
|
|
||||||
May take a file, filename, or dict.
|
May take a file, filename, or dict.
|
||||||
@@ -163,14 +162,11 @@ class Parser(configparser.ConfigParser):
|
|||||||
# fp = open(filename)
|
# fp = open(filename)
|
||||||
# except IOError:
|
# except IOError:
|
||||||
# continue
|
# continue
|
||||||
fp = open(filename)
|
with open(filename) as fp:
|
||||||
try:
|
|
||||||
self._read(fp, filename)
|
self._read(fp, filename)
|
||||||
finally:
|
|
||||||
fp.close()
|
|
||||||
|
|
||||||
def as_dict(self, raw=False, vars=None):
|
def as_dict(self, raw=False, vars=None):
|
||||||
"""Convert an INI file to a dictionary"""
|
"""Convert an INI file to a dictionary."""
|
||||||
# Load INI file into a dict
|
# Load INI file into a dict
|
||||||
result = {}
|
result = {}
|
||||||
for section in self.sections():
|
for section in self.sections():
|
||||||
@@ -191,7 +187,7 @@ class Parser(configparser.ConfigParser):
|
|||||||
|
|
||||||
def dict_from_file(self, file):
|
def dict_from_file(self, file):
|
||||||
if hasattr(file, 'read'):
|
if hasattr(file, 'read'):
|
||||||
self.readfp(file)
|
self.read_file(file)
|
||||||
else:
|
else:
|
||||||
self.read(file)
|
self.read(file)
|
||||||
return self.as_dict()
|
return self.as_dict()
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ missing = object()
|
|||||||
|
|
||||||
|
|
||||||
class Session(object):
|
class Session(object):
|
||||||
|
|
||||||
"""A CherryPy dict-like Session object (one per request)."""
|
"""A CherryPy dict-like Session object (one per request)."""
|
||||||
|
|
||||||
_id = None
|
_id = None
|
||||||
@@ -148,9 +147,11 @@ class Session(object):
|
|||||||
to session data."""
|
to session data."""
|
||||||
|
|
||||||
loaded = False
|
loaded = False
|
||||||
|
"""If True, data has been retrieved from storage.
|
||||||
|
|
||||||
|
This should happen automatically on the first attempt to access
|
||||||
|
session data.
|
||||||
"""
|
"""
|
||||||
If True, data has been retrieved from storage. This should happen
|
|
||||||
automatically on the first attempt to access session data."""
|
|
||||||
|
|
||||||
clean_thread = None
|
clean_thread = None
|
||||||
'Class-level Monitor which calls self.clean_up.'
|
'Class-level Monitor which calls self.clean_up.'
|
||||||
@@ -165,9 +166,10 @@ class Session(object):
|
|||||||
'True if the session requested by the client did not exist.'
|
'True if the session requested by the client did not exist.'
|
||||||
|
|
||||||
regenerated = False
|
regenerated = False
|
||||||
|
"""True if the application called session.regenerate().
|
||||||
|
|
||||||
|
This is not set by internal calls to regenerate the session id.
|
||||||
"""
|
"""
|
||||||
True if the application called session.regenerate(). This is not set by
|
|
||||||
internal calls to regenerate the session id."""
|
|
||||||
|
|
||||||
debug = False
|
debug = False
|
||||||
'If True, log debug information.'
|
'If True, log debug information.'
|
||||||
@@ -335,8 +337,9 @@ class Session(object):
|
|||||||
|
|
||||||
def pop(self, key, default=missing):
|
def pop(self, key, default=missing):
|
||||||
"""Remove the specified key and return the corresponding value.
|
"""Remove the specified key and return the corresponding value.
|
||||||
If key is not found, default is returned if given,
|
|
||||||
otherwise KeyError is raised.
|
If key is not found, default is returned if given, otherwise
|
||||||
|
KeyError is raised.
|
||||||
"""
|
"""
|
||||||
if not self.loaded:
|
if not self.loaded:
|
||||||
self.load()
|
self.load()
|
||||||
@@ -351,13 +354,19 @@ class Session(object):
|
|||||||
return key in self._data
|
return key in self._data
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
"""D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None."""
|
"""D.get(k[,d]) -> D[k] if k in D, else d.
|
||||||
|
|
||||||
|
d defaults to None.
|
||||||
|
"""
|
||||||
if not self.loaded:
|
if not self.loaded:
|
||||||
self.load()
|
self.load()
|
||||||
return self._data.get(key, default)
|
return self._data.get(key, default)
|
||||||
|
|
||||||
def update(self, d):
|
def update(self, d):
|
||||||
"""D.update(E) -> None. Update D from E: for k in E: D[k] = E[k]."""
|
"""D.update(E) -> None.
|
||||||
|
|
||||||
|
Update D from E: for k in E: D[k] = E[k].
|
||||||
|
"""
|
||||||
if not self.loaded:
|
if not self.loaded:
|
||||||
self.load()
|
self.load()
|
||||||
self._data.update(d)
|
self._data.update(d)
|
||||||
@@ -369,7 +378,10 @@ class Session(object):
|
|||||||
return self._data.setdefault(key, default)
|
return self._data.setdefault(key, default)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
"""D.clear() -> None. Remove all items from D."""
|
"""D.clear() -> None.
|
||||||
|
|
||||||
|
Remove all items from D.
|
||||||
|
"""
|
||||||
if not self.loaded:
|
if not self.loaded:
|
||||||
self.load()
|
self.load()
|
||||||
self._data.clear()
|
self._data.clear()
|
||||||
@@ -492,7 +504,8 @@ class FileSession(Session):
|
|||||||
"""Set up the storage system for file-based sessions.
|
"""Set up the storage system for file-based sessions.
|
||||||
|
|
||||||
This should only be called once per process; this will be done
|
This should only be called once per process; this will be done
|
||||||
automatically when using sessions.init (as the built-in Tool does).
|
automatically when using sessions.init (as the built-in Tool
|
||||||
|
does).
|
||||||
"""
|
"""
|
||||||
# The 'storage_path' arg is required for file-based sessions.
|
# The 'storage_path' arg is required for file-based sessions.
|
||||||
kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
|
kwargs['storage_path'] = os.path.abspath(kwargs['storage_path'])
|
||||||
@@ -516,11 +529,8 @@ class FileSession(Session):
|
|||||||
if path is None:
|
if path is None:
|
||||||
path = self._get_file_path()
|
path = self._get_file_path()
|
||||||
try:
|
try:
|
||||||
f = open(path, 'rb')
|
with open(path, 'rb') as f:
|
||||||
try:
|
|
||||||
return pickle.load(f)
|
return pickle.load(f)
|
||||||
finally:
|
|
||||||
f.close()
|
|
||||||
except (IOError, EOFError):
|
except (IOError, EOFError):
|
||||||
e = sys.exc_info()[1]
|
e = sys.exc_info()[1]
|
||||||
if self.debug:
|
if self.debug:
|
||||||
@@ -531,11 +541,8 @@ class FileSession(Session):
|
|||||||
def _save(self, expiration_time):
|
def _save(self, expiration_time):
|
||||||
assert self.locked, ('The session was saved without being locked. '
|
assert self.locked, ('The session was saved without being locked. '
|
||||||
"Check your tools' priority levels.")
|
"Check your tools' priority levels.")
|
||||||
f = open(self._get_file_path(), 'wb')
|
with open(self._get_file_path(), 'wb') as f:
|
||||||
try:
|
|
||||||
pickle.dump((self._data, expiration_time), f, self.pickle_protocol)
|
pickle.dump((self._data, expiration_time), f, self.pickle_protocol)
|
||||||
finally:
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
def _delete(self):
|
def _delete(self):
|
||||||
assert self.locked, ('The session deletion without being locked. '
|
assert self.locked, ('The session deletion without being locked. '
|
||||||
@@ -622,7 +629,8 @@ class MemcachedSession(Session):
|
|||||||
"""Set up the storage system for memcached-based sessions.
|
"""Set up the storage system for memcached-based sessions.
|
||||||
|
|
||||||
This should only be called once per process; this will be done
|
This should only be called once per process; this will be done
|
||||||
automatically when using sessions.init (as the built-in Tool does).
|
automatically when using sessions.init (as the built-in Tool
|
||||||
|
does).
|
||||||
"""
|
"""
|
||||||
for k, v in kwargs.items():
|
for k, v in kwargs.items():
|
||||||
setattr(cls, k, v)
|
setattr(cls, k, v)
|
||||||
|
|||||||
+15
-13
@@ -1,19 +1,18 @@
|
|||||||
"""Module with helpers for serving static files."""
|
"""Module with helpers for serving static files."""
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import stat
|
import stat
|
||||||
import mimetypes
|
|
||||||
import urllib.parse
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
import urllib.parse
|
||||||
from email.generator import _make_boundary as make_boundary
|
from email.generator import _make_boundary as make_boundary
|
||||||
from io import UnsupportedOperation
|
from io import UnsupportedOperation
|
||||||
|
|
||||||
import cherrypy
|
import cherrypy
|
||||||
from cherrypy._cpcompat import ntob
|
from cherrypy._cpcompat import ntob
|
||||||
from cherrypy.lib import cptools, httputil, file_generator_limited
|
from cherrypy.lib import cptools, file_generator_limited, httputil
|
||||||
|
|
||||||
|
|
||||||
def _setup_mimetypes():
|
def _setup_mimetypes():
|
||||||
@@ -57,15 +56,15 @@ def serve_file(path, content_type=None, disposition=None, name=None,
|
|||||||
debug=False):
|
debug=False):
|
||||||
"""Set status, headers, and body in order to serve the given path.
|
"""Set status, headers, and body in order to serve the given path.
|
||||||
|
|
||||||
The Content-Type header will be set to the content_type arg, if provided.
|
The Content-Type header will be set to the content_type arg, if
|
||||||
If not provided, the Content-Type will be guessed by the file extension
|
provided. If not provided, the Content-Type will be guessed by the
|
||||||
of the 'path' argument.
|
file extension of the 'path' argument.
|
||||||
|
|
||||||
If disposition is not None, the Content-Disposition header will be set
|
If disposition is not None, the Content-Disposition header will be
|
||||||
to "<disposition>; filename=<name>; filename*=utf-8''<name>"
|
set to "<disposition>; filename=<name>; filename*=utf-8''<name>" as
|
||||||
as described in :rfc:`6266#appendix-D`.
|
described in :rfc:`6266#appendix-D`. If name is None, it will be set
|
||||||
If name is None, it will be set to the basename of path.
|
to the basename of path. If disposition is None, no Content-
|
||||||
If disposition is None, no Content-Disposition header will be written.
|
Disposition header will be written.
|
||||||
"""
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
|
|
||||||
@@ -185,7 +184,10 @@ def serve_fileobj(fileobj, content_type=None, disposition=None, name=None,
|
|||||||
|
|
||||||
|
|
||||||
def _serve_fileobj(fileobj, content_type, content_length, debug=False):
|
def _serve_fileobj(fileobj, content_type, content_length, debug=False):
|
||||||
"""Internal. Set response.body to the given file object, perhaps ranged."""
|
"""Set ``response.body`` to the given file object, perhaps ranged.
|
||||||
|
|
||||||
|
Internal helper.
|
||||||
|
"""
|
||||||
response = cherrypy.serving.response
|
response = cherrypy.serving.response
|
||||||
|
|
||||||
# HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code
|
# HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ _module__file__base = os.getcwd()
|
|||||||
|
|
||||||
|
|
||||||
class SimplePlugin(object):
|
class SimplePlugin(object):
|
||||||
|
|
||||||
"""Plugin base class which auto-subscribes methods for known channels."""
|
"""Plugin base class which auto-subscribes methods for known channels."""
|
||||||
|
|
||||||
bus = None
|
bus = None
|
||||||
@@ -59,7 +58,6 @@ class SimplePlugin(object):
|
|||||||
|
|
||||||
|
|
||||||
class SignalHandler(object):
|
class SignalHandler(object):
|
||||||
|
|
||||||
"""Register bus channels (and listeners) for system signals.
|
"""Register bus channels (and listeners) for system signals.
|
||||||
|
|
||||||
You can modify what signals your application listens for, and what it does
|
You can modify what signals your application listens for, and what it does
|
||||||
@@ -171,8 +169,8 @@ class SignalHandler(object):
|
|||||||
If the optional 'listener' argument is provided, it will be
|
If the optional 'listener' argument is provided, it will be
|
||||||
subscribed as a listener for the given signal's channel.
|
subscribed as a listener for the given signal's channel.
|
||||||
|
|
||||||
If the given signal name or number is not available on the current
|
If the given signal name or number is not available on the
|
||||||
platform, ValueError is raised.
|
current platform, ValueError is raised.
|
||||||
"""
|
"""
|
||||||
if isinstance(signal, text_or_bytes):
|
if isinstance(signal, text_or_bytes):
|
||||||
signum = getattr(_signal, signal, None)
|
signum = getattr(_signal, signal, None)
|
||||||
@@ -218,11 +216,10 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
class DropPrivileges(SimplePlugin):
|
class DropPrivileges(SimplePlugin):
|
||||||
|
|
||||||
"""Drop privileges. uid/gid arguments not available on Windows.
|
"""Drop privileges. uid/gid arguments not available on Windows.
|
||||||
|
|
||||||
Special thanks to `Gavin Baker
|
Special thanks to `Gavin Baker
|
||||||
<http://antonym.org/2005/12/dropping-privileges-in-python.html>`_
|
<http://antonym.org/2005/12/dropping-privileges-in-python.html>`_.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, bus, umask=None, uid=None, gid=None):
|
def __init__(self, bus, umask=None, uid=None, gid=None):
|
||||||
@@ -234,7 +231,10 @@ class DropPrivileges(SimplePlugin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def uid(self):
|
def uid(self):
|
||||||
"""The uid under which to run. Availability: Unix."""
|
"""The uid under which to run.
|
||||||
|
|
||||||
|
Availability: Unix.
|
||||||
|
"""
|
||||||
return self._uid
|
return self._uid
|
||||||
|
|
||||||
@uid.setter
|
@uid.setter
|
||||||
@@ -250,7 +250,10 @@ class DropPrivileges(SimplePlugin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def gid(self):
|
def gid(self):
|
||||||
"""The gid under which to run. Availability: Unix."""
|
"""The gid under which to run.
|
||||||
|
|
||||||
|
Availability: Unix.
|
||||||
|
"""
|
||||||
return self._gid
|
return self._gid
|
||||||
|
|
||||||
@gid.setter
|
@gid.setter
|
||||||
@@ -332,7 +335,6 @@ class DropPrivileges(SimplePlugin):
|
|||||||
|
|
||||||
|
|
||||||
class Daemonizer(SimplePlugin):
|
class Daemonizer(SimplePlugin):
|
||||||
|
|
||||||
"""Daemonize the running script.
|
"""Daemonize the running script.
|
||||||
|
|
||||||
Use this with a Web Site Process Bus via::
|
Use this with a Web Site Process Bus via::
|
||||||
@@ -423,7 +425,6 @@ class Daemonizer(SimplePlugin):
|
|||||||
|
|
||||||
|
|
||||||
class PIDFile(SimplePlugin):
|
class PIDFile(SimplePlugin):
|
||||||
|
|
||||||
"""Maintain a PID file via a WSPBus."""
|
"""Maintain a PID file via a WSPBus."""
|
||||||
|
|
||||||
def __init__(self, bus, pidfile):
|
def __init__(self, bus, pidfile):
|
||||||
@@ -436,7 +437,8 @@ class PIDFile(SimplePlugin):
|
|||||||
if self.finalized:
|
if self.finalized:
|
||||||
self.bus.log('PID %r already written to %r.' % (pid, self.pidfile))
|
self.bus.log('PID %r already written to %r.' % (pid, self.pidfile))
|
||||||
else:
|
else:
|
||||||
open(self.pidfile, 'wb').write(ntob('%s\n' % pid, 'utf8'))
|
with open(self.pidfile, 'wb') as f:
|
||||||
|
f.write(ntob('%s\n' % pid, 'utf8'))
|
||||||
self.bus.log('PID %r written to %r.' % (pid, self.pidfile))
|
self.bus.log('PID %r written to %r.' % (pid, self.pidfile))
|
||||||
self.finalized = True
|
self.finalized = True
|
||||||
start.priority = 70
|
start.priority = 70
|
||||||
@@ -452,12 +454,11 @@ class PIDFile(SimplePlugin):
|
|||||||
|
|
||||||
|
|
||||||
class PerpetualTimer(threading.Timer):
|
class PerpetualTimer(threading.Timer):
|
||||||
|
|
||||||
"""A responsive subclass of threading.Timer whose run() method repeats.
|
"""A responsive subclass of threading.Timer whose run() method repeats.
|
||||||
|
|
||||||
Use this timer only when you really need a very interruptible timer;
|
Use this timer only when you really need a very interruptible timer;
|
||||||
this checks its 'finished' condition up to 20 times a second, which can
|
this checks its 'finished' condition up to 20 times a second, which
|
||||||
results in pretty high CPU usage
|
can results in pretty high CPU usage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -482,14 +483,14 @@ class PerpetualTimer(threading.Timer):
|
|||||||
|
|
||||||
|
|
||||||
class BackgroundTask(threading.Thread):
|
class BackgroundTask(threading.Thread):
|
||||||
|
|
||||||
"""A subclass of threading.Thread whose run() method repeats.
|
"""A subclass of threading.Thread whose run() method repeats.
|
||||||
|
|
||||||
Use this class for most repeating tasks. It uses time.sleep() to wait
|
Use this class for most repeating tasks. It uses time.sleep() to
|
||||||
for each interval, which isn't very responsive; that is, even if you call
|
wait for each interval, which isn't very responsive; that is, even
|
||||||
self.cancel(), you'll have to wait until the sleep() call finishes before
|
if you call self.cancel(), you'll have to wait until the sleep()
|
||||||
the thread stops. To compensate, it defaults to being daemonic, which means
|
call finishes before the thread stops. To compensate, it defaults to
|
||||||
it won't delay stopping the whole process.
|
being daemonic, which means it won't delay stopping the whole
|
||||||
|
process.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, interval, function, args=[], kwargs={}, bus=None):
|
def __init__(self, interval, function, args=[], kwargs={}, bus=None):
|
||||||
@@ -524,7 +525,6 @@ class BackgroundTask(threading.Thread):
|
|||||||
|
|
||||||
|
|
||||||
class Monitor(SimplePlugin):
|
class Monitor(SimplePlugin):
|
||||||
|
|
||||||
"""WSPBus listener to periodically run a callback in its own thread."""
|
"""WSPBus listener to periodically run a callback in its own thread."""
|
||||||
|
|
||||||
callback = None
|
callback = None
|
||||||
@@ -581,7 +581,6 @@ class Monitor(SimplePlugin):
|
|||||||
|
|
||||||
|
|
||||||
class Autoreloader(Monitor):
|
class Autoreloader(Monitor):
|
||||||
|
|
||||||
"""Monitor which re-executes the process when files change.
|
"""Monitor which re-executes the process when files change.
|
||||||
|
|
||||||
This :ref:`plugin<plugins>` restarts the process (via :func:`os.execv`)
|
This :ref:`plugin<plugins>` restarts the process (via :func:`os.execv`)
|
||||||
@@ -698,20 +697,20 @@ class Autoreloader(Monitor):
|
|||||||
|
|
||||||
|
|
||||||
class ThreadManager(SimplePlugin):
|
class ThreadManager(SimplePlugin):
|
||||||
|
|
||||||
"""Manager for HTTP request threads.
|
"""Manager for HTTP request threads.
|
||||||
|
|
||||||
If you have control over thread creation and destruction, publish to
|
If you have control over thread creation and destruction, publish to
|
||||||
the 'acquire_thread' and 'release_thread' channels (for each thread).
|
the 'acquire_thread' and 'release_thread' channels (for each
|
||||||
This will register/unregister the current thread and publish to
|
thread). This will register/unregister the current thread and
|
||||||
'start_thread' and 'stop_thread' listeners in the bus as needed.
|
publish to 'start_thread' and 'stop_thread' listeners in the bus as
|
||||||
|
needed.
|
||||||
|
|
||||||
If threads are created and destroyed by code you do not control
|
If threads are created and destroyed by code you do not control
|
||||||
(e.g., Apache), then, at the beginning of every HTTP request,
|
(e.g., Apache), then, at the beginning of every HTTP request,
|
||||||
publish to 'acquire_thread' only. You should not publish to
|
publish to 'acquire_thread' only. You should not publish to
|
||||||
'release_thread' in this case, since you do not know whether
|
'release_thread' in this case, since you do not know whether the
|
||||||
the thread will be re-used or not. The bus will call
|
thread will be re-used or not. The bus will call 'stop_thread'
|
||||||
'stop_thread' listeners for you when it stops.
|
listeners for you when it stops.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
threads = None
|
threads = None
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ class Timeouts:
|
|||||||
|
|
||||||
|
|
||||||
class ServerAdapter(object):
|
class ServerAdapter(object):
|
||||||
|
|
||||||
"""Adapter for an HTTP server.
|
"""Adapter for an HTTP server.
|
||||||
|
|
||||||
If you need to start more than one HTTP server (to serve on multiple
|
If you need to start more than one HTTP server (to serve on multiple
|
||||||
@@ -188,9 +187,7 @@ class ServerAdapter(object):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
"""
|
"""A description about where this server is bound."""
|
||||||
A description about where this server is bound.
|
|
||||||
"""
|
|
||||||
if self.bind_addr is None:
|
if self.bind_addr is None:
|
||||||
on_what = 'unknown interface (dynamic?)'
|
on_what = 'unknown interface (dynamic?)'
|
||||||
elif isinstance(self.bind_addr, tuple):
|
elif isinstance(self.bind_addr, tuple):
|
||||||
@@ -292,7 +289,6 @@ class ServerAdapter(object):
|
|||||||
|
|
||||||
|
|
||||||
class FlupCGIServer(object):
|
class FlupCGIServer(object):
|
||||||
|
|
||||||
"""Adapter for a flup.server.cgi.WSGIServer."""
|
"""Adapter for a flup.server.cgi.WSGIServer."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -316,7 +312,6 @@ class FlupCGIServer(object):
|
|||||||
|
|
||||||
|
|
||||||
class FlupFCGIServer(object):
|
class FlupFCGIServer(object):
|
||||||
|
|
||||||
"""Adapter for a flup.server.fcgi.WSGIServer."""
|
"""Adapter for a flup.server.fcgi.WSGIServer."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -362,7 +357,6 @@ class FlupFCGIServer(object):
|
|||||||
|
|
||||||
|
|
||||||
class FlupSCGIServer(object):
|
class FlupSCGIServer(object):
|
||||||
|
|
||||||
"""Adapter for a flup.server.scgi.WSGIServer."""
|
"""Adapter for a flup.server.scgi.WSGIServer."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
"""Windows service. Requires pywin32."""
|
"""Windows service.
|
||||||
|
|
||||||
|
Requires pywin32.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import win32api
|
import win32api
|
||||||
@@ -11,7 +14,6 @@ from cherrypy.process import wspbus, plugins
|
|||||||
|
|
||||||
|
|
||||||
class ConsoleCtrlHandler(plugins.SimplePlugin):
|
class ConsoleCtrlHandler(plugins.SimplePlugin):
|
||||||
|
|
||||||
"""A WSPBus plugin for handling Win32 console events (like Ctrl-C)."""
|
"""A WSPBus plugin for handling Win32 console events (like Ctrl-C)."""
|
||||||
|
|
||||||
def __init__(self, bus):
|
def __init__(self, bus):
|
||||||
@@ -69,10 +71,10 @@ class ConsoleCtrlHandler(plugins.SimplePlugin):
|
|||||||
|
|
||||||
|
|
||||||
class Win32Bus(wspbus.Bus):
|
class Win32Bus(wspbus.Bus):
|
||||||
|
|
||||||
"""A Web Site Process Bus implementation for Win32.
|
"""A Web Site Process Bus implementation for Win32.
|
||||||
|
|
||||||
Instead of time.sleep, this bus blocks using native win32event objects.
|
Instead of time.sleep, this bus blocks using native win32event
|
||||||
|
objects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -120,7 +122,6 @@ class Win32Bus(wspbus.Bus):
|
|||||||
|
|
||||||
|
|
||||||
class _ControlCodes(dict):
|
class _ControlCodes(dict):
|
||||||
|
|
||||||
"""Control codes used to "signal" a service via ControlService.
|
"""Control codes used to "signal" a service via ControlService.
|
||||||
|
|
||||||
User-defined control codes are in the range 128-255. We generally use
|
User-defined control codes are in the range 128-255. We generally use
|
||||||
@@ -152,7 +153,6 @@ def signal_child(service, command):
|
|||||||
|
|
||||||
|
|
||||||
class PyWebService(win32serviceutil.ServiceFramework):
|
class PyWebService(win32serviceutil.ServiceFramework):
|
||||||
|
|
||||||
"""Python Web Service."""
|
"""Python Web Service."""
|
||||||
|
|
||||||
_svc_name_ = 'Python Web Service'
|
_svc_name_ = 'Python Web Service'
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ the new state.::
|
|||||||
| \ |
|
| \ |
|
||||||
| V V
|
| V V
|
||||||
STARTED <-- STARTING
|
STARTED <-- STARTING
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
@@ -65,7 +64,7 @@ import atexit
|
|||||||
try:
|
try:
|
||||||
import ctypes
|
import ctypes
|
||||||
except ImportError:
|
except ImportError:
|
||||||
"""Google AppEngine is shipped without ctypes
|
"""Google AppEngine is shipped without ctypes.
|
||||||
|
|
||||||
:seealso: http://stackoverflow.com/a/6523777/70170
|
:seealso: http://stackoverflow.com/a/6523777/70170
|
||||||
"""
|
"""
|
||||||
@@ -165,8 +164,8 @@ class Bus(object):
|
|||||||
All listeners for a given channel are guaranteed to be called even
|
All listeners for a given channel are guaranteed to be called even
|
||||||
if others at the same channel fail. Each failure is logged, but
|
if others at the same channel fail. Each failure is logged, but
|
||||||
execution proceeds on to the next listener. The only way to stop all
|
execution proceeds on to the next listener. The only way to stop all
|
||||||
processing from inside a listener is to raise SystemExit and stop the
|
processing from inside a listener is to raise SystemExit and stop
|
||||||
whole server.
|
the whole server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
states = states
|
states = states
|
||||||
@@ -312,8 +311,9 @@ class Bus(object):
|
|||||||
def restart(self):
|
def restart(self):
|
||||||
"""Restart the process (may close connections).
|
"""Restart the process (may close connections).
|
||||||
|
|
||||||
This method does not restart the process from the calling thread;
|
This method does not restart the process from the calling
|
||||||
instead, it stops the bus and asks the main thread to call execv.
|
thread; instead, it stops the bus and asks the main thread to
|
||||||
|
call execv.
|
||||||
"""
|
"""
|
||||||
self.execv = True
|
self.execv = True
|
||||||
self.exit()
|
self.exit()
|
||||||
@@ -327,10 +327,11 @@ class Bus(object):
|
|||||||
"""Wait for the EXITING state, KeyboardInterrupt or SystemExit.
|
"""Wait for the EXITING state, KeyboardInterrupt or SystemExit.
|
||||||
|
|
||||||
This function is intended to be called only by the main thread.
|
This function is intended to be called only by the main thread.
|
||||||
After waiting for the EXITING state, it also waits for all threads
|
After waiting for the EXITING state, it also waits for all
|
||||||
to terminate, and then calls os.execv if self.execv is True. This
|
threads to terminate, and then calls os.execv if self.execv is
|
||||||
design allows another thread to call bus.restart, yet have the main
|
True. This design allows another thread to call bus.restart, yet
|
||||||
thread perform the actual execv call (required on some platforms).
|
have the main thread perform the actual execv call (required on
|
||||||
|
some platforms).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
self.wait(states.EXITING, interval=interval, channel='main')
|
self.wait(states.EXITING, interval=interval, channel='main')
|
||||||
@@ -379,13 +380,14 @@ class Bus(object):
|
|||||||
def _do_execv(self):
|
def _do_execv(self):
|
||||||
"""Re-execute the current process.
|
"""Re-execute the current process.
|
||||||
|
|
||||||
This must be called from the main thread, because certain platforms
|
This must be called from the main thread, because certain
|
||||||
(OS X) don't allow execv to be called in a child thread very well.
|
platforms (OS X) don't allow execv to be called in a child
|
||||||
|
thread very well.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
args = self._get_true_argv()
|
args = self._get_true_argv()
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
"""It's probably win32 or GAE"""
|
"""It's probably win32 or GAE."""
|
||||||
args = [sys.executable] + self._get_interpreter_argv() + sys.argv
|
args = [sys.executable] + self._get_interpreter_argv() + sys.argv
|
||||||
|
|
||||||
self.log('Re-spawning %s' % ' '.join(args))
|
self.log('Re-spawning %s' % ' '.join(args))
|
||||||
@@ -472,7 +474,7 @@ class Bus(object):
|
|||||||
c_ind = None
|
c_ind = None
|
||||||
|
|
||||||
if is_module:
|
if is_module:
|
||||||
"""It's containing `-m -m` sequence of arguments"""
|
"""It's containing `-m -m` sequence of arguments."""
|
||||||
if is_command and c_ind < m_ind:
|
if is_command and c_ind < m_ind:
|
||||||
"""There's `-c -c` before `-m`"""
|
"""There's `-c -c` before `-m`"""
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -481,7 +483,7 @@ class Bus(object):
|
|||||||
# Survive module argument here
|
# Survive module argument here
|
||||||
original_module = sys.argv[0]
|
original_module = sys.argv[0]
|
||||||
if not os.access(original_module, os.R_OK):
|
if not os.access(original_module, os.R_OK):
|
||||||
"""There's no such module exist"""
|
"""There's no such module exist."""
|
||||||
raise AttributeError(
|
raise AttributeError(
|
||||||
"{} doesn't seem to be a module "
|
"{} doesn't seem to be a module "
|
||||||
'accessible by current user'.format(original_module))
|
'accessible by current user'.format(original_module))
|
||||||
@@ -489,12 +491,12 @@ class Bus(object):
|
|||||||
# ... and substitute it with the original module path:
|
# ... and substitute it with the original module path:
|
||||||
_argv.insert(m_ind, original_module)
|
_argv.insert(m_ind, original_module)
|
||||||
elif is_command:
|
elif is_command:
|
||||||
"""It's containing just `-c -c` sequence of arguments"""
|
"""It's containing just `-c -c` sequence of arguments."""
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Cannot reconstruct command from '-c'. "
|
"Cannot reconstruct command from '-c'. "
|
||||||
'Ref: https://github.com/cherrypy/cherrypy/issues/1545')
|
'Ref: https://github.com/cherrypy/cherrypy/issues/1545')
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
"""It looks Py_GetArgcArgv is completely absent in some environments
|
"""It looks Py_GetArgcArgv's completely absent in some environments
|
||||||
|
|
||||||
It is known, that there's no Py_GetArgcArgv in MS Windows and
|
It is known, that there's no Py_GetArgcArgv in MS Windows and
|
||||||
``ctypes`` module is completely absent in Google AppEngine
|
``ctypes`` module is completely absent in Google AppEngine
|
||||||
@@ -512,13 +514,13 @@ class Bus(object):
|
|||||||
"""Prepend current working dir to PATH environment variable if needed.
|
"""Prepend current working dir to PATH environment variable if needed.
|
||||||
|
|
||||||
If sys.path[0] is an empty string, the interpreter was likely
|
If sys.path[0] is an empty string, the interpreter was likely
|
||||||
invoked with -m and the effective path is about to change on
|
invoked with -m and the effective path is about to change on re-
|
||||||
re-exec. Add the current directory to $PYTHONPATH to ensure
|
exec. Add the current directory to $PYTHONPATH to ensure that
|
||||||
that the new process sees the same path.
|
the new process sees the same path.
|
||||||
|
|
||||||
This issue cannot be addressed in the general case because
|
This issue cannot be addressed in the general case because
|
||||||
Python cannot reliably reconstruct the
|
Python cannot reliably reconstruct the original command line (
|
||||||
original command line (http://bugs.python.org/issue14208).
|
http://bugs.python.org/issue14208).
|
||||||
|
|
||||||
(This idea filched from tornado.autoreload)
|
(This idea filched from tornado.autoreload)
|
||||||
"""
|
"""
|
||||||
@@ -536,10 +538,10 @@ class Bus(object):
|
|||||||
"""Set the CLOEXEC flag on all open files (except stdin/out/err).
|
"""Set the CLOEXEC flag on all open files (except stdin/out/err).
|
||||||
|
|
||||||
If self.max_cloexec_files is an integer (the default), then on
|
If self.max_cloexec_files is an integer (the default), then on
|
||||||
platforms which support it, it represents the max open files setting
|
platforms which support it, it represents the max open files
|
||||||
for the operating system. This function will be called just before
|
setting for the operating system. This function will be called
|
||||||
the process is restarted via os.execv() to prevent open files
|
just before the process is restarted via os.execv() to prevent
|
||||||
from persisting into the new process.
|
open files from persisting into the new process.
|
||||||
|
|
||||||
Set self.max_cloexec_files to 0 to disable this behavior.
|
Set self.max_cloexec_files to 0 to disable this behavior.
|
||||||
"""
|
"""
|
||||||
@@ -578,7 +580,10 @@ class Bus(object):
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
def log(self, msg='', level=20, traceback=False):
|
def log(self, msg='', level=20, traceback=False):
|
||||||
"""Log the given message. Append the last traceback if requested."""
|
"""Log the given message.
|
||||||
|
|
||||||
|
Append the last traceback if requested.
|
||||||
|
"""
|
||||||
if traceback:
|
if traceback:
|
||||||
msg += '\n' + ''.join(_traceback.format_exception(*sys.exc_info()))
|
msg += '\n' + ''.join(_traceback.format_exception(*sys.exc_info()))
|
||||||
self.publish('log', msg, level)
|
self.publish('log', msg, level)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ Even before any tweaking, this should serve a few demonstration pages.
|
|||||||
Change to this directory and run:
|
Change to this directory and run:
|
||||||
|
|
||||||
cherryd -c site.conf
|
cherryd -c site.conf
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import cherrypy
|
import cherrypy
|
||||||
|
|||||||
+180
-362
@@ -1,8 +1,3 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: iso-8859-1 -*-
|
|
||||||
|
|
||||||
# Documentation is intended to be processed by Epydoc.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Introduction
|
Introduction
|
||||||
============
|
============
|
||||||
@@ -11,266 +6,10 @@ The Munkres module provides an implementation of the Munkres algorithm
|
|||||||
(also called the Hungarian algorithm or the Kuhn-Munkres algorithm),
|
(also called the Hungarian algorithm or the Kuhn-Munkres algorithm),
|
||||||
useful for solving the Assignment Problem.
|
useful for solving the Assignment Problem.
|
||||||
|
|
||||||
Assignment Problem
|
For complete usage documentation, see: https://software.clapper.org/munkres/
|
||||||
==================
|
|
||||||
|
|
||||||
Let *C* be an *n*\ x\ *n* matrix representing the costs of each of *n* workers
|
|
||||||
to perform any of *n* jobs. The assignment problem is to assign jobs to
|
|
||||||
workers in a way that minimizes the total cost. Since each worker can perform
|
|
||||||
only one job and each job can be assigned to only one worker the assignments
|
|
||||||
represent an independent set of the matrix *C*.
|
|
||||||
|
|
||||||
One way to generate the optimal set is to create all permutations of
|
|
||||||
the indexes necessary to traverse the matrix so that no row and column
|
|
||||||
are used more than once. For instance, given this matrix (expressed in
|
|
||||||
Python)::
|
|
||||||
|
|
||||||
matrix = [[5, 9, 1],
|
|
||||||
[10, 3, 2],
|
|
||||||
[8, 7, 4]]
|
|
||||||
|
|
||||||
You could use this code to generate the traversal indexes::
|
|
||||||
|
|
||||||
def permute(a, results):
|
|
||||||
if len(a) == 1:
|
|
||||||
results.insert(len(results), a)
|
|
||||||
|
|
||||||
else:
|
|
||||||
for i in range(0, len(a)):
|
|
||||||
element = a[i]
|
|
||||||
a_copy = [a[j] for j in range(0, len(a)) if j != i]
|
|
||||||
subresults = []
|
|
||||||
permute(a_copy, subresults)
|
|
||||||
for subresult in subresults:
|
|
||||||
result = [element] + subresult
|
|
||||||
results.insert(len(results), result)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
permute(range(len(matrix)), results) # [0, 1, 2] for a 3x3 matrix
|
|
||||||
|
|
||||||
After the call to permute(), the results matrix would look like this::
|
|
||||||
|
|
||||||
[[0, 1, 2],
|
|
||||||
[0, 2, 1],
|
|
||||||
[1, 0, 2],
|
|
||||||
[1, 2, 0],
|
|
||||||
[2, 0, 1],
|
|
||||||
[2, 1, 0]]
|
|
||||||
|
|
||||||
You could then use that index matrix to loop over the original cost matrix
|
|
||||||
and calculate the smallest cost of the combinations::
|
|
||||||
|
|
||||||
n = len(matrix)
|
|
||||||
minval = sys.maxsize
|
|
||||||
for row in range(n):
|
|
||||||
cost = 0
|
|
||||||
for col in range(n):
|
|
||||||
cost += matrix[row][col]
|
|
||||||
minval = min(cost, minval)
|
|
||||||
|
|
||||||
print minval
|
|
||||||
|
|
||||||
While this approach works fine for small matrices, it does not scale. It
|
|
||||||
executes in O(*n*!) time: Calculating the permutations for an *n*\ x\ *n*
|
|
||||||
matrix requires *n*! operations. For a 12x12 matrix, that's 479,001,600
|
|
||||||
traversals. Even if you could manage to perform each traversal in just one
|
|
||||||
millisecond, it would still take more than 133 hours to perform the entire
|
|
||||||
traversal. A 20x20 matrix would take 2,432,902,008,176,640,000 operations. At
|
|
||||||
an optimistic millisecond per operation, that's more than 77 million years.
|
|
||||||
|
|
||||||
The Munkres algorithm runs in O(*n*\ ^3) time, rather than O(*n*!). This
|
|
||||||
package provides an implementation of that algorithm.
|
|
||||||
|
|
||||||
This version is based on
|
|
||||||
http://www.public.iastate.edu/~ddoty/HungarianAlgorithm.html.
|
|
||||||
|
|
||||||
This version was written for Python by Brian Clapper from the (Ada) algorithm
|
|
||||||
at the above web site. (The ``Algorithm::Munkres`` Perl version, in CPAN, was
|
|
||||||
clearly adapted from the same web site.)
|
|
||||||
|
|
||||||
Usage
|
|
||||||
=====
|
|
||||||
|
|
||||||
Construct a Munkres object::
|
|
||||||
|
|
||||||
from munkres import Munkres
|
|
||||||
|
|
||||||
m = Munkres()
|
|
||||||
|
|
||||||
Then use it to compute the lowest cost assignment from a cost matrix. Here's
|
|
||||||
a sample program::
|
|
||||||
|
|
||||||
from munkres import Munkres, print_matrix
|
|
||||||
|
|
||||||
matrix = [[5, 9, 1],
|
|
||||||
[10, 3, 2],
|
|
||||||
[8, 7, 4]]
|
|
||||||
m = Munkres()
|
|
||||||
indexes = m.compute(matrix)
|
|
||||||
print_matrix(matrix, msg='Lowest cost through this matrix:')
|
|
||||||
total = 0
|
|
||||||
for row, column in indexes:
|
|
||||||
value = matrix[row][column]
|
|
||||||
total += value
|
|
||||||
print '(%d, %d) -> %d' % (row, column, value)
|
|
||||||
print 'total cost: %d' % total
|
|
||||||
|
|
||||||
Running that program produces::
|
|
||||||
|
|
||||||
Lowest cost through this matrix:
|
|
||||||
[5, 9, 1]
|
|
||||||
[10, 3, 2]
|
|
||||||
[8, 7, 4]
|
|
||||||
(0, 0) -> 5
|
|
||||||
(1, 1) -> 3
|
|
||||||
(2, 2) -> 4
|
|
||||||
total cost=12
|
|
||||||
|
|
||||||
The instantiated Munkres object can be used multiple times on different
|
|
||||||
matrices.
|
|
||||||
|
|
||||||
Non-square Cost Matrices
|
|
||||||
========================
|
|
||||||
|
|
||||||
The Munkres algorithm assumes that the cost matrix is square. However, it's
|
|
||||||
possible to use a rectangular matrix if you first pad it with 0 values to make
|
|
||||||
it square. This module automatically pads rectangular cost matrices to make
|
|
||||||
them square.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- The module operates on a *copy* of the caller's matrix, so any padding will
|
|
||||||
not be seen by the caller.
|
|
||||||
- The cost matrix must be rectangular or square. An irregular matrix will
|
|
||||||
*not* work.
|
|
||||||
|
|
||||||
Calculating Profit, Rather than Cost
|
|
||||||
====================================
|
|
||||||
|
|
||||||
The cost matrix is just that: A cost matrix. The Munkres algorithm finds
|
|
||||||
the combination of elements (one from each row and column) that results in
|
|
||||||
the smallest cost. It's also possible to use the algorithm to maximize
|
|
||||||
profit. To do that, however, you have to convert your profit matrix to a
|
|
||||||
cost matrix. The simplest way to do that is to subtract all elements from a
|
|
||||||
large value. For example::
|
|
||||||
|
|
||||||
from munkres import Munkres, print_matrix
|
|
||||||
|
|
||||||
matrix = [[5, 9, 1],
|
|
||||||
[10, 3, 2],
|
|
||||||
[8, 7, 4]]
|
|
||||||
cost_matrix = []
|
|
||||||
for row in matrix:
|
|
||||||
cost_row = []
|
|
||||||
for col in row:
|
|
||||||
cost_row += [sys.maxsize - col]
|
|
||||||
cost_matrix += [cost_row]
|
|
||||||
|
|
||||||
m = Munkres()
|
|
||||||
indexes = m.compute(cost_matrix)
|
|
||||||
print_matrix(matrix, msg='Highest profit through this matrix:')
|
|
||||||
total = 0
|
|
||||||
for row, column in indexes:
|
|
||||||
value = matrix[row][column]
|
|
||||||
total += value
|
|
||||||
print '(%d, %d) -> %d' % (row, column, value)
|
|
||||||
|
|
||||||
print 'total profit=%d' % total
|
|
||||||
|
|
||||||
Running that program produces::
|
|
||||||
|
|
||||||
Highest profit through this matrix:
|
|
||||||
[5, 9, 1]
|
|
||||||
[10, 3, 2]
|
|
||||||
[8, 7, 4]
|
|
||||||
(0, 1) -> 9
|
|
||||||
(1, 0) -> 10
|
|
||||||
(2, 2) -> 4
|
|
||||||
total profit=23
|
|
||||||
|
|
||||||
The ``munkres`` module provides a convenience method for creating a cost
|
|
||||||
matrix from a profit matrix. Since it doesn't know whether the matrix contains
|
|
||||||
floating point numbers, decimals, or integers, you have to provide the
|
|
||||||
conversion function; but the convenience method takes care of the actual
|
|
||||||
creation of the cost matrix::
|
|
||||||
|
|
||||||
import munkres
|
|
||||||
|
|
||||||
cost_matrix = munkres.make_cost_matrix(matrix,
|
|
||||||
lambda cost: sys.maxsize - cost)
|
|
||||||
|
|
||||||
So, the above profit-calculation program can be recast as::
|
|
||||||
|
|
||||||
from munkres import Munkres, print_matrix, make_cost_matrix
|
|
||||||
|
|
||||||
matrix = [[5, 9, 1],
|
|
||||||
[10, 3, 2],
|
|
||||||
[8, 7, 4]]
|
|
||||||
cost_matrix = make_cost_matrix(matrix, lambda cost: sys.maxsize - cost)
|
|
||||||
m = Munkres()
|
|
||||||
indexes = m.compute(cost_matrix)
|
|
||||||
print_matrix(matrix, msg='Lowest cost through this matrix:')
|
|
||||||
total = 0
|
|
||||||
for row, column in indexes:
|
|
||||||
value = matrix[row][column]
|
|
||||||
total += value
|
|
||||||
print '(%d, %d) -> %d' % (row, column, value)
|
|
||||||
print 'total profit=%d' % total
|
|
||||||
|
|
||||||
References
|
|
||||||
==========
|
|
||||||
|
|
||||||
1. http://www.public.iastate.edu/~ddoty/HungarianAlgorithm.html
|
|
||||||
|
|
||||||
2. Harold W. Kuhn. The Hungarian Method for the assignment problem.
|
|
||||||
*Naval Research Logistics Quarterly*, 2:83-97, 1955.
|
|
||||||
|
|
||||||
3. Harold W. Kuhn. Variants of the Hungarian method for assignment
|
|
||||||
problems. *Naval Research Logistics Quarterly*, 3: 253-258, 1956.
|
|
||||||
|
|
||||||
4. Munkres, J. Algorithms for the Assignment and Transportation Problems.
|
|
||||||
*Journal of the Society of Industrial and Applied Mathematics*,
|
|
||||||
5(1):32-38, March, 1957.
|
|
||||||
|
|
||||||
5. http://en.wikipedia.org/wiki/Hungarian_algorithm
|
|
||||||
|
|
||||||
Copyright and License
|
|
||||||
=====================
|
|
||||||
|
|
||||||
This software is released under a BSD license, adapted from
|
|
||||||
<http://opensource.org/licenses/bsd-license.php>
|
|
||||||
|
|
||||||
Copyright (c) 2008 Brian M. Clapper
|
|
||||||
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.
|
|
||||||
|
|
||||||
* Neither the name "clapper.org" nor the names of its contributors may be
|
|
||||||
used to endorse or promote products derived from this software without
|
|
||||||
specific prior written permission.
|
|
||||||
|
|
||||||
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 HOLDER 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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__docformat__ = 'restructuredtext'
|
__docformat__ = 'markdown'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Imports
|
# Imports
|
||||||
@@ -278,23 +17,43 @@ __docformat__ = 'restructuredtext'
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import copy
|
import copy
|
||||||
|
from typing import Union, NewType, Sequence, Tuple, Optional, Callable
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Exports
|
# Exports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
__all__ = ['Munkres', 'make_cost_matrix']
|
__all__ = ['Munkres', 'make_cost_matrix', 'DISALLOWED']
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Globals
|
# Globals
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AnyNum = NewType('AnyNum', Union[int, float])
|
||||||
|
Matrix = NewType('Matrix', Sequence[Sequence[AnyNum]])
|
||||||
|
|
||||||
# Info about the module
|
# Info about the module
|
||||||
__version__ = "1.0.6"
|
__version__ = "1.1.4"
|
||||||
__author__ = "Brian Clapper, bmc@clapper.org"
|
__author__ = "Brian Clapper, bmc@clapper.org"
|
||||||
__url__ = "http://software.clapper.org/munkres/"
|
__url__ = "https://software.clapper.org/munkres/"
|
||||||
__copyright__ = "(c) 2008 Brian M. Clapper"
|
__copyright__ = "(c) 2008-2020 Brian M. Clapper"
|
||||||
__license__ = "BSD-style license"
|
__license__ = "Apache Software License"
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
class DISALLOWED_OBJ(object):
|
||||||
|
pass
|
||||||
|
DISALLOWED = DISALLOWED_OBJ()
|
||||||
|
DISALLOWED_PRINTVAL = "D"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Exceptions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class UnsolvableMatrix(Exception):
|
||||||
|
"""
|
||||||
|
Exception raised for unsolvable matrices
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Classes
|
# Classes
|
||||||
@@ -317,30 +76,18 @@ class Munkres:
|
|||||||
self.marked = None
|
self.marked = None
|
||||||
self.path = None
|
self.path = None
|
||||||
|
|
||||||
def make_cost_matrix(profit_matrix, inversion_function):
|
def pad_matrix(self, matrix: Matrix, pad_value: int=0) -> Matrix:
|
||||||
"""
|
|
||||||
**DEPRECATED**
|
|
||||||
|
|
||||||
Please use the module function ``make_cost_matrix()``.
|
|
||||||
"""
|
|
||||||
import munkres
|
|
||||||
return munkres.make_cost_matrix(profit_matrix, inversion_function)
|
|
||||||
|
|
||||||
make_cost_matrix = staticmethod(make_cost_matrix)
|
|
||||||
|
|
||||||
def pad_matrix(self, matrix, pad_value=0):
|
|
||||||
"""
|
"""
|
||||||
Pad a possibly non-square matrix to make it square.
|
Pad a possibly non-square matrix to make it square.
|
||||||
|
|
||||||
:Parameters:
|
**Parameters**
|
||||||
matrix : list of lists
|
|
||||||
matrix to pad
|
|
||||||
|
|
||||||
pad_value : int
|
- `matrix` (list of lists of numbers): matrix to pad
|
||||||
value to use to pad the matrix
|
- `pad_value` (`int`): value to use to pad the matrix
|
||||||
|
|
||||||
:rtype: list of lists
|
**Returns**
|
||||||
:return: a new, possibly padded, matrix
|
|
||||||
|
a new, possibly padded, matrix
|
||||||
"""
|
"""
|
||||||
max_columns = 0
|
max_columns = 0
|
||||||
total_rows = len(matrix)
|
total_rows = len(matrix)
|
||||||
@@ -356,34 +103,35 @@ class Munkres:
|
|||||||
new_row = row[:]
|
new_row = row[:]
|
||||||
if total_rows > row_len:
|
if total_rows > row_len:
|
||||||
# Row too short. Pad it.
|
# Row too short. Pad it.
|
||||||
new_row += [0] * (total_rows - row_len)
|
new_row += [pad_value] * (total_rows - row_len)
|
||||||
new_matrix += [new_row]
|
new_matrix += [new_row]
|
||||||
|
|
||||||
while len(new_matrix) < total_rows:
|
while len(new_matrix) < total_rows:
|
||||||
new_matrix += [[0] * total_rows]
|
new_matrix += [[pad_value] * total_rows]
|
||||||
|
|
||||||
return new_matrix
|
return new_matrix
|
||||||
|
|
||||||
def compute(self, cost_matrix):
|
def compute(self, cost_matrix: Matrix) -> Sequence[Tuple[int, int]]:
|
||||||
"""
|
"""
|
||||||
Compute the indexes for the lowest-cost pairings between rows and
|
Compute the indexes for the lowest-cost pairings between rows and
|
||||||
columns in the database. Returns a list of (row, column) tuples
|
columns in the database. Returns a list of `(row, column)` tuples
|
||||||
that can be used to traverse the matrix.
|
that can be used to traverse the matrix.
|
||||||
|
|
||||||
:Parameters:
|
**WARNING**: This code handles square and rectangular matrices. It
|
||||||
cost_matrix : list of lists
|
does *not* handle irregular matrices.
|
||||||
The cost matrix. If this cost matrix is not square, it
|
|
||||||
will be padded with zeros, via a call to ``pad_matrix()``.
|
|
||||||
(This method does *not* modify the caller's matrix. It
|
|
||||||
operates on a copy of the matrix.)
|
|
||||||
|
|
||||||
**WARNING**: This code handles square and rectangular
|
**Parameters**
|
||||||
matrices. It does *not* handle irregular matrices.
|
|
||||||
|
|
||||||
:rtype: list
|
- `cost_matrix` (list of lists of numbers): The cost matrix. If this
|
||||||
:return: A list of ``(row, column)`` tuples that describe the lowest
|
cost matrix is not square, it will be padded with zeros, via a call
|
||||||
cost path through the matrix
|
to `pad_matrix()`. (This method does *not* modify the caller's
|
||||||
|
matrix. It operates on a copy of the matrix.)
|
||||||
|
|
||||||
|
|
||||||
|
**Returns**
|
||||||
|
|
||||||
|
A list of `(row, column)` tuples that describe the lowest cost path
|
||||||
|
through the matrix
|
||||||
"""
|
"""
|
||||||
self.C = self.pad_matrix(cost_matrix)
|
self.C = self.pad_matrix(cost_matrix)
|
||||||
self.n = len(self.C)
|
self.n = len(self.C)
|
||||||
@@ -422,18 +170,18 @@ class Munkres:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def __copy_matrix(self, matrix):
|
def __copy_matrix(self, matrix: Matrix) -> Matrix:
|
||||||
"""Return an exact copy of the supplied matrix"""
|
"""Return an exact copy of the supplied matrix"""
|
||||||
return copy.deepcopy(matrix)
|
return copy.deepcopy(matrix)
|
||||||
|
|
||||||
def __make_matrix(self, n, val):
|
def __make_matrix(self, n: int, val: AnyNum) -> Matrix:
|
||||||
"""Create an *n*x*n* matrix, populating it with the specific value."""
|
"""Create an *n*x*n* matrix, populating it with the specific value."""
|
||||||
matrix = []
|
matrix = []
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
matrix += [[val for j in range(n)]]
|
matrix += [[val for j in range(n)]]
|
||||||
return matrix
|
return matrix
|
||||||
|
|
||||||
def __step1(self):
|
def __step1(self) -> int:
|
||||||
"""
|
"""
|
||||||
For each row of the matrix, find the smallest element and
|
For each row of the matrix, find the smallest element and
|
||||||
subtract it from every element in its row. Go to Step 2.
|
subtract it from every element in its row. Go to Step 2.
|
||||||
@@ -441,15 +189,22 @@ class Munkres:
|
|||||||
C = self.C
|
C = self.C
|
||||||
n = self.n
|
n = self.n
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
minval = min(self.C[i])
|
vals = [x for x in self.C[i] if x is not DISALLOWED]
|
||||||
|
if len(vals) == 0:
|
||||||
|
# All values in this row are DISALLOWED. This matrix is
|
||||||
|
# unsolvable.
|
||||||
|
raise UnsolvableMatrix(
|
||||||
|
"Row {0} is entirely DISALLOWED.".format(i)
|
||||||
|
)
|
||||||
|
minval = min(vals)
|
||||||
# Find the minimum value for this row and subtract that minimum
|
# Find the minimum value for this row and subtract that minimum
|
||||||
# from every element in the row.
|
# from every element in the row.
|
||||||
for j in range(n):
|
for j in range(n):
|
||||||
self.C[i][j] -= minval
|
if self.C[i][j] is not DISALLOWED:
|
||||||
|
self.C[i][j] -= minval
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
def __step2(self):
|
def __step2(self) -> int:
|
||||||
"""
|
"""
|
||||||
Find a zero (Z) in the resulting matrix. If there is no starred
|
Find a zero (Z) in the resulting matrix. If there is no starred
|
||||||
zero in its row or column, star Z. Repeat for each element in the
|
zero in its row or column, star Z. Repeat for each element in the
|
||||||
@@ -464,11 +219,12 @@ class Munkres:
|
|||||||
self.marked[i][j] = 1
|
self.marked[i][j] = 1
|
||||||
self.col_covered[j] = True
|
self.col_covered[j] = True
|
||||||
self.row_covered[i] = True
|
self.row_covered[i] = True
|
||||||
|
break
|
||||||
|
|
||||||
self.__clear_covers()
|
self.__clear_covers()
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
def __step3(self):
|
def __step3(self) -> int:
|
||||||
"""
|
"""
|
||||||
Cover each column containing a starred zero. If K columns are
|
Cover each column containing a starred zero. If K columns are
|
||||||
covered, the starred zeros describe a complete set of unique
|
covered, the starred zeros describe a complete set of unique
|
||||||
@@ -478,7 +234,7 @@ class Munkres:
|
|||||||
count = 0
|
count = 0
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
for j in range(n):
|
for j in range(n):
|
||||||
if self.marked[i][j] == 1:
|
if self.marked[i][j] == 1 and not self.col_covered[j]:
|
||||||
self.col_covered[j] = True
|
self.col_covered[j] = True
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
@@ -489,7 +245,7 @@ class Munkres:
|
|||||||
|
|
||||||
return step
|
return step
|
||||||
|
|
||||||
def __step4(self):
|
def __step4(self) -> int:
|
||||||
"""
|
"""
|
||||||
Find a noncovered zero and prime it. If there is no starred zero
|
Find a noncovered zero and prime it. If there is no starred zero
|
||||||
in the row containing this primed zero, Go to Step 5. Otherwise,
|
in the row containing this primed zero, Go to Step 5. Otherwise,
|
||||||
@@ -499,11 +255,11 @@ class Munkres:
|
|||||||
"""
|
"""
|
||||||
step = 0
|
step = 0
|
||||||
done = False
|
done = False
|
||||||
row = -1
|
row = 0
|
||||||
col = -1
|
col = 0
|
||||||
star_col = -1
|
star_col = -1
|
||||||
while not done:
|
while not done:
|
||||||
(row, col) = self.__find_a_zero()
|
(row, col) = self.__find_a_zero(row, col)
|
||||||
if row < 0:
|
if row < 0:
|
||||||
done = True
|
done = True
|
||||||
step = 6
|
step = 6
|
||||||
@@ -522,7 +278,7 @@ class Munkres:
|
|||||||
|
|
||||||
return step
|
return step
|
||||||
|
|
||||||
def __step5(self):
|
def __step5(self) -> int:
|
||||||
"""
|
"""
|
||||||
Construct a series of alternating primed and starred zeros as
|
Construct a series of alternating primed and starred zeros as
|
||||||
follows. Let Z0 represent the uncovered primed zero found in Step 4.
|
follows. Let Z0 represent the uncovered primed zero found in Step 4.
|
||||||
@@ -558,7 +314,7 @@ class Munkres:
|
|||||||
self.__erase_primes()
|
self.__erase_primes()
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
def __step6(self):
|
def __step6(self) -> int:
|
||||||
"""
|
"""
|
||||||
Add the value found in Step 4 to every element of each covered
|
Add the value found in Step 4 to every element of each covered
|
||||||
row, and subtract it from every element of each uncovered column.
|
row, and subtract it from every element of each uncovered column.
|
||||||
@@ -566,34 +322,44 @@ class Munkres:
|
|||||||
lines.
|
lines.
|
||||||
"""
|
"""
|
||||||
minval = self.__find_smallest()
|
minval = self.__find_smallest()
|
||||||
|
events = 0 # track actual changes to matrix
|
||||||
for i in range(self.n):
|
for i in range(self.n):
|
||||||
for j in range(self.n):
|
for j in range(self.n):
|
||||||
|
if self.C[i][j] is DISALLOWED:
|
||||||
|
continue
|
||||||
if self.row_covered[i]:
|
if self.row_covered[i]:
|
||||||
self.C[i][j] += minval
|
self.C[i][j] += minval
|
||||||
|
events += 1
|
||||||
if not self.col_covered[j]:
|
if not self.col_covered[j]:
|
||||||
self.C[i][j] -= minval
|
self.C[i][j] -= minval
|
||||||
|
events += 1
|
||||||
|
if self.row_covered[i] and not self.col_covered[j]:
|
||||||
|
events -= 2 # change reversed, no real difference
|
||||||
|
if (events == 0):
|
||||||
|
raise UnsolvableMatrix("Matrix cannot be solved!")
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
def __find_smallest(self):
|
def __find_smallest(self) -> AnyNum:
|
||||||
"""Find the smallest uncovered value in the matrix."""
|
"""Find the smallest uncovered value in the matrix."""
|
||||||
minval = sys.maxsize
|
minval = sys.maxsize
|
||||||
for i in range(self.n):
|
for i in range(self.n):
|
||||||
for j in range(self.n):
|
for j in range(self.n):
|
||||||
if (not self.row_covered[i]) and (not self.col_covered[j]):
|
if (not self.row_covered[i]) and (not self.col_covered[j]):
|
||||||
if minval > self.C[i][j]:
|
if self.C[i][j] is not DISALLOWED and minval > self.C[i][j]:
|
||||||
minval = self.C[i][j]
|
minval = self.C[i][j]
|
||||||
return minval
|
return minval
|
||||||
|
|
||||||
def __find_a_zero(self):
|
|
||||||
|
def __find_a_zero(self, i0: int = 0, j0: int = 0) -> Tuple[int, int]:
|
||||||
"""Find the first uncovered element with value 0"""
|
"""Find the first uncovered element with value 0"""
|
||||||
row = -1
|
row = -1
|
||||||
col = -1
|
col = -1
|
||||||
i = 0
|
i = i0
|
||||||
n = self.n
|
n = self.n
|
||||||
done = False
|
done = False
|
||||||
|
|
||||||
while not done:
|
while not done:
|
||||||
j = 0
|
j = j0
|
||||||
while True:
|
while True:
|
||||||
if (self.C[i][j] == 0) and \
|
if (self.C[i][j] == 0) and \
|
||||||
(not self.row_covered[i]) and \
|
(not self.row_covered[i]) and \
|
||||||
@@ -601,16 +367,16 @@ class Munkres:
|
|||||||
row = i
|
row = i
|
||||||
col = j
|
col = j
|
||||||
done = True
|
done = True
|
||||||
j += 1
|
j = (j + 1) % n
|
||||||
if j >= n:
|
if j == j0:
|
||||||
break
|
break
|
||||||
i += 1
|
i = (i + 1) % n
|
||||||
if i >= n:
|
if i == i0:
|
||||||
done = True
|
done = True
|
||||||
|
|
||||||
return (row, col)
|
return (row, col)
|
||||||
|
|
||||||
def __find_star_in_row(self, row):
|
def __find_star_in_row(self, row: Sequence[AnyNum]) -> int:
|
||||||
"""
|
"""
|
||||||
Find the first starred element in the specified row. Returns
|
Find the first starred element in the specified row. Returns
|
||||||
the column index, or -1 if no starred element was found.
|
the column index, or -1 if no starred element was found.
|
||||||
@@ -623,7 +389,7 @@ class Munkres:
|
|||||||
|
|
||||||
return col
|
return col
|
||||||
|
|
||||||
def __find_star_in_col(self, col):
|
def __find_star_in_col(self, col: Sequence[AnyNum]) -> int:
|
||||||
"""
|
"""
|
||||||
Find the first starred element in the specified row. Returns
|
Find the first starred element in the specified row. Returns
|
||||||
the row index, or -1 if no starred element was found.
|
the row index, or -1 if no starred element was found.
|
||||||
@@ -636,7 +402,7 @@ class Munkres:
|
|||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def __find_prime_in_row(self, row):
|
def __find_prime_in_row(self, row) -> int:
|
||||||
"""
|
"""
|
||||||
Find the first prime element in the specified row. Returns
|
Find the first prime element in the specified row. Returns
|
||||||
the column index, or -1 if no starred element was found.
|
the column index, or -1 if no starred element was found.
|
||||||
@@ -649,20 +415,22 @@ class Munkres:
|
|||||||
|
|
||||||
return col
|
return col
|
||||||
|
|
||||||
def __convert_path(self, path, count):
|
def __convert_path(self,
|
||||||
|
path: Sequence[Sequence[int]],
|
||||||
|
count: int) -> None:
|
||||||
for i in range(count+1):
|
for i in range(count+1):
|
||||||
if self.marked[path[i][0]][path[i][1]] == 1:
|
if self.marked[path[i][0]][path[i][1]] == 1:
|
||||||
self.marked[path[i][0]][path[i][1]] = 0
|
self.marked[path[i][0]][path[i][1]] = 0
|
||||||
else:
|
else:
|
||||||
self.marked[path[i][0]][path[i][1]] = 1
|
self.marked[path[i][0]][path[i][1]] = 1
|
||||||
|
|
||||||
def __clear_covers(self):
|
def __clear_covers(self) -> None:
|
||||||
"""Clear all covered matrix cells"""
|
"""Clear all covered matrix cells"""
|
||||||
for i in range(self.n):
|
for i in range(self.n):
|
||||||
self.row_covered[i] = False
|
self.row_covered[i] = False
|
||||||
self.col_covered[i] = False
|
self.col_covered[i] = False
|
||||||
|
|
||||||
def __erase_primes(self):
|
def __erase_primes(self) -> None:
|
||||||
"""Erase all prime markings"""
|
"""Erase all prime markings"""
|
||||||
for i in range(self.n):
|
for i in range(self.n):
|
||||||
for j in range(self.n):
|
for j in range(self.n):
|
||||||
@@ -673,51 +441,56 @@ class Munkres:
|
|||||||
# Functions
|
# Functions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def make_cost_matrix(profit_matrix, inversion_function):
|
def make_cost_matrix(
|
||||||
|
profit_matrix: Matrix,
|
||||||
|
inversion_function: Optional[Callable[[AnyNum], AnyNum]] = None
|
||||||
|
) -> Matrix:
|
||||||
"""
|
"""
|
||||||
Create a cost matrix from a profit matrix by calling
|
Create a cost matrix from a profit matrix by calling `inversion_function()`
|
||||||
'inversion_function' to invert each value. The inversion
|
to invert each value. The inversion function must take one numeric argument
|
||||||
function must take one numeric argument (of any type) and return
|
(of any type) and return another numeric argument which is presumed to be
|
||||||
another numeric argument which is presumed to be the cost inverse
|
the cost inverse of the original profit value. If the inversion function
|
||||||
of the original profit.
|
is not provided, a given cell's inverted value is calculated as
|
||||||
|
`max(matrix) - value`.
|
||||||
|
|
||||||
This is a static method. Call it like this:
|
This is a static method. Call it like this:
|
||||||
|
|
||||||
.. python::
|
from munkres import Munkres
|
||||||
|
|
||||||
cost_matrix = Munkres.make_cost_matrix(matrix, inversion_func)
|
cost_matrix = Munkres.make_cost_matrix(matrix, inversion_func)
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
.. python::
|
from munkres import Munkres
|
||||||
|
|
||||||
cost_matrix = Munkres.make_cost_matrix(matrix, lambda x : sys.maxsize - x)
|
cost_matrix = Munkres.make_cost_matrix(matrix, lambda x : sys.maxsize - x)
|
||||||
|
|
||||||
:Parameters:
|
**Parameters**
|
||||||
profit_matrix : list of lists
|
|
||||||
The matrix to convert from a profit to a cost matrix
|
|
||||||
|
|
||||||
inversion_function : function
|
- `profit_matrix` (list of lists of numbers): The matrix to convert from
|
||||||
The function to use to invert each entry in the profit matrix
|
profit to cost values.
|
||||||
|
- `inversion_function` (`function`): The function to use to invert each
|
||||||
|
entry in the profit matrix.
|
||||||
|
|
||||||
:rtype: list of lists
|
**Returns**
|
||||||
:return: The converted matrix
|
|
||||||
|
A new matrix representing the inversion of `profix_matrix`.
|
||||||
"""
|
"""
|
||||||
|
if not inversion_function:
|
||||||
|
maximum = max(max(row) for row in profit_matrix)
|
||||||
|
inversion_function = lambda x: maximum - x
|
||||||
|
|
||||||
cost_matrix = []
|
cost_matrix = []
|
||||||
for row in profit_matrix:
|
for row in profit_matrix:
|
||||||
cost_matrix.append([inversion_function(value) for value in row])
|
cost_matrix.append([inversion_function(value) for value in row])
|
||||||
return cost_matrix
|
return cost_matrix
|
||||||
|
|
||||||
def print_matrix(matrix, msg=None):
|
def print_matrix(matrix: Matrix, msg: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Convenience function: Displays the contents of a matrix of integers.
|
Convenience function: Displays the contents of a matrix.
|
||||||
|
|
||||||
:Parameters:
|
**Parameters**
|
||||||
matrix : list of lists
|
|
||||||
Matrix to print
|
|
||||||
|
|
||||||
msg : str
|
- `matrix` (list of lists of numbers): The matrix to print
|
||||||
Optional message to print before displaying the matrix
|
- `msg` (`str`): Optional message to print before displaying the matrix
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
|
||||||
@@ -728,16 +501,21 @@ def print_matrix(matrix, msg=None):
|
|||||||
width = 0
|
width = 0
|
||||||
for row in matrix:
|
for row in matrix:
|
||||||
for val in row:
|
for val in row:
|
||||||
width = max(width, int(math.log10(val)) + 1)
|
if val is DISALLOWED:
|
||||||
|
val = DISALLOWED_PRINTVAL
|
||||||
|
width = max(width, len(str(val)))
|
||||||
|
|
||||||
# Make the format string
|
# Make the format string
|
||||||
format = '%%%dd' % width
|
format = ('%%%d' % width)
|
||||||
|
|
||||||
# Print the matrix
|
# Print the matrix
|
||||||
for row in matrix:
|
for row in matrix:
|
||||||
sep = '['
|
sep = '['
|
||||||
for val in row:
|
for val in row:
|
||||||
sys.stdout.write(sep + format % val)
|
if val is DISALLOWED:
|
||||||
|
val = DISALLOWED_PRINTVAL
|
||||||
|
formatted = ((format + 's') % val)
|
||||||
|
sys.stdout.write(sep + formatted)
|
||||||
sep = ', '
|
sep = ', '
|
||||||
sys.stdout.write(']\n')
|
sys.stdout.write(']\n')
|
||||||
|
|
||||||
@@ -767,11 +545,51 @@ if __name__ == '__main__':
|
|||||||
[9, 7, 4]],
|
[9, 7, 4]],
|
||||||
18),
|
18),
|
||||||
|
|
||||||
|
# Square variant with floating point value
|
||||||
|
([[10.1, 10.2, 8.3],
|
||||||
|
[9.4, 8.5, 1.6],
|
||||||
|
[9.7, 7.8, 4.9]],
|
||||||
|
19.5),
|
||||||
|
|
||||||
# Rectangular variant
|
# Rectangular variant
|
||||||
([[10, 10, 8, 11],
|
([[10, 10, 8, 11],
|
||||||
[9, 8, 1, 1],
|
[9, 8, 1, 1],
|
||||||
[9, 7, 4, 10]],
|
[9, 7, 4, 10]],
|
||||||
15)]
|
15),
|
||||||
|
|
||||||
|
# Rectangular variant with floating point value
|
||||||
|
([[10.01, 10.02, 8.03, 11.04],
|
||||||
|
[9.05, 8.06, 1.07, 1.08],
|
||||||
|
[9.09, 7.1, 4.11, 10.12]],
|
||||||
|
15.2),
|
||||||
|
|
||||||
|
# Rectangular with DISALLOWED
|
||||||
|
([[4, 5, 6, DISALLOWED],
|
||||||
|
[1, 9, 12, 11],
|
||||||
|
[DISALLOWED, 5, 4, DISALLOWED],
|
||||||
|
[12, 12, 12, 10]],
|
||||||
|
20),
|
||||||
|
|
||||||
|
# Rectangular variant with DISALLOWED and floating point value
|
||||||
|
([[4.001, 5.002, 6.003, DISALLOWED],
|
||||||
|
[1.004, 9.005, 12.006, 11.007],
|
||||||
|
[DISALLOWED, 5.008, 4.009, DISALLOWED],
|
||||||
|
[12.01, 12.011, 12.012, 10.013]],
|
||||||
|
20.028),
|
||||||
|
|
||||||
|
# DISALLOWED to force pairings
|
||||||
|
([[1, DISALLOWED, DISALLOWED, DISALLOWED],
|
||||||
|
[DISALLOWED, 2, DISALLOWED, DISALLOWED],
|
||||||
|
[DISALLOWED, DISALLOWED, 3, DISALLOWED],
|
||||||
|
[DISALLOWED, DISALLOWED, DISALLOWED, 4]],
|
||||||
|
10),
|
||||||
|
|
||||||
|
# DISALLOWED to force pairings with floating point value
|
||||||
|
([[1.1, DISALLOWED, DISALLOWED, DISALLOWED],
|
||||||
|
[DISALLOWED, 2.2, DISALLOWED, DISALLOWED],
|
||||||
|
[DISALLOWED, DISALLOWED, 3.3, DISALLOWED],
|
||||||
|
[DISALLOWED, DISALLOWED, DISALLOWED, 4.4]],
|
||||||
|
11.0)]
|
||||||
|
|
||||||
m = Munkres()
|
m = Munkres()
|
||||||
for cost_matrix, expected_total in matrices:
|
for cost_matrix, expected_total in matrices:
|
||||||
@@ -781,6 +599,6 @@ if __name__ == '__main__':
|
|||||||
for r, c in indexes:
|
for r, c in indexes:
|
||||||
x = cost_matrix[r][c]
|
x = cost_matrix[r][c]
|
||||||
total_cost += x
|
total_cost += x
|
||||||
print(('(%d, %d) -> %d' % (r, c, x)))
|
print(('(%d, %d) -> %s' % (r, c, x)))
|
||||||
print(('lowest cost=%d' % total_cost))
|
print(('lowest cost=%s' % total_cost))
|
||||||
assert expected_total == total_cost
|
assert expected_total == total_cost
|
||||||
|
|||||||
+371
-316
File diff suppressed because it is too large
Load Diff
@@ -1,608 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright (c) 2005-2010 ActiveState Software Inc.
|
|
||||||
# Copyright (c) 2013 Eddy Petrișor
|
|
||||||
|
|
||||||
"""Utilities for determining application-specific dirs.
|
|
||||||
|
|
||||||
See <http://github.com/ActiveState/appdirs> for details and usage.
|
|
||||||
"""
|
|
||||||
# Dev Notes:
|
|
||||||
# - MSDN on where to store app data files:
|
|
||||||
# http://support.microsoft.com/default.aspx?scid=kb;en-us;310294#XSLTH3194121123120121120120
|
|
||||||
# - Mac OS X: http://developer.apple.com/documentation/MacOSX/Conceptual/BPFileSystem/index.html
|
|
||||||
# - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
|
||||||
|
|
||||||
__version_info__ = (1, 4, 3)
|
|
||||||
__version__ = '.'.join(map(str, __version_info__))
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
PY3 = sys.version_info[0] == 3
|
|
||||||
|
|
||||||
if PY3:
|
|
||||||
unicode = str
|
|
||||||
|
|
||||||
if sys.platform.startswith('java'):
|
|
||||||
import platform
|
|
||||||
os_name = platform.java_ver()[3][0]
|
|
||||||
if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc.
|
|
||||||
system = 'win32'
|
|
||||||
elif os_name.startswith('Mac'): # "Mac OS X", etc.
|
|
||||||
system = 'darwin'
|
|
||||||
else: # "Linux", "SunOS", "FreeBSD", etc.
|
|
||||||
# Setting this to "linux2" is not ideal, but only Windows or Mac
|
|
||||||
# are actually checked for and the rest of the module expects
|
|
||||||
# *sys.platform* style strings.
|
|
||||||
system = 'linux2'
|
|
||||||
else:
|
|
||||||
system = sys.platform
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def user_data_dir(appname=None, appauthor=None, version=None, roaming=False):
|
|
||||||
r"""Return full path to the user-specific data dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"roaming" (boolean, default False) can be set True to use the Windows
|
|
||||||
roaming appdata directory. That means that for users on a Windows
|
|
||||||
network setup for roaming profiles, this user data will be
|
|
||||||
sync'd on login. See
|
|
||||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
|
||||||
for a discussion of issues.
|
|
||||||
|
|
||||||
Typical user data directories are:
|
|
||||||
Mac OS X: ~/Library/Application Support/<AppName>
|
|
||||||
Unix: ~/.local/share/<AppName> # or in $XDG_DATA_HOME, if defined
|
|
||||||
Win XP (not roaming): C:\Documents and Settings\<username>\Application Data\<AppAuthor>\<AppName>
|
|
||||||
Win XP (roaming): C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>
|
|
||||||
Win 7 (not roaming): C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>
|
|
||||||
Win 7 (roaming): C:\Users\<username>\AppData\Roaming\<AppAuthor>\<AppName>
|
|
||||||
|
|
||||||
For Unix, we follow the XDG spec and support $XDG_DATA_HOME.
|
|
||||||
That means, by default "~/.local/share/<AppName>".
|
|
||||||
"""
|
|
||||||
if system == "win32":
|
|
||||||
if appauthor is None:
|
|
||||||
appauthor = appname
|
|
||||||
const = roaming and "CSIDL_APPDATA" or "CSIDL_LOCAL_APPDATA"
|
|
||||||
path = os.path.normpath(_get_win_folder(const))
|
|
||||||
if appname:
|
|
||||||
if appauthor is not False:
|
|
||||||
path = os.path.join(path, appauthor, appname)
|
|
||||||
else:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
elif system == 'darwin':
|
|
||||||
path = os.path.expanduser('~/Library/Application Support/')
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
else:
|
|
||||||
path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share"))
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def site_data_dir(appname=None, appauthor=None, version=None, multipath=False):
|
|
||||||
r"""Return full path to the user-shared data dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"multipath" is an optional parameter only applicable to *nix
|
|
||||||
which indicates that the entire list of data dirs should be
|
|
||||||
returned. By default, the first item from XDG_DATA_DIRS is
|
|
||||||
returned, or '/usr/local/share/<AppName>',
|
|
||||||
if XDG_DATA_DIRS is not set
|
|
||||||
|
|
||||||
Typical site data directories are:
|
|
||||||
Mac OS X: /Library/Application Support/<AppName>
|
|
||||||
Unix: /usr/local/share/<AppName> or /usr/share/<AppName>
|
|
||||||
Win XP: C:\Documents and Settings\All Users\Application Data\<AppAuthor>\<AppName>
|
|
||||||
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
|
|
||||||
Win 7: C:\ProgramData\<AppAuthor>\<AppName> # Hidden, but writeable on Win 7.
|
|
||||||
|
|
||||||
For Unix, this is using the $XDG_DATA_DIRS[0] default.
|
|
||||||
|
|
||||||
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
|
|
||||||
"""
|
|
||||||
if system == "win32":
|
|
||||||
if appauthor is None:
|
|
||||||
appauthor = appname
|
|
||||||
path = os.path.normpath(_get_win_folder("CSIDL_COMMON_APPDATA"))
|
|
||||||
if appname:
|
|
||||||
if appauthor is not False:
|
|
||||||
path = os.path.join(path, appauthor, appname)
|
|
||||||
else:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
elif system == 'darwin':
|
|
||||||
path = os.path.expanduser('/Library/Application Support')
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
else:
|
|
||||||
# XDG default for $XDG_DATA_DIRS
|
|
||||||
# only first, if multipath is False
|
|
||||||
path = os.getenv('XDG_DATA_DIRS',
|
|
||||||
os.pathsep.join(['/usr/local/share', '/usr/share']))
|
|
||||||
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
|
|
||||||
if appname:
|
|
||||||
if version:
|
|
||||||
appname = os.path.join(appname, version)
|
|
||||||
pathlist = [os.sep.join([x, appname]) for x in pathlist]
|
|
||||||
|
|
||||||
if multipath:
|
|
||||||
path = os.pathsep.join(pathlist)
|
|
||||||
else:
|
|
||||||
path = pathlist[0]
|
|
||||||
return path
|
|
||||||
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def user_config_dir(appname=None, appauthor=None, version=None, roaming=False):
|
|
||||||
r"""Return full path to the user-specific config dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"roaming" (boolean, default False) can be set True to use the Windows
|
|
||||||
roaming appdata directory. That means that for users on a Windows
|
|
||||||
network setup for roaming profiles, this user data will be
|
|
||||||
sync'd on login. See
|
|
||||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
|
||||||
for a discussion of issues.
|
|
||||||
|
|
||||||
Typical user config directories are:
|
|
||||||
Mac OS X: same as user_data_dir
|
|
||||||
Unix: ~/.config/<AppName> # or in $XDG_CONFIG_HOME, if defined
|
|
||||||
Win *: same as user_data_dir
|
|
||||||
|
|
||||||
For Unix, we follow the XDG spec and support $XDG_CONFIG_HOME.
|
|
||||||
That means, by default "~/.config/<AppName>".
|
|
||||||
"""
|
|
||||||
if system in ["win32", "darwin"]:
|
|
||||||
path = user_data_dir(appname, appauthor, None, roaming)
|
|
||||||
else:
|
|
||||||
path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config"))
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def site_config_dir(appname=None, appauthor=None, version=None, multipath=False):
|
|
||||||
r"""Return full path to the user-shared data dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"multipath" is an optional parameter only applicable to *nix
|
|
||||||
which indicates that the entire list of config dirs should be
|
|
||||||
returned. By default, the first item from XDG_CONFIG_DIRS is
|
|
||||||
returned, or '/etc/xdg/<AppName>', if XDG_CONFIG_DIRS is not set
|
|
||||||
|
|
||||||
Typical site config directories are:
|
|
||||||
Mac OS X: same as site_data_dir
|
|
||||||
Unix: /etc/xdg/<AppName> or $XDG_CONFIG_DIRS[i]/<AppName> for each value in
|
|
||||||
$XDG_CONFIG_DIRS
|
|
||||||
Win *: same as site_data_dir
|
|
||||||
Vista: (Fail! "C:\ProgramData" is a hidden *system* directory on Vista.)
|
|
||||||
|
|
||||||
For Unix, this is using the $XDG_CONFIG_DIRS[0] default, if multipath=False
|
|
||||||
|
|
||||||
WARNING: Do not use this on Windows. See the Vista-Fail note above for why.
|
|
||||||
"""
|
|
||||||
if system in ["win32", "darwin"]:
|
|
||||||
path = site_data_dir(appname, appauthor)
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
else:
|
|
||||||
# XDG default for $XDG_CONFIG_DIRS
|
|
||||||
# only first, if multipath is False
|
|
||||||
path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg')
|
|
||||||
pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)]
|
|
||||||
if appname:
|
|
||||||
if version:
|
|
||||||
appname = os.path.join(appname, version)
|
|
||||||
pathlist = [os.sep.join([x, appname]) for x in pathlist]
|
|
||||||
|
|
||||||
if multipath:
|
|
||||||
path = os.pathsep.join(pathlist)
|
|
||||||
else:
|
|
||||||
path = pathlist[0]
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True):
|
|
||||||
r"""Return full path to the user-specific cache dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"opinion" (boolean) can be False to disable the appending of
|
|
||||||
"Cache" to the base app data dir for Windows. See
|
|
||||||
discussion below.
|
|
||||||
|
|
||||||
Typical user cache directories are:
|
|
||||||
Mac OS X: ~/Library/Caches/<AppName>
|
|
||||||
Unix: ~/.cache/<AppName> (XDG default)
|
|
||||||
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Cache
|
|
||||||
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Cache
|
|
||||||
|
|
||||||
On Windows the only suggestion in the MSDN docs is that local settings go in
|
|
||||||
the `CSIDL_LOCAL_APPDATA` directory. This is identical to the non-roaming
|
|
||||||
app data dir (the default returned by `user_data_dir` above). Apps typically
|
|
||||||
put cache data somewhere *under* the given dir here. Some examples:
|
|
||||||
...\Mozilla\Firefox\Profiles\<ProfileName>\Cache
|
|
||||||
...\Acme\SuperApp\Cache\1.0
|
|
||||||
OPINION: This function appends "Cache" to the `CSIDL_LOCAL_APPDATA` value.
|
|
||||||
This can be disabled with the `opinion=False` option.
|
|
||||||
"""
|
|
||||||
if system == "win32":
|
|
||||||
if appauthor is None:
|
|
||||||
appauthor = appname
|
|
||||||
path = os.path.normpath(_get_win_folder("CSIDL_LOCAL_APPDATA"))
|
|
||||||
if appname:
|
|
||||||
if appauthor is not False:
|
|
||||||
path = os.path.join(path, appauthor, appname)
|
|
||||||
else:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
if opinion:
|
|
||||||
path = os.path.join(path, "Cache")
|
|
||||||
elif system == 'darwin':
|
|
||||||
path = os.path.expanduser('~/Library/Caches')
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
else:
|
|
||||||
path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def user_state_dir(appname=None, appauthor=None, version=None, roaming=False):
|
|
||||||
r"""Return full path to the user-specific state dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"roaming" (boolean, default False) can be set True to use the Windows
|
|
||||||
roaming appdata directory. That means that for users on a Windows
|
|
||||||
network setup for roaming profiles, this user data will be
|
|
||||||
sync'd on login. See
|
|
||||||
<http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx>
|
|
||||||
for a discussion of issues.
|
|
||||||
|
|
||||||
Typical user state directories are:
|
|
||||||
Mac OS X: same as user_data_dir
|
|
||||||
Unix: ~/.local/state/<AppName> # or in $XDG_STATE_HOME, if defined
|
|
||||||
Win *: same as user_data_dir
|
|
||||||
|
|
||||||
For Unix, we follow this Debian proposal <https://wiki.debian.org/XDGBaseDirectorySpecification#state>
|
|
||||||
to extend the XDG spec and support $XDG_STATE_HOME.
|
|
||||||
|
|
||||||
That means, by default "~/.local/state/<AppName>".
|
|
||||||
"""
|
|
||||||
if system in ["win32", "darwin"]:
|
|
||||||
path = user_data_dir(appname, appauthor, None, roaming)
|
|
||||||
else:
|
|
||||||
path = os.getenv('XDG_STATE_HOME', os.path.expanduser("~/.local/state"))
|
|
||||||
if appname:
|
|
||||||
path = os.path.join(path, appname)
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def user_log_dir(appname=None, appauthor=None, version=None, opinion=True):
|
|
||||||
r"""Return full path to the user-specific log dir for this application.
|
|
||||||
|
|
||||||
"appname" is the name of application.
|
|
||||||
If None, just the system directory is returned.
|
|
||||||
"appauthor" (only used on Windows) is the name of the
|
|
||||||
appauthor or distributing body for this application. Typically
|
|
||||||
it is the owning company name. This falls back to appname. You may
|
|
||||||
pass False to disable it.
|
|
||||||
"version" is an optional version path element to append to the
|
|
||||||
path. You might want to use this if you want multiple versions
|
|
||||||
of your app to be able to run independently. If used, this
|
|
||||||
would typically be "<major>.<minor>".
|
|
||||||
Only applied when appname is present.
|
|
||||||
"opinion" (boolean) can be False to disable the appending of
|
|
||||||
"Logs" to the base app data dir for Windows, and "log" to the
|
|
||||||
base cache dir for Unix. See discussion below.
|
|
||||||
|
|
||||||
Typical user log directories are:
|
|
||||||
Mac OS X: ~/Library/Logs/<AppName>
|
|
||||||
Unix: ~/.cache/<AppName>/log # or under $XDG_CACHE_HOME if defined
|
|
||||||
Win XP: C:\Documents and Settings\<username>\Local Settings\Application Data\<AppAuthor>\<AppName>\Logs
|
|
||||||
Vista: C:\Users\<username>\AppData\Local\<AppAuthor>\<AppName>\Logs
|
|
||||||
|
|
||||||
On Windows the only suggestion in the MSDN docs is that local settings
|
|
||||||
go in the `CSIDL_LOCAL_APPDATA` directory. (Note: I'm interested in
|
|
||||||
examples of what some windows apps use for a logs dir.)
|
|
||||||
|
|
||||||
OPINION: This function appends "Logs" to the `CSIDL_LOCAL_APPDATA`
|
|
||||||
value for Windows and appends "log" to the user cache dir for Unix.
|
|
||||||
This can be disabled with the `opinion=False` option.
|
|
||||||
"""
|
|
||||||
if system == "darwin":
|
|
||||||
path = os.path.join(
|
|
||||||
os.path.expanduser('~/Library/Logs'),
|
|
||||||
appname)
|
|
||||||
elif system == "win32":
|
|
||||||
path = user_data_dir(appname, appauthor, version)
|
|
||||||
version = False
|
|
||||||
if opinion:
|
|
||||||
path = os.path.join(path, "Logs")
|
|
||||||
else:
|
|
||||||
path = user_cache_dir(appname, appauthor, version)
|
|
||||||
version = False
|
|
||||||
if opinion:
|
|
||||||
path = os.path.join(path, "log")
|
|
||||||
if appname and version:
|
|
||||||
path = os.path.join(path, version)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
class AppDirs(object):
|
|
||||||
"""Convenience wrapper for getting application dirs."""
|
|
||||||
def __init__(self, appname=None, appauthor=None, version=None,
|
|
||||||
roaming=False, multipath=False):
|
|
||||||
self.appname = appname
|
|
||||||
self.appauthor = appauthor
|
|
||||||
self.version = version
|
|
||||||
self.roaming = roaming
|
|
||||||
self.multipath = multipath
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_data_dir(self):
|
|
||||||
return user_data_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version, roaming=self.roaming)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def site_data_dir(self):
|
|
||||||
return site_data_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version, multipath=self.multipath)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_config_dir(self):
|
|
||||||
return user_config_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version, roaming=self.roaming)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def site_config_dir(self):
|
|
||||||
return site_config_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version, multipath=self.multipath)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_cache_dir(self):
|
|
||||||
return user_cache_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_state_dir(self):
|
|
||||||
return user_state_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_log_dir(self):
|
|
||||||
return user_log_dir(self.appname, self.appauthor,
|
|
||||||
version=self.version)
|
|
||||||
|
|
||||||
|
|
||||||
#---- internal support stuff
|
|
||||||
|
|
||||||
def _get_win_folder_from_registry(csidl_name):
|
|
||||||
"""This is a fallback technique at best. I'm not sure if using the
|
|
||||||
registry for this guarantees us the correct answer for all CSIDL_*
|
|
||||||
names.
|
|
||||||
"""
|
|
||||||
if PY3:
|
|
||||||
import winreg as _winreg
|
|
||||||
else:
|
|
||||||
import _winreg
|
|
||||||
|
|
||||||
shell_folder_name = {
|
|
||||||
"CSIDL_APPDATA": "AppData",
|
|
||||||
"CSIDL_COMMON_APPDATA": "Common AppData",
|
|
||||||
"CSIDL_LOCAL_APPDATA": "Local AppData",
|
|
||||||
}[csidl_name]
|
|
||||||
|
|
||||||
key = _winreg.OpenKey(
|
|
||||||
_winreg.HKEY_CURRENT_USER,
|
|
||||||
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders"
|
|
||||||
)
|
|
||||||
dir, type = _winreg.QueryValueEx(key, shell_folder_name)
|
|
||||||
return dir
|
|
||||||
|
|
||||||
|
|
||||||
def _get_win_folder_with_pywin32(csidl_name):
|
|
||||||
from win32com.shell import shellcon, shell
|
|
||||||
dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0)
|
|
||||||
# Try to make this a unicode path because SHGetFolderPath does
|
|
||||||
# not return unicode strings when there is unicode data in the
|
|
||||||
# path.
|
|
||||||
try:
|
|
||||||
dir = unicode(dir)
|
|
||||||
|
|
||||||
# Downgrade to short path name if have highbit chars. See
|
|
||||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
|
||||||
has_high_char = False
|
|
||||||
for c in dir:
|
|
||||||
if ord(c) > 255:
|
|
||||||
has_high_char = True
|
|
||||||
break
|
|
||||||
if has_high_char:
|
|
||||||
try:
|
|
||||||
import win32api
|
|
||||||
dir = win32api.GetShortPathName(dir)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
except UnicodeError:
|
|
||||||
pass
|
|
||||||
return dir
|
|
||||||
|
|
||||||
|
|
||||||
def _get_win_folder_with_ctypes(csidl_name):
|
|
||||||
import ctypes
|
|
||||||
|
|
||||||
csidl_const = {
|
|
||||||
"CSIDL_APPDATA": 26,
|
|
||||||
"CSIDL_COMMON_APPDATA": 35,
|
|
||||||
"CSIDL_LOCAL_APPDATA": 28,
|
|
||||||
}[csidl_name]
|
|
||||||
|
|
||||||
buf = ctypes.create_unicode_buffer(1024)
|
|
||||||
ctypes.windll.shell32.SHGetFolderPathW(None, csidl_const, None, 0, buf)
|
|
||||||
|
|
||||||
# Downgrade to short path name if have highbit chars. See
|
|
||||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
|
||||||
has_high_char = False
|
|
||||||
for c in buf:
|
|
||||||
if ord(c) > 255:
|
|
||||||
has_high_char = True
|
|
||||||
break
|
|
||||||
if has_high_char:
|
|
||||||
buf2 = ctypes.create_unicode_buffer(1024)
|
|
||||||
if ctypes.windll.kernel32.GetShortPathNameW(buf.value, buf2, 1024):
|
|
||||||
buf = buf2
|
|
||||||
|
|
||||||
return buf.value
|
|
||||||
|
|
||||||
def _get_win_folder_with_jna(csidl_name):
|
|
||||||
import array
|
|
||||||
from com.sun import jna
|
|
||||||
from com.sun.jna.platform import win32
|
|
||||||
|
|
||||||
buf_size = win32.WinDef.MAX_PATH * 2
|
|
||||||
buf = array.zeros('c', buf_size)
|
|
||||||
shell = win32.Shell32.INSTANCE
|
|
||||||
shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf)
|
|
||||||
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
|
|
||||||
|
|
||||||
# Downgrade to short path name if have highbit chars. See
|
|
||||||
# <http://bugs.activestate.com/show_bug.cgi?id=85099>.
|
|
||||||
has_high_char = False
|
|
||||||
for c in dir:
|
|
||||||
if ord(c) > 255:
|
|
||||||
has_high_char = True
|
|
||||||
break
|
|
||||||
if has_high_char:
|
|
||||||
buf = array.zeros('c', buf_size)
|
|
||||||
kernel = win32.Kernel32.INSTANCE
|
|
||||||
if kernel.GetShortPathName(dir, buf, buf_size):
|
|
||||||
dir = jna.Native.toString(buf.tostring()).rstrip("\0")
|
|
||||||
|
|
||||||
return dir
|
|
||||||
|
|
||||||
if system == "win32":
|
|
||||||
try:
|
|
||||||
import win32com.shell
|
|
||||||
_get_win_folder = _get_win_folder_with_pywin32
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
from ctypes import windll
|
|
||||||
_get_win_folder = _get_win_folder_with_ctypes
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
import com.sun.jna
|
|
||||||
_get_win_folder = _get_win_folder_with_jna
|
|
||||||
except ImportError:
|
|
||||||
_get_win_folder = _get_win_folder_from_registry
|
|
||||||
|
|
||||||
|
|
||||||
#---- self test code
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
appname = "MyApp"
|
|
||||||
appauthor = "MyCompany"
|
|
||||||
|
|
||||||
props = ("user_data_dir",
|
|
||||||
"user_config_dir",
|
|
||||||
"user_cache_dir",
|
|
||||||
"user_state_dir",
|
|
||||||
"user_log_dir",
|
|
||||||
"site_data_dir",
|
|
||||||
"site_config_dir")
|
|
||||||
|
|
||||||
print("-- app dirs %s --" % __version__)
|
|
||||||
|
|
||||||
print("-- app dirs (with optional 'version')")
|
|
||||||
dirs = AppDirs(appname, appauthor, version="1.0")
|
|
||||||
for prop in props:
|
|
||||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
|
||||||
|
|
||||||
print("\n-- app dirs (without optional 'version')")
|
|
||||||
dirs = AppDirs(appname, appauthor)
|
|
||||||
for prop in props:
|
|
||||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
|
||||||
|
|
||||||
print("\n-- app dirs (without optional 'appauthor')")
|
|
||||||
dirs = AppDirs(appname)
|
|
||||||
for prop in props:
|
|
||||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
|
||||||
|
|
||||||
print("\n-- app dirs (with disabled 'appauthor')")
|
|
||||||
dirs = AppDirs(appname, appauthor=False)
|
|
||||||
for prop in props:
|
|
||||||
print("%s: %s" % (prop, getattr(dirs, prop)))
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
importlib_resources
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Read resources contained within a package."""
|
||||||
|
|
||||||
|
from ._common import (
|
||||||
|
as_file,
|
||||||
|
files,
|
||||||
|
Package,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._legacy import (
|
||||||
|
contents,
|
||||||
|
open_binary,
|
||||||
|
read_binary,
|
||||||
|
open_text,
|
||||||
|
read_text,
|
||||||
|
is_resource,
|
||||||
|
path,
|
||||||
|
Resource,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .abc import ResourceReader
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'Package',
|
||||||
|
'Resource',
|
||||||
|
'ResourceReader',
|
||||||
|
'as_file',
|
||||||
|
'contents',
|
||||||
|
'files',
|
||||||
|
'is_resource',
|
||||||
|
'open_binary',
|
||||||
|
'open_text',
|
||||||
|
'path',
|
||||||
|
'read_binary',
|
||||||
|
'read_text',
|
||||||
|
]
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from contextlib import suppress
|
||||||
|
from io import TextIOWrapper
|
||||||
|
|
||||||
|
from . import abc
|
||||||
|
|
||||||
|
|
||||||
|
class SpecLoaderAdapter:
|
||||||
|
"""
|
||||||
|
Adapt a package spec to adapt the underlying loader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, spec, adapter=lambda spec: spec.loader):
|
||||||
|
self.spec = spec
|
||||||
|
self.loader = adapter(spec)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self.spec, name)
|
||||||
|
|
||||||
|
|
||||||
|
class TraversableResourcesLoader:
|
||||||
|
"""
|
||||||
|
Adapt a loader to provide TraversableResources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, spec):
|
||||||
|
self.spec = spec
|
||||||
|
|
||||||
|
def get_resource_reader(self, name):
|
||||||
|
return CompatibilityFiles(self.spec)._native()
|
||||||
|
|
||||||
|
|
||||||
|
def _io_wrapper(file, mode='r', *args, **kwargs):
|
||||||
|
if mode == 'r':
|
||||||
|
return TextIOWrapper(file, *args, **kwargs)
|
||||||
|
elif mode == 'rb':
|
||||||
|
return file
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid mode value '{}', only 'r' and 'rb' are supported".format(mode)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompatibilityFiles:
|
||||||
|
"""
|
||||||
|
Adapter for an existing or non-existent resource reader
|
||||||
|
to provide a compatibility .files().
|
||||||
|
"""
|
||||||
|
|
||||||
|
class SpecPath(abc.Traversable):
|
||||||
|
"""
|
||||||
|
Path tied to a module spec.
|
||||||
|
Can be read and exposes the resource reader children.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, spec, reader):
|
||||||
|
self._spec = spec
|
||||||
|
self._reader = reader
|
||||||
|
|
||||||
|
def iterdir(self):
|
||||||
|
if not self._reader:
|
||||||
|
return iter(())
|
||||||
|
return iter(
|
||||||
|
CompatibilityFiles.ChildPath(self._reader, path)
|
||||||
|
for path in self._reader.contents()
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_dir = is_file
|
||||||
|
|
||||||
|
def joinpath(self, other):
|
||||||
|
if not self._reader:
|
||||||
|
return CompatibilityFiles.OrphanPath(other)
|
||||||
|
return CompatibilityFiles.ChildPath(self._reader, other)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._spec.name
|
||||||
|
|
||||||
|
def open(self, mode='r', *args, **kwargs):
|
||||||
|
return _io_wrapper(self._reader.open_resource(None), mode, *args, **kwargs)
|
||||||
|
|
||||||
|
class ChildPath(abc.Traversable):
|
||||||
|
"""
|
||||||
|
Path tied to a resource reader child.
|
||||||
|
Can be read but doesn't expose any meaningful children.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reader, name):
|
||||||
|
self._reader = reader
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
def iterdir(self):
|
||||||
|
return iter(())
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return self._reader.is_resource(self.name)
|
||||||
|
|
||||||
|
def is_dir(self):
|
||||||
|
return not self.is_file()
|
||||||
|
|
||||||
|
def joinpath(self, other):
|
||||||
|
return CompatibilityFiles.OrphanPath(self.name, other)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
def open(self, mode='r', *args, **kwargs):
|
||||||
|
return _io_wrapper(
|
||||||
|
self._reader.open_resource(self.name), mode, *args, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
class OrphanPath(abc.Traversable):
|
||||||
|
"""
|
||||||
|
Orphan path, not tied to a module spec or resource reader.
|
||||||
|
Can't be read and doesn't expose any meaningful children.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *path_parts):
|
||||||
|
if len(path_parts) < 1:
|
||||||
|
raise ValueError('Need at least one path part to construct a path')
|
||||||
|
self._path = path_parts
|
||||||
|
|
||||||
|
def iterdir(self):
|
||||||
|
return iter(())
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_dir = is_file
|
||||||
|
|
||||||
|
def joinpath(self, other):
|
||||||
|
return CompatibilityFiles.OrphanPath(*self._path, other)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._path[-1]
|
||||||
|
|
||||||
|
def open(self, mode='r', *args, **kwargs):
|
||||||
|
raise FileNotFoundError("Can't open orphan path")
|
||||||
|
|
||||||
|
def __init__(self, spec):
|
||||||
|
self.spec = spec
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _reader(self):
|
||||||
|
with suppress(AttributeError):
|
||||||
|
return self.spec.loader.get_resource_reader(self.spec.name)
|
||||||
|
|
||||||
|
def _native(self):
|
||||||
|
"""
|
||||||
|
Return the native reader if it supports files().
|
||||||
|
"""
|
||||||
|
reader = self._reader
|
||||||
|
return reader if hasattr(reader, 'files') else self
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self._reader, attr)
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
return CompatibilityFiles.SpecPath(self.spec, self._reader)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_spec(package):
|
||||||
|
"""
|
||||||
|
Construct a package spec with traversable compatibility
|
||||||
|
on the spec/loader/reader.
|
||||||
|
"""
|
||||||
|
return SpecLoaderAdapter(package.__spec__, TraversableResourcesLoader)
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import tempfile
|
||||||
|
import functools
|
||||||
|
import contextlib
|
||||||
|
import types
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import warnings
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
from typing import Union, Optional, cast
|
||||||
|
from .abc import ResourceReader, Traversable
|
||||||
|
|
||||||
|
from ._compat import wrap_spec
|
||||||
|
|
||||||
|
Package = Union[types.ModuleType, str]
|
||||||
|
Anchor = Package
|
||||||
|
|
||||||
|
|
||||||
|
def package_to_anchor(func):
|
||||||
|
"""
|
||||||
|
Replace 'package' parameter as 'anchor' and warn about the change.
|
||||||
|
|
||||||
|
Other errors should fall through.
|
||||||
|
|
||||||
|
>>> files('a', 'b')
|
||||||
|
Traceback (most recent call last):
|
||||||
|
TypeError: files() takes from 0 to 1 positional arguments but 2 were given
|
||||||
|
"""
|
||||||
|
undefined = object()
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(anchor=undefined, package=undefined):
|
||||||
|
if package is not undefined:
|
||||||
|
if anchor is not undefined:
|
||||||
|
return func(anchor, package)
|
||||||
|
warnings.warn(
|
||||||
|
"First parameter to files is renamed to 'anchor'",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return func(package)
|
||||||
|
elif anchor is undefined:
|
||||||
|
return func()
|
||||||
|
return func(anchor)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@package_to_anchor
|
||||||
|
def files(anchor: Optional[Anchor] = None) -> Traversable:
|
||||||
|
"""
|
||||||
|
Get a Traversable resource for an anchor.
|
||||||
|
"""
|
||||||
|
return from_package(resolve(anchor))
|
||||||
|
|
||||||
|
|
||||||
|
def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
|
||||||
|
"""
|
||||||
|
Return the package's loader if it's a ResourceReader.
|
||||||
|
"""
|
||||||
|
# We can't use
|
||||||
|
# a issubclass() check here because apparently abc.'s __subclasscheck__()
|
||||||
|
# hook wants to create a weak reference to the object, but
|
||||||
|
# zipimport.zipimporter does not support weak references, resulting in a
|
||||||
|
# TypeError. That seems terrible.
|
||||||
|
spec = package.__spec__
|
||||||
|
reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore
|
||||||
|
if reader is None:
|
||||||
|
return None
|
||||||
|
return reader(spec.name) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
@functools.singledispatch
|
||||||
|
def resolve(cand: Optional[Anchor]) -> types.ModuleType:
|
||||||
|
return cast(types.ModuleType, cand)
|
||||||
|
|
||||||
|
|
||||||
|
@resolve.register
|
||||||
|
def _(cand: str) -> types.ModuleType:
|
||||||
|
return importlib.import_module(cand)
|
||||||
|
|
||||||
|
|
||||||
|
@resolve.register
|
||||||
|
def _(cand: None) -> types.ModuleType:
|
||||||
|
return resolve(_infer_caller().f_globals['__name__'])
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_caller():
|
||||||
|
"""
|
||||||
|
Walk the stack and find the frame of the first caller not in this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def is_this_file(frame_info):
|
||||||
|
return frame_info.filename == __file__
|
||||||
|
|
||||||
|
def is_wrapper(frame_info):
|
||||||
|
return frame_info.function == 'wrapper'
|
||||||
|
|
||||||
|
not_this_file = itertools.filterfalse(is_this_file, inspect.stack())
|
||||||
|
# also exclude 'wrapper' due to singledispatch in the call stack
|
||||||
|
callers = itertools.filterfalse(is_wrapper, not_this_file)
|
||||||
|
return next(callers).frame
|
||||||
|
|
||||||
|
|
||||||
|
def from_package(package: types.ModuleType):
|
||||||
|
"""
|
||||||
|
Return a Traversable object for the given package.
|
||||||
|
|
||||||
|
"""
|
||||||
|
spec = wrap_spec(package)
|
||||||
|
reader = spec.loader.get_resource_reader(spec.name)
|
||||||
|
return reader.files()
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _tempfile(
|
||||||
|
reader,
|
||||||
|
suffix='',
|
||||||
|
# gh-93353: Keep a reference to call os.remove() in late Python
|
||||||
|
# finalization.
|
||||||
|
*,
|
||||||
|
_os_remove=os.remove,
|
||||||
|
):
|
||||||
|
# Not using tempfile.NamedTemporaryFile as it leads to deeper 'try'
|
||||||
|
# blocks due to the need to close the temporary file to work on Windows
|
||||||
|
# properly.
|
||||||
|
fd, raw_path = tempfile.mkstemp(suffix=suffix)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
os.write(fd, reader())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
del reader
|
||||||
|
yield pathlib.Path(raw_path)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_os_remove(raw_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_file(path):
|
||||||
|
return _tempfile(path.read_bytes, suffix=path.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_present_dir(path: Traversable) -> bool:
|
||||||
|
"""
|
||||||
|
Some Traversables implement ``is_dir()`` to raise an
|
||||||
|
exception (i.e. ``FileNotFoundError``) when the
|
||||||
|
directory doesn't exist. This function wraps that call
|
||||||
|
to always return a boolean and only return True
|
||||||
|
if there's a dir and it exists.
|
||||||
|
"""
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
return path.is_dir()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@functools.singledispatch
|
||||||
|
def as_file(path):
|
||||||
|
"""
|
||||||
|
Given a Traversable object, return that object as a
|
||||||
|
path on the local file system in a context manager.
|
||||||
|
"""
|
||||||
|
return _temp_dir(path) if _is_present_dir(path) else _temp_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
@as_file.register(pathlib.Path)
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _(path):
|
||||||
|
"""
|
||||||
|
Degenerate behavior for pathlib.Path objects.
|
||||||
|
"""
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _temp_path(dir: tempfile.TemporaryDirectory):
|
||||||
|
"""
|
||||||
|
Wrap tempfile.TemporyDirectory to return a pathlib object.
|
||||||
|
"""
|
||||||
|
with dir as result:
|
||||||
|
yield pathlib.Path(result)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _temp_dir(path):
|
||||||
|
"""
|
||||||
|
Given a traversable dir, recursively replicate the whole tree
|
||||||
|
to the file system in a context manager.
|
||||||
|
"""
|
||||||
|
assert path.is_dir()
|
||||||
|
with _temp_path(tempfile.TemporaryDirectory()) as temp_dir:
|
||||||
|
yield _write_contents(temp_dir, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_contents(target, source):
|
||||||
|
child = target.joinpath(source.name)
|
||||||
|
if source.is_dir():
|
||||||
|
child.mkdir()
|
||||||
|
for item in source.iterdir():
|
||||||
|
_write_contents(child, item)
|
||||||
|
else:
|
||||||
|
child.write_bytes(source.read_bytes())
|
||||||
|
return child
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# flake8: noqa
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
from zipfile import Path as ZipPath # type: ignore
|
||||||
|
else:
|
||||||
|
from ..zipp import Path as ZipPath # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from typing import runtime_checkable # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
|
||||||
|
def runtime_checkable(cls): # type: ignore
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from typing import Protocol # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
Protocol = abc.ABC # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class TraversableResourcesLoader:
|
||||||
|
"""
|
||||||
|
Adapt loaders to provide TraversableResources and other
|
||||||
|
compatibility.
|
||||||
|
|
||||||
|
Used primarily for Python 3.9 and earlier where the native
|
||||||
|
loaders do not yet implement TraversableResources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, spec):
|
||||||
|
self.spec = spec
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
return self.spec.origin
|
||||||
|
|
||||||
|
def get_resource_reader(self, name):
|
||||||
|
from . import readers, _adapters
|
||||||
|
|
||||||
|
def _zip_reader(spec):
|
||||||
|
with suppress(AttributeError):
|
||||||
|
return readers.ZipReader(spec.loader, spec.name)
|
||||||
|
|
||||||
|
def _namespace_reader(spec):
|
||||||
|
with suppress(AttributeError, ValueError):
|
||||||
|
return readers.NamespaceReader(spec.submodule_search_locations)
|
||||||
|
|
||||||
|
def _available_reader(spec):
|
||||||
|
with suppress(AttributeError):
|
||||||
|
return spec.loader.get_resource_reader(spec.name)
|
||||||
|
|
||||||
|
def _native_reader(spec):
|
||||||
|
reader = _available_reader(spec)
|
||||||
|
return reader if hasattr(reader, 'files') else None
|
||||||
|
|
||||||
|
def _file_reader(spec):
|
||||||
|
try:
|
||||||
|
path = pathlib.Path(self.path)
|
||||||
|
except TypeError:
|
||||||
|
return None
|
||||||
|
if path.exists():
|
||||||
|
return readers.FileReader(self)
|
||||||
|
|
||||||
|
return (
|
||||||
|
# native reader if it supplies 'files'
|
||||||
|
_native_reader(self.spec)
|
||||||
|
or
|
||||||
|
# local ZipReader if a zip module
|
||||||
|
_zip_reader(self.spec)
|
||||||
|
or
|
||||||
|
# local NamespaceReader if a namespace module
|
||||||
|
_namespace_reader(self.spec)
|
||||||
|
or
|
||||||
|
# local FileReader
|
||||||
|
_file_reader(self.spec)
|
||||||
|
# fallback - adapt the spec ResourceReader to TraversableReader
|
||||||
|
or _adapters.CompatibilityFiles(self.spec)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_spec(package):
|
||||||
|
"""
|
||||||
|
Construct a package spec with traversable compatibility
|
||||||
|
on the spec/loader/reader.
|
||||||
|
|
||||||
|
Supersedes _adapters.wrap_spec to use TraversableResourcesLoader
|
||||||
|
from above for older Python compatibility (<3.10).
|
||||||
|
"""
|
||||||
|
from . import _adapters
|
||||||
|
|
||||||
|
return _adapters.SpecLoaderAdapter(package.__spec__, TraversableResourcesLoader)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 9):
|
||||||
|
StrPath = Union[str, os.PathLike[str]]
|
||||||
|
else:
|
||||||
|
# PathLike is only subscriptable at runtime in 3.9+
|
||||||
|
StrPath = Union[str, "os.PathLike[str]"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from itertools import filterfalse
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
Callable,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type and type variable definitions
|
||||||
|
_T = TypeVar('_T')
|
||||||
|
_U = TypeVar('_U')
|
||||||
|
|
||||||
|
|
||||||
|
def unique_everseen(
|
||||||
|
iterable: Iterable[_T], key: Optional[Callable[[_T], _U]] = None
|
||||||
|
) -> Iterator[_T]:
|
||||||
|
"List unique elements, preserving order. Remember all elements ever seen."
|
||||||
|
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
|
||||||
|
# unique_everseen('ABBCcAD', str.lower) --> A B C D
|
||||||
|
seen: Set[Union[_T, _U]] = set()
|
||||||
|
seen_add = seen.add
|
||||||
|
if key is None:
|
||||||
|
for element in filterfalse(seen.__contains__, iterable):
|
||||||
|
seen_add(element)
|
||||||
|
yield element
|
||||||
|
else:
|
||||||
|
for element in iterable:
|
||||||
|
k = key(element)
|
||||||
|
if k not in seen:
|
||||||
|
seen_add(k)
|
||||||
|
yield element
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import functools
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import types
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from typing import Union, Iterable, ContextManager, BinaryIO, TextIO, Any
|
||||||
|
|
||||||
|
from . import _common
|
||||||
|
|
||||||
|
Package = Union[types.ModuleType, str]
|
||||||
|
Resource = str
|
||||||
|
|
||||||
|
|
||||||
|
def deprecated(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
warnings.warn(
|
||||||
|
f"{func.__name__} is deprecated. Use files() instead. "
|
||||||
|
"Refer to https://importlib-resources.readthedocs.io"
|
||||||
|
"/en/latest/using.html#migrating-from-legacy for migration advice.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_path(path: Any) -> str:
|
||||||
|
"""Normalize a path by ensuring it is a string.
|
||||||
|
|
||||||
|
If the resulting string contains path separators, an exception is raised.
|
||||||
|
"""
|
||||||
|
str_path = str(path)
|
||||||
|
parent, file_name = os.path.split(str_path)
|
||||||
|
if parent:
|
||||||
|
raise ValueError(f'{path!r} must be only a file name')
|
||||||
|
return file_name
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def open_binary(package: Package, resource: Resource) -> BinaryIO:
|
||||||
|
"""Return a file-like object opened for binary reading of the resource."""
|
||||||
|
return (_common.files(package) / normalize_path(resource)).open('rb')
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def read_binary(package: Package, resource: Resource) -> bytes:
|
||||||
|
"""Return the binary contents of the resource."""
|
||||||
|
return (_common.files(package) / normalize_path(resource)).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def open_text(
|
||||||
|
package: Package,
|
||||||
|
resource: Resource,
|
||||||
|
encoding: str = 'utf-8',
|
||||||
|
errors: str = 'strict',
|
||||||
|
) -> TextIO:
|
||||||
|
"""Return a file-like object opened for text reading of the resource."""
|
||||||
|
return (_common.files(package) / normalize_path(resource)).open(
|
||||||
|
'r', encoding=encoding, errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def read_text(
|
||||||
|
package: Package,
|
||||||
|
resource: Resource,
|
||||||
|
encoding: str = 'utf-8',
|
||||||
|
errors: str = 'strict',
|
||||||
|
) -> str:
|
||||||
|
"""Return the decoded string of the resource.
|
||||||
|
|
||||||
|
The decoding-related arguments have the same semantics as those of
|
||||||
|
bytes.decode().
|
||||||
|
"""
|
||||||
|
with open_text(package, resource, encoding, errors) as fp:
|
||||||
|
return fp.read()
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def contents(package: Package) -> Iterable[str]:
|
||||||
|
"""Return an iterable of entries in `package`.
|
||||||
|
|
||||||
|
Note that not all entries are resources. Specifically, directories are
|
||||||
|
not considered resources. Use `is_resource()` on each entry returned here
|
||||||
|
to check if it is a resource or not.
|
||||||
|
"""
|
||||||
|
return [path.name for path in _common.files(package).iterdir()]
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def is_resource(package: Package, name: str) -> bool:
|
||||||
|
"""True if `name` is a resource inside `package`.
|
||||||
|
|
||||||
|
Directories are *not* resources.
|
||||||
|
"""
|
||||||
|
resource = normalize_path(name)
|
||||||
|
return any(
|
||||||
|
traversable.name == resource and traversable.is_file()
|
||||||
|
for traversable in _common.files(package).iterdir()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@deprecated
|
||||||
|
def path(
|
||||||
|
package: Package,
|
||||||
|
resource: Resource,
|
||||||
|
) -> ContextManager[pathlib.Path]:
|
||||||
|
"""A context manager providing a file path object to the resource.
|
||||||
|
|
||||||
|
If the resource does not already exist on its own on the file system,
|
||||||
|
a temporary file will be created. If the file was created, the file
|
||||||
|
will be deleted upon exiting the context manager (no exception is
|
||||||
|
raised if the file was deleted prior to the context manager
|
||||||
|
exiting).
|
||||||
|
"""
|
||||||
|
return _common.as_file(_common.files(package) / normalize_path(resource))
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import abc
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import pathlib
|
||||||
|
from typing import Any, BinaryIO, Iterable, Iterator, NoReturn, Text, Optional
|
||||||
|
|
||||||
|
from ._compat import runtime_checkable, Protocol, StrPath
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ResourceReader", "Traversable", "TraversableResources"]
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceReader(metaclass=abc.ABCMeta):
|
||||||
|
"""Abstract base class for loaders to provide resource reading support."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def open_resource(self, resource: Text) -> BinaryIO:
|
||||||
|
"""Return an opened, file-like object for binary reading.
|
||||||
|
|
||||||
|
The 'resource' argument is expected to represent only a file name.
|
||||||
|
If the resource cannot be found, FileNotFoundError is raised.
|
||||||
|
"""
|
||||||
|
# This deliberately raises FileNotFoundError instead of
|
||||||
|
# NotImplementedError so that if this method is accidentally called,
|
||||||
|
# it'll still do the right thing.
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def resource_path(self, resource: Text) -> Text:
|
||||||
|
"""Return the file system path to the specified resource.
|
||||||
|
|
||||||
|
The 'resource' argument is expected to represent only a file name.
|
||||||
|
If the resource does not exist on the file system, raise
|
||||||
|
FileNotFoundError.
|
||||||
|
"""
|
||||||
|
# This deliberately raises FileNotFoundError instead of
|
||||||
|
# NotImplementedError so that if this method is accidentally called,
|
||||||
|
# it'll still do the right thing.
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_resource(self, path: Text) -> bool:
|
||||||
|
"""Return True if the named 'path' is a resource.
|
||||||
|
|
||||||
|
Files are resources, directories are not.
|
||||||
|
"""
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def contents(self) -> Iterable[str]:
|
||||||
|
"""Return an iterable of entries in `package`."""
|
||||||
|
raise FileNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
class TraversalError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Traversable(Protocol):
|
||||||
|
"""
|
||||||
|
An object with a subset of pathlib.Path methods suitable for
|
||||||
|
traversing directories and opening files.
|
||||||
|
|
||||||
|
Any exceptions that occur when accessing the backing resource
|
||||||
|
may propagate unaltered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def iterdir(self) -> Iterator["Traversable"]:
|
||||||
|
"""
|
||||||
|
Yield Traversable objects in self
|
||||||
|
"""
|
||||||
|
|
||||||
|
def read_bytes(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Read contents of self as bytes
|
||||||
|
"""
|
||||||
|
with self.open('rb') as strm:
|
||||||
|
return strm.read()
|
||||||
|
|
||||||
|
def read_text(self, encoding: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
Read contents of self as text
|
||||||
|
"""
|
||||||
|
with self.open(encoding=encoding) as strm:
|
||||||
|
return strm.read()
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_dir(self) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if self is a directory
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def is_file(self) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if self is a file
|
||||||
|
"""
|
||||||
|
|
||||||
|
def joinpath(self, *descendants: StrPath) -> "Traversable":
|
||||||
|
"""
|
||||||
|
Return Traversable resolved with any descendants applied.
|
||||||
|
|
||||||
|
Each descendant should be a path segment relative to self
|
||||||
|
and each may contain multiple levels separated by
|
||||||
|
``posixpath.sep`` (``/``).
|
||||||
|
"""
|
||||||
|
if not descendants:
|
||||||
|
return self
|
||||||
|
names = itertools.chain.from_iterable(
|
||||||
|
path.parts for path in map(pathlib.PurePosixPath, descendants)
|
||||||
|
)
|
||||||
|
target = next(names)
|
||||||
|
matches = (
|
||||||
|
traversable for traversable in self.iterdir() if traversable.name == target
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
match = next(matches)
|
||||||
|
except StopIteration:
|
||||||
|
raise TraversalError(
|
||||||
|
"Target not found during traversal.", target, list(names)
|
||||||
|
)
|
||||||
|
return match.joinpath(*names)
|
||||||
|
|
||||||
|
def __truediv__(self, child: StrPath) -> "Traversable":
|
||||||
|
"""
|
||||||
|
Return Traversable child in self
|
||||||
|
"""
|
||||||
|
return self.joinpath(child)
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def open(self, mode='r', *args, **kwargs):
|
||||||
|
"""
|
||||||
|
mode may be 'r' or 'rb' to open as text or binary. Return a handle
|
||||||
|
suitable for reading (same as pathlib.Path.open).
|
||||||
|
|
||||||
|
When opening as text, accepts encoding parameters such as those
|
||||||
|
accepted by io.TextIOWrapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""
|
||||||
|
The base name of this object without any parent references.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TraversableResources(ResourceReader):
|
||||||
|
"""
|
||||||
|
The required interface for providing traversable
|
||||||
|
resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def files(self) -> "Traversable":
|
||||||
|
"""Return a Traversable object for the loaded package."""
|
||||||
|
|
||||||
|
def open_resource(self, resource: StrPath) -> io.BufferedReader:
|
||||||
|
return self.files().joinpath(resource).open('rb')
|
||||||
|
|
||||||
|
def resource_path(self, resource: Any) -> NoReturn:
|
||||||
|
raise FileNotFoundError(resource)
|
||||||
|
|
||||||
|
def is_resource(self, path: StrPath) -> bool:
|
||||||
|
return self.files().joinpath(path).is_file()
|
||||||
|
|
||||||
|
def contents(self) -> Iterator[str]:
|
||||||
|
return (item.name for item in self.files().iterdir())
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import collections
|
||||||
|
import pathlib
|
||||||
|
import operator
|
||||||
|
|
||||||
|
from . import abc
|
||||||
|
|
||||||
|
from ._itertools import unique_everseen
|
||||||
|
from ._compat import ZipPath
|
||||||
|
|
||||||
|
|
||||||
|
def remove_duplicates(items):
|
||||||
|
return iter(collections.OrderedDict.fromkeys(items))
|
||||||
|
|
||||||
|
|
||||||
|
class FileReader(abc.TraversableResources):
|
||||||
|
def __init__(self, loader):
|
||||||
|
self.path = pathlib.Path(loader.path).parent
|
||||||
|
|
||||||
|
def resource_path(self, resource):
|
||||||
|
"""
|
||||||
|
Return the file system path to prevent
|
||||||
|
`resources.path()` from creating a temporary
|
||||||
|
copy.
|
||||||
|
"""
|
||||||
|
return str(self.path.joinpath(resource))
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
return self.path
|
||||||
|
|
||||||
|
|
||||||
|
class ZipReader(abc.TraversableResources):
|
||||||
|
def __init__(self, loader, module):
|
||||||
|
_, _, name = module.rpartition('.')
|
||||||
|
self.prefix = loader.prefix.replace('\\', '/') + name + '/'
|
||||||
|
self.archive = loader.archive
|
||||||
|
|
||||||
|
def open_resource(self, resource):
|
||||||
|
try:
|
||||||
|
return super().open_resource(resource)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise FileNotFoundError(exc.args[0])
|
||||||
|
|
||||||
|
def is_resource(self, path):
|
||||||
|
# workaround for `zipfile.Path.is_file` returning true
|
||||||
|
# for non-existent paths.
|
||||||
|
target = self.files().joinpath(path)
|
||||||
|
return target.is_file() and target.exists()
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
return ZipPath(self.archive, self.prefix)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiplexedPath(abc.Traversable):
|
||||||
|
"""
|
||||||
|
Given a series of Traversable objects, implement a merged
|
||||||
|
version of the interface across all objects. Useful for
|
||||||
|
namespace packages which may be multihomed at a single
|
||||||
|
name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *paths):
|
||||||
|
self._paths = list(map(pathlib.Path, remove_duplicates(paths)))
|
||||||
|
if not self._paths:
|
||||||
|
message = 'MultiplexedPath must contain at least one path'
|
||||||
|
raise FileNotFoundError(message)
|
||||||
|
if not all(path.is_dir() for path in self._paths):
|
||||||
|
raise NotADirectoryError('MultiplexedPath only supports directories')
|
||||||
|
|
||||||
|
def iterdir(self):
|
||||||
|
files = (file for path in self._paths for file in path.iterdir())
|
||||||
|
return unique_everseen(files, key=operator.attrgetter('name'))
|
||||||
|
|
||||||
|
def read_bytes(self):
|
||||||
|
raise FileNotFoundError(f'{self} is not a file')
|
||||||
|
|
||||||
|
def read_text(self, *args, **kwargs):
|
||||||
|
raise FileNotFoundError(f'{self} is not a file')
|
||||||
|
|
||||||
|
def is_dir(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def joinpath(self, *descendants):
|
||||||
|
try:
|
||||||
|
return super().joinpath(*descendants)
|
||||||
|
except abc.TraversalError:
|
||||||
|
# One of the paths did not resolve (a directory does not exist).
|
||||||
|
# Just return something that will not exist.
|
||||||
|
return self._paths[0].joinpath(*descendants)
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs):
|
||||||
|
raise FileNotFoundError(f'{self} is not a file')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._paths[0].name
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
paths = ', '.join(f"'{path}'" for path in self._paths)
|
||||||
|
return f'MultiplexedPath({paths})'
|
||||||
|
|
||||||
|
|
||||||
|
class NamespaceReader(abc.TraversableResources):
|
||||||
|
def __init__(self, namespace_path):
|
||||||
|
if 'NamespacePath' not in str(namespace_path):
|
||||||
|
raise ValueError('Invalid path')
|
||||||
|
self.path = MultiplexedPath(*list(namespace_path))
|
||||||
|
|
||||||
|
def resource_path(self, resource):
|
||||||
|
"""
|
||||||
|
Return the file system path to prevent
|
||||||
|
`resources.path()` from creating a temporary
|
||||||
|
copy.
|
||||||
|
"""
|
||||||
|
return str(self.path.joinpath(resource))
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
return self.path
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Interface adapters for low-level readers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
from typing import BinaryIO, List
|
||||||
|
|
||||||
|
from .abc import Traversable, TraversableResources
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleReader(abc.ABC):
|
||||||
|
"""
|
||||||
|
The minimum, low-level interface required from a resource
|
||||||
|
provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abc.abstractmethod
|
||||||
|
def package(self) -> str:
|
||||||
|
"""
|
||||||
|
The name of the package for which this reader loads resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def children(self) -> List['SimpleReader']:
|
||||||
|
"""
|
||||||
|
Obtain an iterable of SimpleReader for available
|
||||||
|
child containers (e.g. directories).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def resources(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Obtain available named resources for this virtual package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def open_binary(self, resource: str) -> BinaryIO:
|
||||||
|
"""
|
||||||
|
Obtain a File-like for a named resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.package.split('.')[-1]
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceContainer(Traversable):
|
||||||
|
"""
|
||||||
|
Traversable container for a package's resources via its reader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, reader: SimpleReader):
|
||||||
|
self.reader = reader
|
||||||
|
|
||||||
|
def is_dir(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def iterdir(self):
|
||||||
|
files = (ResourceHandle(self, name) for name in self.reader.resources)
|
||||||
|
dirs = map(ResourceContainer, self.reader.children())
|
||||||
|
return itertools.chain(files, dirs)
|
||||||
|
|
||||||
|
def open(self, *args, **kwargs):
|
||||||
|
raise IsADirectoryError()
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceHandle(Traversable):
|
||||||
|
"""
|
||||||
|
Handle to a named resource in a ResourceReader.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent: ResourceContainer, name: str):
|
||||||
|
self.parent = parent
|
||||||
|
self.name = name # type: ignore
|
||||||
|
|
||||||
|
def is_file(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_dir(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def open(self, mode='r', *args, **kwargs):
|
||||||
|
stream = self.parent.reader.open_binary(self.name)
|
||||||
|
if 'b' not in mode:
|
||||||
|
stream = io.TextIOWrapper(*args, **kwargs)
|
||||||
|
return stream
|
||||||
|
|
||||||
|
def joinpath(self, name):
|
||||||
|
raise RuntimeError("Cannot traverse into a resource")
|
||||||
|
|
||||||
|
|
||||||
|
class TraversableReader(TraversableResources, SimpleReader):
|
||||||
|
"""
|
||||||
|
A TraversableResources based on SimpleReader. Resource providers
|
||||||
|
may derive from this class to provide the TraversableResources
|
||||||
|
interface by supplying the SimpleReader interface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def files(self):
|
||||||
|
return ResourceContainer(self)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from test.support import import_helper # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
# Python 3.9 and earlier
|
||||||
|
class import_helper: # type: ignore
|
||||||
|
from test.support import (
|
||||||
|
modules_setup,
|
||||||
|
modules_cleanup,
|
||||||
|
DirsOnSysPath,
|
||||||
|
CleanImport,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from test.support import os_helper # type: ignore
|
||||||
|
except ImportError:
|
||||||
|
# Python 3.9 compat
|
||||||
|
class os_helper: # type:ignore
|
||||||
|
from test.support import temp_dir
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Python 3.10
|
||||||
|
from test.support.os_helper import unlink
|
||||||
|
except ImportError:
|
||||||
|
from test.support import unlink as _unlink
|
||||||
|
|
||||||
|
def unlink(target):
|
||||||
|
return _unlink(os.fspath(target))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user