mirror of
https://github.com/rembo10/headphones.git
synced 2026-06-10 12:47:44 +01:00
Merge remote-tracking branch 'upstream/develop' into feature/refactor_config
Conflicts: headphones/__init__.py headphones/searcher.py headphones/webserve.py
This commit is contained in:
@@ -317,10 +317,27 @@
|
||||
<input type="text" name="torrentblackhole_dir" value="${config['torrentblackhole_dir']}" size="50">
|
||||
<small>Folder your Download program watches for Torrents</small>
|
||||
</div>
|
||||
<div class="row checkbox">
|
||||
<label>Open Magnet Links</label>
|
||||
<input type="checkbox" name="open_magnet_links" value="1" ${config['open_magnet_links']}>
|
||||
<small>Allow Headphones to open magnet links</small>
|
||||
|
||||
<div class="row">
|
||||
<label>Magnet links</label>
|
||||
|
||||
<label class="inline" title="Invoke shell command to open magnet URL.">
|
||||
<input type="radio" name="magnet_links" id="magnet_links_0" value="0" ${config['magnet_links_0']}>
|
||||
Ignore
|
||||
</label>
|
||||
|
||||
<label class="inline" title="Use external service to convert magnet links into torrents.">
|
||||
<input type="radio" name="magnet_links" id="magnet_links_1" value="1" ${config['magnet_links_1']}>
|
||||
Open
|
||||
</label>
|
||||
|
||||
<label class="inline">
|
||||
<input type="radio" name="magnet_links" id="magnet_links_2" value="2" ${config['magnet_links_2']}>
|
||||
Convert
|
||||
</label>
|
||||
<div style="clear: both"></div>
|
||||
|
||||
<small>Note: opening magnet URL's is not suitable for headless/console/terminal servers.</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset id="transmission_options">
|
||||
@@ -382,7 +399,7 @@
|
||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs
|
||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents
|
||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>No Preference
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -336,6 +336,10 @@ form .row label {
|
||||
padding-top: 7px;
|
||||
width: 175px;
|
||||
}
|
||||
form .row label.inline {
|
||||
margin-right: 5px;
|
||||
width: auto;
|
||||
}
|
||||
form .row input {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@@ -191,6 +191,11 @@ form {
|
||||
line-height: normal;
|
||||
padding-top: 7px;
|
||||
width: 175px;
|
||||
|
||||
&.inline {
|
||||
margin-right: 5px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
input { margin-right: 5px; }
|
||||
input[type=text], input[type=password] {
|
||||
|
||||
@@ -81,8 +81,11 @@
|
||||
"sInfoFiltered":"(filtered from _MAX_ total items)"},
|
||||
"iDisplayLength": 25,
|
||||
"sPaginationType": "full_numbers",
|
||||
"aaSorting": []
|
||||
|
||||
"aaSorting": [],
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
resetFilters("history");
|
||||
}
|
||||
|
||||
@@ -131,6 +131,10 @@
|
||||
},
|
||||
"fnInitComplete": function(oSettings, json)
|
||||
{
|
||||
},
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
$('#artist_table').on("draw.dt", function () {
|
||||
|
||||
@@ -46,51 +46,50 @@
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
initActions();
|
||||
$(document).ready(function() {
|
||||
initActions();
|
||||
|
||||
$('#log_table').dataTable( {
|
||||
"bProcessing": true,
|
||||
"bServerSide": true,
|
||||
"sAjaxSource": 'getLog',
|
||||
"sPaginationType": "full_numbers",
|
||||
"aaSorting": [[0, 'desc']],
|
||||
"iDisplayLength": 25,
|
||||
"bStateSave": true,
|
||||
"oLanguage": {
|
||||
"sSearch":"Filter:",
|
||||
"sLengthMenu":"Show _MENU_ lines per page",
|
||||
"sEmptyTable": "No log information available",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ lines",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 lines",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total lines)"},
|
||||
"fnRowCallback": function (nRow, aData, iDisplayIndex, iDisplayIndexFull) {
|
||||
if (aData[1] === "ERROR")
|
||||
{
|
||||
$('td', nRow).closest('tr').addClass("gradeX");
|
||||
}
|
||||
else if (aData[1] === "WARNING")
|
||||
{
|
||||
$('td', nRow).closest('tr').addClass("gradeW");
|
||||
}
|
||||
else
|
||||
{
|
||||
$('td', nRow).closest('tr').addClass("gradeZ");
|
||||
}
|
||||
$('#log_table').dataTable( {
|
||||
"bProcessing": true,
|
||||
"bServerSide": true,
|
||||
"sAjaxSource": 'getLog',
|
||||
"sPaginationType": "full_numbers",
|
||||
"aaSorting": [[0, 'desc']],
|
||||
"iDisplayLength": 25,
|
||||
"bStateSave": true,
|
||||
"oLanguage": {
|
||||
"sSearch":"Filter:",
|
||||
"sLengthMenu":"Show _MENU_ lines per page",
|
||||
"sEmptyTable": "No log information available",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ lines",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 lines",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total lines)"},
|
||||
"fnRowCallback": function (nRow, aData, iDisplayIndex, iDisplayIndexFull) {
|
||||
if (aData[1] === "ERROR") {
|
||||
$('td', nRow).closest('tr').addClass("gradeX");
|
||||
} else if (aData[1] === "WARNING") {
|
||||
$('td', nRow).closest('tr').addClass("gradeW");
|
||||
} else {
|
||||
$('td', nRow).closest('tr').addClass("gradeZ");
|
||||
}
|
||||
|
||||
return nRow;
|
||||
},
|
||||
"fnServerData": function ( sSource, aoData, fnCallback ) {
|
||||
/* Add some extra data to the sender */
|
||||
$.getJSON( sSource, aoData, function (json) {
|
||||
fnCallback(json)
|
||||
} );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
return nRow;
|
||||
},
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
},
|
||||
"fnServerData": function ( sSource, aoData, fnCallback ) {
|
||||
/* Add some extra data to the sender */
|
||||
$.getJSON(sSource, aoData, function (json) {
|
||||
fnCallback(json)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
var timer;
|
||||
var timer;
|
||||
function setRefresh()
|
||||
{
|
||||
refreshrate = document.getElementById('refreshrate');
|
||||
|
||||
@@ -112,40 +112,43 @@
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
function initThisPage() {
|
||||
$('#album_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumns": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{ "sType": "title-numeric"},
|
||||
null,
|
||||
null
|
||||
],
|
||||
"aoColumnDefs": [
|
||||
{ 'bSortable': false, 'aTargets': [ 0 ] }
|
||||
],
|
||||
"oLanguage": {
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
"sEmptyTable": "No album information available",
|
||||
"sInfo":"Showing _TOTAL_ albums",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sSearch": ""},
|
||||
"bPaginate": false,
|
||||
"aaSorting": [[5, 'desc']],
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
|
||||
function initThisPage() {
|
||||
$('#album_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumns": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{ "sType": "title-numeric"},
|
||||
null,
|
||||
null
|
||||
],
|
||||
"aoColumnDefs": [
|
||||
{ 'bSortable': false, 'aTargets': [ 0 ] }
|
||||
],
|
||||
"oLanguage": {
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
"sEmptyTable": "No album information available",
|
||||
"sInfo":"Showing _TOTAL_ albums",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sSearch": ""},
|
||||
"bPaginate": false,
|
||||
"aaSorting": [[5, 'desc']]
|
||||
|
||||
});
|
||||
resetFilters("albums");
|
||||
}
|
||||
$(document).ready(function() {
|
||||
initThisPage();
|
||||
});
|
||||
resetFilters("albums");
|
||||
}
|
||||
$(document).ready(function() {
|
||||
initThisPage();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
|
||||
@@ -86,31 +86,31 @@
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
function initThisPage() {
|
||||
$('#artist_table').dataTable({
|
||||
"bDestroy":true,
|
||||
"aoColumns": [
|
||||
null,
|
||||
null,
|
||||
{ "sType": "title-string"},
|
||||
null,
|
||||
{ "sType": "title-string"},
|
||||
null
|
||||
],
|
||||
"oLanguage": {
|
||||
"sSearch" : "",
|
||||
"sEmptyTable": " "
|
||||
},
|
||||
"bStateSave": true,
|
||||
"bPaginate": false
|
||||
function initThisPage() {
|
||||
$('#artist_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumns": [
|
||||
null,
|
||||
null,
|
||||
{ "sType": "title-string"},
|
||||
null,
|
||||
{ "sType": "title-string"},
|
||||
null
|
||||
],
|
||||
"oLanguage": {
|
||||
"sSearch" : "",
|
||||
"sEmptyTable": " "
|
||||
},
|
||||
"bStateSave": true,
|
||||
"bPaginate": false
|
||||
});
|
||||
resetFilters("artists");
|
||||
}
|
||||
$(document).ready(function() {
|
||||
initThisPage();
|
||||
});
|
||||
$(window).load(function(){
|
||||
initFancybox();
|
||||
});
|
||||
resetFilters("artists");
|
||||
}
|
||||
$(document).ready(function() {
|
||||
initThisPage();
|
||||
});
|
||||
$(window).load(function(){
|
||||
initFancybox();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -85,24 +85,26 @@
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function()
|
||||
{
|
||||
$('#artist_table').dataTable(
|
||||
{
|
||||
"bStateSave": true,
|
||||
"bPaginate": true,
|
||||
"oLanguage": {
|
||||
"sSearch": "",
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ albums",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
"sPaginationType": "full_numbers",
|
||||
});
|
||||
$(document).ready(function() {
|
||||
$('#artist_table').dataTable({
|
||||
"bStateSave": true,
|
||||
"bPaginate": true,
|
||||
"oLanguage": {
|
||||
"sSearch": "",
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ albums",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
"sPaginationType": "full_numbers",
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
|
||||
initActions();
|
||||
initActions();
|
||||
});
|
||||
|
||||
function restore_Artist(clicked_id) {
|
||||
|
||||
@@ -53,18 +53,16 @@
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function()
|
||||
{
|
||||
$('#artist_table').dataTable(
|
||||
{
|
||||
"aaSorting": [[1, 'asc']],
|
||||
"bStateSave": false,
|
||||
"bPaginate": false,
|
||||
"oLanguage": {
|
||||
"sSearch" : ""},
|
||||
|
||||
});
|
||||
initActions();
|
||||
$(document).ready(function() {
|
||||
$('#artist_table').dataTable({
|
||||
"aaSorting": [[1, 'asc']],
|
||||
"bStateSave": false,
|
||||
"bPaginate": false,
|
||||
"oLanguage": {
|
||||
"sSearch" : ""},
|
||||
});
|
||||
|
||||
initActions();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -118,22 +118,24 @@
|
||||
<%def name="javascriptIncludes()">
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function()
|
||||
{
|
||||
$('#artist_table').dataTable(
|
||||
{
|
||||
"bStateSave": true,
|
||||
"bPaginate": true,
|
||||
"oLanguage": {
|
||||
"sSearch": "",
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ albums",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
"sPaginationType": "full_numbers",
|
||||
});
|
||||
$(document).ready(function() {
|
||||
$('#artist_table').dataTable({
|
||||
"bStateSave": true,
|
||||
"bPaginate": true,
|
||||
"oLanguage": {
|
||||
"sSearch": "",
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ albums",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
"sPaginationType": "full_numbers",
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
|
||||
initActions();
|
||||
});
|
||||
|
||||
@@ -107,7 +107,11 @@
|
||||
"sSearch" : ""},
|
||||
"iDisplayLength": 25,
|
||||
"sPaginationType": "full_numbers",
|
||||
"aaSorting": []
|
||||
"aaSorting": [],
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
$('#searchresults_table').on("draw.dt", function () {
|
||||
getArt();
|
||||
@@ -121,4 +125,18 @@
|
||||
initThisPage();
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
<%!
|
||||
# Abuse JSON module for escaping JavaScript
|
||||
import json
|
||||
%>
|
||||
$(document).ready(function() {
|
||||
// Search parameter
|
||||
$("#searchbar input[name=name]").val(${name | json.dumps});
|
||||
|
||||
// Album or artist
|
||||
$("#searchbar select[name=type]").val(${type | json.dumps});
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"oLanguage": {
|
||||
"sEmptyTable": " "
|
||||
},
|
||||
"bDestroy":true,
|
||||
"bDestroy": true,
|
||||
"bFilter": false,
|
||||
"bInfo": false,
|
||||
"bPaginate": false
|
||||
|
||||
+11
-8
@@ -24,7 +24,10 @@ import sqlite3
|
||||
import itertools
|
||||
import cherrypy
|
||||
|
||||
from apscheduler.scheduler import Scheduler
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from configobj import ConfigObj
|
||||
|
||||
from headphones import versioncheck, logger, version
|
||||
import headphones.config
|
||||
@@ -62,7 +65,7 @@ DAEMON = False
|
||||
CREATEPID = False
|
||||
PIDFILE= None
|
||||
|
||||
SCHED = Scheduler()
|
||||
SCHED = BackgroundScheduler()
|
||||
|
||||
INIT_LOCK = threading.Lock()
|
||||
__INITIALIZED__ = False
|
||||
@@ -244,19 +247,19 @@ def start():
|
||||
|
||||
# Start our scheduled background tasks
|
||||
from headphones import updater, searcher, librarysync, postprocessor, torrentfinished
|
||||
SCHED.add_interval_job(updater.dbUpdate, hours=CFG.UPDATE_DB_INTERVAL)
|
||||
SCHED.add_interval_job(searcher.searchforalbum, minutes=CFG.SEARCH_INTERVAL)
|
||||
SCHED.add_interval_job(librarysync.libraryScan, hours=CFG.LIBRARYSCAN_INTERVAL, kwargs={'cron':True})
|
||||
SCHED.add_job(updater.dbUpdate, trigger=IntervalTrigger(hours=CFG.UPDATE_DB_INTERVAL))
|
||||
SCHED.add_job(searcher.searchforalbum, trigger=IntervalTrigger(minutes=CFG.SEARCH_INTERVAL))
|
||||
SCHED.add_job(librarysync.libraryScan, trigger=IntervalTrigger(hours=CFG.LIBRARYSCAN_INTERVAL))
|
||||
|
||||
if CFG.CHECK_GITHUB:
|
||||
SCHED.add_interval_job(versioncheck.checkGithub, minutes=CFG.CHECK_GITHUB_INTERVAL)
|
||||
SCHED.add_job(versioncheck.checkGithub, trigger=IntervalTrigger(minutes=CFG.CHECK_GITHUB_INTERVAL))
|
||||
|
||||
if CFG.DOWNLOAD_SCAN_INTERVAL > 0:
|
||||
SCHED.add_interval_job(postprocessor.checkFolder, minutes=CFG.DOWNLOAD_SCAN_INTERVAL)
|
||||
SCHED.add_job(postprocessor.checkFolder, trigger=IntervalTrigger(minutes=CFG.DOWNLOAD_SCAN_INTERVAL))
|
||||
|
||||
# Remove Torrent + data if Post Processed and finished Seeding
|
||||
if CFG.TORRENT_REMOVAL_INTERVAL > 0:
|
||||
SCHED.add_interval_job(torrentfinished.checkTorrentFinished, minutes=CFG.TORRENT_REMOVAL_INTERVAL)
|
||||
SCHED.add_job(torrentfinished.checkTorrentFinished, trigger=IntervalTrigger(minutes=CFG.TORRENT_REMOVAL_INTERVAL))
|
||||
|
||||
SCHED.start()
|
||||
|
||||
|
||||
@@ -125,7 +125,8 @@ _config_definitions = {
|
||||
'OMGWTFNZBS': (int, 'omgwtfnzbs', 0),
|
||||
'OMGWTFNZBS_APIKEY': (str, 'omgwtfnzbs', ''),
|
||||
'OMGWTFNZBS_UID': (str, 'omgwtfnzbs', ''),
|
||||
'OPEN_MAGNET_LINKS': (int, 'General', 0),
|
||||
'OPEN_MAGNET_LINKS': (int, 'General', 0), # 0: Ignore, 1: Open, 2: Convert
|
||||
'MAGNET_LINKS': (int, 'General', 0),
|
||||
'OSX_NOTIFY_APP': (str, 'OSX_Notify', '/Applications/Headphones'),
|
||||
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
|
||||
'OSX_NOTIFY_ONSNATCH': (int, 'OSX_Notify', 0),
|
||||
@@ -400,3 +401,8 @@ class Config(object):
|
||||
if self.HPUSER and self.HPPASS:
|
||||
self.HEADPHONES_INDEXER = True
|
||||
self.CONFIG_VERSION = '5'
|
||||
|
||||
if self.CONFIG_VERSION == '5':
|
||||
if self.OPEN_MAGNET_LINKS:
|
||||
self.MAGNET_LINKS = 2
|
||||
self.CONFIG_VERSION = '5'
|
||||
|
||||
@@ -1093,8 +1093,8 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None):
|
||||
|
||||
# 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))
|
||||
logger.debug('Post processing folders: %s', download_dirs)
|
||||
|
||||
logger.info('Checking to see if there are any folders to process in download_dir(s): %s', download_dirs)
|
||||
# Get a list of folders in the download_dir
|
||||
folders = []
|
||||
|
||||
@@ -1113,10 +1113,13 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None):
|
||||
else:
|
||||
folders.append(path_to_folder)
|
||||
|
||||
if len(folders):
|
||||
logger.info('Found %i folders to process', len(folders))
|
||||
else:
|
||||
logger.info('Found no folders to process in: %s', download_dirs)
|
||||
# Log number of folders
|
||||
if folders:
|
||||
logger.info('Found %i folders to process.', len(folders))
|
||||
logger.debug('Expanded post processing folders: %s', folders)
|
||||
else:
|
||||
logger.info('Found no folders to process. Aborting.')
|
||||
return
|
||||
|
||||
# Parse the folder names to get artist album info
|
||||
myDB = db.DBConnection()
|
||||
|
||||
+180
-90
@@ -20,18 +20,19 @@ from pygazelle import api as gazelleapi
|
||||
from pygazelle import encoding as gazelleencoding
|
||||
from pygazelle import format as gazelleformat
|
||||
from pygazelle import media as gazellemedia
|
||||
from xml.dom import minidom
|
||||
from base64 import b16encode, b32decode
|
||||
from hashlib import sha1
|
||||
|
||||
import os, re, time
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import string
|
||||
import shutil
|
||||
import requests
|
||||
import random
|
||||
import headphones
|
||||
import subprocess
|
||||
import unicodedata
|
||||
|
||||
import headphones
|
||||
from headphones.common import USER_AGENT
|
||||
from headphones import logger, db, helpers, classes, sab, nzbget, request
|
||||
from headphones import utorrent, transmission, notifiers
|
||||
@@ -39,19 +40,136 @@ from headphones import utorrent, transmission, notifiers
|
||||
from bencode import bencode, bdecode
|
||||
|
||||
import headphones.searcher_rutracker as rutrackersearch
|
||||
rutracker = rutrackersearch.Rutracker()
|
||||
|
||||
# Magnet to torrent services, for Black hole. Stolen from CouchPotato.
|
||||
TORRENT_TO_MAGNET_SERVICES = [
|
||||
'https://zoink.it/torrent/%s.torrent',
|
||||
'http://torrage.com/torrent/%s.torrent',
|
||||
'https://torcache.net/torrent/%s.torrent',
|
||||
]
|
||||
|
||||
# Persistent What.cd API object
|
||||
gazelle = None
|
||||
|
||||
def url_fix(s, charset='utf-8'):
|
||||
# RUtracker search object
|
||||
rutracker = rutrackersearch.Rutracker()
|
||||
|
||||
def fix_url(s, charset="utf-8"):
|
||||
"""
|
||||
Fix the URL so it is proper formatted and encoded.
|
||||
"""
|
||||
|
||||
if isinstance(s, unicode):
|
||||
s = s.encode(charset, 'ignore')
|
||||
|
||||
scheme, netloc, path, qs, anchor = urlparse.urlsplit(s)
|
||||
path = urllib.quote(path, '/%')
|
||||
qs = urllib.quote_plus(qs, ':&=')
|
||||
|
||||
return urlparse.urlunsplit((scheme, netloc, path, qs, anchor))
|
||||
|
||||
def torrent_to_file(target_file, data):
|
||||
"""
|
||||
Write torrent data to file, and change permissions accordingly. Will return
|
||||
None in case of a write error. If changing permissions fails, it will
|
||||
continue anyway.
|
||||
"""
|
||||
|
||||
# Write data to file
|
||||
try:
|
||||
with open(target_file, "wb") as fp:
|
||||
fp.write(data)
|
||||
except IOError as e:
|
||||
logger.error("Could not write torrent file '%s': %s. Skipping.",
|
||||
target_file, e.message)
|
||||
return
|
||||
|
||||
# Try to change permissions
|
||||
try:
|
||||
os.chmod(target_file, int(headphones.FILE_PERMISSIONS, 8))
|
||||
except OSError as e:
|
||||
logger.warn("Could not change permissions for file '%s': %s. " \
|
||||
"Continuing.", target_file, e.message)
|
||||
|
||||
# Done
|
||||
return True
|
||||
|
||||
def read_torrent_name(torrent_file, default_name=None):
|
||||
"""
|
||||
Read the torrent file and return the torrent name. If the torrent name
|
||||
cannot be determined, it will return the `default_name`.
|
||||
"""
|
||||
|
||||
# Open file
|
||||
try:
|
||||
with open(torrent_file, "rb") as fp:
|
||||
torrent_info = bdecode(fp.read())
|
||||
except IOError as e:
|
||||
logger.error("Unable to open torrent file: %s", torrent_file)
|
||||
return
|
||||
|
||||
# Read dictionary
|
||||
if torrent_info:
|
||||
try:
|
||||
return torrent_info["info"]["name"]
|
||||
except KeyError:
|
||||
if default_name:
|
||||
logger.warning("Couldn't get name from torrent file: %s. " \
|
||||
"Defaulting to '%s'", e, default_name)
|
||||
else:
|
||||
logger.warning("Couldn't get name from torrent file: %s. No " \
|
||||
"default given", e)
|
||||
|
||||
# Return default
|
||||
return default_name
|
||||
|
||||
def calculate_torrent_hash(link, data=None):
|
||||
"""
|
||||
Calculate the torrent hash from a magnet link or data.
|
||||
"""
|
||||
|
||||
if link.startswith("magnet:"):
|
||||
torrent_hash = re.findall("urn:btih:([\w]{32,40})", link)[0]
|
||||
if len(torrent_hash) == 32:
|
||||
torrent_hash = b16encode(b32decode(torrent_hash)).lower()
|
||||
elif data:
|
||||
info = bdecode(data)["info"]
|
||||
torrent_hash = sha1(bencode(info)).hexdigest()
|
||||
else:
|
||||
raise ValueError("Cannot calculate torrent hash without magnet link " \
|
||||
"or data")
|
||||
|
||||
return torrent_hash
|
||||
|
||||
def get_seed_ratio(provider):
|
||||
"""
|
||||
Return the seed ratio for the specified provider, if applicable. Defaults to
|
||||
None in case of an error.
|
||||
"""
|
||||
|
||||
if provider == 'rutracker.org':
|
||||
seed_ratio = headphones.RUTRACKER_RATIO
|
||||
elif provider == 'Kick Ass Torrents':
|
||||
seed_ratio = headphones.KAT_RATIO
|
||||
elif provider == 'What.cd':
|
||||
seed_ratio = headphones.WHATCD_RATIO
|
||||
elif provider == 'The Pirate Bay':
|
||||
seed_ratio = headphones.PIRATEBAY_RATIO
|
||||
elif provider == 'Waffles.fm':
|
||||
seed_ratio = headphones.WAFFLES_RATIO
|
||||
elif provider == 'Mininova':
|
||||
seed_ratio = headphones.MININOVA_RATIO
|
||||
else:
|
||||
seed_ratio = None
|
||||
|
||||
if seed_ratio is not None:
|
||||
try:
|
||||
seed_ratio = float(seed_ratio)
|
||||
except ValueError:
|
||||
logger.warn("Could not get seed ratio for %s" % provider)
|
||||
|
||||
return seed_ratio
|
||||
|
||||
def searchforalbum(albumid=None, new=False, losslessOnly=False, choose_specific_download=False):
|
||||
myDB = db.DBConnection()
|
||||
|
||||
@@ -80,7 +198,6 @@ def searchforalbum(albumid=None, new=False, losslessOnly=False, choose_specific_
|
||||
return results
|
||||
|
||||
else:
|
||||
|
||||
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
||||
logger.info('Searching for "%s - %s" since it was marked as wanted' % (album['ArtistName'], album['AlbumTitle']))
|
||||
do_sorted_search(album, new, losslessOnly)
|
||||
@@ -233,7 +350,7 @@ def sort_search_results(resultlist, album, new, albumlength):
|
||||
priority = 1
|
||||
# add a search provider priority (weighted based on position)
|
||||
i = next((i for i, word in enumerate(preferred_words) if word in result[3].lower()), None)
|
||||
if i != None:
|
||||
if i is not None:
|
||||
priority += round((len(preferred_words) - i) / float(len(preferred_words)),2)
|
||||
|
||||
temp_list.append((result[0],result[1],result[2],result[3],result[4],priority))
|
||||
@@ -621,8 +738,8 @@ def send_to_downloader(data, bestqual, album):
|
||||
torrent_name = helpers.replace_illegal_chars(folder_name) + '.torrent'
|
||||
download_path = os.path.join(headphones.CFG.TORRENTBLACKHOLE_DIR, torrent_name)
|
||||
|
||||
if bestqual[2].startswith("magnet:"):
|
||||
if headphones.CFG.OPEN_MAGNET_LINKS:
|
||||
if bestqual[2].lower().startswith("magnet:"):
|
||||
if headphones.CFG.MAGNET_LINKS == 1:
|
||||
try:
|
||||
if headphones.SYS_PLATFORM == 'win32':
|
||||
os.startfile(bestqual[2])
|
||||
@@ -636,37 +753,50 @@ def send_to_downloader(data, bestqual, album):
|
||||
except Exception as e:
|
||||
logger.error("Error opening magnet link: %s" % str(e))
|
||||
return
|
||||
else:
|
||||
logger.error("Cannot save magnet files to blackhole. Please switch your torrent downloader to Transmission or uTorrent or allow Headphones to try to open magnet links")
|
||||
return
|
||||
elif headphones.MAGNET_LINKS == 2:
|
||||
# Procedure adapted from CouchPotato
|
||||
torrent_hash = calculate_torrent_hash(bestqual[2])
|
||||
|
||||
else:
|
||||
try:
|
||||
# Randomize list of services
|
||||
services = TORRENT_TO_MAGNET_SERVICES[:]
|
||||
random.shuffle(services)
|
||||
|
||||
if bestqual[3] == 'rutracker.org':
|
||||
download_path = rutracker.get_torrent(bestqual[2], headphones.CFG.TORRENTBLACKHOLE_DIR)
|
||||
if not download_path:
|
||||
return
|
||||
for service in services:
|
||||
data = request.request_content(service % torrent_hash)
|
||||
|
||||
if data and "torcache" in data:
|
||||
if not torrent_to_file(download_path, data):
|
||||
return
|
||||
# Extract folder name from torrent
|
||||
folder_name = read_torrent_name(download_path,
|
||||
bestqual[0])
|
||||
|
||||
# Break for loop
|
||||
break
|
||||
else:
|
||||
#Write the torrent file to a path derived from the TORRENTBLACKHOLE_DIR and file name.
|
||||
with open(download_path, 'wb') as fp:
|
||||
fp.write(data)
|
||||
# No service succeeded
|
||||
logger.warning("Unable to convert magnet with hash " \
|
||||
"'%s' into a torrent file.", torrent_hash)
|
||||
return
|
||||
else:
|
||||
logger.error("Cannot save magnet link in blackhole. " \
|
||||
"Please switch your torrent downloader to " \
|
||||
"Transmission or uTorrent, or allow Headphones " \
|
||||
"to open or convert magnet links")
|
||||
return
|
||||
else:
|
||||
if bestqual[3] == "rutracker.org":
|
||||
download_path = rutracker.get_torrent(bestqual[2],
|
||||
headphones.TORRENTBLACKHOLE_DIR)
|
||||
|
||||
try:
|
||||
os.chmod(download_path, int(headphones.CFG.FILE_PERMISSIONS, 8))
|
||||
except:
|
||||
logger.error("Could not change permissions for file: %s", download_path)
|
||||
if not download_path:
|
||||
return
|
||||
else:
|
||||
if not torrent_to_file(download_path, data):
|
||||
return
|
||||
|
||||
# Open the fresh torrent file again so we can extract the
|
||||
# proper torrent name Used later in post-processing.
|
||||
with open(download_path, 'rb') as fp:
|
||||
torrent_info = bdecode(fp.read())
|
||||
|
||||
folder_name = torrent_info['info'].get('name', '')
|
||||
logger.info('Torrent folder name: %s' % folder_name)
|
||||
except Exception as e:
|
||||
logger.error('Couldn\'t get name from Torrent file: %s. Defaulting to torrent title' % e)
|
||||
folder_name = bestqual[0]
|
||||
# Extract folder name from torrent
|
||||
folder_name = read_torrent_name(download_path, bestqual[0])
|
||||
|
||||
elif headphones.CFG.TORRENT_DOWNLOADER == 1:
|
||||
logger.info("Sending torrent to Transmission")
|
||||
@@ -698,8 +828,8 @@ def send_to_downloader(data, bestqual, album):
|
||||
logger.exception("Unhandled exception")
|
||||
|
||||
# Set Seed Ratio
|
||||
seed_ratio = getSeedRatio(bestqual[3])
|
||||
if seed_ratio != None:
|
||||
seed_ratio = get_seed_ratio(bestqual[3])
|
||||
if seed_ratio is not None:
|
||||
transmission.setSeedRatio(torrentid, seed_ratio)
|
||||
|
||||
else:# if headphones.CFG.TORRENT_DOWNLOADER == 2:
|
||||
@@ -713,7 +843,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
utorrent.labelTorrent(torrentid)
|
||||
else:
|
||||
file_or_url = bestqual[2]
|
||||
torrentid = CalculateTorrentHash(file_or_url, data)
|
||||
torrentid = calculate_torrent_hash(file_or_url, data)
|
||||
folder_name = utorrent.addTorrent(file_or_url, torrentid)
|
||||
|
||||
if folder_name:
|
||||
@@ -730,8 +860,8 @@ def send_to_downloader(data, bestqual, album):
|
||||
logger.exception("Unhandled exception")
|
||||
|
||||
# Set Seed Ratio
|
||||
seed_ratio = getSeedRatio(bestqual[3])
|
||||
if seed_ratio != None:
|
||||
seed_ratio = get_seed_ratio(bestqual[3])
|
||||
if seed_ratio is not None:
|
||||
utorrent.setSeedRatio(torrentid, seed_ratio)
|
||||
|
||||
myDB = db.DBConnection()
|
||||
@@ -739,7 +869,7 @@ def send_to_downloader(data, bestqual, album):
|
||||
myDB.action('INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?)', [album['AlbumID'], bestqual[0], bestqual[1], bestqual[2], "Snatched", folder_name, kind])
|
||||
|
||||
# Store the torrent id so we can check later if it's finished seeding and can be removed
|
||||
if seed_ratio != None and seed_ratio != 0 and torrentid:
|
||||
if seed_ratio is not None and seed_ratio != 0 and torrentid:
|
||||
myDB.action('INSERT INTO snatched VALUES( ?, ?, ?, ?, DATETIME("NOW", "localtime"), ?, ?, ?)', [album['AlbumID'], bestqual[0], bestqual[1], bestqual[2], "Seed_Snatched", torrentid, kind])
|
||||
|
||||
# notify
|
||||
@@ -934,9 +1064,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None):
|
||||
|
||||
# Use proxy if specified
|
||||
if headphones.CFG.KAT_PROXY_URL:
|
||||
providerurl = url_fix(set_proxy(headphones.CFG.KAT_PROXY_URL))
|
||||
providerurl = fix_url(set_proxy(headphones.CFG.KAT_PROXY_URL))
|
||||
else:
|
||||
providerurl = url_fix("https://kickass.to")
|
||||
providerurl = fix_url("https://kickass.to")
|
||||
|
||||
# Build URL
|
||||
providerurl = providerurl + "/usearch/" + ka_term
|
||||
@@ -994,7 +1124,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None):
|
||||
|
||||
if headphones.CFG.WAFFLES:
|
||||
provider = "Waffles.fm"
|
||||
providerurl = url_fix("https://www.waffles.fm/browse.php")
|
||||
providerurl = fix_url("https://www.waffles.fm/browse.php")
|
||||
|
||||
bitrate = None
|
||||
if headphones.CFG.PREFERRED_QUALITY == 3 or losslessOnly:
|
||||
@@ -1185,9 +1315,9 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None):
|
||||
|
||||
# Use proxy if specified
|
||||
if headphones.CFG.PIRATEBAY_PROXY_URL:
|
||||
providerurl = url_fix(set_proxy(headphones.CFG.PIRATEBAY_PROXY_URL))
|
||||
providerurl = fix_url(set_proxy(headphones.CFG.PIRATEBAY_PROXY_URL))
|
||||
else:
|
||||
providerurl = url_fix("https://thepiratebay.se")
|
||||
providerurl = fix_url("https://thepiratebay.se")
|
||||
|
||||
# Build URL
|
||||
providerurl = providerurl + "/search/" + tpb_term + "/0/7/" # 7 is sort by seeders
|
||||
@@ -1227,7 +1357,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None):
|
||||
try:
|
||||
url = item.find("a", {"title":"Download this torrent"})['href']
|
||||
except TypeError:
|
||||
if headphones.CFG.OPEN_MAGNET_LINKS:
|
||||
if headphones.MAGNET_LINKS != 0:
|
||||
url = item.findAll("a")[3]['href']
|
||||
else:
|
||||
logger.info('"%s" only has a magnet link, skipping' % title)
|
||||
@@ -1251,7 +1381,7 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None):
|
||||
|
||||
if headphones.CFG.MININOVA:
|
||||
provider = "Mininova"
|
||||
providerurl = url_fix("http://www.mininova.org/rss/" + term + "/5")
|
||||
providerurl = fix_url("http://www.mininova.org/rss/" + term + "/5")
|
||||
|
||||
if headphones.CFG.PREFERRED_QUALITY == 3 or losslessOnly:
|
||||
categories = "7" #music
|
||||
@@ -1319,7 +1449,6 @@ def searchTorrent(album, new=False, losslessOnly=False, albumlength=None):
|
||||
def preprocess(resultlist):
|
||||
|
||||
for result in resultlist:
|
||||
|
||||
if result[4] == 'torrent':
|
||||
#Get out of here if we're using Transmission
|
||||
if headphones.CFG.TORRENT_DOWNLOADER == 1: ## if not a magnet link still need the .torrent to generate hash... uTorrent support labeling
|
||||
@@ -1328,7 +1457,7 @@ def preprocess(resultlist):
|
||||
if result[3] == 'rutracker.org':
|
||||
return True, result
|
||||
# Get out of here if it's a magnet link
|
||||
if result[2].startswith("magnet"):
|
||||
if result[2].lower().startswith("magnet:"):
|
||||
return True, result
|
||||
|
||||
# Download the torrent file
|
||||
@@ -1343,48 +1472,9 @@ def preprocess(resultlist):
|
||||
return request.request_content(url=result[2], headers=headers), result
|
||||
|
||||
else:
|
||||
|
||||
headers = {'User-Agent': USER_AGENT}
|
||||
|
||||
if result[3] == 'headphones':
|
||||
return request.request_content(url=result[2], headers=headers, auth=(headphones.CFG.HPUSER, headphones.CFG.HPPASS)), result
|
||||
else:
|
||||
return request.request_content(url=result[2], headers=headers), result
|
||||
|
||||
def CalculateTorrentHash(link, data):
|
||||
|
||||
if link.startswith('magnet'):
|
||||
tor_hash = re.findall('urn:btih:([\w]{32,40})', link)[0]
|
||||
if len(tor_hash) == 32:
|
||||
tor_hash = b16encode(b32decode(tor_hash)).lower()
|
||||
else:
|
||||
info = bdecode(data)["info"]
|
||||
tor_hash = sha1(bencode(info)).hexdigest()
|
||||
|
||||
logger.debug('Torrent Hash: ' + str(tor_hash))
|
||||
|
||||
return tor_hash
|
||||
|
||||
def getSeedRatio(provider):
|
||||
seed_ratio = ''
|
||||
if provider == 'rutracker.org':
|
||||
seed_ratio = headphones.CFG.RUTRACKER_RATIO
|
||||
elif provider == 'Kick Ass Torrents':
|
||||
seed_ratio = headphones.CFG.KAT_RATIO
|
||||
elif provider == 'What.cd':
|
||||
seed_ratio = headphones.CFG.WHATCD_RATIO
|
||||
elif provider == 'The Pirate Bay':
|
||||
seed_ratio = headphones.CFG.PIRATEBAY_RATIO
|
||||
elif provider == 'Waffles.fm':
|
||||
seed_ratio = headphones.CFG.WAFFLES_RATIO
|
||||
elif provider == 'Mininova':
|
||||
seed_ratio = headphones.CFG.MININOVA_RATIO
|
||||
if seed_ratio != '':
|
||||
try:
|
||||
seed_ratio_float = float(seed_ratio)
|
||||
except:
|
||||
seed_ratio_float = None
|
||||
logger.warn('Could not get Seed Ratio for %s' % provider)
|
||||
return seed_ratio_float
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers
|
||||
from headphones.helpers import checked, radio,today, cleanName
|
||||
|
||||
from mako.template import Template
|
||||
from mako.lookup import TemplateLookup
|
||||
from mako import exceptions
|
||||
|
||||
@@ -28,7 +27,6 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import string
|
||||
import cherrypy
|
||||
import threading
|
||||
import headphones
|
||||
@@ -142,7 +140,7 @@ class WebInterface(object):
|
||||
searchresults = mb.findArtist(name, limit=100)
|
||||
else:
|
||||
searchresults = mb.findRelease(name, limit=100)
|
||||
return serve_template(templatename="searchresults.html", title='Search Results for: "' + name + '"', searchresults=searchresults, type=type)
|
||||
return serve_template(templatename="searchresults.html", title='Search Results for: "' + name + '"', searchresults=searchresults, name=name, type=type)
|
||||
search.exposed = True
|
||||
|
||||
def addArtist(self, artistid):
|
||||
@@ -1069,7 +1067,7 @@ class WebInterface(object):
|
||||
"prefer_torrents_0" : radio(headphones.CFG.PREFER_TORRENTS, 0),
|
||||
"prefer_torrents_1" : radio(headphones.CFG.PREFER_TORRENTS, 1),
|
||||
"prefer_torrents_2" : radio(headphones.CFG.PREFER_TORRENTS, 2),
|
||||
"open_magnet_links" : checked(headphones.CFG.OPEN_MAGNET_LINKS),
|
||||
"magnet_links" : checked(headphones.CFG.MAGNET_LINKS),
|
||||
"log_dir" : headphones.CFG.LOG_DIR,
|
||||
"cache_dir" : headphones.CFG.CACHE_DIR,
|
||||
"interface_list" : interface_list,
|
||||
|
||||
@@ -52,7 +52,7 @@ def initialize(options=None):
|
||||
'tools.encode.encoding': 'utf-8',
|
||||
'tools.decode.on': True,
|
||||
'log.screen': False,
|
||||
'engine.autoreload_on': False,
|
||||
'engine.autoreload.on': False,
|
||||
}
|
||||
|
||||
if enable_https:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
version_info = (2, 0, 0, 'rc', 2)
|
||||
version = '.'.join(str(n) for n in version_info[:3])
|
||||
release = version + ''.join(str(n) for n in version_info[3:])
|
||||
version_info = (3, 0, 1)
|
||||
version = '3.0.1'
|
||||
release = '3.0.1'
|
||||
|
||||
__version__ = release # PEP 396
|
||||
|
||||
+51
-42
@@ -1,63 +1,72 @@
|
||||
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN',
|
||||
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED',
|
||||
'EVENT_JOBSTORE_JOB_ADDED', 'EVENT_JOBSTORE_JOB_REMOVED',
|
||||
'EVENT_JOB_EXECUTED', 'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED',
|
||||
'EVENT_ALL', 'SchedulerEvent', 'JobStoreEvent', 'JobEvent')
|
||||
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', '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_ERROR', 'EVENT_JOB_MISSED',
|
||||
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
|
||||
|
||||
|
||||
EVENT_SCHEDULER_START = 1 # The scheduler was started
|
||||
EVENT_SCHEDULER_SHUTDOWN = 2 # The scheduler was shut down
|
||||
EVENT_JOBSTORE_ADDED = 4 # A job store was added to the scheduler
|
||||
EVENT_JOBSTORE_REMOVED = 8 # A job store was removed from the scheduler
|
||||
EVENT_JOBSTORE_JOB_ADDED = 16 # A job was added to a job store
|
||||
EVENT_JOBSTORE_JOB_REMOVED = 32 # A job was removed from a job store
|
||||
EVENT_JOB_EXECUTED = 64 # A job was executed successfully
|
||||
EVENT_JOB_ERROR = 128 # A job raised an exception during execution
|
||||
EVENT_JOB_MISSED = 256 # A job's execution was missed
|
||||
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN |
|
||||
EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
|
||||
EVENT_JOBSTORE_JOB_ADDED | EVENT_JOBSTORE_JOB_REMOVED |
|
||||
EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED)
|
||||
EVENT_SCHEDULER_START = 1
|
||||
EVENT_SCHEDULER_SHUTDOWN = 2
|
||||
EVENT_EXECUTOR_ADDED = 4
|
||||
EVENT_EXECUTOR_REMOVED = 8
|
||||
EVENT_JOBSTORE_ADDED = 16
|
||||
EVENT_JOBSTORE_REMOVED = 32
|
||||
EVENT_ALL_JOBS_REMOVED = 64
|
||||
EVENT_JOB_ADDED = 128
|
||||
EVENT_JOB_REMOVED = 256
|
||||
EVENT_JOB_MODIFIED = 512
|
||||
EVENT_JOB_EXECUTED = 1024
|
||||
EVENT_JOB_ERROR = 2048
|
||||
EVENT_JOB_MISSED = 4096
|
||||
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
|
||||
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
|
||||
EVENT_JOB_ERROR | EVENT_JOB_MISSED)
|
||||
|
||||
|
||||
class SchedulerEvent(object):
|
||||
"""
|
||||
An event that concerns the scheduler itself.
|
||||
|
||||
:var code: the type code of this event
|
||||
:ivar code: the type code of this event
|
||||
:ivar alias: alias of the job store or executor that was added or removed (if applicable)
|
||||
"""
|
||||
def __init__(self, code):
|
||||
|
||||
def __init__(self, code, alias=None):
|
||||
super(SchedulerEvent, self).__init__()
|
||||
self.code = code
|
||||
|
||||
|
||||
class JobStoreEvent(SchedulerEvent):
|
||||
"""
|
||||
An event that concerns job stores.
|
||||
|
||||
:var alias: the alias of the job store involved
|
||||
:var job: the new job if a job was added
|
||||
"""
|
||||
def __init__(self, code, alias, job=None):
|
||||
SchedulerEvent.__init__(self, code)
|
||||
self.alias = alias
|
||||
if job:
|
||||
self.job = job
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (code=%d)>' % (self.__class__.__name__, self.code)
|
||||
|
||||
|
||||
class JobEvent(SchedulerEvent):
|
||||
"""
|
||||
An event that concerns a job.
|
||||
|
||||
:ivar code: the type code of this event
|
||||
:ivar job_id: identifier of the job in question
|
||||
:ivar jobstore: alias of the job store containing the job in question
|
||||
"""
|
||||
|
||||
def __init__(self, code, job_id, jobstore):
|
||||
super(JobEvent, self).__init__(code)
|
||||
self.code = code
|
||||
self.job_id = job_id
|
||||
self.jobstore = jobstore
|
||||
|
||||
|
||||
class JobExecutionEvent(JobEvent):
|
||||
"""
|
||||
An event that concerns the execution of individual jobs.
|
||||
|
||||
:var job: the job instance in question
|
||||
:var scheduled_run_time: the time when the job was scheduled to be run
|
||||
:var retval: the return value of the successfully executed job
|
||||
:var exception: the exception raised by the job
|
||||
:var traceback: the traceback object associated with the exception
|
||||
: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 exception: the exception raised by the job
|
||||
:ivar traceback: a formatted traceback for the exception
|
||||
"""
|
||||
def __init__(self, code, job, scheduled_run_time, retval=None,
|
||||
exception=None, traceback=None):
|
||||
SchedulerEvent.__init__(self, code)
|
||||
self.job = job
|
||||
|
||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, traceback=None):
|
||||
super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
|
||||
self.scheduled_run_time = scheduled_run_time
|
||||
self.retval = retval
|
||||
self.exception = exception
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import absolute_import
|
||||
import sys
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
|
||||
class AsyncIOExecutor(BaseExecutor):
|
||||
"""
|
||||
Runs jobs in the default executor of the event loop.
|
||||
|
||||
Plugin alias: ``asyncio``
|
||||
"""
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(AsyncIOExecutor, self).start(scheduler, alias)
|
||||
self._eventloop = scheduler._eventloop
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
def callback(f):
|
||||
try:
|
||||
events = f.result()
|
||||
except:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
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)
|
||||
f.add_done_callback(callback)
|
||||
@@ -0,0 +1,119 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from traceback import format_tb
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pytz import utc
|
||||
import six
|
||||
|
||||
from apscheduler.events import JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||||
|
||||
|
||||
class MaxInstancesReachedError(Exception):
|
||||
def __init__(self, job):
|
||||
super(MaxInstancesReachedError, self).__init__(
|
||||
'Job "%s" has already reached its maximum number of instances (%d)' % (job.id, job.max_instances))
|
||||
|
||||
|
||||
class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
"""Abstract base class that defines the interface that every executor must implement."""
|
||||
|
||||
_scheduler = None
|
||||
_lock = None
|
||||
_logger = logging.getLogger('apscheduler.executors')
|
||||
|
||||
def __init__(self):
|
||||
super(BaseExecutor, self).__init__()
|
||||
self._instances = defaultdict(lambda: 0)
|
||||
|
||||
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
|
||||
running scheduler.
|
||||
|
||||
: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
|
||||
"""
|
||||
|
||||
self._scheduler = scheduler
|
||||
self._lock = scheduler._create_lock()
|
||||
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
"""
|
||||
Shuts down this executor.
|
||||
|
||||
:param bool wait: ``True`` to wait until all submitted jobs have been executed
|
||||
"""
|
||||
|
||||
def submit_job(self, job, run_times):
|
||||
"""
|
||||
Submits job for execution.
|
||||
|
||||
:param Job job: job to execute
|
||||
:param list[datetime] run_times: list of datetimes specifying 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'
|
||||
with self._lock:
|
||||
if self._instances[job.id] >= job.max_instances:
|
||||
raise MaxInstancesReachedError(job)
|
||||
|
||||
self._do_submit_job(job, run_times)
|
||||
self._instances[job.id] += 1
|
||||
|
||||
@abstractmethod
|
||||
def _do_submit_job(self, job, run_times):
|
||||
"""Performs the actual task of scheduling `run_job` to be called."""
|
||||
|
||||
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."""
|
||||
|
||||
with self._lock:
|
||||
self._instances[job_id] -= 1
|
||||
|
||||
for event in events:
|
||||
self._scheduler._dispatch_event(event)
|
||||
|
||||
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`."""
|
||||
|
||||
with self._lock:
|
||||
self._instances[job_id] -= 1
|
||||
|
||||
exc_info = (exc.__class__, exc, traceback)
|
||||
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
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 = job.func(*job.args, **job.kwargs)
|
||||
except:
|
||||
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)
|
||||
else:
|
||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval))
|
||||
logger.info('Job "%s" executed successfully', job)
|
||||
|
||||
return events
|
||||
@@ -0,0 +1,19 @@
|
||||
import sys
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
|
||||
class DebugExecutor(BaseExecutor):
|
||||
"""
|
||||
A special executor that executes the target callable directly instead of deferring it to a thread or process.
|
||||
|
||||
Plugin alias: ``debug``
|
||||
"""
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
try:
|
||||
events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||
except:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import absolute_import
|
||||
import sys
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
|
||||
try:
|
||||
import gevent
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('GeventExecutor requires gevent installed')
|
||||
|
||||
|
||||
class GeventExecutor(BaseExecutor):
|
||||
"""
|
||||
Runs jobs as greenlets.
|
||||
|
||||
Plugin alias: ``gevent``
|
||||
"""
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
def callback(greenlet):
|
||||
try:
|
||||
events = greenlet.get()
|
||||
except:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback)
|
||||
@@ -0,0 +1,54 @@
|
||||
from abc import abstractmethod
|
||||
import concurrent.futures
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
|
||||
class BasePoolExecutor(BaseExecutor):
|
||||
@abstractmethod
|
||||
def __init__(self, pool):
|
||||
super(BasePoolExecutor, self).__init__()
|
||||
self._pool = pool
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
def callback(f):
|
||||
exc, tb = (f.exception_info() if hasattr(f, 'exception_info') else
|
||||
(f.exception(), getattr(f.exception(), '__traceback__', None)))
|
||||
if exc:
|
||||
self._run_job_error(job.id, exc, tb)
|
||||
else:
|
||||
self._run_job_success(job.id, f.result())
|
||||
|
||||
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||
f.add_done_callback(callback)
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
self._pool.shutdown(wait)
|
||||
|
||||
|
||||
class ThreadPoolExecutor(BasePoolExecutor):
|
||||
"""
|
||||
An executor that runs jobs in a concurrent.futures thread pool.
|
||||
|
||||
Plugin alias: ``threadpool``
|
||||
|
||||
:param max_workers: the maximum number of spawned threads.
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers=10):
|
||||
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers))
|
||||
super(ThreadPoolExecutor, self).__init__(pool)
|
||||
|
||||
|
||||
class ProcessPoolExecutor(BasePoolExecutor):
|
||||
"""
|
||||
An executor that runs jobs in a concurrent.futures process pool.
|
||||
|
||||
Plugin alias: ``processpool``
|
||||
|
||||
:param max_workers: the maximum number of spawned processes.
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers=10):
|
||||
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers))
|
||||
super(ProcessPoolExecutor, self).__init__(pool)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
|
||||
class TwistedExecutor(BaseExecutor):
|
||||
"""
|
||||
Runs jobs in the reactor's thread pool.
|
||||
|
||||
Plugin alias: ``twisted``
|
||||
"""
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(TwistedExecutor, self).start(scheduler, alias)
|
||||
self._reactor = scheduler._reactor
|
||||
|
||||
def _do_submit_job(self, job, run_times):
|
||||
def callback(success, result):
|
||||
if success:
|
||||
self._run_job_success(job.id, result)
|
||||
else:
|
||||
self._run_job_error(job.id, result.value, result.tb)
|
||||
|
||||
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
|
||||
self._logger.name)
|
||||
+222
-104
@@ -1,134 +1,252 @@
|
||||
"""
|
||||
Jobs represent scheduled tasks.
|
||||
"""
|
||||
from collections import Iterable, Mapping
|
||||
from uuid import uuid4
|
||||
|
||||
from threading import Lock
|
||||
from datetime import timedelta
|
||||
import six
|
||||
|
||||
from apscheduler.util import to_unicode, ref_to_obj, get_callable_name,\
|
||||
obj_to_ref
|
||||
|
||||
|
||||
class MaxInstancesReachedError(Exception):
|
||||
pass
|
||||
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, \
|
||||
convert_to_datetime
|
||||
|
||||
|
||||
class Job(object):
|
||||
"""
|
||||
Encapsulates the actual Job along with its metadata. Job instances
|
||||
are created by the scheduler when adding jobs, and it should not be
|
||||
directly instantiated.
|
||||
Contains the options given when scheduling callables and its current schedule and other state.
|
||||
This class should never be instantiated by the user.
|
||||
|
||||
:param trigger: trigger that determines the execution times
|
||||
:param func: callable to call when the trigger is triggered
|
||||
:param args: list of positional arguments to call func with
|
||||
:param kwargs: dict of keyword arguments to call func with
|
||||
:param name: name of the job (optional)
|
||||
:param misfire_grace_time: seconds after the designated run time that
|
||||
the job is still allowed to be run
|
||||
:param coalesce: run once instead of many times if the scheduler determines
|
||||
that the job should be run more than once in succession
|
||||
:param max_runs: maximum number of times this job is allowed to be
|
||||
triggered
|
||||
:param max_instances: maximum number of concurrently running
|
||||
instances allowed for this job
|
||||
:var str id: the unique identifier of this job
|
||||
:var str name: the description of this job
|
||||
:var func: the callable to execute
|
||||
:var tuple|list args: positional arguments to the callable
|
||||
:var dict kwargs: keyword arguments to the callable
|
||||
: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 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 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
|
||||
"""
|
||||
id = None
|
||||
next_run_time = None
|
||||
|
||||
def __init__(self, trigger, func, args, kwargs, misfire_grace_time,
|
||||
coalesce, name=None, max_runs=None, max_instances=1):
|
||||
if not trigger:
|
||||
raise ValueError('The trigger must not be None')
|
||||
if not hasattr(func, '__call__'):
|
||||
raise TypeError('func must be callable')
|
||||
if not hasattr(args, '__getitem__'):
|
||||
raise TypeError('args must be a list-like object')
|
||||
if not hasattr(kwargs, '__getitem__'):
|
||||
raise TypeError('kwargs must be a dict-like object')
|
||||
if misfire_grace_time <= 0:
|
||||
raise ValueError('misfire_grace_time must be a positive value')
|
||||
if max_runs is not None and max_runs <= 0:
|
||||
raise ValueError('max_runs must be a positive value')
|
||||
if max_instances <= 0:
|
||||
raise ValueError('max_instances must be a positive value')
|
||||
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs',
|
||||
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
|
||||
|
||||
self._lock = Lock()
|
||||
def __init__(self, scheduler, id=None, **kwargs):
|
||||
super(Job, self).__init__()
|
||||
self._scheduler = scheduler
|
||||
self._jobstore_alias = None
|
||||
self._modify(id=id or uuid4().hex, **kwargs)
|
||||
|
||||
self.trigger = trigger
|
||||
self.func = func
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.name = to_unicode(name or get_callable_name(func))
|
||||
self.misfire_grace_time = misfire_grace_time
|
||||
self.coalesce = coalesce
|
||||
self.max_runs = max_runs
|
||||
self.max_instances = max_instances
|
||||
self.runs = 0
|
||||
self.instances = 0
|
||||
|
||||
def compute_next_run_time(self, now):
|
||||
if self.runs == self.max_runs:
|
||||
self.next_run_time = None
|
||||
else:
|
||||
self.next_run_time = self.trigger.get_next_fire_time(now)
|
||||
|
||||
return self.next_run_time
|
||||
|
||||
def get_run_times(self, now):
|
||||
def modify(self, **changes):
|
||||
"""
|
||||
Computes the scheduled run times between ``next_run_time`` and ``now``.
|
||||
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.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
||||
"""
|
||||
|
||||
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
||||
|
||||
def reschedule(self, trigger, **trigger_args):
|
||||
"""
|
||||
Shortcut for switching the trigger on this job.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
|
||||
"""
|
||||
|
||||
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
|
||||
|
||||
def pause(self):
|
||||
"""
|
||||
Temporarily suspend the execution of this job.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
||||
"""
|
||||
|
||||
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
||||
|
||||
def resume(self):
|
||||
"""
|
||||
Resume the schedule of this job if previously paused.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
||||
"""
|
||||
|
||||
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Unschedules this job and removes it from its associated job store.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
||||
"""
|
||||
|
||||
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
||||
|
||||
@property
|
||||
def pending(self):
|
||||
"""Returns ``True`` if the referenced job is still waiting to be added to its designated job store."""
|
||||
|
||||
return self._jobstore_alias is None
|
||||
|
||||
#
|
||||
# Private API
|
||||
#
|
||||
|
||||
def _get_run_times(self, now):
|
||||
"""
|
||||
Computes the scheduled run times between ``next_run_time`` and ``now`` (inclusive).
|
||||
|
||||
:type now: datetime.datetime
|
||||
:rtype: list[datetime.datetime]
|
||||
"""
|
||||
|
||||
run_times = []
|
||||
run_time = self.next_run_time
|
||||
increment = timedelta(microseconds=1)
|
||||
while ((not self.max_runs or self.runs < self.max_runs) and
|
||||
run_time and run_time <= now):
|
||||
run_times.append(run_time)
|
||||
run_time = self.trigger.get_next_fire_time(run_time + increment)
|
||||
next_run_time = self.next_run_time
|
||||
while next_run_time and next_run_time <= now:
|
||||
run_times.append(next_run_time)
|
||||
next_run_time = self.trigger.get_next_fire_time(next_run_time, now)
|
||||
|
||||
return run_times
|
||||
|
||||
def add_instance(self):
|
||||
self._lock.acquire()
|
||||
try:
|
||||
if self.instances == self.max_instances:
|
||||
raise MaxInstancesReachedError
|
||||
self.instances += 1
|
||||
finally:
|
||||
self._lock.release()
|
||||
def _modify(self, **changes):
|
||||
"""Validates the changes to the Job and makes the modifications if and only if all of them validate."""
|
||||
|
||||
def remove_instance(self):
|
||||
self._lock.acquire()
|
||||
try:
|
||||
assert self.instances > 0, 'Already at 0 instances'
|
||||
self.instances -= 1
|
||||
finally:
|
||||
self._lock.release()
|
||||
approved = {}
|
||||
|
||||
if 'id' in changes:
|
||||
value = changes.pop('id')
|
||||
if not isinstance(value, six.string_types):
|
||||
raise TypeError("id must be a nonempty string")
|
||||
if hasattr(self, 'id'):
|
||||
raise ValueError('The job ID may not be changed')
|
||||
approved['id'] = value
|
||||
|
||||
if 'func' in changes or 'args' in changes or 'kwargs' in changes:
|
||||
func = changes.pop('func') if 'func' in changes else self.func
|
||||
args = changes.pop('args') if 'args' in changes else self.args
|
||||
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
|
||||
|
||||
if isinstance(func, str):
|
||||
func_ref = func
|
||||
func = ref_to_obj(func)
|
||||
elif callable(func):
|
||||
try:
|
||||
func_ref = obj_to_ref(func)
|
||||
except ValueError:
|
||||
# If this happens, this Job won't be serializable
|
||||
func_ref = None
|
||||
else:
|
||||
raise TypeError('func must be a callable or a textual reference to one')
|
||||
|
||||
if not hasattr(self, 'name') and changes.get('name', None) is None:
|
||||
changes['name'] = get_callable_name(func)
|
||||
|
||||
if isinstance(args, six.string_types) or not isinstance(args, Iterable):
|
||||
raise TypeError('args must be a non-string iterable')
|
||||
if isinstance(kwargs, six.string_types) or not isinstance(kwargs, Mapping):
|
||||
raise TypeError('kwargs must be a dict-like object')
|
||||
|
||||
check_callable_args(func, args, kwargs)
|
||||
|
||||
approved['func'] = func
|
||||
approved['func_ref'] = func_ref
|
||||
approved['args'] = args
|
||||
approved['kwargs'] = kwargs
|
||||
|
||||
if 'name' in changes:
|
||||
value = changes.pop('name')
|
||||
if not value or not isinstance(value, six.string_types):
|
||||
raise TypeError("name must be a nonempty string")
|
||||
approved['name'] = value
|
||||
|
||||
if 'misfire_grace_time' in changes:
|
||||
value = changes.pop('misfire_grace_time')
|
||||
if value is not None and (not isinstance(value, six.integer_types) or value <= 0):
|
||||
raise TypeError('misfire_grace_time must be either None or a positive integer')
|
||||
approved['misfire_grace_time'] = value
|
||||
|
||||
if 'coalesce' in changes:
|
||||
value = bool(changes.pop('coalesce'))
|
||||
approved['coalesce'] = value
|
||||
|
||||
if 'max_instances' in changes:
|
||||
value = changes.pop('max_instances')
|
||||
if not isinstance(value, six.integer_types) or value <= 0:
|
||||
raise TypeError('max_instances must be a positive integer')
|
||||
approved['max_instances'] = value
|
||||
|
||||
if 'trigger' in changes:
|
||||
trigger = changes.pop('trigger')
|
||||
if not isinstance(trigger, BaseTrigger):
|
||||
raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__)
|
||||
|
||||
approved['trigger'] = trigger
|
||||
|
||||
if 'executor' in changes:
|
||||
value = changes.pop('executor')
|
||||
if not isinstance(value, six.string_types):
|
||||
raise TypeError('executor must be a string')
|
||||
approved['executor'] = value
|
||||
|
||||
if 'next_run_time' in changes:
|
||||
value = changes.pop('next_run_time')
|
||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time')
|
||||
|
||||
if changes:
|
||||
raise AttributeError('The following are not modifiable attributes of Job: %s' % ', '.join(changes))
|
||||
|
||||
for key, value in six.iteritems(approved):
|
||||
setattr(self, key, value)
|
||||
|
||||
def __getstate__(self):
|
||||
# Prevents the unwanted pickling of transient or unpicklable variables
|
||||
state = self.__dict__.copy()
|
||||
state.pop('instances', None)
|
||||
state.pop('func', None)
|
||||
state.pop('_lock', None)
|
||||
state['func_ref'] = obj_to_ref(self.func)
|
||||
return state
|
||||
# Don't allow this Job to be serialized if the function reference could not be determined
|
||||
if not self.func_ref:
|
||||
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
|
||||
'determined. Consider giving a textual reference (module:function name) instead.' %
|
||||
(self.func,))
|
||||
|
||||
return {
|
||||
'version': 1,
|
||||
'id': self.id,
|
||||
'func': self.func_ref,
|
||||
'trigger': self.trigger,
|
||||
'executor': self.executor,
|
||||
'args': self.args,
|
||||
'kwargs': self.kwargs,
|
||||
'name': self.name,
|
||||
'misfire_grace_time': self.misfire_grace_time,
|
||||
'coalesce': self.coalesce,
|
||||
'max_instances': self.max_instances,
|
||||
'next_run_time': self.next_run_time
|
||||
}
|
||||
|
||||
def __setstate__(self, state):
|
||||
state['instances'] = 0
|
||||
state['func'] = ref_to_obj(state.pop('func_ref'))
|
||||
state['_lock'] = Lock()
|
||||
self.__dict__ = state
|
||||
if state.get('version', 1) > 1:
|
||||
raise ValueError('Job has version %s, but only version 1 can be handled' % state['version'])
|
||||
|
||||
self.id = state['id']
|
||||
self.func_ref = state['func']
|
||||
self.func = ref_to_obj(self.func_ref)
|
||||
self.trigger = state['trigger']
|
||||
self.executor = state['executor']
|
||||
self.args = state['args']
|
||||
self.kwargs = state['kwargs']
|
||||
self.name = state['name']
|
||||
self.misfire_grace_time = state['misfire_grace_time']
|
||||
self.coalesce = state['coalesce']
|
||||
self.max_instances = state['max_instances']
|
||||
self.next_run_time = state['next_run_time']
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Job):
|
||||
return self.id is not None and other.id == self.id or self is other
|
||||
return self.id == other.id
|
||||
return NotImplemented
|
||||
|
||||
def __repr__(self):
|
||||
return '<Job (name=%s, trigger=%s)>' % (self.name, repr(self.trigger))
|
||||
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
|
||||
|
||||
def __str__(self):
|
||||
return '%s (trigger: %s, next run at: %s)' % (self.name,
|
||||
str(self.trigger), str(self.next_run_time))
|
||||
return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)),
|
||||
datetime_repr(self.next_run_time))
|
||||
|
||||
def __unicode__(self):
|
||||
return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time))
|
||||
|
||||
@@ -1,25 +1,127 @@
|
||||
"""
|
||||
Abstract base class that provides the interface needed by all job stores.
|
||||
Job store methods are also documented here.
|
||||
"""
|
||||
from abc import ABCMeta, abstractmethod
|
||||
import logging
|
||||
|
||||
import six
|
||||
|
||||
|
||||
class JobStore(object):
|
||||
def add_job(self, job):
|
||||
"""Adds the given job from this store."""
|
||||
raise NotImplementedError
|
||||
class JobLookupError(KeyError):
|
||||
"""Raised when the job store cannot find a job for update or removal."""
|
||||
|
||||
def update_job(self, job):
|
||||
"""Persists the running state of the given job."""
|
||||
raise NotImplementedError
|
||||
def __init__(self, job_id):
|
||||
super(JobLookupError, self).__init__(six.u('No job by the id of %s was found') % job_id)
|
||||
|
||||
def remove_job(self, job):
|
||||
"""Removes the given jobs from this store."""
|
||||
raise NotImplementedError
|
||||
|
||||
def load_jobs(self):
|
||||
"""Loads jobs from this store into memory."""
|
||||
raise NotImplementedError
|
||||
class ConflictingIdError(KeyError):
|
||||
"""Raised when the uniqueness of job IDs is being violated."""
|
||||
|
||||
def close(self):
|
||||
def __init__(self, job_id):
|
||||
super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id)
|
||||
|
||||
|
||||
class TransientJobError(ValueError):
|
||||
"""Raised when an attempt to add transient (with no func_ref) job to a persistent job store is detected."""
|
||||
|
||||
def __init__(self, job_id):
|
||||
super(TransientJobError, self).__init__(
|
||||
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
|
||||
'determined.') % job_id)
|
||||
|
||||
|
||||
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
"""Abstract base class that defines the interface that every job store must implement."""
|
||||
|
||||
_scheduler = None
|
||||
_alias = None
|
||||
_logger = logging.getLogger('apscheduler.jobstores')
|
||||
|
||||
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
|
||||
running scheduler.
|
||||
|
||||
: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
|
||||
"""
|
||||
|
||||
self._scheduler = scheduler
|
||||
self._alias = alias
|
||||
self._logger = logging.getLogger('apscheduler.jobstores.%s' % alias)
|
||||
|
||||
def shutdown(self):
|
||||
"""Frees any resources still bound to this job store."""
|
||||
|
||||
@abstractmethod
|
||||
def lookup_job(self, job_id):
|
||||
"""
|
||||
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
|
||||
point to the scheduler and itself, respectively.
|
||||
|
||||
:param str|unicode job_id: identifier of the job
|
||||
:rtype: Job
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_due_jobs(self, now):
|
||||
"""
|
||||
Returns the list of jobs that have ``next_run_time`` earlier or equal to ``now``.
|
||||
The returned jobs must be sorted by next run time (ascending).
|
||||
|
||||
:param datetime.datetime now: the current (timezone aware) datetime
|
||||
:rtype: list[Job]
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
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.
|
||||
|
||||
:rtype: datetime.datetime
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
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).
|
||||
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
|
||||
point to the scheduler and itself, respectively.
|
||||
|
||||
:rtype: list[Job]
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def add_job(self, job):
|
||||
"""
|
||||
Adds the given job to this store.
|
||||
|
||||
:param Job job: the job to add
|
||||
:raises ConflictingIdError: if there is another job in this store with the same ID
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update_job(self, job):
|
||||
"""
|
||||
Replaces the job in the store with the given newer version.
|
||||
|
||||
:param Job job: the job to update
|
||||
:raises JobLookupError: if the job does not exist
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def remove_job(self, job_id):
|
||||
"""
|
||||
Removes the given job from this store.
|
||||
|
||||
:param str|unicode job_id: identifier of the job
|
||||
:raises JobLookupError: if the job does not exist
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def remove_all_jobs(self):
|
||||
"""Removes all jobs from this store."""
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % self.__class__.__name__
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
from apscheduler.util import datetime_to_utc_timestamp
|
||||
|
||||
|
||||
class MemoryJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in an array in RAM. Provides no persistence support.
|
||||
|
||||
Plugin alias: ``memory``
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(MemoryJobStore, self).__init__()
|
||||
self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
return self._jobs_index.get(job_id, (None, None))[0]
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
now_timestamp = datetime_to_utc_timestamp(now)
|
||||
pending = []
|
||||
for job, timestamp in self._jobs:
|
||||
if timestamp is None or timestamp > now_timestamp:
|
||||
break
|
||||
pending.append(job)
|
||||
|
||||
return pending
|
||||
|
||||
def get_next_run_time(self):
|
||||
return self._jobs[0][0].next_run_time if self._jobs else None
|
||||
|
||||
def get_all_jobs(self):
|
||||
return [j[0] for j in self._jobs]
|
||||
|
||||
def add_job(self, job):
|
||||
if job.id in self._jobs_index:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
timestamp = datetime_to_utc_timestamp(job.next_run_time)
|
||||
index = self._get_job_index(timestamp, job.id)
|
||||
self._jobs.insert(index, (job, timestamp))
|
||||
self._jobs_index[job.id] = (job, timestamp)
|
||||
|
||||
def update_job(self, job):
|
||||
old_job, old_timestamp = self._jobs_index.get(job.id, (None, None))
|
||||
if old_job is None:
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
# If the next run time has not changed, simply replace the job in its present index.
|
||||
# Otherwise, reinsert the job to the list to preserve the ordering.
|
||||
old_index = self._get_job_index(old_timestamp, old_job.id)
|
||||
new_timestamp = datetime_to_utc_timestamp(job.next_run_time)
|
||||
if old_timestamp == new_timestamp:
|
||||
self._jobs[old_index] = (job, new_timestamp)
|
||||
else:
|
||||
del self._jobs[old_index]
|
||||
new_index = self._get_job_index(new_timestamp, job.id)
|
||||
self._jobs.insert(new_index, (job, new_timestamp))
|
||||
|
||||
self._jobs_index[old_job.id] = (job, new_timestamp)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
job, timestamp = self._jobs_index.get(job_id, (None, None))
|
||||
if job is None:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
index = self._get_job_index(timestamp, job_id)
|
||||
del self._jobs[index]
|
||||
del self._jobs_index[job.id]
|
||||
|
||||
def remove_all_jobs(self):
|
||||
self._jobs = []
|
||||
self._jobs_index = {}
|
||||
|
||||
def shutdown(self):
|
||||
self.remove_all_jobs()
|
||||
|
||||
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
|
||||
the given timestamp.
|
||||
|
||||
:type timestamp: int
|
||||
:type job_id: str
|
||||
"""
|
||||
|
||||
lo, hi = 0, len(self._jobs)
|
||||
timestamp = float('inf') if timestamp is None else timestamp
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
mid_job, mid_timestamp = self._jobs[mid]
|
||||
mid_timestamp = float('inf') if mid_timestamp is None else mid_timestamp
|
||||
if mid_timestamp > timestamp:
|
||||
hi = mid
|
||||
elif mid_timestamp < timestamp:
|
||||
lo = mid + 1
|
||||
elif mid_job.id > job_id:
|
||||
hi = mid
|
||||
elif mid_job.id < job_id:
|
||||
lo = mid + 1
|
||||
else:
|
||||
return mid
|
||||
|
||||
return lo
|
||||
@@ -0,0 +1,124 @@
|
||||
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 bson.binary import Binary
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
from pymongo import MongoClient, ASCENDING
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('MongoDBJobStore requires PyMongo installed')
|
||||
|
||||
|
||||
class MongoDBJobStore(BaseJobStore):
|
||||
"""
|
||||
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>`_.
|
||||
|
||||
Plugin alias: ``mongodb``
|
||||
|
||||
:param str database: database 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
|
||||
arguments
|
||||
: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,
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
|
||||
super(MongoDBJobStore, self).__init__()
|
||||
self.pickle_protocol = pickle_protocol
|
||||
|
||||
if not database:
|
||||
raise ValueError('The "database" parameter must not be empty')
|
||||
if not collection:
|
||||
raise ValueError('The "collection" parameter must not be empty')
|
||||
|
||||
if client:
|
||||
self.connection = maybe_ref(client)
|
||||
else:
|
||||
connect_args.setdefault('w', 1)
|
||||
self.connection = MongoClient(**connect_args)
|
||||
|
||||
self.collection = self.connection[database][collection]
|
||||
self.collection.ensure_index('next_run_time', sparse=True)
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
document = self.collection.find_one(job_id, ['job_state'])
|
||||
return self._reconstitute_job(document['job_state']) if document else None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
timestamp = datetime_to_utc_timestamp(now)
|
||||
return self._get_jobs({'next_run_time': {'$lte': timestamp}})
|
||||
|
||||
def get_next_run_time(self):
|
||||
document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'],
|
||||
sort=[('next_run_time', ASCENDING)])
|
||||
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
||||
|
||||
def get_all_jobs(self):
|
||||
return self._get_jobs({})
|
||||
|
||||
def add_job(self, job):
|
||||
try:
|
||||
self.collection.insert({
|
||||
'_id': job.id,
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
})
|
||||
except DuplicateKeyError:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
def update_job(self, job):
|
||||
changes = {
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
}
|
||||
result = self.collection.update({'_id': job.id}, {'$set': changes})
|
||||
if result and result['n'] == 0:
|
||||
raise JobLookupError(id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
result = self.collection.remove(job_id)
|
||||
if result and result['n'] == 0:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
self.collection.remove()
|
||||
|
||||
def shutdown(self):
|
||||
self.connection.disconnect()
|
||||
|
||||
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, conditions):
|
||||
jobs = []
|
||||
failed_job_ids = []
|
||||
for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(document['job_state']))
|
||||
except:
|
||||
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.collection.remove({'_id': {'$in': failed_job_ids}})
|
||||
|
||||
return jobs
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.connection)
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
Stores jobs in a MongoDB database.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from apscheduler.jobstores.base import JobStore
|
||||
from apscheduler.job import Job
|
||||
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
try:
|
||||
from bson.binary import Binary
|
||||
from pymongo.connection import Connection
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('MongoDBJobStore requires PyMongo installed')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MongoDBJobStore(JobStore):
|
||||
def __init__(self, database='apscheduler', collection='jobs',
|
||||
connection=None, pickle_protocol=pickle.HIGHEST_PROTOCOL,
|
||||
**connect_args):
|
||||
self.jobs = []
|
||||
self.pickle_protocol = pickle_protocol
|
||||
|
||||
if not database:
|
||||
raise ValueError('The "database" parameter must not be empty')
|
||||
if not collection:
|
||||
raise ValueError('The "collection" parameter must not be empty')
|
||||
|
||||
if connection:
|
||||
self.connection = connection
|
||||
else:
|
||||
self.connection = Connection(**connect_args)
|
||||
|
||||
self.collection = self.connection[database][collection]
|
||||
|
||||
def add_job(self, job):
|
||||
job_dict = job.__getstate__()
|
||||
job_dict['trigger'] = Binary(pickle.dumps(job.trigger,
|
||||
self.pickle_protocol))
|
||||
job_dict['args'] = Binary(pickle.dumps(job.args,
|
||||
self.pickle_protocol))
|
||||
job_dict['kwargs'] = Binary(pickle.dumps(job.kwargs,
|
||||
self.pickle_protocol))
|
||||
job.id = self.collection.insert(job_dict)
|
||||
self.jobs.append(job)
|
||||
|
||||
def remove_job(self, job):
|
||||
self.collection.remove(job.id)
|
||||
self.jobs.remove(job)
|
||||
|
||||
def load_jobs(self):
|
||||
jobs = []
|
||||
for job_dict in self.collection.find():
|
||||
try:
|
||||
job = Job.__new__(Job)
|
||||
job_dict['id'] = job_dict.pop('_id')
|
||||
job_dict['trigger'] = pickle.loads(job_dict['trigger'])
|
||||
job_dict['args'] = pickle.loads(job_dict['args'])
|
||||
job_dict['kwargs'] = pickle.loads(job_dict['kwargs'])
|
||||
job.__setstate__(job_dict)
|
||||
jobs.append(job)
|
||||
except Exception:
|
||||
job_name = job_dict.get('name', '(unknown)')
|
||||
logger.exception('Unable to restore job "%s"', job_name)
|
||||
self.jobs = jobs
|
||||
|
||||
def update_job(self, job):
|
||||
spec = {'_id': job.id}
|
||||
document = {'$set': {'next_run_time': job.next_run_time},
|
||||
'$inc': {'runs': 1}}
|
||||
self.collection.update(spec, document)
|
||||
|
||||
def close(self):
|
||||
self.connection.disconnect()
|
||||
|
||||
def __repr__(self):
|
||||
connection = self.collection.database.connection
|
||||
return '<%s (connection=%s)>' % (self.__class__.__name__, connection)
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Stores jobs in an array in RAM. Provides no persistence support.
|
||||
"""
|
||||
|
||||
from apscheduler.jobstores.base import JobStore
|
||||
|
||||
|
||||
class RAMJobStore(JobStore):
|
||||
def __init__(self):
|
||||
self.jobs = []
|
||||
|
||||
def add_job(self, job):
|
||||
self.jobs.append(job)
|
||||
|
||||
def update_job(self, job):
|
||||
pass
|
||||
|
||||
def remove_job(self, job):
|
||||
self.jobs.remove(job)
|
||||
|
||||
def load_jobs(self):
|
||||
pass
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % (self.__class__.__name__)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import six
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
from apscheduler.util import 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 redis import StrictRedis
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('RedisJobStore requires redis installed')
|
||||
|
||||
|
||||
class RedisJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis.
|
||||
|
||||
Plugin alias: ``redis``
|
||||
|
||||
:param int db: the database number 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 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',
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
|
||||
super(RedisJobStore, self).__init__()
|
||||
|
||||
if db is None:
|
||||
raise ValueError('The "db" parameter must not be empty')
|
||||
if not jobs_key:
|
||||
raise ValueError('The "jobs_key" parameter must not be empty')
|
||||
if not run_times_key:
|
||||
raise ValueError('The "run_times_key" parameter must not be empty')
|
||||
|
||||
self.pickle_protocol = pickle_protocol
|
||||
self.jobs_key = jobs_key
|
||||
self.run_times_key = run_times_key
|
||||
self.redis = StrictRedis(db=int(db), **connect_args)
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
job_state = self.redis.hget(self.jobs_key, job_id)
|
||||
return self._reconstitute_job(job_state) if job_state else None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
timestamp = datetime_to_utc_timestamp(now)
|
||||
job_ids = self.redis.zrangebyscore(self.run_times_key, 0, timestamp)
|
||||
if job_ids:
|
||||
job_states = self.redis.hmget(self.jobs_key, *job_ids)
|
||||
return self._reconstitute_jobs(six.moves.zip(job_ids, job_states))
|
||||
return []
|
||||
|
||||
def get_next_run_time(self):
|
||||
next_run_time = self.redis.zrange(self.run_times_key, 0, 0, withscores=True)
|
||||
if next_run_time:
|
||||
return utc_timestamp_to_datetime(next_run_time[0][1])
|
||||
|
||||
def get_all_jobs(self):
|
||||
job_states = self.redis.hgetall(self.jobs_key)
|
||||
jobs = self._reconstitute_jobs(six.iteritems(job_states))
|
||||
return sorted(jobs, key=lambda job: job.next_run_time)
|
||||
|
||||
def add_job(self, job):
|
||||
if self.redis.hexists(self.jobs_key, job.id):
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.multi()
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
pipe.execute()
|
||||
|
||||
def update_job(self, job):
|
||||
if not self.redis.hexists(self.jobs_key, job.id):
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
if job.next_run_time:
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
else:
|
||||
pipe.zrem(self.run_times_key, job.id)
|
||||
pipe.execute()
|
||||
|
||||
def remove_job(self, job_id):
|
||||
if not self.redis.hexists(self.jobs_key, job_id):
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.hdel(self.jobs_key, job_id)
|
||||
pipe.zrem(self.run_times_key, job_id)
|
||||
pipe.execute()
|
||||
|
||||
def remove_all_jobs(self):
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.delete(self.jobs_key)
|
||||
pipe.delete(self.run_times_key)
|
||||
pipe.execute()
|
||||
|
||||
def shutdown(self):
|
||||
self.redis.connection_pool.disconnect()
|
||||
|
||||
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 _reconstitute_jobs(self, job_states):
|
||||
jobs = []
|
||||
failed_job_ids = []
|
||||
for job_id, job_state in job_states:
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(job_state))
|
||||
except:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
||||
failed_job_ids.append(job_id)
|
||||
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.hdel(self.jobs_key, *failed_job_ids)
|
||||
pipe.zrem(self.run_times_key, *failed_job_ids)
|
||||
pipe.execute()
|
||||
|
||||
return jobs
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s>' % self.__class__.__name__
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Stores jobs in a file governed by the :mod:`shelve` module.
|
||||
"""
|
||||
|
||||
import shelve
|
||||
import pickle
|
||||
import random
|
||||
import logging
|
||||
|
||||
from apscheduler.jobstores.base import JobStore
|
||||
from apscheduler.job import Job
|
||||
from apscheduler.util import itervalues
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShelveJobStore(JobStore):
|
||||
MAX_ID = 1000000
|
||||
|
||||
def __init__(self, path, pickle_protocol=pickle.HIGHEST_PROTOCOL):
|
||||
self.jobs = []
|
||||
self.path = path
|
||||
self.pickle_protocol = pickle_protocol
|
||||
self.store = shelve.open(path, 'c', self.pickle_protocol)
|
||||
|
||||
def _generate_id(self):
|
||||
id = None
|
||||
while not id:
|
||||
id = str(random.randint(1, self.MAX_ID))
|
||||
if not id in self.store:
|
||||
return id
|
||||
|
||||
def add_job(self, job):
|
||||
job.id = self._generate_id()
|
||||
self.jobs.append(job)
|
||||
self.store[job.id] = job.__getstate__()
|
||||
|
||||
def update_job(self, job):
|
||||
job_dict = self.store[job.id]
|
||||
job_dict['next_run_time'] = job.next_run_time
|
||||
job_dict['runs'] = job.runs
|
||||
self.store[job.id] = job_dict
|
||||
|
||||
def remove_job(self, job):
|
||||
del self.store[job.id]
|
||||
self.jobs.remove(job)
|
||||
|
||||
def load_jobs(self):
|
||||
jobs = []
|
||||
for job_dict in itervalues(self.store):
|
||||
try:
|
||||
job = Job.__new__(Job)
|
||||
job.__setstate__(job_dict)
|
||||
jobs.append(job)
|
||||
except Exception:
|
||||
job_name = job_dict.get('name', '(unknown)')
|
||||
logger.exception('Unable to restore job "%s"', job_name)
|
||||
|
||||
self.jobs = jobs
|
||||
|
||||
def close(self):
|
||||
self.store.close()
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (path=%s)>' % (self.__class__.__name__, self.path)
|
||||
@@ -0,0 +1,137 @@
|
||||
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 sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
||||
|
||||
|
||||
class SQLAlchemyJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a database table using SQLAlchemy. The table will be created if it doesn't exist in the database.
|
||||
|
||||
Plugin alias: ``sqlalchemy``
|
||||
|
||||
:param str url: connection string (see `SQLAlchemy documentation
|
||||
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
|
||||
on this)
|
||||
:param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url``
|
||||
: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 int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
||||
"""
|
||||
|
||||
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
|
||||
pickle_protocol=pickle.HIGHEST_PROTOCOL):
|
||||
super(SQLAlchemyJobStore, self).__init__()
|
||||
self.pickle_protocol = pickle_protocol
|
||||
metadata = maybe_ref(metadata) or MetaData()
|
||||
|
||||
if engine:
|
||||
self.engine = maybe_ref(engine)
|
||||
elif url:
|
||||
self.engine = create_engine(url)
|
||||
else:
|
||||
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
|
||||
self.jobs_t = Table(
|
||||
tablename, metadata,
|
||||
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
|
||||
Column('next_run_time', Float(25), index=True),
|
||||
Column('job_state', LargeBinary, nullable=False)
|
||||
)
|
||||
|
||||
self.jobs_t.create(self.engine, True)
|
||||
|
||||
def lookup_job(self, 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()
|
||||
return self._reconstitute_job(job_state) if job_state else None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
timestamp = datetime_to_utc_timestamp(now)
|
||||
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
||||
|
||||
def get_next_run_time(self):
|
||||
selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\
|
||||
order_by(self.jobs_t.c.next_run_time).limit(1)
|
||||
next_run_time = self.engine.execute(selectable).scalar()
|
||||
return utc_timestamp_to_datetime(next_run_time)
|
||||
|
||||
def get_all_jobs(self):
|
||||
return self._get_jobs()
|
||||
|
||||
def add_job(self, job):
|
||||
insert = self.jobs_t.insert().values(**{
|
||||
'id': job.id,
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||
})
|
||||
try:
|
||||
self.engine.execute(insert)
|
||||
except IntegrityError:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
def update_job(self, job):
|
||||
update = self.jobs_t.update().values(**{
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||
}).where(self.jobs_t.c.id == job.id)
|
||||
result = self.engine.execute(update)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
||||
result = self.engine.execute(delete)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
delete = self.jobs_t.delete()
|
||||
self.engine.execute(delete)
|
||||
|
||||
def shutdown(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def _reconstitute_job(self, job_state):
|
||||
job_state = pickle.loads(job_state)
|
||||
job_state['jobstore'] = self
|
||||
job = Job.__new__(Job)
|
||||
job.__setstate__(job_state)
|
||||
job._scheduler = self._scheduler
|
||||
job._jobstore_alias = self._alias
|
||||
return job
|
||||
|
||||
def _get_jobs(self, *conditions):
|
||||
jobs = []
|
||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time)
|
||||
selectable = selectable.where(*conditions) if conditions else selectable
|
||||
failed_job_ids = set()
|
||||
for row in self.engine.execute(selectable):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(row.job_state))
|
||||
except:
|
||||
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
|
||||
if failed_job_ids:
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||
self.engine.execute(delete)
|
||||
|
||||
return jobs
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (url=%s)>' % (self.__class__.__name__, self.engine.url)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
Stores jobs in a database table using SQLAlchemy.
|
||||
"""
|
||||
import pickle
|
||||
import logging
|
||||
|
||||
from apscheduler.jobstores.base import JobStore
|
||||
from apscheduler.job import Job
|
||||
|
||||
try:
|
||||
from sqlalchemy import *
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SQLAlchemyJobStore(JobStore):
|
||||
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs',
|
||||
metadata=None, pickle_protocol=pickle.HIGHEST_PROTOCOL):
|
||||
self.jobs = []
|
||||
self.pickle_protocol = pickle_protocol
|
||||
|
||||
if engine:
|
||||
self.engine = engine
|
||||
elif url:
|
||||
self.engine = create_engine(url)
|
||||
else:
|
||||
raise ValueError('Need either "engine" or "url" defined')
|
||||
|
||||
self.jobs_t = Table(tablename, metadata or MetaData(),
|
||||
Column('id', Integer,
|
||||
Sequence(tablename + '_id_seq', optional=True),
|
||||
primary_key=True),
|
||||
Column('trigger', PickleType(pickle_protocol, mutable=False),
|
||||
nullable=False),
|
||||
Column('func_ref', String(1024), nullable=False),
|
||||
Column('args', PickleType(pickle_protocol, mutable=False),
|
||||
nullable=False),
|
||||
Column('kwargs', PickleType(pickle_protocol, mutable=False),
|
||||
nullable=False),
|
||||
Column('name', Unicode(1024), unique=True),
|
||||
Column('misfire_grace_time', Integer, nullable=False),
|
||||
Column('coalesce', Boolean, nullable=False),
|
||||
Column('max_runs', Integer),
|
||||
Column('max_instances', Integer),
|
||||
Column('next_run_time', DateTime, nullable=False),
|
||||
Column('runs', BigInteger))
|
||||
|
||||
self.jobs_t.create(self.engine, True)
|
||||
|
||||
def add_job(self, job):
|
||||
job_dict = job.__getstate__()
|
||||
result = self.engine.execute(self.jobs_t.insert().values(**job_dict))
|
||||
job.id = result.inserted_primary_key[0]
|
||||
self.jobs.append(job)
|
||||
|
||||
def remove_job(self, job):
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job.id)
|
||||
self.engine.execute(delete)
|
||||
self.jobs.remove(job)
|
||||
|
||||
def load_jobs(self):
|
||||
jobs = []
|
||||
for row in self.engine.execute(select([self.jobs_t])):
|
||||
try:
|
||||
job = Job.__new__(Job)
|
||||
job_dict = dict(row.items())
|
||||
job.__setstate__(job_dict)
|
||||
jobs.append(job)
|
||||
except Exception:
|
||||
job_name = job_dict.get('name', '(unknown)')
|
||||
logger.exception('Unable to restore job "%s"', job_name)
|
||||
self.jobs = jobs
|
||||
|
||||
def update_job(self, job):
|
||||
job_dict = job.__getstate__()
|
||||
update = self.jobs_t.update().where(self.jobs_t.c.id == job.id).\
|
||||
values(next_run_time=job_dict['next_run_time'],
|
||||
runs=job_dict['runs'])
|
||||
self.engine.execute(update)
|
||||
|
||||
def close(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (url=%s)>' % (self.__class__.__name__, self.engine.url)
|
||||
@@ -1,559 +0,0 @@
|
||||
"""
|
||||
This module is the main part of the library. It houses the Scheduler class
|
||||
and related exceptions.
|
||||
"""
|
||||
|
||||
from threading import Thread, Event, Lock
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
import os
|
||||
import sys
|
||||
|
||||
from apscheduler.util import *
|
||||
from apscheduler.triggers import SimpleTrigger, IntervalTrigger, CronTrigger
|
||||
from apscheduler.jobstores.ram_store import RAMJobStore
|
||||
from apscheduler.job import Job, MaxInstancesReachedError
|
||||
from apscheduler.events import *
|
||||
from apscheduler.threadpool import ThreadPool
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerAlreadyRunningError(Exception):
|
||||
"""
|
||||
Raised when attempting to start or configure the scheduler when it's
|
||||
already running.
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
return 'Scheduler is already running'
|
||||
|
||||
|
||||
class Scheduler(object):
|
||||
"""
|
||||
This class is responsible for scheduling jobs and triggering
|
||||
their execution.
|
||||
"""
|
||||
|
||||
_stopped = False
|
||||
_thread = None
|
||||
|
||||
def __init__(self, gconfig={}, **options):
|
||||
self._wakeup = Event()
|
||||
self._jobstores = {}
|
||||
self._jobstores_lock = Lock()
|
||||
self._listeners = []
|
||||
self._listeners_lock = Lock()
|
||||
self._pending_jobs = []
|
||||
self.configure(gconfig, **options)
|
||||
|
||||
def configure(self, gconfig={}, **options):
|
||||
"""
|
||||
Reconfigures the scheduler with the given options. Can only be done
|
||||
when the scheduler isn't running.
|
||||
"""
|
||||
if self.running:
|
||||
raise SchedulerAlreadyRunningError
|
||||
|
||||
# Set general options
|
||||
config = combine_opts(gconfig, 'apscheduler.', options)
|
||||
self.misfire_grace_time = int(config.pop('misfire_grace_time', 1))
|
||||
self.coalesce = asbool(config.pop('coalesce', True))
|
||||
self.daemonic = asbool(config.pop('daemonic', True))
|
||||
|
||||
# Configure the thread pool
|
||||
if 'threadpool' in config:
|
||||
self._threadpool = maybe_ref(config['threadpool'])
|
||||
else:
|
||||
threadpool_opts = combine_opts(config, 'threadpool.')
|
||||
self._threadpool = ThreadPool(**threadpool_opts)
|
||||
|
||||
# Configure job stores
|
||||
jobstore_opts = combine_opts(config, 'jobstore.')
|
||||
jobstores = {}
|
||||
for key, value in jobstore_opts.items():
|
||||
store_name, option = key.split('.', 1)
|
||||
opts_dict = jobstores.setdefault(store_name, {})
|
||||
opts_dict[option] = value
|
||||
|
||||
for alias, opts in jobstores.items():
|
||||
classname = opts.pop('class')
|
||||
cls = maybe_ref(classname)
|
||||
jobstore = cls(**opts)
|
||||
self.add_jobstore(jobstore, alias, True)
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Starts the scheduler in a new thread.
|
||||
"""
|
||||
if self.running:
|
||||
raise SchedulerAlreadyRunningError
|
||||
|
||||
# Create a RAMJobStore as the default if there is no default job store
|
||||
if not 'default' in self._jobstores:
|
||||
self.add_jobstore(RAMJobStore(), 'default', True)
|
||||
|
||||
# Schedule all pending jobs
|
||||
for job, jobstore in self._pending_jobs:
|
||||
self._real_add_job(job, jobstore, False)
|
||||
del self._pending_jobs[:]
|
||||
|
||||
self._stopped = False
|
||||
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
||||
self._thread.setDaemon(self.daemonic)
|
||||
self._thread.start()
|
||||
|
||||
def shutdown(self, wait=True, shutdown_threadpool=True):
|
||||
"""
|
||||
Shuts down the scheduler and terminates the thread.
|
||||
Does not interrupt any currently running jobs.
|
||||
|
||||
:param wait: ``True`` to wait until all currently executing jobs have
|
||||
finished (if ``shutdown_threadpool`` is also ``True``)
|
||||
:param shutdown_threadpool: ``True`` to shut down the thread pool
|
||||
"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self._stopped = True
|
||||
self._wakeup.set()
|
||||
|
||||
# Shut down the thread pool
|
||||
if shutdown_threadpool:
|
||||
self._threadpool.shutdown(wait)
|
||||
|
||||
# Wait until the scheduler thread terminates
|
||||
self._thread.join()
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return not self._stopped and self._thread and self._thread.isAlive()
|
||||
|
||||
def add_jobstore(self, jobstore, alias, quiet=False):
|
||||
"""
|
||||
Adds a job store to this scheduler.
|
||||
|
||||
:param jobstore: job store to be added
|
||||
:param alias: alias for the job store
|
||||
:param quiet: True to suppress scheduler thread wakeup
|
||||
:type jobstore: instance of
|
||||
:class:`~apscheduler.jobstores.base.JobStore`
|
||||
:type alias: str
|
||||
"""
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
if alias in self._jobstores:
|
||||
raise KeyError('Alias "%s" is already in use' % alias)
|
||||
self._jobstores[alias] = jobstore
|
||||
jobstore.load_jobs()
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
# Notify listeners that a new job store has been added
|
||||
self._notify_listeners(JobStoreEvent(EVENT_JOBSTORE_ADDED, alias))
|
||||
|
||||
# Notify the scheduler so it can scan the new job store for jobs
|
||||
if not quiet:
|
||||
self._wakeup.set()
|
||||
|
||||
def remove_jobstore(self, alias):
|
||||
"""
|
||||
Removes the job store by the given alias from this scheduler.
|
||||
|
||||
:type alias: str
|
||||
"""
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
try:
|
||||
del self._jobstores[alias]
|
||||
except KeyError:
|
||||
raise KeyError('No such job store: %s' % alias)
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
# Notify listeners that a job store has been removed
|
||||
self._notify_listeners(JobStoreEvent(EVENT_JOBSTORE_REMOVED, alias))
|
||||
|
||||
def add_listener(self, callback, mask=EVENT_ALL):
|
||||
"""
|
||||
Adds a listener for scheduler events. When a matching event occurs,
|
||||
``callback`` is executed with the event object as its sole argument.
|
||||
If the ``mask`` parameter is not provided, the callback will receive
|
||||
events of all types.
|
||||
|
||||
:param callback: any callable that takes one argument
|
||||
:param mask: bitmask that indicates which events should be listened to
|
||||
"""
|
||||
self._listeners_lock.acquire()
|
||||
try:
|
||||
self._listeners.append((callback, mask))
|
||||
finally:
|
||||
self._listeners_lock.release()
|
||||
|
||||
def remove_listener(self, callback):
|
||||
"""
|
||||
Removes a previously added event listener.
|
||||
"""
|
||||
self._listeners_lock.acquire()
|
||||
try:
|
||||
for i, (cb, _) in enumerate(self._listeners):
|
||||
if callback == cb:
|
||||
del self._listeners[i]
|
||||
finally:
|
||||
self._listeners_lock.release()
|
||||
|
||||
def _notify_listeners(self, event):
|
||||
self._listeners_lock.acquire()
|
||||
try:
|
||||
listeners = tuple(self._listeners)
|
||||
finally:
|
||||
self._listeners_lock.release()
|
||||
|
||||
for cb, mask in listeners:
|
||||
if event.code & mask:
|
||||
try:
|
||||
cb(event)
|
||||
except:
|
||||
logger.exception('Error notifying listener')
|
||||
|
||||
def _real_add_job(self, job, jobstore, wakeup):
|
||||
job.compute_next_run_time(datetime.now())
|
||||
if not job.next_run_time:
|
||||
raise ValueError('Not adding job since it would never be run')
|
||||
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
try:
|
||||
store = self._jobstores[jobstore]
|
||||
except KeyError:
|
||||
raise KeyError('No such job store: %s' % jobstore)
|
||||
store.add_job(job)
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
# Notify listeners that a new job has been added
|
||||
event = JobStoreEvent(EVENT_JOBSTORE_JOB_ADDED, jobstore, job)
|
||||
self._notify_listeners(event)
|
||||
|
||||
logger.info('Added job "%s" to job store "%s"', job, jobstore)
|
||||
|
||||
# Notify the scheduler about the new job
|
||||
if wakeup:
|
||||
self._wakeup.set()
|
||||
|
||||
def add_job(self, trigger, func, args, kwargs, jobstore='default',
|
||||
**options):
|
||||
"""
|
||||
Adds the given job to the job list and notifies the scheduler thread.
|
||||
|
||||
:param trigger: alias of the job store to store the job in
|
||||
:param func: callable to run at the given time
|
||||
:param args: list of positional arguments to call func with
|
||||
:param kwargs: dict of keyword arguments to call func with
|
||||
:param jobstore: alias of the job store to store the job in
|
||||
:rtype: :class:`~apscheduler.job.Job`
|
||||
"""
|
||||
job = Job(trigger, func, args or [], kwargs or {},
|
||||
options.pop('misfire_grace_time', self.misfire_grace_time),
|
||||
options.pop('coalesce', self.coalesce), **options)
|
||||
if not self.running:
|
||||
self._pending_jobs.append((job, jobstore))
|
||||
logger.info('Adding job tentatively -- it will be properly '
|
||||
'scheduled when the scheduler starts')
|
||||
else:
|
||||
self._real_add_job(job, jobstore, True)
|
||||
return job
|
||||
|
||||
def _remove_job(self, job, alias, jobstore):
|
||||
jobstore.remove_job(job)
|
||||
|
||||
# Notify listeners that a job has been removed
|
||||
event = JobStoreEvent(EVENT_JOBSTORE_JOB_REMOVED, alias, job)
|
||||
self._notify_listeners(event)
|
||||
|
||||
logger.info('Removed job "%s"', job)
|
||||
|
||||
def add_date_job(self, func, date, args=None, kwargs=None, **options):
|
||||
"""
|
||||
Schedules a job to be completed on a specific date and time.
|
||||
|
||||
:param func: callable to run at the given time
|
||||
:param date: the date/time to run the job at
|
||||
:param name: name of the job
|
||||
:param jobstore: stored the job in the named (or given) job store
|
||||
:param misfire_grace_time: seconds after the designated run time that
|
||||
the job is still allowed to be run
|
||||
:type date: :class:`datetime.date`
|
||||
:rtype: :class:`~apscheduler.job.Job`
|
||||
"""
|
||||
trigger = SimpleTrigger(date)
|
||||
return self.add_job(trigger, func, args, kwargs, **options)
|
||||
|
||||
def add_interval_job(self, func, weeks=0, days=0, hours=0, minutes=0,
|
||||
seconds=0, start_date=None, args=None, kwargs=None,
|
||||
**options):
|
||||
"""
|
||||
Schedules a job to be completed on specified intervals.
|
||||
|
||||
:param func: callable to run
|
||||
:param weeks: number of weeks to wait
|
||||
:param days: number of days to wait
|
||||
:param hours: number of hours to wait
|
||||
:param minutes: number of minutes to wait
|
||||
:param seconds: number of seconds to wait
|
||||
:param start_date: when to first execute the job and start the
|
||||
counter (default is after the given interval)
|
||||
:param args: list of positional arguments to call func with
|
||||
:param kwargs: dict of keyword arguments to call func with
|
||||
:param name: name of the job
|
||||
:param jobstore: alias of the job store to add the job to
|
||||
:param misfire_grace_time: seconds after the designated run time that
|
||||
the job is still allowed to be run
|
||||
:rtype: :class:`~apscheduler.job.Job`
|
||||
"""
|
||||
interval = timedelta(weeks=weeks, days=days, hours=hours,
|
||||
minutes=minutes, seconds=seconds)
|
||||
trigger = IntervalTrigger(interval, start_date)
|
||||
return self.add_job(trigger, func, args, kwargs, **options)
|
||||
|
||||
def add_cron_job(self, func, year='*', month='*', day='*', week='*',
|
||||
day_of_week='*', hour='*', minute='*', second='*',
|
||||
start_date=None, args=None, kwargs=None, **options):
|
||||
"""
|
||||
Schedules a job to be completed on times that match the given
|
||||
expressions.
|
||||
|
||||
:param func: callable to run
|
||||
:param year: year to run on
|
||||
:param month: month to run on (0 = January)
|
||||
:param day: day of month to run on
|
||||
:param week: week of the year to run on
|
||||
:param day_of_week: weekday to run on (0 = Monday)
|
||||
:param hour: hour to run on
|
||||
:param second: second to run on
|
||||
:param args: list of positional arguments to call func with
|
||||
:param kwargs: dict of keyword arguments to call func with
|
||||
:param name: name of the job
|
||||
:param jobstore: alias of the job store to add the job to
|
||||
:param misfire_grace_time: seconds after the designated run time that
|
||||
the job is still allowed to be run
|
||||
:return: the scheduled job
|
||||
:rtype: :class:`~apscheduler.job.Job`
|
||||
"""
|
||||
trigger = CronTrigger(year=year, month=month, day=day, week=week,
|
||||
day_of_week=day_of_week, hour=hour,
|
||||
minute=minute, second=second,
|
||||
start_date=start_date)
|
||||
return self.add_job(trigger, func, args, kwargs, **options)
|
||||
|
||||
def cron_schedule(self, **options):
|
||||
"""
|
||||
Decorator version of :meth:`add_cron_job`.
|
||||
This decorator does not wrap its host function.
|
||||
Unscheduling decorated functions is possible by passing the ``job``
|
||||
attribute of the scheduled function to :meth:`unschedule_job`.
|
||||
"""
|
||||
def inner(func):
|
||||
func.job = self.add_cron_job(func, **options)
|
||||
return func
|
||||
return inner
|
||||
|
||||
def interval_schedule(self, **options):
|
||||
"""
|
||||
Decorator version of :meth:`add_interval_job`.
|
||||
This decorator does not wrap its host function.
|
||||
Unscheduling decorated functions is possible by passing the ``job``
|
||||
attribute of the scheduled function to :meth:`unschedule_job`.
|
||||
"""
|
||||
def inner(func):
|
||||
func.job = self.add_interval_job(func, **options)
|
||||
return func
|
||||
return inner
|
||||
|
||||
def get_jobs(self):
|
||||
"""
|
||||
Returns a list of all scheduled jobs.
|
||||
|
||||
:return: list of :class:`~apscheduler.job.Job` objects
|
||||
"""
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
jobs = []
|
||||
for jobstore in itervalues(self._jobstores):
|
||||
jobs.extend(jobstore.jobs)
|
||||
return jobs
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
def unschedule_job(self, job):
|
||||
"""
|
||||
Removes a job, preventing it from being run any more.
|
||||
"""
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
for alias, jobstore in iteritems(self._jobstores):
|
||||
if job in list(jobstore.jobs):
|
||||
self._remove_job(job, alias, jobstore)
|
||||
return
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
raise KeyError('Job "%s" is not scheduled in any job store' % job)
|
||||
|
||||
def unschedule_func(self, func):
|
||||
"""
|
||||
Removes all jobs that would execute the given function.
|
||||
"""
|
||||
found = False
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
for alias, jobstore in iteritems(self._jobstores):
|
||||
for job in list(jobstore.jobs):
|
||||
if job.func == func:
|
||||
self._remove_job(job, alias, jobstore)
|
||||
found = True
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
if not found:
|
||||
raise KeyError('The given function is not scheduled in this '
|
||||
'scheduler')
|
||||
|
||||
def print_jobs(self, out=None):
|
||||
"""
|
||||
Prints out a textual listing of all jobs currently scheduled on this
|
||||
scheduler.
|
||||
|
||||
:param out: a file-like object to print to (defaults to **sys.stdout**
|
||||
if nothing is given)
|
||||
"""
|
||||
out = out or sys.stdout
|
||||
job_strs = []
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
for alias, jobstore in iteritems(self._jobstores):
|
||||
job_strs.append('Jobstore %s:' % alias)
|
||||
if jobstore.jobs:
|
||||
for job in jobstore.jobs:
|
||||
job_strs.append(' %s' % job)
|
||||
else:
|
||||
job_strs.append(' No scheduled jobs')
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
out.write(os.linesep.join(job_strs))
|
||||
|
||||
def _run_job(self, job, run_times):
|
||||
"""
|
||||
Acts as a harness that runs the actual job code in a thread.
|
||||
"""
|
||||
for run_time in run_times:
|
||||
# See if the job missed its run time window, and handle possible
|
||||
# misfires accordingly
|
||||
difference = datetime.now() - run_time
|
||||
grace_time = timedelta(seconds=job.misfire_grace_time)
|
||||
if difference > grace_time:
|
||||
# Notify listeners about a missed run
|
||||
event = JobEvent(EVENT_JOB_MISSED, job, run_time)
|
||||
self._notify_listeners(event)
|
||||
logger.warning('Run time of job "%s" was missed by %s',
|
||||
job, difference)
|
||||
else:
|
||||
try:
|
||||
job.add_instance()
|
||||
except MaxInstancesReachedError:
|
||||
event = JobEvent(EVENT_JOB_MISSED, job, run_time)
|
||||
self._notify_listeners(event)
|
||||
logger.warning('Execution of job "%s" skipped: '
|
||||
'maximum number of running instances '
|
||||
'reached (%d)', job, job.max_instances)
|
||||
break
|
||||
|
||||
logger.info('Running job "%s" (scheduled at %s)', job,
|
||||
run_time)
|
||||
|
||||
try:
|
||||
retval = job.func(*job.args, **job.kwargs)
|
||||
except:
|
||||
# Notify listeners about the exception
|
||||
exc, tb = sys.exc_info()[1:]
|
||||
event = JobEvent(EVENT_JOB_ERROR, job, run_time,
|
||||
exception=exc, traceback=tb)
|
||||
self._notify_listeners(event)
|
||||
|
||||
logger.exception('Job "%s" raised an exception', job)
|
||||
else:
|
||||
# Notify listeners about successful execution
|
||||
event = JobEvent(EVENT_JOB_EXECUTED, job, run_time,
|
||||
retval=retval)
|
||||
self._notify_listeners(event)
|
||||
|
||||
logger.info('Job "%s" executed successfully', job)
|
||||
|
||||
job.remove_instance()
|
||||
|
||||
# If coalescing is enabled, don't attempt any further runs
|
||||
if job.coalesce:
|
||||
break
|
||||
|
||||
def _process_jobs(self, now):
|
||||
"""
|
||||
Iterates through jobs in every jobstore, starts pending jobs
|
||||
and figures out the next wakeup time.
|
||||
"""
|
||||
next_wakeup_time = None
|
||||
self._jobstores_lock.acquire()
|
||||
try:
|
||||
for alias, jobstore in iteritems(self._jobstores):
|
||||
for job in tuple(jobstore.jobs):
|
||||
run_times = job.get_run_times(now)
|
||||
if run_times:
|
||||
self._threadpool.submit(self._run_job, job, run_times)
|
||||
|
||||
# Increase the job's run count
|
||||
if job.coalesce:
|
||||
job.runs += 1
|
||||
else:
|
||||
job.runs += len(run_times)
|
||||
|
||||
# Update the job, but don't keep finished jobs around
|
||||
if job.compute_next_run_time(now + timedelta(microseconds=1)):
|
||||
jobstore.update_job(job)
|
||||
else:
|
||||
self._remove_job(job, alias, jobstore)
|
||||
|
||||
if not next_wakeup_time:
|
||||
next_wakeup_time = job.next_run_time
|
||||
elif job.next_run_time:
|
||||
next_wakeup_time = min(next_wakeup_time,
|
||||
job.next_run_time)
|
||||
return next_wakeup_time
|
||||
finally:
|
||||
self._jobstores_lock.release()
|
||||
|
||||
def _main_loop(self):
|
||||
"""Executes jobs on schedule."""
|
||||
|
||||
logger.info('Scheduler started')
|
||||
self._notify_listeners(SchedulerEvent(EVENT_SCHEDULER_START))
|
||||
|
||||
self._wakeup.clear()
|
||||
while not self._stopped:
|
||||
logger.debug('Looking for jobs to run')
|
||||
now = datetime.now()
|
||||
next_wakeup_time = self._process_jobs(now)
|
||||
|
||||
# Sleep until the next job is scheduled to be run,
|
||||
# a new job is added or the scheduler is stopped
|
||||
if next_wakeup_time is not None:
|
||||
wait_seconds = time_difference(next_wakeup_time, now)
|
||||
logger.debug('Next wakeup is due at %s (in %f seconds)',
|
||||
next_wakeup_time, wait_seconds)
|
||||
self._wakeup.wait(wait_seconds)
|
||||
else:
|
||||
logger.debug('No jobs; waiting until a job is added')
|
||||
self._wakeup.wait()
|
||||
self._wakeup.clear()
|
||||
|
||||
logger.info('Scheduler has been shut down')
|
||||
self._notify_listeners(SchedulerEvent(EVENT_SCHEDULER_SHUTDOWN))
|
||||
@@ -0,0 +1,12 @@
|
||||
class SchedulerAlreadyRunningError(Exception):
|
||||
"""Raised when attempting to start or configure the scheduler when it's already running."""
|
||||
|
||||
def __str__(self):
|
||||
return 'Scheduler is already running'
|
||||
|
||||
|
||||
class SchedulerNotRunningError(Exception):
|
||||
"""Raised when attempting to shutdown the scheduler when it's not running."""
|
||||
|
||||
def __str__(self):
|
||||
return 'Scheduler is not running'
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import absolute_import
|
||||
from functools import wraps
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
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):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class AsyncIOScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs on an asyncio (:pep:`3156`) event loop.
|
||||
|
||||
Extra options:
|
||||
|
||||
============== =============================================================
|
||||
``event_loop`` AsyncIO event loop to use (defaults to the global event loop)
|
||||
============== =============================================================
|
||||
"""
|
||||
|
||||
_eventloop = None
|
||||
_timeout = None
|
||||
|
||||
def start(self):
|
||||
super(AsyncIOScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_event_loop
|
||||
def shutdown(self, wait=True):
|
||||
super(AsyncIOScheduler, self).shutdown(wait)
|
||||
self._stop_timer()
|
||||
|
||||
def _configure(self, config):
|
||||
self._eventloop = maybe_ref(config.pop('event_loop', None)) or asyncio.get_event_loop()
|
||||
super(AsyncIOScheduler, self)._configure(config)
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
self._stop_timer()
|
||||
if wait_seconds is not None:
|
||||
self._timeout = self._eventloop.call_later(wait_seconds, self.wakeup)
|
||||
|
||||
def _stop_timer(self):
|
||||
if self._timeout:
|
||||
self._timeout.cancel()
|
||||
del self._timeout
|
||||
|
||||
@run_in_event_loop
|
||||
def wakeup(self):
|
||||
self._stop_timer()
|
||||
wait_seconds = self._process_jobs()
|
||||
self._start_timer(wait_seconds)
|
||||
|
||||
def _create_default_executor(self):
|
||||
from apscheduler.executors.asyncio import AsyncIOExecutor
|
||||
return AsyncIOExecutor()
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import absolute_import
|
||||
from threading import Thread, Event
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||
from apscheduler.util import asbool
|
||||
|
||||
|
||||
class BackgroundScheduler(BlockingScheduler):
|
||||
"""
|
||||
A scheduler that runs in the background using a separate thread
|
||||
(:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will return immediately).
|
||||
|
||||
Extra options:
|
||||
|
||||
========== ============================================================================================
|
||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
|
||||
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||
for further details)
|
||||
========== ============================================================================================
|
||||
"""
|
||||
|
||||
_thread = None
|
||||
|
||||
def _configure(self, config):
|
||||
self._daemon = asbool(config.pop('daemon', True))
|
||||
super(BackgroundScheduler, self)._configure(config)
|
||||
|
||||
def start(self):
|
||||
BaseScheduler.start(self)
|
||||
self._event = Event()
|
||||
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
||||
self._thread.daemon = self._daemon
|
||||
self._thread.start()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(BackgroundScheduler, self).shutdown(wait)
|
||||
self._thread.join()
|
||||
del self._thread
|
||||
@@ -0,0 +1,845 @@
|
||||
from __future__ import print_function
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from collections import MutableMapping
|
||||
from threading import RLock
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
import sys
|
||||
|
||||
from pkg_resources import iter_entry_points
|
||||
from tzlocal import get_localzone
|
||||
import six
|
||||
|
||||
from apscheduler.schedulers import SchedulerAlreadyRunningError, SchedulerNotRunningError
|
||||
from apscheduler.executors.base import MaxInstancesReachedError, BaseExecutor
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.jobstores.base import ConflictingIdError, JobLookupError, BaseJobStore
|
||||
from apscheduler.jobstores.memory import MemoryJobStore
|
||||
from apscheduler.job import Job
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import asbool, asint, astimezone, maybe_ref, timedelta_seconds, undefined
|
||||
from apscheduler.events import (
|
||||
SchedulerEvent, JobEvent, EVENT_SCHEDULER_START, EVENT_SCHEDULER_SHUTDOWN, EVENT_JOBSTORE_ADDED,
|
||||
EVENT_JOBSTORE_REMOVED, EVENT_ALL, EVENT_JOB_MODIFIED, EVENT_JOB_REMOVED, EVENT_JOB_ADDED, EVENT_EXECUTOR_ADDED,
|
||||
EVENT_EXECUTOR_REMOVED, EVENT_ALL_JOBS_REMOVED)
|
||||
|
||||
|
||||
class BaseScheduler(six.with_metaclass(ABCMeta)):
|
||||
"""
|
||||
Abstract base class for all schedulers. Takes the following keyword arguments:
|
||||
|
||||
:param str|logging.Logger logger: logger to use for the scheduler's logging (defaults to apscheduler.scheduler)
|
||||
:param str|datetime.tzinfo timezone: the default time zone (defaults to the local timezone)
|
||||
:param dict job_defaults: default values for newly added jobs
|
||||
:param dict jobstores: a dictionary of job store alias -> job store instance or configuration dict
|
||||
:param dict executors: a dictionary of executor alias -> executor instance or configuration dict
|
||||
|
||||
.. seealso:: :ref:`scheduler-config`
|
||||
"""
|
||||
|
||||
_trigger_plugins = dict((ep.name, ep) for ep in iter_entry_points('apscheduler.triggers'))
|
||||
_trigger_classes = {}
|
||||
_executor_plugins = dict((ep.name, ep) for ep in iter_entry_points('apscheduler.executors'))
|
||||
_executor_classes = {}
|
||||
_jobstore_plugins = dict((ep.name, ep) for ep in iter_entry_points('apscheduler.jobstores'))
|
||||
_jobstore_classes = {}
|
||||
_stopped = True
|
||||
|
||||
#
|
||||
# Public API
|
||||
#
|
||||
|
||||
def __init__(self, gconfig={}, **options):
|
||||
super(BaseScheduler, self).__init__()
|
||||
self._executors = {}
|
||||
self._executors_lock = self._create_lock()
|
||||
self._jobstores = {}
|
||||
self._jobstores_lock = self._create_lock()
|
||||
self._listeners = []
|
||||
self._listeners_lock = self._create_lock()
|
||||
self._pending_jobs = []
|
||||
self.configure(gconfig, **options)
|
||||
|
||||
def configure(self, gconfig={}, prefix='apscheduler.', **options):
|
||||
"""
|
||||
Reconfigures the scheduler with the given options. Can only be done when the scheduler isn't running.
|
||||
|
||||
:param dict gconfig: a "global" configuration dictionary whose values can be overridden by keyword arguments to
|
||||
this method
|
||||
:param str|unicode prefix: pick only those keys from ``gconfig`` that are prefixed with this string
|
||||
(pass an empty string or ``None`` to use all keys)
|
||||
:raises SchedulerAlreadyRunningError: if the scheduler is already running
|
||||
"""
|
||||
|
||||
if self.running:
|
||||
raise SchedulerAlreadyRunningError
|
||||
|
||||
# If a non-empty prefix was given, strip it from the keys in the global configuration dict
|
||||
if prefix:
|
||||
prefixlen = len(prefix)
|
||||
gconfig = dict((key[prefixlen:], value) for key, value in six.iteritems(gconfig) if key.startswith(prefix))
|
||||
|
||||
# Create a structure from the dotted options (e.g. "a.b.c = d" -> {'a': {'b': {'c': 'd'}}})
|
||||
config = {}
|
||||
for key, value in six.iteritems(gconfig):
|
||||
parts = key.split('.')
|
||||
parent = config
|
||||
key = parts.pop(0)
|
||||
while parts:
|
||||
parent = parent.setdefault(key, {})
|
||||
key = parts.pop(0)
|
||||
parent[key] = value
|
||||
|
||||
# Override any options with explicit keyword arguments
|
||||
config.update(options)
|
||||
self._configure(config)
|
||||
|
||||
@abstractmethod
|
||||
def start(self):
|
||||
"""
|
||||
Starts the scheduler. The details of this process depend on the implementation.
|
||||
|
||||
:raises SchedulerAlreadyRunningError: if the scheduler is already running
|
||||
"""
|
||||
|
||||
if self.running:
|
||||
raise SchedulerAlreadyRunningError
|
||||
|
||||
with self._executors_lock:
|
||||
# Create a default executor if nothing else is configured
|
||||
if 'default' not in self._executors:
|
||||
self.add_executor(self._create_default_executor(), 'default')
|
||||
|
||||
# Start all the executors
|
||||
for alias, executor in six.iteritems(self._executors):
|
||||
executor.start(self, alias)
|
||||
|
||||
with self._jobstores_lock:
|
||||
# Create a default job store if nothing else is configured
|
||||
if 'default' not in self._jobstores:
|
||||
self.add_jobstore(self._create_default_jobstore(), 'default')
|
||||
|
||||
# Start all the job stores
|
||||
for alias, store in six.iteritems(self._jobstores):
|
||||
store.start(self, alias)
|
||||
|
||||
# Schedule all pending jobs
|
||||
for job, jobstore_alias, replace_existing in self._pending_jobs:
|
||||
self._real_add_job(job, jobstore_alias, replace_existing, False)
|
||||
del self._pending_jobs[:]
|
||||
|
||||
self._stopped = False
|
||||
self._logger.info('Scheduler started')
|
||||
|
||||
# Notify listeners that the scheduler has been started
|
||||
self._dispatch_event(SchedulerEvent(EVENT_SCHEDULER_START))
|
||||
|
||||
@abstractmethod
|
||||
def shutdown(self, wait=True):
|
||||
"""
|
||||
Shuts down the scheduler. Does not interrupt any currently running jobs.
|
||||
|
||||
:param bool wait: ``True`` to wait until all currently executing jobs have finished
|
||||
:raises SchedulerNotRunningError: if the scheduler has not been started yet
|
||||
"""
|
||||
|
||||
if not self.running:
|
||||
raise SchedulerNotRunningError
|
||||
|
||||
self._stopped = True
|
||||
|
||||
# Shut down all executors
|
||||
for executor in six.itervalues(self._executors):
|
||||
executor.shutdown(wait)
|
||||
|
||||
# Shut down all job stores
|
||||
for jobstore in six.itervalues(self._jobstores):
|
||||
jobstore.shutdown()
|
||||
|
||||
self._logger.info('Scheduler has been shut down')
|
||||
self._dispatch_event(SchedulerEvent(EVENT_SCHEDULER_SHUTDOWN))
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return not self._stopped
|
||||
|
||||
def add_executor(self, executor, alias='default', **executor_opts):
|
||||
"""
|
||||
Adds an executor to this scheduler. Any extra keyword arguments will be passed to the executor plugin's
|
||||
constructor, assuming that the first argument is the name of an executor plugin.
|
||||
|
||||
:param str|unicode|apscheduler.executors.base.BaseExecutor executor: either an executor instance or the name of
|
||||
an executor plugin
|
||||
:param str|unicode alias: alias for the scheduler
|
||||
:raises ValueError: if there is already an executor by the given alias
|
||||
"""
|
||||
|
||||
with self._executors_lock:
|
||||
if alias in self._executors:
|
||||
raise ValueError('This scheduler already has an executor by the alias of "%s"' % alias)
|
||||
|
||||
if isinstance(executor, BaseExecutor):
|
||||
self._executors[alias] = executor
|
||||
elif isinstance(executor, six.string_types):
|
||||
self._executors[alias] = executor = self._create_plugin_instance('executor', executor, executor_opts)
|
||||
else:
|
||||
raise TypeError('Expected an executor instance or a string, got %s instead' %
|
||||
executor.__class__.__name__)
|
||||
|
||||
# Start the executor right away if the scheduler is running
|
||||
if self.running:
|
||||
executor.start(self)
|
||||
|
||||
self._dispatch_event(SchedulerEvent(EVENT_EXECUTOR_ADDED, alias))
|
||||
|
||||
def remove_executor(self, alias, shutdown=True):
|
||||
"""
|
||||
Removes the executor by the given alias from this scheduler.
|
||||
|
||||
:param str|unicode alias: alias of the executor
|
||||
:param bool shutdown: ``True`` to shut down the executor after removing it
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
executor = self._lookup_executor(alias)
|
||||
del self._executors[alias]
|
||||
|
||||
if shutdown:
|
||||
executor.shutdown()
|
||||
|
||||
self._dispatch_event(SchedulerEvent(EVENT_EXECUTOR_REMOVED, alias))
|
||||
|
||||
def add_jobstore(self, jobstore, alias='default', **jobstore_opts):
|
||||
"""
|
||||
Adds a job store to this scheduler. Any extra keyword arguments will be passed to the job store plugin's
|
||||
constructor, assuming that the first argument is the name of a job store plugin.
|
||||
|
||||
:param str|unicode|apscheduler.jobstores.base.BaseJobStore jobstore: job store to be added
|
||||
:param str|unicode alias: alias for the job store
|
||||
:raises ValueError: if there is already a job store by the given alias
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
if alias in self._jobstores:
|
||||
raise ValueError('This scheduler already has a job store by the alias of "%s"' % alias)
|
||||
|
||||
if isinstance(jobstore, BaseJobStore):
|
||||
self._jobstores[alias] = jobstore
|
||||
elif isinstance(jobstore, six.string_types):
|
||||
self._jobstores[alias] = jobstore = self._create_plugin_instance('jobstore', jobstore, jobstore_opts)
|
||||
else:
|
||||
raise TypeError('Expected a job store instance or a string, got %s instead' %
|
||||
jobstore.__class__.__name__)
|
||||
|
||||
# Start the job store right away if the scheduler is running
|
||||
if self.running:
|
||||
jobstore.start(self, alias)
|
||||
|
||||
# Notify listeners that a new job store has been added
|
||||
self._dispatch_event(SchedulerEvent(EVENT_JOBSTORE_ADDED, alias))
|
||||
|
||||
# Notify the scheduler so it can scan the new job store for jobs
|
||||
if self.running:
|
||||
self.wakeup()
|
||||
|
||||
def remove_jobstore(self, alias, shutdown=True):
|
||||
"""
|
||||
Removes the job store by the given alias from this scheduler.
|
||||
|
||||
:param str|unicode alias: alias of the job store
|
||||
:param bool shutdown: ``True`` to shut down the job store after removing it
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
jobstore = self._lookup_jobstore(alias)
|
||||
del self._jobstores[alias]
|
||||
|
||||
if shutdown:
|
||||
jobstore.shutdown()
|
||||
|
||||
self._dispatch_event(SchedulerEvent(EVENT_JOBSTORE_REMOVED, alias))
|
||||
|
||||
def add_listener(self, callback, mask=EVENT_ALL):
|
||||
"""
|
||||
add_listener(callback, mask=EVENT_ALL)
|
||||
|
||||
Adds a listener for scheduler events. When a matching event occurs, ``callback`` is executed with the event
|
||||
object as its sole argument. If the ``mask`` parameter is not provided, the callback will receive events of all
|
||||
types.
|
||||
|
||||
:param callback: any callable that takes one argument
|
||||
:param int mask: bitmask that indicates which events should be listened to
|
||||
|
||||
.. seealso:: :mod:`apscheduler.events`
|
||||
.. seealso:: :ref:`scheduler-events`
|
||||
"""
|
||||
|
||||
with self._listeners_lock:
|
||||
self._listeners.append((callback, mask))
|
||||
|
||||
def remove_listener(self, callback):
|
||||
"""Removes a previously added event listener."""
|
||||
|
||||
with self._listeners_lock:
|
||||
for i, (cb, _) in enumerate(self._listeners):
|
||||
if callback == cb:
|
||||
del self._listeners[i]
|
||||
|
||||
def add_job(self, func, trigger=None, args=None, kwargs=None, id=None, name=None, misfire_grace_time=undefined,
|
||||
coalesce=undefined, max_instances=undefined, next_run_time=undefined, jobstore='default',
|
||||
executor='default', replace_existing=False, **trigger_args):
|
||||
"""
|
||||
add_job(func, trigger=None, args=None, kwargs=None, id=None, name=None, misfire_grace_time=undefined, \
|
||||
coalesce=undefined, max_instances=undefined, next_run_time=undefined, jobstore='default', \
|
||||
executor='default', replace_existing=False, **trigger_args)
|
||||
|
||||
Adds the given job to the job list and wakes up the scheduler if it's already running.
|
||||
|
||||
Any option that defaults to ``undefined`` will be replaced with the corresponding default value when the job is
|
||||
scheduled (which happens when the scheduler is started, or immediately if the scheduler is already running).
|
||||
|
||||
The ``func`` argument can be given either as a callable object or a textual reference in the
|
||||
``package.module:some.object`` format, where the first half (separated by ``:``) is an importable module and the
|
||||
second half is a reference to the callable object, relative to the module.
|
||||
|
||||
The ``trigger`` argument can either be:
|
||||
#. the alias name of the trigger (e.g. ``date``, ``interval`` or ``cron``), in which case any extra keyword
|
||||
arguments to this method are passed on to the trigger's constructor
|
||||
#. an instance of a trigger class
|
||||
|
||||
:param func: callable (or a textual reference to one) to run at the given time
|
||||
:param str|apscheduler.triggers.base.BaseTrigger trigger: trigger that determines when ``func`` is called
|
||||
:param list|tuple args: list of positional arguments to call func with
|
||||
:param dict kwargs: dict of keyword arguments to call func with
|
||||
:param str|unicode id: explicit identifier for the job (for modifying it later)
|
||||
:param str|unicode name: textual description of the job
|
||||
:param int misfire_grace_time: seconds after the designated run time that the job is still allowed to be run
|
||||
:param bool coalesce: run once instead of many times if the scheduler determines that the job should be run more
|
||||
than once in succession
|
||||
:param int max_instances: maximum number of concurrently running instances allowed for this job
|
||||
:param datetime next_run_time: when to first run the job, regardless of the trigger (pass ``None`` to add the
|
||||
job as paused)
|
||||
:param str|unicode jobstore: alias of the job store to store the job in
|
||||
:param str|unicode executor: alias of the executor to run the job with
|
||||
:param bool replace_existing: ``True`` to replace an existing job with the same ``id`` (but retain the
|
||||
number of runs from the existing one)
|
||||
:rtype: Job
|
||||
"""
|
||||
|
||||
job_kwargs = {
|
||||
'trigger': self._create_trigger(trigger, trigger_args),
|
||||
'executor': executor,
|
||||
'func': func,
|
||||
'args': tuple(args) if args is not None else (),
|
||||
'kwargs': dict(kwargs) if kwargs is not None else {},
|
||||
'id': id,
|
||||
'name': name,
|
||||
'misfire_grace_time': misfire_grace_time,
|
||||
'coalesce': coalesce,
|
||||
'max_instances': max_instances,
|
||||
'next_run_time': next_run_time
|
||||
}
|
||||
job_kwargs = dict((key, value) for key, value in six.iteritems(job_kwargs) if value is not undefined)
|
||||
job = Job(self, **job_kwargs)
|
||||
|
||||
# Don't really add jobs to job stores before the scheduler is up and running
|
||||
with self._jobstores_lock:
|
||||
if not self.running:
|
||||
self._pending_jobs.append((job, jobstore, replace_existing))
|
||||
self._logger.info('Adding job tentatively -- it will be properly scheduled when the scheduler starts')
|
||||
else:
|
||||
self._real_add_job(job, jobstore, replace_existing, True)
|
||||
|
||||
return job
|
||||
|
||||
def scheduled_job(self, trigger, args=None, kwargs=None, id=None, name=None, misfire_grace_time=undefined,
|
||||
coalesce=undefined, max_instances=undefined, next_run_time=undefined, jobstore='default',
|
||||
executor='default', **trigger_args):
|
||||
"""
|
||||
scheduled_job(trigger, args=None, kwargs=None, id=None, name=None, misfire_grace_time=undefined, \
|
||||
coalesce=undefined, max_instances=undefined, next_run_time=undefined, jobstore='default', \
|
||||
executor='default',**trigger_args)
|
||||
|
||||
A decorator version of :meth:`add_job`, except that ``replace_existing`` is always ``True``.
|
||||
|
||||
.. important:: The ``id`` argument must be given if scheduling a job in a persistent job store. The scheduler
|
||||
cannot, however, enforce this requirement.
|
||||
"""
|
||||
|
||||
def inner(func):
|
||||
self.add_job(func, trigger, args, kwargs, id, name, misfire_grace_time, coalesce, max_instances,
|
||||
next_run_time, jobstore, executor, True, **trigger_args)
|
||||
return func
|
||||
return inner
|
||||
|
||||
def modify_job(self, job_id, jobstore=None, **changes):
|
||||
"""
|
||||
Modifies the properties of a single job. Modifications are passed to this method as extra keyword arguments.
|
||||
|
||||
:param str|unicode job_id: the identifier of the job
|
||||
:param str|unicode jobstore: alias of the job store that contains the job
|
||||
"""
|
||||
with self._jobstores_lock:
|
||||
job, jobstore = self._lookup_job(job_id, jobstore)
|
||||
job._modify(**changes)
|
||||
if jobstore:
|
||||
self._lookup_jobstore(jobstore).update_job(job)
|
||||
|
||||
self._dispatch_event(JobEvent(EVENT_JOB_MODIFIED, job_id, jobstore))
|
||||
|
||||
# Wake up the scheduler since the job's next run time may have been changed
|
||||
self.wakeup()
|
||||
|
||||
def reschedule_job(self, job_id, jobstore=None, trigger=None, **trigger_args):
|
||||
"""
|
||||
Constructs a new trigger for a job and updates its next run time.
|
||||
Extra keyword arguments are passed directly to the trigger's constructor.
|
||||
|
||||
:param str|unicode job_id: the identifier of the job
|
||||
:param str|unicode jobstore: alias of the job store that contains the job
|
||||
:param trigger: alias of the trigger type or a trigger instance
|
||||
"""
|
||||
|
||||
trigger = self._create_trigger(trigger, trigger_args)
|
||||
now = datetime.now(self.timezone)
|
||||
next_run_time = trigger.get_next_fire_time(None, now)
|
||||
self.modify_job(job_id, jobstore, trigger=trigger, next_run_time=next_run_time)
|
||||
|
||||
def pause_job(self, job_id, jobstore=None):
|
||||
"""
|
||||
Causes the given job not to be executed until it is explicitly resumed.
|
||||
|
||||
:param str|unicode job_id: the identifier of the job
|
||||
:param str|unicode jobstore: alias of the job store that contains the job
|
||||
"""
|
||||
|
||||
self.modify_job(job_id, jobstore, next_run_time=None)
|
||||
|
||||
def resume_job(self, job_id, jobstore=None):
|
||||
"""
|
||||
Resumes the schedule of the given job, or removes the job if its schedule is finished.
|
||||
|
||||
:param str|unicode job_id: the identifier of the job
|
||||
:param str|unicode jobstore: alias of the job store that contains the job
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
job, jobstore = self._lookup_job(job_id, jobstore)
|
||||
now = datetime.now(self.timezone)
|
||||
next_run_time = job.trigger.get_next_fire_time(None, now)
|
||||
if next_run_time:
|
||||
self.modify_job(job_id, jobstore, next_run_time=next_run_time)
|
||||
else:
|
||||
self.remove_job(job.id, jobstore)
|
||||
|
||||
def get_jobs(self, jobstore=None, pending=None):
|
||||
"""
|
||||
Returns a list of pending jobs (if the scheduler hasn't been started yet) and scheduled jobs, either from a
|
||||
specific job store or from all of them.
|
||||
|
||||
:param str|unicode jobstore: alias of the job store
|
||||
:param bool pending: ``False`` to leave out pending jobs (jobs that are waiting for the scheduler start to be
|
||||
added to their respective job stores), ``True`` to only include pending jobs, anything else
|
||||
to return both
|
||||
:rtype: list[Job]
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
jobs = []
|
||||
|
||||
if pending is not False:
|
||||
for job, alias, replace_existing in self._pending_jobs:
|
||||
if jobstore is None or alias == jobstore:
|
||||
jobs.append(job)
|
||||
|
||||
if pending is not True:
|
||||
for alias, store in six.iteritems(self._jobstores):
|
||||
if jobstore is None or alias == jobstore:
|
||||
jobs.extend(store.get_all_jobs())
|
||||
|
||||
return jobs
|
||||
|
||||
def get_job(self, job_id, jobstore=None):
|
||||
"""
|
||||
Returns the Job that matches the given ``job_id``.
|
||||
|
||||
:param str|unicode job_id: the identifier of the job
|
||||
:param str|unicode jobstore: alias of the job store that most likely contains the job
|
||||
:return: the Job by the given ID, or ``None`` if it wasn't found
|
||||
:rtype: Job
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
try:
|
||||
return self._lookup_job(job_id, jobstore)[0]
|
||||
except JobLookupError:
|
||||
return
|
||||
|
||||
def remove_job(self, job_id, jobstore=None):
|
||||
"""
|
||||
Removes a job, preventing it from being run any more.
|
||||
|
||||
:param str|unicode job_id: the identifier of the job
|
||||
:param str|unicode jobstore: alias of the job store that contains the job
|
||||
:raises JobLookupError: if the job was not found
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
# Check if the job is among the pending jobs
|
||||
for i, (job, jobstore_alias, replace_existing) in enumerate(self._pending_jobs):
|
||||
if job.id == job_id:
|
||||
del self._pending_jobs[i]
|
||||
jobstore = jobstore_alias
|
||||
break
|
||||
else:
|
||||
# Otherwise, try to remove it from each store until it succeeds or we run out of stores to check
|
||||
for alias, store in six.iteritems(self._jobstores):
|
||||
if jobstore in (None, alias):
|
||||
try:
|
||||
store.remove_job(job_id)
|
||||
except JobLookupError:
|
||||
continue
|
||||
|
||||
jobstore = alias
|
||||
break
|
||||
|
||||
if jobstore is None:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
# Notify listeners that a job has been removed
|
||||
event = JobEvent(EVENT_JOB_REMOVED, job_id, jobstore)
|
||||
self._dispatch_event(event)
|
||||
|
||||
self._logger.info('Removed job %s', job_id)
|
||||
|
||||
def remove_all_jobs(self, jobstore=None):
|
||||
"""
|
||||
Removes all jobs from the specified job store, or all job stores if none is given.
|
||||
|
||||
:param str|unicode jobstore: alias of the job store
|
||||
"""
|
||||
|
||||
with self._jobstores_lock:
|
||||
if jobstore:
|
||||
self._pending_jobs = [pending for pending in self._pending_jobs if pending[1] != jobstore]
|
||||
else:
|
||||
self._pending_jobs = []
|
||||
|
||||
for alias, store in six.iteritems(self._jobstores):
|
||||
if jobstore in (None, alias):
|
||||
store.remove_all_jobs()
|
||||
|
||||
self._dispatch_event(SchedulerEvent(EVENT_ALL_JOBS_REMOVED, jobstore))
|
||||
|
||||
def print_jobs(self, jobstore=None, out=None):
|
||||
"""
|
||||
print_jobs(jobstore=None, out=sys.stdout)
|
||||
|
||||
Prints out a textual listing of all jobs currently scheduled on either all job stores or just a specific one.
|
||||
|
||||
:param str|unicode jobstore: alias of the job store, ``None`` to list jobs from all stores
|
||||
:param file out: a file-like object to print to (defaults to **sys.stdout** if nothing is given)
|
||||
"""
|
||||
|
||||
out = out or sys.stdout
|
||||
with self._jobstores_lock:
|
||||
if self._pending_jobs:
|
||||
print(six.u('Pending jobs:'), file=out)
|
||||
for job, jobstore_alias, replace_existing in self._pending_jobs:
|
||||
if jobstore in (None, jobstore_alias):
|
||||
print(six.u(' %s') % job, file=out)
|
||||
|
||||
for alias, store in six.iteritems(self._jobstores):
|
||||
if jobstore in (None, alias):
|
||||
print(six.u('Jobstore %s:') % alias, file=out)
|
||||
jobs = store.get_all_jobs()
|
||||
if jobs:
|
||||
for job in jobs:
|
||||
print(six.u(' %s') % job, file=out)
|
||||
else:
|
||||
print(six.u(' No scheduled jobs'), file=out)
|
||||
|
||||
@abstractmethod
|
||||
def wakeup(self):
|
||||
"""
|
||||
Notifies the scheduler that there may be jobs due for execution.
|
||||
Triggers :meth:`_process_jobs` to be run in an implementation specific manner.
|
||||
"""
|
||||
|
||||
#
|
||||
# Private API
|
||||
#
|
||||
|
||||
def _configure(self, config):
|
||||
# Set general options
|
||||
self._logger = maybe_ref(config.pop('logger', None)) or getLogger('apscheduler.scheduler')
|
||||
self.timezone = astimezone(config.pop('timezone', None)) or get_localzone()
|
||||
|
||||
# Set the job defaults
|
||||
job_defaults = config.get('job_defaults', {})
|
||||
self._job_defaults = {
|
||||
'misfire_grace_time': asint(job_defaults.get('misfire_grace_time', 1)),
|
||||
'coalesce': asbool(job_defaults.get('coalesce', True)),
|
||||
'max_instances': asint(job_defaults.get('max_instances', 1))
|
||||
}
|
||||
|
||||
# Configure executors
|
||||
self._executors.clear()
|
||||
for alias, value in six.iteritems(config.get('executors', {})):
|
||||
if isinstance(value, BaseExecutor):
|
||||
self.add_executor(value, alias)
|
||||
elif isinstance(value, MutableMapping):
|
||||
executor_class = value.pop('class', None)
|
||||
plugin = value.pop('type', None)
|
||||
if plugin:
|
||||
executor = self._create_plugin_instance('executor', plugin, value)
|
||||
elif executor_class:
|
||||
cls = maybe_ref(executor_class)
|
||||
executor = cls(**value)
|
||||
else:
|
||||
raise ValueError('Cannot create executor "%s" -- either "type" or "class" must be defined' % alias)
|
||||
|
||||
self.add_executor(executor, alias)
|
||||
else:
|
||||
raise TypeError("Expected executor instance or dict for executors['%s'], got %s instead" % (
|
||||
alias, value.__class__.__name__))
|
||||
|
||||
# Configure job stores
|
||||
self._jobstores.clear()
|
||||
for alias, value in six.iteritems(config.get('jobstores', {})):
|
||||
if isinstance(value, BaseJobStore):
|
||||
self.add_jobstore(value, alias)
|
||||
elif isinstance(value, MutableMapping):
|
||||
jobstore_class = value.pop('class', None)
|
||||
plugin = value.pop('type', None)
|
||||
if plugin:
|
||||
jobstore = self._create_plugin_instance('jobstore', plugin, value)
|
||||
elif jobstore_class:
|
||||
cls = maybe_ref(jobstore_class)
|
||||
jobstore = cls(**value)
|
||||
else:
|
||||
raise ValueError('Cannot create job store "%s" -- either "type" or "class" must be defined' % alias)
|
||||
|
||||
self.add_jobstore(jobstore, alias)
|
||||
else:
|
||||
raise TypeError("Expected job store instance or dict for jobstores['%s'], got %s instead" % (
|
||||
alias, value.__class__.__name__))
|
||||
|
||||
def _create_default_executor(self):
|
||||
"""Creates a default executor store, specific to the particular scheduler type."""
|
||||
|
||||
return ThreadPoolExecutor()
|
||||
|
||||
def _create_default_jobstore(self):
|
||||
"""Creates a default job store, specific to the particular scheduler type."""
|
||||
|
||||
return MemoryJobStore()
|
||||
|
||||
def _lookup_executor(self, alias):
|
||||
"""
|
||||
Returns the executor instance by the given name from the list of executors that were added to this scheduler.
|
||||
|
||||
:type alias: str
|
||||
:raises KeyError: if no executor by the given alias is not found
|
||||
"""
|
||||
|
||||
try:
|
||||
return self._executors[alias]
|
||||
except KeyError:
|
||||
raise KeyError('No such executor: %s' % alias)
|
||||
|
||||
def _lookup_jobstore(self, alias):
|
||||
"""
|
||||
Returns the job store instance by the given name from the list of job stores that were added to this scheduler.
|
||||
|
||||
:type alias: str
|
||||
:raises KeyError: if no job store by the given alias is not found
|
||||
"""
|
||||
|
||||
try:
|
||||
return self._jobstores[alias]
|
||||
except KeyError:
|
||||
raise KeyError('No such job store: %s' % alias)
|
||||
|
||||
def _lookup_job(self, job_id, jobstore_alias):
|
||||
"""
|
||||
Finds a job by its ID.
|
||||
|
||||
:type job_id: str
|
||||
:param str jobstore_alias: alias of a job store to look in
|
||||
:return tuple[Job, str]: a tuple of job, jobstore alias (jobstore alias is None in case of a pending job)
|
||||
:raises JobLookupError: if no job by the given ID is found.
|
||||
"""
|
||||
|
||||
# Check if the job is among the pending jobs
|
||||
for job, alias, replace_existing in self._pending_jobs:
|
||||
if job.id == job_id:
|
||||
return job, None
|
||||
|
||||
# Look in all job stores
|
||||
for alias, store in six.iteritems(self._jobstores):
|
||||
if jobstore_alias in (None, alias):
|
||||
job = store.lookup_job(job_id)
|
||||
if job is not None:
|
||||
return job, alias
|
||||
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def _dispatch_event(self, event):
|
||||
"""
|
||||
Dispatches the given event to interested listeners.
|
||||
|
||||
:param SchedulerEvent event: the event to send
|
||||
"""
|
||||
|
||||
with self._listeners_lock:
|
||||
listeners = tuple(self._listeners)
|
||||
|
||||
for cb, mask in listeners:
|
||||
if event.code & mask:
|
||||
try:
|
||||
cb(event)
|
||||
except:
|
||||
self._logger.exception('Error notifying listener')
|
||||
|
||||
def _real_add_job(self, job, jobstore_alias, replace_existing, wakeup):
|
||||
"""
|
||||
:param Job job: the job to add
|
||||
:param bool replace_existing: ``True`` to use update_job() in case the job already exists in the store
|
||||
:param bool wakeup: ``True`` to wake up the scheduler after adding the job
|
||||
"""
|
||||
|
||||
# Fill in undefined values with defaults
|
||||
replacements = {}
|
||||
for key, value in six.iteritems(self._job_defaults):
|
||||
if not hasattr(job, key):
|
||||
replacements[key] = value
|
||||
|
||||
# Calculate the next run time if there is none defined
|
||||
if not hasattr(job, 'next_run_time'):
|
||||
now = datetime.now(self.timezone)
|
||||
replacements['next_run_time'] = job.trigger.get_next_fire_time(None, now)
|
||||
|
||||
# Apply any replacements
|
||||
job._modify(**replacements)
|
||||
|
||||
# Add the job to the given job store
|
||||
store = self._lookup_jobstore(jobstore_alias)
|
||||
try:
|
||||
store.add_job(job)
|
||||
except ConflictingIdError:
|
||||
if replace_existing:
|
||||
store.update_job(job)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Mark the job as no longer pending
|
||||
job._jobstore_alias = jobstore_alias
|
||||
|
||||
# Notify listeners that a new job has been added
|
||||
event = JobEvent(EVENT_JOB_ADDED, job.id, jobstore_alias)
|
||||
self._dispatch_event(event)
|
||||
|
||||
self._logger.info('Added job "%s" to job store "%s"', job.name, jobstore_alias)
|
||||
|
||||
# Notify the scheduler about the new job
|
||||
if wakeup:
|
||||
self.wakeup()
|
||||
|
||||
def _create_plugin_instance(self, type_, alias, constructor_kwargs):
|
||||
"""Creates an instance of the given plugin type, loading the plugin first if necessary."""
|
||||
|
||||
plugin_container, class_container, base_class = {
|
||||
'trigger': (self._trigger_plugins, self._trigger_classes, BaseTrigger),
|
||||
'jobstore': (self._jobstore_plugins, self._jobstore_classes, BaseJobStore),
|
||||
'executor': (self._executor_plugins, self._executor_classes, BaseExecutor)
|
||||
}[type_]
|
||||
|
||||
try:
|
||||
plugin_cls = class_container[alias]
|
||||
except KeyError:
|
||||
if alias in plugin_container:
|
||||
plugin_cls = class_container[alias] = plugin_container[alias].load()
|
||||
if not issubclass(plugin_cls, base_class):
|
||||
raise TypeError('The {0} entry point does not point to a {0} class'.format(type_))
|
||||
else:
|
||||
raise LookupError('No {0} by the name "{1}" was found'.format(type_, alias))
|
||||
|
||||
return plugin_cls(**constructor_kwargs)
|
||||
|
||||
def _create_trigger(self, trigger, trigger_args):
|
||||
if isinstance(trigger, BaseTrigger):
|
||||
return trigger
|
||||
elif trigger is None:
|
||||
trigger = 'date'
|
||||
elif not isinstance(trigger, six.string_types):
|
||||
raise TypeError('Expected a trigger instance or string, got %s instead' % trigger.__class__.__name__)
|
||||
|
||||
# Use the scheduler's time zone if nothing else is specified
|
||||
trigger_args.setdefault('timezone', self.timezone)
|
||||
|
||||
# Instantiate the trigger class
|
||||
return self._create_plugin_instance('trigger', trigger, trigger_args)
|
||||
|
||||
def _create_lock(self):
|
||||
"""Creates a reentrant lock object."""
|
||||
|
||||
return RLock()
|
||||
|
||||
def _process_jobs(self):
|
||||
"""
|
||||
Iterates through jobs in every jobstore, starts jobs that are due and figures out how long to wait for the next
|
||||
round.
|
||||
"""
|
||||
|
||||
self._logger.debug('Looking for jobs to run')
|
||||
now = datetime.now(self.timezone)
|
||||
next_wakeup_time = None
|
||||
|
||||
with self._jobstores_lock:
|
||||
for jobstore_alias, jobstore in six.iteritems(self._jobstores):
|
||||
for job in jobstore.get_due_jobs(now):
|
||||
# Look up the job's executor
|
||||
try:
|
||||
executor = self._lookup_executor(job.executor)
|
||||
except:
|
||||
self._logger.error(
|
||||
'Executor lookup ("%s") failed for job "%s" -- removing it from the job store',
|
||||
job.executor, job)
|
||||
self.remove_job(job.id, jobstore_alias)
|
||||
continue
|
||||
|
||||
run_times = job._get_run_times(now)
|
||||
run_times = run_times[-1:] if run_times and job.coalesce else run_times
|
||||
if run_times:
|
||||
try:
|
||||
executor.submit_job(job, run_times)
|
||||
except MaxInstancesReachedError:
|
||||
self._logger.warning(
|
||||
'Execution of job "%s" skipped: maximum number of running instances reached (%d)',
|
||||
job, job.max_instances)
|
||||
except:
|
||||
self._logger.exception('Error submitting job "%s" to executor "%s"', job, job.executor)
|
||||
|
||||
# Update the job if it has a next execution time. Otherwise remove it from the job store.
|
||||
job_next_run = job.trigger.get_next_fire_time(run_times[-1], now)
|
||||
if job_next_run:
|
||||
job._modify(next_run_time=job_next_run)
|
||||
jobstore.update_job(job)
|
||||
else:
|
||||
self.remove_job(job.id, jobstore_alias)
|
||||
|
||||
# Set a new next wakeup time if there isn't one yet or the jobstore has an even earlier one
|
||||
jobstore_next_run_time = jobstore.get_next_run_time()
|
||||
if jobstore_next_run_time and (next_wakeup_time is None or jobstore_next_run_time < next_wakeup_time):
|
||||
next_wakeup_time = jobstore_next_run_time
|
||||
|
||||
# Determine the delay until this method should be called again
|
||||
if next_wakeup_time is not None:
|
||||
wait_seconds = max(timedelta_seconds(next_wakeup_time - now), 0)
|
||||
self._logger.debug('Next wakeup is due at %s (in %f seconds)', next_wakeup_time, wait_seconds)
|
||||
else:
|
||||
wait_seconds = None
|
||||
self._logger.debug('No jobs; waiting until a job is added')
|
||||
|
||||
return wait_seconds
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import absolute_import
|
||||
from threading import Event
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
|
||||
|
||||
class BlockingScheduler(BaseScheduler):
|
||||
"""
|
||||
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
|
||||
|
||||
def start(self):
|
||||
super(BlockingScheduler, self).start()
|
||||
self._event = Event()
|
||||
self._main_loop()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(BlockingScheduler, self).shutdown(wait)
|
||||
self._event.set()
|
||||
|
||||
def _main_loop(self):
|
||||
while self.running:
|
||||
wait_seconds = self._process_jobs()
|
||||
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME)
|
||||
self._event.clear()
|
||||
|
||||
def wakeup(self):
|
||||
self._event.set()
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
|
||||
try:
|
||||
from gevent.event import Event
|
||||
from gevent.lock import RLock
|
||||
import gevent
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('GeventScheduler requires gevent installed')
|
||||
|
||||
|
||||
class GeventScheduler(BlockingScheduler):
|
||||
"""A scheduler that runs as a Gevent greenlet."""
|
||||
|
||||
_greenlet = None
|
||||
|
||||
def start(self):
|
||||
BaseScheduler.start(self)
|
||||
self._event = Event()
|
||||
self._greenlet = gevent.spawn(self._main_loop)
|
||||
return self._greenlet
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(GeventScheduler, self).shutdown(wait)
|
||||
self._greenlet.join()
|
||||
del self._greenlet
|
||||
|
||||
def _create_lock(self):
|
||||
return RLock()
|
||||
|
||||
def _create_default_executor(self):
|
||||
from apscheduler.executors.gevent import GeventExecutor
|
||||
return GeventExecutor()
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
|
||||
try:
|
||||
from PyQt5.QtCore import QObject, QTimer
|
||||
except ImportError: # pragma: nocover
|
||||
try:
|
||||
from PyQt4.QtCore import QObject, QTimer
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide.QtCore import QObject, QTimer # flake8: noqa
|
||||
except ImportError:
|
||||
raise ImportError('QtScheduler requires either PyQt5, PyQt4 or PySide installed')
|
||||
|
||||
|
||||
class QtScheduler(BaseScheduler):
|
||||
"""A scheduler that runs in a Qt event loop."""
|
||||
|
||||
_timer = None
|
||||
|
||||
def start(self):
|
||||
super(QtScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(QtScheduler, self).shutdown(wait)
|
||||
self._stop_timer()
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
self._stop_timer()
|
||||
if wait_seconds is not None:
|
||||
self._timer = QTimer.singleShot(wait_seconds * 1000, self._process_jobs)
|
||||
|
||||
def _stop_timer(self):
|
||||
if self._timer:
|
||||
if self._timer.isActive():
|
||||
self._timer.stop()
|
||||
del self._timer
|
||||
|
||||
def wakeup(self):
|
||||
self._start_timer(0)
|
||||
|
||||
def _process_jobs(self):
|
||||
wait_seconds = super(QtScheduler, self)._process_jobs()
|
||||
self._start_timer(wait_seconds)
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import absolute_import
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
from apscheduler.util import maybe_ref
|
||||
|
||||
try:
|
||||
from tornado.ioloop import IOLoop
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('TornadoScheduler requires tornado installed')
|
||||
|
||||
|
||||
def run_in_ioloop(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
self._ioloop.add_callback(func, self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class TornadoScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs on a Tornado IOLoop.
|
||||
|
||||
=========== ===============================================================
|
||||
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
|
||||
=========== ===============================================================
|
||||
"""
|
||||
|
||||
_ioloop = None
|
||||
_timeout = None
|
||||
|
||||
def start(self):
|
||||
super(TornadoScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_ioloop
|
||||
def shutdown(self, wait=True):
|
||||
super(TornadoScheduler, self).shutdown(wait)
|
||||
self._stop_timer()
|
||||
|
||||
def _configure(self, config):
|
||||
self._ioloop = maybe_ref(config.pop('io_loop', None)) or IOLoop.current()
|
||||
super(TornadoScheduler, self)._configure(config)
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
self._stop_timer()
|
||||
if wait_seconds is not None:
|
||||
self._timeout = self._ioloop.add_timeout(timedelta(seconds=wait_seconds), self.wakeup)
|
||||
|
||||
def _stop_timer(self):
|
||||
if self._timeout:
|
||||
self._ioloop.remove_timeout(self._timeout)
|
||||
del self._timeout
|
||||
|
||||
@run_in_ioloop
|
||||
def wakeup(self):
|
||||
self._stop_timer()
|
||||
wait_seconds = self._process_jobs()
|
||||
self._start_timer(wait_seconds)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import absolute_import
|
||||
from functools import wraps
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
from apscheduler.util import maybe_ref
|
||||
|
||||
try:
|
||||
from twisted.internet import reactor as default_reactor
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('TwistedScheduler requires Twisted installed')
|
||||
|
||||
|
||||
def run_in_reactor(func):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
self._reactor.callFromThread(func, self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class TwistedScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs on a Twisted reactor.
|
||||
|
||||
Extra options:
|
||||
|
||||
=========== ========================================================
|
||||
``reactor`` Reactor instance to use (defaults to the global reactor)
|
||||
=========== ========================================================
|
||||
"""
|
||||
|
||||
_reactor = None
|
||||
_delayedcall = None
|
||||
|
||||
def _configure(self, config):
|
||||
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
||||
super(TwistedScheduler, self)._configure(config)
|
||||
|
||||
def start(self):
|
||||
super(TwistedScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_reactor
|
||||
def shutdown(self, wait=True):
|
||||
super(TwistedScheduler, self).shutdown(wait)
|
||||
self._stop_timer()
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
self._stop_timer()
|
||||
if wait_seconds is not None:
|
||||
self._delayedcall = self._reactor.callLater(wait_seconds, self.wakeup)
|
||||
|
||||
def _stop_timer(self):
|
||||
if self._delayedcall and self._delayedcall.active():
|
||||
self._delayedcall.cancel()
|
||||
del self._delayedcall
|
||||
|
||||
@run_in_reactor
|
||||
def wakeup(self):
|
||||
self._stop_timer()
|
||||
wait_seconds = self._process_jobs()
|
||||
self._start_timer(wait_seconds)
|
||||
|
||||
def _create_default_executor(self):
|
||||
from apscheduler.executors.twisted import TwistedExecutor
|
||||
return TwistedExecutor()
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
Generic thread pool class. Modeled after Java's ThreadPoolExecutor.
|
||||
Please note that this ThreadPool does *not* fully implement the PEP 3148
|
||||
ThreadPool!
|
||||
"""
|
||||
|
||||
from threading import Thread, Lock, currentThread
|
||||
from weakref import ref
|
||||
import logging
|
||||
import atexit
|
||||
|
||||
try:
|
||||
from queue import Queue, Empty
|
||||
except ImportError:
|
||||
from Queue import Queue, Empty
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_threadpools = set()
|
||||
|
||||
|
||||
# Worker threads are daemonic in order to let the interpreter exit without
|
||||
# an explicit shutdown of the thread pool. The following trick is necessary
|
||||
# to allow worker threads to finish cleanly.
|
||||
def _shutdown_all():
|
||||
for pool_ref in tuple(_threadpools):
|
||||
pool = pool_ref()
|
||||
if pool:
|
||||
pool.shutdown()
|
||||
|
||||
atexit.register(_shutdown_all)
|
||||
|
||||
|
||||
class ThreadPool(object):
|
||||
def __init__(self, core_threads=0, max_threads=20, keepalive=1):
|
||||
"""
|
||||
:param core_threads: maximum number of persistent threads in the pool
|
||||
:param max_threads: maximum number of total threads in the pool
|
||||
:param thread_class: callable that creates a Thread object
|
||||
:param keepalive: seconds to keep non-core worker threads waiting
|
||||
for new tasks
|
||||
"""
|
||||
self.core_threads = core_threads
|
||||
self.max_threads = max(max_threads, core_threads, 1)
|
||||
self.keepalive = keepalive
|
||||
self._queue = Queue()
|
||||
self._threads_lock = Lock()
|
||||
self._threads = set()
|
||||
self._shutdown = False
|
||||
|
||||
_threadpools.add(ref(self))
|
||||
logger.info('Started thread pool with %d core threads and %s maximum '
|
||||
'threads', core_threads, max_threads or 'unlimited')
|
||||
|
||||
def _adjust_threadcount(self):
|
||||
self._threads_lock.acquire()
|
||||
try:
|
||||
if self.num_threads < self.max_threads:
|
||||
self._add_thread(self.num_threads < self.core_threads)
|
||||
finally:
|
||||
self._threads_lock.release()
|
||||
|
||||
def _add_thread(self, core):
|
||||
t = Thread(target=self._run_jobs, args=(core,))
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
self._threads.add(t)
|
||||
|
||||
def _run_jobs(self, core):
|
||||
logger.debug('Started worker thread')
|
||||
block = True
|
||||
timeout = None
|
||||
if not core:
|
||||
block = self.keepalive > 0
|
||||
timeout = self.keepalive
|
||||
|
||||
while True:
|
||||
try:
|
||||
func, args, kwargs = self._queue.get(block, timeout)
|
||||
except Empty:
|
||||
break
|
||||
|
||||
if self._shutdown:
|
||||
break
|
||||
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except:
|
||||
logger.exception('Error in worker thread')
|
||||
|
||||
self._threads_lock.acquire()
|
||||
self._threads.remove(currentThread())
|
||||
self._threads_lock.release()
|
||||
|
||||
logger.debug('Exiting worker thread')
|
||||
|
||||
@property
|
||||
def num_threads(self):
|
||||
return len(self._threads)
|
||||
|
||||
def submit(self, func, *args, **kwargs):
|
||||
if self._shutdown:
|
||||
raise RuntimeError('Cannot schedule new tasks after shutdown')
|
||||
|
||||
self._queue.put((func, args, kwargs))
|
||||
self._adjust_threadcount()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
if self._shutdown:
|
||||
return
|
||||
|
||||
logging.info('Shutting down thread pool')
|
||||
self._shutdown = True
|
||||
_threadpools.remove(ref(self))
|
||||
|
||||
self._threads_lock.acquire()
|
||||
for _ in range(self.num_threads):
|
||||
self._queue.put((None, None, None))
|
||||
self._threads_lock.release()
|
||||
|
||||
if wait:
|
||||
self._threads_lock.acquire()
|
||||
threads = tuple(self._threads)
|
||||
self._threads_lock.release()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
def __repr__(self):
|
||||
if self.max_threads:
|
||||
threadcount = '%d/%d' % (self.num_threads, self.max_threads)
|
||||
else:
|
||||
threadcount = '%d' % self.num_threads
|
||||
|
||||
return '<ThreadPool at %x; threads=%s>' % (id(self), threadcount)
|
||||
@@ -1,3 +0,0 @@
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.simple import SimpleTrigger
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
import six
|
||||
|
||||
|
||||
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
||||
"""Abstract base class that defines the interface that every trigger must implement."""
|
||||
|
||||
@abstractmethod
|
||||
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``.
|
||||
|
||||
:param datetime.datetime previous_fire_time: the previous time the trigger was fired
|
||||
:param datetime.datetime now: current datetime
|
||||
"""
|
||||
@@ -1,32 +1,71 @@
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from apscheduler.triggers.cron.fields import *
|
||||
from apscheduler.util import datetime_ceil, convert_to_datetime
|
||||
from tzlocal import get_localzone
|
||||
import six
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES
|
||||
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
|
||||
|
||||
|
||||
class CronTrigger(object):
|
||||
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour',
|
||||
'minute', 'second')
|
||||
FIELDS_MAP = {'year': BaseField,
|
||||
'month': BaseField,
|
||||
'week': WeekField,
|
||||
'day': DayOfMonthField,
|
||||
'day_of_week': DayOfWeekField,
|
||||
'hour': BaseField,
|
||||
'minute': BaseField,
|
||||
'second': BaseField}
|
||||
class CronTrigger(BaseTrigger):
|
||||
"""
|
||||
Triggers when current time matches all specified time constraints, similarly to how the UNIX cron scheduler works.
|
||||
|
||||
def __init__(self, **values):
|
||||
self.start_date = values.pop('start_date', None)
|
||||
if self.start_date:
|
||||
self.start_date = convert_to_datetime(self.start_date)
|
||||
:param int|str year: 4-digit year
|
||||
:param int|str month: month (1-12)
|
||||
:param int|str day: day of the (1-31)
|
||||
: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 hour: hour (0-23)
|
||||
:param int|str minute: minute (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 end_date: latest possible date/time to trigger on (inclusive)
|
||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
||||
(defaults to scheduler timezone)
|
||||
|
||||
.. note:: The first weekday is always **monday**.
|
||||
"""
|
||||
|
||||
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
|
||||
FIELDS_MAP = {
|
||||
'year': BaseField,
|
||||
'month': BaseField,
|
||||
'week': WeekField,
|
||||
'day': DayOfMonthField,
|
||||
'day_of_week': DayOfWeekField,
|
||||
'hour': BaseField,
|
||||
'minute': BaseField,
|
||||
'second': BaseField
|
||||
}
|
||||
|
||||
__slots__ = 'timezone', 'start_date', 'end_date', 'fields'
|
||||
|
||||
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None,
|
||||
second=None, start_date=None, end_date=None, timezone=None):
|
||||
if timezone:
|
||||
self.timezone = astimezone(timezone)
|
||||
elif start_date and start_date.tzinfo:
|
||||
self.timezone = start_date.tzinfo
|
||||
elif end_date and end_date.tzinfo:
|
||||
self.timezone = end_date.tzinfo
|
||||
else:
|
||||
self.timezone = get_localzone()
|
||||
|
||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||
|
||||
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
||||
if key in self.FIELD_NAMES and value is not None)
|
||||
self.fields = []
|
||||
assign_defaults = False
|
||||
for field_name in self.FIELD_NAMES:
|
||||
if field_name in values:
|
||||
exprs = values.pop(field_name)
|
||||
is_default = False
|
||||
elif not values:
|
||||
assign_defaults = not values
|
||||
elif assign_defaults:
|
||||
exprs = DEFAULT_VALUES[field_name]
|
||||
is_default = True
|
||||
else:
|
||||
@@ -39,18 +78,16 @@ class CronTrigger(object):
|
||||
|
||||
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 fieldnum: int
|
||||
:type amount: int
|
||||
:return: a tuple containing the new date, and the number of the field that was actually incremented
|
||||
:rtype: tuple
|
||||
:return: a tuple containing the new date, and the number of the field
|
||||
that was actually incremented
|
||||
"""
|
||||
i = 0
|
||||
|
||||
values = {}
|
||||
i = 0
|
||||
while i < len(self.fields):
|
||||
field = self.fields[i]
|
||||
if not field.REAL:
|
||||
@@ -77,7 +114,8 @@ class CronTrigger(object):
|
||||
values[field.name] = value + 1
|
||||
i += 1
|
||||
|
||||
return datetime(**values), fieldnum
|
||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
||||
return self.timezone.normalize(dateval + difference), fieldnum
|
||||
|
||||
def _set_field_value(self, dateval, fieldnum, new_value):
|
||||
values = {}
|
||||
@@ -90,13 +128,17 @@ class CronTrigger(object):
|
||||
else:
|
||||
values[field.name] = new_value
|
||||
|
||||
return datetime(**values)
|
||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
||||
return self.timezone.normalize(dateval + difference)
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
if previous_fire_time:
|
||||
start_date = max(now, previous_fire_time + timedelta(microseconds=1))
|
||||
else:
|
||||
start_date = max(now, self.start_date) if self.start_date else now
|
||||
|
||||
def get_next_fire_time(self, start_date):
|
||||
if self.start_date:
|
||||
start_date = max(start_date, self.start_date)
|
||||
next_date = datetime_ceil(start_date)
|
||||
fieldnum = 0
|
||||
next_date = datetime_ceil(start_date).astimezone(self.timezone)
|
||||
while 0 <= fieldnum < len(self.fields):
|
||||
field = self.fields[fieldnum]
|
||||
curr_value = field.get_value(next_date)
|
||||
@@ -104,32 +146,31 @@ class CronTrigger(object):
|
||||
|
||||
if next_value is None:
|
||||
# No valid value was found
|
||||
next_date, fieldnum = self._increment_field_value(next_date,
|
||||
fieldnum - 1)
|
||||
next_date, fieldnum = self._increment_field_value(next_date, fieldnum - 1)
|
||||
elif next_value > curr_value:
|
||||
# A valid, but higher than the starting value, was found
|
||||
if field.REAL:
|
||||
next_date = self._set_field_value(next_date, fieldnum,
|
||||
next_value)
|
||||
next_date = self._set_field_value(next_date, fieldnum, next_value)
|
||||
fieldnum += 1
|
||||
else:
|
||||
next_date, fieldnum = self._increment_field_value(next_date,
|
||||
fieldnum)
|
||||
next_date, fieldnum = self._increment_field_value(next_date, fieldnum)
|
||||
else:
|
||||
# A valid value was found, no changes necessary
|
||||
fieldnum += 1
|
||||
|
||||
# Return if the date has rolled past the end date
|
||||
if self.end_date and next_date > self.end_date:
|
||||
return None
|
||||
|
||||
if fieldnum >= 0:
|
||||
return next_date
|
||||
|
||||
def __str__(self):
|
||||
options = ["%s='%s'" % (f.name, str(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]
|
||||
return 'cron[%s]' % (', '.join(options))
|
||||
|
||||
def __repr__(self):
|
||||
options = ["%s='%s'" % (f.name, str(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:
|
||||
options.append("start_date='%s'" % self.start_date.isoformat(' '))
|
||||
options.append("start_date='%s'" % datetime_repr(self.start_date))
|
||||
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
|
||||
|
||||
@@ -7,8 +7,8 @@ import re
|
||||
|
||||
from apscheduler.util import asint
|
||||
|
||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
|
||||
'WeekdayPositionExpression')
|
||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
|
||||
'LastDayOfMonthExpression')
|
||||
|
||||
|
||||
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
@@ -57,8 +57,7 @@ class RangeExpression(AllExpression):
|
||||
if last is None and step is None:
|
||||
last = first
|
||||
if last is not None and first > last:
|
||||
raise ValueError('The minimum value in a range must not be '
|
||||
'higher than the maximum')
|
||||
raise ValueError('The minimum value in a range must not be higher than the maximum')
|
||||
self.first = first
|
||||
self.last = last
|
||||
|
||||
@@ -102,8 +101,7 @@ class RangeExpression(AllExpression):
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def __init__(self, first, last=None):
|
||||
try:
|
||||
@@ -135,8 +133,7 @@ class WeekdayRangeExpression(RangeExpression):
|
||||
|
||||
class WeekdayPositionExpression(AllExpression):
|
||||
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):
|
||||
try:
|
||||
@@ -169,10 +166,23 @@ class WeekdayPositionExpression(AllExpression):
|
||||
return target_day
|
||||
|
||||
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):
|
||||
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):
|
||||
value_re = re.compile(r'last', re.IGNORECASE)
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_next_value(self, date, field):
|
||||
return monthrange(date.year, date.month)[1]
|
||||
|
||||
def __str__(self):
|
||||
return 'last'
|
||||
|
||||
def __repr__(self):
|
||||
return "%s()" % self.__class__.__name__
|
||||
|
||||
@@ -5,18 +5,18 @@ fields.
|
||||
|
||||
from calendar import monthrange
|
||||
|
||||
from apscheduler.triggers.cron.expressions import *
|
||||
|
||||
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField',
|
||||
'WeekField', 'DayOfMonthField', 'DayOfWeekField')
|
||||
from apscheduler.triggers.cron.expressions import (
|
||||
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, WeekdayRangeExpression)
|
||||
|
||||
|
||||
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1,
|
||||
'day_of_week': 0, 'hour': 0, 'minute': 0, 'second': 0}
|
||||
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53,
|
||||
'day_of_week': 6, 'hour': 23, 'minute': 59, 'second': 59}
|
||||
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*',
|
||||
'day_of_week': '*', 'hour': 0, 'minute': 0, 'second': 0}
|
||||
__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}
|
||||
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59,
|
||||
'second': 59}
|
||||
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0,
|
||||
'second': 0}
|
||||
|
||||
|
||||
class BaseField(object):
|
||||
@@ -65,16 +65,14 @@ class BaseField(object):
|
||||
self.expressions.append(compiled_expr)
|
||||
return
|
||||
|
||||
raise ValueError('Unrecognized expression "%s" for field "%s"' %
|
||||
(expr, self.name))
|
||||
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
|
||||
|
||||
def __str__(self):
|
||||
expr_strings = (str(e) for e in self.expressions)
|
||||
return ','.join(expr_strings)
|
||||
|
||||
def __repr__(self):
|
||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.name,
|
||||
str(self))
|
||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.name, self)
|
||||
|
||||
|
||||
class WeekField(BaseField):
|
||||
@@ -85,7 +83,7 @@ class WeekField(BaseField):
|
||||
|
||||
|
||||
class DayOfMonthField(BaseField):
|
||||
COMPILERS = BaseField.COMPILERS + [WeekdayPositionExpression]
|
||||
COMPILERS = BaseField.COMPILERS + [WeekdayPositionExpression, LastDayOfMonthExpression]
|
||||
|
||||
def get_max(self, dateval):
|
||||
return monthrange(dateval.year, dateval.month)[1]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import convert_to_datetime, datetime_repr, astimezone
|
||||
|
||||
|
||||
class DateTrigger(BaseTrigger):
|
||||
"""
|
||||
Triggers once on the given datetime. If ``run_date`` is left empty, current time is used.
|
||||
|
||||
:param datetime|str run_date: the date/time to run the job at
|
||||
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
|
||||
"""
|
||||
|
||||
__slots__ = 'timezone', 'run_date'
|
||||
|
||||
def __init__(self, run_date=None, timezone=None):
|
||||
timezone = astimezone(timezone) or get_localzone()
|
||||
self.run_date = convert_to_datetime(run_date or datetime.now(), timezone, 'run_date')
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
return self.run_date if previous_fire_time is None else None
|
||||
|
||||
def __str__(self):
|
||||
return 'date[%s]' % datetime_repr(self.run_date)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s (run_date='%s')>" % (self.__class__.__name__, datetime_repr(self.run_date))
|
||||
@@ -1,39 +1,65 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta, datetime
|
||||
from math import ceil
|
||||
|
||||
from apscheduler.util import convert_to_datetime, timedelta_seconds
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_repr, astimezone
|
||||
|
||||
|
||||
class IntervalTrigger(object):
|
||||
def __init__(self, interval, start_date=None):
|
||||
if not isinstance(interval, timedelta):
|
||||
raise TypeError('interval must be a timedelta')
|
||||
if start_date:
|
||||
start_date = convert_to_datetime(start_date)
|
||||
class IntervalTrigger(BaseTrigger):
|
||||
"""
|
||||
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval
|
||||
otherwise.
|
||||
|
||||
self.interval = interval
|
||||
:param int weeks: number of weeks to wait
|
||||
:param int days: number of days to wait
|
||||
:param int hours: number of hours to wait
|
||||
:param int minutes: number of minutes to wait
|
||||
:param int seconds: number of seconds to wait
|
||||
: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.tzinfo|str timezone: time zone to use for the date/time calculations
|
||||
"""
|
||||
|
||||
__slots__ = 'timezone', 'start_date', 'end_date', 'interval'
|
||||
|
||||
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, end_date=None, timezone=None):
|
||||
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds)
|
||||
self.interval_length = timedelta_seconds(self.interval)
|
||||
if self.interval_length == 0:
|
||||
self.interval = timedelta(seconds=1)
|
||||
self.interval_length = 1
|
||||
|
||||
if start_date is None:
|
||||
self.start_date = datetime.now() + self.interval
|
||||
if timezone:
|
||||
self.timezone = astimezone(timezone)
|
||||
elif start_date and start_date.tzinfo:
|
||||
self.timezone = start_date.tzinfo
|
||||
elif end_date and end_date.tzinfo:
|
||||
self.timezone = end_date.tzinfo
|
||||
else:
|
||||
self.start_date = convert_to_datetime(start_date)
|
||||
self.timezone = get_localzone()
|
||||
|
||||
def get_next_fire_time(self, start_date):
|
||||
if start_date < self.start_date:
|
||||
return self.start_date
|
||||
start_date = start_date or (datetime.now(self.timezone) + self.interval)
|
||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||
|
||||
timediff_seconds = timedelta_seconds(start_date - self.start_date)
|
||||
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
|
||||
return self.start_date + self.interval * next_interval_num
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
if previous_fire_time:
|
||||
next_fire_time = previous_fire_time + self.interval
|
||||
elif self.start_date > now:
|
||||
next_fire_time = self.start_date
|
||||
else:
|
||||
timediff_seconds = timedelta_seconds(now - self.start_date)
|
||||
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
|
||||
next_fire_time = self.start_date + self.interval * next_interval_num
|
||||
|
||||
if not self.end_date or next_fire_time <= self.end_date:
|
||||
return self.timezone.normalize(next_fire_time)
|
||||
|
||||
def __str__(self):
|
||||
return 'interval[%s]' % str(self.interval)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s (interval=%s, start_date=%s)>" % (
|
||||
self.__class__.__name__, repr(self.interval),
|
||||
repr(self.start_date))
|
||||
return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval,
|
||||
datetime_repr(self.start_date))
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from apscheduler.util import convert_to_datetime
|
||||
|
||||
|
||||
class SimpleTrigger(object):
|
||||
def __init__(self, run_date):
|
||||
self.run_date = convert_to_datetime(run_date)
|
||||
|
||||
def get_next_fire_time(self, start_date):
|
||||
if self.run_date >= start_date:
|
||||
return self.run_date
|
||||
|
||||
def __str__(self):
|
||||
return 'date[%s]' % str(self.run_date)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (run_date=%s)>' % (
|
||||
self.__class__.__name__, repr(self.run_date))
|
||||
+280
-99
@@ -1,26 +1,48 @@
|
||||
"""
|
||||
This module contains several handy functions primarily meant for internal use.
|
||||
"""
|
||||
"""This module contains several handy functions primarily meant for internal use."""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from time import mktime
|
||||
from __future__ import division
|
||||
from datetime import date, datetime, time, timedelta, tzinfo
|
||||
from inspect import isfunction, ismethod, getargspec
|
||||
from calendar import timegm
|
||||
import re
|
||||
import sys
|
||||
|
||||
__all__ = ('asint', 'asbool', 'convert_to_datetime', 'timedelta_seconds',
|
||||
'time_difference', 'datetime_ceil', 'combine_opts',
|
||||
'get_callable_name', 'obj_to_ref', 'ref_to_obj', 'maybe_ref',
|
||||
'to_unicode', 'iteritems', 'itervalues', 'xrange')
|
||||
from pytz import timezone, utc
|
||||
import six
|
||||
|
||||
try:
|
||||
from inspect import signature
|
||||
except ImportError: # pragma: nocover
|
||||
try:
|
||||
from funcsigs import signature
|
||||
except ImportError:
|
||||
signature = None
|
||||
|
||||
__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',
|
||||
'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
|
||||
|
||||
|
||||
class _Undefined(object):
|
||||
def __nonzero__(self):
|
||||
return False
|
||||
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return '<undefined>'
|
||||
|
||||
undefined = _Undefined() #: a unique object that only signifies that no value is defined
|
||||
|
||||
|
||||
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
|
||||
:rtype: int
|
||||
"""
|
||||
|
||||
if text is not None:
|
||||
return int(text)
|
||||
|
||||
@@ -31,6 +53,7 @@ def asbool(obj):
|
||||
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
if isinstance(obj, str):
|
||||
obj = obj.strip().lower()
|
||||
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
|
||||
@@ -41,36 +64,99 @@ def asbool(obj):
|
||||
return bool(obj)
|
||||
|
||||
|
||||
def astimezone(obj):
|
||||
"""
|
||||
Interprets an object as a timezone.
|
||||
|
||||
:rtype: tzinfo
|
||||
"""
|
||||
|
||||
if isinstance(obj, six.string_types):
|
||||
return timezone(obj)
|
||||
if isinstance(obj, tzinfo):
|
||||
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
|
||||
raise TypeError('Only timezones from the pytz library are supported')
|
||||
if obj.zone == 'local':
|
||||
raise ValueError('Unable to determine the name of the local timezone -- use an explicit timezone instead')
|
||||
return obj
|
||||
if obj is not None:
|
||||
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
|
||||
|
||||
|
||||
_DATE_REGEX = re.compile(
|
||||
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'(?:\.(?P<microsecond>\d{1,6}))?)?')
|
||||
|
||||
|
||||
def convert_to_datetime(input):
|
||||
def convert_to_datetime(input, tz, arg_name):
|
||||
"""
|
||||
Converts the given object to a datetime object, if possible.
|
||||
If an actual datetime object is passed, it is returned unmodified.
|
||||
If the input is a string, it is parsed as a datetime.
|
||||
Converts the given object to a timezone aware datetime object.
|
||||
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 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 with time (Y-m-d H:M:S) or with date+time with microseconds
|
||||
(Y-m-d H:M:S.micro).
|
||||
|
||||
: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 str arg_name: the name of the argument (used in an error message)
|
||||
:rtype: datetime
|
||||
"""
|
||||
if isinstance(input, datetime):
|
||||
return input
|
||||
|
||||
if input is None:
|
||||
return
|
||||
elif isinstance(input, datetime):
|
||||
datetime_ = input
|
||||
elif isinstance(input, date):
|
||||
return datetime.fromordinal(input.toordinal())
|
||||
elif isinstance(input, str):
|
||||
datetime_ = datetime.combine(input, time())
|
||||
elif isinstance(input, six.string_types):
|
||||
m = _DATE_REGEX.match(input)
|
||||
if not m:
|
||||
raise ValueError('Invalid date string')
|
||||
values = [(k, int(v or 0)) for k, v in m.groupdict().items()]
|
||||
values = dict(values)
|
||||
return datetime(**values)
|
||||
raise TypeError('Unsupported input type: %s' % type(input))
|
||||
datetime_ = datetime(**values)
|
||||
else:
|
||||
raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__))
|
||||
|
||||
if datetime_.tzinfo is not None:
|
||||
return datetime_
|
||||
if tz is None:
|
||||
raise ValueError('The "tz" argument must be specified if %s has no timezone information' % arg_name)
|
||||
if isinstance(tz, six.string_types):
|
||||
tz = timezone(tz)
|
||||
|
||||
try:
|
||||
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):
|
||||
"""
|
||||
Converts a datetime instance to a timestamp.
|
||||
|
||||
:type timeval: datetime
|
||||
:rtype: float
|
||||
"""
|
||||
|
||||
if timeval is not None:
|
||||
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
|
||||
|
||||
|
||||
def utc_timestamp_to_datetime(timestamp):
|
||||
"""
|
||||
Converts the given timestamp to a datetime instance.
|
||||
|
||||
:type timestamp: float
|
||||
:rtype: datetime
|
||||
"""
|
||||
|
||||
if timestamp is not None:
|
||||
return datetime.fromtimestamp(timestamp, utc)
|
||||
|
||||
|
||||
def timedelta_seconds(delta):
|
||||
@@ -80,125 +166,220 @@ def timedelta_seconds(delta):
|
||||
:type delta: timedelta
|
||||
:rtype: float
|
||||
"""
|
||||
|
||||
return delta.days * 24 * 60 * 60 + delta.seconds + \
|
||||
delta.microseconds / 1000000.0
|
||||
|
||||
|
||||
def time_difference(date1, date2):
|
||||
"""
|
||||
Returns the time difference in seconds between the given two
|
||||
datetime objects. The difference is calculated as: date1 - date2.
|
||||
|
||||
:param date1: the later datetime
|
||||
:type date1: datetime
|
||||
:param date2: the earlier datetime
|
||||
:type date2: datetime
|
||||
:rtype: float
|
||||
"""
|
||||
later = mktime(date1.timetuple()) + date1.microsecond / 1000000.0
|
||||
earlier = mktime(date2.timetuple()) + date2.microsecond / 1000000.0
|
||||
return later - earlier
|
||||
|
||||
|
||||
def datetime_ceil(dateval):
|
||||
"""
|
||||
Rounds the given datetime object upwards.
|
||||
|
||||
:type dateval: datetime
|
||||
"""
|
||||
|
||||
if dateval.microsecond > 0:
|
||||
return dateval + timedelta(seconds=1,
|
||||
microseconds=-dateval.microsecond)
|
||||
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
|
||||
return dateval
|
||||
|
||||
|
||||
def combine_opts(global_config, prefix, local_config={}):
|
||||
"""
|
||||
Returns a subdictionary from keys and values of ``global_config`` where
|
||||
the key starts with the given prefix, combined with options from
|
||||
local_config. The keys in the subdictionary have the prefix removed.
|
||||
|
||||
:type global_config: dict
|
||||
:type prefix: str
|
||||
:type local_config: dict
|
||||
:rtype: dict
|
||||
"""
|
||||
prefixlen = len(prefix)
|
||||
subconf = {}
|
||||
for key, value in global_config.items():
|
||||
if key.startswith(prefix):
|
||||
key = key[prefixlen:]
|
||||
subconf[key] = value
|
||||
subconf.update(local_config)
|
||||
return subconf
|
||||
def datetime_repr(dateval):
|
||||
return dateval.strftime('%Y-%m-%d %H:%M:%S %Z') if dateval else 'None'
|
||||
|
||||
|
||||
def get_callable_name(func):
|
||||
"""
|
||||
Returns the best available display name for the given function/callable.
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
name = func.__module__
|
||||
if hasattr(func, '__self__') and func.__self__:
|
||||
name += '.' + func.__self__.__name__
|
||||
elif hasattr(func, 'im_self') and func.im_self: # py2.4, 2.5
|
||||
name += '.' + func.im_self.__name__
|
||||
if hasattr(func, '__name__'):
|
||||
name += '.' + func.__name__
|
||||
return name
|
||||
|
||||
# the easy case (on Python 3.3+)
|
||||
if hasattr(func, '__qualname__'):
|
||||
return func.__qualname__
|
||||
|
||||
# class methods, bound and unbound methods
|
||||
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
|
||||
if f_self and hasattr(func, '__name__'):
|
||||
f_class = f_self if isinstance(f_self, type) else f_self.__class__
|
||||
else:
|
||||
f_class = getattr(func, 'im_class', None)
|
||||
|
||||
if f_class and hasattr(func, '__name__'):
|
||||
return '%s.%s' % (f_class.__name__, func.__name__)
|
||||
|
||||
# class or class instance
|
||||
if hasattr(func, '__call__'):
|
||||
# class
|
||||
if hasattr(func, '__name__'):
|
||||
return func.__name__
|
||||
|
||||
# instance of a class with a __call__ method
|
||||
return func.__class__.__name__
|
||||
|
||||
raise TypeError('Unable to determine a name for %r -- maybe it is not a callable?' % func)
|
||||
|
||||
|
||||
def obj_to_ref(obj):
|
||||
"""
|
||||
Returns the path to the given object.
|
||||
"""
|
||||
ref = '%s:%s' % (obj.__module__, obj.__name__)
|
||||
try:
|
||||
obj2 = ref_to_obj(ref)
|
||||
except AttributeError:
|
||||
pass
|
||||
else:
|
||||
if obj2 == obj:
|
||||
return ref
|
||||
|
||||
raise ValueError('Only module level objects are supported')
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
try:
|
||||
ref = '%s:%s' % (obj.__module__, get_callable_name(obj))
|
||||
obj2 = ref_to_obj(ref)
|
||||
if obj != obj2:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
raise ValueError('Cannot determine the reference to %r' % obj)
|
||||
|
||||
return ref
|
||||
|
||||
|
||||
def ref_to_obj(ref):
|
||||
"""
|
||||
Returns the object pointed to by ``ref``.
|
||||
|
||||
:type ref: str
|
||||
"""
|
||||
|
||||
if not isinstance(ref, six.string_types):
|
||||
raise TypeError('References must be strings')
|
||||
if ':' not in ref:
|
||||
raise ValueError('Invalid reference')
|
||||
|
||||
modulename, rest = ref.split(':', 1)
|
||||
obj = __import__(modulename)
|
||||
for name in modulename.split('.')[1:] + rest.split('.'):
|
||||
obj = getattr(obj, name)
|
||||
return obj
|
||||
try:
|
||||
obj = __import__(modulename)
|
||||
except ImportError:
|
||||
raise LookupError('Error resolving reference %s: could not import module' % ref)
|
||||
|
||||
try:
|
||||
for name in modulename.split('.')[1:] + rest.split('.'):
|
||||
obj = getattr(obj, name)
|
||||
return obj
|
||||
except Exception:
|
||||
raise LookupError('Error resolving reference %s: error looking up object' % ref)
|
||||
|
||||
|
||||
def maybe_ref(ref):
|
||||
"""
|
||||
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.
|
||||
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 not isinstance(ref, str):
|
||||
return ref
|
||||
return ref_to_obj(ref)
|
||||
|
||||
|
||||
def to_unicode(string, encoding='ascii'):
|
||||
"""
|
||||
Safely converts a string to a unicode representation on any
|
||||
Python version.
|
||||
"""
|
||||
if hasattr(string, 'decode'):
|
||||
return string.decode(encoding, 'ignore')
|
||||
return string
|
||||
if six.PY2:
|
||||
def repr_escape(string):
|
||||
if isinstance(string, six.text_type):
|
||||
return string.encode('ascii', 'backslashreplace')
|
||||
return string
|
||||
else:
|
||||
repr_escape = lambda string: string
|
||||
|
||||
|
||||
if sys.version_info < (3, 0): # pragma: nocover
|
||||
iteritems = lambda d: d.iteritems()
|
||||
itervalues = lambda d: d.itervalues()
|
||||
xrange = xrange
|
||||
else: # pragma: nocover
|
||||
iteritems = lambda d: d.items()
|
||||
itervalues = lambda d: d.values()
|
||||
xrange = range
|
||||
def check_callable_args(func, args, kwargs):
|
||||
"""
|
||||
Ensures that the given callable can be called with the given arguments.
|
||||
|
||||
:type args: tuple
|
||||
:type kwargs: dict
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
has_varargs = has_var_kwargs = False # indicates if the signature defines *args and **kwargs respectively
|
||||
|
||||
if signature:
|
||||
try:
|
||||
sig = signature(func)
|
||||
except ValueError:
|
||||
return # signature() doesn't work against every kind of callable
|
||||
|
||||
for param in six.itervalues(sig.parameters):
|
||||
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
||||
if param.name in unmatched_kwargs and unmatched_args:
|
||||
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:
|
||||
del unmatched_args[0]
|
||||
elif arg in unmatched_kwargs:
|
||||
unmatched_kwargs.remove(arg)
|
||||
elif default is undefined:
|
||||
unsatisfied_args.append(arg)
|
||||
|
||||
# Make sure there are no conflicts between args and kwargs
|
||||
if pos_kwargs_conflicts:
|
||||
raise ValueError('The following arguments are supplied in both args and kwargs: %s' %
|
||||
', '.join(pos_kwargs_conflicts))
|
||||
|
||||
# Check if keyword arguments are being fed to positional-only parameters
|
||||
if positional_only_kwargs:
|
||||
raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
|
||||
', '.join(positional_only_kwargs))
|
||||
|
||||
# Check that the number of positional arguments minus the number of matched kwargs matches the argspec
|
||||
if unsatisfied_args:
|
||||
raise ValueError('The following arguments have not been supplied: %s' % ', '.join(unsatisfied_args))
|
||||
|
||||
# Check that all keyword-only arguments have been supplied
|
||||
if unsatisfied_kwargs:
|
||||
raise ValueError('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
|
||||
if not has_varargs and unmatched_args:
|
||||
raise ValueError('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
|
||||
if not has_var_kwargs and unmatched_kwargs:
|
||||
raise ValueError('The target callable does not accept the following keyword arguments: %s' %
|
||||
', '.join(unmatched_kwargs))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Copyright 2009 Brian Quinlan. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY BRIAN QUINLAN "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
|
||||
HALL THE FREEBSD PROJECT 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.
|
||||
@@ -0,0 +1,3 @@
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright 2009 Brian Quinlan. All Rights Reserved.
|
||||
# Licensed to PSF under a Contributor Agreement.
|
||||
|
||||
"""Execute computations asynchronously using threads or processes."""
|
||||
|
||||
__author__ = 'Brian Quinlan (brian@sweetapp.com)'
|
||||
|
||||
from concurrent.futures._base import (FIRST_COMPLETED,
|
||||
FIRST_EXCEPTION,
|
||||
ALL_COMPLETED,
|
||||
CancelledError,
|
||||
TimeoutError,
|
||||
Future,
|
||||
Executor,
|
||||
wait,
|
||||
as_completed)
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
|
||||
# Jython doesn't have multiprocessing
|
||||
try:
|
||||
from concurrent.futures.process import ProcessPoolExecutor
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,605 @@
|
||||
# Copyright 2009 Brian Quinlan. All Rights Reserved.
|
||||
# Licensed to PSF under a Contributor Agreement.
|
||||
|
||||
from __future__ import with_statement
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from concurrent.futures._compat import reraise
|
||||
|
||||
try:
|
||||
from collections import namedtuple
|
||||
except ImportError:
|
||||
from concurrent.futures._compat import namedtuple
|
||||
|
||||
__author__ = 'Brian Quinlan (brian@sweetapp.com)'
|
||||
|
||||
FIRST_COMPLETED = 'FIRST_COMPLETED'
|
||||
FIRST_EXCEPTION = 'FIRST_EXCEPTION'
|
||||
ALL_COMPLETED = 'ALL_COMPLETED'
|
||||
_AS_COMPLETED = '_AS_COMPLETED'
|
||||
|
||||
# Possible future states (for internal use by the futures package).
|
||||
PENDING = 'PENDING'
|
||||
RUNNING = 'RUNNING'
|
||||
# The future was cancelled by the user...
|
||||
CANCELLED = 'CANCELLED'
|
||||
# ...and _Waiter.add_cancelled() was called by a worker.
|
||||
CANCELLED_AND_NOTIFIED = 'CANCELLED_AND_NOTIFIED'
|
||||
FINISHED = 'FINISHED'
|
||||
|
||||
_FUTURE_STATES = [
|
||||
PENDING,
|
||||
RUNNING,
|
||||
CANCELLED,
|
||||
CANCELLED_AND_NOTIFIED,
|
||||
FINISHED
|
||||
]
|
||||
|
||||
_STATE_TO_DESCRIPTION_MAP = {
|
||||
PENDING: "pending",
|
||||
RUNNING: "running",
|
||||
CANCELLED: "cancelled",
|
||||
CANCELLED_AND_NOTIFIED: "cancelled",
|
||||
FINISHED: "finished"
|
||||
}
|
||||
|
||||
# Logger for internal use by the futures package.
|
||||
LOGGER = logging.getLogger("concurrent.futures")
|
||||
|
||||
class Error(Exception):
|
||||
"""Base class for all future-related exceptions."""
|
||||
pass
|
||||
|
||||
class CancelledError(Error):
|
||||
"""The Future was cancelled."""
|
||||
pass
|
||||
|
||||
class TimeoutError(Error):
|
||||
"""The operation exceeded the given deadline."""
|
||||
pass
|
||||
|
||||
class _Waiter(object):
|
||||
"""Provides the event that wait() and as_completed() block on."""
|
||||
def __init__(self):
|
||||
self.event = threading.Event()
|
||||
self.finished_futures = []
|
||||
|
||||
def add_result(self, future):
|
||||
self.finished_futures.append(future)
|
||||
|
||||
def add_exception(self, future):
|
||||
self.finished_futures.append(future)
|
||||
|
||||
def add_cancelled(self, future):
|
||||
self.finished_futures.append(future)
|
||||
|
||||
class _AsCompletedWaiter(_Waiter):
|
||||
"""Used by as_completed()."""
|
||||
|
||||
def __init__(self):
|
||||
super(_AsCompletedWaiter, self).__init__()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def add_result(self, future):
|
||||
with self.lock:
|
||||
super(_AsCompletedWaiter, self).add_result(future)
|
||||
self.event.set()
|
||||
|
||||
def add_exception(self, future):
|
||||
with self.lock:
|
||||
super(_AsCompletedWaiter, self).add_exception(future)
|
||||
self.event.set()
|
||||
|
||||
def add_cancelled(self, future):
|
||||
with self.lock:
|
||||
super(_AsCompletedWaiter, self).add_cancelled(future)
|
||||
self.event.set()
|
||||
|
||||
class _FirstCompletedWaiter(_Waiter):
|
||||
"""Used by wait(return_when=FIRST_COMPLETED)."""
|
||||
|
||||
def add_result(self, future):
|
||||
super(_FirstCompletedWaiter, self).add_result(future)
|
||||
self.event.set()
|
||||
|
||||
def add_exception(self, future):
|
||||
super(_FirstCompletedWaiter, self).add_exception(future)
|
||||
self.event.set()
|
||||
|
||||
def add_cancelled(self, future):
|
||||
super(_FirstCompletedWaiter, self).add_cancelled(future)
|
||||
self.event.set()
|
||||
|
||||
class _AllCompletedWaiter(_Waiter):
|
||||
"""Used by wait(return_when=FIRST_EXCEPTION and ALL_COMPLETED)."""
|
||||
|
||||
def __init__(self, num_pending_calls, stop_on_exception):
|
||||
self.num_pending_calls = num_pending_calls
|
||||
self.stop_on_exception = stop_on_exception
|
||||
self.lock = threading.Lock()
|
||||
super(_AllCompletedWaiter, self).__init__()
|
||||
|
||||
def _decrement_pending_calls(self):
|
||||
with self.lock:
|
||||
self.num_pending_calls -= 1
|
||||
if not self.num_pending_calls:
|
||||
self.event.set()
|
||||
|
||||
def add_result(self, future):
|
||||
super(_AllCompletedWaiter, self).add_result(future)
|
||||
self._decrement_pending_calls()
|
||||
|
||||
def add_exception(self, future):
|
||||
super(_AllCompletedWaiter, self).add_exception(future)
|
||||
if self.stop_on_exception:
|
||||
self.event.set()
|
||||
else:
|
||||
self._decrement_pending_calls()
|
||||
|
||||
def add_cancelled(self, future):
|
||||
super(_AllCompletedWaiter, self).add_cancelled(future)
|
||||
self._decrement_pending_calls()
|
||||
|
||||
class _AcquireFutures(object):
|
||||
"""A context manager that does an ordered acquire of Future conditions."""
|
||||
|
||||
def __init__(self, futures):
|
||||
self.futures = sorted(futures, key=id)
|
||||
|
||||
def __enter__(self):
|
||||
for future in self.futures:
|
||||
future._condition.acquire()
|
||||
|
||||
def __exit__(self, *args):
|
||||
for future in self.futures:
|
||||
future._condition.release()
|
||||
|
||||
def _create_and_install_waiters(fs, return_when):
|
||||
if return_when == _AS_COMPLETED:
|
||||
waiter = _AsCompletedWaiter()
|
||||
elif return_when == FIRST_COMPLETED:
|
||||
waiter = _FirstCompletedWaiter()
|
||||
else:
|
||||
pending_count = sum(
|
||||
f._state not in [CANCELLED_AND_NOTIFIED, FINISHED] for f in fs)
|
||||
|
||||
if return_when == FIRST_EXCEPTION:
|
||||
waiter = _AllCompletedWaiter(pending_count, stop_on_exception=True)
|
||||
elif return_when == ALL_COMPLETED:
|
||||
waiter = _AllCompletedWaiter(pending_count, stop_on_exception=False)
|
||||
else:
|
||||
raise ValueError("Invalid return condition: %r" % return_when)
|
||||
|
||||
for f in fs:
|
||||
f._waiters.append(waiter)
|
||||
|
||||
return waiter
|
||||
|
||||
def as_completed(fs, timeout=None):
|
||||
"""An iterator over the given futures that yields each as it completes.
|
||||
|
||||
Args:
|
||||
fs: The sequence of Futures (possibly created by different Executors) to
|
||||
iterate over.
|
||||
timeout: The maximum number of seconds to wait. If None, then there
|
||||
is no limit on the wait time.
|
||||
|
||||
Returns:
|
||||
An iterator that yields the given Futures as they complete (finished or
|
||||
cancelled).
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the entire result iterator could not be generated
|
||||
before the given timeout.
|
||||
"""
|
||||
if timeout is not None:
|
||||
end_time = timeout + time.time()
|
||||
|
||||
with _AcquireFutures(fs):
|
||||
finished = set(
|
||||
f for f in fs
|
||||
if f._state in [CANCELLED_AND_NOTIFIED, FINISHED])
|
||||
pending = set(fs) - finished
|
||||
waiter = _create_and_install_waiters(fs, _AS_COMPLETED)
|
||||
|
||||
try:
|
||||
for future in finished:
|
||||
yield future
|
||||
|
||||
while pending:
|
||||
if timeout is None:
|
||||
wait_timeout = None
|
||||
else:
|
||||
wait_timeout = end_time - time.time()
|
||||
if wait_timeout < 0:
|
||||
raise TimeoutError(
|
||||
'%d (of %d) futures unfinished' % (
|
||||
len(pending), len(fs)))
|
||||
|
||||
waiter.event.wait(wait_timeout)
|
||||
|
||||
with waiter.lock:
|
||||
finished = waiter.finished_futures
|
||||
waiter.finished_futures = []
|
||||
waiter.event.clear()
|
||||
|
||||
for future in finished:
|
||||
yield future
|
||||
pending.remove(future)
|
||||
|
||||
finally:
|
||||
for f in fs:
|
||||
f._waiters.remove(waiter)
|
||||
|
||||
DoneAndNotDoneFutures = namedtuple(
|
||||
'DoneAndNotDoneFutures', 'done not_done')
|
||||
def wait(fs, timeout=None, return_when=ALL_COMPLETED):
|
||||
"""Wait for the futures in the given sequence to complete.
|
||||
|
||||
Args:
|
||||
fs: The sequence of Futures (possibly created by different Executors) to
|
||||
wait upon.
|
||||
timeout: The maximum number of seconds to wait. If None, then there
|
||||
is no limit on the wait time.
|
||||
return_when: Indicates when this function should return. The options
|
||||
are:
|
||||
|
||||
FIRST_COMPLETED - Return when any future finishes or is
|
||||
cancelled.
|
||||
FIRST_EXCEPTION - Return when any future finishes by raising an
|
||||
exception. If no future raises an exception
|
||||
then it is equivalent to ALL_COMPLETED.
|
||||
ALL_COMPLETED - Return when all futures finish or are cancelled.
|
||||
|
||||
Returns:
|
||||
A named 2-tuple of sets. The first set, named 'done', contains the
|
||||
futures that completed (is finished or cancelled) before the wait
|
||||
completed. The second set, named 'not_done', contains uncompleted
|
||||
futures.
|
||||
"""
|
||||
with _AcquireFutures(fs):
|
||||
done = set(f for f in fs
|
||||
if f._state in [CANCELLED_AND_NOTIFIED, FINISHED])
|
||||
not_done = set(fs) - done
|
||||
|
||||
if (return_when == FIRST_COMPLETED) and done:
|
||||
return DoneAndNotDoneFutures(done, not_done)
|
||||
elif (return_when == FIRST_EXCEPTION) and done:
|
||||
if any(f for f in done
|
||||
if not f.cancelled() and f.exception() is not None):
|
||||
return DoneAndNotDoneFutures(done, not_done)
|
||||
|
||||
if len(done) == len(fs):
|
||||
return DoneAndNotDoneFutures(done, not_done)
|
||||
|
||||
waiter = _create_and_install_waiters(fs, return_when)
|
||||
|
||||
waiter.event.wait(timeout)
|
||||
for f in fs:
|
||||
f._waiters.remove(waiter)
|
||||
|
||||
done.update(waiter.finished_futures)
|
||||
return DoneAndNotDoneFutures(done, set(fs) - done)
|
||||
|
||||
class Future(object):
|
||||
"""Represents the result of an asynchronous computation."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initializes the future. Should not be called by clients."""
|
||||
self._condition = threading.Condition()
|
||||
self._state = PENDING
|
||||
self._result = None
|
||||
self._exception = None
|
||||
self._traceback = None
|
||||
self._waiters = []
|
||||
self._done_callbacks = []
|
||||
|
||||
def _invoke_callbacks(self):
|
||||
for callback in self._done_callbacks:
|
||||
try:
|
||||
callback(self)
|
||||
except Exception:
|
||||
LOGGER.exception('exception calling callback for %r', self)
|
||||
|
||||
def __repr__(self):
|
||||
with self._condition:
|
||||
if self._state == FINISHED:
|
||||
if self._exception:
|
||||
return '<Future at %s state=%s raised %s>' % (
|
||||
hex(id(self)),
|
||||
_STATE_TO_DESCRIPTION_MAP[self._state],
|
||||
self._exception.__class__.__name__)
|
||||
else:
|
||||
return '<Future at %s state=%s returned %s>' % (
|
||||
hex(id(self)),
|
||||
_STATE_TO_DESCRIPTION_MAP[self._state],
|
||||
self._result.__class__.__name__)
|
||||
return '<Future at %s state=%s>' % (
|
||||
hex(id(self)),
|
||||
_STATE_TO_DESCRIPTION_MAP[self._state])
|
||||
|
||||
def cancel(self):
|
||||
"""Cancel the future if possible.
|
||||
|
||||
Returns True if the future was cancelled, False otherwise. A future
|
||||
cannot be cancelled if it is running or has already completed.
|
||||
"""
|
||||
with self._condition:
|
||||
if self._state in [RUNNING, FINISHED]:
|
||||
return False
|
||||
|
||||
if self._state in [CANCELLED, CANCELLED_AND_NOTIFIED]:
|
||||
return True
|
||||
|
||||
self._state = CANCELLED
|
||||
self._condition.notify_all()
|
||||
|
||||
self._invoke_callbacks()
|
||||
return True
|
||||
|
||||
def cancelled(self):
|
||||
"""Return True if the future has cancelled."""
|
||||
with self._condition:
|
||||
return self._state in [CANCELLED, CANCELLED_AND_NOTIFIED]
|
||||
|
||||
def running(self):
|
||||
"""Return True if the future is currently executing."""
|
||||
with self._condition:
|
||||
return self._state == RUNNING
|
||||
|
||||
def done(self):
|
||||
"""Return True of the future was cancelled or finished executing."""
|
||||
with self._condition:
|
||||
return self._state in [CANCELLED, CANCELLED_AND_NOTIFIED, FINISHED]
|
||||
|
||||
def __get_result(self):
|
||||
if self._exception:
|
||||
reraise(self._exception, self._traceback)
|
||||
else:
|
||||
return self._result
|
||||
|
||||
def add_done_callback(self, fn):
|
||||
"""Attaches a callable that will be called when the future finishes.
|
||||
|
||||
Args:
|
||||
fn: A callable that will be called with this future as its only
|
||||
argument when the future completes or is cancelled. The callable
|
||||
will always be called by a thread in the same process in which
|
||||
it was added. If the future has already completed or been
|
||||
cancelled then the callable will be called immediately. These
|
||||
callables are called in the order that they were added.
|
||||
"""
|
||||
with self._condition:
|
||||
if self._state not in [CANCELLED, CANCELLED_AND_NOTIFIED, FINISHED]:
|
||||
self._done_callbacks.append(fn)
|
||||
return
|
||||
fn(self)
|
||||
|
||||
def result(self, timeout=None):
|
||||
"""Return the result of the call that the future represents.
|
||||
|
||||
Args:
|
||||
timeout: The number of seconds to wait for the result if the future
|
||||
isn't done. If None, then there is no limit on the wait time.
|
||||
|
||||
Returns:
|
||||
The result of the call that the future represents.
|
||||
|
||||
Raises:
|
||||
CancelledError: If the future was cancelled.
|
||||
TimeoutError: If the future didn't finish executing before the given
|
||||
timeout.
|
||||
Exception: If the call raised then that exception will be raised.
|
||||
"""
|
||||
with self._condition:
|
||||
if self._state in [CANCELLED, CANCELLED_AND_NOTIFIED]:
|
||||
raise CancelledError()
|
||||
elif self._state == FINISHED:
|
||||
return self.__get_result()
|
||||
|
||||
self._condition.wait(timeout)
|
||||
|
||||
if self._state in [CANCELLED, CANCELLED_AND_NOTIFIED]:
|
||||
raise CancelledError()
|
||||
elif self._state == FINISHED:
|
||||
return self.__get_result()
|
||||
else:
|
||||
raise TimeoutError()
|
||||
|
||||
def exception_info(self, timeout=None):
|
||||
"""Return a tuple of (exception, traceback) raised by the call that the
|
||||
future represents.
|
||||
|
||||
Args:
|
||||
timeout: The number of seconds to wait for the exception if the
|
||||
future isn't done. If None, then there is no limit on the wait
|
||||
time.
|
||||
|
||||
Returns:
|
||||
The exception raised by the call that the future represents or None
|
||||
if the call completed without raising.
|
||||
|
||||
Raises:
|
||||
CancelledError: If the future was cancelled.
|
||||
TimeoutError: If the future didn't finish executing before the given
|
||||
timeout.
|
||||
"""
|
||||
with self._condition:
|
||||
if self._state in [CANCELLED, CANCELLED_AND_NOTIFIED]:
|
||||
raise CancelledError()
|
||||
elif self._state == FINISHED:
|
||||
return self._exception, self._traceback
|
||||
|
||||
self._condition.wait(timeout)
|
||||
|
||||
if self._state in [CANCELLED, CANCELLED_AND_NOTIFIED]:
|
||||
raise CancelledError()
|
||||
elif self._state == FINISHED:
|
||||
return self._exception, self._traceback
|
||||
else:
|
||||
raise TimeoutError()
|
||||
|
||||
def exception(self, timeout=None):
|
||||
"""Return the exception raised by the call that the future represents.
|
||||
|
||||
Args:
|
||||
timeout: The number of seconds to wait for the exception if the
|
||||
future isn't done. If None, then there is no limit on the wait
|
||||
time.
|
||||
|
||||
Returns:
|
||||
The exception raised by the call that the future represents or None
|
||||
if the call completed without raising.
|
||||
|
||||
Raises:
|
||||
CancelledError: If the future was cancelled.
|
||||
TimeoutError: If the future didn't finish executing before the given
|
||||
timeout.
|
||||
"""
|
||||
return self.exception_info(timeout)[0]
|
||||
|
||||
# The following methods should only be used by Executors and in tests.
|
||||
def set_running_or_notify_cancel(self):
|
||||
"""Mark the future as running or process any cancel notifications.
|
||||
|
||||
Should only be used by Executor implementations and unit tests.
|
||||
|
||||
If the future has been cancelled (cancel() was called and returned
|
||||
True) then any threads waiting on the future completing (though calls
|
||||
to as_completed() or wait()) are notified and False is returned.
|
||||
|
||||
If the future was not cancelled then it is put in the running state
|
||||
(future calls to running() will return True) and True is returned.
|
||||
|
||||
This method should be called by Executor implementations before
|
||||
executing the work associated with this future. If this method returns
|
||||
False then the work should not be executed.
|
||||
|
||||
Returns:
|
||||
False if the Future was cancelled, True otherwise.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if this method was already called or if set_result()
|
||||
or set_exception() was called.
|
||||
"""
|
||||
with self._condition:
|
||||
if self._state == CANCELLED:
|
||||
self._state = CANCELLED_AND_NOTIFIED
|
||||
for waiter in self._waiters:
|
||||
waiter.add_cancelled(self)
|
||||
# self._condition.notify_all() is not necessary because
|
||||
# self.cancel() triggers a notification.
|
||||
return False
|
||||
elif self._state == PENDING:
|
||||
self._state = RUNNING
|
||||
return True
|
||||
else:
|
||||
LOGGER.critical('Future %s in unexpected state: %s',
|
||||
id(self.future),
|
||||
self.future._state)
|
||||
raise RuntimeError('Future in unexpected state')
|
||||
|
||||
def set_result(self, result):
|
||||
"""Sets the return value of work associated with the future.
|
||||
|
||||
Should only be used by Executor implementations and unit tests.
|
||||
"""
|
||||
with self._condition:
|
||||
self._result = result
|
||||
self._state = FINISHED
|
||||
for waiter in self._waiters:
|
||||
waiter.add_result(self)
|
||||
self._condition.notify_all()
|
||||
self._invoke_callbacks()
|
||||
|
||||
def set_exception_info(self, exception, traceback):
|
||||
"""Sets the result of the future as being the given exception
|
||||
and traceback.
|
||||
|
||||
Should only be used by Executor implementations and unit tests.
|
||||
"""
|
||||
with self._condition:
|
||||
self._exception = exception
|
||||
self._traceback = traceback
|
||||
self._state = FINISHED
|
||||
for waiter in self._waiters:
|
||||
waiter.add_exception(self)
|
||||
self._condition.notify_all()
|
||||
self._invoke_callbacks()
|
||||
|
||||
def set_exception(self, exception):
|
||||
"""Sets the result of the future as being the given exception.
|
||||
|
||||
Should only be used by Executor implementations and unit tests.
|
||||
"""
|
||||
self.set_exception_info(exception, None)
|
||||
|
||||
class Executor(object):
|
||||
"""This is an abstract base class for concrete asynchronous executors."""
|
||||
|
||||
def submit(self, fn, *args, **kwargs):
|
||||
"""Submits a callable to be executed with the given arguments.
|
||||
|
||||
Schedules the callable to be executed as fn(*args, **kwargs) and returns
|
||||
a Future instance representing the execution of the callable.
|
||||
|
||||
Returns:
|
||||
A Future representing the given call.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def map(self, fn, *iterables, **kwargs):
|
||||
"""Returns a iterator equivalent to map(fn, iter).
|
||||
|
||||
Args:
|
||||
fn: A callable that will take as many arguments as there are
|
||||
passed iterables.
|
||||
timeout: The maximum number of seconds to wait. If None, then there
|
||||
is no limit on the wait time.
|
||||
|
||||
Returns:
|
||||
An iterator equivalent to: map(func, *iterables) but the calls may
|
||||
be evaluated out-of-order.
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the entire result iterator could not be generated
|
||||
before the given timeout.
|
||||
Exception: If fn(*args) raises for any values.
|
||||
"""
|
||||
timeout = kwargs.get('timeout')
|
||||
if timeout is not None:
|
||||
end_time = timeout + time.time()
|
||||
|
||||
fs = [self.submit(fn, *args) for args in zip(*iterables)]
|
||||
|
||||
try:
|
||||
for future in fs:
|
||||
if timeout is None:
|
||||
yield future.result()
|
||||
else:
|
||||
yield future.result(end_time - time.time())
|
||||
finally:
|
||||
for future in fs:
|
||||
future.cancel()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
"""Clean-up the resources associated with the Executor.
|
||||
|
||||
It is safe to call this method several times. Otherwise, no other
|
||||
methods can be called after this one.
|
||||
|
||||
Args:
|
||||
wait: If True then shutdown will not return until all running
|
||||
futures have finished executing and the resources used by the
|
||||
executor have been reclaimed.
|
||||
"""
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.shutdown(wait=True)
|
||||
return False
|
||||
@@ -0,0 +1,111 @@
|
||||
from keyword import iskeyword as _iskeyword
|
||||
from operator import itemgetter as _itemgetter
|
||||
import sys as _sys
|
||||
|
||||
|
||||
def namedtuple(typename, field_names):
|
||||
"""Returns a new subclass of tuple with named fields.
|
||||
|
||||
>>> Point = namedtuple('Point', 'x y')
|
||||
>>> Point.__doc__ # docstring for the new class
|
||||
'Point(x, y)'
|
||||
>>> p = Point(11, y=22) # instantiate with positional args or keywords
|
||||
>>> p[0] + p[1] # indexable like a plain tuple
|
||||
33
|
||||
>>> x, y = p # unpack like a regular tuple
|
||||
>>> x, y
|
||||
(11, 22)
|
||||
>>> p.x + p.y # fields also accessable by name
|
||||
33
|
||||
>>> d = p._asdict() # convert to a dictionary
|
||||
>>> d['x']
|
||||
11
|
||||
>>> Point(**d) # convert from a dictionary
|
||||
Point(x=11, y=22)
|
||||
>>> p._replace(x=100) # _replace() is like str.replace() but targets named fields
|
||||
Point(x=100, y=22)
|
||||
|
||||
"""
|
||||
|
||||
# Parse and validate the field names. Validation serves two purposes,
|
||||
# generating informative error messages and preventing template injection attacks.
|
||||
if isinstance(field_names, basestring):
|
||||
field_names = field_names.replace(',', ' ').split() # names separated by whitespace and/or commas
|
||||
field_names = tuple(map(str, field_names))
|
||||
for name in (typename,) + field_names:
|
||||
if not all(c.isalnum() or c=='_' for c in name):
|
||||
raise ValueError('Type names and field names can only contain alphanumeric characters and underscores: %r' % name)
|
||||
if _iskeyword(name):
|
||||
raise ValueError('Type names and field names cannot be a keyword: %r' % name)
|
||||
if name[0].isdigit():
|
||||
raise ValueError('Type names and field names cannot start with a number: %r' % name)
|
||||
seen_names = set()
|
||||
for name in field_names:
|
||||
if name.startswith('_'):
|
||||
raise ValueError('Field names cannot start with an underscore: %r' % name)
|
||||
if name in seen_names:
|
||||
raise ValueError('Encountered duplicate field name: %r' % name)
|
||||
seen_names.add(name)
|
||||
|
||||
# Create and fill-in the class template
|
||||
numfields = len(field_names)
|
||||
argtxt = repr(field_names).replace("'", "")[1:-1] # tuple repr without parens or quotes
|
||||
reprtxt = ', '.join('%s=%%r' % name for name in field_names)
|
||||
dicttxt = ', '.join('%r: t[%d]' % (name, pos) for pos, name in enumerate(field_names))
|
||||
template = '''class %(typename)s(tuple):
|
||||
'%(typename)s(%(argtxt)s)' \n
|
||||
__slots__ = () \n
|
||||
_fields = %(field_names)r \n
|
||||
def __new__(_cls, %(argtxt)s):
|
||||
return _tuple.__new__(_cls, (%(argtxt)s)) \n
|
||||
@classmethod
|
||||
def _make(cls, iterable, new=tuple.__new__, len=len):
|
||||
'Make a new %(typename)s object from a sequence or iterable'
|
||||
result = new(cls, iterable)
|
||||
if len(result) != %(numfields)d:
|
||||
raise TypeError('Expected %(numfields)d arguments, got %%d' %% len(result))
|
||||
return result \n
|
||||
def __repr__(self):
|
||||
return '%(typename)s(%(reprtxt)s)' %% self \n
|
||||
def _asdict(t):
|
||||
'Return a new dict which maps field names to their values'
|
||||
return {%(dicttxt)s} \n
|
||||
def _replace(_self, **kwds):
|
||||
'Return a new %(typename)s object replacing specified fields with new values'
|
||||
result = _self._make(map(kwds.pop, %(field_names)r, _self))
|
||||
if kwds:
|
||||
raise ValueError('Got unexpected field names: %%r' %% kwds.keys())
|
||||
return result \n
|
||||
def __getnewargs__(self):
|
||||
return tuple(self) \n\n''' % locals()
|
||||
for i, name in enumerate(field_names):
|
||||
template += ' %s = _property(_itemgetter(%d))\n' % (name, i)
|
||||
|
||||
# Execute the template string in a temporary namespace and
|
||||
# support tracing utilities by setting a value for frame.f_globals['__name__']
|
||||
namespace = dict(_itemgetter=_itemgetter, __name__='namedtuple_%s' % typename,
|
||||
_property=property, _tuple=tuple)
|
||||
try:
|
||||
exec(template, namespace)
|
||||
except SyntaxError:
|
||||
e = _sys.exc_info()[1]
|
||||
raise SyntaxError(e.message + ':\n' + template)
|
||||
result = namespace[typename]
|
||||
|
||||
# For pickling to work, the __module__ variable needs to be set to the frame
|
||||
# where the named tuple is created. Bypass this step in enviroments where
|
||||
# sys._getframe is not defined (Jython for example).
|
||||
if hasattr(_sys, '_getframe'):
|
||||
result.__module__ = _sys._getframe(1).f_globals.get('__name__', '__main__')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if _sys.version_info[0] < 3:
|
||||
def reraise(exc, traceback):
|
||||
locals_ = {'exc_type': type(exc), 'exc_value': exc, 'traceback': traceback}
|
||||
exec('raise exc_type, exc_value, traceback', {}, locals_)
|
||||
else:
|
||||
def reraise(exc, traceback):
|
||||
# Tracebacks are embedded in exceptions in Python 3
|
||||
raise exc
|
||||
@@ -0,0 +1,363 @@
|
||||
# Copyright 2009 Brian Quinlan. All Rights Reserved.
|
||||
# Licensed to PSF under a Contributor Agreement.
|
||||
|
||||
"""Implements ProcessPoolExecutor.
|
||||
|
||||
The follow diagram and text describe the data-flow through the system:
|
||||
|
||||
|======================= In-process =====================|== Out-of-process ==|
|
||||
|
||||
+----------+ +----------+ +--------+ +-----------+ +---------+
|
||||
| | => | Work Ids | => | | => | Call Q | => | |
|
||||
| | +----------+ | | +-----------+ | |
|
||||
| | | ... | | | | ... | | |
|
||||
| | | 6 | | | | 5, call() | | |
|
||||
| | | 7 | | | | ... | | |
|
||||
| Process | | ... | | Local | +-----------+ | Process |
|
||||
| Pool | +----------+ | Worker | | #1..n |
|
||||
| Executor | | Thread | | |
|
||||
| | +----------- + | | +-----------+ | |
|
||||
| | <=> | Work Items | <=> | | <= | Result Q | <= | |
|
||||
| | +------------+ | | +-----------+ | |
|
||||
| | | 6: call() | | | | ... | | |
|
||||
| | | future | | | | 4, result | | |
|
||||
| | | ... | | | | 3, except | | |
|
||||
+----------+ +------------+ +--------+ +-----------+ +---------+
|
||||
|
||||
Executor.submit() called:
|
||||
- creates a uniquely numbered _WorkItem and adds it to the "Work Items" dict
|
||||
- adds the id of the _WorkItem to the "Work Ids" queue
|
||||
|
||||
Local worker thread:
|
||||
- reads work ids from the "Work Ids" queue and looks up the corresponding
|
||||
WorkItem from the "Work Items" dict: if the work item has been cancelled then
|
||||
it is simply removed from the dict, otherwise it is repackaged as a
|
||||
_CallItem and put in the "Call Q". New _CallItems are put in the "Call Q"
|
||||
until "Call Q" is full. NOTE: the size of the "Call Q" is kept small because
|
||||
calls placed in the "Call Q" can no longer be cancelled with Future.cancel().
|
||||
- reads _ResultItems from "Result Q", updates the future stored in the
|
||||
"Work Items" dict and deletes the dict entry
|
||||
|
||||
Process #1..n:
|
||||
- reads _CallItems from "Call Q", executes the calls, and puts the resulting
|
||||
_ResultItems in "Request Q"
|
||||
"""
|
||||
|
||||
from __future__ import with_statement
|
||||
import atexit
|
||||
import multiprocessing
|
||||
import threading
|
||||
import weakref
|
||||
import sys
|
||||
|
||||
from concurrent.futures import _base
|
||||
|
||||
try:
|
||||
import queue
|
||||
except ImportError:
|
||||
import Queue as queue
|
||||
|
||||
__author__ = 'Brian Quinlan (brian@sweetapp.com)'
|
||||
|
||||
# Workers are created as daemon threads and processes. This is done to allow the
|
||||
# interpreter to exit when there are still idle processes in a
|
||||
# ProcessPoolExecutor's process pool (i.e. shutdown() was not called). However,
|
||||
# allowing workers to die with the interpreter has two undesirable properties:
|
||||
# - The workers would still be running during interpretor shutdown,
|
||||
# meaning that they would fail in unpredictable ways.
|
||||
# - The workers could be killed while evaluating a work item, which could
|
||||
# be bad if the callable being evaluated has external side-effects e.g.
|
||||
# writing to a file.
|
||||
#
|
||||
# To work around this problem, an exit handler is installed which tells the
|
||||
# workers to exit when their work queues are empty and then waits until the
|
||||
# threads/processes finish.
|
||||
|
||||
_threads_queues = weakref.WeakKeyDictionary()
|
||||
_shutdown = False
|
||||
|
||||
def _python_exit():
|
||||
global _shutdown
|
||||
_shutdown = True
|
||||
items = list(_threads_queues.items())
|
||||
for t, q in items:
|
||||
q.put(None)
|
||||
for t, q in items:
|
||||
t.join()
|
||||
|
||||
# Controls how many more calls than processes will be queued in the call queue.
|
||||
# A smaller number will mean that processes spend more time idle waiting for
|
||||
# work while a larger number will make Future.cancel() succeed less frequently
|
||||
# (Futures in the call queue cannot be cancelled).
|
||||
EXTRA_QUEUED_CALLS = 1
|
||||
|
||||
class _WorkItem(object):
|
||||
def __init__(self, future, fn, args, kwargs):
|
||||
self.future = future
|
||||
self.fn = fn
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
class _ResultItem(object):
|
||||
def __init__(self, work_id, exception=None, result=None):
|
||||
self.work_id = work_id
|
||||
self.exception = exception
|
||||
self.result = result
|
||||
|
||||
class _CallItem(object):
|
||||
def __init__(self, work_id, fn, args, kwargs):
|
||||
self.work_id = work_id
|
||||
self.fn = fn
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def _process_worker(call_queue, result_queue):
|
||||
"""Evaluates calls from call_queue and places the results in result_queue.
|
||||
|
||||
This worker is run in a separate process.
|
||||
|
||||
Args:
|
||||
call_queue: A multiprocessing.Queue of _CallItems that will be read and
|
||||
evaluated by the worker.
|
||||
result_queue: A multiprocessing.Queue of _ResultItems that will written
|
||||
to by the worker.
|
||||
shutdown: A multiprocessing.Event that will be set as a signal to the
|
||||
worker that it should exit when call_queue is empty.
|
||||
"""
|
||||
while True:
|
||||
call_item = call_queue.get(block=True)
|
||||
if call_item is None:
|
||||
# Wake up queue management thread
|
||||
result_queue.put(None)
|
||||
return
|
||||
try:
|
||||
r = call_item.fn(*call_item.args, **call_item.kwargs)
|
||||
except BaseException:
|
||||
e = sys.exc_info()[1]
|
||||
result_queue.put(_ResultItem(call_item.work_id,
|
||||
exception=e))
|
||||
else:
|
||||
result_queue.put(_ResultItem(call_item.work_id,
|
||||
result=r))
|
||||
|
||||
def _add_call_item_to_queue(pending_work_items,
|
||||
work_ids,
|
||||
call_queue):
|
||||
"""Fills call_queue with _WorkItems from pending_work_items.
|
||||
|
||||
This function never blocks.
|
||||
|
||||
Args:
|
||||
pending_work_items: A dict mapping work ids to _WorkItems e.g.
|
||||
{5: <_WorkItem...>, 6: <_WorkItem...>, ...}
|
||||
work_ids: A queue.Queue of work ids e.g. Queue([5, 6, ...]). Work ids
|
||||
are consumed and the corresponding _WorkItems from
|
||||
pending_work_items are transformed into _CallItems and put in
|
||||
call_queue.
|
||||
call_queue: A multiprocessing.Queue that will be filled with _CallItems
|
||||
derived from _WorkItems.
|
||||
"""
|
||||
while True:
|
||||
if call_queue.full():
|
||||
return
|
||||
try:
|
||||
work_id = work_ids.get(block=False)
|
||||
except queue.Empty:
|
||||
return
|
||||
else:
|
||||
work_item = pending_work_items[work_id]
|
||||
|
||||
if work_item.future.set_running_or_notify_cancel():
|
||||
call_queue.put(_CallItem(work_id,
|
||||
work_item.fn,
|
||||
work_item.args,
|
||||
work_item.kwargs),
|
||||
block=True)
|
||||
else:
|
||||
del pending_work_items[work_id]
|
||||
continue
|
||||
|
||||
def _queue_management_worker(executor_reference,
|
||||
processes,
|
||||
pending_work_items,
|
||||
work_ids_queue,
|
||||
call_queue,
|
||||
result_queue):
|
||||
"""Manages the communication between this process and the worker processes.
|
||||
|
||||
This function is run in a local thread.
|
||||
|
||||
Args:
|
||||
executor_reference: A weakref.ref to the ProcessPoolExecutor that owns
|
||||
this thread. Used to determine if the ProcessPoolExecutor has been
|
||||
garbage collected and that this function can exit.
|
||||
process: A list of the multiprocessing.Process instances used as
|
||||
workers.
|
||||
pending_work_items: A dict mapping work ids to _WorkItems e.g.
|
||||
{5: <_WorkItem...>, 6: <_WorkItem...>, ...}
|
||||
work_ids_queue: A queue.Queue of work ids e.g. Queue([5, 6, ...]).
|
||||
call_queue: A multiprocessing.Queue that will be filled with _CallItems
|
||||
derived from _WorkItems for processing by the process workers.
|
||||
result_queue: A multiprocessing.Queue of _ResultItems generated by the
|
||||
process workers.
|
||||
"""
|
||||
nb_shutdown_processes = [0]
|
||||
def shutdown_one_process():
|
||||
"""Tell a worker to terminate, which will in turn wake us again"""
|
||||
call_queue.put(None)
|
||||
nb_shutdown_processes[0] += 1
|
||||
while True:
|
||||
_add_call_item_to_queue(pending_work_items,
|
||||
work_ids_queue,
|
||||
call_queue)
|
||||
|
||||
result_item = result_queue.get(block=True)
|
||||
if result_item is not None:
|
||||
work_item = pending_work_items[result_item.work_id]
|
||||
del pending_work_items[result_item.work_id]
|
||||
|
||||
if result_item.exception:
|
||||
work_item.future.set_exception(result_item.exception)
|
||||
else:
|
||||
work_item.future.set_result(result_item.result)
|
||||
# Check whether we should start shutting down.
|
||||
executor = executor_reference()
|
||||
# No more work items can be added if:
|
||||
# - The interpreter is shutting down OR
|
||||
# - The executor that owns this worker has been collected OR
|
||||
# - The executor that owns this worker has been shutdown.
|
||||
if _shutdown or executor is None or executor._shutdown_thread:
|
||||
# Since no new work items can be added, it is safe to shutdown
|
||||
# this thread if there are no pending work items.
|
||||
if not pending_work_items:
|
||||
while nb_shutdown_processes[0] < len(processes):
|
||||
shutdown_one_process()
|
||||
# If .join() is not called on the created processes then
|
||||
# some multiprocessing.Queue methods may deadlock on Mac OS
|
||||
# X.
|
||||
for p in processes:
|
||||
p.join()
|
||||
call_queue.close()
|
||||
return
|
||||
del executor
|
||||
|
||||
_system_limits_checked = False
|
||||
_system_limited = None
|
||||
def _check_system_limits():
|
||||
global _system_limits_checked, _system_limited
|
||||
if _system_limits_checked:
|
||||
if _system_limited:
|
||||
raise NotImplementedError(_system_limited)
|
||||
_system_limits_checked = True
|
||||
try:
|
||||
import os
|
||||
nsems_max = os.sysconf("SC_SEM_NSEMS_MAX")
|
||||
except (AttributeError, ValueError):
|
||||
# sysconf not available or setting not available
|
||||
return
|
||||
if nsems_max == -1:
|
||||
# indetermine limit, assume that limit is determined
|
||||
# by available memory only
|
||||
return
|
||||
if nsems_max >= 256:
|
||||
# minimum number of semaphores available
|
||||
# according to POSIX
|
||||
return
|
||||
_system_limited = "system provides too few semaphores (%d available, 256 necessary)" % nsems_max
|
||||
raise NotImplementedError(_system_limited)
|
||||
|
||||
class ProcessPoolExecutor(_base.Executor):
|
||||
def __init__(self, max_workers=None):
|
||||
"""Initializes a new ProcessPoolExecutor instance.
|
||||
|
||||
Args:
|
||||
max_workers: The maximum number of processes that can be used to
|
||||
execute the given calls. If None or not given then as many
|
||||
worker processes will be created as the machine has processors.
|
||||
"""
|
||||
_check_system_limits()
|
||||
|
||||
if max_workers is None:
|
||||
self._max_workers = multiprocessing.cpu_count()
|
||||
else:
|
||||
self._max_workers = max_workers
|
||||
|
||||
# Make the call queue slightly larger than the number of processes to
|
||||
# prevent the worker processes from idling. But don't make it too big
|
||||
# because futures in the call queue cannot be cancelled.
|
||||
self._call_queue = multiprocessing.Queue(self._max_workers +
|
||||
EXTRA_QUEUED_CALLS)
|
||||
self._result_queue = multiprocessing.Queue()
|
||||
self._work_ids = queue.Queue()
|
||||
self._queue_management_thread = None
|
||||
self._processes = set()
|
||||
|
||||
# Shutdown is a two-step process.
|
||||
self._shutdown_thread = False
|
||||
self._shutdown_lock = threading.Lock()
|
||||
self._queue_count = 0
|
||||
self._pending_work_items = {}
|
||||
|
||||
def _start_queue_management_thread(self):
|
||||
# When the executor gets lost, the weakref callback will wake up
|
||||
# the queue management thread.
|
||||
def weakref_cb(_, q=self._result_queue):
|
||||
q.put(None)
|
||||
if self._queue_management_thread is None:
|
||||
self._queue_management_thread = threading.Thread(
|
||||
target=_queue_management_worker,
|
||||
args=(weakref.ref(self, weakref_cb),
|
||||
self._processes,
|
||||
self._pending_work_items,
|
||||
self._work_ids,
|
||||
self._call_queue,
|
||||
self._result_queue))
|
||||
self._queue_management_thread.daemon = True
|
||||
self._queue_management_thread.start()
|
||||
_threads_queues[self._queue_management_thread] = self._result_queue
|
||||
|
||||
def _adjust_process_count(self):
|
||||
for _ in range(len(self._processes), self._max_workers):
|
||||
p = multiprocessing.Process(
|
||||
target=_process_worker,
|
||||
args=(self._call_queue,
|
||||
self._result_queue))
|
||||
p.start()
|
||||
self._processes.add(p)
|
||||
|
||||
def submit(self, fn, *args, **kwargs):
|
||||
with self._shutdown_lock:
|
||||
if self._shutdown_thread:
|
||||
raise RuntimeError('cannot schedule new futures after shutdown')
|
||||
|
||||
f = _base.Future()
|
||||
w = _WorkItem(f, fn, args, kwargs)
|
||||
|
||||
self._pending_work_items[self._queue_count] = w
|
||||
self._work_ids.put(self._queue_count)
|
||||
self._queue_count += 1
|
||||
# Wake up queue management thread
|
||||
self._result_queue.put(None)
|
||||
|
||||
self._start_queue_management_thread()
|
||||
self._adjust_process_count()
|
||||
return f
|
||||
submit.__doc__ = _base.Executor.submit.__doc__
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
with self._shutdown_lock:
|
||||
self._shutdown_thread = True
|
||||
if self._queue_management_thread:
|
||||
# Wake up queue management thread
|
||||
self._result_queue.put(None)
|
||||
if wait:
|
||||
self._queue_management_thread.join()
|
||||
# To reduce the risk of openning too many files, remove references to
|
||||
# objects that use file descriptors.
|
||||
self._queue_management_thread = None
|
||||
self._call_queue = None
|
||||
self._result_queue = None
|
||||
self._processes = None
|
||||
shutdown.__doc__ = _base.Executor.shutdown.__doc__
|
||||
|
||||
atexit.register(_python_exit)
|
||||
@@ -0,0 +1,138 @@
|
||||
# Copyright 2009 Brian Quinlan. All Rights Reserved.
|
||||
# Licensed to PSF under a Contributor Agreement.
|
||||
|
||||
"""Implements ThreadPoolExecutor."""
|
||||
|
||||
from __future__ import with_statement
|
||||
import atexit
|
||||
import threading
|
||||
import weakref
|
||||
import sys
|
||||
|
||||
from concurrent.futures import _base
|
||||
|
||||
try:
|
||||
import queue
|
||||
except ImportError:
|
||||
import Queue as queue
|
||||
|
||||
__author__ = 'Brian Quinlan (brian@sweetapp.com)'
|
||||
|
||||
# Workers are created as daemon threads. This is done to allow the interpreter
|
||||
# to exit when there are still idle threads in a ThreadPoolExecutor's thread
|
||||
# pool (i.e. shutdown() was not called). However, allowing workers to die with
|
||||
# the interpreter has two undesirable properties:
|
||||
# - The workers would still be running during interpretor shutdown,
|
||||
# meaning that they would fail in unpredictable ways.
|
||||
# - The workers could be killed while evaluating a work item, which could
|
||||
# be bad if the callable being evaluated has external side-effects e.g.
|
||||
# writing to a file.
|
||||
#
|
||||
# To work around this problem, an exit handler is installed which tells the
|
||||
# workers to exit when their work queues are empty and then waits until the
|
||||
# threads finish.
|
||||
|
||||
_threads_queues = weakref.WeakKeyDictionary()
|
||||
_shutdown = False
|
||||
|
||||
def _python_exit():
|
||||
global _shutdown
|
||||
_shutdown = True
|
||||
items = list(_threads_queues.items())
|
||||
for t, q in items:
|
||||
q.put(None)
|
||||
for t, q in items:
|
||||
t.join()
|
||||
|
||||
atexit.register(_python_exit)
|
||||
|
||||
class _WorkItem(object):
|
||||
def __init__(self, future, fn, args, kwargs):
|
||||
self.future = future
|
||||
self.fn = fn
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
if not self.future.set_running_or_notify_cancel():
|
||||
return
|
||||
|
||||
try:
|
||||
result = self.fn(*self.args, **self.kwargs)
|
||||
except BaseException:
|
||||
e, tb = sys.exc_info()[1:]
|
||||
self.future.set_exception_info(e, tb)
|
||||
else:
|
||||
self.future.set_result(result)
|
||||
|
||||
def _worker(executor_reference, work_queue):
|
||||
try:
|
||||
while True:
|
||||
work_item = work_queue.get(block=True)
|
||||
if work_item is not None:
|
||||
work_item.run()
|
||||
continue
|
||||
executor = executor_reference()
|
||||
# Exit if:
|
||||
# - The interpreter is shutting down OR
|
||||
# - The executor that owns the worker has been collected OR
|
||||
# - The executor that owns the worker has been shutdown.
|
||||
if _shutdown or executor is None or executor._shutdown:
|
||||
# Notice other workers
|
||||
work_queue.put(None)
|
||||
return
|
||||
del executor
|
||||
except BaseException:
|
||||
_base.LOGGER.critical('Exception in worker', exc_info=True)
|
||||
|
||||
class ThreadPoolExecutor(_base.Executor):
|
||||
def __init__(self, max_workers):
|
||||
"""Initializes a new ThreadPoolExecutor instance.
|
||||
|
||||
Args:
|
||||
max_workers: The maximum number of threads that can be used to
|
||||
execute the given calls.
|
||||
"""
|
||||
self._max_workers = max_workers
|
||||
self._work_queue = queue.Queue()
|
||||
self._threads = set()
|
||||
self._shutdown = False
|
||||
self._shutdown_lock = threading.Lock()
|
||||
|
||||
def submit(self, fn, *args, **kwargs):
|
||||
with self._shutdown_lock:
|
||||
if self._shutdown:
|
||||
raise RuntimeError('cannot schedule new futures after shutdown')
|
||||
|
||||
f = _base.Future()
|
||||
w = _WorkItem(f, fn, args, kwargs)
|
||||
|
||||
self._work_queue.put(w)
|
||||
self._adjust_thread_count()
|
||||
return f
|
||||
submit.__doc__ = _base.Executor.submit.__doc__
|
||||
|
||||
def _adjust_thread_count(self):
|
||||
# When the executor gets lost, the weakref callback will wake up
|
||||
# the worker threads.
|
||||
def weakref_cb(_, q=self._work_queue):
|
||||
q.put(None)
|
||||
# TODO(bquinlan): Should avoid creating new threads if there are more
|
||||
# idle threads than items in the work queue.
|
||||
if len(self._threads) < self._max_workers:
|
||||
t = threading.Thread(target=_worker,
|
||||
args=(weakref.ref(self, weakref_cb),
|
||||
self._work_queue))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
self._threads.add(t)
|
||||
_threads_queues[t] = self._work_queue
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
with self._shutdown_lock:
|
||||
self._shutdown = True
|
||||
self._work_queue.put(None)
|
||||
if wait:
|
||||
for t in self._threads:
|
||||
t.join()
|
||||
shutdown.__doc__ = _base.Executor.shutdown.__doc__
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2003-2009 Stuart Bishop <stuart@stuartbishop.net>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
'''
|
||||
Custom exceptions raised by pytz.
|
||||
'''
|
||||
|
||||
__all__ = [
|
||||
'UnknownTimeZoneError', 'InvalidTimeError', 'AmbiguousTimeError',
|
||||
'NonExistentTimeError',
|
||||
]
|
||||
|
||||
|
||||
class UnknownTimeZoneError(KeyError):
|
||||
'''Exception raised when pytz is passed an unknown timezone.
|
||||
|
||||
>>> isinstance(UnknownTimeZoneError(), LookupError)
|
||||
True
|
||||
|
||||
This class is actually a subclass of KeyError to provide backwards
|
||||
compatibility with code relying on the undocumented behavior of earlier
|
||||
pytz releases.
|
||||
|
||||
>>> isinstance(UnknownTimeZoneError(), KeyError)
|
||||
True
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTimeError(Exception):
|
||||
'''Base class for invalid time exceptions.'''
|
||||
|
||||
|
||||
class AmbiguousTimeError(InvalidTimeError):
|
||||
'''Exception raised when attempting to create an ambiguous wallclock time.
|
||||
|
||||
At the end of a DST transition period, a particular wallclock time will
|
||||
occur twice (once before the clocks are set back, once after). Both
|
||||
possibilities may be correct, unless further information is supplied.
|
||||
|
||||
See DstTzInfo.normalize() for more info
|
||||
'''
|
||||
|
||||
|
||||
class NonExistentTimeError(InvalidTimeError):
|
||||
'''Exception raised when attempting to create a wallclock time that
|
||||
cannot exist.
|
||||
|
||||
At the start of a DST transition period, the wallclock time jumps forward.
|
||||
The instants jumped over never occur.
|
||||
'''
|
||||
@@ -0,0 +1,168 @@
|
||||
from threading import RLock
|
||||
try:
|
||||
from UserDict import DictMixin
|
||||
except ImportError:
|
||||
from collections import Mapping as DictMixin
|
||||
|
||||
|
||||
# With lazy loading, we might end up with multiple threads triggering
|
||||
# it at the same time. We need a lock.
|
||||
_fill_lock = RLock()
|
||||
|
||||
|
||||
class LazyDict(DictMixin):
|
||||
"""Dictionary populated on first use."""
|
||||
data = None
|
||||
def __getitem__(self, key):
|
||||
if self.data is None:
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if self.data is None:
|
||||
self._fill()
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return self.data[key.upper()]
|
||||
|
||||
def __contains__(self, key):
|
||||
if self.data is None:
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if self.data is None:
|
||||
self._fill()
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return key in self.data
|
||||
|
||||
def __iter__(self):
|
||||
if self.data is None:
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if self.data is None:
|
||||
self._fill()
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return iter(self.data)
|
||||
|
||||
def __len__(self):
|
||||
if self.data is None:
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if self.data is None:
|
||||
self._fill()
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return len(self.data)
|
||||
|
||||
def keys(self):
|
||||
if self.data is None:
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if self.data is None:
|
||||
self._fill()
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return self.data.keys()
|
||||
|
||||
|
||||
class LazyList(list):
|
||||
"""List populated on first use."""
|
||||
|
||||
_props = [
|
||||
'__str__', '__repr__', '__unicode__',
|
||||
'__hash__', '__sizeof__', '__cmp__',
|
||||
'__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__',
|
||||
'append', 'count', 'index', 'extend', 'insert', 'pop', 'remove',
|
||||
'reverse', 'sort', '__add__', '__radd__', '__iadd__', '__mul__',
|
||||
'__rmul__', '__imul__', '__contains__', '__len__', '__nonzero__',
|
||||
'__getitem__', '__setitem__', '__delitem__', '__iter__',
|
||||
'__reversed__', '__getslice__', '__setslice__', '__delslice__']
|
||||
|
||||
def __new__(cls, fill_iter=None):
|
||||
|
||||
if fill_iter is None:
|
||||
return list()
|
||||
|
||||
# We need a new class as we will be dynamically messing with its
|
||||
# methods.
|
||||
class LazyList(list):
|
||||
pass
|
||||
|
||||
fill_iter = [fill_iter]
|
||||
|
||||
def lazy(name):
|
||||
def _lazy(self, *args, **kw):
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if len(fill_iter) > 0:
|
||||
list.extend(self, fill_iter.pop())
|
||||
for method_name in cls._props:
|
||||
delattr(LazyList, method_name)
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return getattr(list, name)(self, *args, **kw)
|
||||
return _lazy
|
||||
|
||||
for name in cls._props:
|
||||
setattr(LazyList, name, lazy(name))
|
||||
|
||||
new_list = LazyList()
|
||||
return new_list
|
||||
|
||||
# Not all versions of Python declare the same magic methods.
|
||||
# Filter out properties that don't exist in this version of Python
|
||||
# from the list.
|
||||
LazyList._props = [prop for prop in LazyList._props if hasattr(list, prop)]
|
||||
|
||||
|
||||
class LazySet(set):
|
||||
"""Set populated on first use."""
|
||||
|
||||
_props = (
|
||||
'__str__', '__repr__', '__unicode__',
|
||||
'__hash__', '__sizeof__', '__cmp__',
|
||||
'__lt__', '__le__', '__eq__', '__ne__', '__gt__', '__ge__',
|
||||
'__contains__', '__len__', '__nonzero__',
|
||||
'__getitem__', '__setitem__', '__delitem__', '__iter__',
|
||||
'__sub__', '__and__', '__xor__', '__or__',
|
||||
'__rsub__', '__rand__', '__rxor__', '__ror__',
|
||||
'__isub__', '__iand__', '__ixor__', '__ior__',
|
||||
'add', 'clear', 'copy', 'difference', 'difference_update',
|
||||
'discard', 'intersection', 'intersection_update', 'isdisjoint',
|
||||
'issubset', 'issuperset', 'pop', 'remove',
|
||||
'symmetric_difference', 'symmetric_difference_update',
|
||||
'union', 'update')
|
||||
|
||||
def __new__(cls, fill_iter=None):
|
||||
|
||||
if fill_iter is None:
|
||||
return set()
|
||||
|
||||
class LazySet(set):
|
||||
pass
|
||||
|
||||
fill_iter = [fill_iter]
|
||||
|
||||
def lazy(name):
|
||||
def _lazy(self, *args, **kw):
|
||||
_fill_lock.acquire()
|
||||
try:
|
||||
if len(fill_iter) > 0:
|
||||
for i in fill_iter.pop():
|
||||
set.add(self, i)
|
||||
for method_name in cls._props:
|
||||
delattr(LazySet, method_name)
|
||||
finally:
|
||||
_fill_lock.release()
|
||||
return getattr(set, name)(self, *args, **kw)
|
||||
return _lazy
|
||||
|
||||
for name in cls._props:
|
||||
setattr(LazySet, name, lazy(name))
|
||||
|
||||
new_set = LazySet()
|
||||
return new_set
|
||||
|
||||
# Not all versions of Python declare the same magic methods.
|
||||
# Filter out properties that don't exist in this version of Python
|
||||
# from the list.
|
||||
LazySet._props = [prop for prop in LazySet._props if hasattr(set, prop)]
|
||||
@@ -0,0 +1,127 @@
|
||||
'''
|
||||
Reference tzinfo implementations from the Python docs.
|
||||
Used for testing against as they are only correct for the years
|
||||
1987 to 2006. Do not use these for real code.
|
||||
'''
|
||||
|
||||
from datetime import tzinfo, timedelta, datetime
|
||||
from pytz import utc, UTC, HOUR, ZERO
|
||||
|
||||
# A class building tzinfo objects for fixed-offset time zones.
|
||||
# Note that FixedOffset(0, "UTC") is a different way to build a
|
||||
# UTC tzinfo object.
|
||||
|
||||
class FixedOffset(tzinfo):
|
||||
"""Fixed offset in minutes east from UTC."""
|
||||
|
||||
def __init__(self, offset, name):
|
||||
self.__offset = timedelta(minutes = offset)
|
||||
self.__name = name
|
||||
|
||||
def utcoffset(self, dt):
|
||||
return self.__offset
|
||||
|
||||
def tzname(self, dt):
|
||||
return self.__name
|
||||
|
||||
def dst(self, dt):
|
||||
return ZERO
|
||||
|
||||
# A class capturing the platform's idea of local time.
|
||||
|
||||
import time as _time
|
||||
|
||||
STDOFFSET = timedelta(seconds = -_time.timezone)
|
||||
if _time.daylight:
|
||||
DSTOFFSET = timedelta(seconds = -_time.altzone)
|
||||
else:
|
||||
DSTOFFSET = STDOFFSET
|
||||
|
||||
DSTDIFF = DSTOFFSET - STDOFFSET
|
||||
|
||||
class LocalTimezone(tzinfo):
|
||||
|
||||
def utcoffset(self, dt):
|
||||
if self._isdst(dt):
|
||||
return DSTOFFSET
|
||||
else:
|
||||
return STDOFFSET
|
||||
|
||||
def dst(self, dt):
|
||||
if self._isdst(dt):
|
||||
return DSTDIFF
|
||||
else:
|
||||
return ZERO
|
||||
|
||||
def tzname(self, dt):
|
||||
return _time.tzname[self._isdst(dt)]
|
||||
|
||||
def _isdst(self, dt):
|
||||
tt = (dt.year, dt.month, dt.day,
|
||||
dt.hour, dt.minute, dt.second,
|
||||
dt.weekday(), 0, -1)
|
||||
stamp = _time.mktime(tt)
|
||||
tt = _time.localtime(stamp)
|
||||
return tt.tm_isdst > 0
|
||||
|
||||
Local = LocalTimezone()
|
||||
|
||||
# A complete implementation of current DST rules for major US time zones.
|
||||
|
||||
def first_sunday_on_or_after(dt):
|
||||
days_to_go = 6 - dt.weekday()
|
||||
if days_to_go:
|
||||
dt += timedelta(days_to_go)
|
||||
return dt
|
||||
|
||||
# In the US, DST starts at 2am (standard time) on the first Sunday in April.
|
||||
DSTSTART = datetime(1, 4, 1, 2)
|
||||
# and ends at 2am (DST time; 1am standard time) on the last Sunday of Oct.
|
||||
# which is the first Sunday on or after Oct 25.
|
||||
DSTEND = datetime(1, 10, 25, 1)
|
||||
|
||||
class USTimeZone(tzinfo):
|
||||
|
||||
def __init__(self, hours, reprname, stdname, dstname):
|
||||
self.stdoffset = timedelta(hours=hours)
|
||||
self.reprname = reprname
|
||||
self.stdname = stdname
|
||||
self.dstname = dstname
|
||||
|
||||
def __repr__(self):
|
||||
return self.reprname
|
||||
|
||||
def tzname(self, dt):
|
||||
if self.dst(dt):
|
||||
return self.dstname
|
||||
else:
|
||||
return self.stdname
|
||||
|
||||
def utcoffset(self, dt):
|
||||
return self.stdoffset + self.dst(dt)
|
||||
|
||||
def dst(self, dt):
|
||||
if dt is None or dt.tzinfo is None:
|
||||
# An exception may be sensible here, in one or both cases.
|
||||
# It depends on how you want to treat them. The default
|
||||
# fromutc() implementation (called by the default astimezone()
|
||||
# implementation) passes a datetime with dt.tzinfo is self.
|
||||
return ZERO
|
||||
assert dt.tzinfo is self
|
||||
|
||||
# Find first Sunday in April & the last in October.
|
||||
start = first_sunday_on_or_after(DSTSTART.replace(year=dt.year))
|
||||
end = first_sunday_on_or_after(DSTEND.replace(year=dt.year))
|
||||
|
||||
# Can't compare naive to aware objects, so strip the timezone from
|
||||
# dt first.
|
||||
if start <= dt.replace(tzinfo=None) < end:
|
||||
return HOUR
|
||||
else:
|
||||
return ZERO
|
||||
|
||||
Eastern = USTimeZone(-5, "Eastern", "EST", "EDT")
|
||||
Central = USTimeZone(-6, "Central", "CST", "CDT")
|
||||
Mountain = USTimeZone(-7, "Mountain", "MST", "MDT")
|
||||
Pacific = USTimeZone(-8, "Pacific", "PST", "PDT")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- coding: ascii -*-
|
||||
|
||||
from doctest import DocFileSuite
|
||||
import unittest, os.path, sys
|
||||
|
||||
THIS_DIR = os.path.dirname(__file__)
|
||||
|
||||
README = os.path.join(THIS_DIR, os.pardir, os.pardir, 'README.txt')
|
||||
|
||||
|
||||
class DocumentationTestCase(unittest.TestCase):
|
||||
def test_readme_encoding(self):
|
||||
'''Confirm the README.txt is pure ASCII.'''
|
||||
f = open(README, 'rb')
|
||||
try:
|
||||
f.read().decode('US-ASCII')
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def test_suite():
|
||||
"For the Z3 test runner"
|
||||
return unittest.TestSuite((
|
||||
DocumentationTestCase('test_readme_encoding'),
|
||||
DocFileSuite(os.path.join(os.pardir, os.pardir, 'README.txt'))))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(
|
||||
THIS_DIR, os.pardir, os.pardir
|
||||
)))
|
||||
unittest.main(defaultTest='test_suite')
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
from operator import *
|
||||
import os.path
|
||||
import sys
|
||||
import unittest
|
||||
import warnings
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only munge path if invoked as a script. Testrunners should have setup
|
||||
# the paths already
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.pardir)))
|
||||
|
||||
|
||||
from pytz.lazy import LazyList, LazySet
|
||||
|
||||
|
||||
class LazyListTestCase(unittest.TestCase):
|
||||
initial_data = [3,2,1]
|
||||
|
||||
def setUp(self):
|
||||
self.base = [3, 2, 1]
|
||||
self.lesser = [2, 1, 0]
|
||||
self.greater = [4, 3, 2]
|
||||
|
||||
self.lazy = LazyList(iter(list(self.base)))
|
||||
|
||||
def test_unary_ops(self):
|
||||
unary_ops = [str, repr, len, bool, not_]
|
||||
try:
|
||||
unary_ops.append(unicode)
|
||||
except NameError:
|
||||
pass # unicode no longer exists in Python 3.
|
||||
|
||||
for op in unary_ops:
|
||||
self.assertEqual(
|
||||
op(self.lazy),
|
||||
op(self.base), str(op))
|
||||
|
||||
def test_binary_ops(self):
|
||||
binary_ops = [eq, ge, gt, le, lt, ne, add, concat]
|
||||
try:
|
||||
binary_ops.append(cmp)
|
||||
except NameError:
|
||||
pass # cmp no longer exists in Python 3.
|
||||
|
||||
for op in binary_ops:
|
||||
self.assertEqual(
|
||||
op(self.lazy, self.lazy),
|
||||
op(self.base, self.base), str(op))
|
||||
for other in [self.base, self.lesser, self.greater]:
|
||||
self.assertEqual(
|
||||
op(self.lazy, other),
|
||||
op(self.base, other), '%s %s' % (op, other))
|
||||
self.assertEqual(
|
||||
op(other, self.lazy),
|
||||
op(other, self.base), '%s %s' % (op, other))
|
||||
|
||||
# Multiplication
|
||||
self.assertEqual(self.lazy * 3, self.base * 3)
|
||||
self.assertEqual(3 * self.lazy, 3 * self.base)
|
||||
|
||||
# Contains
|
||||
self.assertTrue(2 in self.lazy)
|
||||
self.assertFalse(42 in self.lazy)
|
||||
|
||||
def test_iadd(self):
|
||||
self.lazy += [1]
|
||||
self.base += [1]
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_bool(self):
|
||||
self.assertTrue(bool(self.lazy))
|
||||
self.assertFalse(bool(LazyList()))
|
||||
self.assertFalse(bool(LazyList(iter([]))))
|
||||
|
||||
def test_hash(self):
|
||||
self.assertRaises(TypeError, hash, self.lazy)
|
||||
|
||||
def test_isinstance(self):
|
||||
self.assertTrue(isinstance(self.lazy, list))
|
||||
self.assertFalse(isinstance(self.lazy, tuple))
|
||||
|
||||
def test_callable(self):
|
||||
try:
|
||||
callable
|
||||
except NameError:
|
||||
return # No longer exists with Python 3.
|
||||
self.assertFalse(callable(self.lazy))
|
||||
|
||||
def test_append(self):
|
||||
self.base.append('extra')
|
||||
self.lazy.append('extra')
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_count(self):
|
||||
self.assertEqual(self.lazy.count(2), 1)
|
||||
|
||||
def test_index(self):
|
||||
self.assertEqual(self.lazy.index(2), 1)
|
||||
|
||||
def test_extend(self):
|
||||
self.base.extend([6, 7])
|
||||
self.lazy.extend([6, 7])
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_insert(self):
|
||||
self.base.insert(0, 'ping')
|
||||
self.lazy.insert(0, 'ping')
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_pop(self):
|
||||
self.assertEqual(self.lazy.pop(), self.base.pop())
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_remove(self):
|
||||
self.base.remove(2)
|
||||
self.lazy.remove(2)
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_reverse(self):
|
||||
self.base.reverse()
|
||||
self.lazy.reverse()
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_reversed(self):
|
||||
self.assertEqual(list(reversed(self.lazy)), list(reversed(self.base)))
|
||||
|
||||
def test_sort(self):
|
||||
self.base.sort()
|
||||
self.assertNotEqual(self.lazy, self.base, 'Test data already sorted')
|
||||
self.lazy.sort()
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_sorted(self):
|
||||
self.assertEqual(sorted(self.lazy), sorted(self.base))
|
||||
|
||||
def test_getitem(self):
|
||||
for idx in range(-len(self.base), len(self.base)):
|
||||
self.assertEqual(self.lazy[idx], self.base[idx])
|
||||
|
||||
def test_setitem(self):
|
||||
for idx in range(-len(self.base), len(self.base)):
|
||||
self.base[idx] = idx + 1000
|
||||
self.assertNotEqual(self.lazy, self.base)
|
||||
self.lazy[idx] = idx + 1000
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_delitem(self):
|
||||
del self.base[0]
|
||||
self.assertNotEqual(self.lazy, self.base)
|
||||
del self.lazy[0]
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
del self.base[-2]
|
||||
self.assertNotEqual(self.lazy, self.base)
|
||||
del self.lazy[-2]
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_iter(self):
|
||||
self.assertEqual(list(iter(self.lazy)), list(iter(self.base)))
|
||||
|
||||
def test_getslice(self):
|
||||
for i in range(-len(self.base), len(self.base)):
|
||||
for j in range(-len(self.base), len(self.base)):
|
||||
for step in [-1, 1]:
|
||||
self.assertEqual(self.lazy[i:j:step], self.base[i:j:step])
|
||||
|
||||
def test_setslice(self):
|
||||
for i in range(-len(self.base), len(self.base)):
|
||||
for j in range(-len(self.base), len(self.base)):
|
||||
for step in [-1, 1]:
|
||||
replacement = range(0, len(self.base[i:j:step]))
|
||||
self.base[i:j:step] = replacement
|
||||
self.lazy[i:j:step] = replacement
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_delslice(self):
|
||||
del self.base[0:1]
|
||||
del self.lazy[0:1]
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
del self.base[-1:1:-1]
|
||||
del self.lazy[-1:1:-1]
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
|
||||
class LazySetTestCase(unittest.TestCase):
|
||||
initial_data = set([3,2,1])
|
||||
|
||||
def setUp(self):
|
||||
self.base = set([3, 2, 1])
|
||||
self.lazy = LazySet(iter(set(self.base)))
|
||||
|
||||
def test_unary_ops(self):
|
||||
# These ops just need to work.
|
||||
unary_ops = [str, repr]
|
||||
try:
|
||||
unary_ops.append(unicode)
|
||||
except NameError:
|
||||
pass # unicode no longer exists in Python 3.
|
||||
|
||||
for op in unary_ops:
|
||||
op(self.lazy) # These ops just need to work.
|
||||
|
||||
# These ops should return identical values as a real set.
|
||||
unary_ops = [len, bool, not_]
|
||||
|
||||
for op in unary_ops:
|
||||
self.assertEqual(
|
||||
op(self.lazy),
|
||||
op(self.base), '%s(lazy) == %r' % (op, op(self.lazy)))
|
||||
|
||||
def test_binary_ops(self):
|
||||
binary_ops = [eq, ge, gt, le, lt, ne, sub, and_, or_, xor]
|
||||
try:
|
||||
binary_ops.append(cmp)
|
||||
except NameError:
|
||||
pass # cmp no longer exists in Python 3.
|
||||
|
||||
for op in binary_ops:
|
||||
self.assertEqual(
|
||||
op(self.lazy, self.lazy),
|
||||
op(self.base, self.base), str(op))
|
||||
self.assertEqual(
|
||||
op(self.lazy, self.base),
|
||||
op(self.base, self.base), str(op))
|
||||
self.assertEqual(
|
||||
op(self.base, self.lazy),
|
||||
op(self.base, self.base), str(op))
|
||||
|
||||
# Contains
|
||||
self.assertTrue(2 in self.lazy)
|
||||
self.assertFalse(42 in self.lazy)
|
||||
|
||||
def test_iops(self):
|
||||
try:
|
||||
iops = [isub, iand, ior, ixor]
|
||||
except NameError:
|
||||
return # Don't exist in older Python versions.
|
||||
for op in iops:
|
||||
# Mutating operators, so make fresh copies.
|
||||
lazy = LazySet(self.base)
|
||||
base = self.base.copy()
|
||||
op(lazy, set([1]))
|
||||
op(base, set([1]))
|
||||
self.assertEqual(lazy, base, str(op))
|
||||
|
||||
def test_bool(self):
|
||||
self.assertTrue(bool(self.lazy))
|
||||
self.assertFalse(bool(LazySet()))
|
||||
self.assertFalse(bool(LazySet(iter([]))))
|
||||
|
||||
def test_hash(self):
|
||||
self.assertRaises(TypeError, hash, self.lazy)
|
||||
|
||||
def test_isinstance(self):
|
||||
self.assertTrue(isinstance(self.lazy, set))
|
||||
|
||||
def test_callable(self):
|
||||
try:
|
||||
callable
|
||||
except NameError:
|
||||
return # No longer exists with Python 3.
|
||||
self.assertFalse(callable(self.lazy))
|
||||
|
||||
def test_add(self):
|
||||
self.base.add('extra')
|
||||
self.lazy.add('extra')
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_copy(self):
|
||||
self.assertEqual(self.lazy.copy(), self.base)
|
||||
|
||||
def test_method_ops(self):
|
||||
ops = [
|
||||
'difference', 'intersection', 'isdisjoint',
|
||||
'issubset', 'issuperset', 'symmetric_difference', 'union',
|
||||
'difference_update', 'intersection_update',
|
||||
'symmetric_difference_update', 'update']
|
||||
for op in ops:
|
||||
if not hasattr(set, op):
|
||||
continue # Not in this version of Python.
|
||||
# Make a copy, as some of the ops are mutating.
|
||||
lazy = LazySet(set(self.base))
|
||||
base = set(self.base)
|
||||
self.assertEqual(
|
||||
getattr(self.lazy, op)(set([1])),
|
||||
getattr(self.base, op)(set([1])), op)
|
||||
self.assertEqual(self.lazy, self.base, op)
|
||||
|
||||
def test_discard(self):
|
||||
self.base.discard(1)
|
||||
self.assertNotEqual(self.lazy, self.base)
|
||||
self.lazy.discard(1)
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_pop(self):
|
||||
self.assertEqual(self.lazy.pop(), self.base.pop())
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_remove(self):
|
||||
self.base.remove(2)
|
||||
self.lazy.remove(2)
|
||||
self.assertEqual(self.lazy, self.base)
|
||||
|
||||
def test_clear(self):
|
||||
self.lazy.clear()
|
||||
self.assertEqual(self.lazy, set())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
warnings.simplefilter("error") # Warnings should be fatal in tests.
|
||||
unittest.main()
|
||||
@@ -0,0 +1,820 @@
|
||||
# -*- coding: ascii -*-
|
||||
|
||||
import sys, os, os.path
|
||||
import unittest, doctest
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
from datetime import datetime, time, timedelta, tzinfo
|
||||
import warnings
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Only munge path if invoked as a script. Testrunners should have setup
|
||||
# the paths already
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.pardir, os.pardir)))
|
||||
|
||||
import pytz
|
||||
from pytz import reference
|
||||
from pytz.tzfile import _byte_string
|
||||
from pytz.tzinfo import DstTzInfo, StaticTzInfo
|
||||
|
||||
# I test for expected version to ensure the correct version of pytz is
|
||||
# actually being tested.
|
||||
EXPECTED_VERSION='2014.7'
|
||||
EXPECTED_OLSON_VERSION='2014g'
|
||||
|
||||
fmt = '%Y-%m-%d %H:%M:%S %Z%z'
|
||||
|
||||
NOTIME = timedelta(0)
|
||||
|
||||
# GMT is a tzinfo.StaticTzInfo--the class we primarily want to test--while
|
||||
# UTC is reference implementation. They both have the same timezone meaning.
|
||||
UTC = pytz.timezone('UTC')
|
||||
GMT = pytz.timezone('GMT')
|
||||
assert isinstance(GMT, StaticTzInfo), 'GMT is no longer a StaticTzInfo'
|
||||
|
||||
def prettydt(dt):
|
||||
"""datetime as a string using a known format.
|
||||
|
||||
We don't use strftime as it doesn't handle years earlier than 1900
|
||||
per http://bugs.python.org/issue1777412
|
||||
"""
|
||||
if dt.utcoffset() >= timedelta(0):
|
||||
offset = '+%s' % (dt.utcoffset(),)
|
||||
else:
|
||||
offset = '-%s' % (-1 * dt.utcoffset(),)
|
||||
return '%04d-%02d-%02d %02d:%02d:%02d %s %s' % (
|
||||
dt.year, dt.month, dt.day,
|
||||
dt.hour, dt.minute, dt.second,
|
||||
dt.tzname(), offset)
|
||||
|
||||
|
||||
try:
|
||||
unicode
|
||||
except NameError:
|
||||
# Python 3.x doesn't have unicode(), making writing code
|
||||
# for Python 2.3 and Python 3.x a pain.
|
||||
unicode = str
|
||||
|
||||
|
||||
class BasicTest(unittest.TestCase):
|
||||
|
||||
def testVersion(self):
|
||||
# Ensuring the correct version of pytz has been loaded
|
||||
self.assertEqual(EXPECTED_VERSION, pytz.__version__,
|
||||
'Incorrect pytz version loaded. Import path is stuffed '
|
||||
'or this test needs updating. (Wanted %s, got %s)'
|
||||
% (EXPECTED_VERSION, pytz.__version__))
|
||||
|
||||
self.assertEqual(EXPECTED_OLSON_VERSION, pytz.OLSON_VERSION,
|
||||
'Incorrect pytz version loaded. Import path is stuffed '
|
||||
'or this test needs updating. (Wanted %s, got %s)'
|
||||
% (EXPECTED_OLSON_VERSION, pytz.OLSON_VERSION))
|
||||
|
||||
def testGMT(self):
|
||||
now = datetime.now(tz=GMT)
|
||||
self.assertTrue(now.utcoffset() == NOTIME)
|
||||
self.assertTrue(now.dst() == NOTIME)
|
||||
self.assertTrue(now.timetuple() == now.utctimetuple())
|
||||
self.assertTrue(now==now.replace(tzinfo=UTC))
|
||||
|
||||
def testReferenceUTC(self):
|
||||
now = datetime.now(tz=UTC)
|
||||
self.assertTrue(now.utcoffset() == NOTIME)
|
||||
self.assertTrue(now.dst() == NOTIME)
|
||||
self.assertTrue(now.timetuple() == now.utctimetuple())
|
||||
|
||||
def testUnknownOffsets(self):
|
||||
# This tzinfo behavior is required to make
|
||||
# datetime.time.{utcoffset, dst, tzname} work as documented.
|
||||
|
||||
dst_tz = pytz.timezone('US/Eastern')
|
||||
|
||||
# This information is not known when we don't have a date,
|
||||
# so return None per API.
|
||||
self.assertTrue(dst_tz.utcoffset(None) is None)
|
||||
self.assertTrue(dst_tz.dst(None) is None)
|
||||
# We don't know the abbreviation, but this is still a valid
|
||||
# tzname per the Python documentation.
|
||||
self.assertEqual(dst_tz.tzname(None), 'US/Eastern')
|
||||
|
||||
def clearCache(self):
|
||||
pytz._tzinfo_cache.clear()
|
||||
|
||||
def testUnicodeTimezone(self):
|
||||
# We need to ensure that cold lookups work for both Unicode
|
||||
# and traditional strings, and that the desired singleton is
|
||||
# returned.
|
||||
self.clearCache()
|
||||
eastern = pytz.timezone(unicode('US/Eastern'))
|
||||
self.assertTrue(eastern is pytz.timezone('US/Eastern'))
|
||||
|
||||
self.clearCache()
|
||||
eastern = pytz.timezone('US/Eastern')
|
||||
self.assertTrue(eastern is pytz.timezone(unicode('US/Eastern')))
|
||||
|
||||
|
||||
class PicklingTest(unittest.TestCase):
|
||||
|
||||
def _roundtrip_tzinfo(self, tz):
|
||||
p = pickle.dumps(tz)
|
||||
unpickled_tz = pickle.loads(p)
|
||||
self.assertTrue(tz is unpickled_tz, '%s did not roundtrip' % tz.zone)
|
||||
|
||||
def _roundtrip_datetime(self, dt):
|
||||
# Ensure that the tzinfo attached to a datetime instance
|
||||
# is identical to the one returned. This is important for
|
||||
# DST timezones, as some state is stored in the tzinfo.
|
||||
tz = dt.tzinfo
|
||||
p = pickle.dumps(dt)
|
||||
unpickled_dt = pickle.loads(p)
|
||||
unpickled_tz = unpickled_dt.tzinfo
|
||||
self.assertTrue(tz is unpickled_tz, '%s did not roundtrip' % tz.zone)
|
||||
|
||||
def testDst(self):
|
||||
tz = pytz.timezone('Europe/Amsterdam')
|
||||
dt = datetime(2004, 2, 1, 0, 0, 0)
|
||||
|
||||
for localized_tz in tz._tzinfos.values():
|
||||
self._roundtrip_tzinfo(localized_tz)
|
||||
self._roundtrip_datetime(dt.replace(tzinfo=localized_tz))
|
||||
|
||||
def testRoundtrip(self):
|
||||
dt = datetime(2004, 2, 1, 0, 0, 0)
|
||||
for zone in pytz.all_timezones:
|
||||
tz = pytz.timezone(zone)
|
||||
self._roundtrip_tzinfo(tz)
|
||||
|
||||
def testDatabaseFixes(self):
|
||||
# Hack the pickle to make it refer to a timezone abbreviation
|
||||
# that does not match anything. The unpickler should be able
|
||||
# to repair this case
|
||||
tz = pytz.timezone('Australia/Melbourne')
|
||||
p = pickle.dumps(tz)
|
||||
tzname = tz._tzname
|
||||
hacked_p = p.replace(_byte_string(tzname), _byte_string('???'))
|
||||
self.assertNotEqual(p, hacked_p)
|
||||
unpickled_tz = pickle.loads(hacked_p)
|
||||
self.assertTrue(tz is unpickled_tz)
|
||||
|
||||
# Simulate a database correction. In this case, the incorrect
|
||||
# data will continue to be used.
|
||||
p = pickle.dumps(tz)
|
||||
new_utcoffset = tz._utcoffset.seconds + 42
|
||||
|
||||
# Python 3 introduced a new pickle protocol where numbers are stored in
|
||||
# hexadecimal representation. Here we extract the pickle
|
||||
# representation of the number for the current Python version.
|
||||
old_pickle_pattern = pickle.dumps(tz._utcoffset.seconds)[3:-1]
|
||||
new_pickle_pattern = pickle.dumps(new_utcoffset)[3:-1]
|
||||
hacked_p = p.replace(old_pickle_pattern, new_pickle_pattern)
|
||||
|
||||
self.assertNotEqual(p, hacked_p)
|
||||
unpickled_tz = pickle.loads(hacked_p)
|
||||
self.assertEqual(unpickled_tz._utcoffset.seconds, new_utcoffset)
|
||||
self.assertTrue(tz is not unpickled_tz)
|
||||
|
||||
def testOldPickles(self):
|
||||
# Ensure that applications serializing pytz instances as pickles
|
||||
# have no troubles upgrading to a new pytz release. These pickles
|
||||
# where created with pytz2006j
|
||||
east1 = pickle.loads(_byte_string(
|
||||
"cpytz\n_p\np1\n(S'US/Eastern'\np2\nI-18000\n"
|
||||
"I0\nS'EST'\np3\ntRp4\n."
|
||||
))
|
||||
east2 = pytz.timezone('US/Eastern').localize(
|
||||
datetime(2006, 1, 1)).tzinfo
|
||||
self.assertTrue(east1 is east2)
|
||||
|
||||
# Confirm changes in name munging between 2006j and 2007c cause
|
||||
# no problems.
|
||||
pap1 = pickle.loads(_byte_string(
|
||||
"cpytz\n_p\np1\n(S'America/Port_minus_au_minus_Prince'"
|
||||
"\np2\nI-17340\nI0\nS'PPMT'\np3\ntRp4\n."))
|
||||
pap2 = pytz.timezone('America/Port-au-Prince').localize(
|
||||
datetime(1910, 1, 1)).tzinfo
|
||||
self.assertTrue(pap1 is pap2)
|
||||
|
||||
gmt1 = pickle.loads(_byte_string(
|
||||
"cpytz\n_p\np1\n(S'Etc/GMT_plus_10'\np2\ntRp3\n."))
|
||||
gmt2 = pytz.timezone('Etc/GMT+10')
|
||||
self.assertTrue(gmt1 is gmt2)
|
||||
|
||||
|
||||
class USEasternDSTStartTestCase(unittest.TestCase):
|
||||
tzinfo = pytz.timezone('US/Eastern')
|
||||
|
||||
# 24 hours before DST changeover
|
||||
transition_time = datetime(2002, 4, 7, 7, 0, 0, tzinfo=UTC)
|
||||
|
||||
# Increase for 'flexible' DST transitions due to 1 minute granularity
|
||||
# of Python's datetime library
|
||||
instant = timedelta(seconds=1)
|
||||
|
||||
# before transition
|
||||
before = {
|
||||
'tzname': 'EST',
|
||||
'utcoffset': timedelta(hours = -5),
|
||||
'dst': timedelta(hours = 0),
|
||||
}
|
||||
|
||||
# after transition
|
||||
after = {
|
||||
'tzname': 'EDT',
|
||||
'utcoffset': timedelta(hours = -4),
|
||||
'dst': timedelta(hours = 1),
|
||||
}
|
||||
|
||||
def _test_tzname(self, utc_dt, wanted):
|
||||
tzname = wanted['tzname']
|
||||
dt = utc_dt.astimezone(self.tzinfo)
|
||||
self.assertEqual(dt.tzname(), tzname,
|
||||
'Expected %s as tzname for %s. Got %s' % (
|
||||
tzname, str(utc_dt), dt.tzname()
|
||||
)
|
||||
)
|
||||
|
||||
def _test_utcoffset(self, utc_dt, wanted):
|
||||
utcoffset = wanted['utcoffset']
|
||||
dt = utc_dt.astimezone(self.tzinfo)
|
||||
self.assertEqual(
|
||||
dt.utcoffset(), wanted['utcoffset'],
|
||||
'Expected %s as utcoffset for %s. Got %s' % (
|
||||
utcoffset, utc_dt, dt.utcoffset()
|
||||
)
|
||||
)
|
||||
|
||||
def _test_dst(self, utc_dt, wanted):
|
||||
dst = wanted['dst']
|
||||
dt = utc_dt.astimezone(self.tzinfo)
|
||||
self.assertEqual(dt.dst(),dst,
|
||||
'Expected %s as dst for %s. Got %s' % (
|
||||
dst, utc_dt, dt.dst()
|
||||
)
|
||||
)
|
||||
|
||||
def test_arithmetic(self):
|
||||
utc_dt = self.transition_time
|
||||
|
||||
for days in range(-420, 720, 20):
|
||||
delta = timedelta(days=days)
|
||||
|
||||
# Make sure we can get back where we started
|
||||
dt = utc_dt.astimezone(self.tzinfo)
|
||||
dt2 = dt + delta
|
||||
dt2 = dt2 - delta
|
||||
self.assertEqual(dt, dt2)
|
||||
|
||||
# Make sure arithmetic crossing DST boundaries ends
|
||||
# up in the correct timezone after normalization
|
||||
utc_plus_delta = (utc_dt + delta).astimezone(self.tzinfo)
|
||||
local_plus_delta = self.tzinfo.normalize(dt + delta)
|
||||
self.assertEqual(
|
||||
prettydt(utc_plus_delta),
|
||||
prettydt(local_plus_delta),
|
||||
'Incorrect result for delta==%d days. Wanted %r. Got %r'%(
|
||||
days,
|
||||
prettydt(utc_plus_delta),
|
||||
prettydt(local_plus_delta),
|
||||
)
|
||||
)
|
||||
|
||||
def _test_all(self, utc_dt, wanted):
|
||||
self._test_utcoffset(utc_dt, wanted)
|
||||
self._test_tzname(utc_dt, wanted)
|
||||
self._test_dst(utc_dt, wanted)
|
||||
|
||||
def testDayBefore(self):
|
||||
self._test_all(
|
||||
self.transition_time - timedelta(days=1), self.before
|
||||
)
|
||||
|
||||
def testTwoHoursBefore(self):
|
||||
self._test_all(
|
||||
self.transition_time - timedelta(hours=2), self.before
|
||||
)
|
||||
|
||||
def testHourBefore(self):
|
||||
self._test_all(
|
||||
self.transition_time - timedelta(hours=1), self.before
|
||||
)
|
||||
|
||||
def testInstantBefore(self):
|
||||
self._test_all(
|
||||
self.transition_time - self.instant, self.before
|
||||
)
|
||||
|
||||
def testTransition(self):
|
||||
self._test_all(
|
||||
self.transition_time, self.after
|
||||
)
|
||||
|
||||
def testInstantAfter(self):
|
||||
self._test_all(
|
||||
self.transition_time + self.instant, self.after
|
||||
)
|
||||
|
||||
def testHourAfter(self):
|
||||
self._test_all(
|
||||
self.transition_time + timedelta(hours=1), self.after
|
||||
)
|
||||
|
||||
def testTwoHoursAfter(self):
|
||||
self._test_all(
|
||||
self.transition_time + timedelta(hours=1), self.after
|
||||
)
|
||||
|
||||
def testDayAfter(self):
|
||||
self._test_all(
|
||||
self.transition_time + timedelta(days=1), self.after
|
||||
)
|
||||
|
||||
|
||||
class USEasternDSTEndTestCase(USEasternDSTStartTestCase):
|
||||
tzinfo = pytz.timezone('US/Eastern')
|
||||
transition_time = datetime(2002, 10, 27, 6, 0, 0, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'EDT',
|
||||
'utcoffset': timedelta(hours = -4),
|
||||
'dst': timedelta(hours = 1),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'EST',
|
||||
'utcoffset': timedelta(hours = -5),
|
||||
'dst': timedelta(hours = 0),
|
||||
}
|
||||
|
||||
|
||||
class USEasternEPTStartTestCase(USEasternDSTStartTestCase):
|
||||
transition_time = datetime(1945, 8, 14, 23, 0, 0, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'EWT',
|
||||
'utcoffset': timedelta(hours = -4),
|
||||
'dst': timedelta(hours = 1),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'EPT',
|
||||
'utcoffset': timedelta(hours = -4),
|
||||
'dst': timedelta(hours = 1),
|
||||
}
|
||||
|
||||
|
||||
class USEasternEPTEndTestCase(USEasternDSTStartTestCase):
|
||||
transition_time = datetime(1945, 9, 30, 6, 0, 0, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'EPT',
|
||||
'utcoffset': timedelta(hours = -4),
|
||||
'dst': timedelta(hours = 1),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'EST',
|
||||
'utcoffset': timedelta(hours = -5),
|
||||
'dst': timedelta(hours = 0),
|
||||
}
|
||||
|
||||
|
||||
class WarsawWMTEndTestCase(USEasternDSTStartTestCase):
|
||||
# In 1915, Warsaw changed from Warsaw to Central European time.
|
||||
# This involved the clocks being set backwards, causing a end-of-DST
|
||||
# like situation without DST being involved.
|
||||
tzinfo = pytz.timezone('Europe/Warsaw')
|
||||
transition_time = datetime(1915, 8, 4, 22, 36, 0, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'WMT',
|
||||
'utcoffset': timedelta(hours=1, minutes=24),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'CET',
|
||||
'utcoffset': timedelta(hours=1),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
|
||||
|
||||
class VilniusWMTEndTestCase(USEasternDSTStartTestCase):
|
||||
# At the end of 1916, Vilnius changed timezones putting its clock
|
||||
# forward by 11 minutes 35 seconds. Neither timezone was in DST mode.
|
||||
tzinfo = pytz.timezone('Europe/Vilnius')
|
||||
instant = timedelta(seconds=31)
|
||||
transition_time = datetime(1916, 12, 31, 22, 36, 00, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'WMT',
|
||||
'utcoffset': timedelta(hours=1, minutes=24),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'KMT',
|
||||
'utcoffset': timedelta(hours=1, minutes=36), # Really 1:35:36
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
|
||||
|
||||
class VilniusCESTStartTestCase(USEasternDSTStartTestCase):
|
||||
# In 1941, Vilnius changed from MSG to CEST, switching to summer
|
||||
# time while simultaneously reducing its UTC offset by two hours,
|
||||
# causing the clocks to go backwards for this summer time
|
||||
# switchover.
|
||||
tzinfo = pytz.timezone('Europe/Vilnius')
|
||||
transition_time = datetime(1941, 6, 23, 21, 00, 00, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'MSK',
|
||||
'utcoffset': timedelta(hours=3),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'CEST',
|
||||
'utcoffset': timedelta(hours=2),
|
||||
'dst': timedelta(hours=1),
|
||||
}
|
||||
|
||||
|
||||
class LondonHistoryStartTestCase(USEasternDSTStartTestCase):
|
||||
# The first known timezone transition in London was in 1847 when
|
||||
# clocks where synchronized to GMT. However, we currently only
|
||||
# understand v1 format tzfile(5) files which does handle years
|
||||
# this far in the past, so our earliest known transition is in
|
||||
# 1916.
|
||||
tzinfo = pytz.timezone('Europe/London')
|
||||
# transition_time = datetime(1847, 12, 1, 1, 15, 00, tzinfo=UTC)
|
||||
# before = {
|
||||
# 'tzname': 'LMT',
|
||||
# 'utcoffset': timedelta(minutes=-75),
|
||||
# 'dst': timedelta(0),
|
||||
# }
|
||||
# after = {
|
||||
# 'tzname': 'GMT',
|
||||
# 'utcoffset': timedelta(0),
|
||||
# 'dst': timedelta(0),
|
||||
# }
|
||||
transition_time = datetime(1916, 5, 21, 2, 00, 00, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'GMT',
|
||||
'utcoffset': timedelta(0),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'BST',
|
||||
'utcoffset': timedelta(hours=1),
|
||||
'dst': timedelta(hours=1),
|
||||
}
|
||||
|
||||
|
||||
class LondonHistoryEndTestCase(USEasternDSTStartTestCase):
|
||||
# Timezone switchovers are projected into the future, even
|
||||
# though no official statements exist or could be believed even
|
||||
# if they did exist. We currently only check the last known
|
||||
# transition in 2037, as we are still using v1 format tzfile(5)
|
||||
# files.
|
||||
tzinfo = pytz.timezone('Europe/London')
|
||||
# transition_time = datetime(2499, 10, 25, 1, 0, 0, tzinfo=UTC)
|
||||
transition_time = datetime(2037, 10, 25, 1, 0, 0, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'BST',
|
||||
'utcoffset': timedelta(hours=1),
|
||||
'dst': timedelta(hours=1),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'GMT',
|
||||
'utcoffset': timedelta(0),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
|
||||
|
||||
class NoumeaHistoryStartTestCase(USEasternDSTStartTestCase):
|
||||
# Noumea adopted a whole hour offset in 1912. Previously
|
||||
# it was 11 hours, 5 minutes and 48 seconds off UTC. However,
|
||||
# due to limitations of the Python datetime library, we need
|
||||
# to round that to 11 hours 6 minutes.
|
||||
tzinfo = pytz.timezone('Pacific/Noumea')
|
||||
transition_time = datetime(1912, 1, 12, 12, 54, 12, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'LMT',
|
||||
'utcoffset': timedelta(hours=11, minutes=6),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'NCT',
|
||||
'utcoffset': timedelta(hours=11),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
|
||||
|
||||
class NoumeaDSTEndTestCase(USEasternDSTStartTestCase):
|
||||
# Noumea dropped DST in 1997.
|
||||
tzinfo = pytz.timezone('Pacific/Noumea')
|
||||
transition_time = datetime(1997, 3, 1, 15, 00, 00, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'NCST',
|
||||
'utcoffset': timedelta(hours=12),
|
||||
'dst': timedelta(hours=1),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'NCT',
|
||||
'utcoffset': timedelta(hours=11),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
|
||||
|
||||
class NoumeaNoMoreDSTTestCase(NoumeaDSTEndTestCase):
|
||||
# Noumea dropped DST in 1997. Here we test that it stops occuring.
|
||||
transition_time = (
|
||||
NoumeaDSTEndTestCase.transition_time + timedelta(days=365*10))
|
||||
before = NoumeaDSTEndTestCase.after
|
||||
after = NoumeaDSTEndTestCase.after
|
||||
|
||||
|
||||
class TahitiTestCase(USEasternDSTStartTestCase):
|
||||
# Tahiti has had a single transition in its history.
|
||||
tzinfo = pytz.timezone('Pacific/Tahiti')
|
||||
transition_time = datetime(1912, 10, 1, 9, 58, 16, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'LMT',
|
||||
'utcoffset': timedelta(hours=-9, minutes=-58),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'TAHT',
|
||||
'utcoffset': timedelta(hours=-10),
|
||||
'dst': timedelta(0),
|
||||
}
|
||||
|
||||
|
||||
class SamoaInternationalDateLineChange(USEasternDSTStartTestCase):
|
||||
# At the end of 2011, Samoa will switch from being east of the
|
||||
# international dateline to the west. There will be no Dec 30th
|
||||
# 2011 and it will switch from UTC-10 to UTC+14.
|
||||
tzinfo = pytz.timezone('Pacific/Apia')
|
||||
transition_time = datetime(2011, 12, 30, 10, 0, 0, tzinfo=UTC)
|
||||
before = {
|
||||
'tzname': 'SDT',
|
||||
'utcoffset': timedelta(hours=-10),
|
||||
'dst': timedelta(hours=1),
|
||||
}
|
||||
after = {
|
||||
'tzname': 'WSDT',
|
||||
'utcoffset': timedelta(hours=14),
|
||||
'dst': timedelta(hours=1),
|
||||
}
|
||||
|
||||
|
||||
class ReferenceUSEasternDSTStartTestCase(USEasternDSTStartTestCase):
|
||||
tzinfo = reference.Eastern
|
||||
def test_arithmetic(self):
|
||||
# Reference implementation cannot handle this
|
||||
pass
|
||||
|
||||
|
||||
class ReferenceUSEasternDSTEndTestCase(USEasternDSTEndTestCase):
|
||||
tzinfo = reference.Eastern
|
||||
|
||||
def testHourBefore(self):
|
||||
# Python's datetime library has a bug, where the hour before
|
||||
# a daylight saving transition is one hour out. For example,
|
||||
# at the end of US/Eastern daylight saving time, 01:00 EST
|
||||
# occurs twice (once at 05:00 UTC and once at 06:00 UTC),
|
||||
# whereas the first should actually be 01:00 EDT.
|
||||
# Note that this bug is by design - by accepting this ambiguity
|
||||
# for one hour one hour per year, an is_dst flag on datetime.time
|
||||
# became unnecessary.
|
||||
self._test_all(
|
||||
self.transition_time - timedelta(hours=1), self.after
|
||||
)
|
||||
|
||||
def testInstantBefore(self):
|
||||
self._test_all(
|
||||
self.transition_time - timedelta(seconds=1), self.after
|
||||
)
|
||||
|
||||
def test_arithmetic(self):
|
||||
# Reference implementation cannot handle this
|
||||
pass
|
||||
|
||||
|
||||
class LocalTestCase(unittest.TestCase):
|
||||
def testLocalize(self):
|
||||
loc_tz = pytz.timezone('Europe/Amsterdam')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1930, 5, 10, 0, 0, 0))
|
||||
# Actually +00:19:32, but Python datetime rounds this
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'AMT+0020')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1930, 5, 20, 0, 0, 0))
|
||||
# Actually +00:19:32, but Python datetime rounds this
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'NST+0120')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1940, 5, 10, 0, 0, 0))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'NET+0020')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1940, 5, 20, 0, 0, 0))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'CEST+0200')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(2004, 2, 1, 0, 0, 0))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'CET+0100')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(2004, 4, 1, 0, 0, 0))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'CEST+0200')
|
||||
|
||||
tz = pytz.timezone('Europe/Amsterdam')
|
||||
loc_time = loc_tz.localize(datetime(1943, 3, 29, 1, 59, 59))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'CET+0100')
|
||||
|
||||
|
||||
# Switch to US
|
||||
loc_tz = pytz.timezone('US/Eastern')
|
||||
|
||||
# End of DST ambiguity check
|
||||
loc_time = loc_tz.localize(datetime(1918, 10, 27, 1, 59, 59), is_dst=1)
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EDT-0400')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1918, 10, 27, 1, 59, 59), is_dst=0)
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EST-0500')
|
||||
|
||||
self.assertRaises(pytz.AmbiguousTimeError,
|
||||
loc_tz.localize, datetime(1918, 10, 27, 1, 59, 59), is_dst=None
|
||||
)
|
||||
|
||||
# Start of DST non-existent times
|
||||
loc_time = loc_tz.localize(datetime(1918, 3, 31, 2, 0, 0), is_dst=0)
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EST-0500')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1918, 3, 31, 2, 0, 0), is_dst=1)
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EDT-0400')
|
||||
|
||||
self.assertRaises(pytz.NonExistentTimeError,
|
||||
loc_tz.localize, datetime(1918, 3, 31, 2, 0, 0), is_dst=None
|
||||
)
|
||||
|
||||
# Weird changes - war time and peace time both is_dst==True
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1942, 2, 9, 3, 0, 0))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EWT-0400')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1945, 8, 14, 19, 0, 0))
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EPT-0400')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1945, 9, 30, 1, 0, 0), is_dst=1)
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EPT-0400')
|
||||
|
||||
loc_time = loc_tz.localize(datetime(1945, 9, 30, 1, 0, 0), is_dst=0)
|
||||
self.assertEqual(loc_time.strftime('%Z%z'), 'EST-0500')
|
||||
|
||||
def testNormalize(self):
|
||||
tz = pytz.timezone('US/Eastern')
|
||||
dt = datetime(2004, 4, 4, 7, 0, 0, tzinfo=UTC).astimezone(tz)
|
||||
dt2 = dt - timedelta(minutes=10)
|
||||
self.assertEqual(
|
||||
dt2.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
|
||||
'2004-04-04 02:50:00 EDT-0400'
|
||||
)
|
||||
|
||||
dt2 = tz.normalize(dt2)
|
||||
self.assertEqual(
|
||||
dt2.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
|
||||
'2004-04-04 01:50:00 EST-0500'
|
||||
)
|
||||
|
||||
def testPartialMinuteOffsets(self):
|
||||
# utcoffset in Amsterdam was not a whole minute until 1937
|
||||
# However, we fudge this by rounding them, as the Python
|
||||
# datetime library
|
||||
tz = pytz.timezone('Europe/Amsterdam')
|
||||
utc_dt = datetime(1914, 1, 1, 13, 40, 28, tzinfo=UTC) # correct
|
||||
utc_dt = utc_dt.replace(second=0) # But we need to fudge it
|
||||
loc_dt = utc_dt.astimezone(tz)
|
||||
self.assertEqual(
|
||||
loc_dt.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
|
||||
'1914-01-01 14:00:00 AMT+0020'
|
||||
)
|
||||
|
||||
# And get back...
|
||||
utc_dt = loc_dt.astimezone(UTC)
|
||||
self.assertEqual(
|
||||
utc_dt.strftime('%Y-%m-%d %H:%M:%S %Z%z'),
|
||||
'1914-01-01 13:40:00 UTC+0000'
|
||||
)
|
||||
|
||||
def no_testCreateLocaltime(self):
|
||||
# It would be nice if this worked, but it doesn't.
|
||||
tz = pytz.timezone('Europe/Amsterdam')
|
||||
dt = datetime(2004, 10, 31, 2, 0, 0, tzinfo=tz)
|
||||
self.assertEqual(
|
||||
dt.strftime(fmt),
|
||||
'2004-10-31 02:00:00 CET+0100'
|
||||
)
|
||||
|
||||
|
||||
class CommonTimezonesTestCase(unittest.TestCase):
|
||||
def test_bratislava(self):
|
||||
# Bratislava is the default timezone for Slovakia, but our
|
||||
# heuristics where not adding it to common_timezones. Ideally,
|
||||
# common_timezones should be populated from zone.tab at runtime,
|
||||
# but I'm hesitant to pay the startup cost as loading the list
|
||||
# on demand whilst remaining backwards compatible seems
|
||||
# difficult.
|
||||
self.assertTrue('Europe/Bratislava' in pytz.common_timezones)
|
||||
self.assertTrue('Europe/Bratislava' in pytz.common_timezones_set)
|
||||
|
||||
def test_us_eastern(self):
|
||||
self.assertTrue('US/Eastern' in pytz.common_timezones)
|
||||
self.assertTrue('US/Eastern' in pytz.common_timezones_set)
|
||||
|
||||
def test_belfast(self):
|
||||
# Belfast uses London time.
|
||||
self.assertTrue('Europe/Belfast' in pytz.all_timezones_set)
|
||||
self.assertFalse('Europe/Belfast' in pytz.common_timezones)
|
||||
self.assertFalse('Europe/Belfast' in pytz.common_timezones_set)
|
||||
|
||||
|
||||
class BaseTzInfoTestCase:
|
||||
'''Ensure UTC, StaticTzInfo and DstTzInfo work consistently.
|
||||
|
||||
These tests are run for each type of tzinfo.
|
||||
'''
|
||||
tz = None # override
|
||||
tz_class = None # override
|
||||
|
||||
def test_expectedclass(self):
|
||||
self.assertTrue(isinstance(self.tz, self.tz_class))
|
||||
|
||||
def test_fromutc(self):
|
||||
# naive datetime.
|
||||
dt1 = datetime(2011, 10, 31)
|
||||
|
||||
# localized datetime, same timezone.
|
||||
dt2 = self.tz.localize(dt1)
|
||||
|
||||
# Both should give the same results. Note that the standard
|
||||
# Python tzinfo.fromutc() only supports the second.
|
||||
for dt in [dt1, dt2]:
|
||||
loc_dt = self.tz.fromutc(dt)
|
||||
loc_dt2 = pytz.utc.localize(dt1).astimezone(self.tz)
|
||||
self.assertEqual(loc_dt, loc_dt2)
|
||||
|
||||
# localized datetime, different timezone.
|
||||
new_tz = pytz.timezone('Europe/Paris')
|
||||
self.assertTrue(self.tz is not new_tz)
|
||||
dt3 = new_tz.localize(dt1)
|
||||
self.assertRaises(ValueError, self.tz.fromutc, dt3)
|
||||
|
||||
def test_normalize(self):
|
||||
other_tz = pytz.timezone('Europe/Paris')
|
||||
self.assertTrue(self.tz is not other_tz)
|
||||
|
||||
dt = datetime(2012, 3, 26, 12, 0)
|
||||
other_dt = other_tz.localize(dt)
|
||||
|
||||
local_dt = self.tz.normalize(other_dt)
|
||||
|
||||
self.assertTrue(local_dt.tzinfo is not other_dt.tzinfo)
|
||||
self.assertNotEqual(
|
||||
local_dt.replace(tzinfo=None), other_dt.replace(tzinfo=None))
|
||||
|
||||
def test_astimezone(self):
|
||||
other_tz = pytz.timezone('Europe/Paris')
|
||||
self.assertTrue(self.tz is not other_tz)
|
||||
|
||||
dt = datetime(2012, 3, 26, 12, 0)
|
||||
other_dt = other_tz.localize(dt)
|
||||
|
||||
local_dt = other_dt.astimezone(self.tz)
|
||||
|
||||
self.assertTrue(local_dt.tzinfo is not other_dt.tzinfo)
|
||||
self.assertNotEqual(
|
||||
local_dt.replace(tzinfo=None), other_dt.replace(tzinfo=None))
|
||||
|
||||
|
||||
class OptimizedUTCTestCase(unittest.TestCase, BaseTzInfoTestCase):
|
||||
tz = pytz.utc
|
||||
tz_class = tz.__class__
|
||||
|
||||
|
||||
class LegacyUTCTestCase(unittest.TestCase, BaseTzInfoTestCase):
|
||||
# Deprecated timezone, but useful for comparison tests.
|
||||
tz = pytz.timezone('Etc/UTC')
|
||||
tz_class = StaticTzInfo
|
||||
|
||||
|
||||
class StaticTzInfoTestCase(unittest.TestCase, BaseTzInfoTestCase):
|
||||
tz = pytz.timezone('GMT')
|
||||
tz_class = StaticTzInfo
|
||||
|
||||
|
||||
class DstTzInfoTestCase(unittest.TestCase, BaseTzInfoTestCase):
|
||||
tz = pytz.timezone('Australia/Melbourne')
|
||||
tz_class = DstTzInfo
|
||||
|
||||
|
||||
def test_suite():
|
||||
suite = unittest.TestSuite()
|
||||
suite.addTest(doctest.DocTestSuite('pytz'))
|
||||
suite.addTest(doctest.DocTestSuite('pytz.tzinfo'))
|
||||
import test_tzinfo
|
||||
suite.addTest(unittest.defaultTestLoader.loadTestsFromModule(test_tzinfo))
|
||||
return suite
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
warnings.simplefilter("error") # Warnings should be fatal in tests.
|
||||
unittest.main(defaultTest='test_suite')
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python
|
||||
'''
|
||||
$Id: tzfile.py,v 1.8 2004/06/03 00:15:24 zenzen Exp $
|
||||
'''
|
||||
|
||||
try:
|
||||
from cStringIO import StringIO
|
||||
except ImportError:
|
||||
from io import StringIO
|
||||
from datetime import datetime, timedelta
|
||||
from struct import unpack, calcsize
|
||||
|
||||
from pytz.tzinfo import StaticTzInfo, DstTzInfo, memorized_ttinfo
|
||||
from pytz.tzinfo import memorized_datetime, memorized_timedelta
|
||||
|
||||
def _byte_string(s):
|
||||
"""Cast a string or byte string to an ASCII byte string."""
|
||||
return s.encode('US-ASCII')
|
||||
|
||||
_NULL = _byte_string('\0')
|
||||
|
||||
def _std_string(s):
|
||||
"""Cast a string or byte string to an ASCII string."""
|
||||
return str(s.decode('US-ASCII'))
|
||||
|
||||
def build_tzinfo(zone, fp):
|
||||
head_fmt = '>4s c 15x 6l'
|
||||
head_size = calcsize(head_fmt)
|
||||
(magic, format, ttisgmtcnt, ttisstdcnt,leapcnt, timecnt,
|
||||
typecnt, charcnt) = unpack(head_fmt, fp.read(head_size))
|
||||
|
||||
# Make sure it is a tzfile(5) file
|
||||
assert magic == _byte_string('TZif'), 'Got magic %s' % repr(magic)
|
||||
|
||||
# Read out the transition times, localtime indices and ttinfo structures.
|
||||
data_fmt = '>%(timecnt)dl %(timecnt)dB %(ttinfo)s %(charcnt)ds' % dict(
|
||||
timecnt=timecnt, ttinfo='lBB'*typecnt, charcnt=charcnt)
|
||||
data_size = calcsize(data_fmt)
|
||||
data = unpack(data_fmt, fp.read(data_size))
|
||||
|
||||
# make sure we unpacked the right number of values
|
||||
assert len(data) == 2 * timecnt + 3 * typecnt + 1
|
||||
transitions = [memorized_datetime(trans)
|
||||
for trans in data[:timecnt]]
|
||||
lindexes = list(data[timecnt:2 * timecnt])
|
||||
ttinfo_raw = data[2 * timecnt:-1]
|
||||
tznames_raw = data[-1]
|
||||
del data
|
||||
|
||||
# Process ttinfo into separate structs
|
||||
ttinfo = []
|
||||
tznames = {}
|
||||
i = 0
|
||||
while i < len(ttinfo_raw):
|
||||
# have we looked up this timezone name yet?
|
||||
tzname_offset = ttinfo_raw[i+2]
|
||||
if tzname_offset not in tznames:
|
||||
nul = tznames_raw.find(_NULL, tzname_offset)
|
||||
if nul < 0:
|
||||
nul = len(tznames_raw)
|
||||
tznames[tzname_offset] = _std_string(
|
||||
tznames_raw[tzname_offset:nul])
|
||||
ttinfo.append((ttinfo_raw[i],
|
||||
bool(ttinfo_raw[i+1]),
|
||||
tznames[tzname_offset]))
|
||||
i += 3
|
||||
|
||||
# Now build the timezone object
|
||||
if len(transitions) == 0:
|
||||
ttinfo[0][0], ttinfo[0][2]
|
||||
cls = type(zone, (StaticTzInfo,), dict(
|
||||
zone=zone,
|
||||
_utcoffset=memorized_timedelta(ttinfo[0][0]),
|
||||
_tzname=ttinfo[0][2]))
|
||||
else:
|
||||
# Early dates use the first standard time ttinfo
|
||||
i = 0
|
||||
while ttinfo[i][1]:
|
||||
i += 1
|
||||
if ttinfo[i] == ttinfo[lindexes[0]]:
|
||||
transitions[0] = datetime.min
|
||||
else:
|
||||
transitions.insert(0, datetime.min)
|
||||
lindexes.insert(0, i)
|
||||
|
||||
# calculate transition info
|
||||
transition_info = []
|
||||
for i in range(len(transitions)):
|
||||
inf = ttinfo[lindexes[i]]
|
||||
utcoffset = inf[0]
|
||||
if not inf[1]:
|
||||
dst = 0
|
||||
else:
|
||||
for j in range(i-1, -1, -1):
|
||||
prev_inf = ttinfo[lindexes[j]]
|
||||
if not prev_inf[1]:
|
||||
break
|
||||
dst = inf[0] - prev_inf[0] # dst offset
|
||||
|
||||
# Bad dst? Look further. DST > 24 hours happens when
|
||||
# a timzone has moved across the international dateline.
|
||||
if dst <= 0 or dst > 3600*3:
|
||||
for j in range(i+1, len(transitions)):
|
||||
stdinf = ttinfo[lindexes[j]]
|
||||
if not stdinf[1]:
|
||||
dst = inf[0] - stdinf[0]
|
||||
if dst > 0:
|
||||
break # Found a useful std time.
|
||||
|
||||
tzname = inf[2]
|
||||
|
||||
# Round utcoffset and dst to the nearest minute or the
|
||||
# datetime library will complain. Conversions to these timezones
|
||||
# might be up to plus or minus 30 seconds out, but it is
|
||||
# the best we can do.
|
||||
utcoffset = int((utcoffset + 30) // 60) * 60
|
||||
dst = int((dst + 30) // 60) * 60
|
||||
transition_info.append(memorized_ttinfo(utcoffset, dst, tzname))
|
||||
|
||||
cls = type(zone, (DstTzInfo,), dict(
|
||||
zone=zone,
|
||||
_utc_transition_times=transitions,
|
||||
_transition_info=transition_info))
|
||||
|
||||
return cls()
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os.path
|
||||
from pprint import pprint
|
||||
base = os.path.join(os.path.dirname(__file__), 'zoneinfo')
|
||||
tz = build_tzinfo('Australia/Melbourne',
|
||||
open(os.path.join(base,'Australia','Melbourne'), 'rb'))
|
||||
tz = build_tzinfo('US/Eastern',
|
||||
open(os.path.join(base,'US','Eastern'), 'rb'))
|
||||
pprint(tz._utc_transition_times)
|
||||
#print tz.asPython(4)
|
||||
#print tz.transitions_mapping
|
||||
@@ -0,0 +1,563 @@
|
||||
'''Base classes and helpers for building zone specific tzinfo classes'''
|
||||
|
||||
from datetime import datetime, timedelta, tzinfo
|
||||
from bisect import bisect_right
|
||||
try:
|
||||
set
|
||||
except NameError:
|
||||
from sets import Set as set
|
||||
|
||||
import pytz
|
||||
from pytz.exceptions import AmbiguousTimeError, NonExistentTimeError
|
||||
|
||||
__all__ = []
|
||||
|
||||
_timedelta_cache = {}
|
||||
def memorized_timedelta(seconds):
|
||||
'''Create only one instance of each distinct timedelta'''
|
||||
try:
|
||||
return _timedelta_cache[seconds]
|
||||
except KeyError:
|
||||
delta = timedelta(seconds=seconds)
|
||||
_timedelta_cache[seconds] = delta
|
||||
return delta
|
||||
|
||||
_epoch = datetime.utcfromtimestamp(0)
|
||||
_datetime_cache = {0: _epoch}
|
||||
def memorized_datetime(seconds):
|
||||
'''Create only one instance of each distinct datetime'''
|
||||
try:
|
||||
return _datetime_cache[seconds]
|
||||
except KeyError:
|
||||
# NB. We can't just do datetime.utcfromtimestamp(seconds) as this
|
||||
# fails with negative values under Windows (Bug #90096)
|
||||
dt = _epoch + timedelta(seconds=seconds)
|
||||
_datetime_cache[seconds] = dt
|
||||
return dt
|
||||
|
||||
_ttinfo_cache = {}
|
||||
def memorized_ttinfo(*args):
|
||||
'''Create only one instance of each distinct tuple'''
|
||||
try:
|
||||
return _ttinfo_cache[args]
|
||||
except KeyError:
|
||||
ttinfo = (
|
||||
memorized_timedelta(args[0]),
|
||||
memorized_timedelta(args[1]),
|
||||
args[2]
|
||||
)
|
||||
_ttinfo_cache[args] = ttinfo
|
||||
return ttinfo
|
||||
|
||||
_notime = memorized_timedelta(0)
|
||||
|
||||
def _to_seconds(td):
|
||||
'''Convert a timedelta to seconds'''
|
||||
return td.seconds + td.days * 24 * 60 * 60
|
||||
|
||||
|
||||
class BaseTzInfo(tzinfo):
|
||||
# Overridden in subclass
|
||||
_utcoffset = None
|
||||
_tzname = None
|
||||
zone = None
|
||||
|
||||
def __str__(self):
|
||||
return self.zone
|
||||
|
||||
|
||||
class StaticTzInfo(BaseTzInfo):
|
||||
'''A timezone that has a constant offset from UTC
|
||||
|
||||
These timezones are rare, as most locations have changed their
|
||||
offset at some point in their history
|
||||
'''
|
||||
def fromutc(self, dt):
|
||||
'''See datetime.tzinfo.fromutc'''
|
||||
if dt.tzinfo is not None and dt.tzinfo is not self:
|
||||
raise ValueError('fromutc: dt.tzinfo is not self')
|
||||
return (dt + self._utcoffset).replace(tzinfo=self)
|
||||
|
||||
def utcoffset(self, dt, is_dst=None):
|
||||
'''See datetime.tzinfo.utcoffset
|
||||
|
||||
is_dst is ignored for StaticTzInfo, and exists only to
|
||||
retain compatibility with DstTzInfo.
|
||||
'''
|
||||
return self._utcoffset
|
||||
|
||||
def dst(self, dt, is_dst=None):
|
||||
'''See datetime.tzinfo.dst
|
||||
|
||||
is_dst is ignored for StaticTzInfo, and exists only to
|
||||
retain compatibility with DstTzInfo.
|
||||
'''
|
||||
return _notime
|
||||
|
||||
def tzname(self, dt, is_dst=None):
|
||||
'''See datetime.tzinfo.tzname
|
||||
|
||||
is_dst is ignored for StaticTzInfo, and exists only to
|
||||
retain compatibility with DstTzInfo.
|
||||
'''
|
||||
return self._tzname
|
||||
|
||||
def localize(self, dt, is_dst=False):
|
||||
'''Convert naive time to local time'''
|
||||
if dt.tzinfo is not None:
|
||||
raise ValueError('Not naive datetime (tzinfo is already set)')
|
||||
return dt.replace(tzinfo=self)
|
||||
|
||||
def normalize(self, dt, is_dst=False):
|
||||
'''Correct the timezone information on the given datetime.
|
||||
|
||||
This is normally a no-op, as StaticTzInfo timezones never have
|
||||
ambiguous cases to correct:
|
||||
|
||||
>>> from pytz import timezone
|
||||
>>> gmt = timezone('GMT')
|
||||
>>> isinstance(gmt, StaticTzInfo)
|
||||
True
|
||||
>>> dt = datetime(2011, 5, 8, 1, 2, 3, tzinfo=gmt)
|
||||
>>> gmt.normalize(dt) is dt
|
||||
True
|
||||
|
||||
The supported method of converting between timezones is to use
|
||||
datetime.astimezone(). Currently normalize() also works:
|
||||
|
||||
>>> la = timezone('America/Los_Angeles')
|
||||
>>> dt = la.localize(datetime(2011, 5, 7, 1, 2, 3))
|
||||
>>> fmt = '%Y-%m-%d %H:%M:%S %Z (%z)'
|
||||
>>> gmt.normalize(dt).strftime(fmt)
|
||||
'2011-05-07 08:02:03 GMT (+0000)'
|
||||
'''
|
||||
if dt.tzinfo is self:
|
||||
return dt
|
||||
if dt.tzinfo is None:
|
||||
raise ValueError('Naive time - no tzinfo set')
|
||||
return dt.astimezone(self)
|
||||
|
||||
def __repr__(self):
|
||||
return '<StaticTzInfo %r>' % (self.zone,)
|
||||
|
||||
def __reduce__(self):
|
||||
# Special pickle to zone remains a singleton and to cope with
|
||||
# database changes.
|
||||
return pytz._p, (self.zone,)
|
||||
|
||||
|
||||
class DstTzInfo(BaseTzInfo):
|
||||
'''A timezone that has a variable offset from UTC
|
||||
|
||||
The offset might change if daylight saving time comes into effect,
|
||||
or at a point in history when the region decides to change their
|
||||
timezone definition.
|
||||
'''
|
||||
# Overridden in subclass
|
||||
_utc_transition_times = None # Sorted list of DST transition times in UTC
|
||||
_transition_info = None # [(utcoffset, dstoffset, tzname)] corresponding
|
||||
# to _utc_transition_times entries
|
||||
zone = None
|
||||
|
||||
# Set in __init__
|
||||
_tzinfos = None
|
||||
_dst = None # DST offset
|
||||
|
||||
def __init__(self, _inf=None, _tzinfos=None):
|
||||
if _inf:
|
||||
self._tzinfos = _tzinfos
|
||||
self._utcoffset, self._dst, self._tzname = _inf
|
||||
else:
|
||||
_tzinfos = {}
|
||||
self._tzinfos = _tzinfos
|
||||
self._utcoffset, self._dst, self._tzname = self._transition_info[0]
|
||||
_tzinfos[self._transition_info[0]] = self
|
||||
for inf in self._transition_info[1:]:
|
||||
if inf not in _tzinfos:
|
||||
_tzinfos[inf] = self.__class__(inf, _tzinfos)
|
||||
|
||||
def fromutc(self, dt):
|
||||
'''See datetime.tzinfo.fromutc'''
|
||||
if (dt.tzinfo is not None
|
||||
and getattr(dt.tzinfo, '_tzinfos', None) is not self._tzinfos):
|
||||
raise ValueError('fromutc: dt.tzinfo is not self')
|
||||
dt = dt.replace(tzinfo=None)
|
||||
idx = max(0, bisect_right(self._utc_transition_times, dt) - 1)
|
||||
inf = self._transition_info[idx]
|
||||
return (dt + inf[0]).replace(tzinfo=self._tzinfos[inf])
|
||||
|
||||
def normalize(self, dt):
|
||||
'''Correct the timezone information on the given datetime
|
||||
|
||||
If date arithmetic crosses DST boundaries, the tzinfo
|
||||
is not magically adjusted. This method normalizes the
|
||||
tzinfo to the correct one.
|
||||
|
||||
To test, first we need to do some setup
|
||||
|
||||
>>> from pytz import timezone
|
||||
>>> utc = timezone('UTC')
|
||||
>>> eastern = timezone('US/Eastern')
|
||||
>>> fmt = '%Y-%m-%d %H:%M:%S %Z (%z)'
|
||||
|
||||
We next create a datetime right on an end-of-DST transition point,
|
||||
the instant when the wallclocks are wound back one hour.
|
||||
|
||||
>>> utc_dt = datetime(2002, 10, 27, 6, 0, 0, tzinfo=utc)
|
||||
>>> loc_dt = utc_dt.astimezone(eastern)
|
||||
>>> loc_dt.strftime(fmt)
|
||||
'2002-10-27 01:00:00 EST (-0500)'
|
||||
|
||||
Now, if we subtract a few minutes from it, note that the timezone
|
||||
information has not changed.
|
||||
|
||||
>>> before = loc_dt - timedelta(minutes=10)
|
||||
>>> before.strftime(fmt)
|
||||
'2002-10-27 00:50:00 EST (-0500)'
|
||||
|
||||
But we can fix that by calling the normalize method
|
||||
|
||||
>>> before = eastern.normalize(before)
|
||||
>>> before.strftime(fmt)
|
||||
'2002-10-27 01:50:00 EDT (-0400)'
|
||||
|
||||
The supported method of converting between timezones is to use
|
||||
datetime.astimezone(). Currently, normalize() also works:
|
||||
|
||||
>>> th = timezone('Asia/Bangkok')
|
||||
>>> am = timezone('Europe/Amsterdam')
|
||||
>>> dt = th.localize(datetime(2011, 5, 7, 1, 2, 3))
|
||||
>>> fmt = '%Y-%m-%d %H:%M:%S %Z (%z)'
|
||||
>>> am.normalize(dt).strftime(fmt)
|
||||
'2011-05-06 20:02:03 CEST (+0200)'
|
||||
'''
|
||||
if dt.tzinfo is None:
|
||||
raise ValueError('Naive time - no tzinfo set')
|
||||
|
||||
# Convert dt in localtime to UTC
|
||||
offset = dt.tzinfo._utcoffset
|
||||
dt = dt.replace(tzinfo=None)
|
||||
dt = dt - offset
|
||||
# convert it back, and return it
|
||||
return self.fromutc(dt)
|
||||
|
||||
def localize(self, dt, is_dst=False):
|
||||
'''Convert naive time to local time.
|
||||
|
||||
This method should be used to construct localtimes, rather
|
||||
than passing a tzinfo argument to a datetime constructor.
|
||||
|
||||
is_dst is used to determine the correct timezone in the ambigous
|
||||
period at the end of daylight saving time.
|
||||
|
||||
>>> from pytz import timezone
|
||||
>>> fmt = '%Y-%m-%d %H:%M:%S %Z (%z)'
|
||||
>>> amdam = timezone('Europe/Amsterdam')
|
||||
>>> dt = datetime(2004, 10, 31, 2, 0, 0)
|
||||
>>> loc_dt1 = amdam.localize(dt, is_dst=True)
|
||||
>>> loc_dt2 = amdam.localize(dt, is_dst=False)
|
||||
>>> loc_dt1.strftime(fmt)
|
||||
'2004-10-31 02:00:00 CEST (+0200)'
|
||||
>>> loc_dt2.strftime(fmt)
|
||||
'2004-10-31 02:00:00 CET (+0100)'
|
||||
>>> str(loc_dt2 - loc_dt1)
|
||||
'1:00:00'
|
||||
|
||||
Use is_dst=None to raise an AmbiguousTimeError for ambiguous
|
||||
times at the end of daylight saving time
|
||||
|
||||
>>> try:
|
||||
... loc_dt1 = amdam.localize(dt, is_dst=None)
|
||||
... except AmbiguousTimeError:
|
||||
... print('Ambiguous')
|
||||
Ambiguous
|
||||
|
||||
is_dst defaults to False
|
||||
|
||||
>>> amdam.localize(dt) == amdam.localize(dt, False)
|
||||
True
|
||||
|
||||
is_dst is also used to determine the correct timezone in the
|
||||
wallclock times jumped over at the start of daylight saving time.
|
||||
|
||||
>>> pacific = timezone('US/Pacific')
|
||||
>>> dt = datetime(2008, 3, 9, 2, 0, 0)
|
||||
>>> ploc_dt1 = pacific.localize(dt, is_dst=True)
|
||||
>>> ploc_dt2 = pacific.localize(dt, is_dst=False)
|
||||
>>> ploc_dt1.strftime(fmt)
|
||||
'2008-03-09 02:00:00 PDT (-0700)'
|
||||
>>> ploc_dt2.strftime(fmt)
|
||||
'2008-03-09 02:00:00 PST (-0800)'
|
||||
>>> str(ploc_dt2 - ploc_dt1)
|
||||
'1:00:00'
|
||||
|
||||
Use is_dst=None to raise a NonExistentTimeError for these skipped
|
||||
times.
|
||||
|
||||
>>> try:
|
||||
... loc_dt1 = pacific.localize(dt, is_dst=None)
|
||||
... except NonExistentTimeError:
|
||||
... print('Non-existent')
|
||||
Non-existent
|
||||
'''
|
||||
if dt.tzinfo is not None:
|
||||
raise ValueError('Not naive datetime (tzinfo is already set)')
|
||||
|
||||
# Find the two best possibilities.
|
||||
possible_loc_dt = set()
|
||||
for delta in [timedelta(days=-1), timedelta(days=1)]:
|
||||
loc_dt = dt + delta
|
||||
idx = max(0, bisect_right(
|
||||
self._utc_transition_times, loc_dt) - 1)
|
||||
inf = self._transition_info[idx]
|
||||
tzinfo = self._tzinfos[inf]
|
||||
loc_dt = tzinfo.normalize(dt.replace(tzinfo=tzinfo))
|
||||
if loc_dt.replace(tzinfo=None) == dt:
|
||||
possible_loc_dt.add(loc_dt)
|
||||
|
||||
if len(possible_loc_dt) == 1:
|
||||
return possible_loc_dt.pop()
|
||||
|
||||
# If there are no possibly correct timezones, we are attempting
|
||||
# to convert a time that never happened - the time period jumped
|
||||
# during the start-of-DST transition period.
|
||||
if len(possible_loc_dt) == 0:
|
||||
# If we refuse to guess, raise an exception.
|
||||
if is_dst is None:
|
||||
raise NonExistentTimeError(dt)
|
||||
|
||||
# If we are forcing the pre-DST side of the DST transition, we
|
||||
# obtain the correct timezone by winding the clock forward a few
|
||||
# hours.
|
||||
elif is_dst:
|
||||
return self.localize(
|
||||
dt + timedelta(hours=6), is_dst=True) - timedelta(hours=6)
|
||||
|
||||
# If we are forcing the post-DST side of the DST transition, we
|
||||
# obtain the correct timezone by winding the clock back.
|
||||
else:
|
||||
return self.localize(
|
||||
dt - timedelta(hours=6), is_dst=False) + timedelta(hours=6)
|
||||
|
||||
|
||||
# If we get this far, we have multiple possible timezones - this
|
||||
# is an ambiguous case occuring during the end-of-DST transition.
|
||||
|
||||
# If told to be strict, raise an exception since we have an
|
||||
# ambiguous case
|
||||
if is_dst is None:
|
||||
raise AmbiguousTimeError(dt)
|
||||
|
||||
# Filter out the possiblilities that don't match the requested
|
||||
# is_dst
|
||||
filtered_possible_loc_dt = [
|
||||
p for p in possible_loc_dt
|
||||
if bool(p.tzinfo._dst) == is_dst
|
||||
]
|
||||
|
||||
# Hopefully we only have one possibility left. Return it.
|
||||
if len(filtered_possible_loc_dt) == 1:
|
||||
return filtered_possible_loc_dt[0]
|
||||
|
||||
if len(filtered_possible_loc_dt) == 0:
|
||||
filtered_possible_loc_dt = list(possible_loc_dt)
|
||||
|
||||
# If we get this far, we have in a wierd timezone transition
|
||||
# where the clocks have been wound back but is_dst is the same
|
||||
# in both (eg. Europe/Warsaw 1915 when they switched to CET).
|
||||
# At this point, we just have to guess unless we allow more
|
||||
# hints to be passed in (such as the UTC offset or abbreviation),
|
||||
# but that is just getting silly.
|
||||
#
|
||||
# Choose the earliest (by UTC) applicable timezone.
|
||||
sorting_keys = {}
|
||||
for local_dt in filtered_possible_loc_dt:
|
||||
key = local_dt.replace(tzinfo=None) - local_dt.tzinfo._utcoffset
|
||||
sorting_keys[key] = local_dt
|
||||
first_key = sorted(sorting_keys)[0]
|
||||
return sorting_keys[first_key]
|
||||
|
||||
def utcoffset(self, dt, is_dst=None):
|
||||
'''See datetime.tzinfo.utcoffset
|
||||
|
||||
The is_dst parameter may be used to remove ambiguity during DST
|
||||
transitions.
|
||||
|
||||
>>> from pytz import timezone
|
||||
>>> tz = timezone('America/St_Johns')
|
||||
>>> ambiguous = datetime(2009, 10, 31, 23, 30)
|
||||
|
||||
>>> tz.utcoffset(ambiguous, is_dst=False)
|
||||
datetime.timedelta(-1, 73800)
|
||||
|
||||
>>> tz.utcoffset(ambiguous, is_dst=True)
|
||||
datetime.timedelta(-1, 77400)
|
||||
|
||||
>>> try:
|
||||
... tz.utcoffset(ambiguous)
|
||||
... except AmbiguousTimeError:
|
||||
... print('Ambiguous')
|
||||
Ambiguous
|
||||
|
||||
'''
|
||||
if dt is None:
|
||||
return None
|
||||
elif dt.tzinfo is not self:
|
||||
dt = self.localize(dt, is_dst)
|
||||
return dt.tzinfo._utcoffset
|
||||
else:
|
||||
return self._utcoffset
|
||||
|
||||
def dst(self, dt, is_dst=None):
|
||||
'''See datetime.tzinfo.dst
|
||||
|
||||
The is_dst parameter may be used to remove ambiguity during DST
|
||||
transitions.
|
||||
|
||||
>>> from pytz import timezone
|
||||
>>> tz = timezone('America/St_Johns')
|
||||
|
||||
>>> normal = datetime(2009, 9, 1)
|
||||
|
||||
>>> tz.dst(normal)
|
||||
datetime.timedelta(0, 3600)
|
||||
>>> tz.dst(normal, is_dst=False)
|
||||
datetime.timedelta(0, 3600)
|
||||
>>> tz.dst(normal, is_dst=True)
|
||||
datetime.timedelta(0, 3600)
|
||||
|
||||
>>> ambiguous = datetime(2009, 10, 31, 23, 30)
|
||||
|
||||
>>> tz.dst(ambiguous, is_dst=False)
|
||||
datetime.timedelta(0)
|
||||
>>> tz.dst(ambiguous, is_dst=True)
|
||||
datetime.timedelta(0, 3600)
|
||||
>>> try:
|
||||
... tz.dst(ambiguous)
|
||||
... except AmbiguousTimeError:
|
||||
... print('Ambiguous')
|
||||
Ambiguous
|
||||
|
||||
'''
|
||||
if dt is None:
|
||||
return None
|
||||
elif dt.tzinfo is not self:
|
||||
dt = self.localize(dt, is_dst)
|
||||
return dt.tzinfo._dst
|
||||
else:
|
||||
return self._dst
|
||||
|
||||
def tzname(self, dt, is_dst=None):
|
||||
'''See datetime.tzinfo.tzname
|
||||
|
||||
The is_dst parameter may be used to remove ambiguity during DST
|
||||
transitions.
|
||||
|
||||
>>> from pytz import timezone
|
||||
>>> tz = timezone('America/St_Johns')
|
||||
|
||||
>>> normal = datetime(2009, 9, 1)
|
||||
|
||||
>>> tz.tzname(normal)
|
||||
'NDT'
|
||||
>>> tz.tzname(normal, is_dst=False)
|
||||
'NDT'
|
||||
>>> tz.tzname(normal, is_dst=True)
|
||||
'NDT'
|
||||
|
||||
>>> ambiguous = datetime(2009, 10, 31, 23, 30)
|
||||
|
||||
>>> tz.tzname(ambiguous, is_dst=False)
|
||||
'NST'
|
||||
>>> tz.tzname(ambiguous, is_dst=True)
|
||||
'NDT'
|
||||
>>> try:
|
||||
... tz.tzname(ambiguous)
|
||||
... except AmbiguousTimeError:
|
||||
... print('Ambiguous')
|
||||
Ambiguous
|
||||
'''
|
||||
if dt is None:
|
||||
return self.zone
|
||||
elif dt.tzinfo is not self:
|
||||
dt = self.localize(dt, is_dst)
|
||||
return dt.tzinfo._tzname
|
||||
else:
|
||||
return self._tzname
|
||||
|
||||
def __repr__(self):
|
||||
if self._dst:
|
||||
dst = 'DST'
|
||||
else:
|
||||
dst = 'STD'
|
||||
if self._utcoffset > _notime:
|
||||
return '<DstTzInfo %r %s+%s %s>' % (
|
||||
self.zone, self._tzname, self._utcoffset, dst
|
||||
)
|
||||
else:
|
||||
return '<DstTzInfo %r %s%s %s>' % (
|
||||
self.zone, self._tzname, self._utcoffset, dst
|
||||
)
|
||||
|
||||
def __reduce__(self):
|
||||
# Special pickle to zone remains a singleton and to cope with
|
||||
# database changes.
|
||||
return pytz._p, (
|
||||
self.zone,
|
||||
_to_seconds(self._utcoffset),
|
||||
_to_seconds(self._dst),
|
||||
self._tzname
|
||||
)
|
||||
|
||||
|
||||
|
||||
def unpickler(zone, utcoffset=None, dstoffset=None, tzname=None):
|
||||
"""Factory function for unpickling pytz tzinfo instances.
|
||||
|
||||
This is shared for both StaticTzInfo and DstTzInfo instances, because
|
||||
database changes could cause a zones implementation to switch between
|
||||
these two base classes and we can't break pickles on a pytz version
|
||||
upgrade.
|
||||
"""
|
||||
# Raises a KeyError if zone no longer exists, which should never happen
|
||||
# and would be a bug.
|
||||
tz = pytz.timezone(zone)
|
||||
|
||||
# A StaticTzInfo - just return it
|
||||
if utcoffset is None:
|
||||
return tz
|
||||
|
||||
# This pickle was created from a DstTzInfo. We need to
|
||||
# determine which of the list of tzinfo instances for this zone
|
||||
# to use in order to restore the state of any datetime instances using
|
||||
# it correctly.
|
||||
utcoffset = memorized_timedelta(utcoffset)
|
||||
dstoffset = memorized_timedelta(dstoffset)
|
||||
try:
|
||||
return tz._tzinfos[(utcoffset, dstoffset, tzname)]
|
||||
except KeyError:
|
||||
# The particular state requested in this timezone no longer exists.
|
||||
# This indicates a corrupt pickle, or the timezone database has been
|
||||
# corrected violently enough to make this particular
|
||||
# (utcoffset,dstoffset) no longer exist in the zone, or the
|
||||
# abbreviation has been changed.
|
||||
pass
|
||||
|
||||
# See if we can find an entry differing only by tzname. Abbreviations
|
||||
# get changed from the initial guess by the database maintainers to
|
||||
# match reality when this information is discovered.
|
||||
for localized_tz in tz._tzinfos.values():
|
||||
if (localized_tz._utcoffset == utcoffset
|
||||
and localized_tz._dst == dstoffset):
|
||||
return localized_tz
|
||||
|
||||
# This (utcoffset, dstoffset) information has been removed from the
|
||||
# zone. Add it back. This might occur when the database maintainers have
|
||||
# corrected incorrect information. datetime instances using this
|
||||
# incorrect information will continue to do so, exactly as they were
|
||||
# before being pickled. This is purely an overly paranoid safety net - I
|
||||
# doubt this will ever been needed in real life.
|
||||
inf = (utcoffset, dstoffset, tzname)
|
||||
tz._tzinfos[inf] = tz.__class__(inf, tz._tzinfos)
|
||||
return tz._tzinfos[inf]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user